├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── build └── webpack.config.js ├── docs ├── api.md ├── external-libraries.md └── schema-to-instance-pointer.md ├── package-lock.json ├── package.json ├── src ├── discovery.js ├── extract-sub-schema.js ├── get-default-input-values.js ├── get-template-data.js └── relative-json-pointer.js └── test ├── .eslintrc ├── specs ├── extract-sub-schema.spec.js ├── get-default-input-values.spec.js ├── get-template-data.spec.js ├── relative-json-pointer.spec.js └── test-cases │ └── spec-examples-discovery.spec.js └── test-cases └── spec-examples-discovery.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | indent_size = 2 12 | indent_style = space 13 | 14 | # Matches multiple files with brace expansion notation 15 | # Set default charset 16 | [*.{js,json}] 17 | charset = utf-8 18 | 19 | [.prettierrc] 20 | charset = utf-8 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { 4 | "ecmaVersion": 2022, 5 | "sourceType": "module" 6 | }, 7 | "env": { 8 | "browser": false, 9 | "node": true, 10 | "es2020": true 11 | }, 12 | "plugins": ["prettier"], 13 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 14 | "rules": { 15 | "no-console": ["error"], 16 | "no-unused-vars": [ 17 | "error", 18 | { 19 | "vars": "all", 20 | "varsIgnorePattern": "^_", 21 | "args": "after-used", 22 | "ignoreRestSiblings": true 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x, 18.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: npm ci 24 | - run: npm run test 25 | - name: Upload coverage reports to Codecov 26 | uses: codecov/codecov-action@v3 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # System 2 | .DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (http://nodejs.org/api/addons.html) 36 | build/Release 37 | dist 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # Typescript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "trailingComma": "es5", 4 | "singleQuote": true, 5 | "endOfLine": "lf", 6 | "overrides": [ 7 | { 8 | "files": ".prettierrc", 9 | "options": { "parser": "json" } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Martin Hansen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-hyper-schema ![example workflow](https://github.com/mokkabonna/json-hyper-schema/actions/workflows/test.yml/badge.svg) [![codecov](https://codecov.io/gh/mokkabonna/json-hyper-schema/branch/master/graph/badge.svg?token=3MhxT65dCW)](https://codecov.io/gh/mokkabonna/json-hyper-schema) 2 | 3 | > A javascript implementation of json hyper schema. 4 | 5 | This is currently under heavy development, expect the API to change. The tests are probably the best description of the functionality so far. So here is the spec so far: 6 | 7 | ``` 8 | extract sub schema 9 | plain schema 10 | ✔ return a new schema the one sub schema 11 | - throws if not pointing to a subschema 12 | non property pointer 13 | ✔ extracts that schema 14 | not keyword 15 | ✔ extracts the not schema 16 | patternProperties 17 | ✔ includes the schema if allOf if matching the property name 18 | additionalProperties 19 | ✔ does not include the schema if matching properties or patternProperties 20 | ✔ does include the schema if not matching properties or patternProperties 21 | ✔ does include the schema if not matching properties or patternProperties 22 | dependentSchemas 23 | ✔ considers 24 | - works with nested dependencies 25 | schemas in arrays 26 | ✔ extracts allOf 27 | ✔ extracts anyOf 28 | ✔ extracts oneOf 29 | ✔ extracts deeply nested ones 30 | 31 | getTemplateVariableInfoFromInstance 32 | ✔ returns no value if not in instance 33 | ✔ sets properties as not accepting user input if no hrefSchema or set to false 34 | ✔ sets properties as not accepting user input if one property is false 35 | ✔ works with patternproperties 36 | ✔ works with normal properties, patternproperties and additionalProperties 37 | 38 | getTemplateData 39 | ✔ returns empty object when no templated parts 40 | ✔ returns data from instance when templated 41 | ✔ returns empty if not existing in the template 42 | ✔ supports absolute templatePointers 43 | ✔ returns all values 44 | ✔ supports relative templatePointers 45 | 46 | relative json pointer util 47 | resolve to value 48 | ✔ resolves 0 from root 49 | ✔ resolves 0 50 | ✔ resolves 1 51 | ✔ resolves 1/0 52 | ✔ resolves 1/highly/nested/objects 53 | ✔ resolves from within array to sibling array in parent 54 | ✔ does not throw if 0 value and root reference 55 | ✔ throws if with leading zeros 56 | ✔ throws if non number 57 | ✔ throws when trying to go above the root 58 | ✔ throws if trying to get key of root 59 | ✔ resolves 0/objects 60 | resolve to property name 61 | ✔ resolves to index in array 62 | ✔ resolves to property name 63 | 64 | Link discovery based on examples in the JSON hyper schema spec 65 | ✔ Spec examples: Entry point links, no templates. Example in 9.1 66 | ✔ Spec examples: Individually Identified Resources Example in 9.2 67 | ✔ Spec examples: Updated entry point schema with thing Example in 9.2 68 | ✔ Spec examples: Submitting a payload and accepting URI input Example in 9.3 69 | 70 | 71 | 41 passing (17ms) 72 | 2 pending 73 | ``` 74 | 75 | ## Utils 76 | 77 | The following utils are currently in this repository, but will probably become their own package: 78 | 79 | - relative-json-pointer: resolve relative json pointers according to https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01 80 | 81 | - extract-sub-schema: Extracts a new sub schema for one or more properties only 82 | -------------------------------------------------------------------------------- /build/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/resolver.js', 5 | output: { 6 | path: path.resolve(__dirname, '../../pure-rest-api/public/js'), 7 | filename: 'json-hyper-schema.bundle.js', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API of the library 2 | 3 | 4 | ## prepare links 5 | 6 | ### lib.resolve(schema, data) 7 | 8 | This function returns an array of link resolution objects. It contains partially or fully resolved links. The presence of targetUri indicates that it is fully resolved. 9 | 10 | Given the schema link: 11 | 12 | ```js 13 | { 14 | rel: 'category', 15 | href: '/categories/{category}' 16 | } 17 | ``` 18 | 19 | and the instance data: 20 | 21 | ``` 22 | { 23 | id: 1, 24 | category: 'clothes' 25 | } 26 | ``` 27 | 28 | The output is as follows: 29 | 30 | ```js 31 | { 32 | contextUri: 'https://example.io/products/1', 33 | contextPointer: '', 34 | rel: 'category', 35 | targetUri: 'https://example.io/categories/clothes', 36 | attachmentPointer: '' 37 | } 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/external-libraries.md: -------------------------------------------------------------------------------- 1 | There are several tasks that is not core to hyper schema itself. 2 | 3 | These are: 4 | 5 | - validation 6 | - gathering subschema 7 | - resolving relative json pointers 8 | - generating user interface 9 | - http(or other protocol) communication 10 | 11 | These tasks should be done by external libraries. They should be replaceable. -------------------------------------------------------------------------------- /docs/schema-to-instance-pointer.md: -------------------------------------------------------------------------------- 1 | A util that determines what schema pointers apply to which instance values 2 | 3 | / => / 4 | /properties/foo => /foo 5 | /properties/properties/properties/foo => /properties/foo 6 | /properties/array/items/2 = /array/2 7 | /allOf/0/properties/foo => /foo 8 | /anyOf/0/properties/foo => /foo 9 | /properties/properties/properties/foo/items/0 => /properties/foo/0 10 | 11 | /patternProperties/foo => /foo 12 | /patternProperties/foo.+ => /foo1, /foofoo etc 13 | /additionalProperties => /* //need to know about properties and patternProperties 14 | 15 | 16 | 17 | /items/0 => /0 18 | /items => /0-X // how to solve this? 19 | 20 | /items/0 => /0 21 | /items/1 => /1 22 | /additionalItems => /2-X //need to know about items keyword also 23 | 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-hyper-schema", 3 | "version": "0.1.0", 4 | "description": "A json hyper schema implementation.", 5 | "main": "src/index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "scripts": { 10 | "eslint": "eslint src test", 11 | "test": "npm run eslint && c8 --reporter=html --reporter=lcov --reporter=text mocha test/specs/**", 12 | "develop": "nodemon --exec 'c8 --reporter=lcov mocha test/specs/** || exit 1'" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/mokkabonna/json-hyper-schema.git" 17 | }, 18 | "keywords": [ 19 | "json", 20 | "json-schema", 21 | "schema", 22 | "json-hyper-schema" 23 | ], 24 | "author": "Martin Hansen", 25 | "license": "MIT", 26 | "type": "module", 27 | "bugs": { 28 | "url": "https://github.com/mokkabonna/json-hyper-schema/issues" 29 | }, 30 | "homepage": "https://github.com/mokkabonna/json-hyper-schema#readme", 31 | "dependencies": { 32 | "eslint-config-prettier": "^8.6.0", 33 | "eslint-plugin-prettier": "^4.2.1", 34 | "json-pointer": "^0.6.2", 35 | "json-schema-traverse": "^1.0.0", 36 | "lodash-es": "^4.17.21", 37 | "uri-templates": "^0.2.0" 38 | }, 39 | "devDependencies": { 40 | "c8": "^7.13.0", 41 | "chai": "^4.3.7", 42 | "eslint": "^8.34.0", 43 | "eslint-config": "^0.3.0", 44 | "eslint-config-standard": "^10.2.1", 45 | "eslint-plugin-import": "^2.27.5", 46 | "eslint-plugin-node": "^5.2.1", 47 | "eslint-plugin-promise": "^3.8.0", 48 | "eslint-plugin-standard": "^3.1.0", 49 | "json-schema-ref-parser": "^3.3.1", 50 | "json-stringify-safe": "^5.0.1", 51 | "mocha": "^10.2.0", 52 | "nodemon": "^2.0.20", 53 | "prettier": "^2.8.4", 54 | "uri-js": "^4.4.1" 55 | }, 56 | "volta": { 57 | "node": "18.14.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/discovery.js: -------------------------------------------------------------------------------- 1 | import { extractSubSchemas } from './extract-sub-schema.js'; 2 | import { getTemplateVariableInfoFromInstance } from './get-default-input-values.js'; 3 | 4 | const normalizeArray = arr => (Array.isArray(arr) ? arr : [arr]); 5 | 6 | function createLinks(ldo, instance, instanceUri, instancePointer, basesSoFar) { 7 | const defaultInputValues = getTemplateVariableInfoFromInstance(ldo, instance); 8 | const relNormalized = normalizeArray(ldo.rel); 9 | 10 | const configs = relNormalized.map(rel => ({ 11 | contextPointer: instancePointer, 12 | rel, 13 | contextUri: instanceUri, 14 | attachmentPointer: instancePointer, 15 | hrefInputTemplates: [ldo.href, ...basesSoFar], 16 | templateVariableInfo: defaultInputValues, 17 | })); 18 | 19 | return configs; 20 | } 21 | 22 | // for now just the root schema 23 | function flattenSchemas(rootSchema) { 24 | return [rootSchema]; 25 | } 26 | 27 | function discoverRecursive( 28 | schema, 29 | instance, 30 | instanceUri, 31 | instancePointer = '', 32 | basesSoFar = [] 33 | ) { 34 | const subSchema = extractSubSchemas(schema, instancePointer); 35 | const schemas = flattenSchemas(subSchema); 36 | 37 | const discoveredLinks = schemas.flatMap(schema => { 38 | return (schema.links ?? []).flatMap(ldo => { 39 | let bases = basesSoFar; 40 | if (schema.base) { 41 | bases = [...basesSoFar, schema.base]; 42 | } 43 | return createLinks(ldo, instance, instanceUri, instancePointer, bases); 44 | }); 45 | }); 46 | 47 | return discoveredLinks; 48 | } 49 | 50 | function discoverLinks({ schema, instance, instanceUri }) { 51 | //FIXME get all links from schema and subschemas, get only root for now 52 | const discoveredLinks = discoverRecursive(schema, instance, instanceUri); 53 | 54 | return { 55 | discoveredLinks, 56 | }; 57 | } 58 | 59 | export { discoverLinks }; 60 | -------------------------------------------------------------------------------- /src/extract-sub-schema.js: -------------------------------------------------------------------------------- 1 | // convert to imports 2 | import pointer from 'json-pointer'; 3 | import { isPlainObject, attempt, merge, reduce, isObject } from 'lodash-es'; 4 | 5 | const isNotEmptyObject = o => isPlainObject(o) && Object.keys(o).length > 0; 6 | const isRestrictingSchema = s => isNotEmptyObject(s) || s === false; 7 | const has = (obj, prop) => isObject(obj) && Reflect.has(obj, prop); 8 | const isArray = Array.isArray; 9 | 10 | /** 11 | * Extracts all schemas that applies to a particular point in the schema 12 | * Iterates over allOf, anyOf and oneOf and construct a new schema that contains all schemas for the given jsonPointer 13 | * @param {*} schema 14 | * @param {*} jsonPointer 15 | * @param {*} options 16 | * @returns 17 | */ 18 | function extractSubSchemas(schema, jsonPointer, options) { 19 | options = options || {}; 20 | const tokens = pointer.parse(jsonPointer); 21 | const lastToken = tokens[tokens.length - 1]; 22 | let newSchema = {}; 23 | let hasPatternProperty = false; 24 | 25 | attempt(function () { 26 | const subSchema = pointer.get(schema, jsonPointer); 27 | if (subSchema === false) { 28 | newSchema = false; 29 | } else { 30 | merge(newSchema, subSchema); 31 | } 32 | }); 33 | 34 | const hasPlainProperty = isNotEmptyObject(newSchema); 35 | 36 | const schemaArrays = ['allOf', 'oneOf', 'anyOf']; 37 | 38 | schemaArrays.forEach(function (name) { 39 | if (has(schema, name)) { 40 | newSchema[name] = schema[name] 41 | .map(function (arraySchema) { 42 | return extractSubSchemas(arraySchema, jsonPointer); 43 | }) 44 | .filter(isRestrictingSchema); 45 | } 46 | }); 47 | 48 | if (has(schema, 'not')) { 49 | newSchema.not = extractSubSchemas(schema.not, jsonPointer); 50 | } 51 | 52 | if (has(schema, 'patternProperties')) { 53 | const patternSchemas = reduce( 54 | schema.patternProperties, 55 | function (all, schema, key) { 56 | if (new RegExp(key).test(lastToken)) { 57 | all.push(schema); 58 | } 59 | return all; 60 | }, 61 | [] 62 | ); 63 | 64 | if (patternSchemas.length) { 65 | hasPatternProperty = true; 66 | newSchema.allOf = (newSchema.allOf || []).concat(patternSchemas); 67 | } 68 | } 69 | 70 | if ( 71 | has(schema, 'additionalProperties') && 72 | !hasPlainProperty && 73 | !hasPatternProperty 74 | ) { 75 | newSchema.allOf = (newSchema.allOf || []).concat( 76 | schema.additionalProperties 77 | ); 78 | } 79 | 80 | // finds all dependent schemas that are applies if keys are present in the instance 81 | // FIXME need to probably have the full instance, as we need to walk the same tree to find what keys are actually present 82 | if (has(schema, 'dependentSchemas') && isArray(options.presentKeys)) { 83 | const dependencySchemas = options.presentKeys.reduce(function (all, key) { 84 | if (has(schema.dependentSchemas, key)) { 85 | all.push(extractSubSchemas(schema.dependentSchemas[key], jsonPointer)); 86 | } 87 | return all; 88 | }, []); 89 | 90 | newSchema.allOf = (newSchema.allOf || []).concat(dependencySchemas); 91 | } 92 | 93 | return newSchema; 94 | } 95 | 96 | export { extractSubSchemas }; 97 | -------------------------------------------------------------------------------- /src/get-default-input-values.js: -------------------------------------------------------------------------------- 1 | import uriTemplates from 'uri-templates'; 2 | import { extractSubSchemas } from './extract-sub-schema.js'; 3 | import { getTemplateData } from './get-template-data.js'; 4 | 5 | const isFalse = s => s === false; 6 | const has = Reflect.has; 7 | 8 | const isRequiredTemplateVariable = (ldo, name) => { 9 | if (ldo.templateRequired?.includes(name)) return true; 10 | if (ldo.hrefSchema?.required?.includes(name)) return true; 11 | return false; 12 | }; 13 | 14 | /** 15 | * Checks all schemas if any is defined as false, then user input is not allowed 16 | */ 17 | function schemaAcceptsUserInput(schema) { 18 | if (schema === false) return false; 19 | if (Array.isArray(schema.allOf) && schema.allOf.some(isFalse)) return false; 20 | if (Array.isArray(schema.anyOf) && schema.anyOf.every(isFalse)) return false; 21 | if (Array.isArray(schema.oneOf) && schema.oneOf.every(isFalse)) return false; 22 | return true; 23 | } 24 | 25 | function getTemplateVariableInfoFromInstance(ldo, instance) { 26 | const parsedTemplate = uriTemplates(ldo.href); 27 | 28 | const templateData = getTemplateData({ 29 | uriTemplate: ldo.href, 30 | ldo, 31 | instance, 32 | }); 33 | 34 | // check if all template variables does not accept user input 35 | if (ldo.hrefSchema === false || !has(ldo, 'hrefSchema')) { 36 | return Object.fromEntries( 37 | parsedTemplate.varNames.map(name => { 38 | //fixme can varnames have multiple of same? 39 | 40 | const value = templateData[name]; 41 | 42 | return [ 43 | name, 44 | { 45 | ...(Reflect.has(templateData, name) ? { value } : {}), 46 | isRequired: isRequiredTemplateVariable(ldo, name), 47 | acceptsUserInput: false, 48 | hasValueFromInstance: has(templateData, name), 49 | }, 50 | ]; 51 | }) 52 | ); 53 | } 54 | 55 | return Object.fromEntries( 56 | parsedTemplate.varNames.map(name => { 57 | const subSchema = extractSubSchemas( 58 | ldo.hrefSchema, 59 | '/properties/' + name 60 | ); 61 | const value = templateData[name]; 62 | 63 | return [ 64 | name, 65 | { 66 | //fixme consider rename value to more descriptive like valueFromInstance 67 | ...(Reflect.has(templateData, name) ? { value } : {}), 68 | isRequired: isRequiredTemplateVariable(ldo, name), 69 | acceptsUserInput: schemaAcceptsUserInput(subSchema), 70 | hasValueFromInstance: has(templateData, name), 71 | }, 72 | ]; 73 | }) 74 | ); 75 | } 76 | 77 | export { getTemplateVariableInfoFromInstance }; 78 | -------------------------------------------------------------------------------- /src/get-template-data.js: -------------------------------------------------------------------------------- 1 | import { resolveRelativeJsonPointer } from './relative-json-pointer.js'; 2 | import uriTemplates from 'uri-templates'; 3 | import jsonPointer from 'json-pointer'; 4 | 5 | const isRelative = jsonPointer => /^\d+/.test(jsonPointer); 6 | const has = Reflect.has; 7 | 8 | /** 9 | * 10 | * Returns the data for a template given the provided link description object and instance data 11 | * @param {*} uriTemplate the uri template 12 | * @param {*} ldo the resolved "Link Description Object" 13 | * @param {*} instance the instance data 14 | * @returns 15 | */ 16 | function getTemplateData({ uriTemplate, ldo, instance }) { 17 | const parsedTemplate = uriTemplates(uriTemplate); 18 | const templatePointers = ldo.templatePointers ?? {}; 19 | const attachmentPointer = ldo.attachmentPointer ?? ''; 20 | 21 | const result = parsedTemplate.varNames.reduce(function (all, name) { 22 | name = decodeURIComponent(name); 23 | let valuePointer; 24 | 25 | if (has(templatePointers, name)) { 26 | valuePointer = templatePointers[name]; 27 | if (isRelative(valuePointer)) { 28 | try { 29 | all[name] = resolveRelativeJsonPointer( 30 | instance, 31 | attachmentPointer, 32 | valuePointer 33 | ); 34 | } catch (e) { 35 | // Ignore for now 36 | } 37 | } else { 38 | attemptSet(); 39 | } 40 | } else { 41 | valuePointer = attachmentPointer + '/' + name; 42 | attemptSet(); 43 | } 44 | 45 | function attemptSet() { 46 | try { 47 | all[name] = jsonPointer.get(instance, valuePointer); 48 | } catch (e) { 49 | // Ignore for now 50 | } 51 | } 52 | 53 | return all; 54 | }, {}); 55 | 56 | return result; 57 | } 58 | 59 | export { getTemplateData }; 60 | -------------------------------------------------------------------------------- /src/relative-json-pointer.js: -------------------------------------------------------------------------------- 1 | import pointer from 'json-pointer'; 2 | 3 | /** 4 | * Resolves a relative json pointer to a value in the provided data. Needs a base pointer to resolve from. 5 | * 6 | * @param {*} data the json data 7 | * @param {*} basePointer the starting point for the relative pointer 8 | * @param {*} relative the relative pointer 9 | * @returns the value located at the relative pointer location 10 | */ 11 | function resolveRelativeJsonPointer(data, basePointer, relative) { 12 | const tokens = pointer.parse(basePointer); 13 | const match = 14 | /^(?(?[0])?(?[1-9]([0-9]+)?)?)(?.+)?/.exec( 15 | relative 16 | ); 17 | 18 | if ( 19 | !match || 20 | (match.groups.zero && match.groups.nonzero) || // cant have both 0 and non zero 21 | !match.groups.number // must have a number 22 | ) { 23 | throw new Error( 24 | 'Invalid relative pointer. Must start with either 0 or a number >= 1 with non leading zeros.' 25 | ); 26 | } 27 | 28 | const prefix = parseInt(match.groups.number, 10); 29 | const relPointer = match.groups.rest; 30 | 31 | if (prefix > tokens.length || (prefix >= tokens.length && tokens[0] === '')) { 32 | throw new Error('Trying to reference value above root.'); 33 | } 34 | 35 | const relativeTokens = tokens.slice(0, tokens.length - prefix); 36 | 37 | if (relPointer === '#') { 38 | if (relativeTokens[0] === '') { 39 | throw new Error('Tring to get key of root. It does not have a key.'); 40 | } 41 | 42 | let propOrIndex = relativeTokens.pop(); 43 | let newPointer = pointer.compile(relativeTokens); 44 | let parentValue = pointer.get(data, newPointer); 45 | if (Array.isArray(parentValue)) { 46 | return parseInt(propOrIndex, 10); 47 | } else { 48 | return propOrIndex; 49 | } 50 | } else { 51 | let newPointer = pointer.compile(relativeTokens); 52 | 53 | newPointer = newPointer + (relPointer || ''); 54 | 55 | if (newPointer === '/') { 56 | return data; 57 | } else { 58 | return pointer.get(data, newPointer); 59 | } 60 | } 61 | } 62 | 63 | export { resolveRelativeJsonPointer }; 64 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "describe": true, 4 | "it": true, 5 | "beforeEach": true, 6 | "afterEach": true, 7 | "before": true, 8 | "after": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /test/specs/extract-sub-schema.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { extractSubSchemas } from '../../src/extract-sub-schema.js'; 3 | 4 | describe('extract sub schema', function () { 5 | let schema; 6 | 7 | beforeEach(function () { 8 | schema = { 9 | properties: { 10 | name: { 11 | minLength: 2, 12 | }, 13 | }, 14 | }; 15 | }); 16 | 17 | describe('plain schema', function () { 18 | it('return a new schema the one sub schema', function () { 19 | const result = extractSubSchemas(schema, '/properties/name'); 20 | expect(result).to.eql({ minLength: 2 }); 21 | }); 22 | 23 | it('throws if not pointing to a subschema'); 24 | }); 25 | 26 | describe('non property pointer', function () { 27 | it('extracts that schema', function () { 28 | const result = extractSubSchemas( 29 | { 30 | properties: schema.properties, 31 | additionalProperties: { 32 | maxLength: 5, 33 | allOf: [ 34 | { 35 | pattern: '.+', 36 | }, 37 | ], 38 | }, 39 | }, 40 | '/additionalProperties' 41 | ); 42 | expect(result).to.eql({ 43 | maxLength: 5, 44 | allOf: [ 45 | { 46 | pattern: '.+', 47 | }, 48 | ], 49 | }); 50 | }); 51 | }); 52 | 53 | describe('not keyword', function () { 54 | it('extracts the not schema', function () { 55 | const result = extractSubSchemas( 56 | { 57 | properties: schema.properties, 58 | not: { 59 | properties: { 60 | name: { 61 | maxLength: 5, 62 | }, 63 | }, 64 | }, 65 | }, 66 | '/properties/name' 67 | ); 68 | expect(result).to.eql({ 69 | minLength: 2, 70 | not: { 71 | maxLength: 5, 72 | }, 73 | }); 74 | }); 75 | }); 76 | 77 | describe('patternProperties', function () { 78 | it('includes the schema if allOf if matching the property name', function () { 79 | const result = extractSubSchemas( 80 | { 81 | properties: schema.properties, 82 | patternProperties: { 83 | 'na.e': { 84 | maxLength: 5, 85 | }, 86 | 'n..e': { 87 | pattern: '.+', 88 | }, 89 | notMe: false, 90 | }, 91 | }, 92 | '/properties/name' 93 | ); 94 | 95 | expect(result).to.eql({ 96 | minLength: 2, 97 | allOf: [ 98 | { 99 | maxLength: 5, 100 | }, 101 | { 102 | pattern: '.+', 103 | }, 104 | ], 105 | }); 106 | }); 107 | }); 108 | 109 | describe('additionalProperties', function () { 110 | it('does not include the schema if matching properties or patternProperties', function () { 111 | const result = extractSubSchemas( 112 | { 113 | properties: schema.properties, 114 | patternProperties: { 115 | 'na.e': { 116 | maxLength: 5, 117 | }, 118 | notMe: false, 119 | }, 120 | additionalProperties: { 121 | pattern: '.+', 122 | }, 123 | }, 124 | '/properties/name' 125 | ); 126 | 127 | expect(result).to.eql({ 128 | minLength: 2, 129 | allOf: [ 130 | { 131 | maxLength: 5, 132 | }, 133 | ], 134 | }); 135 | }); 136 | 137 | it('does include the schema if not matching properties or patternProperties', function () { 138 | const result = extractSubSchemas( 139 | { 140 | properties: schema.properties, 141 | patternProperties: { 142 | 'na.e': { 143 | maxLength: 5, 144 | }, 145 | notMe: false, 146 | }, 147 | additionalProperties: { 148 | pattern: '.+', 149 | }, 150 | }, 151 | '/properties/foo' 152 | ); 153 | 154 | expect(result).to.eql({ 155 | allOf: [ 156 | { 157 | pattern: '.+', 158 | }, 159 | ], 160 | }); 161 | }); 162 | 163 | it('does include the schema if not matching properties or patternProperties', function () { 164 | const result = extractSubSchemas( 165 | { 166 | properties: schema.properties, 167 | patternProperties: { 168 | 'na.e': { 169 | maxLength: 5, 170 | }, 171 | notMe: false, 172 | }, 173 | additionalProperties: false, 174 | }, 175 | '/properties/foo' 176 | ); 177 | 178 | expect(result).to.eql({ allOf: [false] }); 179 | }); 180 | }); 181 | 182 | describe('dependentSchemas', function () { 183 | it('considers ', function () { 184 | const result = extractSubSchemas( 185 | { 186 | properties: schema.properties, 187 | dependentSchemas: { 188 | other: { 189 | properties: { 190 | name: { 191 | maxLength: 7, 192 | }, 193 | }, 194 | }, 195 | }, 196 | }, 197 | '/properties/name', 198 | { presentKeys: ['other'] } 199 | ); 200 | 201 | expect(result).to.eql({ 202 | minLength: 2, 203 | allOf: [ 204 | { 205 | maxLength: 7, 206 | }, 207 | ], 208 | }); 209 | }); 210 | 211 | it('works with nested dependencies'); 212 | }); 213 | 214 | describe('schemas in arrays', function () { 215 | it('extracts allOf', function () { 216 | const result = extractSubSchemas( 217 | { 218 | properties: schema.properties, 219 | allOf: [ 220 | { 221 | properties: { 222 | name: { 223 | maxLength: 5, 224 | }, 225 | }, 226 | }, 227 | ], 228 | }, 229 | '/properties/name' 230 | ); 231 | expect(result).to.eql({ 232 | minLength: 2, 233 | allOf: [ 234 | { 235 | maxLength: 5, 236 | }, 237 | ], 238 | }); 239 | }); 240 | 241 | it('extracts anyOf', function () { 242 | const result = extractSubSchemas( 243 | { 244 | properties: schema.properties, 245 | anyOf: [ 246 | { 247 | properties: { 248 | name: { 249 | maxLength: 5, 250 | }, 251 | }, 252 | }, 253 | ], 254 | }, 255 | '/properties/name' 256 | ); 257 | expect(result).to.eql({ 258 | minLength: 2, 259 | anyOf: [ 260 | { 261 | maxLength: 5, 262 | }, 263 | ], 264 | }); 265 | }); 266 | 267 | it('extracts oneOf', function () { 268 | const result = extractSubSchemas( 269 | { 270 | properties: schema.properties, 271 | oneOf: [ 272 | { 273 | properties: { 274 | name: { 275 | maxLength: 5, 276 | }, 277 | }, 278 | }, 279 | ], 280 | }, 281 | '/properties/name' 282 | ); 283 | expect(result).to.eql({ 284 | minLength: 2, 285 | oneOf: [ 286 | { 287 | maxLength: 5, 288 | }, 289 | ], 290 | }); 291 | }); 292 | 293 | it('extracts deeply nested ones', function () { 294 | const result = extractSubSchemas( 295 | { 296 | properties: schema.properties, 297 | allOf: [ 298 | { 299 | properties: { 300 | name: { 301 | maxLength: 5, 302 | }, 303 | }, 304 | allOf: [ 305 | { 306 | properties: { 307 | name: { 308 | pattern: '.+', 309 | }, 310 | }, 311 | }, 312 | { 313 | properties: { 314 | foo: true, 315 | }, 316 | }, 317 | ], 318 | }, 319 | ], 320 | }, 321 | '/properties/name' 322 | ); 323 | 324 | expect(result).to.eql({ 325 | minLength: 2, 326 | allOf: [ 327 | { 328 | maxLength: 5, 329 | allOf: [ 330 | { 331 | pattern: '.+', 332 | }, 333 | ], 334 | }, 335 | ], 336 | }); 337 | }); 338 | }); 339 | }); 340 | -------------------------------------------------------------------------------- /test/specs/get-default-input-values.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import _ from 'lodash'; 3 | import { getTemplateVariableInfoFromInstance } from '../../src/get-default-input-values.js'; 4 | 5 | describe('getTemplateVariableInfoFromInstance', function () { 6 | it('returns no value if not in instance', function () { 7 | const result = getTemplateVariableInfoFromInstance( 8 | { 9 | href: '/products/{id}', 10 | templateRequired: ['id'], 11 | }, 12 | {} 13 | ); 14 | 15 | expect(result).to.eql({ 16 | id: { 17 | isRequired: true, 18 | hasValueFromInstance: false, 19 | acceptsUserInput: false, 20 | }, 21 | }); 22 | }); 23 | 24 | it('sets properties as not accepting user input if no hrefSchema or set to false', () => { 25 | const result = getTemplateVariableInfoFromInstance( 26 | { 27 | href: '/products/{id}', 28 | }, 29 | { 30 | id: 1, 31 | } 32 | ); 33 | 34 | expect(result).to.eql({ 35 | id: { 36 | value: 1, 37 | isRequired: false, 38 | hasValueFromInstance: true, 39 | acceptsUserInput: false, 40 | }, 41 | }); 42 | 43 | const result2 = getTemplateVariableInfoFromInstance( 44 | { 45 | href: '/products/{id}', 46 | hrefSchema: false, 47 | }, 48 | { 49 | id: 1, 50 | } 51 | ); 52 | 53 | expect(result2).to.eql({ 54 | id: { 55 | value: 1, 56 | isRequired: false, 57 | hasValueFromInstance: true, 58 | acceptsUserInput: false, 59 | }, 60 | }); 61 | }); 62 | 63 | it('sets properties as not accepting user input if one property is false', () => { 64 | const result = getTemplateVariableInfoFromInstance( 65 | { 66 | href: '/products/{id}', 67 | templateRequired: ['id'], 68 | hrefSchema: { 69 | properties: { 70 | id: false, 71 | }, 72 | }, 73 | }, 74 | { 75 | id: 2, 76 | } 77 | ); 78 | 79 | expect(result).to.eql({ 80 | id: { 81 | value: 2, 82 | isRequired: true, 83 | hasValueFromInstance: true, 84 | acceptsUserInput: false, 85 | }, 86 | }); 87 | }); 88 | 89 | it('works with patternproperties', () => { 90 | const result = getTemplateVariableInfoFromInstance( 91 | { 92 | href: '/products/{id}', 93 | hrefSchema: { 94 | patternProperties: { 95 | '.*': false, 96 | }, 97 | }, 98 | }, 99 | { 100 | id: 2, 101 | } 102 | ); 103 | 104 | expect(result).to.eql({ 105 | id: { 106 | value: 2, 107 | isRequired: false, 108 | hasValueFromInstance: true, 109 | acceptsUserInput: false, 110 | }, 111 | }); 112 | }); 113 | 114 | it('works with normal properties, patternproperties and additionalProperties', () => { 115 | const result = getTemplateVariableInfoFromInstance( 116 | { 117 | href: '/products/{extra}/{123}/{a}/{b}', 118 | hrefSchema: { 119 | a: { 120 | type: 'string', 121 | }, 122 | b: false, 123 | patternProperties: { 124 | '^\\d+$': false, 125 | }, 126 | additionalProperties: false, 127 | }, 128 | }, 129 | { 130 | a: 2, 131 | b: 8, 132 | 123: 3, 133 | extra: 9, 134 | } 135 | ); 136 | 137 | expect(result).to.eql({ 138 | a: { 139 | value: 2, 140 | isRequired: false, 141 | hasValueFromInstance: true, 142 | acceptsUserInput: false, 143 | }, 144 | b: { 145 | value: 8, 146 | isRequired: false, 147 | hasValueFromInstance: true, 148 | acceptsUserInput: false, 149 | }, 150 | 123: { 151 | value: 3, 152 | isRequired: false, 153 | hasValueFromInstance: true, 154 | acceptsUserInput: false, 155 | }, 156 | extra: { 157 | value: 9, 158 | isRequired: false, 159 | hasValueFromInstance: true, 160 | acceptsUserInput: false, 161 | }, 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /test/specs/get-template-data.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { getTemplateData } from '../../src/get-template-data.js'; 3 | 4 | describe('getTemplateData', function () { 5 | let link; 6 | 7 | beforeEach(function () { 8 | link = { 9 | rel: 'about', 10 | href: '/about', 11 | }; 12 | }); 13 | 14 | it('returns empty object when no templated parts', function () { 15 | const result = getTemplateData({ 16 | uriTemplate: link.href, 17 | ldo: link, 18 | instance: {}, 19 | }); 20 | expect(result).to.eql({}); 21 | }); 22 | 23 | it('returns data from instance when templated', function () { 24 | const result = getTemplateData({ 25 | uriTemplate: '/products/{id%28}', 26 | ldo: { 27 | rel: 'self', 28 | href: 'notImportant', 29 | }, 30 | instance: { 31 | 'id(': 7, 32 | }, 33 | }); 34 | expect(result).to.eql({ 35 | 'id(': 7, 36 | }); 37 | }); 38 | 39 | it('returns empty if not existing in the template', function () { 40 | const result = getTemplateData({ 41 | uriTemplate: '/products/{id%28}', 42 | ldo: { 43 | rel: 'self', 44 | href: 'notImportant', 45 | }, 46 | instance: {}, 47 | }); 48 | expect(result).to.eql({}); 49 | }); 50 | 51 | it('supports absolute templatePointers', function () { 52 | const result = getTemplateData({ 53 | uriTemplate: '/products/{id}', 54 | ldo: { 55 | rel: 'self', 56 | href: 'notImportant', 57 | templatePointers: { 58 | id: '/child/id', 59 | }, 60 | }, 61 | instance: { 62 | id: 8, 63 | child: { 64 | id: 9, 65 | }, 66 | }, 67 | }); 68 | expect(result).to.eql({ 69 | id: 9, 70 | }); 71 | }); 72 | 73 | it('returns all values', () => { 74 | const result = getTemplateData({ 75 | uriTemplate: '/products/{id}/child/{childId}', 76 | ldo: { 77 | rel: 'self', 78 | href: 'notImportant', 79 | templatePointers: { 80 | childId: '/child/id', 81 | }, 82 | }, 83 | instance: { 84 | id: 8, 85 | child: { 86 | id: 9, 87 | }, 88 | }, 89 | }); 90 | 91 | expect(result).to.eql({ 92 | id: 8, 93 | childId: 9, 94 | }); 95 | }); 96 | 97 | it('supports relative templatePointers', function () { 98 | let result = getTemplateData({ 99 | uriTemplate: '/products/{id}', 100 | ldo: { 101 | rel: 'self', 102 | href: 'notImportant', 103 | templatePointers: { 104 | id: '0/child/id', 105 | }, 106 | }, 107 | instance: { 108 | id: 8, 109 | child: { 110 | id: 9, 111 | }, 112 | }, 113 | }); 114 | expect(result).to.eql({ 115 | id: 9, 116 | }); 117 | 118 | result = getTemplateData({ 119 | uriTemplate: '/products/{id}', 120 | ldo: { 121 | rel: 'self', 122 | href: 'notImportant', 123 | attachmentPointer: '/child/arr/2', 124 | templatePointers: { 125 | id: '3/child/id', 126 | }, 127 | }, 128 | instance: { 129 | id: 8, 130 | child: { 131 | id: 9, 132 | arr: [1, 2, 3], 133 | }, 134 | }, 135 | }); 136 | expect(result).to.eql({ 137 | id: 9, 138 | }); 139 | 140 | result = getTemplateData({ 141 | uriTemplate: '/products/{id}', 142 | ldo: { 143 | rel: 'self', 144 | href: 'notImportant', 145 | attachmentPointer: '/child/arr/2', 146 | templatePointers: { 147 | id: '1/1', 148 | }, 149 | }, 150 | instance: { 151 | id: 8, 152 | child: { 153 | id: 9, 154 | arr: [1, 2, 3], 155 | }, 156 | }, 157 | }); 158 | expect(result).to.eql({ 159 | id: 2, 160 | }); 161 | 162 | result = getTemplateData({ 163 | uriTemplate: '/products/{id}', 164 | ldo: { 165 | rel: 'self', 166 | href: 'notImportant', 167 | attachmentPointer: '/child/id', 168 | templatePointers: { 169 | id: '2/id', 170 | }, 171 | }, 172 | instance: { 173 | id: 8, 174 | child: { 175 | id: 9, 176 | }, 177 | }, 178 | }); 179 | expect(result).to.eql({ 180 | id: 8, 181 | }); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /test/specs/relative-json-pointer.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { resolveRelativeJsonPointer } from '../../src/relative-json-pointer.js'; 3 | 4 | describe('relative json pointer util', function () { 5 | let data; 6 | beforeEach(function () { 7 | data = { 8 | foo: ['bar', 'baz'], 9 | highly: { 10 | nested: { 11 | objects: true, 12 | arrayinarray: [1, [11, 12], [3, 4, 5]], 13 | }, 14 | }, 15 | }; 16 | }); 17 | 18 | describe('resolve to value', function () { 19 | it('resolves 0 from root', function () { 20 | expect(resolveRelativeJsonPointer(data, '/', '0')).to.eql(data); 21 | }); 22 | 23 | it('resolves 0', function () { 24 | expect(resolveRelativeJsonPointer(data, '/foo/1', '0')).to.eql('baz'); 25 | }); 26 | 27 | it('resolves 1', function () { 28 | expect(resolveRelativeJsonPointer(data, '/foo/1', '1')).to.eql([ 29 | 'bar', 30 | 'baz', 31 | ]); 32 | }); 33 | 34 | it('resolves 1/0', function () { 35 | expect(resolveRelativeJsonPointer(data, '/foo/1', '1/0')).to.eql('bar'); 36 | }); 37 | 38 | it('resolves 1/highly/nested/objects', function () { 39 | expect( 40 | resolveRelativeJsonPointer(data, '/foo/1', '2/highly/nested/objects') 41 | ).to.eql(true); 42 | }); 43 | 44 | it('resolves from within array to sibling array in parent', () => { 45 | expect( 46 | resolveRelativeJsonPointer(data, '/highly/nested/arrayinarray/2/2', '0') 47 | ).to.eql(5, 'from 5 to itself'); 48 | expect( 49 | resolveRelativeJsonPointer( 50 | data, 51 | '/highly/nested/arrayinarray/2/2', 52 | '1/1' 53 | ) 54 | ).to.eql(4, 'from 5 to 4'); 55 | 56 | expect( 57 | resolveRelativeJsonPointer( 58 | data, 59 | '/highly/nested/arrayinarray/2/2', 60 | '2/1/1' 61 | ) 62 | ).to.eql(12, 'from 5 to 12'); 63 | }); 64 | 65 | it('does not throw if 0 value and root reference', function () { 66 | expect(resolveRelativeJsonPointer(data, '/', '0')).to.eql(data); 67 | }); 68 | 69 | it('throws if with leading zeros', () => { 70 | expect(function () { 71 | resolveRelativeJsonPointer(data, '/foo', '01'); 72 | }).to.throw(/invalid relative pointer/i); 73 | }); 74 | 75 | it('throws if non number', () => { 76 | expect(function () { 77 | resolveRelativeJsonPointer(data, '/foo', 'd'); 78 | }).to.throw(/invalid relative pointer/i); 79 | }); 80 | 81 | it('throws when trying to go above the root', function () { 82 | expect(function () { 83 | resolveRelativeJsonPointer(data, '/', '1'); 84 | }).to.throw(/trying to reference/i); 85 | 86 | expect(function () { 87 | resolveRelativeJsonPointer(data, '/foo/1', '3'); 88 | }).to.throw(/trying to reference value above root/i); 89 | }); 90 | 91 | it('throws if trying to get key of root', function () { 92 | expect(function () { 93 | resolveRelativeJsonPointer(data, '/', '0#'); 94 | }).to.throw(); 95 | }); 96 | 97 | it('resolves 0/objects', function () { 98 | expect( 99 | resolveRelativeJsonPointer(data, '/highly/nested', '0/objects') 100 | ).to.eql(true); 101 | }); 102 | }); 103 | 104 | describe('resolve to property name', function () { 105 | it('resolves to index in array', function () { 106 | expect(resolveRelativeJsonPointer(data, '/foo/1', '0#')).to.eql(1); 107 | }); 108 | 109 | it('resolves to property name', function () { 110 | expect(resolveRelativeJsonPointer(data, '/foo/1', '1#')).to.eql('foo'); 111 | expect(resolveRelativeJsonPointer(data, '/highly/nested', '0#')).to.eql( 112 | 'nested' 113 | ); 114 | expect(resolveRelativeJsonPointer(data, '/highly/nested', '1#')).to.eql( 115 | 'highly' 116 | ); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/specs/test-cases/spec-examples-discovery.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { discoverLinks } from '../../../src/discovery.js'; 3 | import { readFileSync } from 'node:fs'; 4 | 5 | describe('Link discovery based on examples in the JSON hyper schema spec', () => { 6 | const suites = JSON.parse( 7 | readFileSync('test/test-cases/spec-examples-discovery.json', 'utf8') 8 | ); 9 | 10 | for (const suite of suites) { 11 | for (const test of suite.tests) { 12 | it(suite.title + ': ' + test.title, () => { 13 | const result = discoverLinks({ 14 | schema: test.schema, 15 | instance: test.instanceData, 16 | instanceUri: test.instanceUri, 17 | }); 18 | 19 | expect(result.discoveredLinks.length).to.equal( 20 | test.discoveredLinks.length 21 | ); 22 | 23 | for (const [index, link] of Object.entries(result.discoveredLinks)) { 24 | expect(link).to.deep.equal( 25 | test.discoveredLinks[index], 26 | 'linkIndex ' + index 27 | ); 28 | } 29 | }); 30 | } 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /test/test-cases/spec-examples-discovery.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Spec examples", 4 | "description": "This suite tests the examples in the spec for correct link discovery. It does not test templating and resolution of URIs. It is not intended to be a comprehensive test suite. Some examples does not explicitly specify the instance data, but it is added to the test to test the link generation. Some examples does not specify the link configuration or the linstance data, but they describe the result in the text.", 5 | "source": "https://json-schema.org/draft/2019-09/json-schema-hypermedia.html#rfc.section.9", 6 | "tests": [ 7 | { 8 | "title": "Entry point links, no templates. Example in 9.1", 9 | "instanceUri": "https://example.com/api", 10 | "schema": { 11 | "$id": "https://schema.example.com/entry", 12 | "$schema": "https://json-schema.org/draft/2019-09/hyper-schema", 13 | "base": "https://example.com/api/", 14 | "links": [ 15 | { 16 | "rel": "self", 17 | "href": "../api" 18 | }, 19 | { 20 | "rel": "about", 21 | "href": "docs" 22 | } 23 | ] 24 | }, 25 | "instanceData": {}, 26 | "discoveredLinks": [ 27 | { 28 | "contextUri": "https://example.com/api", 29 | "attachmentPointer": "", 30 | "contextPointer": "", 31 | "rel": "self", 32 | "hrefInputTemplates": ["../api", "https://example.com/api/"], 33 | "templateVariableInfo": {} 34 | }, 35 | { 36 | "contextUri": "https://example.com/api", 37 | "attachmentPointer": "", 38 | "contextPointer": "", 39 | "rel": "about", 40 | "hrefInputTemplates": ["docs", "https://example.com/api/"], 41 | "templateVariableInfo": {} 42 | } 43 | ] 44 | }, 45 | { 46 | "title": "Individually Identified Resources Example in 9.2", 47 | "description": "Instancedata not specified for this example but added to test. Neither is generated link config.", 48 | "instanceUri": "https://example.com/api/things/123", 49 | "schema": { 50 | "$id": "https://schema.example.com/thing", 51 | "$schema": "https://json-schema.org/draft/2019-09/hyper-schema", 52 | "base": "https://example.com/api/", 53 | "type": "object", 54 | "required": ["data"], 55 | "properties": { 56 | "id": { "$ref": "#/$defs/id" }, 57 | "data": true 58 | }, 59 | "links": [ 60 | { 61 | "rel": "self", 62 | "href": "things/{id}", 63 | "templateRequired": ["id"], 64 | "targetSchema": { "$ref": "#" } 65 | } 66 | ], 67 | "$defs": { 68 | "id": { 69 | "type": "integer", 70 | "minimum": 1, 71 | "readOnly": true 72 | } 73 | } 74 | }, 75 | "instanceData": { 76 | "id": 123, 77 | "data": "some data" 78 | }, 79 | "discoveredLinks": [ 80 | { 81 | "attachmentPointer": "", 82 | "contextUri": "https://example.com/api/things/123", 83 | "contextPointer": "", 84 | "rel": "self", 85 | "hrefInputTemplates": ["things/{id}", "https://example.com/api/"], 86 | "templateVariableInfo": { 87 | "id": { 88 | "value": 123, 89 | "isRequired": true, 90 | "acceptsUserInput": false, 91 | "hasValueFromInstance": true 92 | } 93 | } 94 | } 95 | ] 96 | }, 97 | { 98 | "title": "Updated entry point schema with thing Example in 9.2", 99 | "description": "Adds link to a thing that can not be directly used, but require input of an id. Ref to thing id is made inline.", 100 | "instanceUri": "https://example.com/api", 101 | "schema": { 102 | "$id": "https://schema.example.com/entry", 103 | "$schema": "https://json-schema.org/draft/2019-09/hyper-schema", 104 | "base": "https://example.com/api/", 105 | "links": [ 106 | { 107 | "rel": "self", 108 | "href": "../api" 109 | }, 110 | { 111 | "rel": "about", 112 | "href": "docs" 113 | }, 114 | { 115 | "rel": "tag:rel.example.com,2017:thing", 116 | "href": "things/{id}", 117 | "hrefSchema": { 118 | "required": ["id"], 119 | "properties": { 120 | "id": { "type": "integer", "minimum": 1, "readOnly": true } 121 | } 122 | }, 123 | "targetSchema": { "$ref": "thing#" } 124 | } 125 | ] 126 | }, 127 | "instanceData": {}, 128 | "discoveredLinks": [ 129 | { 130 | "contextUri": "https://example.com/api", 131 | "attachmentPointer": "", 132 | "contextPointer": "", 133 | "rel": "self", 134 | "hrefInputTemplates": ["../api", "https://example.com/api/"], 135 | "templateVariableInfo": {} 136 | }, 137 | { 138 | "contextUri": "https://example.com/api", 139 | "attachmentPointer": "", 140 | "contextPointer": "", 141 | "rel": "about", 142 | "hrefInputTemplates": ["docs", "https://example.com/api/"], 143 | "templateVariableInfo": {} 144 | }, 145 | { 146 | "contextUri": "https://example.com/api", 147 | "attachmentPointer": "", 148 | "contextPointer": "", 149 | "rel": "tag:rel.example.com,2017:thing", 150 | "hrefInputTemplates": ["things/{id}", "https://example.com/api/"], 151 | "templateVariableInfo": { 152 | "id": { 153 | "isRequired": true, 154 | "acceptsUserInput": true, 155 | "hasValueFromInstance": false 156 | } 157 | } 158 | } 159 | ] 160 | }, 161 | { 162 | "title": "Submitting a payload and accepting URI input Example in 9.3", 163 | "description": "", 164 | "instanceUri": "https://example.com/api/stuff", 165 | "schema": { 166 | "$id": "https://schema.example.com/interesting-stuff", 167 | "$schema": "https://json-schema.org/draft/2019-09/hyper-schema", 168 | "required": ["stuffWorthEmailingAbout", "email", "title"], 169 | "properties": { 170 | "title": { 171 | "type": "string" 172 | }, 173 | "stuffWorthEmailingAbout": { 174 | "type": "string" 175 | }, 176 | "email": { 177 | "type": "string", 178 | "format": "email" 179 | }, 180 | "cc": false 181 | }, 182 | "links": [ 183 | { 184 | "rel": "author", 185 | "href": "mailto:{email}?subject={title}{&cc}", 186 | "templateRequired": ["email"], 187 | "hrefSchema": { 188 | "required": ["title"], 189 | "properties": { 190 | "title": { 191 | "type": "string" 192 | }, 193 | "cc": { 194 | "type": "string", 195 | "format": "email" 196 | }, 197 | "email": false 198 | } 199 | }, 200 | "submissionMediaType": "multipart/alternative; boundary=ab2", 201 | "submissionSchema": { 202 | "type": "array", 203 | "items": [ 204 | { 205 | "type": "string", 206 | "contentMediaType": "text/plain; charset=utf8" 207 | }, 208 | { 209 | "type": "string", 210 | "contentMediaType": "text/html" 211 | } 212 | ], 213 | "minItems": 2 214 | } 215 | } 216 | ] 217 | }, 218 | "instanceData": { 219 | "title": "The Awesome Thing", 220 | "stuffWorthEmailingAbout": "Lots of text here...", 221 | "email": "someone@example.com" 222 | }, 223 | "discoveredLinks": [ 224 | { 225 | "contextUri": "https://example.com/api/stuff", 226 | "attachmentPointer": "", 227 | "contextPointer": "", 228 | "rel": "author", 229 | "hrefInputTemplates": ["mailto:{email}?subject={title}{&cc}"], 230 | "templateVariableInfo": { 231 | "email": { 232 | "isRequired": true, 233 | "acceptsUserInput": false, 234 | "hasValueFromInstance": true, 235 | "value": "someone@example.com" 236 | }, 237 | "cc": { 238 | "isRequired": false, 239 | "acceptsUserInput": true, 240 | "hasValueFromInstance": false 241 | }, 242 | "title": { 243 | "isRequired": true, 244 | "acceptsUserInput": true, 245 | "hasValueFromInstance": true, 246 | "value": "The Awesome Thing" 247 | } 248 | } 249 | } 250 | ] 251 | } 252 | ] 253 | } 254 | ] 255 | --------------------------------------------------------------------------------