From ee493cced50e1c4e229f7ce0a082865f01d6e9a1 Mon Sep 17 00:00:00 2001 From: SergioSim Date: Thu, 17 Aug 2023 11:38:47 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(models)=20add=20xAPI=20Profile=20mode?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to support xapi profile validation in Ralph. Therefore we implement the xAPI Profile model which should follow the xAPI profiles structures specification. --- CHANGELOG.md | 5 + setup.cfg | 2 + src/ralph/cli.py | 6 +- src/ralph/models/validator.py | 2 +- src/ralph/models/xapi/profile.py | 466 ++++++++++++++++++++++++ tests/fixtures/hypothesis_strategies.py | 15 +- tests/models/xapi/test_profile.py | 189 ++++++++++ tests/test_cli.py | 11 + tests/test_cli_usage.py | 13 +- 9 files changed, 700 insertions(+), 9 deletions(-) create mode 100644 src/ralph/models/xapi/profile.py create mode 100644 tests/models/xapi/test_profile.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f2529bb21..3b2137a89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to ## [Unreleased] +### Added + +- Implement xAPI JSON-LD profile validation + (CLI command: `ralph validate -f xapi.profile`) + ### Changed - Helm chart: improve chart modularity diff --git a/setup.cfg b/setup.cfg index 3219df036..8b71e9528 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,8 @@ include_package_data = True install_requires = ; By default, we only consider core dependencies required to use Ralph as a ; library (mostly models). + jsonpath-ng>=1.5.3, <2.0 + jsonschema>=4.0.0, <5.0 # Note: v4.18.0 dropped support for python 3.7. langcodes>=3.2.0 pydantic[dotenv,email]>=1.10.0, <2.0 rfc3987>=1.3.0 diff --git a/src/ralph/cli.py b/src/ralph/cli.py index 3fafee1d6..c26d2ef2e 100644 --- a/src/ralph/cli.py +++ b/src/ralph/cli.py @@ -446,9 +446,9 @@ def extract(parser): "-f", "--format", "format_", - type=click.Choice(["edx", "xapi"]), + type=click.Choice(["edx", "xapi", "xapi.profile"]), required=True, - help="Input events format to validate", + help="Input data format to validate", ) @click.option( "-I", @@ -462,7 +462,7 @@ def extract(parser): "--fail-on-unknown", default=False, is_flag=True, - help="Stop validating at first unknown event", + help="Stop validating at first unknown record", ) def validate(format_, ignore_errors, fail_on_unknown): """Validate input events of given format.""" diff --git a/src/ralph/models/validator.py b/src/ralph/models/validator.py index f6f933e88..b6b39339c 100644 --- a/src/ralph/models/validator.py +++ b/src/ralph/models/validator.py @@ -74,7 +74,7 @@ def _validate_event(self, event_str: str): event_str (str): The cleaned JSON-formatted input event_str. """ event = json.loads(event_str) - return self.get_first_valid_model(event).json() + return self.get_first_valid_model(event).json(by_alias=True) @staticmethod def _log_error(message, event_str, error=None): diff --git a/src/ralph/models/xapi/profile.py b/src/ralph/models/xapi/profile.py new file mode 100644 index 000000000..b7bee5b60 --- /dev/null +++ b/src/ralph/models/xapi/profile.py @@ -0,0 +1,466 @@ +"""xAPI JSON-LD Profile.""" + +from datetime import datetime +from typing import Optional, Union + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal + +from jsonpath_ng.parser import JsonPathParser +from jsonpath_ng.exceptions import JsonPathParserError +from jsonschema.exceptions import SchemaError +from jsonschema.validators import validator_for +from pydantic import AnyUrl, BaseModel, Field, Json, conlist, constr, root_validator + +from ralph.models.selector import selector +from ralph.models.xapi.base.common import IRI, LanguageMap +from ralph.models.xapi.base.unnested_objects import ( + BaseXapiActivityDefinition, + BaseXapiActivityInteractionDefinition, +) + +# NB: These pydantic models should follow the profile structure specification. +# See: https://github.com/adlnet/xapi-profiles/blob/master/xapi-profiles-structure.md + + +class JsonPath(str): + """Pydantic custom data type validating JSONPaths.""" + + @classmethod + def __get_validators__(cls): # noqa: D105 + def validate(path: str): + """Check whether the provided `path` is a valid JSONPath.""" + if not path: + raise ValueError("Invalid JSONPath: empty string is not a valid path") + try: + JsonPathParser().parse(path) + except JsonPathParserError as error: + raise ValueError(f"Invalid JSONPath: {error}") from error + return cls(path) + + yield validate + + +class JSONSchema(dict): + """Pydantic custom data type validating JSONSchemas.""" + + @classmethod + def __get_validators__(cls): # noqa: D105 + def validate(schema: dict): + """Check whether the provided `schema` is a valid JSONSchema.""" + try: + validator_for(schema).check_schema(schema) + except SchemaError as error: + raise ValueError(f"Invalid JSONSchema: {error}") from error + return cls(schema) + + yield validate + + +class ProfilePattern(BaseModel): + """Profile `pattern` field. + + Attributes: + id (URI): A URI for the Pattern. + type (str): Equal to `Pattern`. + primary (bool): Only primary Patterns are checked for matching sequences of + Statements. + inScheme (IRI): The IRI of the specific Profile version. + prefLabel (dict): A Language Map of descriptive names for the Pattern. + definition (dict): A Language Map of descriptions of the purpose and usage of + the Pattern. + deprecated (bool): If true, this Pattern is deprecated. + alternates (list): A list of Pattern or Statement Template identifiers. + An alternates Pattern matches if any member of the list matches. + optional (URI): A single Pattern or Statement Template identifier. + An `optional` Pattern matches if the identified thing matches once, or is + not present at all. + oneOrMore (URI): A single Pattern or Statement Template identifier. + A `oneOrMore` Pattern matches if the identified thing matches once, or any + number of times more than once. + sequence (list): A list of Pattern or Statement Template identifiers. + A sequence Pattern matches if the identified things match in the order + specified. + zeroOrMore (URI): A single Pattern or Statement Template identifier. + A `zeroOrMore` Pattern matches if the identified thing is not present or is + present one or more times. + """ + + id: AnyUrl + type: Literal["Pattern"] + primary: Optional[bool] = False + inScheme: Optional[IRI] + prefLabel: Optional[LanguageMap] + definition: Optional[LanguageMap] + deprecated: Optional[bool] = False + alternates: Optional[conlist(AnyUrl, min_items=1)] + optional: Optional[AnyUrl] + oneOrMore: Optional[AnyUrl] + sequence: Optional[conlist(AnyUrl, min_items=1)] + zeroOrMore: Optional[AnyUrl] + + @root_validator() + @classmethod + def check_pattern_requirements(cls, values): + """Check `primary` pattern requirements and matching rules.""" + primary = values.get("primary") + pref_label = values.get("prefLabel") + definition = values.get("definition") + if primary and not (pref_label and definition): + raise ValueError( + "A `primary` pattern MUST include `prefLabel` and `definition` fields" + ) + rules = ["alternates", "optional", "oneOrMore", "sequence", "zeroOrMore"] + if sum(1 for rule in rules if values.get(rule)) != 1: + raise ValueError( + "A pattern MUST contain exactly one of `alternates`, `optional`, " + "`oneOrMore`, `sequence`, and `zeroOrMore` fields" + ) + return values + + +class ProfileTemplateRule(BaseModel): + """Profile `templates.rules` field. + + Note: + We do not validate the following requirements: + - A Statement Template Rule MUST include one or more of + `presence`, `any`, `all` or `none`. + > We accept statement rules not including any of + `presence`, `any`, `all` or `none`. + + Attributes: + location (str): A JSONPath string. This is evaluated on a Statement to find the + evaluated values to apply the requirements in this rule to. + All evaluated values from location are matchable values. + selector (str): JSONPath string. If specified, this JSONPath is evaluated on + each member of the evaluated values resulting from the location selector, + and the resulting values become the evaluated values instead. + If it returns nothing on a location, that represents an unmatchable value + for that location, meaning all will fail, as will a presence of included. + All other values returned are matchable values. + presence (str): Equal to `included`, `excluded` or `recommended`. + any (list): A list of values that needs to intersect with the matchable values. + all (list): A list of values which all the evaluated values need to be from. + none (list): A list of values that can't be in the matchable values. + scopeNote (dict): A Language Map describing usage details for the parts of + Statements addressed by this rule. + For example, a Profile with a rule requiring `result.duration` might provide + guidance on how to calculate it. + """ + + location: JsonPath + selector: Optional[JsonPath] + presence: Optional[Literal["included", "excluded", "recommended"]] + any: Optional[conlist(Union[str, int, bool, list, dict, None], min_items=1)] + all: Optional[conlist(Union[str, int, bool, list, dict, None], min_items=1)] + none: Optional[conlist(Union[str, int, bool, list, dict, None], min_items=1)] + scopeNote: Optional[LanguageMap] + + +class ProfileTemplate(BaseModel): + """Profile `templates` field. + + Note: + We do not validate the following requirements: + - A Statement Template MUST NOT have both `objectStatementRefTemplate` and + `objectActivityType`. + > We accept both `objectStatementRefTemplate` and `objectActivityType`. + + Attributes: + id (URI): A URI for this Statement Template. + type (str): Equal to `StatementTemplate`. + inScheme (IRI): The IRI of the specific Profile version. + prefLabel (dict): A Language Map of descriptive names for the Statement + Template. + definition (dict): A Language Map of descriptions of the purpose and usage of + the Statement Template. + deprecated (bool): If true, this Statement Template is deprecated. + verb (IRI): The verb IRI. + objectActivityType (IRI): The object activity type IRI. + contextGroupingActivityType (list): A List of contextActivities grouping + activity type IRIs. + contextParentActivityType (list): A list of contextActivities parent activity + type IRIs. + contextOtherActivityType (list): A list of contextActivities other activity type + IRIs. + contextCategoryActivityType (list): A list of contextActivities category + activity type IRIs. + attachmentUsageType (list): A list of attachment usage type IRIs. + objectStatementRefTemplate (list): A list of Statement Template identifiers. + contextStatementRefTemplate (list): A list of Statement Template identifiers. + rules (list): Array of Statement Template Rules. See `ProfileTemplateRule`. + """ + + id: AnyUrl + type: Literal["StatementTemplate"] + inScheme: IRI + prefLabel: LanguageMap + definition: LanguageMap + deprecated: Optional[bool] = False + verb: Optional[IRI] + objectActivityType: Optional[IRI] + contextGroupingActivityType: Optional[conlist(IRI, min_items=1)] + contextParentActivityType: Optional[conlist(IRI, min_items=1)] + contextOtherActivityType: Optional[conlist(IRI, min_items=1)] + contextCategoryActivityType: Optional[conlist(IRI, min_items=1)] + attachmentUsageType: Optional[conlist(IRI, min_items=1)] + objectStatementRefTemplate: Optional[conlist(AnyUrl, min_items=1)] + contextStatementRefTemplate: Optional[conlist(AnyUrl, min_items=1)] + rules: Optional[conlist(ProfileTemplateRule, min_items=1)] + + +class ProfileVerbActivityAttachmentConcept(BaseModel): + """Profile `concepts` field for a Verb, Activity Type, and Attachment Usage Type. + + Attributes: + id (IRI): The IRI of this Concept. + type (str): Equal to `Verb`, `ActivityType`, or `AttachmentUsageType`. + inScheme (IRI): The IRI of the Profile version. + prefLabel (dict): A Language Map of the preferred names in each language. + definition (dict): A Language Map of the definition how to use the Concept. + deprecated (bool): If true, this Concept is deprecated. + broader (list): A list of IRIs of Concepts of the same type from this Profile + version that have a broader meaning. + broadMatch (list): A list of IRIs of Concepts of the same type from a different + Profile that have a broader meaning. + narrower (list): A list of IRIs of Concepts of the same type from this Profile + version that have a narrower meaning. + narrowMatch (list): A list of IRIs of Concepts of the same type from different + Profiles that have narrower meanings. + related (list): A list of IRIs of Concepts of the same type from this Profile + version that are close to this Concept's meaning. + relatedMatch (list): A list of IRIs of Concepts of the same type from a + different Profile or a different version of the same Profile that has a + related meaning that is not clearly narrower or broader. + exactMatch (list): A list of IRIs of Concepts of the same type from a different + Profile or a different version of the same Profile that have exactly the + same meaning. + """ + + id: IRI + type: Literal["Verb", "ActivityType", "AttachmentUsageType"] + inScheme: IRI + prefLabel: LanguageMap + definition: LanguageMap + deprecated: Optional[bool] = False + broader: Optional[conlist(IRI, min_items=1)] + broadMatch: Optional[conlist(IRI, min_items=1)] + narrower: Optional[conlist(IRI, min_items=1)] + narrowMatch: Optional[conlist(IRI, min_items=1)] + related: Optional[conlist(IRI, min_items=1)] + relatedMatch: Optional[conlist(IRI, min_items=1)] + exactMatch: Optional[conlist(IRI, min_items=1)] + + +class ProfileExtensionConcept(BaseModel): + """Profile `concepts` field for an Extension. + + Note: + We do not validate the following requirements: + - `recommendedActivityTypes` is only allowed if `type` is `ActivityExtension`. + > We accept `recommendedActivityTypes` with other types too. + - `recommendedVerbs` is only allowed if `type` is not `ActivityExtension`. + > We accept `recommendedVerbs` if `type` is `ActivityExtension`. + - Profiles MUST use at most one of `schema` and `inlineSchema`. + > We accept having both `schema` and `inlineSchema` set. + - In the spec the type of `inlineSchema` is object, however in the description + it is stated that it's a string value. + > We accept both - string and dict values in the `inlineSchema` field. + + Attributes: + id (IRI): The IRI of the extension, used as the extension key in xAPI. + type (): Equal to `ContextExtension`, `ResultExtension`, or `ActivityExtension`. + inScheme (IRI): The IRI of the Profile version. + prefLabel (dict): A Language Map of descriptive names for the extension. + definition (dict): A Language Map of descriptions of the purpose and usage of + the extension. + deprecated (bool): If true, this Concept is deprecated. + recommendedActivityTypes (list): A list of activity type URIs that this + extension is recommended for use with (extending to narrower of the same). + recommendedVerbs (list): A list of verb URIs that this extension is recommended + for use with (extending to narrower of the same). + context (IRI): The IRI of a JSON-LD context for this extension. + schema (IRI): The IRI for accessing a JSON Schema for this extension. + inlineSchema (str or dict): An alternate way to include a JSON Schema. + """ + + id: IRI + type: Literal["ContextExtension", "ResultExtension", "ActivityExtension"] + inScheme: IRI + prefLabel: LanguageMap + definition: LanguageMap + deprecated: Optional[bool] = False + recommendedActivityTypes: Optional[conlist(AnyUrl, min_items=1)] + recommendedVerbs: Optional[conlist(AnyUrl, min_items=1)] + context: Optional[IRI] + schemaIRI: Optional[IRI] = Field(alias="schema") # schema name reserved in pydantic + inlineSchema: Optional[ + Union[Json[JSONSchema], JSONSchema] # pylint: disable=unsubscriptable-object + ] + + +class ProfileDocumentResourceConcept(BaseModel): + """Profile `concepts` field for a Document Resource. + + Note: + We do not validate the following requirements: + - `contentType` should be a media type following RFC 2046. + > We accept any string for the `contentType` field. + - Profiles MUST use at most one of `schema` and `inlineSchema`. + > We accept having both `schema` and `inlineSchema` set. + - In the spec the type of `inlineSchema` is object, however in the description + it is stated that it's a string value. + > We accept both - string and dict values in the `inlineSchema` field. + + Attributes: + id (IRI): The IRI of the document resource, used as the stateId/profileId in + xAPI. + type (str): Equal to `StateResource`, `AgentProfileResource` or + `ActivityProfileResource`. + inScheme (IRI): The IRI of the specific Profile version. + prefLabel (dict): A Language Map of descriptive names for the document resource. + definition (dict): A Language Map of descriptions of the purpose and usage of + the document resource. + contentType (str): The media type for the resource, as described in RFC 2046 + (e.g. application/json). + deprecated (bool): If true, this Concept is deprecated. + context (IRI): The IRI of a JSON-LD context for this document resource. + schema (IRI): The IRI for accessing a JSON Schema for this document resource. + inlineSchema (str or dict): An alternate way to include a JSON Schema. + """ + + id: IRI + type: Literal["StateResource", "AgentProfileResource", "ActivityProfileResource"] + inScheme: IRI + prefLabel: LanguageMap + definition: LanguageMap + contentType: constr(min_length=1) # media type for the resource as in RFC 2046 + deprecated: Optional[bool] = False + context: Optional[IRI] + schemaIRI: Optional[IRI] = Field(alias="schema") # schema name reserved in pydantic + inlineSchema: Optional[ + Union[Json[JSONSchema], JSONSchema] # pylint: disable=unsubscriptable-object + ] + + +class ProfileActivityDefinition(BaseXapiActivityDefinition): + """Profile `concepts.activityDefinition` field.""" + + context: Literal["https://w3id.org/xapi/profiles/activity-context"] = Field( + alias="@context" + ) + + +class ProfileActivityInteractionDefinition(BaseXapiActivityInteractionDefinition): + """Profile `concepts.activityDefinition` field.""" + + context: Literal["https://w3id.org/xapi/profiles/activity-context"] = Field( + alias="@context" + ) + + +class ProfileActivityConcept(BaseModel): + """Profile `concepts` field for an Activity. + + Attributes: + id (IRI): The IRI of the activity. + type (str): Equal to `Activity`. + inScheme (IRI): The IRI of the specific Profile version. + deprecated (bool): If true, this Concept is deprecated. + activityDefinition (dict): An Activity Definition as in xAPI including a + `@context` field. + """ + + id: IRI + type: Literal["Activity"] + inScheme: IRI + deprecated: Optional[bool] = False + activityDefinition: Union[ + ProfileActivityDefinition, ProfileActivityInteractionDefinition + ] + + +ProfileConcept = Union[ + ProfileVerbActivityAttachmentConcept, + ProfileExtensionConcept, + ProfileDocumentResourceConcept, + ProfileActivityConcept, +] + + +class ProfileAuthor(BaseModel): + """Profile `author` field. + + Attributes: + type (str): Equal to `Organization` or `Person`. + name (str): A string with the name of the organization or person. + url (URL): A URL for the Person or Group. + """ + + type: Literal["Organization", "Person"] + name: constr(min_length=1) + url: Optional[AnyUrl] + + +class ProfileVersion(BaseModel): + """Profile `version` field. + + Attributes: + id (IRI): The IRI of the version ID. + wasRevisionOf (list): A list of IRIs of all Profile versions this version was + written as a revision of. + generatedAtTime (datetime): The date this version was created on. + """ + + id: IRI + wasRevisionOf: Optional[conlist(IRI, min_items=1)] + generatedAtTime: datetime + + +class Profile(BaseModel): + """xAPI JSON-LD profile. + + Note: + We do not validate the following requirements: + - All properties that are not JSON-LD keywords (or aliases thereof) MUST expand + to absolute IRIs during processing as defined in the JSON-LD specification. + > We don't validate/process JSON-LD keyword expansion. + - All properties that are not JSON-LD keywords (or aliases thereof) and not + described by this specification MUST be expressed using compact IRIs or + absolute IRIs. + > We ignore all properties that are not defined in the `Profile` model. + - The `@context` could be array-valued. + > We only support `@context` with a string value. + + Attributes: + id (IRI): The IRI of the Profile overall (not a specific version). + @context (URI): Equal to `https://w3id.org/xapi/profiles/context`. + type (str): Equal to `Profile`. + conformsTo (URI): URI of the Profile specification version conformed to. + prefLabel (dict): Language map of names for this Profile. + definition (dict): Language map of descriptions for this Profile. + seeAlso (URL): A URL containing information about the Profile. + versions (list): A list of `ProfileVersion` objects for this Profile. + author (dict): An Organization or Person. See `ProfileAuthor`. + concepts (list): A list of Concepts that make up this Profile. + templates (list): A list of Statement Templates for this Profile. + patterns (list): A list of Patterns for this Profile. + """ + + __selector__ = selector(type="Profile") + + id: IRI + context: Literal["https://w3id.org/xapi/profiles/context"] = Field(alias="@context") + type: Literal["Profile"] + conformsTo: AnyUrl + prefLabel: LanguageMap + definition: LanguageMap + seeAlso: Optional[AnyUrl] + versions: conlist(ProfileVersion, min_items=1) + author: ProfileAuthor + concepts: Optional[conlist(ProfileConcept, min_items=1)] + templates: Optional[conlist(ProfileTemplate, min_items=1)] + patterns: Optional[conlist(ProfilePattern, min_items=1)] diff --git a/tests/fixtures/hypothesis_strategies.py b/tests/fixtures/hypothesis_strategies.py index a393eec3c..41e3b66da 100644 --- a/tests/fixtures/hypothesis_strategies.py +++ b/tests/fixtures/hypothesis_strategies.py @@ -5,12 +5,13 @@ from hypothesis import given from hypothesis import strategies as st -from pydantic import BaseModel +from pydantic import AnyUrl, BaseModel from ralph.models.edx.navigational.fields.events import NavigationalEventField from ralph.models.edx.navigational.statements import UISeqNext, UISeqPrev from ralph.models.xapi.base.contexts import BaseXapiContext from ralph.models.xapi.base.results import BaseXapiResultScore +from ralph.models.xapi.profile import ProfilePattern, ProfileTemplateRule OVERWRITTEN_STRATEGIES = {} @@ -107,4 +108,16 @@ def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs): "min": False, "max": False, }, + ProfilePattern: { + "primary": False, + "alternates": False, + "optional": st.from_type(AnyUrl), + "oneOrMore": False, + "sequence": False, + "zeroOrMore": False, + }, + ProfileTemplateRule: { + "location": st.just("$.timestamp"), + "selector": False, + } } diff --git a/tests/models/xapi/test_profile.py b/tests/models/xapi/test_profile.py new file mode 100644 index 000000000..a158e1ccd --- /dev/null +++ b/tests/models/xapi/test_profile.py @@ -0,0 +1,189 @@ +"""Tests for the xAPI JSON-LD Profile.""" +import json + +import pytest +from pydantic import ValidationError + +from ralph.models.selector import ModelSelector +from ralph.models.xapi.profile import Profile, ProfilePattern, ProfileTemplateRule + +from tests.fixtures.hypothesis_strategies import custom_given + + +@custom_given(Profile) +def test_models_xapi_profile_with_json_ld_keywords(profile): + """Test a `Profile` MAY include JSON-LD keywords.""" + profile = json.loads(profile.json(by_alias=True)) + profile["@base"] = None + try: + Profile(**profile) + except ValidationError as err: + pytest.fail( + f"A profile including JSON-LD keywords should not raise exceptions: {err}" + ) + + +@pytest.mark.parametrize( + "missing", + [ + ("prefLabel",), ("definition", ), ("prefLabel", "definition") + ] +) +@custom_given(ProfilePattern) +def test_models_xapi_profile_pattern_with_invalid_primary_value(missing, pattern): + """Test a `ProfilePattern` MUST include `prefLabel` and `definition` fields.""" + pattern = json.loads(pattern.json(by_alias=True)) + pattern["primary"] = True + for field in missing: + del pattern[field] + + msg = "A `primary` pattern MUST include `prefLabel` and `definition` fields" + with pytest.raises(ValidationError, match=msg): + ProfilePattern(**pattern) + + +@pytest.mark.parametrize( + "rules", + [ + (), + ("alternates", "optional"), + ("oneOrMore", "sequence"), + ("zeroOrMore", "alternates") + ] +) +@custom_given(ProfilePattern) +def test_models_xapi_profile_pattern_with_invalid_number_of_match_rules( + rules, pattern +): + """Test a `ProfilePattern` MUST contain exactly one of `alternates`, `optional`, + `oneOrMore`, `sequence`, and `zeroOrMore`. + """ + rule_values = { + "alternates": ["https://example.com", "https://example.fr"], + "optional": "https://example.com", + "oneOrMore": "https://example.com", + "sequence": ["https://example.com", "https://example.fr"], + "zeroOrMore": "https://example.com" + } + pattern = json.loads(pattern.json(by_alias=True)) + del pattern["optional"] + for rule in rules: + pattern[rule] = rule_values[rule] + + msg = ( + "A pattern MUST contain exactly one of `alternates`, `optional`, " + "`oneOrMore`, `sequence`, and `zeroOrMore` fields" + ) + with pytest.raises(ValidationError, match=msg): + ProfilePattern(**pattern) + + +@custom_given(Profile) +def test_models_xapi_profile_selector_with_valid_model(profile): + """Test given a valid profile, the `get_first_model` method of the model + selector should return the corresponding model. + """ + profile = json.loads(profile.json()) + model_selector = ModelSelector(module="ralph.models.xapi.profile") + assert model_selector.get_first_model(profile) is Profile + + +@pytest.mark.parametrize("field", ["location", "selector"]) +@custom_given(ProfileTemplateRule) +def test_models_xapi_profile_template_rules_with_invalid_json_path(field, rule): + """Test given a profile template rule with a `location` or `selector` containing an + invalid JSONPath, the `ProfileTemplateRule` model should raise an exception. + """ + rule = json.loads(rule.json()) + rule[field] = "" + msg = "Invalid JSONPath: empty string is not a valid path" + with pytest.raises(ValidationError, match=msg): + ProfileTemplateRule(**rule) + + rule[field] = "not a JSONPath" + msg = ( + f"1 validation error for ProfileTemplateRule\n{field}\n Invalid JSONPath: " + r"Parse error at 1:4 near token a \(ID\) \(type=value_error\)" + ) + with pytest.raises(ValidationError, match=msg): + ProfileTemplateRule(**rule) + + +@pytest.mark.parametrize("field", ["location", "selector"]) +@custom_given(ProfileTemplateRule) +def test_models_xapi_profile_template_rules_with_valid_json_path(field, rule): + """Test given a profile template rule with a `location` or `selector` containing an + valid JSONPath, the `ProfileTemplateRule` model should not raise exceptions. + """ + rule = json.loads(rule.json()) + rule[field] = "$.context.extensions['http://example.com/extension']" + try: + ProfileTemplateRule(**rule) + except ValidationError as err: + pytest.fail( + "A `ProfileTemplateRule` with a valid JSONPath should not raise exceptions:" + f" {err}" + ) + + +@custom_given(Profile) +def test_models_xapi_profile_with_valid_json_schema(profile): + """Test given a profile with an extension concept containing a valid JSONSchema, + should not raise exceptions. + """ + profile = json.loads(profile.json(by_alias=True)) + profile["concepts"] = [ + { + "id": "http://example.com", + "type": "ContextExtension", + "inScheme": "http://example.profile.com", + "prefLabel": { + "en-us": "Example context extension", + }, + "definition": { + "en-us": "To use when an example happens", + }, + "inlineSchema": json.dumps( + { + "$id": "https://example.com/example.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Example", + "type": "object", + "properties": { + "example": {"type": "string", "description": "The example."}, + }, + } + ), + } + ] + try: + Profile(**profile) + except ValidationError as err: + pytest.fail( + f"A profile including a valid JSONSchema should not raise exceptions: {err}" + ) + + +@custom_given(Profile) +def test_models_xapi_profile_with_invalid_json_schema(profile): + """Test given a profile with an extension concept containing an invalid JSONSchema, + should raise an exception. + """ + profile = json.loads(profile.json(by_alias=True)) + profile["concepts"] = [ + { + "id": "http://example.com", + "type": "ContextExtension", + "inScheme": "http://example.profile.com", + "prefLabel": { + "en-us": "Example context extension", + }, + "definition": { + "en-us": "To use when an example happens", + }, + "inlineSchema": json.dumps({"type": "example"}), + } + ] + msg = "Invalid JSONSchema: 'example' is not valid under any of the given schemas" + with pytest.raises(ValidationError, match=msg): + Profile(**profile) diff --git a/tests/test_cli.py b/tests/test_cli.py index 188725b81..3a6440eac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,6 +23,7 @@ from ralph.exceptions import ConfigurationException from ralph.models.edx.navigational.statements import UIPageClose from ralph.models.xapi.navigation.statements import PageTerminated +from ralph.models.xapi.profile import Profile from tests.fixtures.backends import ( ES_TEST_HOSTS, @@ -482,6 +483,16 @@ def test_cli_validate_command_with_edx_format(event): assert event_str in result.output +@custom_given(Profile) +def test_cli_validate_command_with_xapi_profile_format(event): + """Test the validate command using the xAPI profile format.""" + + event_str = event.json(by_alias=True) + runner = CliRunner() + result = runner.invoke(cli, "validate -f xapi.profile".split(), input=event_str) + assert event_str in result.output + + @hypothesis_settings(deadline=None) @custom_given(UIPageClose) @pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"]) diff --git a/tests/test_cli_usage.py b/tests/test_cli_usage.py index baa4dc330..a7845fce6 100644 --- a/tests/test_cli_usage.py +++ b/tests/test_cli_usage.py @@ -62,15 +62,20 @@ def test_cli_validate_command_usage(): assert result.exit_code == 0 assert ( "Options:\n" - " -f, --format [edx|xapi] Input events format to validate [required]\n" - " -I, --ignore-errors Continue validating regardless of raised errors\n" - " -F, --fail-on-unknown Stop validating at first unknown event\n" + " -f, --format [edx|xapi|xapi.profile]\n" + " Input data format to validate [required]\n" + " -I, --ignore-errors Continue validating regardless of raised\n" + " errors\n" + " -F, --fail-on-unknown Stop validating at first unknown record\n" ) in result.output result = runner.invoke(cli, ["validate"]) assert result.exit_code > 0 assert ( - "Error: Missing option '-f' / '--format'. Choose from:\n\tedx,\n\txapi\n" + "Error: Missing option '-f' / '--format'. Choose from:\n" + "\tedx,\n" + "\txapi,\n" + "\txapi.profile\n" ) in result.output