diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..7b40b1f8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + open-pull-requests-limit: 20 + schedule: + interval: "daily" + time: "09:00" + timezone: "Europe/London" + ignore: + - dependency-name: "@openactive/*" + groups: + eslint: + dependency-type: "development" + patterns: + - "eslint*" + jasmine: + dependency-type: "development" + patterns: + - "jasmine*" diff --git a/.github/workflows/eslint-auto-update.yml b/.github/workflows/eslint-auto-update.yml new file mode 100644 index 00000000..df1095ff --- /dev/null +++ b/.github/workflows/eslint-auto-update.yml @@ -0,0 +1,92 @@ +name: ESLint Fix +on: pull_request + +permissions: + contents: write + pull-requests: write + +jobs: + # Has dependabot created this PR? + dependabot-metadata: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + outputs: + dependency-group: ${{ steps.metadata.outputs.dependency-group }} + steps: + - name: Dependabot metadata + id: metadata + uses: dependabot/fetch-metadata@v1 + with: + github-token: "${{ secrets.GITHUB_TOKEN }}" + # Is this PR an eslint upgrade? + lint-test: + runs-on: ubuntu-latest + needs: dependabot-metadata + if: ${{ needs.dependabot-metadata.outputs.dependency-group == 'eslint' }} + outputs: + outcome: ${{ steps.lint-test.outcome }} + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Use Node.js 14.x + uses: actions/setup-node@v1 + with: + node-version: 14 + - name: Install + run: npm ci + - name: Lint test + id: lint-test + run: npm run lint + # Has the eslint upgrade resulted in lint errors? If so, create a PR to attempt a lint --fix, in case they can be automatically resolved + create-pr: + runs-on: ubuntu-latest + needs: lint-test + if: ${{ always() && needs.lint-test.outputs.outcome == 'failure' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.ref }} + - name: Use Node.js 14.x + uses: actions/setup-node@v1 + with: + node-version: 14 + - name: Install + run: npm ci + - name: Lint Fix + run: npm run lint-fix + - name: git reset to include the version bumps in the PR + run: git reset HEAD~1 + - name: Create Pull Request + id: cpr + uses: peter-evans/create-pull-request@v5 + with: + path: ./ + token: ${{ secrets.DEPENDABOT_PUBLIC_REPO_ACCESS_TOKEN }} + commit-message: ESLint --fix + committer: openactive-bot + author: openactive-bot + signoff: false + branch: ci/eslint + base: master + delete-branch: true + title: 'ESLint fix' + body: | + Lint fixes based on the latest version of ESLint. + labels: | + automated pr + draft: false + - name: Auto-approve PR + uses: hmarr/auto-approve-action@v3 + with: + pull-request-number: ${{ steps.cpr.outputs.pull-request-number }} + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge "$PR_URL" --auto --body "" --squash + env: + PR_URL: ${{ steps.cpr.outputs.pull-request-url }} + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Check outputs + run: | + echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" + echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml new file mode 100644 index 00000000..c552371e --- /dev/null +++ b/.github/workflows/npm-test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Use Node.js 14.x + uses: actions/setup-node@v1 + with: + node-version: 14 + - name: Install + run: npm install + - name: Test + run: npm test diff --git a/.github/workflows/publish-to-npm.yaml b/.github/workflows/publish-to-npm.yaml new file mode 100644 index 00000000..24213b48 --- /dev/null +++ b/.github/workflows/publish-to-npm.yaml @@ -0,0 +1,51 @@ +name: Publish to npm + +on: + push: + branches: [ master ] + +jobs: + publish: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.author.email, 'hello@openactive.io')" + + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + token: ${{ secrets.PUBLIC_REPO_ACCESS_TOKEN }} + - name: Identify + run: | + git config user.name OpenActive Bot + git config user.email hello@openactive.io + - name: Use Node.js 14.x + uses: actions/setup-node@v1 + with: + node-version: 14 + registry-url: https://registry.npmjs.org/ + - name: Install + run: npm install + - name: Test + run: npm test + - name: Increment Version + run: npm version patch + - name: Publish to npm + run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + - name: Push version update + run: git push + + dispatch: + needs: publish + strategy: + matrix: + repo: ['data-model-validator-site', 'openactive-test-suite'] + runs-on: ubuntu-latest + steps: + - name: Trigger tooling update + uses: peter-evans/repository-dispatch@v1 + with: + token: ${{ secrets.PUBLIC_REPO_ACCESS_TOKEN }} + repository: openactive/${{ matrix.repo }} + event-type: data-model-validator-update diff --git a/.gitignore b/.gitignore index 79b6d43b..bcebf0be 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,6 @@ typings/ # IDE .vscode + +# Validator cache +tmp \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 0c6886ca..158c0064 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v12.16.3 +v14.16.0 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 60548818..00000000 --- a/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: node_js - -node_js: - - "12" - -sudo: false \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17a2424b..f092d27c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,8 +69,8 @@ The rule name you create should: **OR** -* Set `this.targetFields` to an object map of the fields you are targeting in each model, or a string `'*''` wildcard. Setting the property to `null` means that the rule will be applied once to the whole model. If you target a model, you **MUST** implement `validateField`. - +* Set `this.targetFields` to an object map of the fields you are targeting in each model, or a string `'*''` wildcard. Setting the property to `null` means that the rule will be applied once to the whole model. If you target a field, you **MUST** implement `validateField`. +field Generally speaking, you **SHOULD NOT** implement both `validateModel` and `validateField` in the same rule. Independently, a rule can also target particular modes of use. It is used to restrict rules which should only apply during a particular usage of the models (e.g. an Order used during one of the booking phases - C1Request, C2Response or PatchOrder). By default, a rule will target all modes. @@ -129,7 +129,7 @@ this.targetValidationModes = ['C1Request', 'C2Response']; Set `this.meta` to explain what the rule is testing for. -Defining this detail here makes it easier for libaries scrape all of the rules that the validator will run. +Defining this detail here makes it easier for libaries to scrape all of the rules that the validator will run. This meta object should include: @@ -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. @@ -247,6 +247,7 @@ this.createError( #### Adding to the core library * You should write a test for your rule. +* Add the rule's file, as well as its test file to tsconfig.json's `include` array, so that TypeScript can check for errors. * Add the rule to the list in `rules/index`, so that it is processed. #### Adding to your own application diff --git a/README.md b/README.md index 16ee6889..38881141 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The OpenActive data model validator library. -[![Build Status](https://travis-ci.com/openactive/data-model-validator.svg?branch=master)](https://travis-ci.org/openactive/data-model-validator) +[![Tests](https://github.com/openactive/data-model-validator/actions/workflows/npm-test.yml/badge.svg?branch=master)](https://github.com/openactive/data-model-validator/actions/workflows/npm-test.yml) [![Known Vulnerabilities](https://snyk.io/test/github/openactive/data-model-validator/badge.svg)](https://snyk.io/test/github/openactive/data-model-validator) ## Introduction @@ -26,16 +26,16 @@ const { validate } = require('@openactive/data-model-validator'); const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', name: 'Tai chi Class', url: 'http://www.example.org/events/1', startDate: '2017-03-22T20:00:00', activity: 'Tai Chi', location: { - type: 'Place', + '@type': 'Place', name: 'ExampleCo Gym', address: { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1 High Street', addressLocality: 'Bristol', postalCode: 'BS1 4SD' @@ -146,7 +146,7 @@ const result = await validate(feed, options); #### type -The validator will detect the type of the model being validated from the `type` property. You can override this by providing a type option. +The validator will detect the type of the model being validated from the `@type` property. You can override this by providing a `type` option. e.g. @@ -229,10 +229,10 @@ To run tests locally, run: $ npm test ``` -The test run will also include a run of [eslint](https://eslint.org/). To run the tests without these, use: +The test run will also include a run of [eslint](https://eslint.org/) and [TypeScript](https://www.typescriptlang.org/). To run the tests without these, use: ```shell -$ npm run test-no-lint +$ npm run run-tests ``` ### Contributing diff --git a/package.json b/package.json index 75bcbeb0..2a28bfdc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openactive/data-model-validator", - "version": "2.0.39", + "version": "2.0.83", "description": "A library to allow a developer to validate a JSON document against the OpenActive Modelling Opportunity Specification", "homepage": "https://openactive.io", "author": "OpenActive Community ", @@ -12,7 +12,7 @@ ], "main": "src/index.js", "engines": { - "node": "12.16.3" + "node": "14.16.0" }, "repository": { "type": "git", @@ -23,11 +23,13 @@ }, "license": "MIT", "dependencies": { - "@openactive/data-models": "^2.0.127", + "@openactive/data-models": "^2.0.294", + "@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", @@ -36,18 +38,23 @@ "write-file-atomic": "^3.0.3" }, "devDependencies": { - "eslint": "^5.16.0", - "eslint-config-airbnb": "^17.1.1", - "eslint-plugin-import": "^2.20.0", - "jasmine": "^3.5.0", - "nock": "^10.0.6" + "@types/jasmine": "^5.1.4", + "@types/jasmine-expect": "^3.8.1", + "@types/node": "^20.11.24", + "eslint": "^8.36.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-plugin-import": "^2.27.5", + "jasmine": "^4.6.0", + "nock": "^13.3.0", + "sync-request": "^6.1.0", + "typescript": "^5.3.3" }, "scripts": { "lint": "eslint \"src/**/*.js\"", "lint-fix": "eslint \"src/**/*.js\" --fix", - "pretest": "npm run lint", - "test": "npm run test-no-lint", - "test-no-lint": "jasmine", + "pretest": "npm run lint && tsc", + "test": "npm run run-tests", + "run-tests": "jasmine", "test-debug": "node --inspect-brk -i ./node_modules/jasmine/bin/jasmine.js", "postpublish": "git push", "publish-patch": "npm test && git pull && git push && npm version patch && npm publish" diff --git a/src/classes/field.js b/src/classes/field.js index 4524ee25..0a265b91 100644 --- a/src/classes/field.js +++ b/src/classes/field.js @@ -1,8 +1,8 @@ -const UriTemplate = require('uritemplate'); const DataModelHelper = require('../helpers/data-model'); const PropertyHelper = require('../helpers/property'); const Field = class { + // eslint-disable-next-line default-param-last constructor(data = {}, version) { this.data = data; this.version = version; @@ -56,6 +56,18 @@ const Field = class { return this.data.maxDecimalPlaces; } + get minValueInclusive() { + return this.data.minValueInclusive; + } + + get allowReferencing() { + return this.data.allowReferencing; + } + + get valueConstraint() { + return this.data.valueConstraint; + } + get standard() { return this.data.standard; } @@ -161,18 +173,11 @@ const Field = class { } if (!isEnum) { // Is this a URL template? - // This processes most strings... so could be a bit intensive - const template = UriTemplate.parse(data); - let isUrlTemplate = false; - for (const expression of template.expressions) { - if (expression.constructor.name === 'VariableExpression') { - isUrlTemplate = true; - break; - } - } - if (isUrlTemplate) { + if (this.valueConstraint === 'UriTemplate' && PropertyHelper.isUrlTemplate(data)) { returnType = 'https://schema.org/Text'; - } else if (this.constructor.URL_REGEX.test(data)) { + } else if (DataModelHelper.getProperties(this.version).has(data)) { + returnType = 'https://schema.org/Property'; + } else if (PropertyHelper.isUrl(data)) { returnType = 'https://schema.org/URL'; } } @@ -205,7 +210,7 @@ const Field = class { if (uniqueTypes.length === 1) { returnType = `ArrayOf#${uniqueTypes[0].replace(/^#/, '')}`; } else if (uniqueTypes.length > 1) { - returnType = `ArrayOf#{${uniqueTypes.map(item => item.replace(/^#/, '')).join(',')}}`; + returnType = `ArrayOf#{${uniqueTypes.map((item) => item.replace(/^#/, '')).join(',')}}`; } else { returnType = 'Array'; } @@ -296,7 +301,9 @@ const Field = class { actualTypeKey = actualTypeKey.substr(1); } if ( + // @ts-expect-error typeof (this.constructor.canBeTypeOfMapping[testTypeKey]) !== 'undefined' + // @ts-expect-error && this.constructor.canBeTypeOfMapping[testTypeKey] === actualTypeKey ) { return true; @@ -339,41 +346,6 @@ const Field = class { } }; -// Source: adapted from https://gist.github.com/dperini/729294 -Field.URL_REGEX = new RegExp( - '^' - // protocol identifier (mandatory) - // short syntax // not permitted - + '(?:(?:https?):\\/\\/)' - + '(?:' - // IP address dotted notation octets - // excludes loopback network 0.0.0.0 - // excludes reserved space >= 224.0.0.0 - // excludes network & broadcast addresses - // (first & last IP address of each class) - + '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' - + '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' - + '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' - + '|' - // host & domain names, may end with dot - // may not contain dots (e.g localhost) - + '(?:' - + '(?:' - + '[a-z0-9\\u00a1-\\uffff]' - + '[a-z0-9\\u00a1-\\uffff_-]{0,62}' - + ')?' - + '[a-z0-9\\u00a1-\\uffff]\\.?' - + ')+' - // TLD identifier name, may end with dot - + '(?:[a-z\\u00a1-\\uffff]{2,}\\.?)' - + ')' - // port number (optional) - + '(?::\\d{2,5})?' - // resource path (optional) - + '(?:[/?#]\\S*)?' - + '$', 'i', -); - Field.canBeTypeOfMapping = { 'https://schema.org/Date': 'https://schema.org/Text', 'https://schema.org/DateTime': 'https://schema.org/Text', diff --git a/src/classes/model-node.js b/src/classes/model-node.js index 91a87f71..73866c99 100644 --- a/src/classes/model-node.js +++ b/src/classes/model-node.js @@ -110,11 +110,12 @@ const ModelNode = class { } canInheritFrom(parentNode) { - return ( + // Note the below is commented out temporarily to allow Course fields to be inherited by CourseInstance + return true || ( parentNode.model.type === this.model.type || this.model.subClassGraph.indexOf(`#${parentNode.model.type}`) >= 0 || parentNode.model.subClassGraph.indexOf(`#${this.model.type}`) >= 0 - || this.model.subClassGraph.filter(value => parentNode.model.subClassGraph.indexOf(value) !== -1).length > 0 + || this.model.subClassGraph.filter((value) => parentNode.model.subClassGraph.indexOf(value) !== -1).length > 0 ); } @@ -127,6 +128,7 @@ const ModelNode = class { // Does our property allow us to inherit? && typeof this.parentNode.model.fields[this.cleanName] !== 'undefined' && typeof this.parentNode.model.fields[this.cleanName].inheritsTo !== 'undefined' + // @ts-expect-error && this.constructor.checkInheritRule( this.parentNode.model.fields[this.cleanName].inheritsTo, field, @@ -141,6 +143,7 @@ const ModelNode = class { const modelField = this.model.fields[fieldKey]; const fieldValue = this.getValue(fieldKey); if (typeof modelField.inheritsFrom !== 'undefined' + // @ts-expect-error && this.constructor.checkInheritRule(modelField.inheritsFrom, field) && typeof fieldValue === 'object' && !(fieldValue instanceof Array) @@ -161,6 +164,7 @@ const ModelNode = class { } } if (parentModel) { + // @ts-expect-error const parentNode = new this.constructor( modelField.fieldName, fieldValue, @@ -180,4 +184,8 @@ const ModelNode = class { } }; +/** + * @typedef {ModelNode} ModelNodeType + */ + module.exports = ModelNode; diff --git a/src/classes/model.js b/src/classes/model.js index a2630834..596a224d 100644 --- a/src/classes/model.js +++ b/src/classes/model.js @@ -24,10 +24,6 @@ const Model = class { return this.data.hasId || false; } - get idFormat() { - return this.data.idFormat; - } - get isJsonLd() { return typeof this.data.isJsonLd === 'undefined' ? true : this.data.isJsonLd; } @@ -84,6 +80,7 @@ const Model = class { } hasRequiredField(field) { + // @ts-expect-error return PropertyHelper.arrayHasField(this.requiredFields, field, this.version); } @@ -109,6 +106,10 @@ const Model = class { return this.data.recommendedFields || []; } + getDeprecatedFields() { + return Object.values(this.fields).filter((field) => field.deprecationGuidance); + } + getShallNotIncludeFields(validationMode, containingFieldName) { const specificContextualImperativeConfiguration = this.getImperativeConfigurationWithContext(validationMode, containingFieldName); const specificImperativeConfiguration = this.getImperativeConfiguration(validationMode); @@ -120,7 +121,30 @@ 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) { + // @ts-expect-error return PropertyHelper.arrayHasField(this.recommendedFields, field, this.version); } diff --git a/src/errors/validation-error-category.js b/src/errors/validation-error-category.js index 31ff088b..32c1ec85 100644 --- a/src/errors/validation-error-category.js +++ b/src/errors/validation-error-category.js @@ -1,5 +1,3 @@ - - const ValidationErrorCategory = Object.freeze({ CONFORMANCE: 'conformance', DATA_QUALITY: 'data-quality', diff --git a/src/errors/validation-error-severity.js b/src/errors/validation-error-severity.js index ddd72382..2859132f 100644 --- a/src/errors/validation-error-severity.js +++ b/src/errors/validation-error-severity.js @@ -1,5 +1,3 @@ - - const ValidationErrorSeverity = Object.freeze({ FAILURE: 'failure', WARNING: 'warning', diff --git a/src/errors/validation-error-type.js b/src/errors/validation-error-type.js index 66e7f67e..977bc141 100644 --- a/src/errors/validation-error-type.js +++ b/src/errors/validation-error-type.js @@ -1,5 +1,3 @@ - - const ValidationErrorType = { INVALID_JSON: 'invalid_json', MISSING_REQUIRED_FIELD: 'missing_required_field', @@ -9,6 +7,7 @@ const ValidationErrorType = { FIELD_NOT_IN_SPEC: 'field_not_in_spec', FIELD_NOT_ALLOWED_IN_SPEC: 'field_not_allowed_in_spec', FIELD_COULD_BE_TYPO: 'field_could_be_typo', + FIELD_DEPRECATED: 'field_deprecated', EXPERIMENTAL_FIELDS_NOT_CHECKED: 'experimental_fields_not_checked', UNSUPPORTED_VALUE: 'unsupported_value', INVALID_TYPE: 'invalid_type', @@ -41,6 +40,11 @@ const ValidationErrorType = { WRONG_BASE_TYPE: 'wrong_base_type', FIELD_NOT_ALLOWED: 'field_not_allowed', REPEATFREQUENCY_MISALIGNED: 'repeatfrequency_misaligned', + 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/errors/validation-error.js b/src/errors/validation-error.js index 930a63e7..a807018e 100644 --- a/src/errors/validation-error.js +++ b/src/errors/validation-error.js @@ -1,5 +1,3 @@ - - const ValidationErrorMessage = require('./validation-error-message'); const ValidationError = class { diff --git a/src/examples-spec.js b/src/examples-spec.js new file mode 100644 index 00000000..27ed5ea1 --- /dev/null +++ b/src/examples-spec.js @@ -0,0 +1,39 @@ +const request = require('sync-request'); +const { getExamplesWithContent } = require('@openactive/data-models'); +const { validate } = require('./validate'); +const OptionsHelper = require('./helpers/options'); + +const dataModelExamples = getExamplesWithContent('latest', (url) => { + // eslint-disable-next-line no-console + console.log(`Downloading ${url}`); + return JSON.parse(request('GET', url, { + headers: { + 'Content-Type': 'application/ld+json', + }, + }).getBody()); +}); + +for (const { + category, + name, + file, + data, + validationMode, +} of dataModelExamples) { + describe(`Example "${category} -> ${name}" (${file})`, () => { + it('should validate with no failures', async () => { + const results = await validate(data, new OptionsHelper({ + loadRemoteJson: true, + version: 'latest', + validationMode, + })); + const failures = results.filter((result) => result.severity === 'failure') + .map((error) => `${error.path}: ${error.message.split('\n')[0]}`) + // TECH DEBT: beta:virtualTour has been removed from the beta namespace temporarily as it breaks other libraries + // Remove the line below once beta:virtualTour has been re-introduced + .filter((x) => x.indexOf('beta:virtualTour') === -1); + + expect(failures).toEqual([]); + }); + }); +} diff --git a/src/helpers/data-model.js b/src/helpers/data-model.js index d0b52483..55eaeae8 100644 --- a/src/helpers/data-model.js +++ b/src/helpers/data-model.js @@ -7,6 +7,7 @@ const { loadEnum, loadModel, getSchemaOrgVocab, + getProperties, } = require('@openactive/data-models'); const { InvalidModelNameError } = require('../exceptions'); @@ -16,6 +17,13 @@ const DataModelHelper = class { return getSchemaOrgVocab(); } + static getProperties(version) { + if (typeof version === 'undefined') { + throw Error('Parameter "version" must be defined'); + } + return getProperties(version); + } + static getContext(version) { if (typeof version === 'undefined') { throw Error('Parameter "version" must be defined'); diff --git a/src/helpers/graph.js b/src/helpers/graph.js index ffa27ca4..dedaf16c 100644 --- a/src/helpers/graph.js +++ b/src/helpers/graph.js @@ -132,6 +132,8 @@ const GraphHelper = class { } static processProperty(spec, item, classes, version) { + const supersededBy = this.getProperty(spec, item, 'schema:supersededBy', version); + let includes = this.getProperty(spec, item, 'schema:domainIncludes', version); if (typeof includes === 'undefined') { includes = this.getProperty(spec, item, 'rdfs:domain', version); @@ -166,7 +168,7 @@ const GraphHelper = class { } for (const classItem of classes) { if (this.isPropertyEqual(spec, includeId, classItem, version)) { - return this.isPropertyInClassReturn(GraphHelper.PROPERTY_FOUND); + return this.isPropertyInClassReturn(GraphHelper.PROPERTY_FOUND, { supersededBy }); } } } @@ -175,7 +177,7 @@ const GraphHelper = class { return this.isPropertyInClassReturn(GraphHelper.PROPERTY_NOT_IN_DOMAIN, returnIncludes); } } - return this.isPropertyInClassReturn(GraphHelper.PROPERTY_FOUND); + return this.isPropertyInClassReturn(GraphHelper.PROPERTY_FOUND, { supersededBy }); } static processGraph(graph, context, version) { diff --git a/src/helpers/json-loader.js b/src/helpers/json-loader.js index 1825fa98..2f6e0824 100644 --- a/src/helpers/json-loader.js +++ b/src/helpers/json-loader.js @@ -99,6 +99,7 @@ async function getFromFsCacheIfExists(baseCachePath, url) { // Probably just doesn't exist return { exists: false }; } + // @ts-expect-error const parsed = JSON.parse(rawCacheContents); return { exists: true, @@ -108,7 +109,19 @@ async function getFromFsCacheIfExists(baseCachePath, url) { async function saveToFsCache(baseCachePath, url, fileObject) { const cachePath = getFsCachePath(baseCachePath, url); - await writeFileAtomic(cachePath, JSON.stringify(fileObject), { chown: false }); + try { + await writeFileAtomic(cachePath, JSON.stringify(fileObject), { chown: false }); + } catch (error) { + if (error.message.indexOf('EPERM: operation not permitted, rename') !== -1) { + // Ignore EPERM error on Windows when multiple processes try to write the same file + // https://github.com/npm/write-file-atomic/issues/28 + // If there's contention when saving this file, it is likely that one of the other instances of the + // validator is currently writing to the same file with the same contents, and therefore the cache + // file will be written successfully by the other instance, and this error can simply be ignored + } else { + throw error; + } + } } /** @@ -120,6 +133,7 @@ async function saveToFsCache(baseCachePath, url, fileObject) { async function getFromRemoteUrl(url) { let response; try { + // @ts-expect-error response = await axios.get(url, { headers: { 'Content-Type': 'application/ld+json', @@ -134,6 +148,7 @@ async function getFromRemoteUrl(url) { if (match !== null) { const { origin } = new URL(url); const linkUrl = match[1]; + // @ts-expect-error response = await axios.get(origin + linkUrl, { headers: { 'Content-Type': 'application/ld+json', @@ -205,7 +220,7 @@ async function getFileLoadRemote(url) { * * @param {string} url * @param {Object} options - * @returns {Object} + * @returns {Promise} */ async function getFileLoadRemoteAndCacheToFs(url, options) { { diff --git a/src/helpers/property-spec.js b/src/helpers/property-spec.js new file mode 100644 index 00000000..660635dd --- /dev/null +++ b/src/helpers/property-spec.js @@ -0,0 +1,59 @@ +const { performance } = require('perf_hooks'); +const PropertyHelper = require('./property'); + +describe('PropertyHelper', () => { + describe('isUrlTemplate', () => { + it('should return true for template', async () => { + const result = PropertyHelper.isUrlTemplate('{test}'); + expect(result).toBe(true); + }); + it('should return false for string', async () => { + const result = PropertyHelper.isUrlTemplate('test'); + expect(result).toBe(false); + }); + it('should return false for erroneous string', async () => { + const result = PropertyHelper.isUrlTemplate('{description}{something-else}.'); + expect(result).toBe(false); + }); + it('should return false for URL that has previously caused issues', async () => { + const result = PropertyHelper.isUrlTemplate('https://reachstorageaccount.blob.core.windows.net/images/9142d700-adb7-4f7b-af10-fef045ff11f4/783cbdc4-c0c0-449b-8ce0-1e147337a628/The Regal - Bball Court.jpg'); + expect(result).toBe(false); + }); + }); + + describe('isUrl', () => { + const data = [ + // 'https://domain1.domain2.domain3.domain4.net/ ', + // 'https://reachstorageaccount.blob.core.windows.net/images/9142d700-adb7-4f7b-af10-fef045ff11f4/783cbdc4-c0c0-449b-8ce0-1e147337a628/The Regal - Bball Court.jpg', + ]; + + it('should not take more than 100ms to complete', () => { + const t0 = performance.now(); + for (const item of data) { + PropertyHelper.isUrl(item); + } + const t1 = performance.now(); + expect(t1 - t0).toBeLessThan(100); + }); + + it('should return true for url', async () => { + const result = PropertyHelper.isUrl('https://www.example.com/'); + expect(result).toBe(true); + }); + + it('should return true for localhost', async () => { + const result = PropertyHelper.isUrl('http://localhost:5000/'); + expect(result).toBe(true); + }); + + it('should return true for dev domain', async () => { + const result = PropertyHelper.isUrl('http://mylocal.something:5000/'); + expect(result).toBe(true); + }); + + it('should return false for invalid URL', async () => { + const result = PropertyHelper.isUrl('https://www.example.com/ space'); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/helpers/property.js b/src/helpers/property.js index ae89c37e..ea091e36 100644 --- a/src/helpers/property.js +++ b/src/helpers/property.js @@ -1,6 +1,44 @@ const crypto = require('crypto'); +const UriTemplate = require('uritemplate'); const DataModelHelper = require('./data-model'); +// Source: adapted from https://gist.github.com/dperini/729294 +const URL_REGEX = new RegExp('^' + // protocol identifier (mandatory) + // short syntax // not permitted + + '(?:(?:https?):\\/\\/)' + + '(?:' + // IP address dotted notation octets + // excludes loopback network 0.0.0.0 + // excludes reserved space >= 224.0.0.0 + // excludes network & broadcast addresses + // (first & last IP address of each class) + + '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' + + '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' + + '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' + + '|' + // Include localhost + + 'localhost' + + '|' + // host & domain names, may end with dot + // can be replaced by a shortest alternative + // (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+ + + '(?:' + + '(?:' + + '[a-z0-9\\u00a1-\\uffff]' + + '[a-z0-9\\u00a1-\\uffff_-]{0,62}' + + ')?' + + '[a-z0-9\\u00a1-\\uffff]\\.' + + ')+' + // TLD identifier name, may end with dot + + '(?:[a-z\\u00a1-\\uffff]{2,}\\.?)' + + ')' + // port number (optional) + + '(?::\\d{2,5})?' + // resource path (optional) + + '(?:[/?#]\\S*)?' + + '$', 'i'); + const PropertyHelper = class { static getObjectField(data, property, version) { const keyChecks = this.getPropertyKeyChecks(property, version); @@ -183,6 +221,28 @@ const PropertyHelper = class { return keyChecks; } + static isUrlTemplate(data) { + try { + const template = UriTemplate.parse(data); + for (const expression of template.expressions) { + if (expression.constructor.name === 'VariableExpression') { + return true; + } + } + } catch (e) { + return false; + } + return false; + } + + static isValidUUID(data) { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(data); + } + + static isUrl(data) { + return URL_REGEX.test(data); + } + static clearCache() { this.enumCache = {}; this.propertyCache = {}; diff --git a/src/helpers/raw.js b/src/helpers/raw.js index 3138fe1d..f6126a6e 100644 --- a/src/helpers/raw.js +++ b/src/helpers/raw.js @@ -1,31 +1,25 @@ +const _ = require('lodash'); + const RawHelper = class { static isRpdeFeed(data) { - if ( - typeof data !== 'object' - || data === null - || data instanceof Array - ) { + if (!_.isPlainObject(data)) { + return false; + } + const type = data['@type'] || data.type; + // This is a JSON-LD object with a @type + if (!_.isNil(type)) { + return false; + } + if (!Array.isArray(data.items)) { return false; } - if ( - typeof data.type === 'undefined' - && typeof data['@type'] === 'undefined' - && typeof data.items !== 'undefined' - && data.items instanceof Array - ) { - for (const item of data.items) { - if ( - typeof item.state === 'string' - && ( - item.state === 'updated' - || item.state === 'deleted' - ) - ) { - return true; - } + for (const item of data.items) { + if (item.state !== 'updated' && item.state !== 'deleted') { + return false; } } - return false; + // If the page has no items (e.g. a last page), it's still considered an RPDE feed. + return true; } }; diff --git a/src/helpers/self-indexing-object.js b/src/helpers/self-indexing-object.js new file mode 100644 index 00000000..f98515e7 --- /dev/null +++ b/src/helpers/self-indexing-object.js @@ -0,0 +1,27 @@ +/** + * A shorthand to create an object where keys and values are the same. + * + * e.g. + * + * ```js + * > SelfIndexingObject.create(['a', 'b', 'c']) + * { a: 'a', b: 'b', c: 'c' } + * ``` + */ +const SelfIndexingObject = { + /** + * @template TKey + * @param {TKey[]} keys + * @returns {Record} + */ + create(keys) { + return keys.reduce((acc, key) => { + acc[key] = key; + return acc; + }, /** @type {any} */({})); + }, +}; + +module.exports = { + SelfIndexingObject, +}; diff --git a/src/rules/booking/booking-root-type-correct-rule-spec.js b/src/rules/booking/booking-root-type-correct-rule-spec.js index 28865ee3..174cef2a 100644 --- a/src/rules/booking/booking-root-type-correct-rule-spec.js +++ b/src/rules/booking/booking-root-type-correct-rule-spec.js @@ -29,7 +29,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'C1Response', data: { - type: 'OrderQuote', + '@type': 'OrderQuote', }, model: new Model({ type: 'OrderQuote', @@ -38,7 +38,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'BResponse', data: { - type: 'Order', + '@type': 'Order', }, model: new Model({ type: 'Order', @@ -47,7 +47,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'OrdersFeed', data: { - type: 'Order', + '@type': 'Order', }, model: new Model({ type: 'Order', @@ -56,7 +56,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'OrderStatus', data: { - type: 'Order', + '@type': 'Order', }, model: new Model({ type: 'Order', @@ -84,7 +84,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'C1Response', data: { - type: 'Order', + '@type': 'Order', }, model: new Model({ type: 'Order', @@ -93,7 +93,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'BResponse', data: { - type: 'OrderQuote', + '@type': 'OrderQuote', }, model: new Model({ type: 'OrderQuote', @@ -102,7 +102,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'OrdersFeed', data: { - type: 'OrderQuote', + '@type': 'OrderQuote', }, model: new Model({ type: 'OrderQuote', @@ -111,7 +111,7 @@ describe('BookingRootTypeCorrectRule', () => { { validationMode: 'OrderStatus', data: { - type: 'Event', + '@type': 'Event', }, model: new Model({ type: 'Event', 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/booking/booking-root-type-error-rule-spec.js b/src/rules/booking/booking-root-type-error-rule-spec.js index ae52e942..6bda875b 100644 --- a/src/rules/booking/booking-root-type-error-rule-spec.js +++ b/src/rules/booking/booking-root-type-error-rule-spec.js @@ -31,7 +31,7 @@ describe('BookingRootTypeErrorRule', () => { }, 'latest'); const data = { - type: 'UnknownOrderError', + '@type': 'UnknownOrderError', }; const options = new OptionsHelper({ validationMode: 'OpenBookingError' }); @@ -53,7 +53,7 @@ describe('BookingRootTypeErrorRule', () => { }, 'latest'); const data = { - type: 'Event', + '@type': 'Event', }; const options = new OptionsHelper({ validationMode: 'OpenBookingError' }); @@ -80,7 +80,7 @@ describe('BookingRootTypeErrorRule', () => { }, 'latest'); const data = { - type: 'OpenBookingError', + '@type': 'OpenBookingError', }; const options = new OptionsHelper({ validationMode: 'OpenBookingError' }); diff --git a/src/rules/consumer-notes/assume-age-range-rule-spec.js b/src/rules/consumer-notes/assume-age-range-rule-spec.js index ce85f556..f3af5779 100644 --- a/src/rules/consumer-notes/assume-age-range-rule-spec.js +++ b/src/rules/consumer-notes/assume-age-range-rule-spec.js @@ -25,9 +25,9 @@ describe('AssumeAgeRangeRule', () => { it('should return no notice when a complete ageRange is specified', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 18, maxValue: 25, }, @@ -45,9 +45,9 @@ describe('AssumeAgeRangeRule', () => { it('should return no notice when a complete ageRange is specified with namespaces', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', 'schema:minValue': 18, 'schema:maxValue': 25, }, @@ -65,7 +65,7 @@ describe('AssumeAgeRangeRule', () => { it('should return a notice when no ageRange is specified', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -81,9 +81,9 @@ describe('AssumeAgeRangeRule', () => { }); it('should return a notice when a minValue is specified', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 1, }, }; @@ -101,9 +101,9 @@ describe('AssumeAgeRangeRule', () => { }); it('should return a notice when a maxValue is specified', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', maxValue: 1, }, }; @@ -121,9 +121,9 @@ describe('AssumeAgeRangeRule', () => { }); it('should return a notice when a minValue of 0 and no maxValue is set', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 0, }, }; 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-spec.js b/src/rules/consumer-notes/assume-event-status-rule-spec.js index 54a3639d..50fd7821 100644 --- a/src/rules/consumer-notes/assume-event-status-rule-spec.js +++ b/src/rules/consumer-notes/assume-event-status-rule-spec.js @@ -35,7 +35,7 @@ describe('AssumeEventStatusRule', () => { it('should return no errors if the eventStatus fields are valid', async () => { const data = { - type: 'Event', + '@type': 'Event', eventStatus: 'https://schema.org/EventPostponed', }; @@ -52,7 +52,7 @@ describe('AssumeEventStatusRule', () => { it('should return no errors if the eventStatus fields are valid', async () => { const data = { - type: 'Event', + '@type': 'Event', 'schema:eventStatus': 'https://schema.org/EventPostponed', }; @@ -69,7 +69,7 @@ describe('AssumeEventStatusRule', () => { it('should return a notice if the eventStatus field is not set', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -88,7 +88,7 @@ describe('AssumeEventStatusRule', () => { it('should return a notice if the eventStatus field is not valid', async () => { const data = { - type: 'Event', + '@type': 'Event', eventStatus: 'https://openactive.io/EventStatus', }; 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-spec.js b/src/rules/consumer-notes/assume-no-gender-restriction-rule-spec.js index 8f0bd7ba..285632c9 100644 --- a/src/rules/consumer-notes/assume-no-gender-restriction-rule-spec.js +++ b/src/rules/consumer-notes/assume-no-gender-restriction-rule-spec.js @@ -34,7 +34,7 @@ describe('AssumeNoGenderRestrictionRule', () => { it('should return no errors if the genderRestriction fields are valid', async () => { const data = { - type: 'Event', + '@type': 'Event', genderRestriction: 'https://openactive.io/Female', }; @@ -51,7 +51,7 @@ describe('AssumeNoGenderRestrictionRule', () => { it('should return no errors if the genderRestriction fields are valid', async () => { const data = { - type: 'Event', + '@type': 'Event', 'oa:genderRestriction': 'https://openactive.io/Female', }; @@ -68,7 +68,7 @@ describe('AssumeNoGenderRestrictionRule', () => { it('should return a notice if the genderRestriction field is not set', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -87,7 +87,7 @@ describe('AssumeNoGenderRestrictionRule', () => { it('should return a notice if the genderRestriction field is not valid', async () => { const data = { - type: 'Event', + '@type': 'Event', genderRestriction: 'https://openactive.io/Invalid', }; 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/context-in-root-node-rule-spec.js b/src/rules/core/context-in-root-node-rule-spec.js index 978b3bd2..52346563 100644 --- a/src/rules/core/context-in-root-node-rule-spec.js +++ b/src/rules/core/context-in-root-node-rule-spec.js @@ -30,11 +30,11 @@ describe('ContextInRootNodeRule', () => { const dataItems = [ { '@context': metaData.contextUrl, - type: 'Event', + '@type': 'Event', }, { '@context': [metaData.contextUrl], - type: 'Event', + '@type': 'Event', }, ]; @@ -53,7 +53,7 @@ describe('ContextInRootNodeRule', () => { it('should return a failure if the context is missing from the root node', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -74,13 +74,13 @@ describe('ContextInRootNodeRule', () => { it('should return no error if the context is missing and this isn\'t the root node', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const parentNode = new ModelNode( '$', { - type: 'Event', + '@type': 'Event', subEvent: data, }, null, @@ -101,7 +101,7 @@ describe('ContextInRootNodeRule', () => { it('should return a error if the context is present and this isn\'t the root node', async () => { const data = { '@context': metaData.contextUrl, - type: 'Event', + '@type': 'Event', }; const parentNode = new ModelNode( @@ -134,11 +134,11 @@ describe('ContextInRootNodeRule', () => { const dataItems = [ { '@context': 'https://example.org/ns', - type: 'Event', + '@type': 'Event', }, { '@context': ['https://example.org/ns', metaData.contextUrl], - type: 'Event', + '@type': 'Event', }, ]; @@ -163,7 +163,7 @@ describe('ContextInRootNodeRule', () => { it('should return no error if the context is present, but contains non-url fields if the model declares its own type', async () => { const data = { '@context': [metaData.contextUrl, {}], - type: 'Event', + '@type': 'Event', }; const localModel = new Model({ @@ -192,7 +192,7 @@ describe('ContextInRootNodeRule', () => { it('should return an error if the context is present, but contains non-url fields', async () => { const data = { '@context': [metaData.contextUrl, {}], - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( diff --git a/src/rules/core/context-in-root-node-rule.js b/src/rules/core/context-in-root-node-rule.js index 6398bd74..00c0708c 100644 --- a/src/rules/core/context-in-root-node-rule.js +++ b/src/rules/core/context-in-root-node-rule.js @@ -16,7 +16,7 @@ module.exports = class ContextInRootNodeRule extends Rule { tests: { noContext: { description: 'Raises a failure if the @context is missing from the root node.', - message: `The \`@context\` property is required in the root object. It must contain the OpenActive context ("${metaData.contextUrl}") as a string or the first element in an array.\n\nFor example:\n\n\`\`\`\n{\n "@context": "${metaData.contextUrl}",\n "type": "Event"\n}\n\`\`\``, + message: `The \`@context\` property is required in the root object. It must contain the OpenActive context ("${metaData.contextUrl}") as a string or the first element in an array.\n\nFor example:\n\n\`\`\`\n{\n "@context": "${metaData.contextUrl}",\n "@type": "Event"\n}\n\`\`\``, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, type: ValidationErrorType.MISSING_REQUIRED_FIELD, @@ -30,21 +30,21 @@ module.exports = class ContextInRootNodeRule extends Rule { }, oaNotInRightPlace: { description: `Validates that the @context contains the OpenActive context (${metaData.contextUrl}) as a string or the first element in an array.`, - message: `The \`@context\` property must contain the OpenActive context (\`"${metaData.contextUrl}"\`) as a string or the first element in an array.\n\nFor example:\n\n\`\`\`\n{\n "@context": "${metaData.contextUrl}",\n "type": "Event"\n}\n\`\`\``, + message: `The \`@context\` property must contain the OpenActive context (\`"${metaData.contextUrl}"\`) as a string or the first element in an array.\n\nFor example:\n\n\`\`\`\n{\n "@context": "${metaData.contextUrl}",\n "@type": "Event"\n}\n\`\`\``, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, type: ValidationErrorType.FIELD_NOT_IN_DEFINED_VALUES, }, contextIncorrectForDatasetSite: { description: `For a Dataset Site, validates that the @context contains the schema.org context (${metaData.namespaces.schema}) and OpenActive context (${metaData.contextUrl}) as the first and second elements in an array, respectively.`, - message: `For a Dataset Site, the \`@context\` property must be present in the root object and contain the schema.org context (\`"${metaData.namespaces.schema}"\`) and OpenActive context (\`"${metaData.contextUrl}"\`) as the first and second elements in an array, respectively.\n\nFor example:\n\n\`\`\`\n{\n "@context": [\n "${metaData.namespaces.schema}",\n "${metaData.contextUrl}"\n ],\n "type": "Dataset"\n}\n\`\`\``, + message: `For a Dataset Site, the \`@context\` property must be present in the root object and contain the schema.org context (\`"${metaData.namespaces.schema}"\`) and OpenActive context (\`"${metaData.contextUrl}"\`) as the first and second elements in an array, respectively.\n\nFor example:\n\n\`\`\`\n{\n "@context": [\n "${metaData.namespaces.schema}",\n "${metaData.contextUrl}"\n ],\n "@type": "Dataset"\n}\n\`\`\``, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, type: ValidationErrorType.FIELD_NOT_IN_DEFINED_VALUES, }, contextIncorrectForDataCatalog: { description: `For a DataCatalog, validates that the @context contains only the schema.org context (${metaData.namespaces.schema}) as a string.`, - message: `For a \`DataCatalog\`, the \`@context\` property must be present in the root object and must contain the schema.org context (\`"${metaData.namespaces.schema}"\`) as a string.\n\nFor example:\n\n\`\`\`\n{\n "@context": "${metaData.namespaces.schema}",\n "type": "DataCatalog"\n}\n\`\`\``, + message: `For a \`DataCatalog\`, the \`@context\` property must be present in the root object and must contain the schema.org context (\`"${metaData.namespaces.schema}"\`) as a string.\n\nFor example:\n\n\`\`\`\n{\n "@context": "${metaData.namespaces.schema}",\n "@type": "DataCatalog"\n}\n\`\`\``, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, type: ValidationErrorType.FIELD_NOT_IN_DEFINED_VALUES, diff --git a/src/rules/core/deprecated-fields-rule.js b/src/rules/core/deprecated-fields-rule.js new file mode 100644 index 00000000..0835e6cc --- /dev/null +++ b/src/rules/core/deprecated-fields-rule.js @@ -0,0 +1,66 @@ +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 DeprecatedFieldsRule extends Rule { + constructor(options) { + super(options); + this.targetModels = '*'; + this.meta = { + name: 'DeprecatedFieldsRule', + description: 'Validates that deprecated properties are not present in the JSON data.', + tests: { + default: { + message: 'Deprecated properties must not be used in Open Booking API implementations. {{deprecationGuidance}}', + sampleValues: { + deprecationGuidance: 'Field is deprecated.', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.FIELD_DEPRECATED, + }, + feed: { + message: 'This property is deprecated. {{deprecationGuidance}}', + sampleValues: { + deprecationGuidance: 'Field is deprecated.', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.WARNING, + type: ValidationErrorType.FIELD_DEPRECATED, + }, + }, + }; + } + + validateModel(node) { + // Don't do this check for models that we don't actually have a spec for + if (!node.model.hasSpecification) { + return []; + } + const errors = []; + + const deprecatedFields = node.model.getDeprecatedFields(); + for (const field of deprecatedFields) { + const testValue = node.getValueWithInheritance(field.fieldName); + + if (typeof testValue !== 'undefined') { + const errorType = node.options.validationMode === 'RPDEFeed' ? 'feed' : 'default'; + + errors.push( + this.createError( + errorType, + { + value: testValue, + path: node.getPath(field.fieldName), + }, + { + deprecationGuidance: field.deprecationGuidance, + }, + ), + ); + } + } + return errors; + } +}; diff --git a/src/rules/core/deprecated-fields-spec.js b/src/rules/core/deprecated-fields-spec.js new file mode 100644 index 00000000..7886a291 --- /dev/null +++ b/src/rules/core/deprecated-fields-spec.js @@ -0,0 +1,77 @@ +const DeprecatedFieldsRule = require('./deprecated-fields-rule'); +const Model = require('../../classes/model'); +const ModelNode = require('../../classes/model-node'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); +const OptionsHelper = require('../../helpers/options'); + +describe('DeprecatedFieldsRule', () => { + const rule = new DeprecatedFieldsRule(); + + const model = new Model({ + type: 'Event', + fields: { + ageRange: { + fieldName: 'ageRange', + sameAs: 'https://openactive.io/ageRange', + model: '#QuantitativeValue', + description: [ + 'Indicates that an Offer is only applicable to a specific age range.', + ], + deprecationGuidance: 'Use `ageRestriction` instead of `ageRange` within the `Offer` for cases where the `Offer` is age restricted.', + }, + }, + }, 'latest'); + model.hasSpecification = true; + + describe('when in RPDEFeed validation mode', () => { + const options = new OptionsHelper({ validationMode: 'RPDEFeed' }); + + it('should return a failure', async () => { + const data = { + '@type': 'Event', + ageRange: { + '@type': 'QuantitativeValue', + minValue: 18, + maxValue: 65, + }, + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(1); + expect(errors[0].severity).toBe(ValidationErrorSeverity.WARNING); + }); + }); + + describe('when in non-RPDEFeed validation mode', () => { + const options = new OptionsHelper({ validationMode: 'C1Request' }); + + it('should return a failure', async () => { + const data = { + '@type': 'Event', + ageRange: { + '@type': 'QuantitativeValue', + minValue: 18, + maxValue: 65, + }, + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(1); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + }); + }); +}); diff --git a/src/rules/core/fields-correct-type-rule-spec.js b/src/rules/core/fields-correct-type-rule-spec.js index 6f8a7418..74a14e1a 100644 --- a/src/rules/core/fields-correct-type-rule-spec.js +++ b/src/rules/core/fields-correct-type-rule-spec.js @@ -363,6 +363,69 @@ describe('FieldsCorrectTypeRule', () => { } }); + // Property + it('should return no error for recognised property @id used for a Property type', async () => { + const model = new Model({ + type: 'Event', + fields: { + field: { + fieldName: 'field', + requiredType: 'https://schema.org/Property', + }, + }, + }, 'latest'); + model.hasSpecification = true; + const values = [ + 'https://schema.org/name', + ]; + + for (const value of values) { + const data = { + field: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + } + }); + it('should return an error for unrecognised property @id used for a Property type', async () => { + const model = new Model({ + type: 'Event', + fields: { + field: { + fieldName: 'field', + requiredType: 'https://schema.org/Property', + }, + }, + }, 'latest'); + model.hasSpecification = true; + + const values = [ + 'https://schema.org/notaproperty', + ]; + + for (const value of values) { + const data = { + field: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(1); + expect(errors[0].type).toBe(ValidationErrorType.INVALID_TYPE); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + // Date it('should return no error for an valid date type', async () => { const model = new Model({ @@ -653,7 +716,7 @@ describe('FieldsCorrectTypeRule', () => { const values = [ { - type: 'Schedule', + '@type': 'Schedule', }, ]; @@ -688,11 +751,11 @@ describe('FieldsCorrectTypeRule', () => { 27, {}, { - type: 'Person', + '@type': 'Person', }, [ { - type: 'Schedule', + '@type': 'Schedule', }, ], ]; @@ -727,7 +790,7 @@ describe('FieldsCorrectTypeRule', () => { const values = [ { - type: 'Place', + '@type': 'Place', amenityFeature: [ { type: 'ext:MyLocation', @@ -1013,7 +1076,7 @@ describe('FieldsCorrectTypeRule', () => { duration_array: 'PT30M', text_array: 'Lorem ipsum', model_array: { - type: 'Schedule', + '@type': 'Schedule', }, }; @@ -1033,6 +1096,73 @@ describe('FieldsCorrectTypeRule', () => { }); // Multiple rules + it('should return no error for a valid type with inheritance and two different types', async () => { + const models = { + Event: new Model({ + type: 'Event', + fields: { + startDate: { + fieldName: 'startDate', + requiredType: 'https://schema.org/DateTime', + }, + }, + }, 'latest'), + CourseInstance: new Model({ + type: 'CourseInstance', + fields: { + field: { + fieldName: 'startDate', + requiredType: 'https://schema.org/Date', + }, + subEvent: { + fieldName: 'subEvent', + sameAs: 'https://schema.org/subEvent', + model: 'ArrayOf#Event', + inheritsTo: { + exclude: [ + 'subEvent', + 'startDate', + ], + }, + }, + }, + }, 'latest'), + }; + models.CourseInstance.hasSpecification = true; + models.Event.hasSpecification = true; + + const data = { + type: 'CourseInstance', + startDate: '2018-08-01', + subEvent: [ + { + '@type': 'Event', + startDate: '2018-10-02T19:15:00Z', + }, + ], + }; + + // Test the top-level node first + // The subEvent should be inherited, and not error + const rootNodeToTest = new ModelNode( + '$', + data, + null, + models.CourseInstance, + ); + const rootErrors = await rule.validate(rootNodeToTest); + expect(rootErrors.length).toBe(0); + + // Test the next node down + const subEventNodeToTest = new ModelNode( + 'subEvent', + data.subEvent[0], + rootNodeToTest, + models.Event, + ); + const subEventErrors = await rule.validate(subEventNodeToTest); + expect(subEventErrors.length).toBe(0); + }); it('should return no error for an valid type with multiple rules', async () => { const model = new Model({ type: 'Event', @@ -1054,11 +1184,11 @@ describe('FieldsCorrectTypeRule', () => { const values = [ { - type: 'Schedule', + '@type': 'Schedule', }, [ { - type: 'Person', + '@type': 'Person', }, ], 'http://example.com/', @@ -1103,11 +1233,11 @@ describe('FieldsCorrectTypeRule', () => { 27, {}, { - type: 'Person', + '@type': 'Person', }, [ { - type: 'Schedule', + '@type': 'Schedule', }, ], ]; diff --git a/src/rules/core/fields-correct-type-rule.js b/src/rules/core/fields-correct-type-rule.js index d7d172fa..f7b91cc1 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, @@ -105,8 +105,12 @@ module.exports = class FieldsCorrectTypeRule extends Rule { return `[\`string${plural}\` containing an ISO 8601 Time](${type})`; case 'https://schema.org/Duration': return `[\`string${plural}\` containing an ISO 8601 Duration](${type})`; + case 'https://schema.org/Property': + 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(/^#/, '')}\``; } @@ -123,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:`; @@ -154,15 +158,21 @@ module.exports = class FieldsCorrectTypeRule extends Rule { case 'https://schema.org/Duration': example = `${prefix}"PT30M"`; break; + case 'https://schema.org/Property': + example = `${prefix}"https://schema.org/givenName"`; + break; 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); example = `${prefix}"${allowedOptions[0]}"`; } else { - example = `${prefix}{\n${prefix} "type": "${readableType.replace(/^#/, '')}"\n${prefix}}`; + example = `${prefix}{\n${prefix} "@type": "${readableType.replace(/^#/, '')}"\n${prefix}}`; } break; } @@ -180,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)}`; @@ -189,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 an object defined elsewhere may be used in place of the object itself. ${this.getHumanReadableExample(property, 'https://openactive.io/IdReference', version)}`; return examples; } @@ -205,7 +216,7 @@ module.exports = class FieldsCorrectTypeRule extends Rule { const errors = []; // Get the derived type - const fieldValue = node.getMappedValue(field); + const fieldValue = node.getValue(field); const derivedType = fieldObj.detectType(fieldValue); const typeChecks = fieldObj.getAllPossibleTypes(); @@ -232,9 +243,19 @@ module.exports = class FieldsCorrectTypeRule extends Rule { return []; } - const checkPass = fieldObj.detectedTypeIsAllowed(fieldValue); + const checkPass = fieldObj.detectedTypeIsAllowed(fieldValue) + // 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)); if (!checkPass) { + // Hide this error if a more relevant error is being displayed + const isReferencedField = fieldObj.allowReferencing && node.model.getReferencedFields(node.options.validationMode, node.name).includes(field); + const isShouldNotBeReferencedField = fieldObj.allowReferencing && node.model.getShallNotBeReferencedFields(node.options.validationMode, node.name).includes(field); + if (isReferencedField || (isShouldNotBeReferencedField && typeof fieldValue === 'string')) { + return []; + } + + const idReferencingMessage = fieldObj.allowReferencing && !isShouldNotBeReferencedField ? ' or a reference URI to an `@id`' : ''; let testKey; let messageValues = {}; let propName = field; @@ -273,14 +294,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 && !isShouldNotBeReferencedField), }; } 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 && !isShouldNotBeReferencedField), }; } } else { @@ -288,7 +309,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 && !isShouldNotBeReferencedField), + idReferencingMessage, }; } } else { @@ -297,7 +319,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 && !isShouldNotBeReferencedField), + idReferencingMessage, }; } errors.push( diff --git a/src/rules/core/fields-not-in-model-rule-spec.js b/src/rules/core/fields-not-in-model-rule-spec.js index 69047f30..ff231e1b 100644 --- a/src/rules/core/fields-not-in-model-rule-spec.js +++ b/src/rules/core/fields-not-in-model-rule-spec.js @@ -37,12 +37,12 @@ describe('FieldsNotInModelRule', () => { it('should return no errors if all fields are in the spec', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', activity: { - id: 'https://example.com/reference/activities#Speedball', + '@id': 'https://example.com/reference/activities#Speedball', inScheme: 'https://example.com/reference/activities', prefLabel: 'Speedball', - type: 'Concept', + '@type': 'Concept', }, location: { address: { @@ -50,19 +50,19 @@ describe('FieldsNotInModelRule', () => { addressRegion: 'London', postalCode: 'NW5 3DU', streetAddress: 'Raynes Park High School, 46A West Barnes Lane', - type: 'PostalAddress', + '@type': 'PostalAddress', }, description: 'Raynes Park High School in London', geo: { latitude: 51.4034423828125, longitude: -0.2369088977575302, - type: 'GeoCoordinates', + '@type': 'GeoCoordinates', }, - id: 'https://example.com/locations/1234ABCD', + '@id': 'https://example.com/locations/1234ABCD', identifier: '1234ABCD', name: 'Raynes Park High School', telephone: '01253 473934', - type: 'Place', + '@type': 'Place', }, }; @@ -83,7 +83,7 @@ describe('FieldsNotInModelRule', () => { 'https://openactive.io/', 'http://example.org/ext/1.0/schema.jsonld', ], - type: 'Event', + '@type': 'Event', 'ext:myCustomProperty': 'foo', }; @@ -110,7 +110,7 @@ describe('FieldsNotInModelRule', () => { }, }; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NONE, statusCode: 200, data: customContext, @@ -142,7 +142,7 @@ describe('FieldsNotInModelRule', () => { 'https://openactive.io/', 'http://example.org/ext/1.0/schema.jsonld', ], - type: 'Event', + '@type': 'Event', 'ext:myInvalidCustomProperty': 'foo', }; @@ -169,7 +169,7 @@ describe('FieldsNotInModelRule', () => { }, }; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NONE, statusCode: 200, data: customContext, @@ -206,7 +206,7 @@ describe('FieldsNotInModelRule', () => { 'https://openactive.io/', 'http://example.org/ext/1.0/schema.jsonld', ], - type: 'Event', + '@type': 'Event', 'ext:customName': 'Custom Event', }; @@ -234,7 +234,7 @@ describe('FieldsNotInModelRule', () => { '@id': 'http://example.org/ext#1.0', }; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NONE, statusCode: 200, data: customContext, @@ -263,7 +263,7 @@ describe('FieldsNotInModelRule', () => { it('should return a warning per field if any fields are not in the spec, but are in schema.org', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', alternateName: 'Alternate Event', }; @@ -290,7 +290,7 @@ describe('FieldsNotInModelRule', () => { it('should return a failure per field if any fields are not allowed in the spec', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', disallowed_field: 'This field is disallowed by the spec', }; @@ -313,7 +313,7 @@ describe('FieldsNotInModelRule', () => { it('should return a failure per field if any fields are not in the spec', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', invalid_field: 'This field is not in the spec', another_invalid_field: 'This field is also not in the spec', }; @@ -337,7 +337,7 @@ describe('FieldsNotInModelRule', () => { it('should return a notice per field if any extension fields are present', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', 'beta:experimental_field': 'This field is experimental', 'beta:another_experimental_field': 'This field is also experimental', 'Ext:an_extended_field': 'This field extends the OA spec', @@ -361,12 +361,98 @@ describe('FieldsNotInModelRule', () => { } }); + it('should return a failure per field if a field has been superseded', async () => { + const schedule = new Model({ + type: 'PartialSchedule', + inSpec: [ + 'type', + ], + }, 'latest'); + schedule.hasSpecification = true; + + const data = { + '@context': [ + 'https://openactive.io', + 'https://openactive.io/ns-beta', + ], + '@type': 'PartialSchedule', + 'beta:oldProperty': 'America/New_York', + }; + + const customContext = { + '@context': { + rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + rdfs: 'http://www.w3.org/2000/01/rdf-schema#', + schema: 'https://schema.org/', + oa: 'https://openactive.io/', + label: 'rdfs:label', + comment: 'rdfs:comment', + domainIncludes: { + '@id': 'schema:domainIncludes', + '@type': '@id', + }, + rangeIncludes: { + '@id': 'schema:rangeIncludes', + '@type': '@id', + }, + Property: 'rdf:Property', + Class: 'rdfs:Class', + supersededBy: 'schema:supersededBy', + beta: 'https://openactive.io/ns-beta#', + }, + '@graph': [ + { + '@id': 'beta:oldProperty', + '@type': 'Property', + label: 'oldProperty', + comment: 'This old property has now been deprecated.', + supersededBy: 'schema:scheduleTimezone', + domainIncludes: [ + 'oa:PartialSchedule', + ], + rangeIncludes: [ + 'schema:Text', + ], + }, + ], + }; + + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ + errorCode: JsonLoaderHelper.ERROR_NONE, + statusCode: 200, + data: customContext, + url, + exception: null, + contentType: 'application/json', + fetchTime: (new Date()).valueOf(), + })); + + const options = new OptionsHelper({ + loadRemoteJson: true, + validationMode: 'C1Response', + }); + + const nodeToTest = new ModelNode( + '$', + data, + null, + schedule, + options, + ); + + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + expect(errors[0].type).toBe(ValidationErrorType.FIELD_NOT_ALLOWED_IN_SPEC); + }); + it('should return a failure per field if a field is a typo', async () => { const data = { - type: 'Event', + '@type': 'Event', offer: { - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', diff --git a/src/rules/core/fields-not-in-model-rule.js b/src/rules/core/fields-not-in-model-rule.js index 545f2d69..9fcf841d 100644 --- a/src/rules/core/fields-not-in-model-rule.js +++ b/src/rules/core/fields-not-in-model-rule.js @@ -51,7 +51,7 @@ module.exports = class FieldsNotInModelRule extends Rule { }, invalidExperimentalDomainNotFound: { description: 'Raises a notice if experimental properties are detected, but have no definition in the @context.', - message: 'A definition for this extension property was found, but a check could not be performed to assess whether it has been included in the correct object `"type"`.\n\nFor more information about extension properties, see the [extension properties guide](https://openactive.io/modelling-opportunity-data/EditorsDraft/#defining-and-using-custom-namespaces).', + message: 'A definition for this extension property was found, but a check could not be performed to assess whether it has been included in the correct object `"@type"`.\n\nFor more information about extension properties, see the [extension properties guide](https://openactive.io/modelling-opportunity-data/EditorsDraft/#defining-and-using-custom-namespaces).', category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.NOTICE, type: ValidationErrorType.EXPERIMENTAL_FIELDS_NOT_CHECKED, @@ -118,7 +118,7 @@ module.exports = class FieldsNotInModelRule extends Rule { }, notInSpec: { description: 'Raises a error for properties that aren\'t in the OpenActive specification, and that aren\'t caught by other rules.', - message: 'This property is not defined in the OpenActive specification. Data publishers are encouraged to publish as many data properties as possible, and for those that don\'t match the specification, to use [extension properties](https://openactive.io/modelling-opportunity-data/EditorsDraft/#defining-and-using-custom-namespaces).\n\nFor example:\n\n```\n{\n "ext:{{field}}": "my custom data"\n}\n```\n\nIf you are trying to use a recognised property, please check the spelling and ensure that you are using it within the correct object `"type"`. Otherwise if you are trying to add your own property, simply rename it to `ext:{{field}}`.', + message: 'This property is not defined in the OpenActive specification. Data publishers are encouraged to publish as many data properties as possible, and for those that don\'t match the specification, to use [extension properties](https://openactive.io/modelling-opportunity-data/EditorsDraft/#defining-and-using-custom-namespaces).\n\nFor example:\n\n```\n{\n "ext:{{field}}": "my custom data"\n}\n```\n\nIf you are trying to use a recognised property, please check the spelling and ensure that you are using it within the correct object `"@type"`. Otherwise if you are trying to add your own property, simply rename it to `ext:{{field}}`.', sampleValues: { field: 'myCustomPropertyName', }, @@ -136,6 +136,26 @@ module.exports = class FieldsNotInModelRule extends Rule { severity: ValidationErrorSeverity.FAILURE, type: ValidationErrorType.FIELD_NOT_ALLOWED_IN_SPEC, }, + superseded: { + description: 'Raises an error for properties that have been superseded.', + message: 'This term has graduated from the beta namespace and is highly likely to be removed in future, please use `{{field}}` instead.', + sampleValues: { + field: 'supersedingField', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.FIELD_NOT_ALLOWED_IN_SPEC, + }, + supersededFeed: { + description: 'Raises an error for properties that have been superseded.', + message: 'This term has graduated from the beta namespace and is highly likely to be removed in future, please use `{{field}}` instead.', + sampleValues: { + field: 'supersedingField', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.WARNING, + type: ValidationErrorType.FIELD_NOT_ALLOWED_IN_SPEC, + }, }, }; } @@ -278,6 +298,14 @@ module.exports = class FieldsNotInModelRule extends Rule { if (field === '@context') { return []; } + // Don't do this check for cases where the JSON-LD type does not match the expected model + // Other rules will raise an error if the type itself is invalid, and if this type is an + // extension the validator cannot yet validate properties within the type anyway, + // so in either case this rule should not run. + // TODO: Remove this and improve the rule to validate beta and extension types + if (node.model.isJsonLd && node.model.type !== node.getValue('type')) { + return []; + } let errors = []; let testKey = null; let messageValues; @@ -326,6 +354,18 @@ module.exports = class FieldsNotInModelRule extends Rule { [oaContext, schemaOrgVocab], ); if (graphResponse.code === GraphHelper.PROPERTY_FOUND) { + if (graphResponse.data.supersededBy) { + if (node.options.validationMode === 'RPDEFeed') { + testKey = 'supersededFeed'; + } else { + testKey = 'superseded'; + } + + messageValues = { + field: graphResponse.data.supersededBy, + }; + } + isDefined = true; break; } @@ -338,14 +378,6 @@ module.exports = class FieldsNotInModelRule extends Rule { } if (!isDefined) { switch (graphResponse.code) { - case GraphHelper.PROPERTY_NOT_FOUND: - default: - if (field.substring(0, 5) === 'beta:') { - testKey = 'invalidBeta'; - } else { - testKey = 'invalidExperimental'; - } - break; case GraphHelper.PROPERTY_NOT_IN_DOMAIN: if (field.substring(0, 5) === 'beta:') { testKey = 'invalidBetaNotInDomain'; @@ -359,6 +391,14 @@ module.exports = class FieldsNotInModelRule extends Rule { case GraphHelper.PROPERTY_DOMAIN_NOT_FOUND: testKey = 'invalidExperimentalDomainNotFound'; break; + case GraphHelper.PROPERTY_NOT_FOUND: + default: + if (field.substring(0, 5) === 'beta:') { + testKey = 'invalidBeta'; + } else { + testKey = 'invalidExperimental'; + } + break; } } } 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..90f1c3cc --- /dev/null +++ b/src/rules/core/id-references-not-permitted-rule-spec.js @@ -0,0 +1,152 @@ +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..9605613f --- /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](https://permalink.openactive.io/data-model-validator/id-references) 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..b079a286 --- /dev/null +++ b/src/rules/core/id-references-required-rule-spec.js @@ -0,0 +1,89 @@ +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..26d4522e --- /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. Note that the `@id` URL does not need to resolve to an endpoint, it is simply used as a globally unique identifier.\n\nAn `@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 !== 'undefined' && (typeof fieldValue !== 'string' || !PropertyHelper.isUrl(fieldValue))) { + errors.push( + this.createError( + 'default', + { + fieldValue, + path: node.getPath(field), + }, + { field }, + ), + ); + } + } + + return errors; + } +} + +module.exports = IdReferencesRequiredRule; diff --git a/src/rules/core/id-rule-spec.js b/src/rules/core/id-rule-spec.js new file mode 100644 index 00000000..8851ec65 --- /dev/null +++ b/src/rules/core/id-rule-spec.js @@ -0,0 +1,66 @@ +const IdRule = require('./id-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'); + +describe('IdRule', () => { + const rule = new IdRule(); + + const model = new Model({ + type: 'Event', + fields: { + }, + }, 'latest'); + model.hasSpecification = true; + + it('should target id field', () => { + const isTargeted = rule.isFieldTargeted(model, '@id'); + expect(isTargeted).toBe(true); + }); + + it('should return no errors for a value that is a valid URL', async () => { + const values = [ + 'https://www.google.com/', + ]; + + for (const value of values) { + const data = { + '@id': value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + } + }); + + it('should return an error for a value that is not a valid URL', async () => { + const values = [ + '123E4567-E89B-12D3-A456-426614174000', + 0, + '0', + ]; + + for (const value of values) { + const data = { + '@id': value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + expect(errors[0].type).toBe(ValidationErrorType.INVALID_ID); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/core/id-rule.js b/src/rules/core/id-rule.js new file mode 100644 index 00000000..fb39a3ca --- /dev/null +++ b/src/rules/core/id-rule.js @@ -0,0 +1,62 @@ +const Rule = require('../rule'); +const PropertyHelper = require('../../helpers/property'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class IdRule extends Rule { + constructor(options) { + super(options); + this.targetFields = '*'; + this.meta = { + name: 'IdRule', + description: 'Validates that all @id property values are valid URLs.', + tests: { + idInvalid: { + description: 'Raises a failure if an @id value is not a valid URL', + message: 'The value of `@id` must always be an absolute URL.\n\n`@id` properties are used as identifiers for compatibility with JSON-LD. The value of such a property must always be an absolute URI that provides a stable globally unique identifier for the resource, as described in [RFC3986](https://tools.ietf.org/html/rfc3986).\n\nThe primary purpose of the URI format in this context is to provide natural namespacing for the identifier. Hence, the URI itself may not resolve to a valid endpoint, but must use a domain name controlled by the resource owner (the organisation responsible for the OpenActive open data feed).', + category: ValidationErrorCategory.DATA_QUALITY, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.INVALID_ID, + }, + }, + }; + } + + validateField(node, field) { + if (field !== '@id' && field !== 'id') { + return []; + } + + // Don't do this check for models that we don't actually have a spec for + if (!node.model.hasSpecification) { + return []; + } + + // Don't do this check for models that aren't JSON-LD + if (!node.model.isJsonLd) { + return []; + } + + const errors = []; + + // Get the field object + const fieldValue = node.getValue(field); + + if (typeof fieldValue !== 'string' || !PropertyHelper.isUrl(fieldValue)) { + errors.push( + this.createError( + 'idInvalid', + { + fieldValue, + path: node.getPath(field), + }, + ), + ); + } else { + return []; + } + + return errors; + } +}; diff --git a/src/rules/core/minvalueinclusive-rule-spec.js b/src/rules/core/minvalueinclusive-rule-spec.js new file mode 100644 index 00000000..32e5528c --- /dev/null +++ b/src/rules/core/minvalueinclusive-rule-spec.js @@ -0,0 +1,85 @@ +const MinValueInclusiveRule = require('./minvalueinclusive-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'); + +describe('MinValueInclusiveRule', () => { + const rule = new MinValueInclusiveRule(); + + const model = new Model({ + type: 'Schedule', + fields: { + repeatCount: { + fieldName: 'repeatCount', + minValueInclusive: 4, + requiredType: 'https://schema.org/Integer', + }, + }, + }, 'latest'); + model.hasSpecification = true; + + it('should target any field', () => { + const isTargeted = rule.isFieldTargeted(model, 'repeatCount'); + expect(isTargeted).toBe(true); + }); + + it('should return no errors for a value greater than minValueInclusive constraint', async () => { + const values = [ + 89.12345, + 89, + 4.1, + ]; + + for (const value of values) { + const data = { + repeatCount: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + } + }); + + it('should return no error for a value that matches minValueInclusive constraint', async () => { + const data = { repeatCount: 4 }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + }); + + it('should return an error for a value below a minValueInclusive constraint', async () => { + const values = [ + -100.1, + -100, + -3.9, + ]; + + for (const value of values) { + const data = { + repeatCount: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(1); + expect(errors[0].type).toBe(ValidationErrorType.BELOW_MIN_VALUE_INCLUSIVE); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/core/minvalueinclusive-rule.js b/src/rules/core/minvalueinclusive-rule.js new file mode 100644 index 00000000..3dac41cd --- /dev/null +++ b/src/rules/core/minvalueinclusive-rule.js @@ -0,0 +1,67 @@ +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 MinValueInclusiveRule extends Rule { + constructor(options) { + super(options); + this.targetFields = '*'; + this.meta = { + name: 'MinValueInclusiveRule', + description: 'Validates that all properties meet the associated minValueInclusive constraint.', + tests: { + belowMinimum: { + description: 'Raises a failure if the value is below the associated minValueInclusive property.', + message: 'The value of this property must be greater than or equal to {{minValueInclusive}}.', + sampleValues: { + minValueInclusive: 4, + }, + category: ValidationErrorCategory.DATA_QUALITY, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.BELOW_MIN_VALUE_INCLUSIVE, + }, + }, + }; + } + + validateField(node, field) { + // Don't do this check for models that we don't actually have a spec for + if (!node.model.hasSpecification) { + return []; + } + if (!node.model.hasField(field)) { + return []; + } + + const errors = []; + + // Get the field object + const fieldObj = node.model.getField(field); + const fieldValue = node.getValue(field); + + if (typeof fieldValue !== 'number') { + return []; + } + + if ( + typeof fieldObj.minValueInclusive !== 'undefined' + && fieldValue < fieldObj.minValueInclusive + ) { + errors.push( + this.createError( + 'belowMinimum', + { + value: fieldValue, + path: node.getPath(field), + }, + { + minValueInclusive: fieldObj.minValueInclusive, + }, + ), + ); + } + + return errors; + } +}; diff --git a/src/rules/core/no-empty-values-rule-spec.js b/src/rules/core/no-empty-values-rule-spec.js index b559221b..028c03b1 100644 --- a/src/rules/core/no-empty-values-rule-spec.js +++ b/src/rules/core/no-empty-values-rule-spec.js @@ -24,7 +24,7 @@ describe('NoEmptyValuesRule', () => { it('should return no errors if all fields are non-empty', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', field_value: 'Not empty', field_value_array: ['Not empty'], }; @@ -43,7 +43,7 @@ describe('NoEmptyValuesRule', () => { it('should return a failure per field if any fields are null, empty strings or empty arrays', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', invalid_field: '', another_invalid_field: null, invalid_field_array: [], diff --git a/src/rules/core/no-prefix-or-namespace-rule-spec.js b/src/rules/core/no-prefix-or-namespace-rule-spec.js index ea9b6baa..8d63e4a1 100644 --- a/src/rules/core/no-prefix-or-namespace-rule-spec.js +++ b/src/rules/core/no-prefix-or-namespace-rule-spec.js @@ -3,12 +3,12 @@ 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('NoPrefixOrNamespaceRule', () => { const model = new Model({ type: 'Event', hasId: true, - idFormat: 'https://schema.org/URL', inSpec: [ '@context', 'id', @@ -28,7 +28,7 @@ describe('NoPrefixOrNamespaceRule', () => { it('should return no errors if all fields are non-prefixed', async () => { const data = { - type: 'Event', + '@type': 'Event', name: 'An Event', }; @@ -45,7 +45,7 @@ describe('NoPrefixOrNamespaceRule', () => { it('should return no errors if all prefixed or namespaced fields are extensions', async () => { const data = { - type: 'Event', + '@type': 'Event', 'ext:testField': 'An extension field', 'http://ext.example.org/anotherTestField': 'Another extension field', }; @@ -61,38 +61,59 @@ describe('NoPrefixOrNamespaceRule', () => { expect(errors.length).toBe(0); }); - // TODO: Fix this to return a warning if 'type' or 'id' are used (as this was designed to warn for '@type' or '@id'). - // Note that this will likely require wider changes - it('should not return a warning if @type or @id are used', async () => { + it('should return an error if type or id are used for BookableRPDEFeed mode', async () => { const data = { - '@type': 'Event', - '@id': 'http://example.org/event/1', + type: 'Event', + id: 'http://example.org/event/1', }; + const options = new OptionsHelper({ validationMode: 'BookableRPDEFeed' }); const nodeToTest = new ModelNode( '$', data, null, model, + options, ); const errors = await rule.validate(nodeToTest); - expect(errors.length).toBe(0); // Was .toBe(2) + expect(errors.length).toBe(2); - /* for (const error of errors) { expect(error.type).toBe(ValidationErrorType.USE_FIELD_ALIASES); - expect(error.severity).toBe(ValidationErrorSeverity.WARNING); + expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); } - */ }); - it('should return a warning if prefixed fields with aliases are used', async () => { + it('should return a warning if type or id are used for RPDEFeed mode', async () => { const data = { type: 'Event', id: 'http://example.org/event/1', + }; + + const options = new OptionsHelper({ validationMode: 'RPDEFeed' }); + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + options, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(2); + + for (const error of errors) { + expect(error.type).toBe(ValidationErrorType.USE_FIELD_ALIASES); + expect(error.severity).toBe(ValidationErrorSeverity.WARNING); + } + }); + it('should return a warning if prefixed fields with aliases are used', async () => { + const data = { + '@type': 'Event', + '@id': 'http://example.org/event/1', 'schema:name': 'Event Name', 'oa:ageRange': { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 0, }, }; @@ -114,7 +135,7 @@ describe('NoPrefixOrNamespaceRule', () => { }); it('should return a warning if prefixed field values with aliases are used', async () => { const data = { - type: 'skos:Concept', + '@type': 'skos:Concept', }; const nodeToTest = new ModelNode( @@ -134,11 +155,11 @@ describe('NoPrefixOrNamespaceRule', () => { }); it('should return a warning if prefixed fields with namespaces are used', async () => { const data = { - type: 'Event', - id: 'http://example.org/event/1', + '@type': 'Event', + '@id': 'http://example.org/event/1', 'https://schema.org/name': 'Event Name', 'https://openactive.io/ageRange': { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 0, }, }; @@ -160,7 +181,7 @@ describe('NoPrefixOrNamespaceRule', () => { }); it('should return a warning if prefixed field values with namespaces are used', async () => { const data = { - type: 'https://schema.org/Event', + '@type': 'https://schema.org/Event', }; const nodeToTest = new ModelNode( diff --git a/src/rules/core/no-prefix-or-namespace-rule.js b/src/rules/core/no-prefix-or-namespace-rule.js index 58b80c95..b6b32be8 100644 --- a/src/rules/core/no-prefix-or-namespace-rule.js +++ b/src/rules/core/no-prefix-or-namespace-rule.js @@ -12,9 +12,19 @@ module.exports = class NoPrefixOrNamespaceRule extends Rule { name: 'NoPrefixOrNamespaceRule', description: 'Validates that properties that are aliased in the @context are not submitted in their unaliased form.', tests: { - typeAndId: { - description: 'Validates that @type and @id are submitted as type and id.', - message: 'OpenActive.io maps the JSON-LD property `@{{field}}` to `{{field}}`, so `{{field}}` should always be used as the name of this property.', + typeAndIdFailure: { + description: 'Validates that @type and @id are submitted as @type and @id, not using the deprecated aliases of type and id, for bookable data.', + message: 'The property name `@{{field}}` must always be used instead of `{{field}}`, as the use of `id` and `type` is now deprecated.', + sampleValues: { + field: 'type', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.USE_FIELD_ALIASES, + }, + typeAndIdWarning: { + description: 'Warns if @type and @id are submitted using the deprecated aliases type and id in non-bookable data.', + message: 'The property name `@{{field}}` should always be used instead of `{{field}}`, as the use of `id` and `type` is now deprecated.', sampleValues: { field: 'type', }, @@ -85,13 +95,14 @@ module.exports = class NoPrefixOrNamespaceRule extends Rule { const prop = PropertyHelper.getFullyQualifiedProperty(field, node.options.version); if ( - prop.alias !== null + node.model.isJsonLd + && prop.alias !== null && prop.namespace === null && prop.prefix === null - && field !== prop.alias - && false // Note this rule is temporarily disabled + && (prop.alias === 'type' || prop.alias === 'id') + && field !== `@${prop.alias}` ) { - testKey = 'typeAndId'; + testKey = node.options.validationMode === 'RPDEFeed' ? 'typeAndIdWarning' : 'typeAndIdFailure'; messageValues = { field: prop.alias, }; diff --git a/src/rules/core/recommended-fields-rule-spec.js b/src/rules/core/recommended-fields-rule-spec.js index 9cc93f4a..9325fa3a 100644 --- a/src/rules/core/recommended-fields-rule-spec.js +++ b/src/rules/core/recommended-fields-rule-spec.js @@ -66,7 +66,7 @@ describe('RecommendedFieldsRule', () => { }, }; - const loadInheritanceModel = () => Object.assign({}, baseInheritanceModel); + const loadInheritanceModel = () => ({ ...baseInheritanceModel }); const rule = new RecommendedFieldsRule(); @@ -78,7 +78,7 @@ describe('RecommendedFieldsRule', () => { it('should return no errors if all recommended fields are present', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', name: 'Tai chi Class', 'schema:description': 'A class about Tai Chi', }; @@ -97,7 +97,7 @@ describe('RecommendedFieldsRule', () => { it('should return a warning per field if any recommended fields are missing', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -121,7 +121,7 @@ describe('RecommendedFieldsRule', () => { it('should return no errors if all recommended fields are present', async () => { const data = { - type: 'Event', + '@type': 'Event', duration: 'PT1H30M', }; @@ -139,7 +139,7 @@ describe('RecommendedFieldsRule', () => { it('should return a warning per field if any recommended fields are missing', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -165,7 +165,7 @@ describe('RecommendedFieldsRule', () => { it('should return no errors if all recommended fields are present', async () => { const data = { - type: 'Event', + '@type': 'Event', barTab: 300, }; @@ -183,7 +183,7 @@ describe('RecommendedFieldsRule', () => { it('should return a warning per field if any recommended fields are missing', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -212,9 +212,9 @@ describe('RecommendedFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -248,9 +248,9 @@ describe('RecommendedFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -284,9 +284,9 @@ describe('RecommendedFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -320,9 +320,9 @@ describe('RecommendedFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -356,9 +356,9 @@ describe('RecommendedFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -392,9 +392,9 @@ describe('RecommendedFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -428,9 +428,9 @@ describe('RecommendedFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -464,9 +464,9 @@ describe('RecommendedFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -500,9 +500,9 @@ describe('RecommendedFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -536,9 +536,9 @@ describe('RecommendedFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; diff --git a/src/rules/core/recommended-fields-rule.js b/src/rules/core/recommended-fields-rule.js index c443e1d7..395318de 100644 --- a/src/rules/core/recommended-fields-rule.js +++ b/src/rules/core/recommended-fields-rule.js @@ -34,7 +34,6 @@ module.exports = class RecommendedFieldsRule extends Rule { } const errors = []; - const recommendedFields = node.model.getRecommendedFields(node.options.validationMode, node.name); for (const field of recommendedFields) { const testValue = node.getValueWithInheritance(field); diff --git a/src/rules/core/required-fields-rule-spec.js b/src/rules/core/required-fields-rule-spec.js index 82604785..41b196e8 100644 --- a/src/rules/core/required-fields-rule-spec.js +++ b/src/rules/core/required-fields-rule-spec.js @@ -69,7 +69,7 @@ describe('RequiredFieldsRule', () => { }, }; - const loadInheritanceModel = () => Object.assign({}, baseInheritanceModel); + const loadInheritanceModel = () => ({ ...baseInheritanceModel }); it('should target models of any type', () => { const isTargeted = rule.isModelTargeted(model); @@ -79,12 +79,12 @@ describe('RequiredFieldsRule', () => { it('should return no errors if all required fields are present', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', 'oa:activity': { - id: 'https://example.com/reference/activities#Speedball', + '@id': 'https://example.com/reference/activities#Speedball', inScheme: 'https://example.com/reference/activities', prefLabel: 'Speedball', - type: 'Concept', + '@type': 'Concept', }, location: { address: { @@ -92,19 +92,19 @@ describe('RequiredFieldsRule', () => { addressRegion: 'London', postalCode: 'NW5 3DU', streetAddress: 'Raynes Park High School, 46A West Barnes Lane', - type: 'PostalAddress', + '@type': 'PostalAddress', }, description: 'Raynes Park High School in London', geo: { latitude: 51.4034423828125, longitude: -0.2369088977575302, - type: 'GeoCoordinates', + '@type': 'GeoCoordinates', }, - id: 'https://example.com/locations/1234ABCD', + '@id': 'https://example.com/locations/1234ABCD', identifier: '1234ABCD', name: 'Raynes Park High School', telephone: '01253 473934', - type: 'Place', + '@type': 'Place', }, }; @@ -122,7 +122,7 @@ describe('RequiredFieldsRule', () => { it('should return a failure per field if any required fields are missing', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -146,7 +146,7 @@ describe('RequiredFieldsRule', () => { it('should return no errors if all required fields are present', async () => { const data = { - type: 'Event', + '@type': 'Event', duration: 'PT1H30M', }; @@ -164,7 +164,7 @@ describe('RequiredFieldsRule', () => { it('should return a failure per field if any required fields are missing', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -190,7 +190,7 @@ describe('RequiredFieldsRule', () => { it('should return no errors if all required fields are present', async () => { const data = { - type: 'Event', + '@type': 'Event', barTab: 300, }; @@ -209,7 +209,7 @@ describe('RequiredFieldsRule', () => { it('should return a failure per field if any required fields are missing', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', dressCode: 'blacktie', }; @@ -239,10 +239,10 @@ describe('RequiredFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -277,10 +277,10 @@ describe('RequiredFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -315,10 +315,10 @@ describe('RequiredFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -353,10 +353,10 @@ describe('RequiredFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -391,10 +391,10 @@ describe('RequiredFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -422,7 +422,6 @@ describe('RequiredFieldsRule', () => { }); }); - describe('with inheritsFrom properties', () => { it('should respect required fields when inheritsFrom is *', async () => { const modelObj = loadInheritanceModel(); @@ -430,11 +429,11 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, @@ -469,11 +468,11 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, @@ -508,11 +507,11 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, @@ -547,11 +546,11 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, @@ -586,11 +585,11 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, @@ -620,7 +619,6 @@ describe('RequiredFieldsRule', () => { }); }); - describe('with inheritsTo and inheritsFrom properties', () => { it('should respect required fields when inheritsFrom is *', async () => { const modelObj = loadInheritanceModel(); @@ -628,17 +626,17 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -672,17 +670,17 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -716,17 +714,17 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -760,17 +758,17 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; @@ -804,17 +802,17 @@ describe('RequiredFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }, subEvent: [{ subEvent: [{ - type: 'Event', + '@type': 'Event', }], }], }; diff --git a/src/rules/core/required-optional-fields-rule-spec.js b/src/rules/core/required-optional-fields-rule-spec.js index 2700ac6b..66d51b9a 100644 --- a/src/rules/core/required-optional-fields-rule-spec.js +++ b/src/rules/core/required-optional-fields-rule-spec.js @@ -98,7 +98,7 @@ describe('RequiredOptionalFieldsRule', () => { }, }; - const loadInheritanceModel = () => Object.assign({}, baseInheritanceModel); + const loadInheritanceModel = () => ({ ...baseInheritanceModel }); it('should target models of any type', () => { const isTargeted = rule.isModelTargeted(model); @@ -108,7 +108,7 @@ describe('RequiredOptionalFieldsRule', () => { it('should return no errors if required optional fields are present', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', startDate: '2018-01-27T12:00:00Z', }; @@ -126,7 +126,7 @@ describe('RequiredOptionalFieldsRule', () => { it('should return no errors if required optional fields are present with a namespace', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', 'schema:startDate': '2018-01-27T12:00:00Z', }; @@ -144,7 +144,7 @@ describe('RequiredOptionalFieldsRule', () => { it('should return a failure per option group if any required optional fields are missing', async () => { const data = { '@context': 'https://openactive.io/', - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -166,7 +166,7 @@ describe('RequiredOptionalFieldsRule', () => { const options = new OptionsHelper({ validationMode: 'C1Request' }); it('should return no errors if required optional fields are present', async () => { const data = { - type: 'Event', + '@type': 'Event', endDate: '2018-01-27T12:00:00Z', }; @@ -184,7 +184,7 @@ describe('RequiredOptionalFieldsRule', () => { it('should return a failure per option group if any required optional fields are missing', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2018-01-27T12:00:00Z', }; @@ -210,7 +210,7 @@ describe('RequiredOptionalFieldsRule', () => { it('should return no errors if required optional fields are present', async () => { const data = { - type: 'Event', + '@type': 'Event', church: 'Holy Trinity', }; @@ -228,7 +228,7 @@ describe('RequiredOptionalFieldsRule', () => { it('should return a failure per option group if any required optional fields are missing', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -256,9 +256,9 @@ describe('RequiredOptionalFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -292,9 +292,9 @@ describe('RequiredOptionalFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -328,9 +328,9 @@ describe('RequiredOptionalFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -364,9 +364,9 @@ describe('RequiredOptionalFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -400,9 +400,9 @@ describe('RequiredOptionalFieldsRule', () => { inheritanceModel.hasSpecification = true; const data = { name: 'Test Event', - type: 'Event', + '@type': 'Event', subEvent: [{ - type: 'Event', + '@type': 'Event', }], }; @@ -436,9 +436,9 @@ describe('RequiredOptionalFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -472,9 +472,9 @@ describe('RequiredOptionalFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -508,9 +508,9 @@ describe('RequiredOptionalFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -544,9 +544,9 @@ describe('RequiredOptionalFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; @@ -580,9 +580,9 @@ describe('RequiredOptionalFieldsRule', () => { const inheritanceModel = new Model(modelObj, 'latest'); inheritanceModel.hasSpecification = true; const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', name: 'Test Event', }, }; diff --git a/src/rules/core/shall-not-include-fields-rule-spec.js b/src/rules/core/shall-not-include-fields-rule-spec.js index d92bfd64..215cf82d 100644 --- a/src/rules/core/shall-not-include-fields-rule-spec.js +++ b/src/rules/core/shall-not-include-fields-rule-spec.js @@ -48,7 +48,7 @@ describe('ShallNotIncludeFieldsRule', () => { it('should return no error when no shall not include fields part of data', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -64,7 +64,7 @@ describe('ShallNotIncludeFieldsRule', () => { it('should return failures when shall not include fields present in data', async () => { const data = { - type: 'Event', + '@type': 'Event', duration: 1, }; @@ -87,7 +87,7 @@ describe('ShallNotIncludeFieldsRule', () => { it('should return no error when shall not include fields present in data', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const nodeToTest = new ModelNode( @@ -104,7 +104,7 @@ describe('ShallNotIncludeFieldsRule', () => { it('should return failures when shall not include fields present in data', async () => { const data = { - type: 'Event', + '@type': 'Event', price: 10000, }; @@ -126,7 +126,7 @@ describe('ShallNotIncludeFieldsRule', () => { describe('when no imperative config for validation mode', () => { it('should not create errors', async () => { const data = { - type: 'Event', + '@type': 'Event', duration: 1, }; diff --git a/src/rules/core/valid-model-type-rule-spec.js b/src/rules/core/valid-model-type-rule-spec.js index 9e458c87..2318770a 100644 --- a/src/rules/core/valid-model-type-rule-spec.js +++ b/src/rules/core/valid-model-type-rule-spec.js @@ -31,7 +31,7 @@ describe('ValidModelTypeRule', () => { it('should return no errors if there is a type we recognise', async () => { const data = { - type: 'Event', + '@type': 'Event', }; model.hasSpecification = true; @@ -72,7 +72,7 @@ describe('ValidModelTypeRule', () => { const parentNode = new ModelNode( '$', { - type: 'Event', + '@type': 'Event', subEvent: data, }, null, @@ -92,7 +92,7 @@ describe('ValidModelTypeRule', () => { for (const error of errors) { expect(error.type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD); expect(error.severity).toBe(ValidationErrorSeverity.FAILURE); - expect(error.message).toBe('Objects in `subEvent` must be of type `Event`. Please amend the property to `"type": "Event"` in the object to allow for further validation.\n\nFor example:\n\n```\n"subEvent": {\n "type": "Event"\n}\n```'); + expect(error.message).toBe('Objects in `subEvent` must be of type `Event`. Please amend the property to `"@type": "Event"` in the object to allow for further validation.\n\nFor example:\n\n```\n"subEvent": {\n "@type": "Event"\n}\n```'); } }); @@ -106,7 +106,7 @@ describe('ValidModelTypeRule', () => { const parentNode = new ModelNode( '$', { - type: 'Event', + '@type': 'Event', }, null, model, @@ -130,7 +130,7 @@ describe('ValidModelTypeRule', () => { it('should return a tip if the type is present, but we don\'t recognise the model', async () => { const data = { - type: 'OutsideSpec', + '@type': 'OutsideSpec', }; const nodeToTest = new ModelNode( diff --git a/src/rules/core/valid-model-type-rule.js b/src/rules/core/valid-model-type-rule.js index 7bb8c4b9..fc25658c 100644 --- a/src/rules/core/valid-model-type-rule.js +++ b/src/rules/core/valid-model-type-rule.js @@ -13,13 +13,13 @@ module.exports = class ValidModelTypeRule extends Rule { description: 'Validates that objects are submitted with a recognised type.', tests: { noType: { - message: 'Please add a `type` property to this JSON object.\n\nFor example:\n\n```\n{\n "type": "Event"\n}\n```', + message: 'Please add an `@type` property to this JSON object.\n\nFor example:\n\n```\n{\n "@type": "Event"\n}\n```', category: ValidationErrorCategory.DATA_QUALITY, severity: ValidationErrorSeverity.FAILURE, type: ValidationErrorType.MISSING_REQUIRED_FIELD, }, noTypeWithHint: { - message: 'Objects in `{{field}}` must be of type `{{typeHint}}`. Please amend the property to `"type": "{{typeHint}}"` in the object to allow for further validation.\n\nFor example:\n\n```\n"{{field}}": {\n "type": "{{typeHint}}"\n}\n```', + message: 'Objects in `{{field}}` must be of type `{{typeHint}}`. Please amend the property to `"@type": "{{typeHint}}"` in the object to allow for further validation.\n\nFor example:\n\n```\n"{{field}}": {\n "@type": "{{typeHint}}"\n}\n```', sampleValues: { field: 'activity', typeHint: 'Concept', @@ -29,7 +29,7 @@ module.exports = class ValidModelTypeRule extends Rule { type: ValidationErrorType.MISSING_REQUIRED_FIELD, }, noTypeWithArrayHint: { - message: 'Objects in `{{field}}` must be of type `{{typeHint}}`. Please amend the property to `"type": "{{typeHint}}"` in the object to allow for further validation.\n\nFor example:\n\n```\n"{{field}}": [\n {\n "type": "{{typeHint}}"\n }\n]\n```', + message: 'Objects in `{{field}}` must be of type `{{typeHint}}`. Please amend the property to `"@type": "{{typeHint}}"` in the object to allow for further validation.\n\nFor example:\n\n```\n"{{field}}": [\n {\n "@type": "{{typeHint}}"\n }\n]\n```', sampleValues: { field: 'activity', typeHint: 'Concept', @@ -41,7 +41,7 @@ module.exports = class ValidModelTypeRule extends Rule { noExperimental: { message: 'Type `{{type}}` is not recognised by the validator, as it is not part of the [Modelling Opportunity Data specification](https://openactive.io/modelling-opportunity-data/) or schema.org, and cannot be checked for validity.', sampleValues: { - type: 'CreativeWork', + '@type': 'CreativeWork', }, category: ValidationErrorCategory.DATA_QUALITY, severity: ValidationErrorSeverity.SUGGESTION, @@ -69,7 +69,7 @@ module.exports = class ValidModelTypeRule extends Rule { const fieldObj = node.parentNode.model.getField(node.name); if (typeof fieldObj === 'object' && fieldObj !== null) { const types = fieldObj.getAllPossibleTypes(); - const uniqueTypes = [...new Set(types.map(x => x.replace(/^ArrayOf/, '')))]; + const uniqueTypes = [...new Set(types.map((x) => x.replace(/^ArrayOf/, '')))]; if (uniqueTypes.length === 1 && uniqueTypes[0].match(/^#/)) { testKey = 'noTypeWithHint'; if (typeof node.arrayIndex !== 'undefined') { diff --git a/src/rules/core/value-in-options-rule-spec.js b/src/rules/core/value-in-options-rule-spec.js index 41ede237..d1d0138a 100644 --- a/src/rules/core/value-in-options-rule-spec.js +++ b/src/rules/core/value-in-options-rule-spec.js @@ -40,6 +40,15 @@ describe('ValueInOptionsRule', () => { 'HEAD', ], }, + genderRestriction: { + fieldName: 'genderRestriction', + requiredType: 'https://schema.org/Text', + options: [ + 'https://openactive.io/NoRestriction', + 'https://openactive.io/MaleOnly', + 'https://openactive.io/FemaleOnly', + ], + }, }, }, 'latest'); model.hasSpecification = true; @@ -53,7 +62,7 @@ describe('ValueInOptionsRule', () => { it('should return no errors if the field value is in the options array', async () => { const data = { - type: 'Event', + '@type': 'Event', singleOption: 'GET', }; @@ -70,7 +79,7 @@ describe('ValueInOptionsRule', () => { it('should return a failure if the field value is not in the options array', async () => { const data = { - type: 'Event', + '@type': 'Event', singleOption: 'DELETE', }; @@ -90,7 +99,7 @@ describe('ValueInOptionsRule', () => { it('should return no errors if the field value is in the enum options array', async () => { const data = { - type: 'Event', + '@type': 'Event', eventStatus: 'https://schema.org/EventScheduled', }; @@ -107,7 +116,7 @@ describe('ValueInOptionsRule', () => { it('should return a failure if the field value is not in the enum options array', async () => { const data = { - type: 'Event', + '@type': 'Event', eventStatus: 'https://schema.org/EventInvalid', }; @@ -127,7 +136,7 @@ describe('ValueInOptionsRule', () => { it('should return no errors if the field value is in the options array when the value is an array', async () => { const data = { - type: 'Event', + '@type': 'Event', multipleOption: ['GET'], }; @@ -144,7 +153,7 @@ describe('ValueInOptionsRule', () => { it('should return a failure if the field value is not in the options array when the value is an array', async () => { const data = { - type: 'Event', + '@type': 'Event', multipleOption: ['DELETE'], }; @@ -164,7 +173,7 @@ describe('ValueInOptionsRule', () => { it('should return no errors if the field value is in the enum options array when the value is an array', async () => { const data = { - type: 'Event', + '@type': 'Event', dayOfWeek: ['https://schema.org/Sunday'], }; @@ -181,7 +190,7 @@ describe('ValueInOptionsRule', () => { it('should return a failure if the field value is not in the enum options array when the value is an array', async () => { const data = { - type: 'Event', + '@type': 'Event', dayOfWeek: ['https://schema.org/Thirdday'], }; @@ -201,7 +210,7 @@ describe('ValueInOptionsRule', () => { it('should return no errors if the field value in goodrelations is in the enum options array', async () => { const data = { - type: 'Offer', + '@type': 'Offer', acceptedPaymentMethod: ['http://purl.org/goodrelations/v1#Cash'], }; @@ -218,7 +227,7 @@ describe('ValueInOptionsRule', () => { it('should return a failure if the field value in goodrelations is not in the enum options array', async () => { const data = { - type: 'Offer', + '@type': 'Offer', acceptedPaymentMethod: ['http://purl.org/goodrelations/v1#Invalid'], }; @@ -235,4 +244,28 @@ describe('ValueInOptionsRule', () => { expect(errors[0].type).toBe(ValidationErrorType.FIELD_NOT_IN_DEFINED_VALUES); expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); }); + + it('should return a different error message if the field value is an array', async () => { + const data = { + '@type': 'Offer', + genderRestriction: [ + 'https://openactive.io/NoRestriction', + 'https://openactive.io/MaleOnly', + 'https://openactive.io/FemaleOnly', + ], + }; + + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + + expect(errors[0].message).toContain('This property cannot be an array. Must be one of:'); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + }); }); diff --git a/src/rules/core/value-in-options-rule.js b/src/rules/core/value-in-options-rule.js index 70ed550a..10958ef1 100644 --- a/src/rules/core/value-in-options-rule.js +++ b/src/rules/core/value-in-options-rule.js @@ -16,7 +16,17 @@ module.exports = class ValueInOptionsRule extends Rule { message: 'Value `"{{value}}"` is not in the allowed values for this property. Must be one of:\n\n{{allowedValues}}.', sampleValues: { value: 'Male', - allowedValues: '
  • `"https://openactive.io/Female"`
  • `"https://openactive.io/Male"`
  • `"https://openactive.io/None"`
', + allowedValues: '
  • `"https://openactive.io/Female"`
  • `"https://openactive.io/Male"`
  • `"https://openactive.io/NoRestriction"`
', + }, + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.FIELD_NOT_IN_DEFINED_VALUES, + }, + fieldArray: { + message: 'This property cannot be an array. Must be one of:\n\n{{allowedValues}}.', + sampleValues: { + value: 'Male', + allowedValues: '
  • `"https://openactive.io/Female"`
  • `"https://openactive.io/Male"`
  • `"https://openactive.io/NoRestriction"`
', }, category: ValidationErrorCategory.CONFORMANCE, severity: ValidationErrorSeverity.FAILURE, @@ -40,6 +50,7 @@ module.exports = class ValueInOptionsRule extends Rule { && typeof fieldObj !== 'undefined' ) { let isInOptions = true; + let isArrayButCannotBeArray = false; let allowedOptions; let testType; const possibleTypes = fieldObj.getAllPossibleTypes(); @@ -65,16 +76,33 @@ module.exports = class ValueInOptionsRule extends Rule { for (const value of fieldValue) { if (allowedOptions.indexOf(value) < 0) { isInOptions = false; + isArrayButCannotBeArray = false; singleFieldValue = value; break; } } + } else if (fieldValue instanceof Array && !fieldObj.canBeArray()) { + isArrayButCannotBeArray = true; } else if (allowedOptions.indexOf(fieldValue) < 0) { isInOptions = false; } } - if (!isInOptions) { + if (isArrayButCannotBeArray) { + errors.push( + this.createError( + 'fieldArray', + { + value: singleFieldValue, + path: node.getPath(field), + }, + { + value: singleFieldValue, + allowedValues: `
  • \`"${allowedOptions.join('"`
  • `"')}"\`
`, + }, + ), + ); + } else if (!isInOptions) { errors.push( this.createError( 'default', diff --git a/src/rules/core/value-is-required-content-rule-spec.js b/src/rules/core/value-is-required-content-rule-spec.js index 28220e8e..1bf4cbc0 100644 --- a/src/rules/core/value-is-required-content-rule-spec.js +++ b/src/rules/core/value-is-required-content-rule-spec.js @@ -26,7 +26,7 @@ describe('ValueIsRequiredContentRule', () => { it('should return no errors if the field value matches required content', async () => { const data = { - type: 'Event', + '@type': 'Event', 'schema:eventStatus': 'https://schema.org/EventScheduled', }; @@ -43,7 +43,7 @@ describe('ValueIsRequiredContentRule', () => { it('should return a failure if the field value does not match required content', async () => { const data = { - type: 'Event', + '@type': 'Event', eventStatus: 'https://schema.org/EventInvalid', }; diff --git a/src/rules/core/valueconstraint-rule-spec.js b/src/rules/core/valueconstraint-rule-spec.js new file mode 100644 index 00000000..c7e3784e --- /dev/null +++ b/src/rules/core/valueconstraint-rule-spec.js @@ -0,0 +1,121 @@ +const ValueConstraintRule = require('./valueconstraint-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'); + +describe('ValueConstraintRule', () => { + const rule = new ValueConstraintRule(); + + const model = new Model({ + type: 'Schedule', + fields: { + uuid: { + fieldName: 'uuid', + valueConstraint: 'UUID', + }, + uritemplate: { + fieldName: 'uritemplate', + valueConstraint: 'UriTemplate', + }, + }, + }, 'latest'); + model.hasSpecification = true; + + it('should target any field', () => { + const isTargeted = rule.isFieldTargeted(model, 'repeatCount'); + expect(isTargeted).toBe(true); + }); + + it('should return no errors for a value that is a valid UUID', async () => { + const values = [ + '123e4567-e89b-12d3-a456-426614174000', + '00000000-0000-0000-0000-000000000000', + ]; + + for (const value of values) { + const data = { + uuid: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + } + }); + + it('should return an error when the value is not a valid UUID', async () => { + const values = [ + '123E4567-E89B-12D3-A456-426614174000', + '000000000000000000000000000000000000', + '123e4567-e89b-12d3-a456-4266141740000', + '123e4567-e89b-12d3-a456-42661417400', + 0, + 1.1, + ]; + + for (const value of values) { + const data = { + uuid: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + expect(errors[0].type).toBe(ValidationErrorType.VALUE_OUTWITH_CONSTRAINT); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); + + it('should return no errors for a value that is a valid URI Template', async () => { + const values = [ + 'https://api.example.org/session-series/123/{startDate}', + ]; + + for (const value of values) { + const data = { + uritemplate: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + expect(errors.length).toBe(0); + } + }); + + it('should return an error when the value is not a valid URI Template', async () => { + const values = [ + 'https://api.example.org/session-series/123/', + ]; + + for (const value of values) { + const data = { + uritemplate: value, + }; + const nodeToTest = new ModelNode( + '$', + data, + null, + model, + ); + const errors = await rule.validate(nodeToTest); + + expect(errors.length).toBe(1); + expect(errors[0].type).toBe(ValidationErrorType.VALUE_OUTWITH_CONSTRAINT); + expect(errors[0].severity).toBe(ValidationErrorSeverity.FAILURE); + } + }); +}); diff --git a/src/rules/core/valueconstraint-rule.js b/src/rules/core/valueconstraint-rule.js new file mode 100644 index 00000000..839ecb6d --- /dev/null +++ b/src/rules/core/valueconstraint-rule.js @@ -0,0 +1,66 @@ +const Rule = require('../rule'); +const PropertyHelper = require('../../helpers/property'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +module.exports = class ValueConstraintRule extends Rule { + constructor(options) { + super(options); + this.targetFields = '*'; + this.meta = { + name: 'ValueConstraintRule', + description: 'Validates that all properties meet the associated valueConstraint parameter.', + tests: { + valueConstraint: { + description: 'Raises a failure if the value does not match the associated constraint', + message: 'The value of this property did not match the expected "{{valueConstraint}}" format.', + sampleValues: { + valueConstraint: 'UriTemplate', + }, + category: ValidationErrorCategory.DATA_QUALITY, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.VALUE_OUTWITH_CONSTRAINT, + }, + }, + }; + } + + validateField(node, field) { + // Don't do this check for models that we don't actually have a spec for + if (!node.model.hasSpecification) { + return []; + } + if (!node.model.hasField(field)) { + return []; + } + + const errors = []; + + // Get the field object + const fieldObj = node.model.getField(field); + const fieldValue = node.getValue(field); + + if (typeof fieldObj.valueConstraint !== 'undefined' + && (typeof fieldValue !== 'string' + || (fieldObj.valueConstraint === 'UriTemplate' && !PropertyHelper.isUrlTemplate(fieldValue)) + || (fieldObj.valueConstraint === 'UUID' && !PropertyHelper.isValidUUID(fieldValue)))) { + errors.push( + this.createError( + 'valueConstraint', + { + fieldValue, + path: node.getPath(field), + }, + { + valueConstraint: fieldObj.valueConstraint, + }, + ), + ); + } else { + return []; + } + + return errors; + } +}; diff --git a/src/rules/data-quality/activity-in-activity-list-rule-spec.js b/src/rules/data-quality/activity-in-activity-list-rule-spec.js index ae1ec70d..0be5932c 100644 --- a/src/rules/data-quality/activity-in-activity-list-rule-spec.js +++ b/src/rules/data-quality/activity-in-activity-list-rule-spec.js @@ -24,7 +24,7 @@ describe('ActivityInActivityListRule', () => { const activityList = { '@context': 'https://openactive.io/', - '@id': 'https://openactive.io/activity-list', + id: 'https://openactive.io/activity-list', title: 'OpenActive Activity List', description: 'This document describes the OpenActive standard activity list.', type: 'ConceptScheme', @@ -58,10 +58,10 @@ describe('ActivityInActivityListRule', () => { it('should return no error when an activity in the list is supplied', async () => { const data = { - type: 'Event', + '@type': 'Event', }; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NONE, statusCode: 200, data: activityList, @@ -74,12 +74,12 @@ describe('ActivityInActivityListRule', () => { const activities = [ { prefLabel: 'Football', - type: 'Concept', + '@type': 'Concept', inScheme: 'https://openactive.io/activity-list', }, { prefLabel: 'flag football', - type: 'Concept', + '@type': 'Concept', inScheme: 'https://openactive.io/activity-list', }, ]; @@ -99,10 +99,10 @@ describe('ActivityInActivityListRule', () => { }); it('should return an error when an activity not in the list is supplied', async () => { const data = { - type: 'Event', + '@type': 'Event', }; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NONE, statusCode: 200, data: activityList, @@ -116,12 +116,12 @@ describe('ActivityInActivityListRule', () => { { id: 'https://openactive.io/activity-list#a4375402-067d-4549-9d3a-8c1e998350a3', prefLabel: 'Secret Football', - type: 'Concept', + '@type': 'Concept', }, { id: 'https://openactive.io/activity-list#a4375402-067d-4549-9d3a-8c1e998350a3', prefLabel: 'Not Real Football', - type: 'Concept', + '@type': 'Concept', }, ]; @@ -144,19 +144,19 @@ describe('ActivityInActivityListRule', () => { }); it('should return an error when an activity list URL does not exist', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const activities = [ { id: 'https://openactive.io/activity-list/#a4375402-067d-4549-9d3a-8c1e998350a3', prefLabel: 'Not Real Football', - type: 'Concept', + '@type': 'Concept', inScheme: 'http://example.org/bad-list.jsonld', }, ]; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NO_REMOTE, statusCode: 404, data: null, @@ -188,10 +188,10 @@ describe('ActivityInActivityListRule', () => { }); it('should return an error when using an old Activity List URL', async () => { const data = { - type: 'Event', + '@type': 'Event', }; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NONE, statusCode: 200, data: activityList, @@ -205,7 +205,7 @@ describe('ActivityInActivityListRule', () => { { id: 'https://openactive.io/activity-list/#a4375402-067d-4549-9d3a-8c1e998350a3', prefLabel: 'Not Real Football', - type: 'Concept', + '@type': 'Concept', inScheme: 'https://openactive.io/activity-list/activity-list.jsonld', }, ]; @@ -230,19 +230,19 @@ describe('ActivityInActivityListRule', () => { }); it('should return an error when an activity list URL contains invalid JSON', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const activities = [ { id: 'https://openactive.io/activity-list/#a4375402-067d-4549-9d3a-8c1e998350a3', prefLabel: 'Not Real Football', - type: 'Concept', + '@type': 'Concept', inScheme: 'http://example.org/bad-list.jsonld', }, ]; - spyOn(JsonLoaderHelper, 'getFile').and.callFake(async url => ({ + spyOn(JsonLoaderHelper, 'getFile').and.callFake(async (url) => ({ errorCode: JsonLoaderHelper.ERROR_NO_REMOTE, statusCode: 200, data: null, diff --git a/src/rules/data-quality/activity-in-activity-list-rule.js b/src/rules/data-quality/activity-in-activity-list-rule.js index 8a5448dc..83fa1bad 100644 --- a/src/rules/data-quality/activity-in-activity-list-rule.js +++ b/src/rules/data-quality/activity-in-activity-list-rule.js @@ -44,7 +44,7 @@ module.exports = class ActivityInActivityListRule extends Rule { type: ValidationErrorType.ACTIVITY_NOT_IN_ACTIVITY_LIST, }, noIdMatch: { - message: 'Activity `"{{activity}}"` was found in the activity list `"{{list}}"`, but the `"id"` did not match.\n\nThe correct `"id"` is `"{{correctId}}"`.', + message: 'Activity `"{{activity}}"` was found in the activity list `"{{list}}"`, but the `"@id"` did not match.\n\nThe correct `"@id"` is `"{{correctId}}"`.', sampleValues: { activity: 'Touch Rugby Union', correctId: 'https://openactive.io/activity-list#dc8b8b2b-0a83-403f-863a-4ec05ebb2410', diff --git a/src/rules/data-quality/address-trailing-comma-rule-spec.js b/src/rules/data-quality/address-trailing-comma-rule-spec.js index 293cbee7..0fe73800 100644 --- a/src/rules/data-quality/address-trailing-comma-rule-spec.js +++ b/src/rules/data-quality/address-trailing-comma-rule-spec.js @@ -40,7 +40,7 @@ describe('AddressTrailingCommaRule', () => { it('should return no error when an address has no trailing commas', async () => { const data = { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1, Test Road', addressLocality: 'Test Locality', addressRegion: 'Testshire', @@ -59,7 +59,7 @@ describe('AddressTrailingCommaRule', () => { }); it('should return an error when an address has trailing commas', async () => { const data = { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1, Test Road,', addressLocality: 'Test Locality, ', addressRegion: 'Testshire,', @@ -82,7 +82,7 @@ describe('AddressTrailingCommaRule', () => { }); it('should return an error when an address has trailing commas in namespaced fields', async () => { const data = { - type: 'PostalAddress', + '@type': 'PostalAddress', 'schema:streetAddress': '1, Test Road,', 'schema:addressLocality': 'Test Locality, ', 'schema:addressRegion': 'Testshire,', diff --git a/src/rules/data-quality/address-warning-rule-spec.js b/src/rules/data-quality/address-warning-rule-spec.js index c259201a..a8c57df2 100644 --- a/src/rules/data-quality/address-warning-rule-spec.js +++ b/src/rules/data-quality/address-warning-rule-spec.js @@ -32,9 +32,9 @@ describe('AddressWarningRule', () => { // No error it('should return no error when address is a PostalAddress object', async () => { const data = { - type: 'Place', + '@type': 'Place', address: { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1, Test Road', }, }; @@ -50,7 +50,7 @@ describe('AddressWarningRule', () => { }); it('should return no error when address is not set', async () => { const data = { - type: 'Place', + '@type': 'Place', }; const nodeToTest = new ModelNode( @@ -66,7 +66,7 @@ describe('AddressWarningRule', () => { // Error it('should return an error when address is a string', async () => { const data = { - type: 'Event', + '@type': 'Event', address: '1, Test Road', }; diff --git a/src/rules/data-quality/age-range-min-or-max-rule-spec.js b/src/rules/data-quality/age-range-min-or-max-rule-spec.js index 80d15180..6b6a9f11 100644 --- a/src/rules/data-quality/age-range-min-or-max-rule-spec.js +++ b/src/rules/data-quality/age-range-min-or-max-rule-spec.js @@ -24,9 +24,9 @@ describe('AgeRangeMinOrMaxRule', () => { it('should return no error when a minValue is specified', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 1, }, }; @@ -42,9 +42,9 @@ describe('AgeRangeMinOrMaxRule', () => { }); it('should return no error when a minValue is specified in a namespaced field', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', 'schema:minValue': 1, }, }; @@ -60,9 +60,9 @@ describe('AgeRangeMinOrMaxRule', () => { }); it('should return no error when a maxValue is specified', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', maxValue: 1, }, }; @@ -78,9 +78,9 @@ describe('AgeRangeMinOrMaxRule', () => { }); it('should return an error when no minValue or maxValue is set', async () => { const data = { - type: 'Event', + '@type': 'Event', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', }, }; diff --git a/src/rules/data-quality/available-channel-for-prepayment-rule-spec.js b/src/rules/data-quality/available-channel-for-prepayment-rule-spec.js index 153fea5c..4c6e47b8 100644 --- a/src/rules/data-quality/available-channel-for-prepayment-rule-spec.js +++ b/src/rules/data-quality/available-channel-for-prepayment-rule-spec.js @@ -28,7 +28,7 @@ describe('AvailableChannelForPrepaymentRule', () => { it('should return no error when payment is not set', async () => { const data = { - type: 'Offer', + '@type': 'Offer', }; const nodeToTest = new ModelNode( @@ -50,7 +50,7 @@ describe('AvailableChannelForPrepaymentRule', () => { for (const validChannel of validAvailableChannelsForPrepayment) { it(`should return no error when availableChannel contains ${validChannel}`, async () => { const data = { - type: 'Offer', + '@type': 'Offer', prepayment, availableChannel: [validChannel, invalidChannel], }; @@ -68,7 +68,7 @@ describe('AvailableChannelForPrepaymentRule', () => { it('should return an error when availableChannel does not contain a valid value', async () => { const data = { - type: 'Offer', + '@type': 'Offer', prepayment, availableChannel: [invalidChannel], }; diff --git a/src/rules/data-quality/available-channel-for-prepayment-rule.js b/src/rules/data-quality/available-channel-for-prepayment-rule.js index cc989a10..26ecab0b 100644 --- a/src/rules/data-quality/available-channel-for-prepayment-rule.js +++ b/src/rules/data-quality/available-channel-for-prepayment-rule.js @@ -29,7 +29,7 @@ module.exports = class AvailableChannelPrepaymentRule extends Rule { if (['https://openactive.io/Required', 'https://openactive.io/Optional'].includes(prepaymentValue)) { const validAvailableChannels = ['https://openactive.io/OpenBookingPrepayment', 'https://openactive.io/TelephonePrepayment', 'https://openactive.io/OnlinePrepayment']; - const validAndPresentAvailableChannels = validAvailableChannels.filter(x => availableChannels.includes(x)); + const validAndPresentAvailableChannels = validAvailableChannels.filter((x) => availableChannels.includes(x)); if (validAndPresentAvailableChannels.length === 0) { errors.push( this.createError( diff --git a/src/rules/data-quality/concept-id-in-scheme-rule-spec.js b/src/rules/data-quality/concept-id-in-scheme-rule-spec.js index 196d8a82..be75e4ed 100644 --- a/src/rules/data-quality/concept-id-in-scheme-rule-spec.js +++ b/src/rules/data-quality/concept-id-in-scheme-rule-spec.js @@ -34,8 +34,8 @@ describe('ConceptIdInSchemeRule', () => { it('should return no error when both id and inScheme are specified', async () => { const data = { - type: 'Concept', - id: 'http://example.org/concept/1', + '@type': 'Concept', + '@id': 'http://example.org/concept/1', inScheme: 'http://example.org/scheme/2', }; @@ -50,8 +50,8 @@ describe('ConceptIdInSchemeRule', () => { }); it('should return no error when both id and inScheme are specified in a namespaced field', async () => { const data = { - type: 'Concept', - id: 'http://example.org/concept/1', + '@type': 'Concept', + '@id': 'http://example.org/concept/1', 'skos:inScheme': 'http://example.org/scheme/2', }; @@ -66,7 +66,7 @@ describe('ConceptIdInSchemeRule', () => { }); it('should return no error when neither id or inScheme are specified', async () => { const data = { - type: 'Concept', + '@type': 'Concept', }; const nodeToTest = new ModelNode( @@ -80,8 +80,8 @@ describe('ConceptIdInSchemeRule', () => { }); it('should return an error when an id but no inScheme is set', async () => { const data = { - type: 'Concept', - id: 'http://example.org/concept/1', + '@type': 'Concept', + '@id': 'http://example.org/concept/1', }; const nodeToTest = new ModelNode( @@ -97,7 +97,7 @@ describe('ConceptIdInSchemeRule', () => { }); it('should return an error when an inScheme but no id is set', async () => { const data = { - type: 'Concept', + '@type': 'Concept', inScheme: 'http://example.org/scheme/2', }; diff --git a/src/rules/data-quality/concept-id-in-scheme-rule.js b/src/rules/data-quality/concept-id-in-scheme-rule.js index 1c8449ca..1a39e75b 100644 --- a/src/rules/data-quality/concept-id-in-scheme-rule.js +++ b/src/rules/data-quality/concept-id-in-scheme-rule.js @@ -24,7 +24,7 @@ module.exports = class ConceptIdInSchemeRule extends Rule { }, activity: { description: 'Validates that both id and inScheme are set on Concept if one of them is set in an activity list.', - message: 'When using an activity list via `inScheme`, `id` must also be included to reference the Concept in the activity list.\n\n`id` must not be set without reference to an activity list.\n\nAn example referece to an activity list is below:\n\n```\n"activity": [\n {\n "type": "Concept",\n "id": "https://openactive.io/activity-list#72ddb2dc-7d75-424e-880a-d90eabe91381",\n "inScheme": "https://openactive.io/activity-list",\n "prefLabel": "Running"\n }\n]\n```', + message: 'When using an activity list via `inScheme`, `id` must also be included to reference the Concept in the activity list.\n\n`id` must not be set without reference to an activity list.\n\nAn example reference to an activity list is below:\n\n```\n"activity": [\n {\n "@type": "Concept",\n "id": "https://openactive.io/activity-list#72ddb2dc-7d75-424e-880a-d90eabe91381",\n "inScheme": "https://openactive.io/activity-list",\n "prefLabel": "Running"\n }\n]\n```', category: ValidationErrorCategory.DATA_QUALITY, severity: ValidationErrorSeverity.WARNING, type: ValidationErrorType.CONCEPT_ID_AND_IN_SCHEME_TOGETHER, diff --git a/src/rules/data-quality/concept-no-props-if-inscheme-rule-spec.js b/src/rules/data-quality/concept-no-props-if-inscheme-rule-spec.js index 9aef7e2a..ea33a744 100644 --- a/src/rules/data-quality/concept-no-props-if-inscheme-rule-spec.js +++ b/src/rules/data-quality/concept-no-props-if-inscheme-rule-spec.js @@ -39,7 +39,7 @@ describe('ConceptNoPropsIfInSchemeRule', () => { it('should return no error when inScheme is not specified', async () => { const data = { - type: 'Concept', + '@type': 'Concept', broader: ['http://example.org/concept/1'], narrower: ['http://example.org/scheme/2'], }; @@ -55,8 +55,8 @@ describe('ConceptNoPropsIfInSchemeRule', () => { }); it('should return no error when both inScheme is specified in a namespaced field', async () => { const data = { - type: 'Concept', - id: 'http://example.org/concept/1', + '@type': 'Concept', + '@id': 'http://example.org/concept/1', 'skos:inScheme': 'http://example.org/scheme/2', }; @@ -71,7 +71,7 @@ describe('ConceptNoPropsIfInSchemeRule', () => { }); it('should return no error when neither broader or narrower are specified', async () => { const data = { - type: 'Concept', + '@type': 'Concept', inScheme: 'http://example.org/scheme/2', }; diff --git a/src/rules/data-quality/currency-if-non-zero-price-rule-spec.js b/src/rules/data-quality/currency-if-non-zero-price-rule-spec.js index 1026e584..9abb3cfc 100644 --- a/src/rules/data-quality/currency-if-non-zero-price-rule-spec.js +++ b/src/rules/data-quality/currency-if-non-zero-price-rule-spec.js @@ -5,64 +5,140 @@ const ValidationErrorType = require('../../errors/validation-error-type'); const ValidationErrorSeverity = require('../../errors/validation-error-severity'); describe('CurrencyIfNonZeroPriceRule', () => { - let model; + let models; let rule; beforeEach(() => { - model = new Model({ - type: 'Offer', - inSpec: [ - 'price', - 'priceCurrency', - ], - }, 'latest'); + models = { + Offer: new Model({ + type: 'Offer', + inSpec: [ + 'price', + 'priceCurrency', + ], + }, 'latest'), + OfferOverride: new Model({ + type: 'OfferOverride', + inSpec: [ + 'price', + 'priceCurrency', + ], + }, 'latest'), + TaxChargeSpecification: new Model({ + type: 'TaxChargeSpecification', + inSpec: [ + 'price', + 'priceCurrency', + ], + }, 'latest'), + PriceSpecification: new Model({ + type: 'PriceSpecification', + inSpec: [ + 'price', + 'priceCurrency', + ], + }, 'latest'), + }; rule = new CurrencyIfNonZeroPriceRule(); }); it('should target Offer models', () => { - const isTargeted = rule.isModelTargeted(model); + let isTargeted = rule.isModelTargeted(models.Offer); + expect(isTargeted).toBe(true); + isTargeted = rule.isModelTargeted(models.OfferOverride); + expect(isTargeted).toBe(true); + isTargeted = rule.isModelTargeted(models.TaxChargeSpecification); + expect(isTargeted).toBe(true); + isTargeted = rule.isModelTargeted(models.PriceSpecification); expect(isTargeted).toBe(true); }); it('should return no errors if the Offer has a price of zero', async () => { - const data = { - type: 'Offer', - price: 0, - }; + const dataItems = [ + { + '@type': 'Offer', + price: 0, + }, + { + '@type': 'OfferOverride', + price: 0, + }, + { + '@type': 'TaxChargeSpecification', + price: 0, + }, + { + '@type': 'PriceSpecification', + price: 0, + }, + ]; - const nodeToTest = new ModelNode( - '$', - data, - null, - model, - ); - const errors = await rule.validate(nodeToTest); + for (const data of dataItems) { + const nodeToTest = new ModelNode( + '$', + data, + null, + models[data['@type']], + ); + const errors = await rule.validate(nodeToTest); - expect(errors.length).toBe(0); + expect(errors.length).toBe(0); + } }); it('should return no errors if the Offer has a non-zero price, but has a currency set', async () => { - const data = { - type: 'Offer', - price: 5, - priceCurrency: 'GBP', - }; + const dataItems = [ + { + '@type': 'Offer', + price: 0, + priceCurrency: 'GBP', + }, + { + '@type': 'OfferOverride', + price: 0, + priceCurrency: 'GBP', + }, + { + '@type': 'TaxChargeSpecification', + price: 0, + priceCurrency: 'GBP', + }, + { + '@type': 'PriceSpecification', + price: 0, + priceCurrency: 'GBP', + }, + ]; - const nodeToTest = new ModelNode( - '$', - data, - null, - model, - ); - const errors = await rule.validate(nodeToTest); + for (const data of dataItems) { + const nodeToTest = new ModelNode( + '$', + data, + null, + models[data['@type']], + ); + const errors = await rule.validate(nodeToTest); - expect(errors.length).toBe(0); + expect(errors.length).toBe(0); + } }); it('should return an error if the Offer has a non-zero price, and has no currency set', async () => { const dataItems = [ { - type: 'Offer', + '@type': 'Offer', + price: 5, + }, + { + '@type': 'OfferOverride', + price: 5, + }, + { + '@type': 'TaxChargeSpecification', + price: 5, + }, + { + '@type': 'PriceSpecification', price: 5, }, ]; @@ -72,7 +148,7 @@ describe('CurrencyIfNonZeroPriceRule', () => { '$', data, null, - model, + models[data['@type']], ); const errors = await rule.validate(nodeToTest); diff --git a/src/rules/data-quality/currency-if-non-zero-price-rule.js b/src/rules/data-quality/currency-if-non-zero-price-rule.js index eae045c6..3d65ddb6 100644 --- a/src/rules/data-quality/currency-if-non-zero-price-rule.js +++ b/src/rules/data-quality/currency-if-non-zero-price-rule.js @@ -6,13 +6,13 @@ const ValidationErrorSeverity = require('../../errors/validation-error-severity' module.exports = class CurrencyIfNonZeroPriceRule extends Rule { constructor(options) { super(options); - this.targetModels = ['Offer']; + this.targetModels = ['TaxChargeSpecification', 'PriceSpecification', 'Offer', 'OfferOverride']; this.meta = { name: 'CurrencyIfNonZeroPriceRule', description: 'Validates that a priceCurrency is set if an Offer\'s price is non-zero.', tests: { default: { - message: 'A `priceCurrency` is required on an `Offer` containing a non-zero `price`.\n\nYou can fix this by setting a `priceCurrency` field on this `Offer`.\n\ne.g.\n\n```\n{\n "type": "{{model}}",\n "price": {{price}},\n "priceCurrency": "GBP"\n}\n```', + message: 'A `priceCurrency` is required on an `Offer` containing a non-zero `price`.\n\nYou can fix this by setting a `priceCurrency` field on this `Offer`.\n\ne.g.\n\n```\n{\n "@type": "{{model}}",\n "price": {{price}},\n "priceCurrency": "GBP"\n}\n```', sampleValues: { model: 'Offer', price: 5, diff --git a/src/rules/data-quality/dates-must-have-duration-rule-spec.js b/src/rules/data-quality/dates-must-have-duration-rule-spec.js index 7006438a..da437c16 100644 --- a/src/rules/data-quality/dates-must-have-duration-rule-spec.js +++ b/src/rules/data-quality/dates-must-have-duration-rule-spec.js @@ -35,7 +35,7 @@ describe('DatesMustHaveDurationRule', () => { it('should return no error when a duration is supplied with a startDate and endDate', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2017-09-06T09:00:00Z', endDate: '2017-09-06T10:00:00Z', duration: 'PT1H', @@ -53,7 +53,7 @@ describe('DatesMustHaveDurationRule', () => { }); it('should return no error when a duration is supplied with a startDate and endDate in namespaced field', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2017-09-06T09:00:00Z', endDate: '2017-09-06T10:00:00Z', 'schema:duration': 'PT1H', @@ -71,7 +71,7 @@ describe('DatesMustHaveDurationRule', () => { }); it('should return an error when no duration is supplied with a startDate and endDate', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2017-09-06T09:00:00Z', endDate: '2017-01-15T09:00:00Z', }; diff --git a/src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule-spec.js b/src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule-spec.js new file mode 100644 index 00000000..c2dadb6d --- /dev/null +++ b/src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule-spec.js @@ -0,0 +1,54 @@ +const DiscussionUrlShouldPointToRecognisedDiscussionBoardRule = require('./discussion-url-should-point-to-recognised-discussion-board-rule'); +const ModelNode = require('../../classes/model-node'); +const Model = require('../../classes/model'); +const DataModelHelper = require('../../helpers/data-model'); +const ValidationErrorType = require('../../errors/validation-error-type'); +const ValidationErrorCategory = require('../../errors/validation-error-category'); +const ValidationErrorSeverity = require('../../errors/validation-error-severity'); + +describe('DiscussionUrlShouldPointToRecognisedDiscussionBoardRule', () => { + const rule = new DiscussionUrlShouldPointToRecognisedDiscussionBoardRule(); + + const model = new Model( + DataModelHelper.loadModel('Dataset', 'latest'), + 'latest', + true, + ); + + it('should target Dataset discussionUrl', () => { + const isTargeted = rule.isFieldTargeted(model, 'discussionUrl'); + expect(isTargeted).toBe(true); + }); + + it('should return no error when discussionUrl points to a GitHub repo\'s /issues', async () => { + const nodeToTest = new ModelNode('$', { + discussionUrl: 'https://github.com/openactive/openactive-test-suite/issues', + }, null, model); + const errors = await rule.validate(nodeToTest); + expect(errors).toHaveSize(0); + }); + + it('should return an error when discussionUrl points to a GitHub repo but not to /issues', async () => { + const nodeToTest = new ModelNode('$', { + discussionUrl: 'https://github.com/openactive/openactive-test-suite', + }, null, model); + const errors = await rule.validate(nodeToTest); + expect(errors).toHaveSize(1); + expect(errors[0].rule).toEqual('DiscussionUrlShouldPointToRecognisedDiscussionBoardRule'); + expect(errors[0].category).toEqual(ValidationErrorCategory.DATA_QUALITY); + expect(errors[0].type).toEqual(ValidationErrorType.INVALID_FORMAT); + expect(errors[0].severity).toEqual(ValidationErrorSeverity.FAILURE); + }); + + it('should return a warning when discussionUrl points to an unrecognised place', async () => { + const nodeToTest = new ModelNode('$', { + discussionUrl: 'https://example.com/some-place', + }, null, model); + const errors = await rule.validate(nodeToTest); + expect(errors).toHaveSize(1); + expect(errors[0].rule).toEqual('DiscussionUrlShouldPointToRecognisedDiscussionBoardRule'); + expect(errors[0].category).toEqual(ValidationErrorCategory.CONFORMANCE); + expect(errors[0].type).toEqual(ValidationErrorType.INVALID_FORMAT); + expect(errors[0].severity).toEqual(ValidationErrorSeverity.WARNING); + }); +}); diff --git a/src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule.js b/src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule.js new file mode 100644 index 00000000..cda2caf9 --- /dev/null +++ b/src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule.js @@ -0,0 +1,79 @@ +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'); +const { SelfIndexingObject } = require('../../helpers/self-indexing-object'); + +const TEST_KEYS = SelfIndexingObject.create(/** @type {const} */([ + 'githubButNotIssues', + 'unrecognisedFormat', +])); + +module.exports = class DiscussionUrlShouldPointToRecognisedDiscussionBoardRule extends Rule { + constructor(options) { + super(options); + this.targetFields = { + Dataset: ['discussionUrl'], + }; + this.meta = { + name: 'DiscussionUrlShouldPointToRecognisedDiscussionBoardRule', + description: 'Validates that the `discussionUrl` property points to a recognised discussion board.', + tests: { + [TEST_KEYS.githubButNotIssues]: { + message: 'If `discussionUrl` points to GitHub, it should be to the project\'s /issues page e.g. `https://github.com/openactive/OpenActive.Server.NET/issues`.', + category: ValidationErrorCategory.DATA_QUALITY, + severity: ValidationErrorSeverity.FAILURE, + type: ValidationErrorType.INVALID_FORMAT, + }, + [TEST_KEYS.unrecognisedFormat]: { + message: 'The `discussionUrl` property does not point to a recognised discussion board. Currently recognised discussion board formats: `https://github.com///issues`.', + category: ValidationErrorCategory.CONFORMANCE, + severity: ValidationErrorSeverity.WARNING, + type: ValidationErrorType.INVALID_FORMAT, + }, + }, + }; + } + + /** + * @param {import('../../classes/model-node').ModelNodeType} node + * @param {string} field + */ + async validateField(node, field) { + const discussionUrlRaw = node.getValue(field); + const discussionUrl = new URL(discussionUrlRaw); + if (discussionUrl.hostname === 'github.com') { + return this.validateGitHubUrl(discussionUrl, node, field); + } + return [ + this.createError( + TEST_KEYS.unrecognisedFormat, + { + value: node.getValue(field), + path: node.getPath(field), + }, + ), + ]; + } + + /** + * @param {URL} discussionUrl + * @param {import('../../classes/model-node').ModelNodeType} node + * @param {string} field + */ + validateGitHubUrl(discussionUrl, node, field) { + const isGhIssuesUrl = discussionUrl.pathname.endsWith('/issues'); + if (isGhIssuesUrl) { + return []; + } + return [ + this.createError( + TEST_KEYS.githubButNotIssues, + { + value: node.getValue(field), + path: node.getPath(field), + }, + ), + ]; + } +}; diff --git a/src/rules/data-quality/end-before-start-rule-spec.js b/src/rules/data-quality/end-before-start-rule-spec.js index 714b837e..27b05908 100644 --- a/src/rules/data-quality/end-before-start-rule-spec.js +++ b/src/rules/data-quality/end-before-start-rule-spec.js @@ -28,7 +28,7 @@ describe('EndBeforeStartRule', () => { it('should return no error when the startDate is before the endDate', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2017-09-06T09:00:00Z', endDate: '2018-01-15T09:00:00+01:00', }; @@ -44,7 +44,7 @@ describe('EndBeforeStartRule', () => { }); it('should return no error when the startDate is before the endDate in a namespaced field', async () => { const data = { - type: 'Event', + '@type': 'Event', 'schema:startDate': '2017-09-06T09:00:00Z', 'schema:endDate': '2018-01-15T09:00:00+01:00', }; @@ -60,7 +60,7 @@ describe('EndBeforeStartRule', () => { }); it('should return no error when the startDate is set, but the endDate isn\'t', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2017-09-06T09:00:00Z', }; @@ -75,7 +75,7 @@ describe('EndBeforeStartRule', () => { }); it('should return an error when the startDate is after the endDate', async () => { const data = { - type: 'Event', + '@type': 'Event', startDate: '2017-09-06T09:00:00Z', endDate: '2017-01-15T09:00:00Z', }; diff --git a/src/rules/data-quality/event-no-schedule-subevent-rule-spec.js b/src/rules/data-quality/event-no-schedule-subevent-rule-spec.js index 7fef4f45..a75bb69b 100644 --- a/src/rules/data-quality/event-no-schedule-subevent-rule-spec.js +++ b/src/rules/data-quality/event-no-schedule-subevent-rule-spec.js @@ -35,7 +35,7 @@ describe('EventNoScheduleSubeventRule', () => { it('should return no errors if there is no subEvent or eventSchedule set on the Event', async () => { const data = { - type: 'Event', + '@type': 'Event', name: 'Test Event', }; @@ -53,11 +53,11 @@ describe('EventNoScheduleSubeventRule', () => { it('should return no errors if there is an subEvent or eventSchedule set on a subclass of Event', async () => { const data = { - type: 'SessionSeries', + '@type': 'SessionSeries', name: 'Test Event', subEvent: [ { - type: 'Event', + '@type': 'Event', }, ], }; @@ -76,16 +76,16 @@ describe('EventNoScheduleSubeventRule', () => { it('should return a warning if eventSchedule or subEvent is set on the Event', async () => { const dataItems = [ { - type: 'Event', + '@type': 'Event', eventSchedule: { instanceType: 'Event', }, }, { - type: 'Event', + '@type': 'Event', subEvent: [ { - type: 'Event', + '@type': 'Event', }, ], }, 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 0aa0f31b..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/if-needs-booking-must-have-valid-offer-rule-spec.js b/src/rules/data-quality/if-needs-booking-must-have-valid-offer-rule-spec.js index a66f8475..788addc6 100644 --- a/src/rules/data-quality/if-needs-booking-must-have-valid-offer-rule-spec.js +++ b/src/rules/data-quality/if-needs-booking-must-have-valid-offer-rule-spec.js @@ -27,7 +27,7 @@ describe('IfNeedsBookingMustHaveValidOfferRule', () => { it('should return no errors if the Event has an isAccessibleWithoutBooking of true', async () => { const data = { - type: 'Event', + '@type': 'Event', isAccessibleWithoutBooking: true, }; @@ -45,21 +45,21 @@ describe('IfNeedsBookingMustHaveValidOfferRule', () => { it('should return no errors if the Event has an isAccessibleWithoutBooking of false, but has an offer with an id or url', async () => { const dataItems = [ { - type: 'Event', + '@type': 'Event', isAccessibleWithoutBooking: false, offers: [ { - type: 'Offer', - id: 'https://example.org/offer/1', + '@type': 'Offer', + '@id': 'https://example.org/offer/1', }, ], }, { - type: 'Event', + '@type': 'Event', isAccessibleWithoutBooking: true, offers: [ { - type: 'Offer', + '@type': 'Offer', url: 'https://example.org/offer/1', }, ], @@ -82,20 +82,20 @@ describe('IfNeedsBookingMustHaveValidOfferRule', () => { it('should return an error if the Event as an isAccessibleWithoutBooking of false, and no offer with an id or url', async () => { const dataItems = [ { - type: 'Event', + '@type': 'Event', isAccessibleWithoutBooking: false, }, { - type: 'Event', + '@type': 'Event', isAccessibleWithoutBooking: false, offers: [], }, { - type: 'Event', + '@type': 'Event', isAccessibleWithoutBooking: false, offers: [ { - type: 'Offer', + '@type': 'Offer', }, ], }, diff --git a/src/rules/data-quality/is-accessible-for-free-rule-spec.js b/src/rules/data-quality/is-accessible-for-free-rule-spec.js index dcb7075e..bbde76e2 100644 --- a/src/rules/data-quality/is-accessible-for-free-rule-spec.js +++ b/src/rules/data-quality/is-accessible-for-free-rule-spec.js @@ -37,10 +37,10 @@ describe('IsAccessibleForFreeRule', () => { // No error it('should return no error when isAccessibleForFree is set to true with a zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', @@ -59,10 +59,10 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return no error when isAccessibleForFree is set to true with a zero offer in a namespaced field', async () => { const data = { - type: 'Event', + '@type': 'Event', 'schema:offers': [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', @@ -81,10 +81,10 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return no error when isAccessibleForFree is set to false with no zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Unfree Offer', price: 10.00, priceCurrency: 'GBP', @@ -103,10 +103,10 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return no error when isAccessibleForFree is not set with no zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Unfree Offer', price: 10.00, priceCurrency: 'GBP', @@ -125,12 +125,12 @@ describe('IsAccessibleForFreeRule', () => { it('should return no error when isAccessibleForFree is set to true with a parent zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', @@ -150,12 +150,12 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return no error when isAccessibleForFree is set to false with no parent zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Unfree Offer', price: 10.00, priceCurrency: 'GBP', @@ -175,12 +175,12 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return no error when isAccessibleForFree is not set with no parent zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Unfree Offer', price: 10.00, priceCurrency: 'GBP', @@ -201,10 +201,10 @@ describe('IsAccessibleForFreeRule', () => { // Error it('should return an error when isAccessibleForFree is set to false with a zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', @@ -225,10 +225,10 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return an error when isAccessibleForFree is not set with a zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', @@ -249,12 +249,12 @@ describe('IsAccessibleForFreeRule', () => { it('should return an error when isAccessibleForFree is set to false with a parent zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', @@ -276,12 +276,12 @@ describe('IsAccessibleForFreeRule', () => { }); it('should return an error when isAccessibleForFree is not set with a parent zero offer', async () => { const data = { - type: 'Event', + '@type': 'Event', superEvent: { - type: 'Event', + '@type': 'Event', offers: [{ - type: 'Offer', - id: 'http://example.org/offer/1', + '@type': 'Offer', + '@id': 'http://example.org/offer/1', name: 'Free Offer', price: 0.00, priceCurrency: 'GBP', diff --git a/src/rules/data-quality/is-accessible-for-free-rule.js b/src/rules/data-quality/is-accessible-for-free-rule.js index 8afaf063..52aac782 100644 --- a/src/rules/data-quality/is-accessible-for-free-rule.js +++ b/src/rules/data-quality/is-accessible-for-free-rule.js @@ -13,7 +13,7 @@ module.exports = class IsAccessibleForFreeRule extends Rule { description: 'Validates that isAccessibleForFree is set to true for events that have a zero-price offer.', tests: { default: { - message: 'Where a `{{model}}` has at least one Offer with `price` set to `0`, it should also have a property named `isAccessibleForFree` set to `true`.\n\nFor example:\n\n```\n{\n "type": "{{model}}",\n "offers": [\n {\n "type": "Offer",\n "price": 0\n }\n ],\n "isAccessibleForFree": true\n}\n```', + message: 'Where a `{{model}}` has at least one `Offer` with `price` set to `0`, it should also have a property named `isAccessibleForFree` set to `true`.\n\nFor example:\n\n```\n{\n "@type": "{{model}}",\n "offers": [\n {\n "@type": "Offer",\n "price": 0\n }\n ],\n "isAccessibleForFree": true\n}\n```', sampleValues: { model: 'Event', }, diff --git a/src/rules/data-quality/max-less-than-min-rule-spec.js b/src/rules/data-quality/max-less-than-min-rule-spec.js index aeb1ee2a..328c084e 100644 --- a/src/rules/data-quality/max-less-than-min-rule-spec.js +++ b/src/rules/data-quality/max-less-than-min-rule-spec.js @@ -28,7 +28,7 @@ describe('MaxLessThenMinRule', () => { it('should return no error when the minValue is lower than the maxValue', async () => { const data = { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 1, maxValue: 10, }; @@ -44,7 +44,7 @@ describe('MaxLessThenMinRule', () => { }); it('should return no error when the minValue is lower than the maxValue in a namespaced field', async () => { const data = { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', 'schema:minValue': 1, 'schema:maxValue': 10, }; @@ -60,7 +60,7 @@ describe('MaxLessThenMinRule', () => { }); it('should return no error when the minValue is equal to the maxValue', async () => { const data = { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 10, maxValue: 10, }; @@ -76,7 +76,7 @@ describe('MaxLessThenMinRule', () => { }); it('should return no error when the minValue is set, but the maxValue isn\'t', async () => { const data = { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 1, }; @@ -91,7 +91,7 @@ describe('MaxLessThenMinRule', () => { }); it('should return an error when the minValue is greater than the maxValue', async () => { const data = { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 10, maxValue: 1, }; diff --git a/src/rules/data-quality/no-html-rule-spec.js b/src/rules/data-quality/no-html-rule-spec.js index 248ab082..69b6d5e7 100644 --- a/src/rules/data-quality/no-html-rule-spec.js +++ b/src/rules/data-quality/no-html-rule-spec.js @@ -37,7 +37,7 @@ describe('NoHtmlRule', () => { ]) { it(`should return no error when no HTML is supplied in content of value ${JSON.stringify(description)}`, async () => { const data = { - type: 'Event', + '@type': 'Event', }; data.description = description; const nodeToTest = new ModelNode( @@ -58,7 +58,7 @@ describe('NoHtmlRule', () => { ]) { it(`should return no error when HTML is supplied in beta:formattedDescription with value ${JSON.stringify(description)}`, async () => { const data = { - type: 'Event', + '@type': 'Event', }; data['beta:formattedDescription'] = description; const nodeToTest = new ModelNode( @@ -82,7 +82,7 @@ describe('NoHtmlRule', () => { ]) { it(`should return an error when HTML is supplied in content of value ${JSON.stringify(description)}`, async () => { const data = { - type: 'Event', + '@type': 'Event', }; data.description = description; const nodeToTest = new ModelNode( diff --git a/src/rules/data-quality/no-zero-duration-rule-spec.js b/src/rules/data-quality/no-zero-duration-rule-spec.js index 2bc07838..8d333410 100644 --- a/src/rules/data-quality/no-zero-duration-rule-spec.js +++ b/src/rules/data-quality/no-zero-duration-rule-spec.js @@ -24,7 +24,7 @@ describe('NoZeroDurationRule', () => { it('should return no error when a non-zero duration is supplied', async () => { const data = { - type: 'Event', + '@type': 'Event', duration: 'PT1H', }; @@ -40,7 +40,7 @@ describe('NoZeroDurationRule', () => { it('should return an error when a zero duration is supplied', async () => { const data = { - type: 'Event', + '@type': 'Event', duration: 'PT0S', }; @@ -58,7 +58,7 @@ describe('NoZeroDurationRule', () => { it('should return an error when a zero duration is supplied with a namespace', async () => { const data = { - type: 'Event', + '@type': 'Event', 'schema:duration': 'PT0S', }; diff --git a/src/rules/data-quality/openactive-urls-correct-rule-spec.js b/src/rules/data-quality/openactive-urls-correct-rule-spec.js index 9528468f..ef9e6252 100644 --- a/src/rules/data-quality/openactive-urls-correct-rule-spec.js +++ b/src/rules/data-quality/openactive-urls-correct-rule-spec.js @@ -30,11 +30,11 @@ describe('OpenactiveUrlsCorrectRule', () => { const dataItems = [ { '@context': metaData.contextUrl, - type: 'Event', + '@type': 'Event', }, { '@context': [metaData.contextUrl], - type: 'Event', + '@type': 'Event', }, ]; @@ -55,11 +55,11 @@ describe('OpenactiveUrlsCorrectRule', () => { const dataItems = [ { '@context': 'https://example.org/ns', - type: 'Event', + '@type': 'Event', }, { '@context': ['https://example.org/ns'], - type: 'Event', + '@type': 'Event', }, ]; @@ -80,27 +80,27 @@ describe('OpenactiveUrlsCorrectRule', () => { const dataItems = [ { '@context': 'https://www.openactive.io/', - type: 'Event', + '@type': 'Event', }, { '@context': ['https://www.openactive.io/'], - type: 'Event', + '@type': 'Event', }, { '@context': 'http://www.openactive.io/', - type: 'Event', + '@type': 'Event', }, { '@context': ['http://www.openactive.io/'], - type: 'Event', + '@type': 'Event', }, { '@context': 'http://openactive.io/', - type: 'Event', + '@type': 'Event', }, { '@context': ['http://openactive.io/'], - type: 'Event', + '@type': 'Event', }, ]; diff --git a/src/rules/data-quality/scheduled-session-must-be-subevent-rule-spec.js b/src/rules/data-quality/scheduled-session-must-be-subevent-rule-spec.js index fcf64b4d..fcc5944e 100644 --- a/src/rules/data-quality/scheduled-session-must-be-subevent-rule-spec.js +++ b/src/rules/data-quality/scheduled-session-must-be-subevent-rule-spec.js @@ -25,7 +25,7 @@ describe('ScheduledSessionMustBeSubeventRule', () => { it('should return no errors if the ScheduledSession is a subEvent of another Event', async () => { const data = { - type: 'ScheduledSession', + '@type': 'ScheduledSession', }; const nodeToTest = new ModelNode( @@ -41,7 +41,7 @@ describe('ScheduledSessionMustBeSubeventRule', () => { it('should return an error if the ScheduledSession is not a subEvent of another Event', async () => { const data = { - type: 'ScheduledSession', + '@type': 'ScheduledSession', }; const nodeToTest = new ModelNode( 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/data-quality/session-course-has-subevent-or-schedule-rule-spec.js b/src/rules/data-quality/session-course-has-subevent-or-schedule-rule-spec.js index 0fe2567b..41ed58c1 100644 --- a/src/rules/data-quality/session-course-has-subevent-or-schedule-rule-spec.js +++ b/src/rules/data-quality/session-course-has-subevent-or-schedule-rule-spec.js @@ -39,30 +39,30 @@ describe('SessionCourseHasSubeventOrScheduleRule', () => { it('should return no errors if the eventSchedule or the subEvent of the event is set', async () => { const dataItems = [ { - type: 'SessionSeries', + '@type': 'SessionSeries', eventSchedule: { - type: 'Schedule', + '@type': 'Schedule', }, }, { - type: 'SessionSeries', + '@type': 'SessionSeries', subEvent: [ { - type: 'Event', + '@type': 'Event', }, ], }, { - type: 'CourseInstance', + '@type': 'CourseInstance', eventSchedule: { - type: 'Schedule', + '@type': 'Schedule', }, }, { - type: 'CourseInstance', + '@type': 'CourseInstance', subEvent: [ { - type: 'Event', + '@type': 'Event', }, ], }, @@ -73,7 +73,7 @@ describe('SessionCourseHasSubeventOrScheduleRule', () => { '$', data, null, - models[data.type], + models[data['@type']], ); const errors = await rule.validate(nodeToTest); @@ -84,10 +84,10 @@ describe('SessionCourseHasSubeventOrScheduleRule', () => { it('should return a failure if the eventSchedule and the subEvent of the event is not set', async () => { const dataItems = [ { - type: 'CourseInstance', + '@type': 'CourseInstance', }, { - type: 'SessionSeries', + '@type': 'SessionSeries', }, ]; @@ -96,7 +96,7 @@ describe('SessionCourseHasSubeventOrScheduleRule', () => { '$', data, null, - models[data.type], + models[data['@type']], ); const errors = await rule.validate(nodeToTest); @@ -111,11 +111,11 @@ describe('SessionCourseHasSubeventOrScheduleRule', () => { it('should return a failure if the event\'s eventSchedule is not set and it\'s subEvent is an empty array', async () => { const dataItems = [ { - type: 'CourseInstance', + '@type': 'CourseInstance', subEvent: [], }, { - type: 'SessionSeries', + '@type': 'SessionSeries', subEvent: [], }, ]; @@ -125,7 +125,7 @@ describe('SessionCourseHasSubeventOrScheduleRule', () => { '$', data, null, - models[data.type], + models[data['@type']], ); const errors = await rule.validate(nodeToTest); diff --git a/src/rules/data-quality/session-series-schedule-type-rule-spec.js b/src/rules/data-quality/session-series-schedule-type-rule-spec.js index f478f99b..b5c904c9 100644 --- a/src/rules/data-quality/session-series-schedule-type-rule-spec.js +++ b/src/rules/data-quality/session-series-schedule-type-rule-spec.js @@ -28,10 +28,10 @@ describe('SessionSeriesScheduleTypeRule', () => { it('should return no errors if the scheduledEventType of the eventSchedule of the SessionSeries is ScheduledSession', async () => { const data = { - type: 'SessionSeries', + '@type': 'SessionSeries', eventSchedule: [ { - type: 'Schedule', + '@type': 'Schedule', scheduledEventType: 'ScheduledSession', }, ], @@ -49,10 +49,10 @@ describe('SessionSeriesScheduleTypeRule', () => { }); it('should return no errors if the type of the eventSchedule of the SessionSeries is PartialSchedule', async () => { const data = { - type: 'SessionSeries', + '@type': 'SessionSeries', eventSchedule: [ { - type: 'PartialSchedule', + '@type': 'PartialSchedule', }, ], }; @@ -70,7 +70,7 @@ describe('SessionSeriesScheduleTypeRule', () => { it('should return no errors if the eventSchedule of the SessionSeries is not set', async () => { const data = { - type: 'SessionSeries', + '@type': 'SessionSeries', }; const nodeToTest = new ModelNode( @@ -86,10 +86,10 @@ describe('SessionSeriesScheduleTypeRule', () => { it('should return a failure if the scheduledEventType of the eventSchedule of the SessionSeries is not ScheduledSession', async () => { const data = { - type: 'SessionSeries', + '@type': 'SessionSeries', eventSchedule: [ { - type: 'Schedule', + '@type': 'Schedule', scheduledEventType: 'Event', }, ], diff --git a/src/rules/data-quality/thumbnail-has-no-thumbnail-rule-spec.js b/src/rules/data-quality/thumbnail-has-no-thumbnail-rule-spec.js index a1c75972..fcd0c3ab 100644 --- a/src/rules/data-quality/thumbnail-has-no-thumbnail-rule-spec.js +++ b/src/rules/data-quality/thumbnail-has-no-thumbnail-rule-spec.js @@ -24,10 +24,10 @@ describe('ThumbnailHasNoThumbnailRule', () => { it('should return no error when a ImageObject\'s parent is not an ImageObject', async () => { const data = { - type: 'ImageObject', + '@type': 'ImageObject', thumbnail: [ { - type: 'ImageObject', + '@type': 'ImageObject', }, ], }; @@ -43,16 +43,16 @@ describe('ThumbnailHasNoThumbnailRule', () => { }); it('should return no error when a ImageObject\'s parent is an ImageObject, but it doesn\'t have a thumbnail', async () => { const parentData = { - type: 'ImageObject', + '@type': 'ImageObject', thumbnail: [ { - type: 'ImageObject', + '@type': 'ImageObject', }, ], }; const data = { - type: 'ImageObject', + '@type': 'ImageObject', }; const parentNode = new ModelNode( @@ -73,13 +73,13 @@ describe('ThumbnailHasNoThumbnailRule', () => { }); it('should return an error when a ImageObject\'s parent is an ImageObject, and it has a thumbnail', async () => { const parentData = { - type: 'ImageObject', + '@type': 'ImageObject', thumbnail: [ { - type: 'ImageObject', + '@type': 'ImageObject', thumbnail: [ { - type: 'ImageObject', + '@type': 'ImageObject', }, ], }, @@ -87,10 +87,10 @@ describe('ThumbnailHasNoThumbnailRule', () => { }; const data = { - type: 'ImageObject', + '@type': 'ImageObject', thumbnail: [ { - type: 'ImageObject', + '@type': 'ImageObject', }, ], }; diff --git a/src/rules/index.js b/src/rules/index.js index c4bf1ca5..51ce9d6d 100644 --- a/src/rules/index.js +++ b/src/rules/index.js @@ -11,6 +11,7 @@ module.exports = { require('./core/required-fields-rule'), require('./core/required-optional-fields-rule'), require('./core/shall-not-include-fields-rule'), + require('./core/deprecated-fields-rule'), require('./core/fields-not-in-model-rule'), require('./core/fields-correct-type-rule'), require('./core/recommended-fields-rule'), @@ -20,6 +21,11 @@ module.exports = { require('./core/precision-rule'), require('./core/no-prefix-or-namespace-rule'), require('./core/context-in-root-node-rule'), + 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'), @@ -52,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 diff --git a/src/rules/raw-rule.js b/src/rules/raw-rule.js index 4b1364db..466efe0e 100644 --- a/src/rules/raw-rule.js +++ b/src/rules/raw-rule.js @@ -22,5 +22,4 @@ const RawRule = class extends Rule { } }; - module.exports = RawRule; diff --git a/src/rules/raw/rpde-feed-rule-spec.js b/src/rules/raw/rpde-feed-rule-spec.js index 65255e66..88a3bf26 100644 --- a/src/rules/raw/rpde-feed-rule-spec.js +++ b/src/rules/raw/rpde-feed-rule-spec.js @@ -13,7 +13,7 @@ describe('RpdeFeedRule', () => { it('should return no notices for input that isn\'t an RPDE feed', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const { errors } = await rule.validate(data); @@ -28,7 +28,7 @@ describe('RpdeFeedRule', () => { kind: 'session', state: 'updated', data: { - type: 'Event', + '@type': 'Event', }, modified: 1533177378657, }, @@ -44,6 +44,20 @@ describe('RpdeFeedRule', () => { expect(errors[0].severity).toBe(ValidationErrorSeverity.NOTICE); }); + it('should return a notice for an empty RPDE feed', async () => { + const data = { + items: [], + next: 'https://example.org/api/feed/?afterId=ABCDEF09001015&afterTimestamp=1533206202992&limit=500', + license: 'https://creativecommons.org/licenses/by/4.0/', + }; + + const { errors } = await rule.validate(data); + expect(errors.length).toBe(1); + + expect(errors[0].type).toBe(ValidationErrorType.FOUND_RPDE_FEED); + expect(errors[0].severity).toBe(ValidationErrorSeverity.NOTICE); + }); + it('should return a notice for an RPDE feed, and modify the data with a limit to the number of items', async () => { const feed = { items: [ @@ -52,7 +66,7 @@ describe('RpdeFeedRule', () => { kind: 'session', state: 'updated', data: { - type: 'Event', + '@type': 'Event', }, modified: 1533177378657, }, @@ -61,7 +75,7 @@ describe('RpdeFeedRule', () => { kind: 'session', state: 'updated', data: { - type: 'Event', + '@type': 'Event', }, modified: 1533177378657, }, @@ -70,7 +84,7 @@ describe('RpdeFeedRule', () => { kind: 'session', state: 'updated', data: { - type: 'Event', + '@type': 'Event', }, modified: 1533177378657, }, diff --git a/src/rules/raw/valid-input-rule-spec.js b/src/rules/raw/valid-input-rule-spec.js index 2f6d2b7d..4196b00c 100644 --- a/src/rules/raw/valid-input-rule-spec.js +++ b/src/rules/raw/valid-input-rule-spec.js @@ -12,7 +12,7 @@ describe('ValidInputRule', () => { it('should return no error for valid input', async () => { const data = { - type: 'Event', + '@type': 'Event', }; const { errors } = await rule.validate(data); @@ -22,7 +22,7 @@ describe('ValidInputRule', () => { it('should return a warning for an array input', async () => { const data = [ { - type: 'Event', + '@type': 'Event', }, ]; diff --git a/src/rules/rule-spec.js b/src/rules/rule-spec.js index 3b1167d8..05e93f4c 100644 --- a/src/rules/rule-spec.js +++ b/src/rules/rule-spec.js @@ -3,7 +3,7 @@ const Model = require('../classes/model'); describe('Rule', () => { const model = new Model({ - type: 'Event', + '@type': 'Event', requiredFields: [ '@context', 'activity', diff --git a/src/rules/rule.js b/src/rules/rule.js index fe9cd21f..c1b1519d 100644 --- a/src/rules/rule.js +++ b/src/rules/rule.js @@ -5,9 +5,26 @@ const ValidationError = require('../errors/validation-error'); class Rule { constructor(options) { this.options = options || new OptionsHelper(); + /** @type {string[] | '*'} */ this.targetModels = []; + /** @type {'*' | {[model: string]: '*' | string[]}} */ this.targetFields = {}; + /** @type {string[] | '*'} */ this.targetValidationModes = '*'; + /** + * @type {{ + * name: string; + * description: string; + * tests: {[key: string]: { + * description?: string; + * message: string; + * sampleValues?: { [messageTemplateArg: string]: string }; + * category: string; + * severity: string; + * type: string; + * }} + * }} + */ this.meta = { name: 'Rule', description: 'This is a base rule description that should be overridden.', @@ -38,11 +55,22 @@ class Rule { return errors; } - validateModel(/* node */) { + /** + * @param {import('../classes/model-node').ModelNodeType} node + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + validateModel(node) { throw Error('Model validation rule not implemented'); } - async validateField(/* node, field */) { + /** + * @param {import('../classes/model-node').ModelNodeType} node + * @param {string} field + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async validateField(node, field) { throw Error('Field validation rule not implemented'); } @@ -88,6 +116,10 @@ class Rule { ); } + /** + * @param {import('../classes/model')} model + * @param {string} field + */ isFieldTargeted(model, field) { if (this.targetFields === '*') { return true; @@ -129,7 +161,6 @@ class Rule { isValidationModeTargeted(validationMode) { if (this.targetValidationModes === '*') return true; - if (this.targetValidationModes instanceof Array) { return this.targetValidationModes.includes(validationMode); } diff --git a/src/validate-spec.js b/src/validate-spec.js index 9f0f4c3f..99d85bb3 100644 --- a/src/validate-spec.js +++ b/src/validate-spec.js @@ -6,6 +6,7 @@ const DataModelHelper = require('./helpers/data-model'); const OptionsHelper = require('./helpers/options'); describe('validate', () => { + let validSessionSeries; let validEvent; let options; let activityList; @@ -13,69 +14,68 @@ describe('validate', () => { beforeEach(() => { metaData = DataModelHelper.getMetaData('latest'); - validEvent = { + validSessionSeries = { '@context': metaData.contextUrl, - id: 'http://www.example.org/events/1', - type: 'SessionSeries', + '@id': 'http://www.example.org/events/1', + '@type': 'SessionSeries', name: 'Tai chi Class', description: 'A Tai chi class', duration: 'PT1H', isCoached: true, url: 'http://www.example.org/events/1', - startDate: '2017-03-22T20:00:00Z', ageRange: { - type: 'QuantitativeValue', + '@type': 'QuantitativeValue', minValue: 18, maxValue: 60, }, genderRestriction: 'https://openactive.io/NoRestriction', activity: [ { - id: 'https://openactive.io/activity-list#c16df6ed-a4a0-4275-a8c3-1c8cff56856f', + '@id': 'https://openactive.io/activity-list#c16df6ed-a4a0-4275-a8c3-1c8cff56856f', prefLabel: 'Tai Chi', - type: 'Concept', + '@type': 'Concept', inScheme: 'https://openactive.io/activity-list', }, ], category: [ { - id: 'https://openactive.io/activity-list#594e5805-3a5c-4c60-80fc-c0a28eb64a06', + '@id': 'https://openactive.io/activity-list#594e5805-3a5c-4c60-80fc-c0a28eb64a06', prefLabel: 'Holistic Classes', - type: 'Concept', + '@type': 'Concept', inScheme: 'https://openactive.io/activity-list', }, ], programme: { - type: 'Brand', + '@type': 'Brand', name: 'Play Ball!', url: 'http://example.org/brand/play-ball', description: 'Something about a ball', logo: { - type: 'ImageObject', + '@type': 'ImageObject', url: 'http://example.com/static/image/speedball_large.jpg', }, }, eventStatus: 'https://schema.org/EventScheduled', image: [{ - type: 'ImageObject', + '@type': 'ImageObject', url: 'http://www.example.org/logo.png', }], subEvent: [ { - type: 'ScheduledSession', - id: 'http://www.example.org/events/12', + '@type': 'ScheduledSession', + '@id': 'http://www.example.org/events/12', url: 'http://www.example.org/events/12', startDate: '2017-03-22T20:00:00Z', endDate: '2017-03-22T21:00:00Z', offers: [{ - id: 'http://example.org/offer/1', - ageRange: { - type: 'QuantitativeValue', + '@id': 'http://example.org/offer/1', + ageRestriction: { + '@type': 'QuantitativeValue', minValue: 18, maxValue: 65, }, url: 'http://example.org/offer/1', - type: 'Offer', + '@type': 'Offer', name: 'Single session', price: 5, priceCurrency: 'GBP', @@ -83,20 +83,20 @@ describe('validate', () => { remainingAttendeeCapacity: 10, }, { - type: 'ScheduledSession', - id: 'http://www.example.org/events/13', + '@type': 'ScheduledSession', + '@id': 'http://www.example.org/events/13', url: 'http://www.example.org/events/13', startDate: '2017-03-29T20:00:00Z', endDate: '2017-03-29T21:00:00Z', offers: [{ - id: 'http://example.org/offer/1', - ageRange: { - type: 'QuantitativeValue', + '@id': 'http://example.org/offer/1', + ageRestriction: { + '@type': 'QuantitativeValue', minValue: 18, maxValue: 65, }, url: 'http://example.org/offer/1', - type: 'Offer', + '@type': 'Offer', name: 'Single session', price: 5, priceCurrency: 'GBP', @@ -105,8 +105,8 @@ describe('validate', () => { }, ], organizer: { - id: 'http://www.example.org', - type: 'Organization', + '@id': 'http://www.example.org', + '@type': 'Organization', name: 'Example Co', url: 'http://www.example.org', description: 'Example organizer', @@ -115,30 +115,30 @@ describe('validate', () => { 'http://www.example.org/facebook', ], logo: { - type: 'ImageObject', + '@type': 'ImageObject', url: 'http://www.example.org/logo.png', }, }, leader: [{ - id: 'http://www.example.org/person/1', - type: 'Person', + '@id': 'http://www.example.org/person/1', + '@type': 'Person', name: 'Joe Bloggs', }], level: [ 'Beginner', ], location: { - id: 'http://www.example.org/locations/gym', - type: 'Place', + '@id': 'http://www.example.org/locations/gym', + '@type': 'Place', name: 'ExampleCo Gym', description: 'ExampleCo\'s main gym', image: [{ - type: 'ImageObject', + '@type': 'ImageObject', url: 'http://www.example.org/gym.png', }], url: 'http://www.example.org/locations/gym', address: { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1 High Street', addressLocality: 'Bristol', addressRegion: 'Bristol', @@ -149,48 +149,173 @@ describe('validate', () => { geo: { latitude: 51.4034423828125, longitude: -0.2369088977575302, - type: 'GeoCoordinates', + '@type': 'GeoCoordinates', }, openingHoursSpecification: [{ - type: 'OpeningHoursSpecification', + '@type': 'OpeningHoursSpecification', opens: '07:00', closes: '21:00', - dayOfWeek: 'https://schema.org/Monday', + dayOfWeek: ['https://schema.org/Monday'], }], amenityFeature: [ { name: 'Changing Facilities', value: true, - type: 'ChangingFacilities', + '@type': 'ChangingFacilities', }, ], }, offers: [{ - id: 'http://example.org/offer/1', - ageRange: { - type: 'QuantitativeValue', + '@id': 'http://example.org/offer/1', + ageRestriction: { + '@type': 'QuantitativeValue', minValue: 18, maxValue: 65, }, url: 'http://example.org/offer/1', - type: 'Offer', + '@type': 'Offer', name: 'Single session', price: 5, priceCurrency: 'GBP', }], maximumAttendeeCapacity: 20, }; + validEvent = { + '@context': metaData.contextUrl, + '@id': 'http://www.example.org/events/1', + '@type': 'Event', + name: 'Tai chi Class', + description: 'A Tai chi class', + duration: 'PT1H', + isCoached: true, + url: 'http://www.example.org/events/1', + ageRange: { + '@type': 'QuantitativeValue', + minValue: 18, + maxValue: 60, + }, + genderRestriction: 'https://openactive.io/NoRestriction', + activity: [ + { + '@id': 'https://openactive.io/activity-list#c16df6ed-a4a0-4275-a8c3-1c8cff56856f', + prefLabel: 'Tai Chi', + '@type': 'Concept', + inScheme: 'https://openactive.io/activity-list', + }, + ], + category: [ + { + '@id': 'https://openactive.io/activity-list#594e5805-3a5c-4c60-80fc-c0a28eb64a06', + prefLabel: 'Holistic Classes', + '@type': 'Concept', + inScheme: 'https://openactive.io/activity-list', + }, + ], + programme: { + '@type': 'Brand', + name: 'Play Ball!', + url: 'http://example.org/brand/play-ball', + description: 'Something about a ball', + logo: { + '@type': 'ImageObject', + url: 'http://example.com/static/image/speedball_large.jpg', + }, + }, + eventStatus: 'https://schema.org/EventScheduled', + image: [{ + '@type': 'ImageObject', + url: 'http://www.example.org/logo.png', + }], + startDate: '2017-03-22T20:00:00Z', + endDate: '2017-03-22T21:00:00Z', + offers: [{ + '@id': 'http://example.org/offer/1', + ageRestriction: { + '@type': 'QuantitativeValue', + minValue: 18, + maxValue: 65, + }, + url: 'http://example.org/offer/1', + '@type': 'Offer', + name: 'Single session', + price: 5, + priceCurrency: 'GBP', + }], + remainingAttendeeCapacity: 10, + maximumAttendeeCapacity: 20, + organizer: { + '@id': 'http://www.example.org', + '@type': 'Organization', + name: 'Example Co', + url: 'http://www.example.org', + description: 'Example organizer', + telephone: '01234567890', + sameAs: [ + 'http://www.example.org/facebook', + ], + logo: { + '@type': 'ImageObject', + url: 'http://www.example.org/logo.png', + }, + }, + leader: [{ + '@id': 'http://www.example.org/person/1', + '@type': 'Person', + name: 'Joe Bloggs', + }], + level: [ + 'Beginner', + ], + location: { + '@id': 'http://www.example.org/locations/gym', + '@type': 'Place', + name: 'ExampleCo Gym', + description: 'ExampleCo\'s main gym', + image: [{ + '@type': 'ImageObject', + url: 'http://www.example.org/gym.png', + }], + url: 'http://www.example.org/locations/gym', + address: { + '@type': 'PostalAddress', + streetAddress: '1 High Street', + addressLocality: 'Bristol', + addressRegion: 'Bristol', + addressCountry: 'GB', + postalCode: 'BS1 4SD', + }, + telephone: '0845000000', + geo: { + latitude: 51.4034423828125, + longitude: -0.2369088977575302, + '@type': 'GeoCoordinates', + }, + openingHoursSpecification: [{ + '@type': 'OpeningHoursSpecification', + opens: '07:00', + closes: '21:00', + dayOfWeek: ['https://schema.org/Monday'], + }], + amenityFeature: [ + { + name: 'Changing Facilities', + value: true, + '@type': 'ChangingFacilities', + }, + ], + }, + }; activityList = { '@context': 'https://openactive.io/', - '@id': 'https://openactive.io/activity-list', + id: 'https://openactive.io/activity-list', title: 'OpenActive Activity List', description: 'This document describes the OpenActive standard activity list.', - type: 'ConceptScheme', + '@type': 'ConceptScheme', license: 'https://creativecommons.org/licenses/by/4.0/', concept: [ { id: 'https://openactive.io/activity-list#c16df6ed-a4a0-4275-a8c3-1c8cff56856f', - type: 'Concept', + '@type': 'Concept', prefLabel: 'Tai Chi', definition: 'Tai chi combines deep breathing and relaxation with slow and gentle movements.', broader: 'https://openactive.io/activity-list#594e5805-3a5c-4c60-80fc-c0a28eb64a06', @@ -219,7 +344,7 @@ describe('validate', () => { it('should return a failure if passed an invalid model', async () => { const data = {}; - const result = await validate(data, new OptionsHelper({ type: 'InvalidModel' })); + const result = await validate(data, new OptionsHelper({ '@type': 'InvalidModel' })); expect(result.length).toBe(2); expect(result[0].type).toBe(ValidationErrorType.MISSING_REQUIRED_FIELD); @@ -258,26 +383,23 @@ describe('validate', () => { }); it('should return no errors for a valid Event', async () => { - const event = Object.assign({}, validEvent); + const event = { ...validSessionSeries }; const result = await validate(event, options); expect(result.length).toBe(0); }); - // TODO: Fix this to return a warning if 'type' or 'id' are used (as this was designed to warn for '@type' or '@id') // Note that this will likely require wider changes it('should only return alias warnings for a valid Event with aliased properties', async () => { - const event = Object.assign( - {}, - validEvent, - { - // '@type': 'EventSeries', - 'schema:name': validEvent.name, - 'oa:ageRange': Object.assign({}, validEvent.ageRange), - }, - ); + const event = { + + ...validSessionSeries, + // '@type': 'EventSeries', + 'schema:name': validSessionSeries.name, + 'oa:ageRange': { ...validSessionSeries.ageRange }, + }; // delete event.type; delete event.name; @@ -295,7 +417,7 @@ describe('validate', () => { it('should provide a jsonpath to the location of a problem', async () => { // This event is missing location addressRegion, which is a recommended field - const event = Object.assign({}, validEvent); + const event = { ...validSessionSeries }; delete event.location.address.addressRegion; @@ -307,17 +429,15 @@ describe('validate', () => { }); it('should provide a jsonpath to the location of a problem with a namespace', async () => { - const event = Object.assign( - {}, - validEvent, - { - 'https://openactive.io/ageRange': { - type: 'QuantitativeValue', - minValue: 60, - maxValue: 18, - }, + const event = { + + ...validSessionSeries, + 'https://openactive.io/ageRange': { + '@type': 'QuantitativeValue', + minValue: 60, + maxValue: 18, }, - ); + }; delete event.ageRange; @@ -331,13 +451,13 @@ describe('validate', () => { it('should check submodels of a model even if we don\'t know what type it is', async () => { const data = { - type: 'UnknownType', + '@type': 'UnknownType', geo: { latitude: 51.4034423828125, - type: 'GeoCoordinates', + '@type': 'GeoCoordinates', }, location: { - type: 'SafariPark', + '@type': 'SafariPark', }, }; @@ -365,17 +485,17 @@ describe('validate', () => { it('should cope with flexible model types', async () => { const place = { '@context': metaData.contextUrl, - id: 'http://www.example.org/locations/gym', - type: 'Place', + '@id': 'http://www.example.org/locations/gym', + '@type': 'Place', name: 'ExampleCo Gym', description: 'ExampleCo\'s main gym', image: [{ - type: 'ImageObject', + '@type': 'ImageObject', url: 'http://www.example.org/gym.png', }], url: 'http://www.example.org/locations/gym', address: { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1 High Street', addressLocality: 'Bristol', addressRegion: 'Bristol', @@ -386,24 +506,24 @@ describe('validate', () => { geo: { latitude: 51.4034423828125, longitude: -0.2369088977575302, - type: 'GeoCoordinates', + '@type': 'GeoCoordinates', }, openingHoursSpecification: [{ - type: 'OpeningHoursSpecification', + '@type': 'OpeningHoursSpecification', opens: '07:00', closes: '21:00', - dayOfWeek: 'https://schema.org/Monday', + dayOfWeek: ['https://schema.org/Monday'], }], amenityFeature: [ { name: 'Changing Facilities', value: true, - type: 'ChangingFacilities', + '@type': 'ChangingFacilities', }, { name: 'My Place', value: true, - type: 'ext:MyPlace', + '@type': 'ext:MyPlace', }, ], }; @@ -420,17 +540,17 @@ describe('validate', () => { it('should cope with arrays of flexible model types mixed with invalid elements', async () => { const place = { '@context': metaData.contextUrl, - id: 'http://www.example.org/locations/gym', - type: 'Place', + '@id': 'http://www.example.org/locations/gym', + '@type': 'Place', name: 'ExampleCo Gym', description: 'ExampleCo\'s main gym', image: [{ - type: 'ImageObject', + '@type': 'ImageObject', url: 'http://www.example.org/gym.png', }], url: 'http://www.example.org/locations/gym', address: { - type: 'PostalAddress', + '@type': 'PostalAddress', streetAddress: '1 High Street', addressLocality: 'Bristol', addressRegion: 'Bristol', @@ -441,25 +561,25 @@ describe('validate', () => { geo: { latitude: 51.4034423828125, longitude: -0.2369088977575302, - type: 'GeoCoordinates', + '@type': 'GeoCoordinates', }, openingHoursSpecification: [{ - type: 'OpeningHoursSpecification', + '@type': 'OpeningHoursSpecification', opens: '07:00', closes: '21:00', - dayOfWeek: 'https://schema.org/Monday', + dayOfWeek: ['https://schema.org/Monday'], }], amenityFeature: [ { name: 'Changing Facilities', value: true, - type: 'ChangingRooms', + '@type': 'ChangingRooms', }, 'An invalid array element', { name: 'My Place', value: true, - type: 'ext:MyPlace', + '@type': 'ext:MyPlace', }, ], }; @@ -480,7 +600,7 @@ describe('validate', () => { it('should not throw if a property of value null is passed', async () => { const data = { '@context': metaData.contextUrl, - type: 'Event', + '@type': 'Event', 'beta:distance': null, }; @@ -491,7 +611,7 @@ describe('validate', () => { it('should not throw if a property of value null is passed', async () => { const data = { '@context': metaData.contextUrl, - type: 'Event', + '@type': 'Event', category: [null, null], }; @@ -500,7 +620,7 @@ describe('validate', () => { }); it('should return an unsupported warning if nested arrays are passed', async () => { - const event = Object.assign({}, validEvent); + const event = { ...validSessionSeries }; event.leader = [event.leader]; @@ -514,7 +634,7 @@ describe('validate', () => { }); it('should not throw if a value object is passed', async () => { - const event = Object.assign({}, validEvent); + const event = { ...validSessionSeries }; event.name = { '@value': event.name, @@ -529,6 +649,25 @@ describe('validate', () => { expect(result[0].path).toBe('$.name'); }); + it('should not throw errors for Event', async () => { + const result = await validate(validEvent, options); + + expect(result.length).toBe(0); + }); + + it('should warn on deprecated fields', async () => { + const event = { ...validSessionSeries }; + event.offers[0].ageRange = event.offers[0].ageRestriction; + delete event.offers[0].ageRestriction; + + const result = await validate(event, options); + + expect(result.length).toBe(2); + expect(result[0].type).toBe(ValidationErrorType.FIELD_DEPRECATED); + expect(result[0].severity).toBe(ValidationErrorSeverity.WARNING); + expect(result[0].path).toBe('$.offers[0].ageRange'); + }); + it('should recognise an RPDE feed', async () => { const feed = { items: [ @@ -536,7 +675,7 @@ describe('validate', () => { id: 'ABCDEF09001015', kind: 'SessionSeries', state: 'updated', - data: validEvent, + data: validSessionSeries, modified: 1533177378657, }, ], diff --git a/src/validate.js b/src/validate.js index c21402a6..b0228aa1 100644 --- a/src/validate.js +++ b/src/validate.js @@ -208,7 +208,7 @@ async function validate(value, options) { index += 1; } - return errors.map(x => x.data); + return errors.map((x) => x.data); } function isRpdeFeed(data) { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..be33275d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "checkJs": true, + "downlevelIteration": true, + "target": "ES2019", + "moduleResolution": "node", + "types": ["jasmine", "node", "jasmine-expect"] + }, + "include": [ + "src/classes/**/*.js", + "src/errors/**/*.js", + "src/exceptions.js", + "src/helpers/**/*.js", + "src/rules/rule.js", + "src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule.js", + "src/rules/data-quality/discussion-url-should-point-to-recognised-discussion-board-rule-spec.js", + // TODO add other rules + ], + "exclude": [ + "src/helpers/graph.js" + ] +}