Skip to content

Commit ee493cc

Browse files
committed
✨(models) add xAPI Profile model
We want to support xapi profile validation in Ralph. Therefore we implement the xAPI Profile model which should follow the xAPI profiles structures specification.
1 parent 2331f40 commit ee493cc

9 files changed

Lines changed: 700 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Implement xAPI JSON-LD profile validation
14+
(CLI command: `ralph validate -f xapi.profile`)
15+
1116
### Changed
1217

1318
- Helm chart: improve chart modularity

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ include_package_data = True
2929
install_requires =
3030
; By default, we only consider core dependencies required to use Ralph as a
3131
; library (mostly models).
32+
jsonpath-ng>=1.5.3, <2.0
33+
jsonschema>=4.0.0, <5.0 # Note: v4.18.0 dropped support for python 3.7.
3234
langcodes>=3.2.0
3335
pydantic[dotenv,email]>=1.10.0, <2.0
3436
rfc3987>=1.3.0

src/ralph/cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,9 @@ def extract(parser):
446446
"-f",
447447
"--format",
448448
"format_",
449-
type=click.Choice(["edx", "xapi"]),
449+
type=click.Choice(["edx", "xapi", "xapi.profile"]),
450450
required=True,
451-
help="Input events format to validate",
451+
help="Input data format to validate",
452452
)
453453
@click.option(
454454
"-I",
@@ -462,7 +462,7 @@ def extract(parser):
462462
"--fail-on-unknown",
463463
default=False,
464464
is_flag=True,
465-
help="Stop validating at first unknown event",
465+
help="Stop validating at first unknown record",
466466
)
467467
def validate(format_, ignore_errors, fail_on_unknown):
468468
"""Validate input events of given format."""

src/ralph/models/validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def _validate_event(self, event_str: str):
7474
event_str (str): The cleaned JSON-formatted input event_str.
7575
"""
7676
event = json.loads(event_str)
77-
return self.get_first_valid_model(event).json()
77+
return self.get_first_valid_model(event).json(by_alias=True)
7878

7979
@staticmethod
8080
def _log_error(message, event_str, error=None):

src/ralph/models/xapi/profile.py

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.

tests/fixtures/hypothesis_strategies.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55

66
from hypothesis import given
77
from hypothesis import strategies as st
8-
from pydantic import BaseModel
8+
from pydantic import AnyUrl, BaseModel
99

1010
from ralph.models.edx.navigational.fields.events import NavigationalEventField
1111
from ralph.models.edx.navigational.statements import UISeqNext, UISeqPrev
1212
from ralph.models.xapi.base.contexts import BaseXapiContext
1313
from ralph.models.xapi.base.results import BaseXapiResultScore
14+
from ralph.models.xapi.profile import ProfilePattern, ProfileTemplateRule
1415

1516
OVERWRITTEN_STRATEGIES = {}
1617

@@ -107,4 +108,16 @@ def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs):
107108
"min": False,
108109
"max": False,
109110
},
111+
ProfilePattern: {
112+
"primary": False,
113+
"alternates": False,
114+
"optional": st.from_type(AnyUrl),
115+
"oneOrMore": False,
116+
"sequence": False,
117+
"zeroOrMore": False,
118+
},
119+
ProfileTemplateRule: {
120+
"location": st.just("$.timestamp"),
121+
"selector": False,
122+
}
110123
}

tests/models/xapi/test_profile.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Tests for the xAPI JSON-LD Profile."""
2+
import json
3+
4+
import pytest
5+
from pydantic import ValidationError
6+
7+
from ralph.models.selector import ModelSelector
8+
from ralph.models.xapi.profile import Profile, ProfilePattern, ProfileTemplateRule
9+
10+
from tests.fixtures.hypothesis_strategies import custom_given
11+
12+
13+
@custom_given(Profile)
14+
def test_models_xapi_profile_with_json_ld_keywords(profile):
15+
"""Test a `Profile` MAY include JSON-LD keywords."""
16+
profile = json.loads(profile.json(by_alias=True))
17+
profile["@base"] = None
18+
try:
19+
Profile(**profile)
20+
except ValidationError as err:
21+
pytest.fail(
22+
f"A profile including JSON-LD keywords should not raise exceptions: {err}"
23+
)
24+
25+
26+
@pytest.mark.parametrize(
27+
"missing",
28+
[
29+
("prefLabel",), ("definition", ), ("prefLabel", "definition")
30+
]
31+
)
32+
@custom_given(ProfilePattern)
33+
def test_models_xapi_profile_pattern_with_invalid_primary_value(missing, pattern):
34+
"""Test a `ProfilePattern` MUST include `prefLabel` and `definition` fields."""
35+
pattern = json.loads(pattern.json(by_alias=True))
36+
pattern["primary"] = True
37+
for field in missing:
38+
del pattern[field]
39+
40+
msg = "A `primary` pattern MUST include `prefLabel` and `definition` fields"
41+
with pytest.raises(ValidationError, match=msg):
42+
ProfilePattern(**pattern)
43+
44+
45+
@pytest.mark.parametrize(
46+
"rules",
47+
[
48+
(),
49+
("alternates", "optional"),
50+
("oneOrMore", "sequence"),
51+
("zeroOrMore", "alternates")
52+
]
53+
)
54+
@custom_given(ProfilePattern)
55+
def test_models_xapi_profile_pattern_with_invalid_number_of_match_rules(
56+
rules, pattern
57+
):
58+
"""Test a `ProfilePattern` MUST contain exactly one of `alternates`, `optional`,
59+
`oneOrMore`, `sequence`, and `zeroOrMore`.
60+
"""
61+
rule_values = {
62+
"alternates": ["https://example.com", "https://example.fr"],
63+
"optional": "https://example.com",
64+
"oneOrMore": "https://example.com",
65+
"sequence": ["https://example.com", "https://example.fr"],
66+
"zeroOrMore": "https://example.com"
67+
}
68+
pattern = json.loads(pattern.json(by_alias=True))
69+
del pattern["optional"]
70+
for rule in rules:
71+
pattern[rule] = rule_values[rule]
72+
73+
msg = (
74+
"A pattern MUST contain exactly one of `alternates`, `optional`, "
75+
"`oneOrMore`, `sequence`, and `zeroOrMore` fields"
76+
)
77+
with pytest.raises(ValidationError, match=msg):
78+
ProfilePattern(**pattern)
79+
80+
81+
@custom_given(Profile)
82+
def test_models_xapi_profile_selector_with_valid_model(profile):
83+
"""Test given a valid profile, the `get_first_model` method of the model
84+
selector should return the corresponding model.
85+
"""
86+
profile = json.loads(profile.json())
87+
model_selector = ModelSelector(module="ralph.models.xapi.profile")
88+
assert model_selector.get_first_model(profile) is Profile
89+
90+
91+
@pytest.mark.parametrize("field", ["location", "selector"])
92+
@custom_given(ProfileTemplateRule)
93+
def test_models_xapi_profile_template_rules_with_invalid_json_path(field, rule):
94+
"""Test given a profile template rule with a `location` or `selector` containing an
95+
invalid JSONPath, the `ProfileTemplateRule` model should raise an exception.
96+
"""
97+
rule = json.loads(rule.json())
98+
rule[field] = ""
99+
msg = "Invalid JSONPath: empty string is not a valid path"
100+
with pytest.raises(ValidationError, match=msg):
101+
ProfileTemplateRule(**rule)
102+
103+
rule[field] = "not a JSONPath"
104+
msg = (
105+
f"1 validation error for ProfileTemplateRule\n{field}\n Invalid JSONPath: "
106+
r"Parse error at 1:4 near token a \(ID\) \(type=value_error\)"
107+
)
108+
with pytest.raises(ValidationError, match=msg):
109+
ProfileTemplateRule(**rule)
110+
111+
112+
@pytest.mark.parametrize("field", ["location", "selector"])
113+
@custom_given(ProfileTemplateRule)
114+
def test_models_xapi_profile_template_rules_with_valid_json_path(field, rule):
115+
"""Test given a profile template rule with a `location` or `selector` containing an
116+
valid JSONPath, the `ProfileTemplateRule` model should not raise exceptions.
117+
"""
118+
rule = json.loads(rule.json())
119+
rule[field] = "$.context.extensions['http://example.com/extension']"
120+
try:
121+
ProfileTemplateRule(**rule)
122+
except ValidationError as err:
123+
pytest.fail(
124+
"A `ProfileTemplateRule` with a valid JSONPath should not raise exceptions:"
125+
f" {err}"
126+
)
127+
128+
129+
@custom_given(Profile)
130+
def test_models_xapi_profile_with_valid_json_schema(profile):
131+
"""Test given a profile with an extension concept containing a valid JSONSchema,
132+
should not raise exceptions.
133+
"""
134+
profile = json.loads(profile.json(by_alias=True))
135+
profile["concepts"] = [
136+
{
137+
"id": "http://example.com",
138+
"type": "ContextExtension",
139+
"inScheme": "http://example.profile.com",
140+
"prefLabel": {
141+
"en-us": "Example context extension",
142+
},
143+
"definition": {
144+
"en-us": "To use when an example happens",
145+
},
146+
"inlineSchema": json.dumps(
147+
{
148+
"$id": "https://example.com/example.schema.json",
149+
"$schema": "https://json-schema.org/draft/2020-12/schema",
150+
"title": "Example",
151+
"type": "object",
152+
"properties": {
153+
"example": {"type": "string", "description": "The example."},
154+
},
155+
}
156+
),
157+
}
158+
]
159+
try:
160+
Profile(**profile)
161+
except ValidationError as err:
162+
pytest.fail(
163+
f"A profile including a valid JSONSchema should not raise exceptions: {err}"
164+
)
165+
166+
167+
@custom_given(Profile)
168+
def test_models_xapi_profile_with_invalid_json_schema(profile):
169+
"""Test given a profile with an extension concept containing an invalid JSONSchema,
170+
should raise an exception.
171+
"""
172+
profile = json.loads(profile.json(by_alias=True))
173+
profile["concepts"] = [
174+
{
175+
"id": "http://example.com",
176+
"type": "ContextExtension",
177+
"inScheme": "http://example.profile.com",
178+
"prefLabel": {
179+
"en-us": "Example context extension",
180+
},
181+
"definition": {
182+
"en-us": "To use when an example happens",
183+
},
184+
"inlineSchema": json.dumps({"type": "example"}),
185+
}
186+
]
187+
msg = "Invalid JSONSchema: 'example' is not valid under any of the given schemas"
188+
with pytest.raises(ValidationError, match=msg):
189+
Profile(**profile)

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ralph.exceptions import ConfigurationException
2424
from ralph.models.edx.navigational.statements import UIPageClose
2525
from ralph.models.xapi.navigation.statements import PageTerminated
26+
from ralph.models.xapi.profile import Profile
2627

2728
from tests.fixtures.backends import (
2829
ES_TEST_HOSTS,
@@ -482,6 +483,16 @@ def test_cli_validate_command_with_edx_format(event):
482483
assert event_str in result.output
483484

484485

486+
@custom_given(Profile)
487+
def test_cli_validate_command_with_xapi_profile_format(event):
488+
"""Test the validate command using the xAPI profile format."""
489+
490+
event_str = event.json(by_alias=True)
491+
runner = CliRunner()
492+
result = runner.invoke(cli, "validate -f xapi.profile".split(), input=event_str)
493+
assert event_str in result.output
494+
495+
485496
@hypothesis_settings(deadline=None)
486497
@custom_given(UIPageClose)
487498
@pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"])

tests/test_cli_usage.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,20 @@ def test_cli_validate_command_usage():
6262
assert result.exit_code == 0
6363
assert (
6464
"Options:\n"
65-
" -f, --format [edx|xapi] Input events format to validate [required]\n"
66-
" -I, --ignore-errors Continue validating regardless of raised errors\n"
67-
" -F, --fail-on-unknown Stop validating at first unknown event\n"
65+
" -f, --format [edx|xapi|xapi.profile]\n"
66+
" Input data format to validate [required]\n"
67+
" -I, --ignore-errors Continue validating regardless of raised\n"
68+
" errors\n"
69+
" -F, --fail-on-unknown Stop validating at first unknown record\n"
6870
) in result.output
6971

7072
result = runner.invoke(cli, ["validate"])
7173
assert result.exit_code > 0
7274
assert (
73-
"Error: Missing option '-f' / '--format'. Choose from:\n\tedx,\n\txapi\n"
75+
"Error: Missing option '-f' / '--format'. Choose from:\n"
76+
"\tedx,\n"
77+
"\txapi,\n"
78+
"\txapi.profile\n"
7479
) in result.output
7580

7681

0 commit comments

Comments
 (0)