Skip to content

Commit dc207cf

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 dc207cf

8 files changed

Lines changed: 553 additions & 8 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ 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+
jsonschema>=4.0.0, <5.0 # Note: v4.18.0 dropped support for python 3.7.
3233
langcodes>=3.2.0
3334
pydantic[dotenv,email]>=1.10.0, <2.0
3435
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: 427 additions & 0 deletions
Large diffs are not rendered by default.

tests/models/xapi/test_profile.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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
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+
@custom_given(Profile)
27+
def test_models_xapi_profile_selector_with_valid_model(profile):
28+
"""Test given a valid profile, the `get_first_model` method of the model
29+
selector should return the corresponding model.
30+
"""
31+
profile = json.loads(profile.json())
32+
model_selector = ModelSelector(module="ralph.models.xapi.profile")
33+
assert model_selector.get_first_model(profile) is Profile
34+
35+
36+
@custom_given(Profile)
37+
def test_models_xapi_profile_with_valid_json_schema(profile):
38+
"""Test given a profile with an extension concept containing a valid JSONSchema,
39+
should not raise exceptions.
40+
"""
41+
profile = json.loads(profile.json(by_alias=True))
42+
profile["concepts"] = [
43+
{
44+
"id": "http://example.com",
45+
"type": "ContextExtension",
46+
"inScheme": "http://example.profile.com",
47+
"prefLabel": {
48+
"en-us": "Example context extension",
49+
},
50+
"definition": {
51+
"en-us": "To use when an example happens",
52+
},
53+
"inlineSchema": json.dumps(
54+
{
55+
"$id": "https://example.com/example.schema.json",
56+
"$schema": "https://json-schema.org/draft/2020-12/schema",
57+
"title": "Example",
58+
"type": "object",
59+
"properties": {
60+
"example": {"type": "string", "description": "The example."},
61+
},
62+
}
63+
),
64+
}
65+
]
66+
try:
67+
Profile(**profile)
68+
except ValidationError as err:
69+
pytest.fail(
70+
f"A profile including a valid JSONSchema should not raise exceptions: {err}"
71+
)
72+
73+
74+
@custom_given(Profile)
75+
def test_models_xapi_profile_with_invalid_json_schema(profile):
76+
"""Test given a profile with an extension concept containing an invalid JSONSchema,
77+
should raise an exception.
78+
"""
79+
profile = json.loads(profile.json(by_alias=True))
80+
profile["concepts"] = [
81+
{
82+
"id": "http://example.com",
83+
"type": "ContextExtension",
84+
"inScheme": "http://example.profile.com",
85+
"prefLabel": {
86+
"en-us": "Example context extension",
87+
},
88+
"definition": {
89+
"en-us": "To use when an example happens",
90+
},
91+
"inlineSchema": json.dumps({"type": "example"}),
92+
}
93+
]
94+
msg = "Invalid JSONSchema: 'example' is not valid under any of the given schemas"
95+
with pytest.raises(ValidationError, match=msg):
96+
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)