Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/id reference for feeds validation #412

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ this.meta = {
};
```

### validateModel and validateField
### `validateModel` and `validateField`

Only one of these methods is expected to be implemented on each rule.

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@
},
"license": "MIT",
"dependencies": {
"@openactive/data-models": "^2.0.219",
"@openactive/data-models": "github:openactive/data-models#2660e5d",
"@types/lodash": "^4.14.182",
"axios": "^0.19.2",
"currency-codes": "^1.5.1",
"html-entities": "^1.3.1",
"jsonpath": "^1.0.2",
"lodash": "^4.17.21",
"moment": "^2.24.0",
"rrule": "^2.6.2",
"striptags": "^3.1.1",
Expand Down
4 changes: 4 additions & 0 deletions src/classes/model-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,8 @@ const ModelNode = class {
}
};

/**
* @typedef {ModelNode} ModelNodeType
*/

module.exports = ModelNode;
36 changes: 29 additions & 7 deletions src/classes/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const Model = class {
return this.imperativeConfiguration[imperativeConfigName];
}

getImperativeConfigurationWithContext(validationMode, containingFieldName) {
getImperativeConfigurationWithContext(validationMode, { containingFieldName = null, rpdeKind = null }) {
if (!this.validationMode) return undefined;
if (!this.imperativeConfigurationWithContext) return undefined;

Expand All @@ -63,13 +63,13 @@ const Model = class {

if (!contextualImperativeConfigs) return undefined;

const contextualImperativeConfig = contextualImperativeConfigs[containingFieldName];
const fieldContextualImperativeConfig = contextualImperativeConfigs[containingFieldName];

return contextualImperativeConfig;
return (!fieldContextualImperativeConfig) ? contextualImperativeConfigs[rpdeKind] : fieldContextualImperativeConfig;
}

getRequiredFields(validationMode, containingFieldName) {
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName);
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, { containingFieldName });
const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode);

if (specificContextualImperativeConfiguration && specificContextualImperativeConfiguration.requiredFields) return specificContextualImperativeConfiguration.requiredFields;
Expand All @@ -84,7 +84,7 @@ const Model = class {
}

getRequiredOptions(validationMode, containingFieldName) {
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName);
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, { containingFieldName });
const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode);

if (specificContextualImperativeConfiguration && specificContextualImperativeConfiguration.requiredOptions) return specificContextualImperativeConfiguration.requiredOptions;
Expand All @@ -95,7 +95,7 @@ const Model = class {
}

getRecommendedFields(validationMode, containingFieldName) {
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName);
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, { containingFieldName });
const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode);

if (specificContextualImperativeConfiguration && specificContextualImperativeConfiguration.recommendedFields) return specificContextualImperativeConfiguration.recommendedFields;
Expand All @@ -106,7 +106,7 @@ const Model = class {
}

getShallNotIncludeFields(validationMode, containingFieldName) {
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName);
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, { containingFieldName });
const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode);

if (specificContextualImperativeConfiguration && specificContextualImperativeConfiguration.shallNotInclude) return specificContextualImperativeConfiguration.shallNotInclude;
Expand All @@ -116,6 +116,28 @@ const Model = class {
return this.data.shallNotInclude || [];
}

getReferencedFields(validationMode, { containingFieldName = null, rpdeKind = null }) {
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, { containingFieldName, rpdeKind });
const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode);

if (specificContextualImperativeConfiguration && specificContextualImperativeConfiguration.referencedFields) return specificContextualImperativeConfiguration.referencedFields;

if (specificImperativeConfiguration && specificImperativeConfiguration.referencedFields) return specificImperativeConfiguration.referencedFields;

return this.data.referencedFields || [];
}

getShallNotBeReferencedFields(validationMode, { containingFieldName = null, rpdeKind = null }) {
const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, { containingFieldName, rpdeKind });
const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode);

if (specificContextualImperativeConfiguration && specificContextualImperativeConfiguration.shallNotBeReferencedFields) return specificContextualImperativeConfiguration.shallNotBeReferencedFields;

if (specificImperativeConfiguration && specificImperativeConfiguration.shallNotBeReferencedFields) return specificImperativeConfiguration.shallNotBeReferencedFields;

return this.data.shallNotBeReferencedFields || [];
}

hasRecommendedField(field) {
return PropertyHelper.arrayHasField(this.recommendedFields, field, this.version);
}
Expand Down
2 changes: 2 additions & 0 deletions src/errors/validation-error-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const ValidationErrorType = {
BELOW_MIN_VALUE_INCLUSIVE: 'below_min_value_inclusive',
VALUE_OUTWITH_CONSTRAINT: 'value_outwith_constraint',
INVALID_ID: 'invalid_id',
FIELD_NOT_ID_REFERENCE: 'field_not_id_reference',
FIELD_SHOUlD_NOT_BE_ID_REFERENCE: 'field_should_not_be_id_reference',
};

module.exports = Object.freeze(ValidationErrorType);
4 changes: 4 additions & 0 deletions src/helpers/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const OptionsHelper = class {
get validationMode() {
return this.options.validationMode || 'RPDEFeed';
}

get rpdeKind() {
return this.options.rpdeKind || null;
}
};

module.exports = OptionsHelper;
157 changes: 157 additions & 0 deletions src/rules/core/id-references-for-certain-feeds-rule-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
const Model = require('../../classes/model');
const ModelNode = require('../../classes/model-node');
const OptionsHelper = require('../../helpers/options');
const IdReferencesForCertainFeedsRule = require('./id-references-for-certain-feeds-rule');
const ValidationErrorType = require('../../errors/validation-error-type');
const ValidationErrorSeverity = require('../../errors/validation-error-severity');

describe('IdReferencesForCertainFeedsRule', () => {
Copy link
Contributor

@nickevansuk nickevansuk Jul 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For these rules, see the refactoring of #407.

Recommend refactoring to separate the request/response concerns (which should be covered by the validation mode), so that these rules rely purely on the validation files.

Note although #407 limits the @id reference rules to apply only to certain validation modes as an initial release, once this PR is completed suggest that we update them to apply to all validation modes (i.e. targetValidationModes = '*')

const rule = new IdReferencesForCertainFeedsRule();

describe('for kind FacilityUse/Slot or IndividualFacilityUse/Slot feeds', () => {
const model = new Model({
type: 'Slot',
validationMode: {
RPDEFeed: 'feed',
BookableRPDEFeed: 'feed',
},
rpdeKind: [
'FacilityUse/Slot',
'IndividualFacilityUse/Slot',
],
imperativeConfigurationWithContext: {
Copy link
Contributor

@nickevansuk nickevansuk Jul 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this same property is being used here for both RPDE kind and parent, suggest this data models property should be renamed imperativeConfigurationByRpdeKind (and perhaps for clarity the existing property renamed to imperativeConfigurationByParentPropertyName or something more intuitive?)

imperativeConfigurationWithContext is only used within the data-model-validator, so should not have any impact on other dependencies

This should make the configuration clearer

feed: {
'FacilityUse/Slot': {
referencedFields: [
'facilityUse',
],
},
'IndividualFacilityUse/Slot': {
referencedFields: [
'facilityUse',
],
},
},
},
imperativeConfiguration: {
feed: {},
},
}, 'latest');
model.hasSpecification = true;
it('should validate that facilityUse within the Slot is a ID reference, and not an object', async () => {
const options = new OptionsHelper({ validationMode: 'RPDEFeed', rpdeKind: 'FacilityUse/Slot' });

const data = {
'@context': 'https://openactive.io/',
'@type': 'Slot',
facilityUse: 'https://example.com/item/2',
};

const nodeToTest = new ModelNode(
'$',
data,
null,
model,
options,
);
const errors = await rule.validate(nodeToTest);

expect(errors.length).toBe(0);
});
it('should error when the facilityUse within the Slot is an object not an ID reference', async () => {
const options = new OptionsHelper({ validationMode: 'RPDEFeed', rpdeKind: 'FacilityUse/Slot' });

const data = {
'@context': 'https://openactive.io/',
'@type': 'Slot',
facilityUse: {
'@id': 'https://example.com/item/2',
},
};

const nodeToTest = new ModelNode(
'$',
data,
null,
model,
options,
);
const errors = await rule.validate(nodeToTest);

expect(errors.length).toBe(1);
expect(errors[0].type).toBe(ValidationErrorType.FIELD_NOT_ID_REFERENCE);
expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE);
});
});
describe('for kind ScheduledSessions feeds', () => {
const model = new Model({
type: 'ScheduledSession',
validationMode: {
RPDEFeed: 'feed',
BookableRPDEFeed: 'feed',
},
rpdeKind: [
'ScheduledSession',
'ScheduledSession.SessionSeries',
],
imperativeConfigurationWithContext: {
feed: {
ScheduledSession: {
referencedFields: [
'superEvent',
],
},
},
},
imperativeConfiguration: {
feed: {},
},
}, 'latest');
model.hasSpecification = true;

it('should validate that superEvent within the ScheduledSession is a ID reference, and not an object', async () => {
const options = new OptionsHelper({ validationMode: 'RPDEFeed', rpdeKind: 'ScheduledSession' });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies, I think we might have crossed wires slightly in our design chat on this...

There are some challenges with specifying rpdeKind at the very top level:

  1. We'd need to have the kind selectable or attributed to a validationMode in the validator GUI or have a different mode selection), or add an to that GUI that the RPDE kind of all items is the same as the first item (which isn't in the RPDE spec)
  2. The error messages will be unclear as the kind will be hidden from view if the item level validation (linked from validation results HTML in the test suite) is still shown in the GUI at the data level as now.
  3. As a separation of concerns, the validationMode is something that the data-model-validator cannot possibly know, as it's about the request/response context in which it finds the JSON. However the rpdeKind could be known to the validator, as it can be gleaned from the JSON itself.
  4. It precludes feeds with mixed RPDE item kinds from being validated in future.

So as an alternative proposal, I'd recommend we make the following changes:

  1. Equip the validator to validate the RPDE item level (e.g. the example JSON below)
  2. Update the rules here to look at the RPDE item kind in the parent, rather than looking at a global rpdeKind setting
  3. Update the test suite to pass the RPDE item (rather than just the contents of its data property) to the validator from the feed harvesting, and to include this in the encoded JSON in the links within the HTML validation output
  4. Also test this works when passing a whole feed page to the validator (as in the validator GUI samples dropdown), as each RPDE item should provide the kind for this rule running on its data.

So hence following example should pass the validator:

{
  "state": "updated",
  "kind": "ScheduledSession",
  "id": "C5EE1E55-2DE6-44F7-A865-42F268A82C63",
  "modified": 1521565719,
  "data": {
    "@context": [
      "https://openactive.io/",
      "https://openactive.io/ns-beta"
    ],
    "@type": "ScheduledSession",
    "@id": "https://opensessions.io/api/session-series/1402CBP20150217#/subEvent/C5EE1E55-2DE6-44F7-A865-42F268A82C63",
    "identifier": "C5EE1E55-2DE6-44F7-A865-42F268A82C63",
    "superEvent": "https://opensessions.io/api/session-series/1402CBP20150217",
    "startDate": "2016-05-09T18:15:00Z",
    "endDate": "2016-05-09T19:15:00Z",
    "duration": "PT1H",
    "eventStatus": "https://schema.org/EventScheduled",
    "maximumAttendeeCapacity": 10,
    "remainingAttendeeCapacity": 0,
    "url": "https://bookwhen.com/hulafit/e/ev-ssyp-20160510011500?r=oa"
  }
}


const data = {
'@context': 'https://openactive.io/',
'@type': 'ScheduledSession',
superEvent: 'https://example.com/item/2',
};

const nodeToTest = new ModelNode(
'$',
data,
null,
model,
options,
);
const errors = await rule.validate(nodeToTest);

expect(errors.length).toBe(0);
});
it('should error when superEvent within the ScheduledSession is an object not an ID reference', async () => {
const options = new OptionsHelper({ validationMode: 'RPDEFeed', rpdeKind: 'ScheduledSession' });

const data = {
'@context': 'https://openactive.io/',
'@type': 'ScheduledSession',
superEvent: {
'@id': 'https://example.com/item/2',
},
};

const nodeToTest = new ModelNode(
'$',
data,
null,
model,
options,
);
const errors = await rule.validate(nodeToTest);

expect(errors.length).toBe(1);
expect(errors[0].type).toBe(ValidationErrorType.FIELD_NOT_ID_REFERENCE);
expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE);
});
});
});
74 changes: 74 additions & 0 deletions src/rules/core/id-references-for-certain-feeds-rule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const Rule = require('../rule');
const PropertyHelper = require('../../helpers/property');
const ValidationErrorCategory = require('../../errors/validation-error-category');
const ValidationErrorSeverity = require('../../errors/validation-error-severity');
const validationErrorType = require('../../errors/validation-error-type');

/** @typedef {import('../../classes/model-node').ModelNodeType} ModelNode */

class IdReferencesForCertainFeedsRule extends Rule {
constructor(options) {
super(options);
this.targetValidationModes = [
'RPDEFeed',
'BookableRPDEFeed',
];
this.targetRpdeKinds = [
'FacilityUse/Slot',
'IndividualFacilityUse/Slot',
'ScheduledSession',
];
this.targetModels = '*';
this.meta = {
name: 'IdReferencesForCertainFeedsRule',
description: 'Validates that certain properties in the specified feeds are an ID reference and not objects',
tests: {
default: {
description: `Raises a failure if properties within the data object in a RPDE Feed is not an ID reference
(ie a reference to the object and not the object itself)`,
message: 'For {{rpdeKind}} feeds, {{field}} must be an compact ID reference, not the object representing the data itself',
sampleValues: {
rpdeKind: 'FacilityUse/Slot',
field: 'facilityUse',
},
category: ValidationErrorCategory.CONFORMANCE,
severity: ValidationErrorSeverity.FAILURE,
type: validationErrorType.FIELD_NOT_ID_REFERENCE,
},
},
};
}

/**
* @param {ModelNode} node
*/
validateModel(node) {
// Don't do this check for models that we don't actually have a spec for or for models that aren't JSON-LD
if (!node.model.hasSpecification || !node.model.isJsonLd) {
return [];
}

const errors = [];
const referencedFields = node.model.getReferencedFields(node.options.validationMode, { rpdeKind: node.options.rpdeKind });
for (const field of referencedFields) {
const fieldValue = node.getValue(field);

if (typeof fieldValue !== 'string' || !PropertyHelper.isUrl(fieldValue)) {
errors.push(
this.createError(
'default',
{
fieldValue,
path: node.getPath(field),
},
{ referencedField: field },
),
);
}
}

return errors;
}
}

module.exports = IdReferencesForCertainFeedsRule;
Loading