diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c0e254..4b2b273 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/package.json b/package.json index bc0a162..7f9d6e8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/classes/model-node.js b/src/classes/model-node.js index 45c3d29..ef6be9d 100644 --- a/src/classes/model-node.js +++ b/src/classes/model-node.js @@ -181,4 +181,8 @@ const ModelNode = class { } }; +/** + * @typedef {ModelNode} ModelNodeType + */ + module.exports = ModelNode; diff --git a/src/classes/model.js b/src/classes/model.js index 57ace1c..0abd9da 100644 --- a/src/classes/model.js +++ b/src/classes/model.js @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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); } diff --git a/src/errors/validation-error-type.js b/src/errors/validation-error-type.js index 79e9c46..29b2b40 100644 --- a/src/errors/validation-error-type.js +++ b/src/errors/validation-error-type.js @@ -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); diff --git a/src/helpers/options.js b/src/helpers/options.js index d3826cb..fe905dd 100644 --- a/src/helpers/options.js +++ b/src/helpers/options.js @@ -30,6 +30,10 @@ const OptionsHelper = class { get validationMode() { return this.options.validationMode || 'RPDEFeed'; } + + get rpdeKind() { + return this.options.rpdeKind || null; + } }; module.exports = OptionsHelper; diff --git a/src/rules/core/id-references-for-certain-feeds-rule-spec.js b/src/rules/core/id-references-for-certain-feeds-rule-spec.js new file mode 100644 index 0000000..e5a5059 --- /dev/null +++ b/src/rules/core/id-references-for-certain-feeds-rule-spec.js @@ -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', () => { + 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: { + 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' }); + + 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); + }); + }); +}); diff --git a/src/rules/core/id-references-for-certain-feeds-rule.js b/src/rules/core/id-references-for-certain-feeds-rule.js new file mode 100644 index 0000000..c06cf7c --- /dev/null +++ b/src/rules/core/id-references-for-certain-feeds-rule.js @@ -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; diff --git a/src/rules/core/id-references-for-requests-rule-spec.js b/src/rules/core/id-references-for-requests-rule-spec.js new file mode 100644 index 0000000..cb207df --- /dev/null +++ b/src/rules/core/id-references-for-requests-rule-spec.js @@ -0,0 +1,132 @@ +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const IdReferencesForRequestsRule = require('./id-references-for-requests-rule'); +const OptionsHelper = require('../../helpers/options'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + + +describe('IdReferencesForRequestsRule', () => { + const rule = new IdReferencesForRequestsRule(); + + const model = new Model({ + type: 'OrderItem', + referencedFields: [ + 'orderedItem', + 'acceptedOffer', + ], + }, 'latest'); + model.hasSpecification = true; + + it('should return a failure if a request object does not have `acceptedOffer` as a compact ID reference', async () => { + const options = new OptionsHelper({ validationMode: 'C1Request' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + acceptedOffer: { + '@type': 'Offer', + '@id': 'https://example.com/offer/1', + }, + orderedItem: '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); + }); + it('should return a failure if a request object does not have `orderedItem` as a compact ID reference', async () => { + const options = new OptionsHelper({ validationMode: 'C1Request' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + orderedItem: { + '@type': 'ScheduledSession', + '@id': 'https://example.com/session/1', + }, + acceptedOffer: 'https://example.com/offer/1', + }; + + 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); + }); + it('should return no errors if a response object has `acceptedOffer` as a data object', async () => { + const options = new OptionsHelper({ validationMode: 'C1Response' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + acceptedOffer: { + '@type': 'ScheduledSession', + '@id': 'https://example.com/offer/1', + }, + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + }); + it('should return no errors if a response object has `orderedItem` as a data object', async () => { + const options = new OptionsHelper({ validationMode: 'C1Response' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + orderedItem: { + '@type': 'ScheduledSession', + '@id': 'https://example.com/session/1', + }, + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + }); + it('should return no errors if a request object has `orderedItem` as a compact ID reference', async () => { + const options = new OptionsHelper({ validationMode: 'C1Request' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + orderedItem: 'https://example.com/session/1', + acceptedOffer: 'https://example.com/offer/1', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + }); +}); diff --git a/src/rules/core/id-references-for-requests-rule.js b/src/rules/core/id-references-for-requests-rule.js new file mode 100644 index 0000000..293a4e2 --- /dev/null +++ b/src/rules/core/id-references-for-requests-rule.js @@ -0,0 +1,72 @@ +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 IdReferencesForRequestsRule extends Rule { + constructor(options) { + super(options); + this.targetValidationModes = [ + 'C1Request', + 'C2Request', + 'PRequest', + 'BRequest', + 'BOrderProposalRequest', + 'OrderPatch', + ]; + this.targetModels = '*'; + this.meta = { + name: 'IdReferencesForRequestsRule', + description: 'Validates that acceptedOffer and orderedItem are ID references and not objects for requests (C1, C2 etc)', + tests: { + default: { + description: `Raises a failure if the acceptedOffer or orderedItem within the OrderItem of a request is not a URL + (ie a reference to the object and not the object itself)`, + message: 'For requests, {{field}} must be a compact ID reference, not the object representing the data itself', + sampleValues: { + field: 'acceptedOffer', + }, + 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 = IdReferencesForRequestsRule; diff --git a/src/rules/core/no-id-references-for-certain-feeds-rule-spec.js b/src/rules/core/no-id-references-for-certain-feeds-rule-spec.js new file mode 100644 index 0000000..ae65f8d --- /dev/null +++ b/src/rules/core/no-id-references-for-certain-feeds-rule-spec.js @@ -0,0 +1,87 @@ +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const OptionsHelper = require('../../helpers/options'); +const NoIdReferencesForCertainFeedsRule = require('./no-id-references-for-certain-feeds-rule'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('NoIdReferencesForCertainFeedsRule', () => { + const rule = new NoIdReferencesForCertainFeedsRule(); + + describe('for kind ScheduledSessions.SessionSeries feeds', () => { + const model = new Model({ + type: 'ScheduledSession', + validationMode: { + RPDEFeed: 'feed', + BookableRPDEFeed: 'feed', + }, + rpdeKind: [ + 'ScheduledSession', + 'ScheduledSession.SessionSeries', + ], + imperativeConfigurationWithContext: { + feed: { + ScheduledSession: { + referencedFields: [ + 'superEvent', + ], + }, + 'ScheduledSession.SessionSeries': { + shallNotBeReferencedFields: [ + 'superEvent', + ], + }, + }, + }, + imperativeConfiguration: { + feed: {}, + }, + }, 'latest'); + model.hasSpecification = true; + + it('should validate that superEvent within the ScheduledSession is a not an ID reference, but an object', async () => { + const options = new OptionsHelper({ validationMode: 'RPDEFeed', rpdeKind: 'ScheduledSession.SessionSeries' }); + + 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(0); + }); + it('should error when superEvent within the ScheduledSession is not an object but an ID reference', async () => { + const options = new OptionsHelper({ validationMode: 'RPDEFeed', rpdeKind: 'ScheduledSession.SessionSeries' }); + + 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(1); + expect(errors[0].type).toBe(ValidationErrorType.FIELD_SHOUlD_NOT_BE_ID_REFERENCE); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + }); + }); +}); diff --git a/src/rules/core/no-id-references-for-certain-feeds-rule.js b/src/rules/core/no-id-references-for-certain-feeds-rule.js new file mode 100644 index 0000000..ed820aa --- /dev/null +++ b/src/rules/core/no-id-references-for-certain-feeds-rule.js @@ -0,0 +1,72 @@ +const _ = require('lodash'); +const Rule = require('../rule'); +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 NoIdReferencesForCertainFeedsRule extends Rule { + constructor(options) { + super(options); + this.targetValidationModes = [ + 'RPDEFeed', + 'BookableRPDEFeed', + ]; + this.targetRpdeKinds = [ + 'ScheduledSession.SessionSeries', + ]; + this.targetModels = '*'; + this.meta = { + name: 'NoIdReferencesForCertainFeedsRule', + description: 'Validates that certain properties in the specified feeds are not an ID reference and are objects', + tests: { + default: { + description: `Raises a failure if properties within the data object in a RPDE Feed is an ID reference + (ie a reference to the object and not the object itself)`, + message: 'For {{rpdeKind}} feeds, {{field}} must be not an compact ID reference, but the object representing the data itself', + sampleValues: { + rpdeKind: 'ScheduledSession.SessionSeries', + field: 'superEvent', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: validationErrorType.FIELD_SHOUlD_NOT_BE_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 shouldNotBeReferencedFields = node.model.getShallNotBeReferencedFields(node.options.validationMode, { rpdeKind: node.options.rpdeKind }); + for (const field of shouldNotBeReferencedFields) { + const fieldValue = node.getValue(field); + + if (!_.isPlainObject(fieldValue)) { + errors.push( + this.createError( + 'default', + { + fieldValue, + path: node.getPath(field), + }, + { field }, + ), + ); + } + } + + return errors; + } +} + +module.exports = NoIdReferencesForCertainFeedsRule; diff --git a/src/rules/core/no-id-references-for-responses-rule-spec.js b/src/rules/core/no-id-references-for-responses-rule-spec.js new file mode 100644 index 0000000..ae41d45 --- /dev/null +++ b/src/rules/core/no-id-references-for-responses-rule-spec.js @@ -0,0 +1,153 @@ +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const OptionsHelper = require('../../helpers/options'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); +const NoIdReferencesForResponsesRule = require('./no-id-references-for-responses-rule'); + + +describe('NoIdReferencesForResponsesRule', () => { + const rule = new NoIdReferencesForResponsesRule(); + + const model = new Model({ + type: 'OrderItem', + validationMode: { + C1Request: 'request', + C1Response: 'Cresponse', + }, + imperativeConfiguration: { + request: { + requiredFields: [ + 'type', + 'acceptedOffer', + 'orderedItem', + 'position', + ], + recommendedFields: [], + shallNotInclude: [ + 'id', + 'orderItemStatus', + 'unitTaxSpecification', + 'accessCode', + 'error', + 'cancellationMessage', + 'customerNotice', + 'orderItemIntakeForm', + ], + requiredOptions: [], + referencedFields: [ + 'orderedItem', + 'acceptedOffer', + ], + }, + Cresponse: { + requiredFields: [ + 'type', + 'acceptedOffer', + 'orderedItem', + 'position', + ], + shallNotInclude: [ + 'id', + 'orderItemStatus', + 'cancellationMessage', + 'customerNotice', + 'accessCode', + 'accessPass', + 'error', + ], + requiredOptions: [], + shallNotBeReferencedFields: [ + 'orderedItem', + 'acceptedOffer', + ], + }, + }, + }, 'latest'); + model.hasSpecification = true; + + it('should return a failure if a response object does not have `acceptedOffer` as a data object', async () => { + const options = new OptionsHelper({ validationMode: 'C1Response' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + acceptedOffer: 'https://example.com/offer/1', + orderedItem: { + '@id': 'https://example.com/item/1', + }, + }; + + 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_SHOUlD_NOT_BE_ID_REFERENCE); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + }); + it('should return a failure if a response object does not have `orderedItem` as a data object', async () => { + const options = new OptionsHelper({ validationMode: 'C1Response' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + orderedItem: 'https://example.com/session/1', + acceptedOffer: { + '@id': 'https://example.com/offer/1', + }, + }; + + 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_SHOUlD_NOT_BE_ID_REFERENCE); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + }); + it('should return no errors if a request object has `acceptedOffer` as a compact ID reference', async () => { + const options = new OptionsHelper({ validationMode: 'C1Request' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + acceptedOffer: 'https://example.com/offer/1', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + }); + it('should return no errors if a request object has `orderedItem` as a compact ID reference', async () => { + const options = new OptionsHelper({ validationMode: 'C1Request' }); + const data = { + '@context': 'https://openactive.io/', + '@type': 'OrderItem', + orderedItem: 'https://example.com/session/1', + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + }); +}); diff --git a/src/rules/core/no-id-references-for-responses-rule.js b/src/rules/core/no-id-references-for-responses-rule.js new file mode 100644 index 0000000..4883d3f --- /dev/null +++ b/src/rules/core/no-id-references-for-responses-rule.js @@ -0,0 +1,72 @@ +const _ = require('lodash'); +const Rule = require('../rule'); +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 NoIdReferencesForResponsesRule extends Rule { + constructor(options) { + super(options); + this.targetValidationModes = [ + 'C1Response', + 'C2Response', + 'PResponse', + 'BResponse', + 'OrdersFeed', + 'OrderStatus', + ]; + this.targetModels = '*'; + this.meta = { + name: 'NoIdReferencesForResponsesRule', + description: 'Validates that acceptedOffer and orderedItem are not ID references and are objects for responses (C1, C2 etc)', + tests: { + default: { + description: `Raises a failure if the acceptedOffer or orderedItem within the OrderItem of a response is a URL + (ie a reference to the object and not the object itself)`, + message: 'For responses, {{field}} must not be a compact ID reference, but the object representing the data itself', + sampleValues: { + field: 'acceptedOffer', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: validationErrorType.FIELD_SHOUlD_NOT_BE_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 shouldNotBeReferencedFields = node.model.getShallNotBeReferencedFields(node.options.validationMode, { rpdeKind: node.options.rpdeKind }); + for (const field of shouldNotBeReferencedFields) { + const fieldValue = node.getValue(field); + + if (!_.isPlainObject(fieldValue)) { + errors.push( + this.createError( + 'default', + { + fieldValue, + path: node.getPath(field), + }, + { field }, + ), + ); + } + } + + return errors; + } +} + +module.exports = NoIdReferencesForResponsesRule; diff --git a/src/rules/index.js b/src/rules/index.js index a7d0e3b..07e145b 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -23,6 +23,10 @@ module.exports = { require('./core/valueconstraint-rule'), require('./core/minvalueinclusive-rule'), require('./core/id-rule'), + require('./core/id-references-for-certain-feeds-rule'), + require('./core/no-id-references-for-certain-feeds-rule'), + require('./core/id-references-for-requests-rule'), + require('./core/no-id-references-for-responses-rule'), // Formatting rules require('./format/duration-format-rule'), diff --git a/src/rules/rule.js b/src/rules/rule.js index fe9cd21..024a8a4 100644 --- a/src/rules/rule.js +++ b/src/rules/rule.js @@ -8,6 +8,9 @@ class Rule { this.targetModels = []; this.targetFields = {}; this.targetValidationModes = '*'; + // This option is used to target a specific RPDE feed. It is only read if the validation mode for this rule is + // either 'RPDEFeed' or 'BookableRPDEFeed'. + this.targetRpdeKinds = null; this.meta = { name: 'Rule', description: 'This is a base rule description that should be overridden.', @@ -18,7 +21,7 @@ class Rule { async validate(nodeToTest) { let errors = []; - if (!this.isValidationModeTargeted(nodeToTest.options.validationMode)) { + if (!this.isValidationModeTargeted(nodeToTest.options.validationMode, nodeToTest.options.rpdeKind)) { return errors; } @@ -126,11 +129,17 @@ class Rule { return false; } - isValidationModeTargeted(validationMode) { + isValidationModeTargeted(validationMode, rpdeKind) { if (this.targetValidationModes === '*') return true; if (this.targetValidationModes instanceof Array) { + if (validationMode === 'RPDEFeed' || validationMode === 'BookableRPDEFeed') { + if (this.targetRpdeKinds) { + if (this.targetRpdeKinds === '*') return true; + return this.targetRpdeKinds.includes(rpdeKind); + } + } return this.targetValidationModes.includes(validationMode); }