├── .node-version ├── .gitignore ├── test ├── snapshots │ ├── test.ts.snap │ ├── api-test.ts.snap │ ├── bind-test.ts.snap │ ├── utils-test.ts.snap │ ├── document-test.ts.snap │ ├── sanitize-test.ts.snap │ ├── validate-test.ts.snap │ ├── add-property-test.ts.snap │ ├── assert-schema-test.ts.snap │ ├── custom-types-test.ts.snap │ ├── extend-schema-test.ts.snap │ ├── schema-error-test.ts.snap │ ├── string-length-test.ts.snap │ ├── utils-test.ts.md │ ├── test.ts.md │ ├── sanitize-test.ts.md │ ├── bind-test.ts.md │ ├── api-test.ts.md │ ├── validate-test.ts.md │ ├── custom-types-test.ts.md │ ├── string-length-test.ts.md │ ├── schema-error-test.ts.md │ ├── extend-schema-test.ts.md │ ├── assert-schema-test.ts.md │ ├── add-property-test.ts.md │ └── document-test.ts.md ├── api-test.ts ├── formats-test.ts ├── numbers-test.ts ├── utils-test.ts ├── pattern-properties-test.ts ├── extend-schema-test.ts ├── other-schemas.ts ├── bind-test.ts ├── trim-test.ts ├── custom-format-null-test.ts ├── string-length-test.ts ├── test.ts ├── schema-error-test.ts ├── examples-test.ts ├── document-format-test.ts ├── validate-test.ts ├── custom-types-test.ts ├── fill-test.ts ├── add-property-test.ts ├── sanitize-test.ts ├── example-schemas.ts ├── assert-schema-test.ts └── document-test.ts ├── .vscode └── settings.json ├── src ├── index.ts ├── document │ ├── doc-formats.ts │ ├── docs.ts │ └── utils.ts ├── fill.ts ├── trim.ts ├── formats.ts ├── objects.ts ├── actions.ts ├── utils.ts ├── sanitize.ts └── api.ts ├── renovate.json ├── .circleci └── config.yml ├── LICENSE.md ├── package.json ├── tsconfig.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 8.17.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | .history -------------------------------------------------------------------------------- /test/snapshots/test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/api-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/api-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/bind-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/bind-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/utils-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/utils-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/document-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/document-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/sanitize-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/sanitize-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/validate-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/validate-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/add-property-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/add-property-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/assert-schema-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/assert-schema-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/custom-types-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/custom-types-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/extend-schema-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/extend-schema-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/schema-error-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/schema-error-test.ts.snap -------------------------------------------------------------------------------- /test/snapshots/string-length-test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cypress-io/schema-tools/HEAD/test/snapshots/string-length-test.ts.snap -------------------------------------------------------------------------------- /test/api-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import * as methods from '../src/api' 3 | 4 | test('public api', t => { 5 | const methodNames = Object.keys(methods) 6 | t.snapshot(methodNames) 7 | }) 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.semi": false, 3 | "prettier.singleQuote": true, 4 | "editor.formatOnSave": true, 5 | "files.exclude": { 6 | "node_modules/": true, 7 | "dist/": true 8 | }, 9 | "git.ignoreLimitWarning": true 10 | } 11 | -------------------------------------------------------------------------------- /test/snapshots/utils-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/utils-test.ts` 2 | 3 | The actual snapshot is saved in `utils-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## oneOfRegex 8 | 9 | > Snapshot 1 10 | 11 | 'generated regex: /^(foo|bar)$/' 12 | -------------------------------------------------------------------------------- /test/snapshots/test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/test.ts` 2 | 3 | The actual snapshot is saved in `test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## provided schemas 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | 'car', 13 | 'person', 14 | 'team', 15 | ] 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // TODO maybe group the exports by type 2 | export * from './actions' 3 | export * from './api' 4 | export * from './document/docs' 5 | export * from './fill' 6 | export * from './formats' 7 | export * from './objects' 8 | export * from './sanitize' 9 | export * from './trim' 10 | export * from './utils' 11 | -------------------------------------------------------------------------------- /test/snapshots/sanitize-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/sanitize-test.ts` 2 | 3 | The actual snapshot is saved in `sanitize-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## example sanitize 8 | 9 | > Snapshot 1 10 | 11 | `{␊ 12 | "age": 21,␊ 13 | "name": "joe"␊ 14 | }` 15 | -------------------------------------------------------------------------------- /test/snapshots/bind-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/bind-test.ts` 2 | 3 | The actual snapshot is saved in `bind-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## exposes list of bound methods 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | 'assertSchema', 13 | 'fill', 14 | 'getExample', 15 | 'hasSchema', 16 | 'sanitize', 17 | 'schemaNames', 18 | 'trim', 19 | 'validate', 20 | ] 21 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "commitMessage": "{{semanticPrefix}}Update {{depName}} to {{newVersion}} 🌟", 7 | "prTitle": "{{semanticPrefix}}{{#if isPin}}Pin{{else}}Update{{/if}} dependency {{depName}} to version {{#if isRange}}{{newVersion}}{{else}}{{#if isMajor}}{{newVersionMajor}}.x{{else}}{{newVersion}}{{/if}}{{/if}} 🌟", 8 | "major": { 9 | "automerge": false 10 | }, 11 | "minor": { 12 | "automerge": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/snapshots/api-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/api-test.ts` 2 | 3 | The actual snapshot is saved in `api-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## public api 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | 'getVersionedSchema', 13 | 'getObjectSchema', 14 | 'hasSchema', 15 | 'schemaNames', 16 | 'getSchemaVersions', 17 | 'getExample', 18 | 'validateBySchema', 19 | 'validate', 20 | 'SchemaError', 21 | 'assertBySchema', 22 | 'assertSchema', 23 | 'bind', 24 | ] 25 | -------------------------------------------------------------------------------- /test/formats-test.ts: -------------------------------------------------------------------------------- 1 | import { getDefaults, CustomFormats, CustomFormat } from '../src/formats' 2 | import test from 'ava' 3 | 4 | test('defaults by name', t => { 5 | const bar: CustomFormat = { 6 | name: 'bar', 7 | detect: /bar/, 8 | description: 'custom format named "bar"', 9 | defaultValue: 'my-value', 10 | } 11 | const formats: CustomFormats = { 12 | foo: bar, 13 | } 14 | const defaults = getDefaults(formats) 15 | t.is(defaults.foo, undefined, 'no default under key foo') 16 | t.is(defaults.bar, bar.defaultValue, 'but there is one under name bar') 17 | }) 18 | -------------------------------------------------------------------------------- /test/numbers-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { assertBySchema, JsonSchema, PlainObject } from '..' 3 | 4 | test('allows arrays of numbers', t => { 5 | t.plan(0) 6 | const jsonSchema: JsonSchema = { 7 | title: 'Schema with numbers', 8 | type: 'object', 9 | additionalProperties: false, 10 | properties: { 11 | numbers: { 12 | type: 'array', 13 | items: { 14 | type: 'number', 15 | }, 16 | }, 17 | }, 18 | } 19 | const example: PlainObject = { 20 | numbers: [1, 2, 3], 21 | } 22 | const o: PlainObject = { 23 | numbers: [101, 102, 103], 24 | } 25 | assertBySchema(jsonSchema, example)(o) 26 | }) 27 | -------------------------------------------------------------------------------- /test/snapshots/validate-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/validate-test.ts` 2 | 3 | The actual snapshot is saved in `validate-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## greedy validation 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | 'data.createdAt is required', 13 | 'data has additional properties: foo, bar', 14 | 'data.name is the wrong type', 15 | ] 16 | 17 | ## shows error for missing property 18 | 19 | > Snapshot 1 20 | 21 | [ 22 | 'data.createdAt is required', 23 | ] 24 | 25 | ## shows error for wrong type 26 | 27 | > Snapshot 1 28 | 29 | [ 30 | 'data.createdAt must be date-time format', 31 | ] 32 | -------------------------------------------------------------------------------- /test/utils-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import R from 'ramda' 3 | import { oneOfRegex, Semver, stringToSemver } from '../src' 4 | 5 | const isRegExp = R.is(RegExp) 6 | 7 | test('oneOfRegex', t => { 8 | t.is(typeof oneOfRegex, 'function') 9 | const r = oneOfRegex('foo', 'bar') 10 | t.true(isRegExp(r)) 11 | t.true(r.test('foo')) 12 | t.true(r.test('bar')) 13 | t.false(r.test('baz')) 14 | t.false(r.test('Foo')) 15 | t.false(r.test('FOO')) 16 | t.snapshot(`generated regex: ${r.toString()}`) 17 | }) 18 | 19 | test('stringToSemver', t => { 20 | const r: Semver = stringToSemver('1.2.3') 21 | t.deepEqual(r, { 22 | major: 1, 23 | minor: 2, 24 | patch: 3, 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/snapshots/custom-types-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/custom-types-test.ts` 2 | 3 | The actual snapshot is saved in `custom-types-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## invalid age 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | { 13 | field: 'data.info.age', 14 | message: 'is less than minimum', 15 | }, 16 | ] 17 | 18 | ## invalid date-time 19 | 20 | > Snapshot 1 21 | 22 | [ 23 | { 24 | field: 'data.t', 25 | message: 'must be date-time format', 26 | }, 27 | ] 28 | 29 | ## invalid uuid 30 | 31 | > Snapshot 1 32 | 33 | [ 34 | { 35 | field: 'data.id', 36 | message: 'must be uuid format', 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | test: 5 | parallelism: 1 6 | working_directory: ~/repo 7 | docker: 8 | - image: cypress/base:14.17.3 9 | steps: 10 | - add_ssh_keys: 11 | fingerprints: 12 | - 'SHA256:gN8aOCVRuaCpKCQJvmQPYmOeHXWigALxpMTXOLeZs94' 13 | - checkout 14 | - restore_cache: 15 | key: repo-{{ .Branch }}-2 16 | - run: npm ci 17 | - save_cache: 18 | key: repo-{{ .Branch }}-2-{{ checksum "package.json" }} 19 | paths: 20 | - ~/.npm 21 | - run: npm run build 22 | - run: npm test 23 | - run: npm run size 24 | - run: npm run semantic-release 25 | 26 | workflows: 27 | version: 2 28 | test: 29 | jobs: 30 | - test: 31 | context: services:npm-publish 32 | -------------------------------------------------------------------------------- /test/pattern-properties-test.ts: -------------------------------------------------------------------------------- 1 | import { assertBySchema } from '../src' 2 | import { JsonSchema } from '../src/objects' 3 | import test from 'ava' 4 | 5 | test('pattern properties', t => { 6 | t.plan(0) 7 | const schema: JsonSchema = { 8 | type: 'object', 9 | title: 'test', 10 | // allow only properties that contains letter "a" like "a", "aa", "aaa", ... 11 | // and enumerated property "role" 12 | patternProperties: { 13 | '^a+$': { 14 | type: 'string', 15 | }, 16 | '^role$': { 17 | enum: ['admin', 'member'], 18 | }, 19 | }, 20 | additionalProperties: false, 21 | } 22 | 23 | assertBySchema(schema)({ 24 | a: 'foo', 25 | }) 26 | 27 | assertBySchema(schema)({ 28 | a: 'foo', 29 | aa: 'foo', 30 | }) 31 | 32 | assertBySchema(schema)({ 33 | a: 'foo', 34 | aa: 'foo', 35 | role: 'admin', 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/snapshots/string-length-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/string-length-test.ts` 2 | 3 | The actual snapshot is saved in `string-length-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## enforces string max length 8 | 9 | > Snapshot 1 10 | 11 | `Schema violated␊ 12 | ␊ 13 | Errors:␊ 14 | data.name has longer length than allowed␊ 15 | ␊ 16 | Current object:␊ 17 | {␊ 18 | "name": "A very long name for some reason"␊ 19 | }␊ 20 | ␊ 21 | Expected object like this:␊ 22 | {␊ 23 | "name": "Joe"␊ 24 | }` 25 | 26 | ## enforces string min length 27 | 28 | > Snapshot 1 29 | 30 | `Schema violated␊ 31 | ␊ 32 | Errors:␊ 33 | data.name has less length than allowed␊ 34 | ␊ 35 | Current object:␊ 36 | {␊ 37 | "name": "A"␊ 38 | }␊ 39 | ␊ 40 | Expected object like this:␊ 41 | {␊ 42 | "name": "Joe Smith"␊ 43 | }` 44 | -------------------------------------------------------------------------------- /test/extend-schema-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { assertSchema } from '../src' 3 | import { person100, person110, schemas } from './example-schemas' 4 | 5 | test('extend existing schema creates new schema', t => { 6 | t.truthy(person110, 'returns new schema') 7 | t.false(person110 === person100, 'returns new object') 8 | t.deepEqual(person110.schema.required, ['name', 'age']) 9 | t.is(person110.schema.title, person100.schema.title, 'copied title') 10 | t.snapshot(person110.example, 'example object') 11 | t.snapshot(person110.version, 'example version') 12 | t.snapshot(person110.schema, 'new json schema') 13 | }) 14 | 15 | test('extend can mark required properties false', t => { 16 | t.notThrows(() => { 17 | return assertSchema(schemas)('car', '1.0.0')({ 18 | color: 'blue', 19 | }) 20 | }) 21 | 22 | t.notThrows(() => { 23 | return assertSchema(schemas)('car', '1.1.0')({ 24 | color: 'blue', 25 | }) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/snapshots/schema-error-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/schema-error-test.ts` 2 | 3 | The actual snapshot is saved in `schema-error-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## error has expected properties 8 | 9 | > Snapshot 1 10 | 11 | `Schema Person@1.0.0 violated␊ 12 | ␊ 13 | Errors:␊ 14 | data.name is required␊ 15 | ␊ 16 | Current object:␊ 17 | {␊ 18 | "age": -1␊ 19 | }␊ 20 | ␊ 21 | Expected object like this:␊ 22 | {␊ 23 | "age": 10,␊ 24 | "name": "Joe"␊ 25 | }` 26 | 27 | ## greedy error has expected properties 28 | 29 | > Snapshot 1 30 | 31 | `Schema Person@1.0.0 violated␊ 32 | ␊ 33 | Errors:␊ 34 | data.name is required␊ 35 | data.age is less than minimum␊ 36 | ␊ 37 | Current object:␊ 38 | {␊ 39 | "age": -1␊ 40 | }␊ 41 | ␊ 42 | Expected object like this:␊ 43 | {␊ 44 | "age": 10,␊ 45 | "name": "Joe"␊ 46 | }` 47 | -------------------------------------------------------------------------------- /test/other-schemas.ts: -------------------------------------------------------------------------------- 1 | import { SchemaCollection, VersionedSchema, ObjectSchema } from '../src/objects' 2 | import { combineSchemas, versionSchemas } from '../src/utils' 3 | 4 | // individual schema describing "Todo item" v1.0.0 5 | const todo100: ObjectSchema = { 6 | version: { 7 | major: 1, 8 | minor: 0, 9 | patch: 0, 10 | }, 11 | schema: { 12 | type: 'object', 13 | title: 'TodoItem', 14 | description: 'An example schema describing a todo item', 15 | properties: { 16 | caption: { 17 | type: 'string', 18 | description: 'what needs to be done', 19 | }, 20 | done: { 21 | type: 'boolean', 22 | description: 'Is it done', 23 | }, 24 | }, 25 | required: ['caption', 'done'], 26 | additionalProperties: false, 27 | }, 28 | example: { 29 | caption: 'start testing', 30 | done: false, 31 | }, 32 | } 33 | 34 | const todoItemVersions: VersionedSchema = versionSchemas(todo100) 35 | export const schemas: SchemaCollection = combineSchemas(todoItemVersions) 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Cypress.io, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /test/bind-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { bind } from '../src/api' 3 | import { exampleFormats, schemas as schemasA } from './example-schemas' 4 | import { schemas as schemasB } from './other-schemas' 5 | 6 | const api = bind( 7 | { schemas: schemasA, formats: exampleFormats }, 8 | { schemas: schemasB }, 9 | ) 10 | 11 | test('exposes list of bound methods', t => { 12 | t.plan(1) 13 | const names = Object.keys(api).sort() 14 | t.snapshot(names) 15 | }) 16 | 17 | test('bind api to schemas', t => { 18 | t.plan(0) 19 | const person = { 20 | name: 'Joe', 21 | age: 20, 22 | } 23 | api.assertSchema('person', '1.0.0')(person) 24 | }) 25 | 26 | test('catches names without capital letter (custom name format)', t => { 27 | t.plan(1) 28 | 29 | const person = { 30 | name: 'joe', 31 | age: 20, 32 | } 33 | try { 34 | api.assertSchema('person', '1.0.0')(person) 35 | } catch (e) { 36 | t.deepEqual(e.errors, ['data.name must be name format']) 37 | } 38 | }) 39 | 40 | test('assert todo item schema', t => { 41 | t.plan(0) 42 | const item = { 43 | caption: 'Write schemas', 44 | done: true, 45 | } 46 | api.assertSchema('todoItem', '1.0.0')(item) 47 | }) 48 | -------------------------------------------------------------------------------- /test/snapshots/extend-schema-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/extend-schema-test.ts` 2 | 3 | The actual snapshot is saved in `extend-schema-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## extend existing schema creates new schema 8 | 9 | > example object 10 | 11 | { 12 | age: 10, 13 | name: 'Joe', 14 | title: 'mr', 15 | } 16 | 17 | > example version 18 | 19 | { 20 | major: 1, 21 | minor: 1, 22 | patch: 0, 23 | } 24 | 25 | > new json schema 26 | 27 | { 28 | additionalProperties: false, 29 | description: 'Person with title', 30 | properties: { 31 | age: { 32 | description: 'Age in years', 33 | minimum: 0, 34 | type: 'integer', 35 | }, 36 | name: { 37 | description: 'this person needs a name', 38 | format: 'name', 39 | minLength: 2, 40 | type: 'string', 41 | }, 42 | title: { 43 | description: 'How to address this person', 44 | format: null, 45 | type: 'string', 46 | }, 47 | }, 48 | required: [ 49 | 'name', 50 | 'age', 51 | ], 52 | title: 'Person', 53 | type: 'object', 54 | } 55 | -------------------------------------------------------------------------------- /test/trim-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { bind } from '../src/api' 3 | import { schemas } from './example-schemas' 4 | 5 | const api = bind({ schemas: schemas }) 6 | 7 | const schemaName = 'Person' 8 | const schemaVersion = '1.2.0' 9 | 10 | test('has trim method', t => { 11 | t.is(typeof api.trim, 'function') 12 | }) 13 | 14 | test('trim returns a cloned object', t => { 15 | t.plan(3) 16 | const e = api.getExample(schemaName, schemaVersion) 17 | if (!e) { 18 | return 19 | } 20 | 21 | t.truthy(e, 'we have an example') 22 | const r: object = api.trim(schemaName, schemaVersion, e) 23 | t.true(r !== e, 'returns new object') 24 | t.deepEqual(r, e, 'nothing should be trimmed') 25 | }) 26 | 27 | test('trim removes extra properties', t => { 28 | const e: any = api.getExample(schemaName, schemaVersion) as object 29 | // add extra property 30 | e.foo = 'bar' 31 | const r: object = api.trim(schemaName, schemaVersion, e) 32 | t.true(r !== e, 'returns new object') 33 | t.deepEqual( 34 | r, 35 | { 36 | title: 'mr', 37 | name: 'Joe', 38 | age: 10, 39 | traits: { 40 | eyeColor: 'brown', 41 | hairColor: 'black', 42 | }, 43 | }, 44 | 'extra property foo should be trimmed', 45 | ) 46 | }) 47 | -------------------------------------------------------------------------------- /test/custom-format-null-test.ts: -------------------------------------------------------------------------------- 1 | import validator from 'is-my-json-valid' 2 | import test from 'ava' 3 | import { JsonSchemaFormats } from '../src/formats' 4 | import { JsonSchema } from '../src/objects' 5 | 6 | const schema: JsonSchema = { 7 | type: 'object', 8 | title: 'testSchema', 9 | additionalProperties: false, 10 | properties: { 11 | t: { 12 | type: ['null', 'string'], 13 | format: 'foo', 14 | description: 'this property could be a string in format "foo" or a null', 15 | }, 16 | }, 17 | required: ['t'], 18 | } 19 | 20 | const formats: JsonSchemaFormats = { 21 | // custom format "foo" can only be the string "FOO" 22 | foo: /^FOO$/, 23 | } 24 | 25 | test('valid string in format foo', t => { 26 | const validate = validator(schema, { formats }) 27 | t.true(validate({ t: 'FOO' })) 28 | }) 29 | 30 | test('invalid string is caught', t => { 31 | t.plan(2) 32 | const validate = validator(schema, { formats }) 33 | const result = validate({ t: 'bar' }) 34 | t.false(result) 35 | t.deepEqual(validate.errors, [ 36 | { field: 'data.t', message: 'must be foo format' }, 37 | ]) 38 | }) 39 | 40 | test('null is allowed with custom format', t => { 41 | t.plan(1) 42 | const validate = validator(schema, { formats }) 43 | const result = validate({ t: null }) 44 | t.true(result) 45 | }) 46 | -------------------------------------------------------------------------------- /test/string-length-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { assertBySchema, JsonSchema, PlainObject } from '..' 3 | 4 | test('enforces string min length', t => { 5 | t.plan(2) 6 | const jsonSchema: JsonSchema = { 7 | title: 'Schema with minLength string property', 8 | type: 'object', 9 | additionalProperties: false, 10 | properties: { 11 | name: { 12 | type: 'string', 13 | minLength: 5, 14 | }, 15 | }, 16 | } 17 | const example: PlainObject = { 18 | name: 'Joe Smith', 19 | } 20 | const o: PlainObject = { 21 | name: 'A', 22 | } 23 | const e: Error = t.throws(() => assertBySchema(jsonSchema, example)(o)) 24 | t.snapshot(e.message) 25 | }) 26 | 27 | test('enforces string max length', t => { 28 | t.plan(2) 29 | const jsonSchema: JsonSchema = { 30 | title: 'Schema with maxLength string property', 31 | type: 'object', 32 | additionalProperties: false, 33 | properties: { 34 | name: { 35 | type: 'string', 36 | maxLength: 5, 37 | }, 38 | }, 39 | } 40 | const example: PlainObject = { 41 | name: 'Joe', 42 | } 43 | const o: PlainObject = { 44 | name: 'A very long name for some reason', 45 | } 46 | const e: Error = t.throws(() => assertBySchema(jsonSchema, example)(o)) 47 | t.snapshot(e.message) 48 | }) 49 | -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import validator from 'is-my-json-valid' 2 | import test from 'ava' 3 | import { clone } from 'ramda' 4 | import { getExample, schemaNames, setPackageName } from '../src' 5 | import { JsonSchema } from '../src/objects' 6 | import { schemas } from './example-schemas' 7 | 8 | test('has schema names', t => { 9 | t.is(typeof schemaNames, 'function') 10 | const names = schemaNames(schemas) 11 | t.truthy(Array.isArray(names)) 12 | }) 13 | 14 | test('provided schemas', t => { 15 | const names = schemaNames(schemas) 16 | t.snapshot(names) 17 | }) 18 | 19 | test('returns example', t => { 20 | const example = getExample(schemas)('example')('1.0.0') 21 | t.deepEqual(example, { 22 | age: 10, 23 | name: 'Joe', 24 | }) 25 | }) 26 | 27 | test('optional uri field', t => { 28 | const schema: JsonSchema = { 29 | title: 'Test', 30 | type: 'object', 31 | properties: { 32 | // GOOD EXAMPLE optional but formatted property 33 | url: { 34 | type: ['string', 'null'], 35 | format: 'uri', 36 | }, 37 | }, 38 | additionalProperties: false, 39 | } 40 | const valid = validator(schema) 41 | t.true(valid({ url: 'https://foo.com' }), 'has url property') 42 | t.true(valid({}), 'undefined url property') 43 | t.true(valid({ url: null }), 'null url property') 44 | t.false(valid({ url: 'foo' }), 'invalid url format') 45 | }) 46 | 47 | test('sets package name', t => { 48 | t.plan(1) 49 | const schemasWithName = clone(schemas) 50 | setPackageName(schemasWithName, 'test-package') 51 | t.is(schemasWithName.person['1.0.0'].package, 'test-package') 52 | }) 53 | -------------------------------------------------------------------------------- /test/snapshots/assert-schema-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/assert-schema-test.ts` 2 | 3 | The actual snapshot is saved in `assert-schema-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## error message has object with substitutions 8 | 9 | > error message 10 | 11 | `Schema Person@1.0.0 violated␊ 12 | ␊ 13 | Errors:␊ 14 | data.name must be name format␊ 15 | ␊ 16 | Current object:␊ 17 | {␊ 18 | "age": 10,␊ 19 | "name": "lowercase"␊ 20 | }␊ 21 | ␊ 22 | Expected object like this:␊ 23 | {␊ 24 | "age": 10,␊ 25 | "name": "Joe"␊ 26 | }` 27 | 28 | > list of errors 29 | 30 | [ 31 | 'data.name must be name format', 32 | ] 33 | 34 | ## missing name membership invitation 1.0.0 35 | 36 | > Snapshot 1 37 | 38 | `Schema Person@1.0.0 violated␊ 39 | ␊ 40 | Errors:␊ 41 | data.name is required␊ 42 | ␊ 43 | Current object:␊ 44 | {␊ 45 | "age": 10␊ 46 | }␊ 47 | ␊ 48 | Expected object like this:␊ 49 | {␊ 50 | "age": 10,␊ 51 | "name": "Joe"␊ 52 | }` 53 | 54 | ## require all properties 55 | 56 | > Snapshot 1 57 | 58 | `Schema Example@1.0.0 violated␊ 59 | ␊ 60 | Errors:␊ 61 | data.foo is required␊ 62 | data.bar is required␊ 63 | ␊ 64 | Current object:␊ 65 | {␊ 66 | }␊ 67 | ␊ 68 | Expected object like this:␊ 69 | {␊ 70 | "foo": "foo"␊ 71 | }` 72 | 73 | ## whitelist errors only 74 | 75 | > Snapshot 1 76 | 77 | `Schema Person@1.0.0 violated␊ 78 | ␊ 79 | Errors:␊ 80 | data.age is less than minimum` 81 | -------------------------------------------------------------------------------- /src/document/doc-formats.ts: -------------------------------------------------------------------------------- 1 | import quote from 'quote' 2 | import { CustomFormats } from '../formats' 3 | import { checkMark, emptyMark, findUsedColumns } from './utils' 4 | 5 | const ticks = quote({ quotes: '`' }) 6 | 7 | /** 8 | * Replaces "|" character in code block with unicode pipe symbol 9 | * @param s 10 | */ 11 | const escapedCode = (s: string) => ticks(s.replace(/\|/g, '`|`')) 12 | 13 | export const documentCustomFormats = (formats: CustomFormats) => { 14 | const headers = [ 15 | 'name', 16 | 'regular expression', 17 | 'dynamic', 18 | 'example', 19 | 'default', 20 | ] 21 | const rows = Object.keys(formats).map(name => { 22 | const format = formats[name] 23 | const formatName = format.name 24 | const r = format.detect.toString() 25 | const escaped = escapedCode(r) 26 | const dynamic = 'defaultValue' in format ? checkMark : emptyMark 27 | const example = 28 | 'example' in format 29 | ? escapedCode(JSON.stringify(format.example)) 30 | : emptyMark 31 | const defaultValue = 32 | 'defaultValue' in format 33 | ? ticks(JSON.stringify(format.defaultValue)) 34 | : emptyMark 35 | 36 | return { 37 | name: formatName, 38 | 'regular expression': escaped, 39 | dynamic, 40 | default: defaultValue, 41 | example, 42 | } 43 | }) 44 | const usedHeaders = findUsedColumns(headers, rows) 45 | 46 | return [ 47 | { h2: 'formats' }, 48 | { p: 'Custom formats defined to better represent our data.' }, 49 | { 50 | table: { 51 | headers: usedHeaders, 52 | rows, 53 | }, 54 | }, 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /test/schema-error-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { SchemaError } from '../src' 3 | import { assertSchema, getExample } from '../src' 4 | import { schemas } from './example-schemas' 5 | 6 | test('error has expected properties', t => { 7 | t.plan(5) 8 | 9 | const assertPerson100 = assertSchema(schemas)('Person', '1.0.0', { 10 | greedy: false, 11 | }) 12 | const example = getExample(schemas)('Person')('1.0.0') 13 | 14 | // notice missing "name" property and invalid "age" value 15 | const o = { 16 | age: -1, 17 | } 18 | try { 19 | assertPerson100(o) 20 | } catch (e) { 21 | t.true(e instanceof SchemaError, 'error is instance of SchemaError') 22 | t.deepEqual( 23 | e.errors, 24 | ['data.name is required'], 25 | 'only required properties is detected at first', 26 | ) 27 | t.is(e.object, o, 'current object') 28 | t.is(e.example, example, 'example object') 29 | // entire message has everything 30 | t.snapshot(e.message) 31 | } 32 | }) 33 | 34 | test('greedy error has expected properties', t => { 35 | t.plan(5) 36 | 37 | const assertPerson100 = assertSchema(schemas)('Person', '1.0.0', { 38 | greedy: true, 39 | }) 40 | const example = getExample(schemas)('Person')('1.0.0') 41 | 42 | // notice missing "name" property and invalid "age" value 43 | const o = { 44 | age: -1, 45 | } 46 | try { 47 | assertPerson100(o) 48 | } catch (e) { 49 | t.true(e instanceof SchemaError, 'error is instance of SchemaError') 50 | t.deepEqual( 51 | e.errors, 52 | ['data.name is required', 'data.age is less than minimum'], 53 | 'all errors are reported', 54 | ) 55 | t.is(e.object, o, 'current object') 56 | t.is(e.example, example, 'example object') 57 | // entire message has everything 58 | t.snapshot(e.message) 59 | } 60 | }) 61 | -------------------------------------------------------------------------------- /test/examples-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { 3 | assertSchema, 4 | bind, 5 | getExample, 6 | getSchemaVersions, 7 | schemaNames, 8 | } from '../src' 9 | import { schemas } from './example-schemas' 10 | 11 | const names = schemaNames(schemas) 12 | const getSchemaExample = getExample(schemas) 13 | const assert = assertSchema(schemas) 14 | 15 | const api = bind({ schemas }) 16 | 17 | test('has Person schema', t => { 18 | t.true(api.hasSchema('Person', '1.0.0')) 19 | }) 20 | 21 | test('has no Person schema 10000.0.0', t => { 22 | t.false(api.hasSchema('Person', '10000.0.0')) 23 | }) 24 | 25 | test('it has several schema names', t => { 26 | t.true(Array.isArray(names)) 27 | t.true(names.length > 0) 28 | }) 29 | 30 | test('getExample is curried', t => { 31 | t.truthy(getExample(schemas, 'Person', '1.0.0'), 'all arguments together') 32 | t.truthy( 33 | getExample(schemas)('Person', '1.0.0'), 34 | 'schemas then name and version', 35 | ) 36 | t.truthy(getExample(schemas)('Person')('1.0.0'), 'curried version') 37 | }) 38 | 39 | // TODO factor out these functions into API to check that every schema has an example 40 | // function hasVersions(name: string) {} 41 | 42 | // function hasExample(name: string) {} 43 | 44 | names.forEach((name: string) => { 45 | const versions = getSchemaVersions(schemas)(name) 46 | 47 | test(`schema ${name} has versions`, t => { 48 | t.true(Array.isArray(versions)) 49 | t.true(versions.length > 0) 50 | }) 51 | 52 | versions.forEach(version => { 53 | test(`${name}@${version} has example`, t => { 54 | const example = getSchemaExample(name)(version) 55 | t.is(typeof example, 'object') 56 | }) 57 | 58 | test(`${name}@${version} example is valid`, t => { 59 | t.plan(0) 60 | const example = getSchemaExample(name)(version) 61 | if (example) { 62 | assert(name, version)(example) 63 | } 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /test/document-format-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { JsonProperty } from '../src' 3 | import { 4 | anchorForSchema, 5 | emptyMark, 6 | formatToMarkdown, 7 | } from '../src/document/utils' 8 | import { exampleFormats, person100, schemas } from './example-schemas' 9 | 10 | test('no format', t => { 11 | const value: JsonProperty = { 12 | type: 'string', 13 | } 14 | const result = formatToMarkdown(schemas, exampleFormats)(value) 15 | t.is(result, emptyMark) 16 | }) 17 | 18 | test('no custom schemas or formats', t => { 19 | const value: JsonProperty = { 20 | type: 'string', 21 | } 22 | const result = formatToMarkdown(undefined, undefined)(value) 23 | t.is(result, emptyMark) 24 | }) 25 | 26 | test('date-time format', t => { 27 | const value: JsonProperty = { 28 | type: 'string', 29 | format: 'date-time', 30 | } 31 | const result = formatToMarkdown(undefined, undefined)(value) 32 | t.is(result, '`date-time`') 33 | }) 34 | 35 | test('custom format', t => { 36 | t.true('name' in exampleFormats, 'there is custom format "name"') 37 | const value: JsonProperty = { 38 | type: 'string', 39 | format: 'name', 40 | } 41 | const result = formatToMarkdown(undefined, exampleFormats)(value) 42 | t.is(result, '[name](#formats)', 'points at the custom formats section') 43 | }) 44 | 45 | test('schema using "see" text', t => { 46 | t.plan(1) 47 | const value: JsonProperty = { 48 | type: 'object', 49 | see: 'another thing', 50 | } 51 | const result = formatToMarkdown(undefined, exampleFormats)(value) 52 | t.is(result, '`another thing`', 'adds quotes') 53 | }) 54 | 55 | test('anchorForSchema', t => { 56 | t.is(anchorForSchema(person100), 'person100') 57 | }) 58 | 59 | test('schema pointing at another schema', t => { 60 | t.true('person' in schemas, 'there is a schema named "person"') 61 | const value: JsonProperty = { 62 | type: 'array', 63 | items: { 64 | ...person100.schema, 65 | }, 66 | see: person100, 67 | } 68 | const result = formatToMarkdown(schemas, exampleFormats)(value) 69 | t.is(result, '[Person@1.0.0](#person100)') 70 | }) 71 | -------------------------------------------------------------------------------- /src/fill.ts: -------------------------------------------------------------------------------- 1 | import { curry, difference, keys, reduce } from 'ramda' 2 | import { getObjectSchema } from './api' 3 | import { 4 | ObjectSchema, 5 | PlainObject, 6 | SchemaCollection, 7 | SchemaVersion, 8 | } from './objects' 9 | 10 | // TODO: add types to input args 11 | export const fillBySchema = curry( 12 | (schema: ObjectSchema, object: PlainObject): PlainObject => { 13 | // @ts-ignore 14 | schema = schema.properties || (schema.schema || schema.items).properties 15 | const objectProps = keys(object) 16 | const schemaProps = keys(schema) 17 | 18 | const missingProperties = difference(schemaProps, objectProps) 19 | const filledObject = reduce( 20 | (result: PlainObject, key: string | number): PlainObject => { 21 | const property = schema[key] 22 | if ('defaultValue' in property) { 23 | const value = property.defaultValue 24 | return { ...result, [key]: value } 25 | } else { 26 | throw new Error( 27 | `Do not know how to get default value for property "${key}"`, 28 | ) 29 | } 30 | }, 31 | object, 32 | missingProperties, 33 | ) 34 | 35 | return filledObject 36 | }, 37 | ) 38 | 39 | const fillObject = ( 40 | schemas: SchemaCollection, 41 | schemaName: string, 42 | version: SchemaVersion, 43 | object: object, 44 | ): PlainObject => { 45 | const schema = getObjectSchema(schemas, schemaName, version) 46 | if (!schema) { 47 | throw new Error( 48 | `Could not find schema ${schemaName}@${version} to trim an object`, 49 | ) 50 | } 51 | if (!object) { 52 | throw new Error('Expected an object to trim') 53 | } 54 | 55 | return fillBySchema(schema, object) 56 | } 57 | 58 | /** 59 | * Fills missing properties with explicit default values if possible. Curried. 60 | * Note: for now only fills top level. 61 | * 62 | * @example 63 | * const o = ... // some object with missing properties for Person 1.0.0 64 | * const t = fill('Person', '1.0.0', o) 65 | * // t has missing properties filled if possible 66 | */ 67 | export const fill = curry(fillObject) 68 | -------------------------------------------------------------------------------- /src/trim.ts: -------------------------------------------------------------------------------- 1 | import { contains, curry, keys, map, reduce } from 'ramda' 2 | import { getObjectSchema } from './api' 3 | import { 4 | ObjectSchema, 5 | PlainObject, 6 | SchemaCollection, 7 | SchemaVersion, 8 | } from './objects' 9 | import { hasPropertiesArray, isJsonSchema } from './sanitize' 10 | 11 | // TODO: add types to input args 12 | /** 13 | * Takes an object and removes all properties not listed in the schema 14 | */ 15 | export const trimBySchema = curry( 16 | (schema: ObjectSchema, object: object): PlainObject => { 17 | // @ts-ignore 18 | schema = schema.properties || (schema.schema || schema.items).properties 19 | const objectProps = keys(object) 20 | const schemaProps = keys(schema) 21 | return reduce( 22 | (trimmedObj, prop) => { 23 | if (contains(prop, schemaProps)) { 24 | if (object[prop] && isJsonSchema(schema[prop])) { 25 | trimmedObj[prop] = trimBySchema(schema[prop], object[prop]) 26 | } else if (object[prop] && hasPropertiesArray(schema[prop])) { 27 | trimmedObj[prop] = map(trimBySchema(schema[prop]), object[prop]) 28 | } else { 29 | trimmedObj[prop] = object[prop] 30 | } 31 | } 32 | return trimmedObj 33 | }, 34 | {}, 35 | objectProps, 36 | ) 37 | }, 38 | ) 39 | 40 | const trimObject = ( 41 | schemas: SchemaCollection, 42 | schemaName: string, 43 | version: SchemaVersion, 44 | object: PlainObject, 45 | ): PlainObject => { 46 | const schema = getObjectSchema(schemas, schemaName, version) 47 | if (!schema) { 48 | throw new Error( 49 | `Could not find schema ${schemaName}@${version} to trim an object`, 50 | ) 51 | } 52 | if (!object) { 53 | throw new Error('Expected an object to trim') 54 | } 55 | 56 | return trimBySchema(schema, object) 57 | } 58 | 59 | /** 60 | * Removes all properties from the given object that are not in the schema. Curried 61 | * 62 | * @example 63 | * const o = ... // some object 64 | * const t = trim('Person', '1.0.0', o) 65 | * // t only has properties from the schema Person v1.0.0 66 | */ 67 | export const trim = curry(trimObject) 68 | -------------------------------------------------------------------------------- /src/formats.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Describes our custom formats like "uuid" and "projectId" 3 | */ 4 | export type CustomFormat = { 5 | name: string 6 | description: string 7 | detect: RegExp 8 | /** 9 | * If the value is highly dynamic, here is its replacement for sanitize 10 | * 11 | * @type {(string | number)} 12 | */ 13 | defaultValue?: string | number 14 | example?: string | number 15 | } 16 | 17 | /** 18 | * A collection of custom formats by name 19 | */ 20 | export type CustomFormats = { 21 | [key: string]: CustomFormat 22 | } 23 | 24 | /** 25 | * Collection of regular expressions to use to validate custom formats 26 | */ 27 | export type JsonSchemaFormats = { 28 | [key: string]: RegExp 29 | } 30 | 31 | /** 32 | * Returns object of regular expressions used to detect custom formats 33 | */ 34 | export const detectors = (formats: CustomFormats) => { 35 | const result: JsonSchemaFormats = {} 36 | Object.keys(formats).forEach(name => { 37 | result[name] = formats[name].detect 38 | }) 39 | return result 40 | } 41 | 42 | /** 43 | * Strips out leading and trailing "/" characters so that regular expression 44 | * can be used as a string key in "patternProperties" in JSON schema 45 | * @param r Regular express 46 | * @example regexAsPatternKey(formats.uuid) 47 | */ 48 | export const regexAsPatternKey = (r: RegExp): string => { 49 | const s = r.toString() 50 | // something like /^....$/ 51 | // remove first and last "/" characters 52 | const middle = s.substr(1, s.length - 2) 53 | return middle 54 | } 55 | 56 | /** 57 | * An object with default values for custom properties 58 | */ 59 | export type FormatDefaults = { 60 | [key: string]: string | number 61 | } 62 | 63 | /** 64 | * Given an object of custom formats returns all default values (if any) 65 | */ 66 | export const getDefaults = (formats: CustomFormats) => { 67 | const result: FormatDefaults = {} 68 | Object.keys(formats).forEach(key => { 69 | const format: CustomFormat = formats[key] 70 | if (typeof format.defaultValue !== 'undefined') { 71 | // store values under name, not under original key 72 | result[format.name] = format.defaultValue 73 | } 74 | }) 75 | return result 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cypress/schema-tools", 3 | "version": "0.0.0-development", 4 | "description": "Validate, sanitize and document JSON schemas", 5 | "main": "dist", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "prettier": "prettier --write 'src/**/*.ts'", 9 | "lint": "tslint --project .", 10 | "build": "tsc", 11 | "prebuild": "npm run deps && npm run prettier", 12 | "postbuild": "npm run check-transpiled-correctly", 13 | "check-transpiled-correctly": "node .", 14 | "deps": "deps-ok", 15 | "size": "npm pack --dry", 16 | "semantic-release": "semantic-release", 17 | "pretest": "npm run build", 18 | "test": "npm run unit", 19 | "unit": "ava-ts --verbose test/*-test.ts", 20 | "repl": "ts-node" 21 | }, 22 | "keywords": [ 23 | "schema", 24 | "json-schema", 25 | "utility" 26 | ], 27 | "author": "Gleb Bahmutov ", 28 | "license": "MIT", 29 | "files": [ 30 | "dist" 31 | ], 32 | "dependencies": { 33 | "@bahmutov/all-paths": "1.0.2", 34 | "@types/ramda": "0.25.47", 35 | "debug": "4.3.4", 36 | "is-my-json-valid": "github:ax-vasquez/is-my-json-valid#b875c39b07f757593d9b9123e023b8fd2c350a0c", 37 | "json-stable-stringify": "1.0.1", 38 | "json2md": "1.6.3", 39 | "lodash": "4.17.21", 40 | "quote": "0.4.0", 41 | "ramda": "0.25.0" 42 | }, 43 | "devDependencies": { 44 | "@types/lodash.camelcase": "4.3.7", 45 | "@types/node": "9.6.61", 46 | "ava": "2.4.0", 47 | "ava-ts": "0.25.2", 48 | "common-tags": "1.8.2", 49 | "deps-ok": "1.4.1", 50 | "husky": "7.0.4", 51 | "jsen": "0.6.6", 52 | "prettier": "2.7.1", 53 | "semantic-release": "19.0.3", 54 | "terminal-banner": "1.1.0", 55 | "ts-node": "7.0.1", 56 | "tslint": "5.20.1", 57 | "typescript": "4.7.4" 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "https://github.com/cypress-io/schema-tools.git" 62 | }, 63 | "publishConfig": { 64 | "access": "public" 65 | }, 66 | "husky": { 67 | "hooks": { 68 | "pre-commit": "npm test" 69 | } 70 | }, 71 | "prettier": { 72 | "printWidth": 80, 73 | "semi": false, 74 | "singleQuote": true, 75 | "trailingComma": "all" 76 | }, 77 | "release": { 78 | "analyzeCommits": { 79 | "preset": "angular", 80 | "releaseRules": [ 81 | { 82 | "type": "break", 83 | "release": "major" 84 | } 85 | ] 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /test/validate-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { validate, validateBySchema } from '../src' 3 | import { JsonSchema } from '../src/objects' 4 | import { schemas } from './example-schemas' 5 | 6 | const validateExample100 = validate(schemas)('person', '1.0.0') 7 | 8 | test('is a function', t => { 9 | t.is(typeof validate, 'function') 10 | }) 11 | 12 | test('passing membership invitation 1.0.0', t => { 13 | const o = { 14 | name: 'foo', 15 | age: 1, 16 | } 17 | t.truthy(validateExample100(o)) 18 | }) 19 | 20 | test('missing name 1.0.0', t => { 21 | const o = { 22 | age: 1, 23 | } 24 | const result = validateExample100(o) 25 | t.deepEqual(result, ['data.name is required']) 26 | }) 27 | 28 | const schema: JsonSchema = { 29 | title: 'TestSchema', 30 | type: 'object', 31 | properties: { 32 | createdAt: { 33 | type: 'string', 34 | format: 'date-time', 35 | }, 36 | name: { 37 | type: 'string', 38 | }, 39 | hook: { 40 | type: 'string', 41 | format: 'hookId', 42 | }, 43 | }, 44 | required: ['createdAt', 'name', 'hook'], 45 | additionalProperties: false, 46 | } 47 | 48 | test('lists additional properties', t => { 49 | t.plan(3) 50 | 51 | const o = { 52 | createdAt: new Date().toISOString(), 53 | name: 'Joe', 54 | hook: 'h1', 55 | // additional properties on purpose 56 | foo: 1, 57 | bar: 2, 58 | } 59 | const result = validateBySchema(schema, undefined)(o) 60 | t.true(Array.isArray(result)) 61 | 62 | if (Array.isArray(result)) { 63 | t.is(result.length, 1, 'does not repeat same error') 64 | } 65 | 66 | t.deepEqual(result, ['data has additional properties: foo, bar']) 67 | }) 68 | 69 | test('validates object by schema', t => { 70 | const o = { 71 | createdAt: new Date().toISOString(), 72 | name: 'Joe', 73 | hook: 'h1', 74 | } 75 | t.true(validateBySchema(schema)(o)) 76 | }) 77 | 78 | test('shows error for missing property', t => { 79 | const o = { 80 | name: 'Joe', 81 | hook: 'h1', 82 | } 83 | t.snapshot(validateBySchema(schema)(o)) 84 | }) 85 | 86 | test('shows error for wrong type', t => { 87 | const o = { 88 | createdAt: 'Sunday', 89 | name: 'Joe', 90 | hook: 'h1', 91 | } 92 | t.snapshot(validateBySchema(schema)(o)) 93 | }) 94 | 95 | test('greedy validation', t => { 96 | t.plan(3) 97 | 98 | // several things wrong 99 | // 1. missing property 100 | // 2. invalid property format 101 | // 3. additional properties 102 | const o = { 103 | name: 42, 104 | hook: 'h1', 105 | foo: 1, 106 | bar: 2, 107 | } 108 | const greedy = true 109 | const result = validateBySchema(schema, undefined, greedy)(o) 110 | t.true(Array.isArray(result)) 111 | if (Array.isArray(result)) { 112 | t.is(result.length, 3, 'gets all errors') 113 | t.snapshot(result) 114 | } 115 | }) 116 | -------------------------------------------------------------------------------- /src/objects.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * "Simple" object type that can store strings, numbers and other simple objects 3 | */ 4 | export type PlainObject = { 5 | [key: string]: 6 | | string 7 | | number 8 | | boolean 9 | | null 10 | | undefined 11 | | string[] 12 | | number[] 13 | | PlainObject 14 | | PlainObject[] 15 | } 16 | 17 | /** 18 | * schema version string like "1.1.0" 19 | */ 20 | export type SchemaVersion = string 21 | 22 | /** 23 | * Name and version tuple 24 | */ 25 | export type NameVersion = { 26 | name: string 27 | version: SchemaVersion 28 | } 29 | 30 | export type JsonPropertyTypes = 31 | | 'number' 32 | | 'integer' 33 | | 'string' 34 | | 'object' 35 | | 'boolean' 36 | | 'array' 37 | | string[] 38 | | number[] 39 | 40 | export type DefaultValue = null | boolean | number | string 41 | 42 | export type JsonProperty = { 43 | type: JsonPropertyTypes 44 | format?: string 45 | minimum?: number 46 | maximum?: number 47 | minItems?: number 48 | maxItems?: number 49 | minLength?: number 50 | maxLength?: number 51 | description?: string 52 | required?: boolean | string[] 53 | properties?: JsonProperties 54 | items?: JsonProperty 55 | see?: string | ObjectSchema 56 | title?: string 57 | patternProperties?: object 58 | additionalProperties?: boolean 59 | enum?: string[] 60 | // if the property is deprecated show this message 61 | deprecated?: string 62 | 63 | /** 64 | * An explicit default value if we fill an object to match schema. 65 | * For now allows a limited set of primitive types 66 | * 67 | * @type {DefaultValue} 68 | */ 69 | defaultValue?: DefaultValue 70 | } 71 | 72 | export type JsonProperties = { 73 | [key: string]: JsonProperty 74 | } 75 | 76 | // describes roughly http://json-schema.org/examples.html 77 | export type JsonSchema = { 78 | title: string 79 | type: 'object' 80 | description?: string 81 | properties?: JsonProperties 82 | patternProperties?: object 83 | // which properties are MUST have 84 | required?: string[] | true 85 | // does the schema allow unknown properties? 86 | additionalProperties: boolean 87 | deprecated?: string 88 | } 89 | 90 | export type ObjectSchema = { 91 | version: Semver 92 | schema: JsonSchema 93 | example: PlainObject 94 | /** 95 | * Usually the name of the package this schema is defined in. 96 | */ 97 | package?: string 98 | } 99 | 100 | export type VersionedSchema = { 101 | // SemverString to ObjectSchema 102 | [key: string]: ObjectSchema 103 | } 104 | 105 | export type SchemaCollection = { 106 | // schema name to versioned schema 107 | [key: string]: VersionedSchema 108 | } 109 | 110 | /** 111 | * Semantic version object 112 | * 113 | * @example const v:Semver = {major: 1, minor: 0, patch: 2} 114 | * @see https://semver.org/ 115 | */ 116 | export type Semver = { 117 | major: number 118 | minor: number 119 | patch: number 120 | } 121 | -------------------------------------------------------------------------------- /src/document/docs.ts: -------------------------------------------------------------------------------- 1 | // generates Markdown document with all schema information 2 | 3 | import json2md from 'json2md' 4 | import { flatten } from 'ramda' 5 | import { 6 | getObjectSchema, 7 | getSchemaVersions, 8 | normalizeName, 9 | schemaNames, 10 | } from '..' 11 | import { CustomFormats } from '../formats' 12 | import { ObjectSchema, SchemaCollection } from '../objects' 13 | import { documentCustomFormats } from './doc-formats' 14 | import { anchor, documentObjectSchema } from './utils' 15 | 16 | // const ticks = quote({ quotes: '`' }) 17 | const title = [{ h1: 'Schemas' }] 18 | const titleLink = [{ p: '[🔝](#schemas)' }] 19 | 20 | /** 21 | * Returns Markdown string describing the entire schema collection. 22 | * 23 | * @param {SchemaCollection} schemas Object with all schemas 24 | * @param {CustomFormats} formats Custom formats (optional) 25 | * @returns {string} Markdown 26 | */ 27 | export function documentSchemas( 28 | schemas: SchemaCollection, 29 | formats?: CustomFormats, 30 | ): string { 31 | const toDoc = (schemaName: string) => { 32 | const versions = getSchemaVersions(schemas)(schemaName) 33 | if (!versions.length) { 34 | return [{ h2: `⚠️ Could not find any versions of schema ${schemaName}` }] 35 | } 36 | 37 | const documentSchemaVersion = (version: string) => { 38 | const schema: ObjectSchema = getObjectSchema(schemas)(schemaName)( 39 | version, 40 | ) as ObjectSchema 41 | 42 | if (!schema) { 43 | throw new Error(`cannot find schema ${schemaName}@${version}`) 44 | } 45 | 46 | const schemaDoc = documentObjectSchema(schema, schemas, formats) 47 | 48 | return flatten(schemaDoc.concat(titleLink)) 49 | } 50 | 51 | const versionFragments = versions.map(documentSchemaVersion) 52 | 53 | const start: object[] = [{ h2: normalizeName(schemaName) }] 54 | return start.concat(flatten(versionFragments)) 55 | } 56 | 57 | const fragments = flatten(schemaNames(schemas).map(toDoc)) 58 | 59 | const schemaNameToTopLevelLink = (schemaName: string) => 60 | `[${schemaName}](#${anchor(schemaName)})` 61 | 62 | const schemaVersionLink = (schemaName: string) => (version: string) => 63 | `[${version}](#${anchor(schemaName + version)})` 64 | 65 | const tocHeading = (schemaName: string) => { 66 | const versions = getSchemaVersions(schemas)(schemaName) 67 | const topLink = schemaNameToTopLevelLink(schemaName) 68 | if (versions.length < 2) { 69 | return topLink 70 | } else { 71 | const versionLinks = versions.map(schemaVersionLink(schemaName)) 72 | const linkWithVersions = topLink + ' - ' + versionLinks.join(', ') 73 | return linkWithVersions 74 | } 75 | } 76 | 77 | const headings = schemaNames(schemas) 78 | const toc = [ 79 | { 80 | ul: headings.map(tocHeading), 81 | }, 82 | ] 83 | 84 | let list = (title as any[]).concat(toc).concat(fragments) 85 | 86 | if (formats) { 87 | list = list.concat(documentCustomFormats(formats)).concat(titleLink) 88 | } 89 | 90 | return json2md(list) 91 | } 92 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | import { clone, equals, mergeDeepRight, reject } from 'ramda' 2 | import { 3 | DefaultValue, 4 | JsonProperties, 5 | JsonPropertyTypes, 6 | ObjectSchema, 7 | } from './objects' 8 | import { normalizeRequiredProperties } from './utils' 9 | 10 | // 11 | // different actions that produce new schema from existing one 12 | // 13 | 14 | type NewSchemaOptions = { 15 | schema: ObjectSchema 16 | title?: string 17 | description: string 18 | } 19 | 20 | type AddPropertyOptions = { 21 | property: string 22 | propertyType: JsonPropertyTypes 23 | propertyFormat: string | null 24 | exampleValue: any 25 | isRequired?: boolean 26 | propertyDescription?: string 27 | defaultValue?: DefaultValue 28 | see?: string | ObjectSchema 29 | } 30 | 31 | /** 32 | * Adds a property to another schema, creating a new schema. 33 | */ 34 | const addProperty = ( 35 | from: NewSchemaOptions, 36 | ...newProperties: AddPropertyOptions[] 37 | ) => { 38 | const newSchema: ObjectSchema = clone(from.schema) 39 | newSchema.schema.description = from.description 40 | if (from.title) { 41 | newSchema.schema.title = from.title 42 | } else { 43 | // copying title from previous schema BUT 44 | // incrementing "minor" version because we are extending schema 45 | newSchema.version.minor += 1 46 | } 47 | 48 | if (!newSchema.schema.properties) { 49 | newSchema.schema.properties = {} 50 | } 51 | 52 | newProperties.forEach((options: AddPropertyOptions) => { 53 | const newProperties = newSchema.schema.properties as JsonProperties 54 | newProperties[options.property] = { 55 | type: options.propertyType, 56 | } 57 | const newProp = newProperties[options.property] 58 | 59 | // refine new property 60 | if (options.propertyFormat) { 61 | newProp.format = options.propertyFormat 62 | } 63 | 64 | normalizeRequiredProperties(newSchema.schema) 65 | // now newSchema.schema.required is string[] 66 | const required: string[] = newSchema.schema.required as string[] 67 | 68 | if (options.isRequired) { 69 | required.push(options.property) 70 | } else { 71 | newSchema.schema.required = reject(equals(options.property), required) 72 | } 73 | 74 | if (options.propertyDescription) { 75 | newProp.description = options.propertyDescription 76 | } 77 | 78 | if (options.see) { 79 | newProp.see = options.see 80 | } 81 | 82 | if ('defaultValue' in options) { 83 | newProp.defaultValue = options.defaultValue 84 | } 85 | 86 | newSchema.example[options.property] = clone(options.exampleValue) 87 | }) 88 | 89 | return newSchema 90 | } 91 | 92 | const extend = (from: ObjectSchema, schemaObj) => { 93 | const newSchema: ObjectSchema = mergeDeepRight(clone(from), schemaObj) 94 | 95 | // bump the minor version if it was not given 96 | if (!equals(schemaObj.version, newSchema.version)) { 97 | newSchema.version.minor += 1 98 | } 99 | 100 | normalizeRequiredProperties(newSchema.schema) 101 | 102 | return newSchema 103 | } 104 | 105 | export { extend, addProperty } 106 | -------------------------------------------------------------------------------- /test/custom-types-test.ts: -------------------------------------------------------------------------------- 1 | import validator from 'is-my-json-valid' 2 | import test from 'ava' 3 | 4 | // looking at jsen as an alternative 5 | // import jsen from 'jsen' 6 | ;(() => { 7 | // GOOD EXAMPLE date-time string format 8 | const schema = { 9 | properties: { 10 | t: { 11 | type: 'string', 12 | format: 'date-time', 13 | }, 14 | }, 15 | required: ['t'], 16 | } 17 | 18 | test('valid date-time', t => { 19 | const validate = validator(schema) 20 | t.true(validate({ t: '2018-03-21T02:01:29.557Z' })) 21 | }) 22 | 23 | test('invalid date-time', t => { 24 | const validate = validator(schema) 25 | const result = validate({ t: '1001' }) 26 | t.false(result) 27 | t.snapshot(validate.errors) 28 | }) 29 | })() 30 | ;(() => { 31 | // GOOD EXAMPLE uuid custom string format 32 | const schema = { 33 | properties: { 34 | id: { 35 | type: 'string', 36 | format: 'uuid', 37 | required: true, 38 | }, 39 | }, 40 | } 41 | const formats = { 42 | uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 43 | } 44 | 45 | test('valid uuid', t => { 46 | const validate = validator(schema, { formats }) 47 | t.true(validate({ id: '22908a15-d7cd-4779-b31c-78b021c684f8' })) 48 | }) 49 | 50 | test('invalid uuid', t => { 51 | const validate = validator(schema, { formats }) 52 | const result = validate({ id: 'something-there' }) 53 | t.false(result) 54 | t.snapshot(validate.errors) 55 | }) 56 | })() 57 | ;(() => { 58 | const innerSchema = { 59 | properties: { 60 | age: { 61 | type: 'integer', 62 | minimum: 1, 63 | required: true, 64 | }, 65 | }, 66 | } 67 | const schema = { 68 | properties: { 69 | name: { 70 | type: 'string', 71 | required: true, 72 | }, 73 | age: { 74 | $ref: 'definitions#/age', 75 | required: true, 76 | }, 77 | }, 78 | } 79 | 80 | const schemas = { 81 | definitions: innerSchema, 82 | } 83 | 84 | test('valid age when using external schema', t => { 85 | const validate = validator(schema, { schemas }) 86 | const person = { 87 | name: 'joe', 88 | age: 20, 89 | } 90 | t.true(validate(person)) 91 | }) 92 | })() 93 | ;(() => { 94 | const innerSchema = { 95 | properties: { 96 | age: { 97 | type: 'integer', 98 | minimum: 1, 99 | }, 100 | }, 101 | required: ['age'], 102 | } 103 | const schema = { 104 | properties: { 105 | name: { 106 | type: 'string', 107 | required: true, 108 | }, 109 | info: { 110 | type: 'object', 111 | properties: innerSchema.properties, 112 | required: innerSchema.required, 113 | }, 114 | }, 115 | required: ['name', 'info'], 116 | } 117 | 118 | test('valid age when using internal schema', t => { 119 | const validate = validator(schema) 120 | const person = { 121 | name: 'joe', 122 | info: { 123 | age: 20, 124 | }, 125 | } 126 | t.true(validate(person)) 127 | }) 128 | 129 | test('invalid age', t => { 130 | const validate = validator(schema) 131 | const person = { 132 | name: 'joe', 133 | info: { 134 | age: -4, 135 | }, 136 | } 137 | const result = validate(person) 138 | t.false(result) 139 | t.snapshot(validate.errors) 140 | }) 141 | })() 142 | -------------------------------------------------------------------------------- /test/fill-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { 3 | fillBySchema, 4 | JsonSchema, 5 | ObjectSchema, 6 | stringToSemver, 7 | trimBySchema, 8 | } from '../src' 9 | 10 | // for comparison: here is how "trim" works 11 | test('trim removes extra property', t => { 12 | const schema: JsonSchema = { 13 | title: 'test schema', 14 | type: 'object', 15 | additionalProperties: false, 16 | properties: { 17 | first: { 18 | type: 'number', 19 | }, 20 | }, 21 | } 22 | const objectSchema: ObjectSchema = { 23 | version: stringToSemver('1.0.0'), 24 | schema, 25 | example: { 26 | first: 42, 27 | }, 28 | } 29 | // this object has 1 known property "first" and 1 extra property "second" 30 | const o = { 31 | first: 1, 32 | second: 2, 33 | } 34 | const result = trimBySchema(objectSchema, o) 35 | // second unknown property has been removed 36 | t.deepEqual(result, { 37 | first: 1, 38 | }) 39 | }) 40 | 41 | test('fill adds required property using explicit default value', t => { 42 | const schema: JsonSchema = { 43 | title: 'test schema', 44 | type: 'object', 45 | additionalProperties: false, 46 | properties: { 47 | first: { 48 | type: 'number', 49 | }, 50 | second: { 51 | type: 'number', 52 | defaultValue: 99, 53 | }, 54 | }, 55 | required: ['first', 'second'], 56 | } 57 | const objectSchema: ObjectSchema = { 58 | version: stringToSemver('1.0.0'), 59 | schema, 60 | example: { 61 | first: 42, 62 | second: 43, 63 | }, 64 | } 65 | // this object has only one required property "first" and is missing second property 66 | const o = { 67 | first: 1, 68 | } 69 | const result = fillBySchema(objectSchema, o) 70 | // second property with explicit default value has been added 71 | t.deepEqual(result, { 72 | first: 1, 73 | second: 99, 74 | }) 75 | }) 76 | 77 | test('fill adds properties to an empty object', t => { 78 | const schema: JsonSchema = { 79 | title: 'test schema', 80 | type: 'object', 81 | additionalProperties: false, 82 | properties: { 83 | first: { 84 | type: 'number', 85 | defaultValue: 14, 86 | }, 87 | second: { 88 | type: 'number', 89 | defaultValue: 99, 90 | }, 91 | }, 92 | required: ['first', 'second'], 93 | } 94 | const objectSchema: ObjectSchema = { 95 | version: stringToSemver('1.0.0'), 96 | schema, 97 | example: { 98 | first: 42, 99 | second: 43, 100 | }, 101 | } 102 | // empty starting object 103 | const o = {} 104 | const result = fillBySchema(objectSchema, o) 105 | // second property with explicit default value has been added 106 | t.deepEqual(result, { 107 | first: 14, 108 | second: 99, 109 | }) 110 | }) 111 | 112 | test('throws an error if cannot fill missing property', t => { 113 | const schema: JsonSchema = { 114 | title: 'test schema', 115 | type: 'object', 116 | additionalProperties: false, 117 | properties: { 118 | first: { 119 | type: 'number', 120 | }, 121 | second: { 122 | type: 'number', 123 | defaultValue: 99, 124 | }, 125 | }, 126 | required: ['first', 'second'], 127 | } 128 | const objectSchema: ObjectSchema = { 129 | version: stringToSemver('1.0.0'), 130 | schema, 131 | example: { 132 | first: 42, 133 | second: 43, 134 | }, 135 | } 136 | // does not know how to polyfill first property, there is no default value 137 | t.throws(() => { 138 | fillBySchema(objectSchema, {}) 139 | }, 'Do not know how to get default value for property "first"') 140 | }) 141 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { reduce, camelCase } from 'lodash' 2 | import { map, path, uniq } from 'ramda' 3 | import { 4 | JsonSchema, 5 | ObjectSchema, 6 | SchemaCollection, 7 | SchemaVersion, 8 | Semver, 9 | VersionedSchema, 10 | } from './objects' 11 | 12 | /** 13 | * converts semantic version object into a string. 14 | * @example semverToString({major: 1, minor: 2, patch: 3}) // "1.2.3" 15 | */ 16 | export const semverToString = (s: Semver): SchemaVersion => 17 | `${s.major}.${s.minor}.${s.patch}` 18 | 19 | /** 20 | * Converts semver string like "1.2.3" to Semver structure. 21 | * @example stringToSemver("1.2.3") // {major: 1, minor: 2, patch: 3} 22 | */ 23 | export const stringToSemver = (s: SchemaVersion): Semver => { 24 | const [major, minor, patch] = s.split('.') 25 | return { 26 | major: parseInt(major), 27 | minor: parseInt(minor), 28 | patch: parseInt(patch), 29 | } 30 | } 31 | 32 | /** 33 | * Returns consistent name for a schema. 34 | * 35 | * @example normalizeName('membership_invitation') //> 'membershipInvitation' 36 | */ 37 | export const normalizeName = (s: string): string => camelCase(s) 38 | 39 | export const normalizeRequiredProperties = (schema: JsonSchema) => { 40 | if (schema.required === true) { 41 | if (schema.properties) { 42 | const reducer = (memo, obj, key) => { 43 | if (obj.required !== false) { 44 | memo.push(key) 45 | } 46 | 47 | return memo 48 | } 49 | 50 | schema.required = reduce(schema.properties, reducer, []) 51 | } else { 52 | schema.required = [] 53 | } 54 | } 55 | return schema 56 | } 57 | 58 | /** 59 | * Returns single object with every object schema under semver key. 60 | * @param schemas Schemas to combine into single object 61 | * @example versionSchemas(TestInformation100, TestInformation110) 62 | */ 63 | export const versionSchemas = (...schemas: ObjectSchema[]) => { 64 | if (!schemas.length) { 65 | throw new Error('expected list of schemas') 66 | } 67 | 68 | const titles: string[] = map(path(['schema', 'title']))(schemas) as string[] 69 | const unique = uniq(titles) 70 | if (unique.length !== 1) { 71 | throw new Error(`expected same schema titles, got ${titles.join(', ')}`) 72 | } 73 | 74 | const result: VersionedSchema = {} 75 | schemas.forEach(s => { 76 | normalizeRequiredProperties(s.schema) 77 | const version = semverToString(s.version) 78 | result[version] = s 79 | }) 80 | return result 81 | } 82 | 83 | /** 84 | * Sets name for each schema in the collection. 85 | * Note: mutates the input collection 86 | */ 87 | export const setPackageName = ( 88 | schemas: SchemaCollection, 89 | packageName: string, 90 | ) => { 91 | Object.keys(schemas).forEach(name => { 92 | Object.keys(schemas[name]).forEach(version => { 93 | const schema = schemas[name][version] 94 | if (!schema.package) { 95 | schema.package = packageName 96 | } 97 | }) 98 | }) 99 | // returns modified schemas just for convenience 100 | return schemas 101 | } 102 | 103 | /** 104 | * Combines multiple versioned schemas into single object 105 | * 106 | * @example combineSchemas(BillingPlan, GetRunResponse, ...) 107 | */ 108 | export const combineSchemas = (...versioned: VersionedSchema[]) => { 109 | const result: SchemaCollection = {} 110 | versioned.forEach(v => { 111 | const title = v[Object.keys(v)[0]].schema.title 112 | const name = normalizeName(title) 113 | result[name] = v 114 | }) 115 | return result 116 | } 117 | 118 | /** 119 | * A little helper type to create array with at least 1 item. 120 | * @see https://glebbahmutov.com/blog/trying-typescript/ 121 | */ 122 | type UnemptyArray = [T, ...T[]] 123 | 124 | /** 125 | * Creates regular expression that matches only given list of strings. 126 | * 127 | * @example const r = oneOfRegex('foo', 'bar') 128 | * r.test('foo') // true 129 | * r.test('bar') // true 130 | * r.test('FOO') // false 131 | */ 132 | export const oneOfRegex = (...values: UnemptyArray) => { 133 | return new RegExp(`^(${values.join('|')})$`) 134 | } 135 | -------------------------------------------------------------------------------- /test/add-property-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { ObjectSchema } from '../src' 3 | import { addProperty } from '../src/actions' 4 | import { person100 } from './example-schemas' 5 | 6 | test('addProperty creates example', t => { 7 | const person110 = addProperty( 8 | { 9 | schema: person100, 10 | description: 'Person with title', 11 | }, 12 | { 13 | property: 'title', 14 | propertyType: 'string', 15 | propertyFormat: null, 16 | exampleValue: 'mr', 17 | isRequired: false, 18 | propertyDescription: 'How to address this person', 19 | }, 20 | ) 21 | t.truthy(person110, 'returns new schema') 22 | t.false(person110 === person100, 'returns new object') 23 | t.is(person110.schema.title, person100.schema.title, 'copied title') 24 | t.snapshot(person110.example, 'example object') 25 | t.snapshot(person110.version, 'example version') 26 | t.snapshot(person110.schema, 'new json schema') 27 | }) 28 | 29 | test('addProperty several properties', t => { 30 | t.plan(1) 31 | const person110 = addProperty( 32 | { 33 | schema: person100, 34 | description: 'Person with title', 35 | }, 36 | { 37 | property: 'title', 38 | propertyType: 'string', 39 | propertyFormat: null, 40 | exampleValue: 'mr', 41 | isRequired: false, 42 | propertyDescription: 'How to address this person', 43 | }, 44 | { 45 | property: 'mood', 46 | propertyType: 'string', 47 | propertyFormat: null, 48 | exampleValue: 'blue', 49 | isRequired: false, 50 | propertyDescription: 'How does this person feel', 51 | }, 52 | ) 53 | t.snapshot(person110, 'added two properties: title and mood') 54 | }) 55 | 56 | test('addProperty links property via see parameter', t => { 57 | t.plan(1) 58 | const person110 = addProperty( 59 | { 60 | schema: person100, 61 | description: 'Person with title', 62 | }, 63 | { 64 | property: 'title', 65 | propertyType: 'string', 66 | propertyFormat: null, 67 | exampleValue: 'mr', 68 | isRequired: false, 69 | propertyDescription: 'How to address this person', 70 | see: person100, 71 | }, 72 | ) 73 | t.snapshot( 74 | person110, 75 | 'new schema with property that points at different schema', 76 | ) 77 | }) 78 | 79 | test('addProperty respects isRequired false', t => { 80 | t.plan(2) 81 | const a: ObjectSchema = { 82 | version: { 83 | major: 1, 84 | minor: 0, 85 | patch: 0, 86 | }, 87 | example: { 88 | foo: 'foo', 89 | }, 90 | schema: { 91 | title: 'test', 92 | type: 'object', 93 | description: 'test schema A', 94 | properties: { 95 | foo: { 96 | type: 'string', 97 | }, 98 | }, 99 | required: true, 100 | additionalProperties: false, 101 | }, 102 | } 103 | const b = addProperty( 104 | { 105 | schema: a, 106 | description: 'Test schema B', 107 | }, 108 | { 109 | property: 'bar', 110 | propertyType: 'string', 111 | propertyFormat: null, 112 | exampleValue: 'bar', 113 | isRequired: false, 114 | }, 115 | ) 116 | t.deepEqual(b.schema.required, ['foo'], 'bar should not be required') 117 | t.snapshot(b, 'new schema without required new property "bar"') 118 | }) 119 | 120 | test('addProperty with default value', t => { 121 | t.plan(1) 122 | const person110 = addProperty( 123 | { 124 | schema: person100, 125 | description: 'Person with title', 126 | }, 127 | { 128 | property: 'title', 129 | propertyType: 'string', 130 | propertyFormat: null, 131 | exampleValue: 'mr', 132 | isRequired: false, 133 | propertyDescription: 'How to address this person', 134 | defaultValue: 'Mr/Ms', 135 | }, 136 | { 137 | property: 'mood', 138 | propertyType: 'string', 139 | propertyFormat: null, 140 | exampleValue: 'blue', 141 | isRequired: false, 142 | propertyDescription: 'How does this person feel', 143 | defaultValue: 'happy', 144 | }, 145 | ) 146 | t.snapshot( 147 | person110, 148 | 'added two properties: title and mood with default values', 149 | ) 150 | }) 151 | -------------------------------------------------------------------------------- /test/sanitize-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import stringify from 'json-stable-stringify' 3 | import { getDefaults } from '../src/formats' 4 | import { JsonSchema } from '../src/objects' 5 | import { sanitize, sanitizeBySchema } from '../src/sanitize' 6 | import { exampleFormats, schemas } from './example-schemas' 7 | 8 | const schemaName = 'person' 9 | const schemaVersion = '1.0.0' 10 | const formatDefaults = getDefaults(exampleFormats) 11 | 12 | test('example sanitize', t => { 13 | const object = { 14 | name: 'joe', 15 | age: 21, 16 | } 17 | const result = sanitize(schemas)(schemaName, schemaVersion)(object) 18 | t.snapshot(stringify(result, { space: ' ' })) 19 | }) 20 | 21 | test('sanitize with default values', t => { 22 | t.plan(1) 23 | const object = { 24 | name: 'joe', 25 | age: 21, 26 | } 27 | const result = sanitize(schemas, formatDefaults)(schemaName, schemaVersion)( 28 | object, 29 | ) 30 | t.deepEqual(result, { 31 | name: 'Buddy', 32 | age: 21, 33 | }) 34 | }) 35 | 36 | test('sanitize empty object using schema', t => { 37 | const schema: JsonSchema = { 38 | title: 'TestSchema', 39 | type: 'object', 40 | additionalProperties: false, 41 | properties: { 42 | createdAt: { 43 | type: 'string', 44 | format: 'date-time', 45 | }, 46 | name: { 47 | type: 'string', 48 | }, 49 | hook: { 50 | type: 'string', 51 | format: 'hookId', 52 | }, 53 | ids: { 54 | type: 'array', 55 | items: { 56 | type: 'string', 57 | format: 'uuid', 58 | }, 59 | }, 60 | }, 61 | } 62 | 63 | const o = {} 64 | const result = sanitizeBySchema(schema, formatDefaults)(o) 65 | t.deepEqual(result, {}) 66 | }) 67 | 68 | test('sanitize string array', t => { 69 | t.plan(1) 70 | const schema: JsonSchema = { 71 | title: 'TestSchema', 72 | type: 'object', 73 | additionalProperties: false, 74 | properties: { 75 | names: { 76 | type: 'array', 77 | items: { 78 | type: 'string', 79 | format: 'name', 80 | }, 81 | }, 82 | }, 83 | } 84 | 85 | const o = { 86 | names: ['Joe', 'Mary'], 87 | } 88 | const result = sanitizeBySchema(schema, formatDefaults)(o) 89 | t.deepEqual( 90 | result, 91 | { names: ['Buddy', 'Buddy'] }, 92 | 'both names were replaced with default value for the format', 93 | ) 94 | }) 95 | 96 | test('sanitize array', t => { 97 | const schema: JsonSchema = { 98 | title: 'TestSchema', 99 | type: 'object', 100 | additionalProperties: false, 101 | properties: { 102 | names: { 103 | type: 'array', 104 | items: { 105 | // requires "title" in order to be considered a schema 106 | title: 'Name', 107 | type: 'object', 108 | properties: { 109 | name: { 110 | type: 'string', 111 | format: 'name', 112 | }, 113 | }, 114 | }, 115 | }, 116 | }, 117 | } 118 | 119 | const o = { 120 | names: [ 121 | { 122 | name: 'Joe', 123 | }, 124 | { 125 | name: 'Mary', 126 | }, 127 | ], 128 | } 129 | const result = sanitizeBySchema(schema, formatDefaults)(o) 130 | t.deepEqual( 131 | result, 132 | { 133 | names: [ 134 | { 135 | name: 'Buddy', 136 | }, 137 | { 138 | name: 'Buddy', 139 | }, 140 | ], 141 | }, 142 | 'name in each object is sanitized', 143 | ) 144 | }) 145 | 146 | test('sanitize array that can be null', t => { 147 | const schema: JsonSchema = { 148 | title: 'TestSchema', 149 | type: 'object', 150 | additionalProperties: false, 151 | properties: { 152 | names: { 153 | // notice that names can be "null" 154 | // https://github.com/cypress-io/schema-tools/issues/53 155 | type: ['array', 'null'], 156 | items: { 157 | // requires "title" in order to be considered a schema 158 | title: 'Name', 159 | type: 'object', 160 | properties: { 161 | name: { 162 | type: 'string', 163 | format: 'name', 164 | }, 165 | }, 166 | }, 167 | }, 168 | }, 169 | } 170 | 171 | const o = { 172 | names: [ 173 | { 174 | name: 'Joe', 175 | }, 176 | { 177 | name: 'Mary', 178 | }, 179 | ], 180 | } 181 | const result = sanitizeBySchema(schema, formatDefaults)(o) 182 | t.deepEqual( 183 | result, 184 | { 185 | names: [ 186 | { 187 | name: 'Buddy', 188 | }, 189 | { 190 | name: 'Buddy', 191 | }, 192 | ], 193 | }, 194 | 'name in each object is sanitized', 195 | ) 196 | }) 197 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "lib": [ 7 | "es7" 8 | ] /* Specify library files to be included in the compilation. */, 9 | // "allowJs": true, /* Allow javascript files to be compiled. */ 10 | // "checkJs": true, /* Report errors in .js files. */ 11 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "declaration": true /* Generates corresponding '.d.ts' file. */, 13 | "sourceMap": true /* Generates corresponding '.map' file. */, 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist" /* Redirect output structure to the directory. */, 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | "removeComments": true /* Do not emit comments to output. */, 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": false /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | "strictNullChecks": true /* Enable strict null checks. */, 27 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 28 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 29 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 30 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 31 | 32 | /* Additional Checks */ 33 | "noUnusedLocals": true /* Report errors on unused locals. */, 34 | "noUnusedParameters": true /* Report errors on unused parameters. */, 35 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 36 | // "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 37 | 38 | /* Module Resolution Options */ 39 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | }, 59 | "include": ["./src/**/*"] 60 | } 61 | -------------------------------------------------------------------------------- /src/sanitize.ts: -------------------------------------------------------------------------------- 1 | import debugApi from 'debug' 2 | import { clone } from 'ramda' 3 | import { assertSchema, getObjectSchema } from './api' 4 | import { FormatDefaults } from './formats' 5 | import { JsonSchema, PlainObject, SchemaCollection } from './objects' 6 | 7 | const debug = debugApi('schema-tools') 8 | 9 | const isDynamicFormat = (formatDefaults: FormatDefaults | undefined) => ( 10 | format: string, 11 | ) => (formatDefaults ? format in formatDefaults : false) 12 | 13 | const isString = s => typeof s === 'string' 14 | 15 | const canPropertyBeString = type => 16 | type === 'string' || (Array.isArray(type) && type.includes('string')) 17 | 18 | const canPropertyBeArray = type => 19 | type === 'array' || (Array.isArray(type) && type.includes('array')) 20 | 21 | const isArrayType = prop => canPropertyBeArray(prop.type) && prop.items 22 | 23 | const isStringArray = prop => 24 | isArrayType(prop) && canPropertyBeString(prop.items.type) 25 | 26 | const isJsonSchema = o => 27 | isString(o.title) && 28 | o.properties && 29 | (o.type === 'object' || (Array.isArray(o.type) && o.type.includes('object'))) 30 | 31 | const hasPropertiesArray = prop => 32 | isArrayType(prop) && prop.items && isJsonSchema(prop.items) 33 | 34 | /** 35 | * Sanitize an object given a JSON schema. Replaces all highly dynamic fields 36 | * (like "date-time", "uuid") with default values. 37 | * @param schema 38 | */ 39 | const sanitizeBySchema = ( 40 | schema: JsonSchema, 41 | formatDefaults?: FormatDefaults, 42 | ) => (object: PlainObject) => { 43 | const isDynamic = isDynamicFormat(formatDefaults) 44 | let result = clone(object) 45 | 46 | // simple single level sanitize for now 47 | const props = schema.properties 48 | if (props) { 49 | Object.keys(props).forEach(key => { 50 | if (!(key in object)) { 51 | // do not sanitize / replace non-existent value 52 | return 53 | } 54 | 55 | const prop = props[key] 56 | debug('looking at property %j', prop) 57 | 58 | if (key in object && Array.isArray(object[key])) { 59 | debug('%s is present as an array', key) 60 | 61 | if (isStringArray(prop)) { 62 | debug('%s is a string array', key) 63 | // go through the items in the array and if the format is dynamic 64 | // set default values 65 | const list: string[] = result[key] as string[] 66 | 67 | if (prop.items && prop.items.format) { 68 | const itemFormat = prop.items.format 69 | debug('items format %s', itemFormat) 70 | 71 | if (formatDefaults && isDynamic(itemFormat)) { 72 | debug( 73 | 'format %s is dynamic, need to replace with default value', 74 | itemFormat, 75 | ) 76 | const defaultValue = formatDefaults[itemFormat] 77 | for (let k = 0; k < list.length; k += 1) { 78 | list[k] = defaultValue as string 79 | } 80 | return 81 | } 82 | } 83 | } else if (isArrayType(prop) && hasPropertiesArray(prop)) { 84 | debug('property %s is array-like and has properties', key) 85 | 86 | const list: PlainObject[] = object[key] as PlainObject[] 87 | const propSchema: JsonSchema = prop.items as JsonSchema 88 | result[key] = list.map(sanitizeBySchema(propSchema, formatDefaults)) 89 | return 90 | } 91 | } 92 | if (!isString(object[key])) { 93 | // for now can only sanitize string properties 94 | return 95 | } 96 | 97 | if (canPropertyBeString(prop.type)) { 98 | if (prop.format && formatDefaults && isDynamic(prop.format)) { 99 | const defaultValue = formatDefaults[prop.format] 100 | if (!defaultValue) { 101 | throw new Error( 102 | `Cannot find default value for format name ${prop.format}`, 103 | ) 104 | } 105 | result[key] = defaultValue 106 | } 107 | } 108 | }) 109 | } 110 | 111 | return result 112 | } 113 | 114 | /** 115 | * Given a schema (by name and version) and an object, replaces dynamic values 116 | * in the object with default values. Useful to replace UUIDs, timestamps, etc 117 | * with defaults before comparing with expected value. 118 | */ 119 | const sanitize = ( 120 | schemas: SchemaCollection, 121 | formatDefaults?: FormatDefaults, 122 | ) => (name: string, version: string) => (object: PlainObject) => { 123 | assertSchema(schemas)(name, version)(object) 124 | const schema = getObjectSchema(schemas, name, version) 125 | if (!schema) { 126 | throw new Error( 127 | `Could not find schema ${name}@${version} to sanitize an object`, 128 | ) 129 | } 130 | 131 | return sanitizeBySchema(schema.schema, formatDefaults)(object) 132 | } 133 | 134 | export { 135 | sanitize, 136 | sanitizeBySchema, 137 | isDynamicFormat, 138 | isJsonSchema, 139 | hasPropertiesArray, 140 | } 141 | -------------------------------------------------------------------------------- /test/example-schemas.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CustomFormat, 3 | CustomFormats, 4 | detectors, 5 | extend, 6 | JsonSchemaFormats, 7 | } from '../src' 8 | import { ObjectSchema, SchemaCollection, VersionedSchema } from '../src/objects' 9 | import { combineSchemas, versionSchemas } from '../src/utils' 10 | 11 | const traits100: ObjectSchema = { 12 | version: { 13 | major: 1, 14 | minor: 0, 15 | patch: 0, 16 | }, 17 | schema: { 18 | type: 'object', 19 | title: 'Traits', 20 | description: 'Physical traits of person', 21 | properties: { 22 | eyeColor: { 23 | type: 'string', 24 | description: 'Eye color', 25 | minLength: 2, 26 | maxLength: 20, 27 | }, 28 | hairColor: { 29 | type: 'string', 30 | description: 'Hair color', 31 | }, 32 | }, 33 | additionalProperties: false, 34 | }, 35 | example: { 36 | eyeColor: 'brown', 37 | hairColor: 'black', 38 | }, 39 | } 40 | 41 | const name: CustomFormat = { 42 | name: 'name', 43 | description: 'Custom name format', 44 | detect: /^[A-Z][a-z]+$/, 45 | defaultValue: 'Buddy', 46 | } 47 | 48 | const exampleFormats: CustomFormats = { 49 | name, 50 | } 51 | 52 | // individual schema describing "Person" v1.0.0 53 | const person100: ObjectSchema = { 54 | version: { 55 | major: 1, 56 | minor: 0, 57 | patch: 0, 58 | }, 59 | schema: { 60 | type: 'object', 61 | title: 'Person', 62 | description: 'An example schema describing a person', 63 | properties: { 64 | name: { 65 | type: 'string', 66 | format: 'name', 67 | description: 'this person needs a name', 68 | minLength: 2, 69 | }, 70 | age: { 71 | type: 'integer', 72 | minimum: 0, 73 | description: 'Age in years', 74 | }, 75 | }, 76 | required: ['name', 'age'], 77 | additionalProperties: false, 78 | }, 79 | example: { 80 | name: 'Joe', 81 | age: 10, 82 | }, 83 | } 84 | 85 | const person110: ObjectSchema = extend(person100, { 86 | schema: { 87 | description: 'Person with title', 88 | properties: { 89 | title: { 90 | type: 'string', 91 | format: null, 92 | description: 'How to address this person', 93 | }, 94 | }, 95 | }, 96 | example: { 97 | title: 'mr', 98 | }, 99 | }) 100 | 101 | const person120: ObjectSchema = extend(person110, { 102 | schema: { 103 | description: 'Person with traits', 104 | properties: { 105 | traits: { 106 | ...traits100.schema, 107 | see: traits100, 108 | }, 109 | }, 110 | }, 111 | example: { 112 | name: 'Joe', 113 | age: 10, 114 | traits: { 115 | eyeColor: 'brown', 116 | hairColor: 'black', 117 | }, 118 | }, 119 | }) 120 | 121 | // example schema that has an array of "Person" objects 122 | const team100: ObjectSchema = { 123 | version: { 124 | major: 1, 125 | minor: 0, 126 | patch: 0, 127 | }, 128 | schema: { 129 | type: 'object', 130 | title: 'Team', 131 | description: 'A team of people', 132 | properties: { 133 | people: { 134 | type: 'array', 135 | items: { 136 | ...person100.schema, 137 | }, 138 | see: person100, 139 | }, 140 | }, 141 | additionalProperties: false, 142 | }, 143 | example: { 144 | people: [person100.example], 145 | }, 146 | } 147 | 148 | const car100: ObjectSchema = { 149 | version: { 150 | major: 1, 151 | minor: 0, 152 | patch: 0, 153 | }, 154 | schema: { 155 | type: 'object', 156 | title: 'Car', 157 | description: 'A motor vehicle', 158 | properties: { 159 | color: { 160 | type: 'string', 161 | }, 162 | }, 163 | additionalProperties: false, 164 | required: true, 165 | }, 166 | example: { 167 | color: 'red', 168 | }, 169 | } 170 | 171 | const car110: ObjectSchema = extend(car100, { 172 | schema: { 173 | properties: { 174 | doors: { 175 | type: 'number', 176 | required: false, 177 | }, 178 | }, 179 | }, 180 | example: { 181 | doors: 2, 182 | }, 183 | }) 184 | 185 | // collection of "Person" schemas by version. 186 | // In our case there will be single version, but here is where we can combine multiple 187 | // versions like: versionSchemas(person100, person110, person200, ...) 188 | const personVersions: VersionedSchema = versionSchemas( 189 | person100, 190 | person110, 191 | person120, 192 | ) 193 | const teamVersions: VersionedSchema = versionSchemas(team100) 194 | const carVersions: VersionedSchema = versionSchemas(car100, car110) 195 | 196 | // combines "Person" versions with other schemas if any 197 | const schemas: SchemaCollection = combineSchemas( 198 | personVersions, 199 | teamVersions, 200 | carVersions, 201 | ) 202 | 203 | const formats: JsonSchemaFormats = detectors(exampleFormats) 204 | 205 | export { 206 | car100, 207 | person100, 208 | person110, 209 | person120, 210 | formats, 211 | schemas, 212 | exampleFormats, 213 | } 214 | -------------------------------------------------------------------------------- /test/assert-schema-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { 3 | JsonSchema, 4 | ObjectSchema, 5 | SchemaCollection, 6 | VersionedSchema, 7 | assertSchema, 8 | combineSchemas, 9 | versionSchemas, 10 | } from '../src' 11 | import { formats, schemas } from './example-schemas' 12 | 13 | const assertExample100 = assertSchema(schemas)('Person', '1.0.0') 14 | 15 | test('is a function', t => { 16 | t.is(typeof assertSchema, 'function') 17 | }) 18 | 19 | test('passing example 1.0.0', t => { 20 | t.plan(1) 21 | 22 | const o = { 23 | name: 'Mary', 24 | age: 5, 25 | } 26 | const fn = () => assertExample100(o) 27 | t.notThrows(fn) 28 | }) 29 | 30 | test('returns original object if passes', t => { 31 | t.plan(1) 32 | const o = { 33 | name: 'Mary', 34 | age: 5, 35 | } 36 | const result = assertExample100(o) 37 | t.is(result, o, 'original object returned') 38 | }) 39 | 40 | test('missing name membership invitation 1.0.0', t => { 41 | t.plan(2) 42 | 43 | const o = { 44 | // missing name on purpose 45 | age: 10, 46 | } 47 | const fn = () => assertExample100(o) 48 | t.throws(fn) 49 | 50 | // let's keep track of how the error message looks 51 | try { 52 | fn() 53 | } catch (e) { 54 | t.snapshot(e.message) 55 | } 56 | }) 57 | 58 | test('has schema name and version in the error object', t => { 59 | t.plan(2) 60 | 61 | const o = { 62 | // missing name on purpose 63 | age: 10, 64 | } 65 | const fn = () => assertExample100(o) 66 | 67 | try { 68 | fn() 69 | } catch (e) { 70 | t.is(e.schemaName, 'Person') 71 | t.is(e.schemaVersion, '1.0.0') 72 | } 73 | }) 74 | 75 | test('has input object and example in the error object', t => { 76 | t.plan(2) 77 | 78 | const o = { 79 | // missing name on purpose 80 | age: 10, 81 | } 82 | const fn = () => assertExample100(o) 83 | 84 | try { 85 | fn() 86 | } catch (e) { 87 | t.is(e.object, o) 88 | t.deepEqual(e.example, { 89 | name: 'Joe', 90 | age: 10, 91 | }) 92 | } 93 | }) 94 | 95 | test('passing membership invitation 1.0.0 with field substitution', t => { 96 | t.plan(1) 97 | 98 | // notice invalid "age" value 99 | const o = { 100 | name: 'Joe', 101 | age: -1, 102 | } 103 | // replace "age" value with value from the example 104 | const assert = assertSchema(schemas)('Person', '1.0.0', { 105 | substitutions: ['age'], 106 | }) 107 | const fn = () => assert(o) 108 | t.notThrows(fn) 109 | }) 110 | 111 | test('error message has object with substitutions', t => { 112 | t.plan(3) 113 | 114 | // notice invalid "age" value and invalid "name" 115 | const o = { 116 | name: 'lowercase', 117 | age: -1, 118 | } 119 | // replace "age" value with value from the example 120 | // but the "name" does not match schema format 121 | const assert = assertSchema(schemas, formats)('Person', '1.0.0', { 122 | substitutions: ['age'], 123 | }) 124 | 125 | try { 126 | assert(o) 127 | } catch (e) { 128 | // because we told assertSchema to substitute ["age"], it will grab 129 | // the age value from the example object for this schema 130 | const oWithSubstitutions = { 131 | name: 'lowercase', 132 | age: e.example.age, 133 | } 134 | t.deepEqual(e.object, oWithSubstitutions, 'object with replaced values') 135 | t.snapshot(e.message, 'error message') 136 | t.snapshot(e.errors, 'list of errors') 137 | } 138 | }) 139 | 140 | test('lists additional properties', t => { 141 | t.plan(1) 142 | 143 | const o = { 144 | name: 'test', 145 | age: 1, 146 | // notice additional property 147 | foo: 'bar', 148 | } 149 | const assert = assertSchema(schemas)('Person', '1.0.0') 150 | try { 151 | assert(o) 152 | } catch (e) { 153 | t.deepEqual(e.errors, ['data has additional properties: foo']) 154 | } 155 | }) 156 | 157 | test('whitelist errors only', t => { 158 | t.plan(1) 159 | 160 | const o = { 161 | name: 'test', 162 | age: -2, 163 | } 164 | const assert = assertSchema(schemas)('Person', '1.0.0', { 165 | omit: { 166 | object: true, 167 | example: true, 168 | }, 169 | }) 170 | try { 171 | assert(o) 172 | } catch (e) { 173 | t.snapshot(e.message) 174 | } 175 | }) 176 | 177 | test('require all properties', t => { 178 | t.plan(1) 179 | const schema: JsonSchema = { 180 | title: 'Example', 181 | type: 'object', 182 | properties: { 183 | foo: { 184 | type: 'string', 185 | }, 186 | bar: { 187 | type: 'number', 188 | }, 189 | }, 190 | required: true, 191 | additionalProperties: false, 192 | } 193 | const schema100: ObjectSchema = { 194 | version: { 195 | major: 1, 196 | minor: 0, 197 | patch: 0, 198 | }, 199 | schema, 200 | example: { 201 | foo: 'foo', 202 | }, 203 | } 204 | const schemaVersions: VersionedSchema = versionSchemas(schema100) 205 | const schemas: SchemaCollection = combineSchemas(schemaVersions) 206 | const assert = assertSchema(schemas)('Example', '1.0.0') 207 | const o = {} 208 | try { 209 | assert(o) 210 | } catch (e) { 211 | t.snapshot(e.message) 212 | } 213 | }) 214 | -------------------------------------------------------------------------------- /test/snapshots/add-property-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/add-property-test.ts` 2 | 3 | The actual snapshot is saved in `add-property-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## addProperty creates example 8 | 9 | > example object 10 | 11 | { 12 | age: 10, 13 | name: 'Joe', 14 | title: 'mr', 15 | } 16 | 17 | > example version 18 | 19 | { 20 | major: 1, 21 | minor: 1, 22 | patch: 0, 23 | } 24 | 25 | > new json schema 26 | 27 | { 28 | additionalProperties: false, 29 | description: 'Person with title', 30 | properties: { 31 | age: { 32 | description: 'Age in years', 33 | minimum: 0, 34 | type: 'integer', 35 | }, 36 | name: { 37 | description: 'this person needs a name', 38 | format: 'name', 39 | minLength: 2, 40 | type: 'string', 41 | }, 42 | title: { 43 | description: 'How to address this person', 44 | type: 'string', 45 | }, 46 | }, 47 | required: [ 48 | 'name', 49 | 'age', 50 | ], 51 | title: 'Person', 52 | type: 'object', 53 | } 54 | 55 | ## addProperty links property via see parameter 56 | 57 | > new schema with property that points at different schema 58 | 59 | { 60 | example: { 61 | age: 10, 62 | name: 'Joe', 63 | title: 'mr', 64 | }, 65 | schema: { 66 | additionalProperties: false, 67 | description: 'Person with title', 68 | properties: { 69 | age: { 70 | description: 'Age in years', 71 | minimum: 0, 72 | type: 'integer', 73 | }, 74 | name: { 75 | description: 'this person needs a name', 76 | format: 'name', 77 | minLength: 2, 78 | type: 'string', 79 | }, 80 | title: { 81 | description: 'How to address this person', 82 | see: { 83 | example: { 84 | age: 10, 85 | name: 'Joe', 86 | }, 87 | schema: { 88 | additionalProperties: false, 89 | description: 'An example schema describing a person', 90 | properties: { 91 | age: { 92 | description: 'Age in years', 93 | minimum: 0, 94 | type: 'integer', 95 | }, 96 | name: { 97 | description: 'this person needs a name', 98 | format: 'name', 99 | minLength: 2, 100 | type: 'string', 101 | }, 102 | }, 103 | required: [ 104 | 'name', 105 | 'age', 106 | ], 107 | title: 'Person', 108 | type: 'object', 109 | }, 110 | version: { 111 | major: 1, 112 | minor: 0, 113 | patch: 0, 114 | }, 115 | }, 116 | type: 'string', 117 | }, 118 | }, 119 | required: [ 120 | 'name', 121 | 'age', 122 | ], 123 | title: 'Person', 124 | type: 'object', 125 | }, 126 | version: { 127 | major: 1, 128 | minor: 1, 129 | patch: 0, 130 | }, 131 | } 132 | 133 | ## addProperty respects isRequired false 134 | 135 | > new schema without required new property "bar" 136 | 137 | { 138 | example: { 139 | bar: 'bar', 140 | foo: 'foo', 141 | }, 142 | schema: { 143 | additionalProperties: false, 144 | description: 'Test schema B', 145 | properties: { 146 | bar: { 147 | type: 'string', 148 | }, 149 | foo: { 150 | type: 'string', 151 | }, 152 | }, 153 | required: [ 154 | 'foo', 155 | ], 156 | title: 'test', 157 | type: 'object', 158 | }, 159 | version: { 160 | major: 1, 161 | minor: 1, 162 | patch: 0, 163 | }, 164 | } 165 | 166 | ## addProperty several properties 167 | 168 | > added two properties: title and mood 169 | 170 | { 171 | example: { 172 | age: 10, 173 | mood: 'blue', 174 | name: 'Joe', 175 | title: 'mr', 176 | }, 177 | schema: { 178 | additionalProperties: false, 179 | description: 'Person with title', 180 | properties: { 181 | age: { 182 | description: 'Age in years', 183 | minimum: 0, 184 | type: 'integer', 185 | }, 186 | mood: { 187 | description: 'How does this person feel', 188 | type: 'string', 189 | }, 190 | name: { 191 | description: 'this person needs a name', 192 | format: 'name', 193 | minLength: 2, 194 | type: 'string', 195 | }, 196 | title: { 197 | description: 'How to address this person', 198 | type: 'string', 199 | }, 200 | }, 201 | required: [ 202 | 'name', 203 | 'age', 204 | ], 205 | title: 'Person', 206 | type: 'object', 207 | }, 208 | version: { 209 | major: 1, 210 | minor: 1, 211 | patch: 0, 212 | }, 213 | 214 | 215 | ## addProperty with default value 216 | 217 | > added two properties: title and mood with default values 218 | 219 | { 220 | example: { 221 | age: 10, 222 | mood: 'blue', 223 | name: 'Joe', 224 | title: 'mr', 225 | }, 226 | schema: { 227 | additionalProperties: false, 228 | description: 'Person with title', 229 | properties: { 230 | age: { 231 | description: 'Age in years', 232 | minimum: 0, 233 | type: 'integer', 234 | }, 235 | mood: { 236 | defaultValue: 'happy', 237 | description: 'How does this person feel', 238 | type: 'string', 239 | }, 240 | name: { 241 | description: 'this person needs a name', 242 | format: 'name', 243 | minLength: 2, 244 | type: 'string', 245 | }, 246 | title: { 247 | defaultValue: 'Mr/Ms', 248 | description: 'How to address this person', 249 | type: 'string', 250 | }, 251 | }, 252 | required: [ 253 | 'name', 254 | 'age', 255 | ], 256 | title: 'Person', 257 | type: 'object', 258 | }, 259 | version: { 260 | major: 1, 261 | minor: 1, 262 | patch: 0, 263 | }, 264 | } -------------------------------------------------------------------------------- /test/document-test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import json2md from 'json2md' 3 | import { clone } from 'ramda' 4 | import { documentSchemas, setPackageName } from '../src' 5 | import { documentCustomFormats } from '../src/document/doc-formats' 6 | import { 7 | documentObjectSchema, 8 | documentProperties, 9 | documentProperty, 10 | documentSchema, 11 | findUsedColumns, 12 | } from '../src/document/utils' 13 | import { CustomFormats } from '../src/formats' 14 | import { 15 | JsonProperties, 16 | JsonProperty, 17 | JsonSchema, 18 | ObjectSchema, 19 | } from '../src/objects' 20 | import { exampleFormats, schemas } from './example-schemas' 21 | 22 | test('documents just schemas', t => { 23 | // without schemas and formats, the output document 24 | // just has the custom format by name 25 | const markdown = documentSchemas(schemas) 26 | t.snapshot(markdown) 27 | }) 28 | 29 | test('documents schemas and custom formats', t => { 30 | // with schemas and custom formats it can link to the formats section 31 | const markdown = documentSchemas(schemas, exampleFormats) 32 | t.snapshot(markdown) 33 | }) 34 | 35 | test('document properties', t => { 36 | t.plan(1) 37 | const properties: JsonProperties = { 38 | foo: { 39 | type: 'string', 40 | description: 'Property foo', 41 | }, 42 | bar: { 43 | type: 'string', 44 | enum: ['A', 'B'], 45 | description: 'Can only be choice a or b', 46 | }, 47 | } 48 | const result = documentProperties(properties) 49 | t.snapshot(result) 50 | }) 51 | 52 | test('document property with minLength and maxLength', t => { 53 | t.plan(1) 54 | const property: JsonProperty = { 55 | type: 'string', 56 | minLength: 5, 57 | maxLength: 20, 58 | } 59 | const docProp = documentProperty([]) 60 | const result = docProp('test property', property) 61 | t.snapshot({ 62 | property, 63 | result, 64 | }) 65 | }) 66 | 67 | test('document property with minLength and maxLength at 0', t => { 68 | t.plan(1) 69 | const property: JsonProperty = { 70 | type: 'string', 71 | minLength: 0, 72 | maxLength: 0, 73 | } 74 | const docProp = documentProperty([]) 75 | const result = docProp('test property', property) 76 | t.snapshot({ 77 | property, 78 | result, 79 | }) 80 | }) 81 | 82 | test('document property with explicit default value', t => { 83 | t.plan(1) 84 | const property: JsonProperty = { 85 | type: 'string', 86 | defaultValue: 'foo Bar', 87 | } 88 | const docProp = documentProperty([]) 89 | const result = docProp('test property', property) 90 | t.snapshot({ 91 | property, 92 | result, 93 | }) 94 | }) 95 | 96 | test('documents schemas with package name', t => { 97 | t.plan(1) 98 | const schemasWithName = clone(schemas) 99 | setPackageName(schemasWithName, 'test-package') 100 | const markdown = documentSchemas(schemasWithName, exampleFormats) 101 | t.true(markdown.includes('Defined in `test-package`')) 102 | }) 103 | 104 | test('sublist', t => { 105 | const json = [ 106 | { 107 | ul: [ 108 | 'top level', 109 | { 110 | ul: ['inner level 1', 'inner level 2'], 111 | }, 112 | ], 113 | }, 114 | ] 115 | const md = json2md(json) 116 | t.snapshot(md) 117 | }) 118 | 119 | test('filters unused columns', t => { 120 | const headers = ['first', 'second', 'third'] 121 | const rows = [ 122 | { 123 | first: 'a', 124 | second: '', 125 | third: '', 126 | }, 127 | { 128 | first: 'b', 129 | second: '', 130 | third: '', 131 | }, 132 | { 133 | first: '', 134 | second: '', 135 | third: 'c', 136 | }, 137 | ] 138 | const used = findUsedColumns(headers, rows) 139 | t.snapshot(used) 140 | }) 141 | 142 | test('JSON schema object to Markdown object', t => { 143 | const schema: JsonSchema = { 144 | title: 'test schema', 145 | type: 'object', 146 | additionalProperties: false, 147 | properties: { 148 | id: { 149 | type: 'string', 150 | }, 151 | name: { 152 | type: 'string', 153 | }, 154 | }, 155 | } 156 | const result = documentSchema(schema) 157 | t.snapshot(result) 158 | }) 159 | 160 | test('JSON schema object to Markdown', t => { 161 | const schema: JsonSchema = { 162 | title: 'test schema', 163 | type: 'object', 164 | additionalProperties: false, 165 | properties: { 166 | id: { 167 | type: 'string', 168 | }, 169 | name: { 170 | type: 'string', 171 | }, 172 | }, 173 | } 174 | const result = json2md(documentSchema(schema)) 175 | t.snapshot(result) 176 | }) 177 | 178 | test('custom formats', t => { 179 | const formats: CustomFormats = { 180 | foo: { 181 | // should use name in the documentation 182 | name: 'my-foo', 183 | description: 'example custom format foo', 184 | detect: /^foo$/, 185 | }, 186 | } 187 | const result = documentCustomFormats(formats) 188 | t.snapshot(result) 189 | t.snapshot(json2md(result)) 190 | }) 191 | 192 | test('JSON schema with enumeration to Markdown', t => { 193 | const schema: JsonSchema = { 194 | title: 'test schema', 195 | type: 'object', 196 | additionalProperties: false, 197 | properties: { 198 | id: { 199 | type: 'string', 200 | }, 201 | name: { 202 | type: 'string', 203 | enum: ['joe', 'mary'], 204 | }, 205 | }, 206 | } 207 | const result = json2md(documentSchema(schema)) 208 | t.snapshot(result) 209 | }) 210 | 211 | test('document deprecated schema', t => { 212 | t.plan(1) 213 | const jsonSchema: JsonSchema = { 214 | title: 'testSchema', 215 | type: 'object', 216 | additionalProperties: false, 217 | description: 'This is a test schema', 218 | deprecated: 'no longer in use', 219 | properties: { 220 | id: { 221 | type: 'string', 222 | }, 223 | name: { 224 | type: 'string', 225 | enum: ['joe', 'mary'], 226 | }, 227 | }, 228 | } 229 | const schema: ObjectSchema = { 230 | version: { 231 | major: 1, 232 | minor: 2, 233 | patch: 3, 234 | }, 235 | schema: jsonSchema, 236 | example: { 237 | id: 'abc', 238 | name: 'joe', 239 | }, 240 | } 241 | const result = json2md(documentObjectSchema(schema)) 242 | t.snapshot(result) 243 | }) 244 | 245 | test('document deprecated schema property', t => { 246 | t.plan(1) 247 | const jsonSchema: JsonSchema = { 248 | title: 'testSchema', 249 | type: 'object', 250 | additionalProperties: true, 251 | description: 'This is a test schema', 252 | properties: { 253 | id: { 254 | type: 'string', 255 | }, 256 | name: { 257 | type: 'string', 258 | enum: ['joe', 'mary'], 259 | deprecated: 'use property "fullName" instead', 260 | }, 261 | }, 262 | } 263 | const schema: ObjectSchema = { 264 | version: { 265 | major: 1, 266 | minor: 2, 267 | patch: 3, 268 | }, 269 | schema: jsonSchema, 270 | example: { 271 | id: 'abc', 272 | name: 'joe', 273 | }, 274 | } 275 | const result = json2md(documentObjectSchema(schema)) 276 | t.snapshot(result) 277 | }) 278 | -------------------------------------------------------------------------------- /src/document/utils.ts: -------------------------------------------------------------------------------- 1 | import stringify from 'json-stable-stringify' 2 | import quote from 'quote' 3 | import { find, flatten, toLower } from 'ramda' 4 | import { normalizeName, schemaNames, semverToString } from '..' 5 | import { CustomFormats } from '../formats' 6 | import { 7 | JsonProperties, 8 | JsonProperty, 9 | JsonSchema, 10 | ObjectSchema, 11 | SchemaCollection, 12 | } from '../objects' 13 | 14 | const ticks = quote({ quotes: '`' }) 15 | 16 | /** 17 | * Unicode '✔' for consistency 18 | */ 19 | export const checkMark = '✔' 20 | 21 | /** 22 | * Empty string for markdown table cells 23 | */ 24 | export const emptyMark = '' 25 | 26 | const isCustomFormat = (formats: CustomFormats) => name => name in formats 27 | 28 | const knownSchemaNames = (schemas: SchemaCollection) => schemaNames(schemas) 29 | 30 | const isSchemaName = (schemas: SchemaCollection) => (s: string) => 31 | knownSchemaNames(schemas).includes(normalizeName(s)) 32 | 33 | // removes all characters to have a link 34 | export const anchor = (s: string) => toLower(s.replace(/[\.@]/g, '')) 35 | 36 | export const anchorForSchema = (s: ObjectSchema): string => { 37 | const schemaName = toLower(normalizeName(s.schema.title)) 38 | const seeVersion = semverToString(s.version) 39 | const nameAndVersion = `${schemaName}@${seeVersion}` 40 | return anchor(nameAndVersion) 41 | } 42 | 43 | export const enumToMarkdown = enumeration => { 44 | if (!enumeration) { 45 | return emptyMark 46 | } 47 | return ticks(enumeration.map(JSON.stringify).join(', ')) 48 | } 49 | 50 | export const formatToMarkdown = ( 51 | schemas?: SchemaCollection, 52 | formats?: CustomFormats, 53 | ) => (value: JsonProperty): string => { 54 | if (!value.format) { 55 | if (value.see) { 56 | if (typeof value.see === 'string') { 57 | // try finding schema by name 58 | return schemas && isSchemaName(schemas)(value.see) 59 | ? `[${value.see}](#${toLower(normalizeName(value.see))})` 60 | : ticks(value.see) 61 | } else { 62 | const seeSchema: ObjectSchema = value.see 63 | const schemaName = `${seeSchema.schema.title}` 64 | const seeVersion = semverToString(seeSchema.version) 65 | const nameAndVersion = `${schemaName}@${seeVersion}` 66 | const seeAnchor = anchorForSchema(seeSchema) 67 | return schemas && isSchemaName(schemas)(schemaName) 68 | ? `[${nameAndVersion}](#${seeAnchor})` 69 | : ticks(nameAndVersion) 70 | } 71 | } else { 72 | return emptyMark 73 | } 74 | } 75 | 76 | if (formats && isCustomFormat(formats)(value.format)) { 77 | // point at the formats section 78 | return `[${value.format}](#formats)` 79 | } 80 | 81 | return ticks(value.format) 82 | } 83 | 84 | export const findUsedColumns = (headers: string[], rows: object[]) => { 85 | const isUsed = (header: string) => find(r => r[header], rows) 86 | const usedHeaders = headers.filter(isUsed) 87 | return usedHeaders 88 | } 89 | 90 | type PropertyDescription = { 91 | name: string 92 | type: string 93 | required: string 94 | format: string 95 | description: string 96 | enum: string 97 | deprecated: string 98 | minLength: string 99 | maxLength: string 100 | defaultValue: string 101 | } 102 | 103 | const existingProp = (name: string) => (o: object): string => 104 | name in o ? String(o[name]) : emptyMark 105 | 106 | export const documentProperty = ( 107 | requiredProperties: string[], 108 | schemas?: SchemaCollection, 109 | formats?: CustomFormats, 110 | ) => (prop: string, value: JsonProperty): PropertyDescription => { 111 | const isRequired = name => requiredProperties.indexOf(name) !== -1 112 | const typeText = type => (Array.isArray(type) ? type.join(' or ') : type) 113 | const deprecatedMessage = (value: JsonProperty) => 114 | value.deprecated ? `**deprecated** ${value.deprecated}` : emptyMark 115 | 116 | return { 117 | name: ticks(prop), 118 | type: typeText(value.type), 119 | required: isRequired(prop) ? checkMark : emptyMark, 120 | format: formatToMarkdown(schemas, formats)(value), 121 | enum: enumToMarkdown(value.enum), 122 | description: value.description ? value.description : emptyMark, 123 | deprecated: deprecatedMessage(value), 124 | minLength: existingProp('minLength')(value), 125 | maxLength: existingProp('maxLength')(value), 126 | defaultValue: existingProp('defaultValue')(value), 127 | } 128 | } 129 | 130 | export const documentProperties = ( 131 | properties: JsonProperties, 132 | required: string[] | true = [], 133 | schemas?: SchemaCollection, 134 | formats?: CustomFormats, 135 | ): PropertyDescription[] => { 136 | const requiredProperties: string[] = Array.isArray(required) 137 | ? required 138 | : Object.keys(properties) 139 | 140 | const docProperty = documentProperty(requiredProperties, schemas, formats) 141 | 142 | return Object.keys(properties) 143 | .sort() 144 | .map(prop => { 145 | const value: JsonProperty = properties[prop] 146 | return docProperty(prop, value) 147 | }) 148 | } 149 | 150 | export const documentSchema = ( 151 | schema: JsonSchema, 152 | schemas?: SchemaCollection, 153 | formats?: CustomFormats, 154 | ) => { 155 | const properties = schema.properties 156 | 157 | if (properties) { 158 | const rows: PropertyDescription[] = documentProperties( 159 | properties, 160 | schema.required, 161 | schemas, 162 | formats, 163 | ) 164 | const headers = [ 165 | 'name', 166 | 'type', 167 | 'required', 168 | 'format', 169 | 'enum', 170 | 'description', 171 | 'deprecated', 172 | 'minLength', 173 | 'maxLength', 174 | 'defaultValue', 175 | ] 176 | const usedHeaders = findUsedColumns(headers, rows) 177 | const table: object[] = [ 178 | { 179 | table: { 180 | headers: usedHeaders, 181 | rows, 182 | }, 183 | }, 184 | ] 185 | if (schema.additionalProperties) { 186 | table.push({ 187 | p: 'This schema allows additional properties.', 188 | }) 189 | } 190 | return table 191 | } else { 192 | return { p: 'Hmm, no properties found in this schema' } 193 | } 194 | } 195 | 196 | const schemaNameHeading = (name: string, version: string) => 197 | `${name}@${version}` 198 | 199 | export const documentObjectSchema = ( 200 | schema: ObjectSchema, 201 | schemas?: SchemaCollection, 202 | formats?: CustomFormats, 203 | ) => { 204 | const schemaName = schema.schema.title 205 | 206 | if (schemaName.includes(' ')) { 207 | throw new Error(`Schema title contains spaces "${schemaName}" 208 | This can cause problems generating anchors!`) 209 | } 210 | 211 | const schemaVersion = semverToString(schema.version) 212 | 213 | const start: object[] = [ 214 | { h3: schemaNameHeading(normalizeName(schemaName), schemaVersion) }, 215 | ] 216 | if (schema.package) { 217 | start.push({ 218 | p: `Defined in ${ticks(schema.package)}`, 219 | }) 220 | } 221 | if (schema.schema.description) { 222 | start.push({ p: schema.schema.description }) 223 | } 224 | 225 | if (schema.schema.deprecated) { 226 | start.push({ 227 | p: `**deprecated** ${schema.schema.deprecated}`, 228 | }) 229 | } 230 | 231 | const propertiesTable = documentSchema(schema.schema, schemas, formats) 232 | 233 | const exampleFragment = flatten([ 234 | { p: 'Example:' }, 235 | { 236 | code: { 237 | language: 'json', 238 | content: stringify(schema.example, { space: ' ' }), 239 | }, 240 | }, 241 | ]) 242 | 243 | return flatten(start.concat(propertiesTable).concat(exampleFragment)) 244 | } 245 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import validator from 'is-my-json-valid' 2 | import debugApi from 'debug' 3 | import stringify from 'json-stable-stringify' 4 | import { get, set } from 'lodash' 5 | import { 6 | clone, 7 | curry, 8 | difference, 9 | filter, 10 | find, 11 | keys, 12 | map, 13 | mergeAll, 14 | mergeDeepLeft, 15 | prop, 16 | uniq, 17 | uniqBy, 18 | whereEq, 19 | } from 'ramda' 20 | import { fill } from './fill' 21 | import { 22 | CustomFormats, 23 | detectors, 24 | FormatDefaults, 25 | getDefaults, 26 | JsonSchemaFormats, 27 | } from './formats' 28 | import { 29 | JsonSchema, 30 | ObjectSchema, 31 | PlainObject, 32 | SchemaCollection, 33 | SchemaVersion, 34 | } from './objects' 35 | import { sanitize } from './sanitize' 36 | import { trim } from './trim' 37 | import * as utils from './utils' 38 | 39 | const debug = debugApi('schema-tools') 40 | 41 | export const getVersionedSchema = 42 | (schemas: SchemaCollection) => (name: string) => { 43 | name = utils.normalizeName(name) 44 | return schemas[name] 45 | } 46 | 47 | const _getObjectSchema = ( 48 | schemas: SchemaCollection, 49 | schemaName: string, 50 | version: SchemaVersion, 51 | ): ObjectSchema | undefined => { 52 | schemaName = utils.normalizeName(schemaName) 53 | 54 | const namedSchemas = schemas[schemaName] 55 | if (!namedSchemas) { 56 | debug('missing schema %s', schemaName) 57 | return 58 | } 59 | return namedSchemas[version] as ObjectSchema 60 | } 61 | 62 | /** 63 | * Returns object schema given a name and a version. Curried. 64 | * @returns an object or undefined 65 | * @example 66 | * getObjectSchema(schemas, 'membershipInvitation', '1.0.0') 67 | * getObjectSchema(schemas)('membershipInvitation')('1.0.0') 68 | */ 69 | export const getObjectSchema = curry(_getObjectSchema) 70 | 71 | const _hasSchema = ( 72 | schemas: SchemaCollection, 73 | schemaName: string, 74 | version: SchemaVersion, 75 | ): boolean => Boolean(_getObjectSchema(schemas, schemaName, version)) 76 | 77 | /** 78 | * Returns true if the given schema collection has schema by 79 | * name and version. Curried. 80 | * @returns `true` if there is a schema with such name and version 81 | * @example 82 | * getObjectSchema(schemas, 'membershipInvitation', '1.0.0') // true 83 | * getObjectSchema(schemas)('fooBarBaz', '1.0.0') // false 84 | */ 85 | export const hasSchema = curry(_hasSchema) 86 | 87 | /** 88 | * Returns normalized names of all schemas 89 | * 90 | * @example schemaNames() //> ['membershipInvitation', 'somethingElse', ...] 91 | */ 92 | export const schemaNames = (schemas: SchemaCollection) => 93 | Object.keys(schemas).sort() 94 | 95 | /** 96 | * Returns list of version strings available for given schema name. 97 | * 98 | * If there is no such schema, returns empty list. 99 | * 100 | * @param schemaName Schema name to look up 101 | */ 102 | export const getSchemaVersions = 103 | (schemas: SchemaCollection) => (schemaName: string) => { 104 | schemaName = utils.normalizeName(schemaName) 105 | if (schemas[schemaName]) { 106 | return Object.keys(schemas[schemaName]) 107 | } 108 | return [] 109 | } 110 | 111 | /** 112 | * Returns our example for a schema with given version. Curried 113 | * @example getExample('membershipInvitation')('1.0.0') 114 | * // {id: '...', email: '...', role: '...'} 115 | */ 116 | export const getExample = curry( 117 | (schemas: SchemaCollection, schemaName: string, version: SchemaVersion) => { 118 | const o = getObjectSchema(schemas)(schemaName)(version) 119 | if (!o) { 120 | debug('could not find object schema %s@%s', schemaName, version) 121 | return 122 | } 123 | return o.example 124 | }, 125 | ) 126 | 127 | /** 128 | * Error returned by the json validation library. 129 | * Has an error message for specific property 130 | */ 131 | type ValidationError = { 132 | field: string 133 | message: string 134 | } 135 | 136 | const dataHasAdditionalPropertiesValidationError = { 137 | field: 'data', 138 | message: 'has additional properties', 139 | } 140 | 141 | const findDataHasAdditionalProperties = find( 142 | whereEq(dataHasAdditionalPropertiesValidationError), 143 | ) 144 | 145 | const includesDataHasAdditionalPropertiesError = ( 146 | errors: ValidationError[], 147 | ): boolean => findDataHasAdditionalProperties(errors) !== undefined 148 | 149 | const errorToString = (error: ValidationError): string => 150 | `${error.field} ${error.message}` 151 | 152 | /** 153 | * Flattens validation errors into user-friendlier strings 154 | */ 155 | 156 | const errorsToStrings = (errors: ValidationError[]): string[] => 157 | errors.map(errorToString) 158 | 159 | /** 160 | * Validates given object using JSON schema. Returns either 'true' or list of string errors 161 | */ 162 | export const validateBySchema = 163 | (schema: JsonSchema, formats?: JsonSchemaFormats, greedy: boolean = true) => 164 | (object: object): true | string[] => { 165 | // TODO this could be cached, or even be part of the loaded module 166 | // when validating use our additional formats, like "uuid" 167 | const validate = validator(schema, { formats, greedy }) 168 | if (validate(object)) { 169 | return true 170 | } 171 | 172 | const uniqueErrors: ValidationError[] = uniqBy( 173 | errorToString, 174 | validate.errors, 175 | ) 176 | 177 | if ( 178 | includesDataHasAdditionalPropertiesError(uniqueErrors) && 179 | keys(schema.properties).length 180 | ) { 181 | const hasData: ValidationError = findDataHasAdditionalProperties( 182 | uniqueErrors, 183 | ) as ValidationError 184 | const additionalProperties: string[] = difference( 185 | keys(object), 186 | keys(schema.properties), 187 | ) 188 | hasData.message += ': ' + additionalProperties.join(', ') 189 | } 190 | 191 | const errors = uniq(errorsToStrings(uniqueErrors)) 192 | return errors 193 | } 194 | 195 | /** 196 | * Validates an object against given schema and version 197 | * 198 | * @param {string} schemaName 199 | * @param {object} object 200 | * @param {string} version 201 | * @returns {(true | string[])} If there are no errors returns true. 202 | * If there are any validation errors returns list of strings 203 | * 204 | */ 205 | export const validate = 206 | ( 207 | schemas: SchemaCollection, 208 | formats?: JsonSchemaFormats, 209 | greedy: boolean = true, 210 | ) => 211 | (schemaName: string, version: string) => 212 | (object: object): true | string[] => { 213 | schemaName = utils.normalizeName(schemaName) 214 | 215 | const namedSchemas = schemas[schemaName] 216 | if (!namedSchemas) { 217 | return [`Missing schema ${schemaName}`] 218 | } 219 | 220 | const aSchema = namedSchemas[version] as ObjectSchema 221 | if (!aSchema) { 222 | return [`Missing schema ${schemaName}@${version}`] 223 | } 224 | 225 | // TODO this could be cached, or even be part of the loaded module 226 | // when validating use our additional formats, like "uuid" 227 | return validateBySchema(aSchema.schema, formats, greedy)(object) 228 | } 229 | 230 | /** 231 | * Error thrown when an object does not pass schema. 232 | * 233 | * @export 234 | * @class SchemaError 235 | * @extends {Error} 236 | */ 237 | export class SchemaError extends Error { 238 | /** 239 | * List of individual errors 240 | * 241 | * @type {string[]} 242 | * @memberof SchemaError 243 | */ 244 | errors: string[] 245 | 246 | /** 247 | * Current object being validated 248 | * 249 | * @type {PlainObject} 250 | * @memberof SchemaError 251 | */ 252 | object: PlainObject 253 | 254 | /** 255 | * Example object from the schema 256 | * 257 | * @type {PlainObject} 258 | * @memberof SchemaError 259 | */ 260 | example: PlainObject 261 | 262 | /** 263 | * Name of the schema that failed 264 | * 265 | * @type {string} 266 | * @memberof SchemaError 267 | */ 268 | schemaName: string 269 | 270 | /** 271 | * Version of the schema violated 272 | * 273 | * @type {string} 274 | * @memberof SchemaError 275 | */ 276 | schemaVersion?: string 277 | 278 | constructor( 279 | message: string, 280 | errors: string[], 281 | object: PlainObject, 282 | example: PlainObject, 283 | schemaName: string, 284 | schemaVersion?: string, 285 | ) { 286 | super(message) 287 | Object.setPrototypeOf(this, new.target.prototype) 288 | this.errors = errors 289 | this.object = object 290 | this.example = example 291 | this.schemaName = schemaName 292 | if (schemaVersion) { 293 | this.schemaVersion = schemaVersion 294 | } 295 | } 296 | } 297 | 298 | type ErrorMessageWhiteList = { 299 | errors: boolean 300 | object: boolean 301 | example: boolean 302 | } 303 | 304 | type AssertBySchemaOptions = { 305 | greedy: boolean 306 | substitutions: string[] 307 | omit: Partial 308 | } 309 | 310 | const AssertBySchemaDefaults: AssertBySchemaOptions = { 311 | greedy: true, 312 | substitutions: [], 313 | omit: { 314 | errors: false, 315 | object: false, 316 | example: false, 317 | }, 318 | } 319 | 320 | export const assertBySchema = 321 | ( 322 | schema: JsonSchema, 323 | example: PlainObject = {}, 324 | options?: Partial, 325 | label?: string, 326 | formats?: JsonSchemaFormats, 327 | schemaVersion?: SchemaVersion, 328 | ) => 329 | (object: PlainObject) => { 330 | const allOptions = mergeDeepLeft( 331 | options || AssertBySchemaDefaults, 332 | AssertBySchemaDefaults, 333 | ) 334 | 335 | const replace = () => { 336 | const cloned = clone(object) 337 | allOptions.substitutions.forEach((property) => { 338 | const value = get(example, property) 339 | set(cloned, property, value) 340 | }) 341 | return cloned 342 | } 343 | 344 | const replaced = allOptions.substitutions.length ? replace() : object 345 | const result = validateBySchema( 346 | schema, 347 | formats, 348 | allOptions.greedy, 349 | )(replaced) 350 | if (result === true) { 351 | return object 352 | } 353 | 354 | const title = label ? `Schema ${label} violated` : 'Schema violated' 355 | const emptyLine = '' 356 | let parts = [title] 357 | 358 | if (!allOptions.omit.errors) { 359 | parts = parts.concat([emptyLine, 'Errors:']).concat(result) 360 | } 361 | 362 | if (!allOptions.omit.object) { 363 | const objectString = stringify(replaced, { space: ' ' }) 364 | parts = parts.concat([emptyLine, 'Current object:', objectString]) 365 | } 366 | 367 | if (!allOptions.omit.example) { 368 | const exampleString = stringify(example, { space: ' ' }) 369 | parts = parts.concat([ 370 | emptyLine, 371 | 'Expected object like this:', 372 | exampleString, 373 | ]) 374 | } 375 | 376 | const message = parts.join('\n') 377 | 378 | throw new SchemaError( 379 | message, 380 | result, 381 | replaced, 382 | example, 383 | schema.title, 384 | schemaVersion, 385 | ) 386 | } 387 | 388 | /** 389 | * Validates given object against a schema, throws an error if schema 390 | * has been violated. Returns the original object if everything is ok. 391 | * 392 | * @param name Schema name 393 | * @param version Schema version 394 | * @param substitutions Replaces specific properties with values from example object 395 | * @example getOrganization() 396 | * .then(assertSchema('organization', '1.0.0')) 397 | * .then(useOrganization) 398 | * @example getOrganization() 399 | * // returns {id: 'foo', ...} 400 | * // but will check {id: '931...', ...} 401 | * // where "id" is taken from this schema example object 402 | * .then(assertSchema('organization', '1.0.0', ['id'])) 403 | * .then(useOrganization) 404 | */ 405 | export const assertSchema = 406 | (schemas: SchemaCollection, formats?: JsonSchemaFormats) => 407 | (name: string, version: string, options?: Partial) => 408 | (object: PlainObject) => { 409 | const example = getExample(schemas)(name)(version) 410 | const schema = getObjectSchema(schemas)(name)(version) 411 | if (!schema) { 412 | throw new Error(`Could not find schema ${name}@${version}`) 413 | } 414 | // TODO we can read title and description from the JSON schema itself 415 | // so external label would not be necessary 416 | const label = `${name}@${version}` 417 | return assertBySchema( 418 | schema.schema, 419 | example, 420 | options, 421 | label, 422 | formats, 423 | utils.semverToString(schema.version), 424 | )(object) 425 | } 426 | 427 | type BindOptions = { 428 | schemas: SchemaCollection 429 | formats?: CustomFormats 430 | } 431 | 432 | const mergeSchemas = (schemas: SchemaCollection[]): SchemaCollection => 433 | mergeAll(schemas) 434 | 435 | const mergeFormats = (formats: CustomFormats[]): CustomFormats => 436 | mergeAll(formats) 437 | 438 | const exists = (x) => Boolean(x) 439 | 440 | /** 441 | * Given schemas and formats creates "mini" API bound to the these schemas. 442 | * Can take multiple schemas and merged them all, and merges formats. 443 | */ 444 | export const bind = (...options: BindOptions[]) => { 445 | const allSchemas: SchemaCollection[] = map(prop('schemas'), options) 446 | const schemas = mergeSchemas(allSchemas) 447 | 448 | const allFormats: CustomFormats[] = filter( 449 | exists, 450 | map(prop('formats'), options as Required[]), 451 | ) 452 | const formats = mergeFormats(allFormats) 453 | 454 | const formatDetectors = detectors(formats) 455 | 456 | const defaults: FormatDefaults = getDefaults(formats) 457 | 458 | const api = { 459 | assertSchema: assertSchema(schemas, formatDetectors), 460 | schemaNames: schemaNames(schemas), 461 | getExample: getExample(schemas), 462 | sanitize: sanitize(schemas, defaults), 463 | validate: validate(schemas), 464 | trim: trim(schemas), 465 | hasSchema: hasSchema(schemas), 466 | fill: fill(schemas), 467 | } 468 | return api 469 | } 470 | -------------------------------------------------------------------------------- /test/snapshots/document-test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `test/document-test.ts` 2 | 3 | The actual snapshot is saved in `document-test.ts.snap`. 4 | 5 | Generated by [AVA](https://ava.li). 6 | 7 | ## JSON schema object to Markdown 8 | 9 | > Snapshot 1 10 | 11 | `name | type␊ 12 | --- | ---␊ 13 | `id` | string␊ 14 | `name` | string␊ 15 | ` 16 | 17 | ## JSON schema object to Markdown object 18 | 19 | > Snapshot 1 20 | 21 | [ 22 | { 23 | table: { 24 | headers: [ 25 | 'name', 26 | 'type', 27 | ], 28 | rows: [ 29 | { 30 | defaultValue: '', 31 | deprecated: '', 32 | description: '', 33 | enum: '', 34 | format: '', 35 | maxLength: '', 36 | minLength: '', 37 | name: '`id`', 38 | required: '', 39 | type: 'string', 40 | }, 41 | { 42 | defaultValue: '', 43 | deprecated: '', 44 | description: '', 45 | enum: '', 46 | format: '', 47 | maxLength: '', 48 | minLength: '', 49 | name: '`name`', 50 | required: '', 51 | type: 'string', 52 | }, 53 | ], 54 | }, 55 | }, 56 | ] 57 | 58 | ## JSON schema with enumeration to Markdown 59 | 60 | > Snapshot 1 61 | 62 | `name | type | enum␊ 63 | --- | --- | ---␊ 64 | `id` | string | ␊ 65 | `name` | string | `"joe", "mary"`␊ 66 | ` 67 | 68 | ## custom formats 69 | 70 | > Snapshot 1 71 | 72 | [ 73 | { 74 | h2: 'formats', 75 | }, 76 | { 77 | p: 'Custom formats defined to better represent our data.', 78 | }, 79 | { 80 | table: { 81 | headers: [ 82 | 'name', 83 | 'regular expression', 84 | ], 85 | rows: [ 86 | { 87 | default: '', 88 | dynamic: '', 89 | example: '', 90 | name: 'my-foo', 91 | 'regular expression': '`/^foo$/`', 92 | }, 93 | ], 94 | }, 95 | }, 96 | ] 97 | 98 | > Snapshot 2 99 | 100 | `## formats␊ 101 | ␊ 102 | ␊ 103 | Custom formats defined to better represent our data.␊ 104 | ␊ 105 | name | regular expression␊ 106 | --- | ---␊ 107 | my-foo | `/^foo$/`␊ 108 | ` 109 | 110 | ## document deprecated schema 111 | 112 | > Snapshot 1 113 | 114 | `### testSchema@1.2.3␊ 115 | ␊ 116 | ␊ 117 | This is a test schema␊ 118 | ␊ 119 | ␊ 120 | **deprecated** no longer in use␊ 121 | ␊ 122 | name | type | enum␊ 123 | --- | --- | ---␊ 124 | `id` | string | ␊ 125 | `name` | string | `"joe", "mary"`␊ 126 | ␊ 127 | ␊ 128 | Example:␊ 129 | ␊ 130 | ```json␊ 131 | {␊ 132 | "id": "abc",␊ 133 | "name": "joe"␊ 134 | }␊ 135 | ```␊ 136 | ` 137 | 138 | ## document deprecated schema property 139 | 140 | > Snapshot 1 141 | 142 | `### testSchema@1.2.3␊ 143 | ␊ 144 | ␊ 145 | This is a test schema␊ 146 | ␊ 147 | name | type | enum | deprecated␊ 148 | --- | --- | --- | ---␊ 149 | `id` | string | | ␊ 150 | `name` | string | `"joe", "mary"` | **deprecated** use property "fullName" instead␊ 151 | ␊ 152 | ␊ 153 | This schema allows additional properties.␊ 154 | ␊ 155 | ␊ 156 | Example:␊ 157 | ␊ 158 | ```json␊ 159 | {␊ 160 | "id": "abc",␊ 161 | "name": "joe"␊ 162 | }␊ 163 | ```␊ 164 | ` 165 | 166 | ## document properties 167 | 168 | > Snapshot 1 169 | 170 | [ 171 | { 172 | defaultValue: '', 173 | deprecated: '', 174 | description: 'Can only be choice a or b', 175 | enum: '`"A", "B"`', 176 | format: '', 177 | maxLength: '', 178 | minLength: '', 179 | name: '`bar`', 180 | required: '', 181 | type: 'string', 182 | }, 183 | { 184 | defaultValue: '', 185 | deprecated: '', 186 | description: 'Property foo', 187 | enum: '', 188 | format: '', 189 | maxLength: '', 190 | minLength: '', 191 | name: '`foo`', 192 | required: '', 193 | type: 'string', 194 | }, 195 | ] 196 | 197 | ## document property with explicit default value 198 | 199 | > Snapshot 1 200 | 201 | { 202 | property: { 203 | defaultValue: 'foo Bar', 204 | type: 'string', 205 | }, 206 | result: { 207 | defaultValue: 'foo Bar', 208 | deprecated: '', 209 | description: '', 210 | enum: '', 211 | format: '', 212 | maxLength: '', 213 | minLength: '', 214 | name: '`test property`', 215 | required: '', 216 | type: 'string', 217 | }, 218 | } 219 | 220 | ## document property with minLength and maxLength 221 | 222 | > Snapshot 1 223 | 224 | { 225 | property: { 226 | maxLength: 20, 227 | minLength: 5, 228 | type: 'string', 229 | }, 230 | result: { 231 | defaultValue: '', 232 | deprecated: '', 233 | description: '', 234 | enum: '', 235 | format: '', 236 | maxLength: '20', 237 | minLength: '5', 238 | name: '`test property`', 239 | required: '', 240 | type: 'string', 241 | }, 242 | } 243 | 244 | ## document property with minLength and maxLength at 0 245 | 246 | > Snapshot 1 247 | 248 | { 249 | property: { 250 | maxLength: 0, 251 | minLength: 0, 252 | type: 'string', 253 | }, 254 | result: { 255 | defaultValue: '', 256 | deprecated: '', 257 | description: '', 258 | enum: '', 259 | format: '', 260 | maxLength: '0', 261 | minLength: '0', 262 | name: '`test property`', 263 | required: '', 264 | type: 'string', 265 | }, 266 | } 267 | 268 | ## documents just schemas 269 | 270 | > Snapshot 1 271 | 272 | `# Schemas␊ 273 | ␊ 274 | ␊ 275 | - [car](#car) - [1.0.0](#car100), [1.1.0](#car110)␊ 276 | - [person](#person) - [1.0.0](#person100), [1.1.0](#person110), [1.2.0](#person120)␊ 277 | - [team](#team)␊ 278 | ␊ 279 | ## car␊ 280 | ␊ 281 | ### car@1.0.0␊ 282 | ␊ 283 | ␊ 284 | A motor vehicle␊ 285 | ␊ 286 | name | type | required␊ 287 | --- | --- | ---␊ 288 | `color` | string | ✔␊ 289 | ␊ 290 | ␊ 291 | Example:␊ 292 | ␊ 293 | ```json␊ 294 | {␊ 295 | "color": "red"␊ 296 | }␊ 297 | ```␊ 298 | ␊ 299 | ␊ 300 | [🔝](#schemas)␊ 301 | ␊ 302 | ### car@1.1.0␊ 303 | ␊ 304 | ␊ 305 | A motor vehicle␊ 306 | ␊ 307 | name | type | required␊ 308 | --- | --- | ---␊ 309 | `color` | string | ✔␊ 310 | `doors` | number | ␊ 311 | ␊ 312 | ␊ 313 | Example:␊ 314 | ␊ 315 | ```json␊ 316 | {␊ 317 | "color": "red",␊ 318 | "doors": 2␊ 319 | }␊ 320 | ```␊ 321 | ␊ 322 | ␊ 323 | [🔝](#schemas)␊ 324 | ␊ 325 | ## person␊ 326 | ␊ 327 | ### person@1.0.0␊ 328 | ␊ 329 | ␊ 330 | An example schema describing a person␊ 331 | ␊ 332 | name | type | required | format | description | minLength␊ 333 | --- | --- | --- | --- | --- | ---␊ 334 | `age` | integer | ✔ | | Age in years | ␊ 335 | `name` | string | ✔ | `name` | this person needs a name | 2␊ 336 | ␊ 337 | ␊ 338 | Example:␊ 339 | ␊ 340 | ```json␊ 341 | {␊ 342 | "age": 10,␊ 343 | "name": "Joe"␊ 344 | }␊ 345 | ```␊ 346 | ␊ 347 | ␊ 348 | [🔝](#schemas)␊ 349 | ␊ 350 | ### person@1.1.0␊ 351 | ␊ 352 | ␊ 353 | Person with title␊ 354 | ␊ 355 | name | type | required | format | description | minLength␊ 356 | --- | --- | --- | --- | --- | ---␊ 357 | `age` | integer | ✔ | | Age in years | ␊ 358 | `name` | string | ✔ | `name` | this person needs a name | 2␊ 359 | `title` | string | | | How to address this person | ␊ 360 | ␊ 361 | ␊ 362 | Example:␊ 363 | ␊ 364 | ```json␊ 365 | {␊ 366 | "age": 10,␊ 367 | "name": "Joe",␊ 368 | "title": "mr"␊ 369 | }␊ 370 | ```␊ 371 | ␊ 372 | ␊ 373 | [🔝](#schemas)␊ 374 | ␊ 375 | ### person@1.2.0␊ 376 | ␊ 377 | ␊ 378 | Person with traits␊ 379 | ␊ 380 | name | type | required | format | description | minLength␊ 381 | --- | --- | --- | --- | --- | ---␊ 382 | `age` | integer | ✔ | | Age in years | ␊ 383 | `name` | string | ✔ | `name` | this person needs a name | 2␊ 384 | `title` | string | | | How to address this person | ␊ 385 | `traits` | object | | `Traits@1.0.0` | Physical traits of person | ␊ 386 | ␊ 387 | ␊ 388 | Example:␊ 389 | ␊ 390 | ```json␊ 391 | {␊ 392 | "age": 10,␊ 393 | "name": "Joe",␊ 394 | "title": "mr",␊ 395 | "traits": {␊ 396 | "eyeColor": "brown",␊ 397 | "hairColor": "black"␊ 398 | }␊ 399 | }␊ 400 | ```␊ 401 | ␊ 402 | ␊ 403 | [🔝](#schemas)␊ 404 | ␊ 405 | ## team␊ 406 | ␊ 407 | ### team@1.0.0␊ 408 | ␊ 409 | ␊ 410 | A team of people␊ 411 | ␊ 412 | name | type | format␊ 413 | --- | --- | ---␊ 414 | `people` | array | [Person@1.0.0](#person100)␊ 415 | ␊ 416 | ␊ 417 | Example:␊ 418 | ␊ 419 | ```json␊ 420 | {␊ 421 | "people": [␊ 422 | {␊ 423 | "age": 10,␊ 424 | "name": "Joe"␊ 425 | }␊ 426 | ]␊ 427 | }␊ 428 | ```␊ 429 | ␊ 430 | ␊ 431 | [🔝](#schemas)␊ 432 | ` 433 | 434 | ## documents schemas and custom formats 435 | 436 | > Snapshot 1 437 | 438 | `# Schemas␊ 439 | ␊ 440 | ␊ 441 | - [car](#car) - [1.0.0](#car100), [1.1.0](#car110)␊ 442 | - [person](#person) - [1.0.0](#person100), [1.1.0](#person110), [1.2.0](#person120)␊ 443 | - [team](#team)␊ 444 | ␊ 445 | ## car␊ 446 | ␊ 447 | ### car@1.0.0␊ 448 | ␊ 449 | ␊ 450 | A motor vehicle␊ 451 | ␊ 452 | name | type | required␊ 453 | --- | --- | ---␊ 454 | `color` | string | ✔␊ 455 | ␊ 456 | ␊ 457 | Example:␊ 458 | ␊ 459 | ```json␊ 460 | {␊ 461 | "color": "red"␊ 462 | }␊ 463 | ```␊ 464 | ␊ 465 | ␊ 466 | [🔝](#schemas)␊ 467 | ␊ 468 | ### car@1.1.0␊ 469 | ␊ 470 | ␊ 471 | A motor vehicle␊ 472 | ␊ 473 | name | type | required␊ 474 | --- | --- | ---␊ 475 | `color` | string | ✔␊ 476 | `doors` | number | ␊ 477 | ␊ 478 | ␊ 479 | Example:␊ 480 | ␊ 481 | ```json␊ 482 | {␊ 483 | "color": "red",␊ 484 | "doors": 2␊ 485 | }␊ 486 | ```␊ 487 | ␊ 488 | ␊ 489 | [🔝](#schemas)␊ 490 | ␊ 491 | ## person␊ 492 | ␊ 493 | ### person@1.0.0␊ 494 | ␊ 495 | ␊ 496 | An example schema describing a person␊ 497 | ␊ 498 | name | type | required | format | description | minLength␊ 499 | --- | --- | --- | --- | --- | ---␊ 500 | `age` | integer | ✔ | | Age in years | ␊ 501 | `name` | string | ✔ | [name](#formats) | this person needs a name | 2␊ 502 | ␊ 503 | ␊ 504 | Example:␊ 505 | ␊ 506 | ```json␊ 507 | {␊ 508 | "age": 10,␊ 509 | "name": "Joe"␊ 510 | }␊ 511 | ```␊ 512 | ␊ 513 | ␊ 514 | [🔝](#schemas)␊ 515 | ␊ 516 | ### person@1.1.0␊ 517 | ␊ 518 | ␊ 519 | Person with title␊ 520 | ␊ 521 | name | type | required | format | description | minLength␊ 522 | --- | --- | --- | --- | --- | ---␊ 523 | `age` | integer | ✔ | | Age in years | ␊ 524 | `name` | string | ✔ | [name](#formats) | this person needs a name | 2␊ 525 | `title` | string | | | How to address this person | ␊ 526 | ␊ 527 | ␊ 528 | Example:␊ 529 | ␊ 530 | ```json␊ 531 | {␊ 532 | "age": 10,␊ 533 | "name": "Joe",␊ 534 | "title": "mr"␊ 535 | }␊ 536 | ```␊ 537 | ␊ 538 | ␊ 539 | [🔝](#schemas)␊ 540 | ␊ 541 | ### person@1.2.0␊ 542 | ␊ 543 | ␊ 544 | Person with traits␊ 545 | ␊ 546 | name | type | required | format | description | minLength␊ 547 | --- | --- | --- | --- | --- | ---␊ 548 | `age` | integer | ✔ | | Age in years | ␊ 549 | `name` | string | ✔ | [name](#formats) | this person needs a name | 2␊ 550 | `title` | string | | | How to address this person | ␊ 551 | `traits` | object | | `Traits@1.0.0` | Physical traits of person | ␊ 552 | ␊ 553 | ␊ 554 | Example:␊ 555 | ␊ 556 | ```json␊ 557 | {␊ 558 | "age": 10,␊ 559 | "name": "Joe",␊ 560 | "title": "mr",␊ 561 | "traits": {␊ 562 | "eyeColor": "brown",␊ 563 | "hairColor": "black"␊ 564 | }␊ 565 | }␊ 566 | ```␊ 567 | ␊ 568 | ␊ 569 | [🔝](#schemas)␊ 570 | ␊ 571 | ## team␊ 572 | ␊ 573 | ### team@1.0.0␊ 574 | ␊ 575 | ␊ 576 | A team of people␊ 577 | ␊ 578 | name | type | format␊ 579 | --- | --- | ---␊ 580 | `people` | array | [Person@1.0.0](#person100)␊ 581 | ␊ 582 | ␊ 583 | Example:␊ 584 | ␊ 585 | ```json␊ 586 | {␊ 587 | "people": [␊ 588 | {␊ 589 | "age": 10,␊ 590 | "name": "Joe"␊ 591 | }␊ 592 | ]␊ 593 | }␊ 594 | ```␊ 595 | ␊ 596 | ␊ 597 | [🔝](#schemas)␊ 598 | ␊ 599 | ## formats␊ 600 | ␊ 601 | ␊ 602 | Custom formats defined to better represent our data.␊ 603 | ␊ 604 | name | regular expression | dynamic | default␊ 605 | --- | --- | --- | ---␊ 606 | name | `/^[A-Z][a-z]+$/` | ✔ | `"Buddy"`␊ 607 | ␊ 608 | ␊ 609 | [🔝](#schemas)␊ 610 | ` 611 | 612 | ## filters unused columns 613 | 614 | > Snapshot 1 615 | 616 | [ 617 | 'first', 618 | 'third', 619 | ] 620 | 621 | ## sublist 622 | 623 | > Snapshot 1 624 | 625 | `␊ 626 | - top level␊ 627 | - inner level 1␊ 628 | - inner level 2␊ 629 | ␊ 630 | ` 631 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @cypress/schema-tools [![CircleCI](https://circleci.com/gh/cypress-io/schema-tools.svg?style=svg&circle-token=aa9b52bab9e9216699ba7258929f727b06b13afe)](https://circleci.com/gh/cypress-io/schema-tools) [![renovate-app badge][renovate-badge]][renovate-app] 2 | 3 | > Validate, sanitize and document [JSON schemas][json-schema] 4 | 5 | ## Motivation 6 | 7 | Explicit [JSON schemas][json-schema] describing objects passed around in your system are good! 8 | 9 | - they are a living testable documentation instead of manual Wiki editing 10 | - provide examples for tests and integrations 11 | - validate inputs and outputs of the API calls 12 | 13 | ## TOC 14 | 15 | - [Schemas](#schemas) 16 | - [Formats](#formats) 17 | - [API](#api) 18 | - [Debugging](#debugging), [testing](#testing) and [license](#license) 19 | 20 | ## Schemas 21 | 22 | Each individual schema object should have 3 parts: a version, an example and a [JSON schema][json-schema] describing its properties. See [test/example-schemas.ts](test/example-schemas.ts). Start with a single `ObjectSchema` that describes a particular version of an object 23 | 24 | ```typescript 25 | import { ObjectSchema } from '@cypress/schema-tools' 26 | const person100: ObjectSchema = { 27 | // has semantic version numbers 28 | version: { 29 | major: 1, 30 | minor: 0, 31 | patch: 0, 32 | }, 33 | // JSON schema 34 | schema: { 35 | type: 'object', 36 | title: 'Person', 37 | description: 'An example schema describing a person', 38 | properties: { 39 | name: { 40 | type: 'string', 41 | format: 'name', 42 | description: 'this person needs a name', 43 | }, 44 | age: { 45 | type: 'integer', 46 | minimum: 0, 47 | description: 'Age in years', 48 | }, 49 | }, 50 | required: ['name', 'age'], 51 | // note: you can just use required: true to require all properties 52 | }, 53 | // has typical example 54 | example: { 55 | name: 'Joe', 56 | age: 10, 57 | }, 58 | } 59 | ``` 60 | 61 | You can have multiple separate versions of the "Person" schema, and then combine them into single object. 62 | 63 | ```typescript 64 | import {ObjectSchema, VersionedSchema, versionSchemas} from '@cypress/schema-tools' 65 | const person100: ObjectSchema = { ... } 66 | // maybe added another property 67 | const person110: ObjectSchema = { ... } 68 | // some big changes 69 | const person200: ObjectSchema = { ... } 70 | const personVersions: VersionedSchema = versionSchemas(person100, person110, person200) 71 | ``` 72 | 73 | Finally, you probably have "Person" versioned schema, and maybe "Organization" and maybe some other schemas. So put them into a single collection 74 | 75 | ```typescript 76 | import { SchemaCollection, combineSchemas } from '@cypress/schema-tools' 77 | export const schemas: SchemaCollection = combineSchemas( 78 | personVersions, 79 | organizationVersions, 80 | ) 81 | ``` 82 | 83 | Now you can use the `schemas` object to validate and sanitize any object. 84 | 85 | ## Formats 86 | 87 | In addition to the [formats included with JSON-schemas](https://spacetelescope.github.io/understanding-json-schema/reference/string.html#built-in-formats) you can define custom formats that will be used to validate values. Start with a single custom format to describe an UUID for example 88 | 89 | ```typescript 90 | // single custom format 91 | import { CustomFormat, CustomFormats } from '@cypress/schema-tools' 92 | const uuid: CustomFormat = { 93 | name: 'uuid', // the name 94 | description: 'GUID used through the system', 95 | // regular expression to use to validate value 96 | detect: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 97 | // (optional) replace actual value with this default value 98 | // when using to sanitize an object 99 | defaultValue: 'ffffffff-ffff-ffff-ffff-ffffffffffff', 100 | } 101 | // export all custom formats, in our case just 1 102 | export const formats: CustomFormats = { uuid } 103 | ``` 104 | 105 | Now every time you use your schemas, pass the formats too so that the validator knows how to check values from custom formats. 106 | 107 | ```typescript 108 | // example JSON schema using uuid custom format 109 | const employee100: ObjectSchema = { 110 | // has semantic version numbers 111 | version: { 112 | major: 1, 113 | minor: 0, 114 | patch: 0, 115 | }, 116 | // JSON schema 117 | schema: { 118 | type: 'object', 119 | title: 'Employee', 120 | properties: { 121 | id: { 122 | type: 'string', 123 | format: 'uuid', 124 | }, 125 | }, 126 | }, 127 | example: { 128 | id: 'a368dbfd-08e4-4698-b9a3-b2b660a11835', 129 | }, 130 | } 131 | // employee100 goes into "schemas", then 132 | assertSchema(schemas, formats)('Employee', '1.0.0')(someObject) 133 | ``` 134 | 135 | ## API 136 | 137 | - [hasSchema](#hasschema) 138 | - [documentSchemas](#documentschemas) 139 | - [validate](#validate) 140 | - [assertSchema](#assertschema) 141 | - [trim](#trim) 142 | - [fill](#fill) 143 | - [sanitize](#sanitize) 144 | - [bind](#bind) 145 | - [SchemaError](#schemaerror) 146 | - [addProperty](#addproperty) 147 | - [extend](#extend) 148 | - [oneOfRegex](#oneofregex) 149 | 150 | ### hasSchema 151 | 152 | Returns `true` if the given schema exists in the collection. Curried function. 153 | 154 | ```typescript 155 | import { hasSchema } from '@cypress/schema-tools' 156 | import { schemas } from './schemas' 157 | hasSchema(schemas, 'Name', '1.0.0') // true 158 | hasSchema(schemas)('FooBarBaz')('1.0.0') // false 159 | ``` 160 | 161 | ### documentSchemas 162 | 163 | You can document your schemas using provided method. Example code file 164 | 165 | ```typescript 166 | import { documentSchemas } from '@cypress/schema-tools' 167 | import { schemas } from './schemas' 168 | import { formats } from './formats' 169 | console.log(documentSchemas(schemas, formats)) 170 | ``` 171 | 172 | Call it from your NPM scripts 173 | 174 | ```json 175 | { 176 | "scripts": { 177 | "document": "ts-node ./document.ts > schemas.md" 178 | }, 179 | "devDependencies": { 180 | "ts-node": "5.0.1", 181 | "typescript": "2.8.1" 182 | } 183 | } 184 | ``` 185 | 186 | If you want to tell where a schema is coming from, you can set package name, which will add a note to the output Markdown 187 | 188 | ```typescript 189 | import { setPackageName, documentSchemas } from '@cypress/schema-tools' 190 | import { schemas } from './schemas' 191 | setPackageName(schemas, 'my-schemas') 192 | console.log(documentSchemas(schemas, formats)) 193 | // each schema will have a note that it was defined in "my-schemas" 194 | ``` 195 | 196 | ### validate 197 | 198 | Checks a given object against a schema and returns list of errors if the object does not pass the schema. Returns `true` if the object passes schema, and a list of strings if there are errors (I know, we should use [Either or Validation](http://folktale.origamitower.com/api/v2.1.0/en/folktale.validation.html)). 199 | 200 | ```ts 201 | import { validate } from '@cypress/schema-tools' 202 | // see example in ./test/example-schemas.ts 203 | import { schemas } from './my-schemas' 204 | import { formats } from './my-formats' 205 | const validatePerson100 = validate(schemas, formats)('person', '1.0.0') 206 | const result = validatePerson100(someObject) 207 | if (result === true) { 208 | // all good 209 | } else { 210 | const errorMessage = result.join('\n') 211 | console.error(errorMessage) 212 | } 213 | ``` 214 | 215 | Typical validation messages are 216 | 217 | ``` 218 | data.createdAt is required 219 | data.createdAt must be date-time format 220 | ``` 221 | 222 | To stop after finding initial set of errors, pass `greedy = false` flag 223 | 224 | ```js 225 | const validatePerson100 = validate(schemas, formats, false)('person', '1.0.0') 226 | ``` 227 | 228 | ### assertSchema 229 | 230 | Checks a given object against schemas (and formats) and throws a [SchemaError](#schemaerror) if the object violates the given schema. 231 | 232 | ```js 233 | try { 234 | assertSchema(schemas, formats)('Person', '1.0.0')(object) 235 | } catch (e) { 236 | console.error(e.message) 237 | // can also inspect individual fields, see SchemaError 238 | } 239 | ``` 240 | 241 | You can substitute some fields from example object to help with dynamic data. For example, to avoid breaking on invalid `id` value, we can tell `assertSchema` to use `id` value from the example object. 242 | 243 | ```js 244 | const o = { 245 | name: 'Mary', 246 | age: -1, 247 | } 248 | assertSchema(schemas, formats)('Person', '1.0.0', { 249 | substitutions: ['age'], 250 | })(o) 251 | // everything is good, because the actual object asserted was 252 | // {name: 'Mary', age: 10} 253 | ``` 254 | 255 | You can also limit the error message and omit some properties. Typically the error message with include list of errors, current and example objects, which might create a wall of text. To omit `object` and `example` but leave other fields when forming error message use 256 | 257 | ```js 258 | const o = { 259 | name: 'Mary', 260 | age: -1, 261 | } 262 | assertSchema(schemas, formats)('Person', '1.0.0', { 263 | omit: { 264 | object: true, 265 | example: true, 266 | }, 267 | })(o) 268 | // Error message is much much shorter, only "errors" and label will be there 269 | ``` 270 | 271 | By default the json schema check is [greedy](https://github.com/mafintosh/is-my-json-valid#greedy-mode-tries-to-validate-as-much-as-possible) but you can limit it via an option 272 | 273 | ```js 274 | assertSchema(schemas, formats)('Person', '1.0.0', { greedy: false }) 275 | ``` 276 | 277 | ### trim 278 | 279 | Often you have an object that has _more_ properties than the schema allows. For example if you have new result that should go to "older" clients, you might want to `trim` the result object and then assert schema. 280 | 281 | ```js 282 | import { trim } from '@cypress/schema-tools' 283 | const trimPerson = trim(schemas, 'Person', '1.0.0') 284 | const person = ... // some result with lots of properties 285 | const trimmed = trimPerson(person) 286 | // trimmed should be valid Person 1.0.0 object 287 | // if the values are actually matching Person@1.0.0 288 | // all extra properties should have been removed 289 | ``` 290 | 291 | ### fill 292 | 293 | The opposite of `trim`. Tries to fill missing object properties with explicit default values from the schema. See [test/fill-test.ts](test/fill-test.ts) for example. 294 | 295 | ### sanitize 296 | 297 | If you schema has dynamic data, like timestamps or uuids, it is impossible to compare objects without first deleting some fields, breaking the schema. To solve this you can mark some properties with format and if that format has a default value, you can replace all dynamic values with default ones. 298 | 299 | In the example below the `name` property has format called `name` like this 300 | 301 | ```js 302 | name: { 303 | type: 'string', 304 | format: 'name' 305 | } 306 | ``` 307 | 308 | Now we can sanitize any object which will replace `name` value with default value, but will keep other properties unchanged. 309 | 310 | ```js 311 | import { sanitize, getDefaults } from '@cypress/schema-tools' 312 | const name: CustomFormat = { 313 | name: 'name', 314 | description: 'Custom name format', 315 | detect: /^[A-Z][a-z]+$/, 316 | defaultValue: 'Buddy', 317 | } 318 | const exampleFormats: CustomFormats = { 319 | name, 320 | } 321 | const formatDefaults = getDefaults(exampleFormats) 322 | const object = { 323 | name: 'joe', 324 | age: 21, 325 | } 326 | const sanitizePerson = sanitize(schemas, formatDefaults)('person', '1.0.0') 327 | // now pass any object with dynamic "name" property 328 | const result = sanitizePerson(object) 329 | // result is {name: 'Buddy', age: 21} 330 | ``` 331 | 332 | For another example see [test/sanitize-test.ts](test/sanitize-test.ts) 333 | 334 | ### bind 335 | 336 | There are multiple methods to validate, assert or sanitize an object against a schema. All take schemas and (optional) formats. But a project using schema tools probably has a single collection of schemas that it wants to use again and again. The `bind` method makes it easy to bind the first argument in each function to a schema collection and just call methods with an object later. 337 | 338 | ```js 339 | import { schemas } from './my-schemas' 340 | import { formats } from './my-formats' 341 | import { bind } from '@cypress/schema-tools' 342 | const api = bind({ schemas, formats }) 343 | api.assertSchema('name', '1.0.0')(someObject) 344 | ``` 345 | 346 | See [test/bind-test.ts](test/bind-test.ts) for examples 347 | 348 | ### SchemaError 349 | 350 | When asserting an object against a schema a custom error is thrown. It is an instance of `Error`, with a very detailed message. It also has additional properties. 351 | 352 | - `errors` is the list of strings with individual validation errors 353 | - `object` the object being validated 354 | - `example` example object for the schema 355 | - `schemaName` is the title of the schema, like `Person` 356 | - `schemaVersion` the version like `1.0.0` of the schema violated, if known. 357 | 358 | ### addProperty 359 | 360 | You can easily extend existing schema using included `addProperty` function. See [src/actions.ts](src/actions.ts) and [test/add-property-test.ts](test/add-property-test.ts) for examples. 361 | 362 | ### extend 363 | 364 | Rather than add a single property at a time, you can simply use `extend(existingSchema, newSchemaObj)`. 365 | 366 | The `existingSchema` will be deep cloned and have the `newSchemaObj` properties merged in. 367 | 368 | If `newSchemaObj.version` is not provided, then the previous schema's semver `minor` property will be bumped by one. 369 | 370 | Fields like `required` are automatically unioned. 371 | 372 | See [src/actions.ts](src/actions.ts) and [test/extend-schema-test.ts](test/extend-schema-test.ts) for examples. 373 | 374 | ### oneOfRegex 375 | 376 | A little utility function to create a regular expression to match only the given strings. 377 | 378 | ```js 379 | import { oneOfRegex } from '@cypress/schema-tools' 380 | const r = oneOfRegex('foo', 'bar') 381 | r.test('foo') // true 382 | r.test('bar') // true 383 | r.toString() // "/^(foo|bar)$/" 384 | ``` 385 | 386 | ## Debugging 387 | 388 | To see log messages from this module, run with `DEBUG=schema-tools` 389 | 390 | ## Testing 391 | 392 | Uses [ava-ts](https://github.com/andywer/ava-ts#readme) to run Ava test runner directly against TypeScript test files. Use `npm t` to build and test everything in the `test` folder. 393 | 394 | To run a single test file, use command 395 | 396 | ``` 397 | npx ava-ts test/ 398 | ``` 399 | 400 | To update snapshots and use verbose reporter (prints test names) 401 | 402 | ``` 403 | npx ava-ts test/ --verbose -u 404 | ``` 405 | 406 | ## License 407 | 408 | This project is licensed under the terms of the [MIT license](LICENSE.md). 409 | 410 | [renovate-badge]: https://img.shields.io/badge/renovate-app-blue.svg 411 | [renovate-app]: https://renovateapp.com/ 412 | [json-schema]: http://json-schema.org/ 413 | --------------------------------------------------------------------------------