diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c0e2545..4b2b2731 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 ead74cf3..ea7afdf3 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,13 @@ }, "license": "MIT", "dependencies": { - "@openactive/data-models": "^2.0.219", + "@openactive/data-models": "^2.0.289", + "@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 45c3d29b..ef6be9db 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 7e31434e..0ce48fdc 100644 --- a/src/classes/model.js +++ b/src/classes/model.js @@ -120,6 +120,28 @@ const Model = class { return this.data.shallNotInclude || []; } + getReferencedFields(validationMode, containingFieldName) { + const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName); + 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) { + const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName); + 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 29263eef..15246aed 100644 --- a/src/errors/validation-error-type.js +++ b/src/errors/validation-error-type.js @@ -44,6 +44,8 @@ const ValidationErrorType = { BELOW_MIN_VALUE_INCLUSIVE: 'below_min_value_inclusive', VALUE_OUTWITH_CONSTRAINT: 'value_outwith_constraint', INVALID_ID: 'invalid_id', + FIELD_MUST_BE_ID_REFERENCE: 'FIELD_MUST_BE_ID_REFERENCE', + FIELD_MUST_NOT_BE_ID_REFERENCE: 'FIELD_MUST_NOT_BE_ID_REFERENCE', }; module.exports = Object.freeze(ValidationErrorType); diff --git a/src/rules/booking/booking-root-type-correct-rule.js b/src/rules/booking/booking-root-type-correct-rule.js index e4589e9f..3d4be3b0 100644 --- a/src/rules/booking/booking-root-type-correct-rule.js +++ b/src/rules/booking/booking-root-type-correct-rule.js @@ -10,13 +10,17 @@ module.exports = class BookingRootTypeCorrectRule extends Rule { this.targetValidationModes = [ 'C1Request', 'C1Response', + 'C1ResponseOrderItemError', 'C2Request', 'C2Response', + 'C2ResponseOrderItemError', 'PRequest', 'PResponse', + 'PResponseOrderItemError', 'BRequest', 'BOrderProposalRequest', 'BResponse', + 'BResponseOrderItemError', 'OrderProposalPatch', 'OrderPatch', 'OrdersFeed', diff --git a/src/rules/consumer-notes/assume-age-range-rule.js b/src/rules/consumer-notes/assume-age-range-rule.js index 348d8b0f..38a0fd29 100644 --- a/src/rules/consumer-notes/assume-age-range-rule.js +++ b/src/rules/consumer-notes/assume-age-range-rule.js @@ -11,9 +11,13 @@ module.exports = class AssumeAgeRangeRule extends Rule { 'RPDEFeed', 'BookableRPDEFeed', 'C1Response', + 'C1ResponseOrderItemError', 'C2Response', + 'C2ResponseOrderItemError', 'PResponse', + 'PResponseOrderItemError', 'BResponse', + 'BResponseOrderItemError', ]; this.meta = { name: 'AssumeAgeRangeRule', diff --git a/src/rules/consumer-notes/assume-event-status-rule.js b/src/rules/consumer-notes/assume-event-status-rule.js index 0890e376..3223970a 100644 --- a/src/rules/consumer-notes/assume-event-status-rule.js +++ b/src/rules/consumer-notes/assume-event-status-rule.js @@ -11,9 +11,13 @@ module.exports = class AssumeEventStatusRule extends Rule { 'RPDEFeed', 'BookableRPDEFeed', 'C1Response', + 'C1ResponseOrderItemError', 'C2Response', + 'C2ResponseOrderItemError', 'PResponse', + 'PResponseOrderItemError', 'BResponse', + 'BResponseOrderItemError', ]; this.meta = { name: 'AssumeEventStatusRule', diff --git a/src/rules/consumer-notes/assume-no-gender-restriction-rule.js b/src/rules/consumer-notes/assume-no-gender-restriction-rule.js index da29ec98..a53b76cc 100644 --- a/src/rules/consumer-notes/assume-no-gender-restriction-rule.js +++ b/src/rules/consumer-notes/assume-no-gender-restriction-rule.js @@ -11,9 +11,13 @@ module.exports = class AssumeNoGenderRestrictionRule extends Rule { 'RPDEFeed', 'BookableRPDEFeed', 'C1Response', + 'C1ResponseOrderItemError', 'C2Response', + 'C2ResponseOrderItemError', 'PResponse', + 'PResponseOrderItemError', 'BResponse', + 'BResponseOrderItemError', ]; this.meta = { name: 'AssumeNoGenderRestrictionRule', diff --git a/src/rules/core/fields-correct-type-rule.js b/src/rules/core/fields-correct-type-rule.js index 7917d4a6..885ed715 100644 --- a/src/rules/core/fields-correct-type-rule.js +++ b/src/rules/core/fields-correct-type-rule.js @@ -21,11 +21,11 @@ module.exports = class FieldsCorrectTypeRule extends Rule { }, singleType: { description: 'Validates that a property conforms to a single type.', - message: 'Invalid type, expected {{expectedType}} but found {{foundType}}.{{examples}}', + message: 'Invalid type, expected {{expectedType}}{{idReferencingMessage}} but found {{foundType}}.{{examples}}', sampleValues: { expectedType: this.constructor.getHumanReadableType('https://schema.org/Text'), foundType: this.constructor.getHumanReadableType('https://schema.org/Number'), - examples: this.constructor.makeExamples('property', ['https://schema.org/Text'], this.options.version), + examples: this.constructor.makeExamples('property', ['https://schema.org/Text'], this.options.version, true), }, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, @@ -37,7 +37,7 @@ module.exports = class FieldsCorrectTypeRule extends Rule { sampleValues: { expectedType: this.constructor.getHumanReadableType('LocationFeatureSpecification'), foundType: this.constructor.getHumanReadableType('ChangingRooms'), - examples: this.constructor.makeExamples('property', ['LocationFeatureSpecification'], this.options.version), + examples: this.constructor.makeExamples('property', ['LocationFeatureSpecification'], this.options.version, true), }, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, @@ -49,7 +49,7 @@ module.exports = class FieldsCorrectTypeRule extends Rule { sampleValues: { expectedType: this.constructor.getHumanReadableType('LocationFeatureSpecification'), foundTypes: this.constructor.makeExpectedTypeList(['ChangingRooms', 'GolfCourse']), - examples: this.constructor.makeExamples('property', ['LocationFeatureSpecification'], this.options.version), + examples: this.constructor.makeExamples('property', ['LocationFeatureSpecification'], this.options.version, true), }, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, @@ -57,11 +57,11 @@ module.exports = class FieldsCorrectTypeRule extends Rule { }, multipleTypes: { description: 'Validates that a property conforms one of a list of types.', - message: 'Invalid type, expected one of {{expectedTypes}} but found {{foundType}}.{{examples}}', + message: 'Invalid type, expected one of {{expectedTypes}}{{idReferencingMessage}} but found {{foundType}}.{{examples}}', sampleValues: { expectedTypes: this.constructor.makeExpectedTypeList(['https://schema.org/Text', 'ArrayOf#https://schema.org/Text', '#Concept', 'ArrayOf#Concept']), foundType: this.constructor.getHumanReadableType('https://schema.org/Number'), - examples: this.constructor.makeExamples('property', ['https://schema.org/Text', 'ArrayOf#https://schema.org/Text', '#Concept', 'ArrayOf#Concept'], this.options.version), + examples: this.constructor.makeExamples('property', ['https://schema.org/Text', 'ArrayOf#https://schema.org/Text', '#Concept', 'ArrayOf#Concept'], this.options.version, true), }, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, @@ -109,6 +109,8 @@ module.exports = class FieldsCorrectTypeRule extends Rule { return `[\`string${plural}\` containing the URL of a property](${type}) from the [OpenActive](https://openactive.io/ns) or [schema.org](https://schema.org/) vocabularies`; case 'https://schema.org/URL': return `[\`string${plural}\` containing a url](${type})`; + case 'https://openactive.io/IdReference': + return '[`@id` reference](https://permalink.openactive.io/data-model-validator/id-references)'; default: return `\`${type.replace(/^#/, '')}\``; } @@ -125,7 +127,7 @@ module.exports = class FieldsCorrectTypeRule extends Rule { } const humanReadableType = this.getHumanReadableRawType(readableType, isArray); let aOrAn = 'A'; - if (isArray || humanReadableType.match(/^\[?`[aeiouAEIOU]/)) { + if (isArray || humanReadableType.match(/^\[?`[aeiouAEIOU`]/)) { aOrAn = 'An'; } const hint = `${aOrAn} ${isArray ? 'array of ' : ''}${humanReadableType} looks like this:`; @@ -162,6 +164,9 @@ module.exports = class FieldsCorrectTypeRule extends Rule { case 'https://schema.org/URL': example = `${prefix}"https://www.example.org/"`; break; + case 'https://openactive.io/IdReference': + example = `${prefix}"https://id.example.com/api/session-series/1402CBP20150217"`; + break; default: if (PropertyHelper.isEnum(readableType, version)) { const allowedOptions = PropertyHelper.getEnumOptions(readableType, version); @@ -185,7 +190,7 @@ module.exports = class FieldsCorrectTypeRule extends Rule { return `${expectedTypes}`; } - static makeExamples(property, types, version, renderedExample) { + static makeExamples(property, types, version, renderedExample, allowReferencing) { let examples = ''; for (const type of types) { examples = `${examples}\n\n${this.getHumanReadableExample(property, type, version)}`; @@ -194,6 +199,7 @@ module.exports = class FieldsCorrectTypeRule extends Rule { const hint = types.length > 1 ? 'A full example of the preferred approach looks like this:' : 'A full example looks like this:'; examples = `${examples}\n\n${hint}\n\n${renderedExample}`; } + if (allowReferencing) examples = `${examples}\n\nA URI reference which matches the \`@id\` of another object may also be used in place of the object itself.${this.getHumanReadableExample(property, 'https://openactive.io/IdReference', version)}`; return examples; } @@ -241,6 +247,8 @@ module.exports = class FieldsCorrectTypeRule extends Rule { // Pass check if referencing via a URL that matches an @id elsewhere is allowed, and in use || (fieldObj.allowReferencing && typeof fieldValue === 'string' && PropertyHelper.isUrl(fieldValue)); + const idReferencingMessage = fieldObj.allowReferencing ? ' or a reference URI to an `@id`' : ''; + if (!checkPass) { let testKey; let messageValues = {}; @@ -280,14 +288,14 @@ module.exports = class FieldsCorrectTypeRule extends Rule { messageValues = { expectedType: this.constructor.getHumanReadableType(typeChecks[0]), foundType: this.constructor.getHumanReadableType(notAllowed[0]), - examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample()), + examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample(), fieldObj.allowReferencing), }; } else if (notAllowed.length > 1) { testKey = 'singleTypeSubclassMultipleError'; messageValues = { expectedType: this.constructor.getHumanReadableType(typeChecks[0]), foundTypes: this.constructor.makeExpectedTypeList(notAllowed), - examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample()), + examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample(), fieldObj.allowReferencing), }; } } else { @@ -295,7 +303,8 @@ module.exports = class FieldsCorrectTypeRule extends Rule { messageValues = { expectedType: this.constructor.getHumanReadableType(typeChecks[0]), foundType: this.constructor.getHumanReadableType(derivedType), - examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample()), + examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample(), fieldObj.allowReferencing), + idReferencingMessage, }; } } else { @@ -304,7 +313,8 @@ module.exports = class FieldsCorrectTypeRule extends Rule { messageValues = { expectedTypes, foundType: this.constructor.getHumanReadableType(derivedType), - examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample()), + examples: this.constructor.makeExamples(propName, typeChecks, node.options.version, fieldObj.getRenderedExample(), fieldObj.allowReferencing), + idReferencingMessage, }; } errors.push( diff --git a/src/rules/core/id-references-not-permitted-rule-spec.js b/src/rules/core/id-references-not-permitted-rule-spec.js new file mode 100644 index 00000000..4afaafba --- /dev/null +++ b/src/rules/core/id-references-not-permitted-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 IdReferencesNotPermittedRule = require('./id-references-not-permitted-rule'); + + +describe('IdReferencesNotPermittedRule', () => { + const rule = new IdReferencesNotPermittedRule(); + + 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_MUST_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_MUST_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/id-references-not-permitted-rule.js b/src/rules/core/id-references-not-permitted-rule.js new file mode 100644 index 00000000..a824b3af --- /dev/null +++ b/src/rules/core/id-references-not-permitted-rule.js @@ -0,0 +1,81 @@ +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 IdReferencesNotPermittedRule extends Rule { + constructor(options) { + super(options); + this.targetValidationModes = [ + 'C1Request', + 'C1Response', + 'C1ResponseOrderItemError', + 'C2Request', + 'C2Response', + 'C2ResponseOrderItemError', + 'PRequest', + 'PResponse', + 'PResponseOrderItemError', + 'BRequest', + 'BOrderProposalRequest', + 'BResponse', + 'BResponseOrderItemError', + 'OrderProposalPatch', + 'OrderPatch', + 'OrdersFeed', + 'OrderStatus', + ]; + this.targetModels = '*'; + this.meta = { + name: 'IdReferencesNotPermittedRule', + description: 'Validates that ID references are not used where not permitted', + tests: { + default: { + description: 'Raises a failure if the value of a property is a URL (i.e. it is a reference to the object and not the object itself)', + message: 'In this validation mode `{{field}}` must be an object representing the data itself, not a compact `@id` reference or string', + sampleValues: { + field: 'acceptedOffer', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: validationErrorType.FIELD_MUST_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, node.name); + for (const field of shouldNotBeReferencedFields) { + const fieldValue = node.getValue(field); + + if (typeof fieldValue === 'string') { + errors.push( + this.createError( + 'default', + { + fieldValue, + path: node.getPath(field), + }, + { field }, + ), + ); + } + } + + return errors; + } +} + +module.exports = IdReferencesNotPermittedRule; diff --git a/src/rules/core/id-references-required-rule-spec.js b/src/rules/core/id-references-required-rule-spec.js new file mode 100644 index 00000000..4b8e362c --- /dev/null +++ b/src/rules/core/id-references-required-rule-spec.js @@ -0,0 +1,90 @@ +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 IdReferencesRequiredRule = require('./id-references-required-rule'); + + +describe('IdReferencesRequiredRule', () => { + const rule = new IdReferencesRequiredRule(); + + 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_MUST_BE_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_MUST_BE_ID_REFERENCE); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + }); + 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-required-rule.js b/src/rules/core/id-references-required-rule.js new file mode 100644 index 00000000..024b2bda --- /dev/null +++ b/src/rules/core/id-references-required-rule.js @@ -0,0 +1,82 @@ +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 IdReferencesRequiredRule extends Rule { + constructor(options) { + super(options); + this.targetValidationModes = [ + 'C1Request', + 'C1Response', + 'C1ResponseOrderItemError', + 'C2Request', + 'C2Response', + 'C2ResponseOrderItemError', + 'PRequest', + 'PResponse', + 'PResponseOrderItemError', + 'BRequest', + 'BOrderProposalRequest', + 'BResponse', + 'BResponseOrderItemError', + 'OrderProposalPatch', + 'OrderPatch', + 'OrdersFeed', + 'OrderStatus', + ]; + this.targetModels = '*'; + this.meta = { + name: 'IdReferencesRequiredRule', + description: 'Validates that ID references are used where permitted', + tests: { + default: { + description: 'Raises a failure if the value of a property is not a URL (i.e. it is the object itself, not a reference to the object)', + message: 'In this validation mode `{{field}}` must be a compact [`@id` reference](https://permalink.openactive.io/data-model-validator/id-references), not the object representing the data itself. An `@id` reference looks like this:\n\n```\n"{{field}}": "https://id.example.com/api/session-series/1402CBP20150217"\n```', + sampleValues: { + field: 'acceptedOffer', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: validationErrorType.FIELD_MUST_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 referencedFields = node.model.getReferencedFields(node.options.validationMode, node.name); + 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 = IdReferencesRequiredRule; diff --git a/src/rules/data-quality/event-remaining-attendee-capacity-rule-spec.js b/src/rules/data-quality/event-remaining-attendee-capacity-rule-spec.js deleted file mode 100644 index 620ae55f..00000000 --- a/src/rules/data-quality/event-remaining-attendee-capacity-rule-spec.js +++ /dev/null @@ -1,81 +0,0 @@ -const EventRemainingAttendeeCapacityRule = require('./event-remaining-attendee-capacity-rule'); -const Model = require('../../classes/model'); -const ModelNode = require('../../classes/model-node'); -const ValidationErrorType = require('../../errors/validation-error-type'); -const ValidationErrorSeverity = require('../../errors/validation-error-severity'); -const OptionsHelper = require('../../helpers/options'); - -describe('EventRemainingAttendeeCapacityRule', () => { - const rule = new EventRemainingAttendeeCapacityRule(); - - const model = new Model({ - type: 'Event', - fields: { - remainingAttendeeCapacity: { - fieldName: 'remainingAttendeeCapacity', - requiredType: 'https://schema.org/Integer', - }, - }, - }, 'latest'); - - it('should target remainingAttendeeCapacity fields', () => { - const isTargeted = rule.isFieldTargeted(model, 'remainingAttendeeCapacity'); - expect(isTargeted).toBe(true); - }); - - describe('isValidationModeTargeted', () => { - const modesToTest = ['C1Response', 'C2Response', 'PResponse', 'BResponse']; - - for (const mode of modesToTest) { - it(`should target ${mode}`, () => { - const isTargeted = rule.isValidationModeTargeted(mode); - expect(isTargeted).toBe(true); - }); - } - - it('should not target RPDEFeed validation mode', () => { - const isTargeted = rule.isValidationModeTargeted('RPDEFeed'); - expect(isTargeted).toBe(false); - }); - }); - - describe('when in a booking mode like C1Response', () => { - const options = new OptionsHelper({ validationMode: 'C1Response' }); - - it('should return no error when remainingAttendeeCapacity is > 0', async () => { - const data = { - '@type': 'Event', - remainingAttendeeCapacity: 1, - }; - - const nodeToTest = new ModelNode( - '$', - data, - null, - model, - options, - ); - const errors = await rule.validate(nodeToTest); - expect(errors.length).toBe(0); - }); - - it('should return no error when remainingAttendeeCapacity is < 0', async () => { - const data = { - '@type': 'Event', - remainingAttendeeCapacity: -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_IN_DEFINED_VALUES); - expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); - }); - }); -}); diff --git a/src/rules/data-quality/event-remaining-attendee-capacity-rule.js b/src/rules/data-quality/event-remaining-attendee-capacity-rule.js deleted file mode 100644 index beea33ea..00000000 --- a/src/rules/data-quality/event-remaining-attendee-capacity-rule.js +++ /dev/null @@ -1,49 +0,0 @@ -const Rule = require('../rule'); -const ValidationErrorType = require('../../errors/validation-error-type'); -const ValidationErrorCategory = require('../../errors/validation-error-category'); -const ValidationErrorSeverity = require('../../errors/validation-error-severity'); - -module.exports = class EventRemainingAttendeeCapacityRule extends Rule { - constructor(options) { - super(options); - this.targetFields = { Event: ['remainingAttendeeCapacity'] }; - this.targetValidationModes = [ - 'C1Response', - 'C2Response', - 'PResponse', - 'BResponse', - ]; - this.meta = { - name: 'EventRemainingAttendeeCapacityRule', - description: 'Validates that the remainingAttendeeCapacity of an Event is greater than or equal to 0', - tests: { - default: { - description: 'Raises a failure if the remainingAttendeeCapacity of an Event is not greater than or equal to 0', - message: 'The `remainingAttendeeCapacity` of an `Event` must be greater than or equal to 0.', - category: ValidationErrorCategory.DATA_QUALITY, - severity: ValidationErrorSeverity.FAILURE, - type: ValidationErrorType.FIELD_NOT_IN_DEFINED_VALUES, - }, - }, - }; - } - - validateField(node, field) { - const errors = []; - - const fieldValue = node.getValue(field); - - if (fieldValue < 0) { - errors.push( - this.createError( - 'default', - { - path: node.getPath(field), - }, - ), - ); - } - - return errors; - } -}; diff --git a/src/rules/data-quality/scheduled-session-must-be-subevent-rule.js b/src/rules/data-quality/scheduled-session-must-be-subevent-rule.js index 438f6934..cb2fd242 100644 --- a/src/rules/data-quality/scheduled-session-must-be-subevent-rule.js +++ b/src/rules/data-quality/scheduled-session-must-be-subevent-rule.js @@ -11,9 +11,13 @@ module.exports = class ScheduledSessionMustBeSubeventRule extends Rule { 'RPDEFeed', 'BookableRPDEFeed', 'C1Response', + 'C1ResponseOrderItemError', 'C2Response', + 'C2ResponseOrderItemError', 'PResponse', + 'PResponseOrderItemError', 'BResponse', + 'BResponseOrderItemError', ]; this.meta = { name: 'ScheduledSessionMustBeSubeventRule', diff --git a/src/rules/index.js b/src/rules/index.js index 11d9a3fe..51ce9d6d 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -24,6 +24,8 @@ module.exports = { require('./core/valueconstraint-rule'), require('./core/minvalueinclusive-rule'), require('./core/id-rule'), + require('./core/id-references-required-rule'), + require('./core/id-references-not-permitted-rule'), // Formatting rules require('./format/duration-format-rule'), @@ -56,7 +58,6 @@ module.exports = { require('./data-quality/session-series-schedule-type-rule'), require('./data-quality/currency-if-non-zero-price-rule'), require('./data-quality/if-needs-booking-must-have-valid-offer-rule'), - require('./data-quality/event-remaining-attendee-capacity-rule'), require('./data-quality/available-channel-for-prepayment-rule'), // Notes on the data consumer