├── .nvmrc ├── .npmrc ├── .husky └── pre-commit ├── .gitattributes ├── .npmignore ├── eslint.config.js ├── .editorconfig ├── .github ├── dependabot.yml ├── workflows │ └── ci.yml └── stale.yml ├── src ├── BooleanSchema.js ├── IntegerSchema.js ├── NullSchema.js ├── NullSchema.test.js ├── BooleanSchema.test.js ├── MixedSchema.js ├── RawSchema.js ├── utils.test.js ├── MixedSchema.test.js ├── example.js ├── NumberSchema.js ├── schemas │ └── basic.json ├── FluentJSONSchema.js ├── ArraySchema.test.js ├── StringSchema.js ├── NumberSchema.test.js ├── ArraySchema.js ├── utils.js ├── IntegerSchema.test.js ├── RawSchema.test.js ├── StringSchema.test.js ├── ObjectSchema.js ├── BaseSchema.js ├── FluentSchema.test.js ├── FluentSchema.integration.test.js └── BaseSchema.test.js ├── LICENSE ├── package.json ├── .gitignore ├── types ├── FluentJSONSchema.d.ts └── FluentJSONSchema.test-d.ts └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v14.19.0 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Set default behavior to automatically convert line endings 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .gitignore 3 | .husky 4 | .npmignore 5 | .nvmrc 6 | .prettierrc 7 | .travis.yml 8 | yarn.lock 9 | .idea 10 | coverage 11 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('neostandard')({ 4 | ignores: require('neostandard').resolveIgnoresFromGitignore(), 5 | ts: true 6 | }) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /src/BooleanSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | 4 | const initialState = { 5 | type: 'boolean' 6 | } 7 | 8 | /** 9 | * Represents a BooleanSchema. 10 | * @param {Object} [options] - Options 11 | * @param {StringSchema} [options.schema] - Default schema 12 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 13 | * @returns {StringSchema} 14 | */ 15 | 16 | const BooleanSchema = ({ schema = initialState, ...options } = {}) => { 17 | options = { 18 | generateIds: false, 19 | factory: BaseSchema, 20 | ...options 21 | } 22 | return { 23 | ...BaseSchema({ ...options, schema }) 24 | } 25 | } 26 | 27 | module.exports = { 28 | BooleanSchema, 29 | default: BooleanSchema 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | - 'v*' 9 | paths-ignore: 10 | - 'docs/**' 11 | - '*.md' 12 | pull_request: 13 | paths-ignore: 14 | - 'docs/**' 15 | - '*.md' 16 | 17 | # This allows a subsequently queued workflow run to interrupt previous runs 18 | concurrency: 19 | group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" 20 | cancel-in-progress: true 21 | 22 | permissions: 23 | contents: read 24 | 25 | jobs: 26 | test: 27 | permissions: 28 | contents: write 29 | pull-requests: write 30 | uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 31 | with: 32 | lint: true 33 | license-check: true 34 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 15 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - 'discussion' 8 | - 'feature request' 9 | - 'bug' 10 | - 'help wanted' 11 | - 'plugin suggestion' 12 | - 'good first issue' 13 | # Label to use when marking an issue as stale 14 | staleLabel: stale 15 | # Comment to post when marking an issue as stale. Set to `false` to disable 16 | markComment: > 17 | This issue has been automatically marked as stale because it has not had 18 | recent activity. It will be closed if no further activity occurs. Thank you 19 | for your contributions. 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false -------------------------------------------------------------------------------- /src/IntegerSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { NumberSchema } = require('./NumberSchema') 3 | 4 | const initialState = { 5 | type: 'integer' 6 | } 7 | 8 | /** 9 | * Represents a NumberSchema. 10 | * @param {Object} [options] - Options 11 | * @param {NumberSchema} [options.schema] - Default schema 12 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 13 | * @returns {NumberSchema} 14 | */ 15 | // https://medium.com/javascript-scene/javascript-factory-functions-with-es6-4d224591a8b1 16 | // Factory Functions for Mixin Composition withBaseSchema 17 | const IntegerSchema = ( 18 | { schema, ...options } = { 19 | schema: initialState, 20 | generateIds: false, 21 | factory: IntegerSchema 22 | } 23 | ) => ({ 24 | ...NumberSchema({ ...options, schema }) 25 | }) 26 | 27 | module.exports = { 28 | IntegerSchema, 29 | default: IntegerSchema 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present The Fastify team 4 | 5 | The Fastify team members are listed at https://github.com/fastify/fastify#team. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /src/NullSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | const { setAttribute, FLUENT_SCHEMA } = require('./utils') 4 | 5 | const initialState = { 6 | type: 'null' 7 | } 8 | 9 | /** 10 | * Represents a NullSchema. 11 | * @param {Object} [options] - Options 12 | * @param {StringSchema} [options.schema] - Default schema 13 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 14 | * @returns {StringSchema} 15 | */ 16 | 17 | const NullSchema = ({ schema = initialState, ...options } = {}) => { 18 | options = { 19 | generateIds: false, 20 | factory: NullSchema, 21 | ...options 22 | } 23 | const { valueOf, raw } = BaseSchema({ ...options, schema }) 24 | return { 25 | valueOf, 26 | raw, 27 | [FLUENT_SCHEMA]: true, 28 | isFluentSchema: true, 29 | 30 | /** 31 | * Set a property to type null 32 | * 33 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.1.1|reference} 34 | * @returns {FluentSchema} 35 | */ 36 | null: () => setAttribute({ schema, ...options }, ['type', 'null']) 37 | } 38 | } 39 | 40 | module.exports = { 41 | NullSchema, 42 | default: NullSchema 43 | } 44 | -------------------------------------------------------------------------------- /src/NullSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { NullSchema } = require('./NullSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('NullSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(NullSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | NullSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('constructor', () => { 22 | it('without params', () => { 23 | assert.deepStrictEqual(NullSchema().valueOf(), { 24 | type: 'null' 25 | }) 26 | }) 27 | it('from S', () => { 28 | assert.deepStrictEqual(S.null().valueOf(), { 29 | $schema: 'http://json-schema.org/draft-07/schema#', 30 | type: 'null' 31 | }) 32 | }) 33 | }) 34 | 35 | it('sets a null type to the prop', () => { 36 | assert.strictEqual( 37 | S.object().prop('prop', S.null()).valueOf().properties.prop.type, 38 | 'null' 39 | ) 40 | }) 41 | 42 | describe('raw', () => { 43 | it('allows to add a custom attribute', () => { 44 | const schema = NullSchema().raw({ customKeyword: true }).valueOf() 45 | 46 | assert.deepStrictEqual(schema, { 47 | type: 'null', 48 | customKeyword: true 49 | }) 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/BooleanSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { BooleanSchema } = require('./BooleanSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('BooleanSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(BooleanSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | BooleanSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('constructor', () => { 22 | it('without params', () => { 23 | assert.deepStrictEqual(BooleanSchema().valueOf(), { 24 | type: 'boolean' 25 | }) 26 | }) 27 | it('from S', () => { 28 | assert.deepStrictEqual(S.boolean().valueOf(), { 29 | $schema: 'http://json-schema.org/draft-07/schema#', 30 | type: 'boolean' 31 | }) 32 | }) 33 | }) 34 | 35 | it('sets a null type to the prop', () => { 36 | assert.strictEqual( 37 | S.object().prop('prop', S.boolean()).valueOf().properties.prop.type, 38 | 'boolean' 39 | ) 40 | }) 41 | 42 | describe('raw', () => { 43 | it('allows to add a custom attribute', () => { 44 | const schema = BooleanSchema().raw({ customKeyword: true }).valueOf() 45 | 46 | assert.deepStrictEqual(schema, { 47 | type: 'boolean', 48 | customKeyword: true 49 | }) 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluent-json-schema", 3 | "version": "6.0.0", 4 | "description": "JSON Schema fluent API", 5 | "main": "src/FluentJSONSchema.js", 6 | "type": "commonjs", 7 | "types": "types/FluentJSONSchema.d.ts", 8 | "keywords": [ 9 | "JSON", 10 | "schema", 11 | "jsonschema", 12 | "json schema", 13 | "validation", 14 | "json schema builder", 15 | "json schema validation" 16 | ], 17 | "license": "MIT", 18 | "author": "Lorenzo Sicilia ", 19 | "contributors": [ 20 | "Matteo Collina " 21 | ], 22 | "homepage": "https://github.com/fastify/fluent-json-schema#readme", 23 | "funding": [ 24 | { 25 | "type": "github", 26 | "url": "https://github.com/sponsors/fastify" 27 | }, 28 | { 29 | "type": "opencollective", 30 | "url": "https://opencollective.com/fastify" 31 | } 32 | ], 33 | "bugs": { 34 | "url": "https://github.com/fastify/fluent-json-schema/issues" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/fastify/fluent-json-schema.git" 39 | }, 40 | "scripts": { 41 | "lint": "eslint", 42 | "lint:fix": "eslint --fix", 43 | "test": "npm run test:unit && npm run test:typescript", 44 | "test:unit": "c8 --100 node --test", 45 | "test:typescript": "tsd", 46 | "doc": "jsdoc2md ./src/*.js > docs/API.md" 47 | }, 48 | "devDependencies": { 49 | "ajv": "^8.12.0", 50 | "ajv-formats": "^3.0.1", 51 | "c8": "^10.1.2", 52 | "eslint": "^9.17.0", 53 | "jsdoc-to-markdown": "^9.0.0", 54 | "lodash.merge": "^4.6.2", 55 | "neostandard": "^0.12.0", 56 | "tsd": "^0.33.0" 57 | }, 58 | "dependencies": { 59 | "@fastify/deepmerge": "^3.0.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/MixedSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { NullSchema } = require('./NullSchema') 3 | const { BooleanSchema } = require('./BooleanSchema') 4 | const { StringSchema } = require('./StringSchema') 5 | const { NumberSchema } = require('./NumberSchema') 6 | const { IntegerSchema } = require('./IntegerSchema') 7 | const { ObjectSchema } = require('./ObjectSchema') 8 | const { ArraySchema } = require('./ArraySchema') 9 | 10 | const { TYPES, FLUENT_SCHEMA } = require('./utils') 11 | 12 | const initialState = { 13 | type: [], 14 | definitions: [], 15 | properties: [], 16 | required: [] 17 | } 18 | 19 | /** 20 | * Represents a MixedSchema. 21 | * @param {Object} [options] - Options 22 | * @param {MixedSchema} [options.schema] - Default schema 23 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 24 | * @returns {StringSchema} 25 | */ 26 | 27 | const MixedSchema = ({ schema = initialState, ...options } = {}) => { 28 | options = { 29 | generateIds: false, 30 | factory: MixedSchema, 31 | ...options 32 | } 33 | return { 34 | [FLUENT_SCHEMA]: true, 35 | ...(schema.type.includes(TYPES.STRING) 36 | ? StringSchema({ ...options, schema, factory: MixedSchema }) 37 | : {}), 38 | ...(schema.type.includes(TYPES.NUMBER) 39 | ? NumberSchema({ ...options, schema, factory: MixedSchema }) 40 | : {}), 41 | ...(schema.type.includes(TYPES.BOOLEAN) 42 | ? BooleanSchema({ ...options, schema, factory: MixedSchema }) 43 | : {}), 44 | ...(schema.type.includes(TYPES.INTEGER) 45 | ? IntegerSchema({ ...options, schema, factory: MixedSchema }) 46 | : {}), 47 | ...(schema.type.includes(TYPES.OBJECT) 48 | ? ObjectSchema({ ...options, schema, factory: MixedSchema }) 49 | : {}), 50 | ...(schema.type.includes(TYPES.ARRAY) 51 | ? ArraySchema({ ...options, schema, factory: MixedSchema }) 52 | : {}), 53 | ...(schema.type.includes(TYPES.NULL) 54 | ? NullSchema({ ...options, schema, factory: MixedSchema }) 55 | : {}) 56 | } 57 | } 58 | 59 | module.exports = { 60 | MixedSchema, 61 | default: MixedSchema 62 | } 63 | -------------------------------------------------------------------------------- /src/RawSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | const { BooleanSchema } = require('./BooleanSchema') 4 | const { StringSchema } = require('./StringSchema') 5 | const { NumberSchema } = require('./NumberSchema') 6 | const { IntegerSchema } = require('./IntegerSchema') 7 | const { ObjectSchema } = require('./ObjectSchema') 8 | const { ArraySchema } = require('./ArraySchema') 9 | const { toArray, FluentSchemaError } = require('./utils') 10 | 11 | /** 12 | * Represents a raw JSON Schema that will be parsed 13 | * @param {Object} schema 14 | * @returns {FluentSchema} 15 | */ 16 | 17 | const RawSchema = (schema = {}) => { 18 | if (typeof schema !== 'object') { 19 | throw new FluentSchemaError('A fragment must be a JSON object') 20 | } 21 | const { type, definitions, properties, required, ...props } = schema 22 | switch (schema.type) { 23 | case 'string': { 24 | const schema = { 25 | type, 26 | ...props 27 | } 28 | return StringSchema({ schema, factory: StringSchema }) 29 | } 30 | 31 | case 'integer': { 32 | const schema = { 33 | type, 34 | ...props 35 | } 36 | return IntegerSchema({ schema, factory: NumberSchema }) 37 | } 38 | case 'number': { 39 | const schema = { 40 | type, 41 | ...props 42 | } 43 | return NumberSchema({ schema, factory: NumberSchema }) 44 | } 45 | 46 | case 'boolean': { 47 | const schema = { 48 | type, 49 | ...props 50 | } 51 | return BooleanSchema({ schema, factory: BooleanSchema }) 52 | } 53 | 54 | case 'object': { 55 | const schema = { 56 | type, 57 | definitions: toArray(definitions) || [], 58 | properties: toArray(properties) || [], 59 | required: required || [], 60 | ...props 61 | } 62 | return ObjectSchema({ schema, factory: ObjectSchema }) 63 | } 64 | 65 | case 'array': { 66 | const schema = { 67 | type, 68 | ...props 69 | } 70 | return ArraySchema({ schema, factory: ArraySchema }) 71 | } 72 | 73 | default: { 74 | const schema = { 75 | ...props 76 | } 77 | 78 | return BaseSchema({ 79 | schema, 80 | factory: BaseSchema 81 | }) 82 | } 83 | } 84 | } 85 | 86 | module.exports = { 87 | RawSchema, 88 | default: RawSchema 89 | } 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Vim swap files 133 | *.swp 134 | 135 | # macOS files 136 | .DS_Store 137 | 138 | # Clinic 139 | .clinic 140 | 141 | # lock files 142 | bun.lockb 143 | package-lock.json 144 | pnpm-lock.yaml 145 | yarn.lock 146 | 147 | # editor files 148 | .vscode 149 | .idea 150 | 151 | #tap files 152 | .tap/ 153 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { setRaw, combineDeepmerge } = require('./utils') 7 | const { StringSchema } = require('./StringSchema') 8 | const { ObjectSchema } = require('./ObjectSchema') 9 | 10 | describe('setRaw', () => { 11 | it('add an attribute to a prop using ObjectSchema', () => { 12 | const factory = ObjectSchema 13 | const schema = setRaw( 14 | { schema: { properties: [{ name: 'foo', type: 'string' }] }, factory }, 15 | { nullable: true } 16 | ) 17 | assert.deepStrictEqual(schema.valueOf(), { 18 | properties: { 19 | foo: { 20 | nullable: true, 21 | type: 'string' 22 | } 23 | } 24 | }) 25 | }) 26 | 27 | it('add an attribute to a prop using StringSchema', () => { 28 | const factory = StringSchema 29 | const schema = setRaw( 30 | { schema: { type: 'string', properties: [] }, factory }, 31 | { nullable: true } 32 | ) 33 | assert.deepStrictEqual(schema.valueOf(), { 34 | nullable: true, 35 | type: 'string' 36 | }) 37 | }) 38 | }) 39 | 40 | describe('combineDeepmerge', () => { 41 | it('should merge empty arrays', () => { 42 | const result = combineDeepmerge([], []) 43 | assert.deepStrictEqual(result, []) 44 | }) 45 | 46 | it('should merge array with primitive values', () => { 47 | const result = combineDeepmerge([1], [2]) 48 | assert.deepStrictEqual(result, [1, 2]) 49 | }) 50 | 51 | it('should merge arrays with primitive values', () => { 52 | const result = combineDeepmerge([], [1, 2]) 53 | assert.deepStrictEqual(result, [1, 2]) 54 | }) 55 | 56 | it('should merge arrays with primitive values', () => { 57 | const result = combineDeepmerge([1, 2], [1, 2, 3]) 58 | assert.deepStrictEqual(result, [1, 2, 3]) 59 | }) 60 | 61 | it('should merge array with simple Schemas', () => { 62 | const result = combineDeepmerge([{ type: 'string' }], [{ type: 'string' }]) 63 | assert.deepStrictEqual(result, [{ type: 'string' }]) 64 | }) 65 | 66 | it('should merge array with named Schemas', () => { 67 | const result = combineDeepmerge( 68 | [{ name: 'variant 1', type: 'string' }], 69 | [{ name: 'variant 2', type: 'string' }] 70 | ) 71 | assert.deepStrictEqual(result, [ 72 | { name: 'variant 1', type: 'string' }, 73 | { name: 'variant 2', type: 'string' } 74 | ]) 75 | }) 76 | 77 | it('should merge array with same named Schemas', () => { 78 | const result = combineDeepmerge( 79 | [{ name: 'variant 2', type: 'string' }], 80 | [{ name: 'variant 2', type: 'number' }] 81 | ) 82 | assert.deepStrictEqual(result, [{ name: 'variant 2', type: 'number' }]) 83 | }) 84 | 85 | it('should merge array with same named Schemas', () => { 86 | const result = combineDeepmerge( 87 | [{ name: 'variant 2', type: 'string' }], 88 | [ 89 | { name: 'variant 2', type: 'number' }, 90 | { name: 'variant 1', type: 'string' } 91 | ] 92 | ) 93 | assert.deepStrictEqual(result, [ 94 | { name: 'variant 2', type: 'number' }, 95 | { name: 'variant 1', type: 'string' } 96 | ]) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/MixedSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { MixedSchema } = require('./MixedSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('MixedSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(MixedSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol / 1', () => { 15 | assert.notStrictEqual( 16 | MixedSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | it('Expose symbol / 2', () => { 22 | const types = [ 23 | S.TYPES.STRING, 24 | S.TYPES.NUMBER, 25 | S.TYPES.BOOLEAN, 26 | S.TYPES.INTEGER, 27 | S.TYPES.OBJECT, 28 | S.TYPES.ARRAY, 29 | S.TYPES.NULL 30 | ] 31 | assert.notStrictEqual( 32 | MixedSchema(types)[Symbol.for('fluent-schema-object')], 33 | undefined 34 | ) 35 | }) 36 | 37 | describe('factory', () => { 38 | it('without params', () => { 39 | assert.deepStrictEqual(MixedSchema().valueOf(), { 40 | [Symbol.for('fluent-schema-object')]: true 41 | }) 42 | }) 43 | }) 44 | 45 | describe('from S', () => { 46 | it('valid', () => { 47 | const types = [ 48 | S.TYPES.STRING, 49 | S.TYPES.NUMBER, 50 | S.TYPES.BOOLEAN, 51 | S.TYPES.INTEGER, 52 | S.TYPES.OBJECT, 53 | S.TYPES.ARRAY, 54 | S.TYPES.NULL 55 | ] 56 | assert.deepStrictEqual(S.mixed(types).valueOf(), { 57 | $schema: 'http://json-schema.org/draft-07/schema#', 58 | type: types 59 | }) 60 | }) 61 | it('invalid param', () => { 62 | const types = '' 63 | assert.throws( 64 | () => S.mixed(types), 65 | (err) => 66 | err instanceof S.FluentSchemaError && 67 | err.message === 68 | "Invalid 'types'. It must be an array of types. Valid types are string | number | boolean | integer | object | array | null" 69 | ) 70 | }) 71 | 72 | it('invalid type', () => { 73 | const types = ['string', 'invalid'] 74 | assert.throws( 75 | () => S.mixed(types), 76 | (err) => 77 | err instanceof S.FluentSchemaError && 78 | err.message === 79 | "Invalid 'types'. It must be an array of types. Valid types are string | number | boolean | integer | object | array | null" 80 | ) 81 | }) 82 | }) 83 | 84 | it('sets a type object to the prop', () => { 85 | assert.deepStrictEqual( 86 | S.object() 87 | .prop( 88 | 'prop', 89 | S.mixed([S.TYPES.STRING, S.TYPES.NUMBER]).minimum(10).maxLength(5) 90 | ) 91 | .valueOf(), 92 | { 93 | $schema: 'http://json-schema.org/draft-07/schema#', 94 | properties: { 95 | prop: { maxLength: 5, minimum: 10, type: ['string', 'number'] } 96 | }, 97 | type: 'object' 98 | } 99 | ) 100 | }) 101 | 102 | describe('raw', () => { 103 | it('allows to add a custom attribute', () => { 104 | const types = [S.TYPES.STRING, S.TYPES.NUMBER] 105 | 106 | const schema = S.mixed(types).raw({ customKeyword: true }).valueOf() 107 | 108 | assert.deepStrictEqual(schema, { 109 | $schema: 'http://json-schema.org/draft-07/schema#', 110 | type: ['string', 'number'], 111 | customKeyword: true 112 | }) 113 | }) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /src/example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const S = require('./FluentJSONSchema') 3 | const Ajv = require('ajv') 4 | 5 | const ROLES = { 6 | ADMIN: 'ADMIN', 7 | USER: 'USER' 8 | } 9 | 10 | const schema = S.object() 11 | .id('http://foo/user') 12 | .title('My First Fluent JSON Schema') 13 | .description('A simple user') 14 | .prop( 15 | 'email', 16 | S.string() 17 | .format(S.FORMATS.EMAIL) 18 | .required() 19 | ) 20 | .prop( 21 | 'password', 22 | S.string() 23 | .minLength(8) 24 | .required() 25 | ) 26 | .prop( 27 | 'role', 28 | S.string() 29 | .enum(Object.values(ROLES)) 30 | .default(ROLES.USER) 31 | ) 32 | .prop( 33 | 'birthday', 34 | S.raw({ type: 'string', format: 'date', formatMaximum: '2020-01-01' }) // formatMaximum is an AJV custom keywords 35 | ) 36 | .definition( 37 | 'address', 38 | S.object() 39 | .id('#address') 40 | .prop('line1', S.anyOf([S.string(), S.null()])) // JSON Schema nullable 41 | .prop('line2', S.string().raw({ nullable: true })) // Open API / Swagger nullable 42 | .prop('country', S.string()) 43 | .prop('city', S.string()) 44 | .prop('zipcode', S.string()) 45 | .required(['line1', 'country', 'city', 'zipcode']) 46 | ) 47 | .prop('address', S.ref('#address')) 48 | 49 | console.log(JSON.stringify(schema.valueOf(), undefined, 2)) 50 | 51 | const ajv = new Ajv({ allErrors: true }) 52 | const validate = ajv.compile(schema.valueOf()) 53 | let user = {} 54 | let valid = validate(user) 55 | console.log({ valid }) //= > {valid: false} 56 | console.log(validate.errors) 57 | /* [ 58 | { 59 | keyword: 'required', 60 | dataPath: '', 61 | schemaPath: '#/required', 62 | params: { missingProperty: 'email' }, 63 | message: "should have required property 'email'", 64 | }, 65 | { 66 | keyword: 'required', 67 | dataPath: '', 68 | schemaPath: '#/required', 69 | params: { missingProperty: 'password' }, 70 | message: "should have required property 'password'", 71 | }, 72 | ] */ 73 | 74 | user = { email: 'test', password: 'password' } 75 | valid = validate(user) 76 | console.log({ valid }) //= > {valid: false} 77 | console.log(validate.errors) 78 | /* 79 | [ { keyword: 'format', 80 | dataPath: '.email', 81 | schemaPath: '#/properties/email/format', 82 | params: { format: 'email' }, 83 | message: 'should match format "email"' } ] 84 | */ 85 | 86 | user = { email: 'test@foo.com', password: 'password' } 87 | valid = validate(user) 88 | console.log({ valid }) //= > {valid: true} 89 | console.log(validate.errors) // => null 90 | 91 | user = { email: 'test@foo.com', password: 'password', address: { line1: '' } } 92 | valid = validate(user) 93 | console.log({ valid }) //= > {valid: false} 94 | console.log(validate.errors) 95 | 96 | /* 97 | { valid: false } 98 | [ { keyword: 'required', 99 | dataPath: '.address', 100 | schemaPath: '#definitions/address/required', 101 | params: { missingProperty: 'country' }, 102 | message: 'should have required property \'country\'' }, 103 | { keyword: 'required', 104 | dataPath: '.address', 105 | schemaPath: '#definitions/address/required', 106 | params: { missingProperty: 'city' }, 107 | message: 'should have required property \'city\'' }, 108 | { keyword: 'required', 109 | dataPath: '.address', 110 | schemaPath: '#definitions/address/required', 111 | params: { missingProperty: 'zipcoce' }, 112 | message: 'should have required property \'zipcode\'' } ] 113 | */ 114 | 115 | const userBaseSchema = S.object() 116 | .additionalProperties(false) 117 | .prop('username', S.string()) 118 | .prop('password', S.string()) 119 | 120 | const userSchema = S.object() 121 | .prop('id', S.string().format('uuid')) 122 | .prop('createdAt', S.string().format('time')) 123 | .prop('updatedAt', S.string().format('time')) 124 | .extend(userBaseSchema) 125 | .valueOf() 126 | 127 | console.log(userSchema.valueOf()) 128 | -------------------------------------------------------------------------------- /src/NumberSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | const { setAttribute, FluentSchemaError } = require('./utils') 4 | 5 | const initialState = { 6 | type: 'number' 7 | } 8 | 9 | /** 10 | * Represents a NumberSchema. 11 | * @param {Object} [options] - Options 12 | * @param {NumberSchema} [options.schema] - Default schema 13 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 14 | * @returns {NumberSchema} 15 | */ 16 | // https://medium.com/javascript-scene/javascript-factory-functions-with-es6-4d224591a8b1 17 | // Factory Functions for Mixin Composition withBaseSchema 18 | const NumberSchema = ( 19 | { schema, ...options } = { 20 | schema: initialState, 21 | generateIds: false, 22 | factory: NumberSchema 23 | } 24 | ) => ({ 25 | ...BaseSchema({ ...options, schema }), 26 | 27 | /** 28 | * It represents an inclusive lower limit for a numeric instance. 29 | * 30 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.2.4|reference} 31 | * @param {number} min 32 | * @returns {FluentSchema} 33 | */ 34 | 35 | minimum: min => { 36 | if (typeof min !== 'number') { throw new FluentSchemaError("'minimum' must be a Number") } 37 | if (schema.type === 'integer' && !Number.isInteger(min)) { throw new FluentSchemaError("'minimum' must be an Integer") } 38 | return setAttribute({ schema, ...options }, ['minimum', min, 'number']) 39 | }, 40 | 41 | /** 42 | * It represents an exclusive lower limit for a numeric instance. 43 | * 44 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.2.5|reference} 45 | * @param {number} min 46 | * @returns {FluentSchema} 47 | */ 48 | 49 | exclusiveMinimum: min => { 50 | if (typeof min !== 'number') { throw new FluentSchemaError("'exclusiveMinimum' must be a Number") } 51 | if (schema.type === 'integer' && !Number.isInteger(min)) { throw new FluentSchemaError("'exclusiveMinimum' must be an Integer") } 52 | return setAttribute({ schema, ...options }, [ 53 | 'exclusiveMinimum', 54 | min, 55 | 'number' 56 | ]) 57 | }, 58 | 59 | /** 60 | * It represents an inclusive upper limit for a numeric instance. 61 | * 62 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.2.2|reference} 63 | * @param {number} max 64 | * @returns {FluentSchema} 65 | */ 66 | 67 | maximum: max => { 68 | if (typeof max !== 'number') { throw new FluentSchemaError("'maximum' must be a Number") } 69 | if (schema.type === 'integer' && !Number.isInteger(max)) { throw new FluentSchemaError("'maximum' must be an Integer") } 70 | return setAttribute({ schema, ...options }, ['maximum', max, 'number']) 71 | }, 72 | 73 | /** 74 | * It represents an exclusive upper limit for a numeric instance. 75 | * 76 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.2.3|reference} 77 | * @param {number} max 78 | * @returns {FluentSchema} 79 | */ 80 | 81 | exclusiveMaximum: max => { 82 | if (typeof max !== 'number') { throw new FluentSchemaError("'exclusiveMaximum' must be a Number") } 83 | if (schema.type === 'integer' && !Number.isInteger(max)) { throw new FluentSchemaError("'exclusiveMaximum' must be an Integer") } 84 | return setAttribute({ schema, ...options }, [ 85 | 'exclusiveMaximum', 86 | max, 87 | 'number' 88 | ]) 89 | }, 90 | 91 | /** 92 | * It's strictly greater than 0. 93 | * 94 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.2.1|reference} 95 | * @param {number} multiple 96 | * @returns {FluentSchema} 97 | */ 98 | 99 | multipleOf: multiple => { 100 | if (typeof multiple !== 'number') { throw new FluentSchemaError("'multipleOf' must be a Number") } 101 | if (schema.type === 'integer' && !Number.isInteger(multiple)) { throw new FluentSchemaError("'multipleOf' must be an Integer") } 102 | return setAttribute({ schema, ...options }, [ 103 | 'multipleOf', 104 | multiple, 105 | 'number' 106 | ]) 107 | } 108 | }) 109 | 110 | module.exports = { 111 | NumberSchema, 112 | default: NumberSchema 113 | } 114 | -------------------------------------------------------------------------------- /src/schemas/basic.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "description": "basic schema from z-schema benchmark (https://github.com/zaggino/z-schema)", 4 | "schema": { 5 | "$schema": "http://json-schema.org/draft-07/schema#", 6 | "title": "Product set", 7 | "type": "array", 8 | "items": { 9 | "title": "Product", 10 | "type": "object", 11 | "properties": { 12 | "uuid": { 13 | "description": "The unique identifier for a product", 14 | "type": "number" 15 | }, 16 | "name": { 17 | "type": "string" 18 | }, 19 | "price": { 20 | "type": "number", 21 | "exclusiveMinimum": 0 22 | }, 23 | "tags": { 24 | "type": "array", 25 | "items": { 26 | "type": "string" 27 | }, 28 | "minItems": 1, 29 | "uniqueItems": true 30 | }, 31 | "dimensions": { 32 | "type": "object", 33 | "properties": { 34 | "length": { "type": "number" }, 35 | "width": { "type": "number" }, 36 | "height": { "type": "number" } 37 | }, 38 | "required": ["length", "width", "height"] 39 | }, 40 | "warehouseLocation": { 41 | "description": "Coordinates of the warehouse with the product", 42 | "type": "string" 43 | } 44 | }, 45 | "required": ["uuid", "name", "price"] 46 | } 47 | }, 48 | "tests": [ 49 | { 50 | "description": "valid array from z-schema benchmark", 51 | "data": [ 52 | { 53 | "id": 2, 54 | "name": "An ice sculpture", 55 | "price": 12.5, 56 | "tags": ["cold", "ice"], 57 | "dimensions": { 58 | "length": 7.0, 59 | "width": 12.0, 60 | "height": 9.5 61 | }, 62 | "warehouseLocation": { 63 | "latitude": -78.75, 64 | "longitude": 20.4 65 | } 66 | }, 67 | { 68 | "id": 3, 69 | "name": "A blue mouse", 70 | "price": 25.5, 71 | "dimensions": { 72 | "length": 3.1, 73 | "width": 1.0, 74 | "height": 1.0 75 | }, 76 | "warehouseLocation": { 77 | "latitude": 54.4, 78 | "longitude": -32.7 79 | } 80 | } 81 | ], 82 | "valid": true 83 | }, 84 | { 85 | "description": "not array", 86 | "data": 1, 87 | "valid": false 88 | }, 89 | { 90 | "description": "array of not onjects", 91 | "data": [1, 2, 3], 92 | "valid": false 93 | }, 94 | { 95 | "description": "missing required properties", 96 | "data": [{}], 97 | "valid": false 98 | }, 99 | { 100 | "description": "required property of wrong type", 101 | "data": [{ "id": 1, "name": "product", "price": "not valid" }], 102 | "valid": false 103 | }, 104 | { 105 | "description": "smallest valid product", 106 | "data": [{ "id": 1, "name": "product", "price": 100 }], 107 | "valid": true 108 | }, 109 | { 110 | "description": "tags should be array", 111 | "data": [{ "tags": {}, "id": 1, "name": "product", "price": 100 }], 112 | "valid": false 113 | }, 114 | { 115 | "description": "dimensions should be object", 116 | "data": [ 117 | { "dimensions": [], "id": 1, "name": "product", "price": 100 } 118 | ], 119 | "valid": false 120 | }, 121 | { 122 | "description": "valid product with tag", 123 | "data": [ 124 | { "tags": ["product"], "id": 1, "name": "product", "price": 100 } 125 | ], 126 | "valid": true 127 | }, 128 | { 129 | "description": "dimensions miss required properties", 130 | "data": [ 131 | { 132 | "dimensions": {}, 133 | "tags": ["product"], 134 | "id": 1, 135 | "name": "product", 136 | "price": 100 137 | } 138 | ], 139 | "valid": false 140 | }, 141 | { 142 | "description": "valid product with tag and dimensions", 143 | "data": [ 144 | { 145 | "dimensions": { "length": 7, "width": 12, "height": 9.5 }, 146 | "tags": ["product"], 147 | "id": 1, 148 | "name": "product", 149 | "price": 100 150 | } 151 | ], 152 | "valid": true 153 | } 154 | ] 155 | } 156 | ] 157 | -------------------------------------------------------------------------------- /src/FluentJSONSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { FORMATS, TYPES, FluentSchemaError } = require('./utils') 4 | 5 | const { BaseSchema } = require('./BaseSchema') 6 | const { NullSchema } = require('./NullSchema') 7 | const { BooleanSchema } = require('./BooleanSchema') 8 | const { StringSchema } = require('./StringSchema') 9 | const { NumberSchema } = require('./NumberSchema') 10 | const { IntegerSchema } = require('./IntegerSchema') 11 | const { ObjectSchema } = require('./ObjectSchema') 12 | const { ArraySchema } = require('./ArraySchema') 13 | const { MixedSchema } = require('./MixedSchema') 14 | const { RawSchema } = require('./RawSchema') 15 | 16 | const initialState = { 17 | $schema: 'http://json-schema.org/draft-07/schema#', 18 | definitions: [], 19 | properties: [], 20 | required: [] 21 | } 22 | 23 | /** 24 | * Represents a S. 25 | * @param {Object} [options] - Options 26 | * @param {S} [options.schema] - Default schema 27 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 28 | * @returns {S} 29 | */ 30 | 31 | const S = ( 32 | { schema = initialState, ...options } = { 33 | generateIds: false, 34 | factory: BaseSchema 35 | } 36 | ) => ({ 37 | ...BaseSchema({ ...options, schema }), 38 | 39 | /** 40 | * Set a property to type string 41 | * 42 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.3|reference} 43 | * @returns {StringSchema} 44 | */ 45 | 46 | string: () => 47 | StringSchema({ 48 | ...options, 49 | schema, 50 | factory: StringSchema 51 | }).as('string'), 52 | 53 | /** 54 | * Set a property to type number 55 | * 56 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#numeric|reference} 57 | * @returns {NumberSchema} 58 | */ 59 | 60 | number: () => 61 | NumberSchema({ 62 | ...options, 63 | schema, 64 | factory: NumberSchema 65 | }).as('number'), 66 | 67 | /** 68 | * Set a property to type integer 69 | * 70 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#numeric|reference} 71 | * @returns {IntegerSchema} 72 | */ 73 | 74 | integer: () => 75 | IntegerSchema({ 76 | ...options, 77 | schema, 78 | factory: IntegerSchema 79 | }).as('integer'), 80 | 81 | /** 82 | * Set a property to type boolean 83 | * 84 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.7|reference} 85 | * @returns {BooleanSchema} 86 | */ 87 | 88 | boolean: () => 89 | BooleanSchema({ 90 | ...options, 91 | schema, 92 | factory: BooleanSchema 93 | }).as('boolean'), 94 | 95 | /** 96 | * Set a property to type array 97 | * 98 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4|reference} 99 | * @returns {ArraySchema} 100 | */ 101 | 102 | array: () => 103 | ArraySchema({ 104 | ...options, 105 | schema, 106 | factory: ArraySchema 107 | }).as('array'), 108 | 109 | /** 110 | * Set a property to type object 111 | * 112 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5|reference} 113 | * @returns {ObjectSchema} 114 | */ 115 | 116 | object: baseSchema => 117 | ObjectSchema({ 118 | ...options, 119 | schema: baseSchema || schema, 120 | factory: ObjectSchema 121 | }).as('object'), 122 | 123 | /** 124 | * Set a property to type null 125 | * 126 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#general|reference} 127 | * @returns {NullSchema} 128 | */ 129 | 130 | null: () => 131 | NullSchema({ 132 | ...options, 133 | schema, 134 | factory: NullSchema 135 | }).null(), 136 | 137 | /** 138 | * A mixed schema is the union of multiple types (e.g. ['string', 'integer'] 139 | * 140 | * @param {Array.} types 141 | * @returns {MixedSchema} 142 | */ 143 | 144 | mixed: types => { 145 | if ( 146 | !Array.isArray(types) || 147 | (Array.isArray(types) && 148 | types.filter(t => !Object.values(TYPES).includes(t)).length > 0) 149 | ) { 150 | throw new FluentSchemaError( 151 | `Invalid 'types'. It must be an array of types. Valid types are ${Object.values( 152 | TYPES 153 | ).join(' | ')}` 154 | ) 155 | } 156 | 157 | return MixedSchema({ 158 | ...options, 159 | schema: { 160 | ...schema, 161 | type: types 162 | }, 163 | factory: MixedSchema 164 | }) 165 | }, 166 | 167 | /** 168 | * Because the differences between JSON Schemas and Open API (Swagger) 169 | * it can be handy to arbitrary modify the schema injecting a fragment 170 | * 171 | * * Examples: 172 | * - S.raw({ nullable:true, format: 'date', formatMaximum: '2020-01-01' }) 173 | * - S.string().format('date').raw({ formatMaximum: '2020-01-01' }) 174 | * 175 | * @param {string} fragment an arbitrary JSON Schema to inject 176 | * @returns {BaseSchema} 177 | */ 178 | 179 | raw: fragment => { 180 | return RawSchema(fragment) 181 | } 182 | }) 183 | 184 | const fluentSchema = { 185 | ...BaseSchema(), 186 | FORMATS, 187 | TYPES, 188 | FluentSchemaError, 189 | withOptions: S, 190 | string: () => S().string(), 191 | mixed: types => S().mixed(types), 192 | object: () => S().object(), 193 | array: () => S().array(), 194 | boolean: () => S().boolean(), 195 | integer: () => S().integer(), 196 | number: () => S().number(), 197 | null: () => S().null(), 198 | raw: fragment => S().raw(fragment) 199 | } 200 | module.exports = fluentSchema 201 | module.exports.default = fluentSchema 202 | module.exports.S = fluentSchema 203 | -------------------------------------------------------------------------------- /src/ArraySchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { ArraySchema } = require('./ArraySchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('ArraySchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(ArraySchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | ArraySchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('constructor', () => { 22 | it('without params', () => { 23 | assert.deepStrictEqual(ArraySchema().valueOf(), { 24 | type: 'array' 25 | }) 26 | }) 27 | 28 | it('from S', () => { 29 | assert.deepStrictEqual(S.array().valueOf(), { 30 | $schema: 'http://json-schema.org/draft-07/schema#', 31 | type: 'array' 32 | }) 33 | }) 34 | }) 35 | 36 | describe('keywords:', () => { 37 | describe('items', () => { 38 | it('valid object', () => { 39 | assert.deepStrictEqual(ArraySchema().items(S.number()).valueOf(), { 40 | type: 'array', 41 | items: { type: 'number' } 42 | }) 43 | }) 44 | it('valid array', () => { 45 | assert.deepStrictEqual( 46 | ArraySchema().items([S.number(), S.string()]).valueOf(), 47 | { 48 | type: 'array', 49 | items: [{ type: 'number' }, { type: 'string' }] 50 | } 51 | ) 52 | }) 53 | it('invalid', () => { 54 | assert.throws( 55 | () => ArraySchema().items(''), 56 | (err) => 57 | err instanceof S.FluentSchemaError && 58 | err.message === "'items' must be a S or an array of S" 59 | ) 60 | }) 61 | }) 62 | 63 | describe('additionalItems', () => { 64 | it('valid', () => { 65 | assert.deepStrictEqual( 66 | ArraySchema() 67 | .items([S.number(), S.string()]) 68 | .additionalItems(S.string()) 69 | .valueOf(), 70 | { 71 | type: 'array', 72 | items: [{ type: 'number' }, { type: 'string' }], 73 | additionalItems: { type: 'string' } 74 | } 75 | ) 76 | }) 77 | it('false', () => { 78 | assert.deepStrictEqual( 79 | ArraySchema() 80 | .items([S.number(), S.string()]) 81 | .additionalItems(false) 82 | .valueOf(), 83 | { 84 | type: 'array', 85 | items: [{ type: 'number' }, { type: 'string' }], 86 | additionalItems: false 87 | } 88 | ) 89 | }) 90 | it('invalid', () => { 91 | assert.throws( 92 | () => ArraySchema().additionalItems(''), 93 | (err) => 94 | err instanceof S.FluentSchemaError && 95 | err.message === "'additionalItems' must be a boolean or a S" 96 | ) 97 | }) 98 | }) 99 | 100 | describe('contains', () => { 101 | it('valid', () => { 102 | assert.deepStrictEqual(ArraySchema().contains(S.string()).valueOf(), { 103 | type: 'array', 104 | contains: { type: 'string' } 105 | }) 106 | }) 107 | it('invalid', () => { 108 | assert.throws( 109 | () => ArraySchema().contains('').valueOf(), 110 | (err) => 111 | err instanceof S.FluentSchemaError && 112 | err.message === "'contains' must be a S" 113 | ) 114 | }) 115 | }) 116 | 117 | describe('uniqueItems', () => { 118 | it('valid', () => { 119 | assert.deepStrictEqual(ArraySchema().uniqueItems(true).valueOf(), { 120 | type: 'array', 121 | uniqueItems: true 122 | }) 123 | }) 124 | it('invalid', () => { 125 | assert.throws( 126 | () => ArraySchema().uniqueItems('invalid').valueOf(), 127 | (err) => 128 | err instanceof S.FluentSchemaError && 129 | err.message === "'uniqueItems' must be a boolean" 130 | ) 131 | }) 132 | }) 133 | 134 | describe('minItems', () => { 135 | it('valid', () => { 136 | assert.deepStrictEqual(ArraySchema().minItems(3).valueOf(), { 137 | type: 'array', 138 | minItems: 3 139 | }) 140 | }) 141 | it('invalid', () => { 142 | assert.throws( 143 | () => ArraySchema().minItems('3').valueOf(), 144 | (err) => 145 | err instanceof S.FluentSchemaError && 146 | err.message === "'minItems' must be a integer" 147 | ) 148 | }) 149 | }) 150 | 151 | describe('maxItems', () => { 152 | it('valid', () => { 153 | assert.deepStrictEqual(ArraySchema().maxItems(5).valueOf(), { 154 | type: 'array', 155 | maxItems: 5 156 | }) 157 | }) 158 | it('invalid', () => { 159 | assert.throws( 160 | () => ArraySchema().maxItems('5').valueOf(), 161 | (err) => 162 | err instanceof S.FluentSchemaError && 163 | err.message === "'maxItems' must be a integer" 164 | ) 165 | }) 166 | }) 167 | 168 | describe('raw', () => { 169 | it('allows to add a custom attribute', () => { 170 | const schema = ArraySchema().raw({ customKeyword: true }).valueOf() 171 | 172 | assert.deepStrictEqual(schema, { 173 | type: 'array', 174 | customKeyword: true 175 | }) 176 | }) 177 | }) 178 | 179 | describe('default array in an object', () => { 180 | it('valid', () => { 181 | const value = [] 182 | assert.deepStrictEqual( 183 | S.object().prop('p1', ArraySchema().default(value)).valueOf() 184 | .properties.p1.default, 185 | value 186 | ) 187 | }) 188 | }) 189 | }) 190 | }) 191 | -------------------------------------------------------------------------------- /src/StringSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | const { FORMATS, setAttribute, FluentSchemaError } = require('./utils') 4 | 5 | const initialState = { 6 | type: 'string', 7 | // properties: [], //FIXME it shouldn't be set for a string because it has only attributes 8 | required: [] 9 | } 10 | 11 | /** 12 | * Represents a StringSchema. 13 | * @param {Object} [options] - Options 14 | * @param {StringSchema} [options.schema] - Default schema 15 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 16 | * @returns {StringSchema} 17 | */ 18 | // https://medium.com/javascript-scene/javascript-factory-functions-with-es6-4d224591a8b1 19 | // Factory Functions for Mixin Composition withBaseSchema 20 | const StringSchema = ( 21 | { schema, ...options } = { 22 | schema: initialState, 23 | generateIds: false, 24 | factory: StringSchema 25 | } 26 | ) => ({ 27 | ...BaseSchema({ ...options, schema }), 28 | /* /!** 29 | * Set a property to type string 30 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.3|reference} 31 | * @returns {StringSchema} 32 | *!/ 33 | 34 | string: () => 35 | StringSchema({ schema: { ...schema }, ...options }).as('string'), */ 36 | 37 | /** 38 | * A string instance is valid against this keyword if its length is greater than, or equal to, the value of this keyword. 39 | * The length of a string instance is defined as the number of its characters as defined by RFC 7159 [RFC7159]. 40 | * 41 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.3.2|reference} 42 | * @param {number} min 43 | * @returns {StringSchema} 44 | */ 45 | 46 | minLength: min => { 47 | if (!Number.isInteger(min)) { throw new FluentSchemaError("'minLength' must be an Integer") } 48 | return setAttribute({ schema, ...options }, ['minLength', min, 'string']) 49 | }, 50 | 51 | /** 52 | * A string instance is valid against this keyword if its length is less than, or equal to, the value of this keyword. 53 | * The length of a string instance is defined as the number of its characters as defined by RFC 7159 [RFC7159]. 54 | * 55 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.3.1|reference} 56 | * @param {number} max 57 | * @returns {StringSchema} 58 | */ 59 | 60 | maxLength: max => { 61 | if (!Number.isInteger(max)) { throw new FluentSchemaError("'maxLength' must be an Integer") } 62 | return setAttribute({ schema, ...options }, ['maxLength', max, 'string']) 63 | }, 64 | 65 | /** 66 | * A string value can be RELATIVE_JSON_POINTER, JSON_POINTER, UUID, REGEX, IPV6, IPV4, HOSTNAME, EMAIL, URL, URI_TEMPLATE, URI_REFERENCE, URI, TIME, DATE, 67 | * 68 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.7.3|reference} 69 | * @param {string} format 70 | * @returns {StringSchema} 71 | */ 72 | 73 | format: format => { 74 | if (!Object.values(FORMATS).includes(format)) { 75 | throw new FluentSchemaError( 76 | `'format' must be one of ${Object.values(FORMATS).join(', ')}` 77 | ) 78 | } 79 | return setAttribute({ schema, ...options }, ['format', format, 'string']) 80 | }, 81 | 82 | /** 83 | * This string SHOULD be a valid regular expression, according to the ECMA 262 regular expression dialect. 84 | * A string instance is considered valid if the regular expression matches the instance successfully. 85 | * 86 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.3.3|reference} 87 | * @param {string} pattern 88 | * @returns {StringSchema} 89 | */ 90 | pattern: pattern => { 91 | if (!(typeof pattern === 'string') && !(pattern instanceof RegExp)) { 92 | throw new FluentSchemaError( 93 | '\'pattern\' must be a string or a RegEx (e.g. /.*/)' 94 | ) 95 | } 96 | 97 | if (pattern instanceof RegExp) { 98 | const flags = new RegExp(pattern).flags 99 | pattern = pattern 100 | .toString() 101 | .substr(1) 102 | .replace(new RegExp(`/${flags}$`), '') 103 | } 104 | 105 | return setAttribute({ schema, ...options }, ['pattern', pattern, 'string']) 106 | }, 107 | 108 | /** 109 | * If the instance value is a string, this property defines that the string SHOULD 110 | * be interpreted as binary data and decoded using the encoding named by this property. 111 | * RFC 2045, Sec 6.1 [RFC2045] lists the possible values for this property. 112 | * 113 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.8.3|reference} 114 | * @param {string} encoding 115 | * @returns {StringSchema} 116 | */ 117 | 118 | contentEncoding: encoding => { 119 | if (!(typeof encoding === 'string')) { throw new FluentSchemaError('\'contentEncoding\' must be a string') } 120 | return setAttribute({ schema, ...options }, [ 121 | 'contentEncoding', 122 | encoding, 123 | 'string' 124 | ]) 125 | }, 126 | 127 | /** 128 | * The value of this property must be a media type, as defined by RFC 2046 [RFC2046]. 129 | * This property defines the media type of instances which this schema defines. 130 | * 131 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.8.4|reference} 132 | * @param {string} mediaType 133 | * @returns {StringSchema} 134 | */ 135 | 136 | contentMediaType: mediaType => { 137 | if (!(typeof mediaType === 'string')) { throw new FluentSchemaError('\'contentMediaType\' must be a string') } 138 | return setAttribute({ schema, ...options }, [ 139 | 'contentMediaType', 140 | mediaType, 141 | 'string' 142 | ]) 143 | } 144 | }) 145 | 146 | module.exports = { 147 | StringSchema, 148 | FORMATS, 149 | default: StringSchema 150 | } 151 | -------------------------------------------------------------------------------- /src/NumberSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { NumberSchema } = require('./NumberSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('NumberSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(NumberSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | NumberSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('constructor', () => { 22 | it('without params', () => { 23 | assert.deepStrictEqual(NumberSchema().valueOf(), { 24 | type: 'number' 25 | }) 26 | }) 27 | 28 | it('from S', () => { 29 | assert.deepStrictEqual(S.number().valueOf(), { 30 | $schema: 'http://json-schema.org/draft-07/schema#', 31 | type: 'number' 32 | }) 33 | }) 34 | }) 35 | 36 | describe('keywords:', () => { 37 | describe('minimum', () => { 38 | it('valid', () => { 39 | const prop = 'prop' 40 | assert.deepStrictEqual( 41 | S.object().prop(prop, S.number().minimum(5.1)).valueOf(), 42 | { 43 | $schema: 'http://json-schema.org/draft-07/schema#', 44 | properties: { 45 | prop: { 46 | type: 'number', 47 | minimum: 5.1 48 | } 49 | }, 50 | type: 'object' 51 | } 52 | ) 53 | }) 54 | it('invalid value', () => { 55 | assert.throws( 56 | () => S.number().minimum('5.1'), 57 | (err) => 58 | err instanceof S.FluentSchemaError && 59 | err.message === "'minimum' must be a Number" 60 | ) 61 | }) 62 | }) 63 | describe('maximum', () => { 64 | it('valid', () => { 65 | const prop = 'prop' 66 | assert.deepStrictEqual( 67 | S.object().prop(prop, S.number().maximum(5.1)).valueOf(), 68 | { 69 | $schema: 'http://json-schema.org/draft-07/schema#', 70 | properties: { 71 | prop: { 72 | type: 'number', 73 | maximum: 5.1 74 | } 75 | }, 76 | type: 'object' 77 | } 78 | ) 79 | }) 80 | it('invalid value', () => { 81 | assert.throws( 82 | () => S.number().maximum('5.1'), 83 | (err) => 84 | err instanceof S.FluentSchemaError && 85 | err.message === "'maximum' must be a Number" 86 | ) 87 | }) 88 | }) 89 | describe('multipleOf', () => { 90 | it('valid', () => { 91 | const prop = 'prop' 92 | assert.deepStrictEqual( 93 | S.object().prop(prop, S.number().multipleOf(5.1)).valueOf(), 94 | { 95 | $schema: 'http://json-schema.org/draft-07/schema#', 96 | properties: { 97 | prop: { 98 | type: 'number', 99 | multipleOf: 5.1 100 | } 101 | }, 102 | type: 'object' 103 | } 104 | ) 105 | }) 106 | it('invalid value', () => { 107 | assert.throws( 108 | () => S.number().multipleOf('5.1'), 109 | (err) => 110 | err instanceof S.FluentSchemaError && 111 | err.message === "'multipleOf' must be a Number" 112 | ) 113 | }) 114 | }) 115 | 116 | describe('exclusiveMinimum', () => { 117 | it('valid', () => { 118 | const prop = 'prop' 119 | assert.deepStrictEqual( 120 | S.object().prop(prop, S.number().exclusiveMinimum(5.1)).valueOf(), 121 | { 122 | $schema: 'http://json-schema.org/draft-07/schema#', 123 | properties: { 124 | prop: { 125 | type: 'number', 126 | exclusiveMinimum: 5.1 127 | } 128 | }, 129 | type: 'object' 130 | } 131 | ) 132 | }) 133 | it('invalid value', () => { 134 | assert.throws( 135 | () => S.number().exclusiveMinimum('5.1'), 136 | (err) => 137 | err instanceof S.FluentSchemaError && 138 | err.message === "'exclusiveMinimum' must be a Number" 139 | ) 140 | }) 141 | }) 142 | describe('exclusiveMaximum', () => { 143 | it('valid', () => { 144 | const prop = 'prop' 145 | assert.deepStrictEqual( 146 | S.object().prop(prop, S.number().exclusiveMaximum(5.1)).valueOf(), 147 | { 148 | $schema: 'http://json-schema.org/draft-07/schema#', 149 | properties: { 150 | prop: { 151 | type: 'number', 152 | exclusiveMaximum: 5.1 153 | } 154 | }, 155 | type: 'object' 156 | } 157 | ) 158 | }) 159 | it('invalid value', () => { 160 | assert.throws( 161 | () => S.number().exclusiveMaximum('5.1'), 162 | (err) => 163 | err instanceof S.FluentSchemaError && 164 | err.message === "'exclusiveMaximum' must be a Number" 165 | ) 166 | }) 167 | }) 168 | 169 | describe('raw', () => { 170 | it('allows to add a custom attribute', () => { 171 | const schema = NumberSchema().raw({ customKeyword: true }).valueOf() 172 | 173 | assert.deepStrictEqual(schema, { 174 | type: 'number', 175 | customKeyword: true 176 | }) 177 | }) 178 | }) 179 | }) 180 | 181 | it('works', () => { 182 | const schema = S.object() 183 | .id('http://foo.com/user') 184 | .title('A User') 185 | .description('A User desc') 186 | .prop('age', S.number().maximum(10)) 187 | .valueOf() 188 | 189 | assert.deepStrictEqual(schema, { 190 | $id: 'http://foo.com/user', 191 | $schema: 'http://json-schema.org/draft-07/schema#', 192 | description: 'A User desc', 193 | properties: { age: { maximum: 10, type: 'number' } }, 194 | title: 'A User', 195 | type: 'object' 196 | }) 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /types/FluentJSONSchema.d.ts: -------------------------------------------------------------------------------- 1 | export interface BaseSchema { 2 | id: (id: string) => T 3 | title: (title: string) => T 4 | description: (description: string) => T 5 | examples: (examples: Array) => T 6 | ref: (ref: string) => T 7 | enum: (values: Array) => T 8 | const: (value: any) => T 9 | default: (value: any) => T 10 | required: (fields?: string[]) => T 11 | ifThen: (ifClause: JSONSchema, thenClause: JSONSchema) => T 12 | ifThenElse: ( 13 | ifClause: JSONSchema, 14 | thenClause: JSONSchema, 15 | elseClause: JSONSchema 16 | ) => T 17 | not: (schema: JSONSchema) => T 18 | anyOf: (schema: Array) => T 19 | allOf: (schema: Array) => T 20 | oneOf: (schema: Array) => T 21 | readOnly: (isReadOnly?: boolean) => T 22 | writeOnly: (isWriteOnly?: boolean) => T 23 | deprecated: (isDeprecated?: boolean) => T 24 | isFluentSchema: boolean 25 | isFluentJSONSchema: boolean 26 | raw: (fragment: any) => T 27 | } 28 | 29 | export type TYPE = 30 | | 'string' 31 | | 'number' 32 | | 'boolean' 33 | | 'integer' 34 | | 'object' 35 | | 'array' 36 | | 'null' 37 | 38 | type FORMATS = { 39 | RELATIVE_JSON_POINTER: 'relative-json-pointer' 40 | JSON_POINTER: 'json-pointer' 41 | UUID: 'uuid' 42 | REGEX: 'regex' 43 | IPV6: 'ipv6' 44 | IPV4: 'ipv4' 45 | HOSTNAME: 'hostname' 46 | EMAIL: 'email' 47 | URL: 'url' 48 | URI_TEMPLATE: 'uri-template' 49 | URI_REFERENCE: 'uri-reference' 50 | URI: 'uri' 51 | TIME: 'time' 52 | DATE: 'date' 53 | DATE_TIME: 'date-time' 54 | ISO_TIME: 'iso-time' 55 | ISO_DATE_TIME: 'iso-date-time' 56 | } 57 | 58 | export type JSONSchema = 59 | | ObjectSchema 60 | | StringSchema 61 | | NumberSchema 62 | | ArraySchema 63 | | IntegerSchema 64 | | BooleanSchema 65 | | NullSchema 66 | | ExtendedSchema 67 | 68 | export class FluentSchemaError extends Error { 69 | name: string 70 | } 71 | 72 | export interface SchemaOptions { 73 | schema: object 74 | generateIds: boolean 75 | } 76 | 77 | export interface StringSchema extends BaseSchema { 78 | minLength: (min: number) => StringSchema 79 | maxLength: (min: number) => StringSchema 80 | format: (format: FORMATS[keyof FORMATS]) => StringSchema 81 | pattern: (pattern: string | RegExp) => StringSchema 82 | contentEncoding: (encoding: string) => StringSchema 83 | contentMediaType: (mediaType: string) => StringSchema 84 | } 85 | 86 | export interface NullSchema { 87 | null: () => StringSchema 88 | } 89 | 90 | export interface BooleanSchema extends BaseSchema { 91 | boolean: () => BooleanSchema 92 | } 93 | 94 | export interface NumberSchema extends BaseSchema { 95 | minimum: (min: number) => NumberSchema 96 | exclusiveMinimum: (min: number) => NumberSchema 97 | maximum: (max: number) => NumberSchema 98 | exclusiveMaximum: (max: number) => NumberSchema 99 | multipleOf: (multiple: number) => NumberSchema 100 | } 101 | 102 | export interface IntegerSchema extends BaseSchema { 103 | minimum: (min: number) => IntegerSchema 104 | exclusiveMinimum: (min: number) => IntegerSchema 105 | maximum: (max: number) => IntegerSchema 106 | exclusiveMaximum: (max: number) => IntegerSchema 107 | multipleOf: (multiple: number) => IntegerSchema 108 | } 109 | 110 | export interface ArraySchema extends BaseSchema { 111 | items: (items: JSONSchema | Array) => ArraySchema 112 | additionalItems: (items: Array | boolean) => ArraySchema 113 | contains: (value: JSONSchema | boolean) => ArraySchema 114 | uniqueItems: (boolean: boolean) => ArraySchema 115 | minItems: (min: number) => ArraySchema 116 | maxItems: (max: number) => ArraySchema 117 | } 118 | 119 | export interface ObjectSchema = Record> extends BaseSchema> { 120 | definition: (name: Key, props?: JSONSchema) => ObjectSchema 121 | prop: (name: Key, props?: JSONSchema) => ObjectSchema 122 | additionalProperties: (value: JSONSchema | boolean) => ObjectSchema 123 | maxProperties: (max: number) => ObjectSchema 124 | minProperties: (min: number) => ObjectSchema 125 | patternProperties: (options: PatternPropertiesOptions) => ObjectSchema 126 | dependencies: (options: DependenciesOptions) => ObjectSchema 127 | propertyNames: (value: JSONSchema) => ObjectSchema 128 | extend: (schema: ObjectSchema | ExtendedSchema) => ExtendedSchema 129 | only: (properties: string[]) => ObjectSchema 130 | without: (properties: string[]) => ObjectSchema 131 | dependentRequired: (options: DependentRequiredOptions) => ObjectSchema 132 | dependentSchemas: (options: DependentSchemaOptions) => ObjectSchema 133 | } 134 | 135 | export type ExtendedSchema = Pick 136 | 137 | type InferSchemaMap = { 138 | string: StringSchema 139 | number: NumberSchema 140 | boolean: BooleanSchema 141 | integer: IntegerSchema 142 | object: ObjectSchema 143 | array: ArraySchema 144 | null: NullSchema 145 | } 146 | 147 | export type MixedSchema = 148 | T extends readonly [infer First extends TYPE, ...infer Rest extends TYPE[]] 149 | ? InferSchemaMap[First] & MixedSchema 150 | : unknown 151 | 152 | interface PatternPropertiesOptions { 153 | [key: string]: JSONSchema 154 | } 155 | 156 | interface DependenciesOptions { 157 | [key: string]: JSONSchema[] 158 | } 159 | 160 | type Key = keyof T | (string & {}) 161 | 162 | type DependentSchemaOptions>> = Partial> 163 | 164 | type DependentRequiredOptions>> = Partial> 165 | 166 | export function withOptions (options: SchemaOptions): T 167 | 168 | type ObjectPlaceholder = Record 169 | 170 | export interface S extends BaseSchema { 171 | string: () => StringSchema 172 | number: () => NumberSchema 173 | integer: () => IntegerSchema 174 | boolean: () => BooleanSchema 175 | array: () => ArraySchema 176 | object: () => ObjectSchema 177 | null: () => NullSchema 178 | mixed(types: T): MixedSchema 179 | raw: (fragment: any) => S 180 | FORMATS: FORMATS 181 | } 182 | 183 | // eslint-disable-next-line @typescript-eslint/no-redeclare, no-var 184 | export declare var S: S 185 | 186 | export default S 187 | -------------------------------------------------------------------------------- /types/FluentJSONSchema.test-d.ts: -------------------------------------------------------------------------------- 1 | // This file will be passed to the TypeScript CLI to verify our typings compile 2 | 3 | import S, { FluentSchemaError } from '..' 4 | 5 | console.log('isFluentSchema:', S.object().isFluentJSONSchema) 6 | const schema = S.object() 7 | .id('http://foo.com/user') 8 | .title('A User') 9 | .description('A User desc') 10 | .definition( 11 | 'address', 12 | S.object() 13 | .id('#address') 14 | .prop('line1', S.anyOf([S.string(), S.null()])) // JSON Schema nullable 15 | .prop('line2', S.string().raw({ nullable: true })) // Open API / Swagger nullable 16 | .prop('country') 17 | .allOf([S.string()]) 18 | .prop('city') 19 | .prop('zipcode') 20 | ) 21 | .prop('username', S.string().pattern(/[a-z]*/g)) 22 | .prop('email', S.string().format('email')) 23 | .prop('email2', S.string().format(S.FORMATS.EMAIL)) 24 | .prop( 25 | 'avatar', 26 | S.string().contentEncoding('base64').contentMediaType('image/png') 27 | ) 28 | .required() 29 | .prop( 30 | 'password', 31 | S.string().default('123456').minLength(6).maxLength(12).pattern('.*') 32 | ) 33 | .required() 34 | .prop('addresses', S.array().items([S.ref('#address')])) 35 | .required() 36 | .prop( 37 | 'role', 38 | S.object() 39 | .id('http://foo.com/role') 40 | .prop('name') 41 | .enum(['ADMIN', 'USER']) 42 | .prop('permissions') 43 | ) 44 | .required() 45 | .prop('age', S.mixed(['string', 'integer'])) 46 | .ifThen(S.object().prop('age', S.string()), S.required(['age'])) 47 | .readOnly() 48 | .writeOnly(true) 49 | .valueOf() 50 | 51 | console.log('example:\n', JSON.stringify(schema)) 52 | console.log('isFluentSchema:', S.object().isFluentSchema) 53 | 54 | const userBaseSchema = S.object() 55 | .additionalProperties(false) 56 | .prop('username', S.string()) 57 | .prop('password', S.string()) 58 | 59 | const userSchema = S.object() 60 | .prop('id', S.string().format('uuid')) 61 | .prop('createdAt', S.string().format('time')) 62 | .prop('updatedAt', S.string().format('time')) 63 | .extend(userBaseSchema) 64 | 65 | console.log('user:\n', JSON.stringify(userSchema.valueOf())) 66 | 67 | const largeUserSchema = S.object() 68 | .prop('id', S.string().format('uuid')) 69 | .prop('username', S.string()) 70 | .prop('password', S.string()) 71 | .prop('createdAt', S.string().format('time')) 72 | .prop('updatedAt', S.string().format('time')) 73 | 74 | const userSubsetSchema = largeUserSchema.only(['username', 'password']) 75 | 76 | console.log('user subset:', JSON.stringify(userSubsetSchema.valueOf())) 77 | 78 | const personSchema = S.object() 79 | .prop('name', S.string()) 80 | .prop('age', S.number()) 81 | .prop('id', S.string().format('uuid')) 82 | .prop('createdAt', S.string().format('time')) 83 | .prop('updatedAt', S.string().format('time')) 84 | 85 | const bodySchema = personSchema.without(['createdAt', 'updatedAt']) 86 | 87 | console.log('person subset:', JSON.stringify(bodySchema.valueOf())) 88 | 89 | const personSchemaAllowsUnix = S.object() 90 | .prop('name', S.string()) 91 | .prop('age', S.number()) 92 | .prop('id', S.string().format('uuid')) 93 | .prop('createdAt', S.mixed(['string', 'integer']).format('time')) 94 | .prop('updatedAt', S.mixed(['string', 'integer']).minimum(0)) 95 | 96 | console.log('person schema allows unix:', JSON.stringify(personSchemaAllowsUnix.valueOf())) 97 | 98 | try { 99 | S.object().prop('foo', 'boom!' as any) 100 | } catch (e) { 101 | if (e instanceof FluentSchemaError) { 102 | console.log(e.message) 103 | } 104 | } 105 | 106 | const arrayExtendedSchema = S.array().items(userSchema).valueOf() 107 | 108 | console.log('array of user\n', JSON.stringify(arrayExtendedSchema)) 109 | 110 | const extendExtendedSchema = S.object().extend(userSchema) 111 | 112 | console.log('extend of user\n', JSON.stringify(extendExtendedSchema)) 113 | 114 | const rawNullableSchema = S.object() 115 | .raw({ nullable: true }) 116 | .required(['foo', 'hello']) 117 | .prop('foo', S.string()) 118 | .prop('hello', S.string()) 119 | 120 | console.log('raw schema with nullable props\n', JSON.stringify(rawNullableSchema)) 121 | 122 | const dependentRequired = S.object() 123 | .dependentRequired({ 124 | foo: ['bar'], 125 | }) 126 | .prop('foo') 127 | .prop('bar') 128 | .valueOf() 129 | 130 | console.log('dependentRequired:\n', JSON.stringify(dependentRequired)) 131 | 132 | const dependentSchemas = S.object() 133 | .dependentSchemas({ 134 | foo: S.object().prop('bar'), 135 | }) 136 | .prop('bar', S.object().prop('bar')) 137 | .valueOf() 138 | 139 | console.log('dependentRequired:\n', JSON.stringify(dependentSchemas)) 140 | 141 | const deprecatedSchema = S.object() 142 | .deprecated() 143 | .prop('foo', S.string().deprecated()) 144 | .valueOf() 145 | 146 | console.log('deprecatedSchema:\n', JSON.stringify(deprecatedSchema)) 147 | 148 | type Foo = { 149 | foo: string 150 | bar: string 151 | } 152 | 153 | const dependentRequiredWithType = S.object() 154 | .dependentRequired({ 155 | foo: ['bar'], 156 | }) 157 | .prop('foo') 158 | .prop('bar') 159 | .valueOf() 160 | 161 | console.log('dependentRequired:\n', JSON.stringify(dependentRequiredWithType)) 162 | 163 | const dependentSchemasWithType = S.object() 164 | .dependentSchemas({ 165 | foo: S.object().prop('bar'), 166 | }) 167 | .prop('bar', S.object().prop('bar')) 168 | .valueOf() 169 | 170 | console.log('dependentSchemasWithType:\n', JSON.stringify(dependentSchemasWithType)) 171 | 172 | const deprecatedSchemaWithType = S.object() 173 | .deprecated() 174 | .prop('foo', S.string().deprecated()) 175 | .valueOf() 176 | 177 | console.log('deprecatedSchemaWithType:\n', JSON.stringify(deprecatedSchemaWithType)) 178 | 179 | type ReallyLongType = { 180 | foo: string 181 | bar: string 182 | baz: string 183 | xpto: string 184 | abcd: number 185 | kct: { 186 | a: string 187 | b: number 188 | d: null 189 | } 190 | } 191 | 192 | const deepTestOnTypes = S.object() 193 | .prop('bar', S.object().prop('bar')) 194 | // you can provide any string, to avoid breaking changes 195 | .prop('aaaa', S.anyOf([S.string()])) 196 | .definition('abcd', S.number()) 197 | .valueOf() 198 | 199 | console.log('deepTestOnTypes:\n', JSON.stringify(deepTestOnTypes)) 200 | 201 | const tsIsoSchema = S.object() 202 | .prop('createdAt', S.string().format('iso-time')) 203 | .prop('updatedAt', S.string().format('iso-date-time')) 204 | .valueOf() 205 | 206 | console.log('ISO schema OK:', JSON.stringify(tsIsoSchema)) 207 | -------------------------------------------------------------------------------- /src/ArraySchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | const { setAttribute, isFluentSchema, FluentSchemaError } = require('./utils') 4 | 5 | const initialState = { 6 | // $schema: 'http://json-schema.org/draft-07/schema#', 7 | type: 'array', 8 | definitions: [], 9 | properties: [], 10 | required: [] 11 | } 12 | 13 | /** 14 | * Represents a ArraySchema. 15 | * @param {Object} [options] - Options 16 | * @param {StringSchema} [options.schema] - Default schema 17 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 18 | * @returns {ArraySchema} 19 | */ 20 | // https://medium.com/javascript-scene/javascript-factory-functions-with-es6-4d224591a8b1 21 | // Factory Functions for Mixin Composition withBaseSchema 22 | const ArraySchema = ({ schema = initialState, ...options } = {}) => { 23 | options = { 24 | generateIds: false, 25 | factory: ArraySchema, 26 | ...options 27 | } 28 | return { 29 | ...BaseSchema({ ...options, schema }), 30 | 31 | /** 32 | * This keyword determines how child instances validate for arrays, and does not directly validate the immediate instance itself. 33 | * If "items" is a schema, validation succeeds if all elements in the array successfully validate against that schema. 34 | * If "items" is an array of schemas, validation succeeds if each element of the instance validates against the schema at the same position, if any. 35 | * Omitting this keyword has the same behavior as an empty schema. 36 | * 37 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4.1|reference} 38 | * @param {FluentSchema|FluentSchema[]} items 39 | * @returns {FluentSchema} 40 | */ 41 | 42 | items: items => { 43 | if ( 44 | !isFluentSchema(items) && 45 | !( 46 | Array.isArray(items) && 47 | items.filter(v => isFluentSchema(v)).length > 0 48 | ) 49 | ) { throw new FluentSchemaError("'items' must be a S or an array of S") } 50 | if (Array.isArray(items)) { 51 | const values = items.map(v => { 52 | const { $schema, ...rest } = v.valueOf() 53 | return rest 54 | }) 55 | return setAttribute({ schema, ...options }, ['items', values, 'array']) 56 | } 57 | const { $schema, ...rest } = items.valueOf() 58 | return setAttribute({ schema, ...options }, [ 59 | 'items', 60 | { ...rest }, 61 | 'array' 62 | ]) 63 | }, 64 | 65 | /** 66 | * This keyword determines how child instances validate for arrays, and does not directly validate the immediate instance itself. 67 | * 68 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4.2|reference} 69 | * @param {FluentSchema|boolean} items 70 | * @returns {FluentSchema} 71 | */ 72 | 73 | additionalItems: items => { 74 | if (typeof items !== 'boolean' && !isFluentSchema(items)) { 75 | throw new FluentSchemaError( 76 | "'additionalItems' must be a boolean or a S" 77 | ) 78 | } 79 | if (items === false) { 80 | return setAttribute({ schema, ...options }, [ 81 | 'additionalItems', 82 | false, 83 | 'array' 84 | ]) 85 | } 86 | const { $schema, ...rest } = items.valueOf() 87 | return setAttribute({ schema, ...options }, [ 88 | 'additionalItems', 89 | { ...rest }, 90 | 'array' 91 | ]) 92 | }, 93 | 94 | /** 95 | * An array instance is valid against "contains" if at least one of its elements is valid against the given schema. 96 | * 97 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4.6|reference} 98 | * @param {FluentSchema} value 99 | * @returns {FluentSchema} 100 | */ 101 | 102 | contains: value => { 103 | if (!isFluentSchema(value)) { throw new FluentSchemaError("'contains' must be a S") } 104 | const { 105 | $schema, 106 | definitions, 107 | properties, 108 | required, 109 | ...rest 110 | } = value.valueOf() 111 | return setAttribute({ schema, ...options }, [ 112 | 'contains', 113 | { ...rest }, 114 | 'array' 115 | ]) 116 | }, 117 | 118 | /** 119 | * If this keyword has boolean value false, the instance validates successfully. 120 | * If it has boolean value true, the instance validates successfully if all of its elements are unique. 121 | * Omitting this keyword has the same behavior as a value of false. 122 | * 123 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4.5|reference} 124 | * @param {boolean} boolean 125 | * @returns {FluentSchema} 126 | */ 127 | 128 | uniqueItems: boolean => { 129 | if (typeof boolean !== 'boolean') { throw new FluentSchemaError("'uniqueItems' must be a boolean") } 130 | return setAttribute({ schema, ...options }, [ 131 | 'uniqueItems', 132 | boolean, 133 | 'array' 134 | ]) 135 | }, 136 | 137 | /** 138 | * An array instance is valid against "minItems" if its size is greater than, or equal to, the value of this keyword. 139 | * Omitting this keyword has the same behavior as a value of 0. 140 | * 141 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4.4|reference} 142 | * @param {number} min 143 | * @returns {FluentSchema} 144 | */ 145 | 146 | minItems: min => { 147 | if (!Number.isInteger(min)) { throw new FluentSchemaError("'minItems' must be a integer") } 148 | return setAttribute({ schema, ...options }, ['minItems', min, 'array']) 149 | }, 150 | 151 | /** 152 | * An array instance is valid against "minItems" if its size is greater than, or equal to, the value of this keyword. 153 | * Omitting this keyword has the same behavior as a value of 0. 154 | * 155 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.4.3|reference} 156 | * @param {number} max 157 | * @returns {FluentSchema} 158 | */ 159 | 160 | maxItems: max => { 161 | if (!Number.isInteger(max)) { throw new FluentSchemaError("'maxItems' must be a integer") } 162 | return setAttribute({ schema, ...options }, ['maxItems', max, 'array']) 163 | } 164 | } 165 | } 166 | 167 | module.exports = { 168 | ArraySchema, 169 | default: ArraySchema 170 | } 171 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const deepmerge = require('@fastify/deepmerge') 3 | const isFluentSchema = (obj) => obj?.isFluentSchema 4 | 5 | const hasCombiningKeywords = (attributes) => 6 | attributes.allOf || attributes.anyOf || attributes.oneOf || attributes.not 7 | 8 | class FluentSchemaError extends Error { 9 | constructor (message) { 10 | super(message) 11 | this.name = 'FluentSchemaError' 12 | } 13 | } 14 | 15 | const last = (array) => { 16 | if (!array) return 17 | const [prop] = [...array].reverse() 18 | return prop 19 | } 20 | 21 | const isUniq = (array) => 22 | array.filter((v, i, a) => a.indexOf(v) === i).length === array.length 23 | 24 | const isBoolean = (value) => typeof value === 'boolean' 25 | 26 | const omit = (obj, props) => 27 | Object.entries(obj).reduce((memo, [key, value]) => { 28 | if (!props.includes(key)) { 29 | memo[key] = value 30 | } 31 | return memo 32 | }, {}) 33 | 34 | const flat = (array) => 35 | array.reduce((memo, prop) => { 36 | const { name, ...rest } = prop 37 | memo[name] = rest 38 | return memo 39 | }, {}) 40 | 41 | const combineArray = (options) => { 42 | const { clone, isMergeableObject, deepmerge } = options 43 | 44 | return (target, source) => { 45 | const result = target.slice() 46 | 47 | source.forEach((item, index) => { 48 | const prop = target.find((attr) => attr.name === item.name) 49 | if (result[index] === undefined) { 50 | result[index] = clone(item) 51 | } else if (isMergeableObject(prop)) { 52 | const propIndex = target.findIndex((prop) => prop.name === item.name) 53 | result[propIndex] = deepmerge(prop, item) 54 | } else if (target.indexOf(item) === -1) { 55 | result.push(item) 56 | } 57 | }) 58 | return result 59 | } 60 | } 61 | 62 | const combineDeepmerge = deepmerge({ mergeArray: combineArray }) 63 | const toArray = (obj) => 64 | obj && Object.entries(obj).map(([key, value]) => ({ name: key, ...value })) 65 | 66 | const REQUIRED = Symbol('required') 67 | const FLUENT_SCHEMA = Symbol.for('fluent-schema-object') 68 | 69 | const RELATIVE_JSON_POINTER = 'relative-json-pointer' 70 | const JSON_POINTER = 'json-pointer' 71 | const UUID = 'uuid' 72 | const REGEX = 'regex' 73 | const IPV6 = 'ipv6' 74 | const IPV4 = 'ipv4' 75 | const HOSTNAME = 'hostname' 76 | const EMAIL = 'email' 77 | const URL = 'url' 78 | const URI_TEMPLATE = 'uri-template' 79 | const URI_REFERENCE = 'uri-reference' 80 | const URI = 'uri' 81 | const TIME = 'time' 82 | const DATE = 'date' 83 | const DATE_TIME = 'date-time' 84 | const ISO_TIME = 'iso-time' 85 | const ISO_DATE_TIME = 'iso-date-time' 86 | 87 | const FORMATS = { 88 | RELATIVE_JSON_POINTER, 89 | JSON_POINTER, 90 | UUID, 91 | REGEX, 92 | IPV6, 93 | IPV4, 94 | HOSTNAME, 95 | EMAIL, 96 | URL, 97 | URI_TEMPLATE, 98 | URI_REFERENCE, 99 | URI, 100 | TIME, 101 | DATE, 102 | DATE_TIME, 103 | ISO_TIME, 104 | ISO_DATE_TIME 105 | } 106 | 107 | const STRING = 'string' 108 | const NUMBER = 'number' 109 | const BOOLEAN = 'boolean' 110 | const INTEGER = 'integer' 111 | const OBJECT = 'object' 112 | const ARRAY = 'array' 113 | const NULL = 'null' 114 | 115 | const TYPES = { 116 | STRING, 117 | NUMBER, 118 | BOOLEAN, 119 | INTEGER, 120 | OBJECT, 121 | ARRAY, 122 | NULL 123 | } 124 | 125 | const patchIdsWithParentId = ({ schema, generateIds, parentId }) => { 126 | const properties = Object.entries(schema.properties || {}) 127 | if (properties.length === 0) return schema 128 | return { 129 | ...schema, 130 | properties: properties.reduce((memo, [key, props]) => { 131 | const $id = props.$id || (generateIds ? `#properties/${key}` : undefined) 132 | memo[key] = { 133 | ...props, 134 | $id: 135 | generateIds && parentId 136 | ? `${parentId}/${$id.replace('#', '')}` 137 | : $id // e.g. #properties/foo/properties/bar 138 | } 139 | return memo 140 | }, {}) 141 | } 142 | } 143 | 144 | const appendRequired = ({ 145 | attributes: { name, required, ...attributes }, 146 | schema 147 | }) => { 148 | const { schemaRequired, attributeRequired } = (required || []).reduce( 149 | (memo, item) => { 150 | if (item === REQUIRED) { 151 | // Append prop name to the schema.required 152 | memo.schemaRequired.push(name) 153 | } else { 154 | // Propagate required attributes 155 | memo.attributeRequired.push(item) 156 | } 157 | return memo 158 | }, 159 | { schemaRequired: [], attributeRequired: [] } 160 | ) 161 | 162 | const patchedRequired = [...schema.required, ...schemaRequired] 163 | if (!isUniq(patchedRequired)) { 164 | throw new FluentSchemaError( 165 | "'required' has repeated keys, check your calls to .required()" 166 | ) 167 | } 168 | 169 | const schemaPatched = { 170 | ...schema, 171 | required: patchedRequired 172 | } 173 | const attributesPatched = { 174 | ...attributes, 175 | required: attributeRequired 176 | } 177 | return [schemaPatched, attributesPatched] 178 | } 179 | 180 | const setAttribute = ({ schema, ...options }, attribute) => { 181 | const [key, value] = attribute 182 | const currentProp = last(schema.properties) 183 | if (currentProp) { 184 | const { name, ...props } = currentProp 185 | return options.factory({ schema, ...options }).prop(name, { 186 | [key]: value, 187 | ...props 188 | }) 189 | } 190 | return options.factory({ schema: { ...schema, [key]: value }, ...options }) 191 | } 192 | 193 | const setRaw = ({ schema, ...options }, raw) => { 194 | const currentProp = last(schema.properties) 195 | if (currentProp) { 196 | const { name, ...props } = currentProp 197 | return options.factory({ schema, ...options }).prop(name, { 198 | ...raw, 199 | ...props 200 | }) 201 | } 202 | return options.factory({ schema: { ...schema, ...raw }, ...options }) 203 | } 204 | // TODO LS maybe we can just use setAttribute and remove this one 205 | const setComposeType = ({ prop, schemas, schema, options }) => { 206 | if (!(Array.isArray(schemas) && schemas.every((v) => isFluentSchema(v)))) { 207 | throw new FluentSchemaError( 208 | `'${prop}' must be a an array of FluentSchema rather than a '${typeof schemas}'` 209 | ) 210 | } 211 | 212 | const values = schemas.map((schema) => { 213 | const { $schema, ...props } = schema.valueOf({ isRoot: false }) 214 | return props 215 | }) 216 | 217 | return options.factory({ schema: { ...schema, [prop]: values }, ...options }) 218 | } 219 | 220 | module.exports = { 221 | isFluentSchema, 222 | hasCombiningKeywords, 223 | FluentSchemaError, 224 | last, 225 | isUniq, 226 | isBoolean, 227 | flat, 228 | toArray, 229 | omit, 230 | REQUIRED, 231 | patchIdsWithParentId, 232 | appendRequired, 233 | setRaw, 234 | setAttribute, 235 | setComposeType, 236 | combineDeepmerge, 237 | FORMATS, 238 | TYPES, 239 | FLUENT_SCHEMA 240 | } 241 | -------------------------------------------------------------------------------- /src/IntegerSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { IntegerSchema } = require('./IntegerSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('IntegerSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(IntegerSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | IntegerSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('constructor', () => { 22 | it('without params', () => { 23 | assert.deepStrictEqual(IntegerSchema().valueOf(), { 24 | type: 'integer' 25 | }) 26 | }) 27 | 28 | it('from S', () => { 29 | assert.deepStrictEqual(S.integer().valueOf(), { 30 | $schema: 'http://json-schema.org/draft-07/schema#', 31 | type: 'integer' 32 | }) 33 | }) 34 | }) 35 | 36 | describe('keywords:', () => { 37 | describe('minimum', () => { 38 | it('valid', () => { 39 | const prop = 'prop' 40 | assert.deepStrictEqual( 41 | S.object().prop(prop, S.integer().minimum(5)).valueOf(), 42 | { 43 | $schema: 'http://json-schema.org/draft-07/schema#', 44 | properties: { 45 | prop: { 46 | type: 'integer', 47 | minimum: 5 48 | } 49 | }, 50 | type: 'object' 51 | } 52 | ) 53 | }) 54 | it('invalid number', () => { 55 | assert.throws( 56 | () => S.integer().minimum('5.1'), 57 | (err) => 58 | err instanceof S.FluentSchemaError && 59 | err.message === "'minimum' must be a Number" 60 | ) 61 | }) 62 | it('invalid integer', () => { 63 | assert.throws( 64 | () => S.integer().minimum(5.1), 65 | (err) => 66 | err instanceof S.FluentSchemaError && 67 | err.message === "'minimum' must be an Integer" 68 | ) 69 | }) 70 | }) 71 | describe('maximum', () => { 72 | it('valid', () => { 73 | const prop = 'prop' 74 | assert.deepStrictEqual( 75 | S.object().prop(prop, S.integer().maximum(5)).valueOf(), 76 | { 77 | $schema: 'http://json-schema.org/draft-07/schema#', 78 | properties: { 79 | prop: { 80 | type: 'integer', 81 | maximum: 5 82 | } 83 | }, 84 | type: 'object' 85 | } 86 | ) 87 | }) 88 | it('invalid number', () => { 89 | assert.throws( 90 | () => S.integer().maximum('5.1'), 91 | (err) => 92 | err instanceof S.FluentSchemaError && 93 | err.message === "'maximum' must be a Number" 94 | ) 95 | }) 96 | it('invalid float', () => { 97 | assert.throws( 98 | () => S.integer().maximum(5.1), 99 | (err) => 100 | err instanceof S.FluentSchemaError && 101 | err.message === "'maximum' must be an Integer" 102 | ) 103 | }) 104 | }) 105 | describe('multipleOf', () => { 106 | it('valid', () => { 107 | const prop = 'prop' 108 | assert.deepStrictEqual( 109 | S.object().prop(prop, S.integer().multipleOf(5)).valueOf(), 110 | { 111 | $schema: 'http://json-schema.org/draft-07/schema#', 112 | properties: { 113 | prop: { 114 | type: 'integer', 115 | multipleOf: 5 116 | } 117 | }, 118 | type: 'object' 119 | } 120 | ) 121 | }) 122 | it('invalid value', () => { 123 | assert.throws( 124 | () => S.integer().multipleOf('5.1'), 125 | (err) => 126 | err instanceof S.FluentSchemaError && 127 | err.message === "'multipleOf' must be a Number" 128 | ) 129 | }) 130 | it('invalid integer', () => { 131 | assert.throws( 132 | () => S.integer().multipleOf(5.1), 133 | (err) => 134 | err instanceof S.FluentSchemaError && 135 | err.message === "'multipleOf' must be an Integer" 136 | ) 137 | }) 138 | }) 139 | 140 | describe('exclusiveMinimum', () => { 141 | it('valid', () => { 142 | const prop = 'prop' 143 | assert.deepStrictEqual( 144 | S.object().prop(prop, S.integer().exclusiveMinimum(5)).valueOf(), 145 | { 146 | $schema: 'http://json-schema.org/draft-07/schema#', 147 | properties: { 148 | prop: { 149 | type: 'integer', 150 | exclusiveMinimum: 5 151 | } 152 | }, 153 | type: 'object' 154 | } 155 | ) 156 | }) 157 | it('invalid number', () => { 158 | assert.throws( 159 | () => S.integer().exclusiveMinimum('5.1'), 160 | (err) => 161 | err instanceof S.FluentSchemaError && 162 | err.message === "'exclusiveMinimum' must be a Number" 163 | ) 164 | }) 165 | it('invalid integer', () => { 166 | assert.throws( 167 | () => S.integer().exclusiveMinimum(5.1), 168 | (err) => 169 | err instanceof S.FluentSchemaError && 170 | err.message === "'exclusiveMinimum' must be an Integer" 171 | ) 172 | }) 173 | }) 174 | describe('exclusiveMaximum', () => { 175 | it('valid', () => { 176 | const prop = 'prop' 177 | assert.deepStrictEqual( 178 | S.object().prop(prop, S.integer().exclusiveMaximum(5)).valueOf(), 179 | { 180 | $schema: 'http://json-schema.org/draft-07/schema#', 181 | properties: { 182 | prop: { 183 | type: 'integer', 184 | exclusiveMaximum: 5 185 | } 186 | }, 187 | type: 'object' 188 | } 189 | ) 190 | }) 191 | it('invalid number', () => { 192 | assert.throws( 193 | () => S.integer().exclusiveMaximum('5.1'), 194 | (err) => 195 | err instanceof S.FluentSchemaError && 196 | err.message === "'exclusiveMaximum' must be a Number" 197 | ) 198 | }) 199 | it('invalid integer', () => { 200 | assert.throws( 201 | () => S.integer().exclusiveMaximum(5.1), 202 | (err) => 203 | err instanceof S.FluentSchemaError && 204 | err.message === "'exclusiveMaximum' must be an Integer" 205 | ) 206 | }) 207 | }) 208 | }) 209 | 210 | describe('raw', () => { 211 | it('allows to add a custom attribute', () => { 212 | const schema = IntegerSchema().raw({ customKeyword: true }).valueOf() 213 | 214 | assert.deepStrictEqual(schema, { 215 | type: 'integer', 216 | customKeyword: true 217 | }) 218 | }) 219 | }) 220 | 221 | it('works', () => { 222 | const schema = S.object() 223 | .id('http://foo.com/user') 224 | .title('A User') 225 | .description('A User desc') 226 | .prop('age', S.integer().maximum(10)) 227 | .valueOf() 228 | 229 | assert.deepStrictEqual(schema, { 230 | $id: 'http://foo.com/user', 231 | $schema: 'http://json-schema.org/draft-07/schema#', 232 | description: 'A User desc', 233 | properties: { age: { maximum: 10, type: 'integer' } }, 234 | title: 'A User', 235 | type: 'object' 236 | }) 237 | }) 238 | }) 239 | -------------------------------------------------------------------------------- /src/RawSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { RawSchema } = require('./RawSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('RawSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(RawSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | RawSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('base', () => { 22 | it('parses type', () => { 23 | const input = S.enum(['foo']).valueOf() 24 | const schema = RawSchema(input) 25 | assert.ok(schema.isFluentSchema) 26 | assert.deepStrictEqual(schema.valueOf(), { 27 | ...input 28 | }) 29 | }) 30 | 31 | it('adds an attribute', () => { 32 | const input = S.enum(['foo']).valueOf() 33 | const schema = RawSchema(input) 34 | const attribute = 'title' 35 | const modified = schema.title(attribute) 36 | assert.ok(schema.isFluentSchema) 37 | assert.deepStrictEqual(modified.valueOf(), { 38 | ...input, 39 | title: attribute 40 | }) 41 | }) 42 | 43 | it("throws an exception if the input isn't an object", () => { 44 | assert.throws( 45 | () => RawSchema('boom!'), 46 | (err) => 47 | err instanceof S.FluentSchemaError && 48 | err.message === 'A fragment must be a JSON object' 49 | ) 50 | }) 51 | }) 52 | 53 | describe('string', () => { 54 | it('parses type', () => { 55 | const input = S.string().valueOf() 56 | const schema = RawSchema(input) 57 | assert.ok(schema.isFluentSchema) 58 | assert.deepStrictEqual(schema.valueOf(), { 59 | ...input 60 | }) 61 | }) 62 | 63 | it('adds an attribute', () => { 64 | const input = S.string().valueOf() 65 | const schema = RawSchema(input) 66 | const modified = schema.minLength(3) 67 | assert.ok(schema.isFluentSchema) 68 | assert.deepStrictEqual(modified.valueOf(), { 69 | minLength: 3, 70 | ...input 71 | }) 72 | }) 73 | 74 | it('parses a prop', () => { 75 | const input = S.string().minLength(5).valueOf() 76 | const schema = RawSchema(input) 77 | assert.ok(schema.isFluentSchema) 78 | assert.deepStrictEqual(schema.valueOf(), { 79 | ...input 80 | }) 81 | }) 82 | }) 83 | 84 | describe('number', () => { 85 | it('parses type', () => { 86 | const input = S.number().valueOf() 87 | const schema = RawSchema(input) 88 | assert.ok(schema.isFluentSchema) 89 | assert.deepStrictEqual(schema.valueOf(), { 90 | ...input 91 | }) 92 | }) 93 | 94 | it('adds an attribute', () => { 95 | const input = S.number().valueOf() 96 | const schema = RawSchema(input) 97 | const modified = schema.maximum(3) 98 | assert.ok(schema.isFluentSchema) 99 | assert.deepStrictEqual(modified.valueOf(), { 100 | maximum: 3, 101 | ...input 102 | }) 103 | }) 104 | 105 | it('parses a prop', () => { 106 | const input = S.number().maximum(5).valueOf() 107 | const schema = RawSchema(input) 108 | assert.ok(schema.isFluentSchema) 109 | assert.deepStrictEqual(schema.valueOf(), { 110 | ...input 111 | }) 112 | }) 113 | }) 114 | 115 | describe('integer', () => { 116 | it('parses type', () => { 117 | const input = S.integer().valueOf() 118 | const schema = RawSchema(input) 119 | assert.ok(schema.isFluentSchema) 120 | assert.deepStrictEqual(schema.valueOf(), { 121 | ...input 122 | }) 123 | }) 124 | 125 | it('adds an attribute', () => { 126 | const input = S.integer().valueOf() 127 | const schema = RawSchema(input) 128 | const modified = schema.maximum(3) 129 | assert.ok(schema.isFluentSchema) 130 | assert.deepStrictEqual(modified.valueOf(), { 131 | maximum: 3, 132 | ...input 133 | }) 134 | }) 135 | 136 | it('parses a prop', () => { 137 | const input = S.integer().maximum(5).valueOf() 138 | const schema = RawSchema(input) 139 | assert.ok(schema.isFluentSchema) 140 | assert.deepStrictEqual(schema.valueOf(), { 141 | ...input 142 | }) 143 | }) 144 | }) 145 | 146 | describe('boolean', () => { 147 | it('parses type', () => { 148 | const input = S.boolean().valueOf() 149 | const schema = RawSchema(input) 150 | assert.ok(schema.isFluentSchema) 151 | assert.deepStrictEqual(schema.valueOf(), { 152 | ...input 153 | }) 154 | }) 155 | }) 156 | 157 | describe('object', () => { 158 | it('parses type', () => { 159 | const input = S.object().valueOf() 160 | const schema = RawSchema(input) 161 | assert.ok(schema.isFluentSchema) 162 | assert.deepStrictEqual(schema.valueOf(), { 163 | ...input 164 | }) 165 | }) 166 | 167 | it('parses properties', () => { 168 | const input = S.object().prop('foo').prop('bar', S.string()).valueOf() 169 | const schema = RawSchema(input) 170 | assert.ok(schema.isFluentSchema) 171 | assert.deepStrictEqual(schema.valueOf(), { 172 | ...input 173 | }) 174 | }) 175 | 176 | it('parses nested properties', () => { 177 | const input = S.object() 178 | .prop('foo', S.object().prop('bar', S.string().minLength(3))) 179 | .valueOf() 180 | const schema = RawSchema(input) 181 | const modified = schema.prop('boom') 182 | assert.ok(modified.isFluentSchema) 183 | assert.deepStrictEqual(modified.valueOf(), { 184 | ...input, 185 | properties: { 186 | ...input.properties, 187 | boom: {} 188 | } 189 | }) 190 | }) 191 | 192 | it('parses definitions', () => { 193 | const input = S.object().definition('foo', S.string()).valueOf() 194 | const schema = RawSchema(input) 195 | assert.ok(schema.isFluentSchema) 196 | assert.deepStrictEqual(schema.valueOf(), { 197 | ...input 198 | }) 199 | }) 200 | }) 201 | 202 | describe('array', () => { 203 | it('parses type', () => { 204 | const input = S.array().items(S.string()).valueOf() 205 | const schema = RawSchema(input) 206 | assert.ok(schema.isFluentSchema) 207 | assert.deepStrictEqual(schema.valueOf(), { 208 | ...input 209 | }) 210 | }) 211 | 212 | it('parses properties', () => { 213 | const input = S.array().items(S.string()).valueOf() 214 | 215 | const schema = RawSchema(input).maxItems(1) 216 | assert.ok(schema.isFluentSchema) 217 | assert.deepStrictEqual(schema.valueOf(), { 218 | ...input, 219 | maxItems: 1 220 | }) 221 | }) 222 | 223 | it('parses nested properties', () => { 224 | const input = S.array() 225 | .items( 226 | S.object().prop( 227 | 'foo', 228 | S.object().prop('bar', S.string().minLength(3)) 229 | ) 230 | ) 231 | .valueOf() 232 | const schema = RawSchema(input) 233 | const modified = schema.maxItems(1) 234 | assert.ok(modified.isFluentSchema) 235 | assert.deepStrictEqual(modified.valueOf(), { 236 | ...input, 237 | maxItems: 1 238 | }) 239 | }) 240 | 241 | it('parses definitions', () => { 242 | const input = S.object().definition('foo', S.string()).valueOf() 243 | const schema = RawSchema(input) 244 | assert.ok(schema.isFluentSchema) 245 | assert.deepStrictEqual(schema.valueOf(), { 246 | ...input 247 | }) 248 | }) 249 | }) 250 | }) 251 | -------------------------------------------------------------------------------- /src/StringSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { StringSchema, FORMATS } = require('./StringSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('StringSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(StringSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | StringSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | describe('constructor', () => { 22 | it('without params', () => { 23 | assert.deepStrictEqual(StringSchema().valueOf(), { 24 | type: 'string' 25 | }) 26 | }) 27 | 28 | it('from S', () => { 29 | assert.deepStrictEqual(S.string().valueOf(), { 30 | $schema: 'http://json-schema.org/draft-07/schema#', 31 | type: 'string' 32 | }) 33 | }) 34 | }) 35 | 36 | describe('keywords:', () => { 37 | describe('minLength', () => { 38 | it('valid', () => { 39 | const schema = S.object() 40 | .prop('prop', StringSchema().minLength(5)) 41 | .valueOf() 42 | assert.deepStrictEqual(schema, { 43 | $schema: 'http://json-schema.org/draft-07/schema#', 44 | properties: { 45 | prop: { 46 | type: 'string', 47 | minLength: 5 48 | } 49 | }, 50 | type: 'object' 51 | }) 52 | }) 53 | it('invalid', () => { 54 | assert.throws( 55 | () => StringSchema().minLength('5.1'), 56 | (err) => 57 | err instanceof S.FluentSchemaError && 58 | err.message === "'minLength' must be an Integer" 59 | ) 60 | }) 61 | }) 62 | describe('maxLength', () => { 63 | it('valid', () => { 64 | const schema = StringSchema().maxLength(10).valueOf() 65 | assert.deepStrictEqual(schema, { 66 | type: 'string', 67 | maxLength: 10 68 | }) 69 | }) 70 | it('invalid', () => { 71 | assert.throws( 72 | () => StringSchema().maxLength('5.1'), 73 | (err) => 74 | err instanceof S.FluentSchemaError && 75 | err.message === "'maxLength' must be an Integer" 76 | ) 77 | }) 78 | }) 79 | describe('format', () => { 80 | it('valid FORMATS.DATE', () => { 81 | assert.deepStrictEqual(StringSchema().format(FORMATS.DATE).valueOf(), { 82 | type: 'string', 83 | format: FORMATS.DATE 84 | }) 85 | }) 86 | it('valid FORMATS.DATE_TIME', () => { 87 | assert.deepStrictEqual( 88 | StringSchema().format(FORMATS.DATE_TIME).valueOf(), 89 | { 90 | type: 'string', 91 | format: 'date-time' 92 | } 93 | ) 94 | }) 95 | it('valid FORMATS.ISO_DATE_TIME', () => { 96 | assert.deepStrictEqual( 97 | StringSchema().format(FORMATS.ISO_DATE_TIME).valueOf(), 98 | { 99 | type: 'string', 100 | format: 'iso-date-time' 101 | } 102 | ) 103 | }) 104 | it('valid FORMATS.ISO_TIME', () => { 105 | assert.deepStrictEqual( 106 | StringSchema().format(FORMATS.ISO_TIME).valueOf(), 107 | { 108 | type: 'string', 109 | format: 'iso-time' 110 | } 111 | ) 112 | }) 113 | it('invalid', () => { 114 | assert.throws( 115 | () => StringSchema().format('invalid'), 116 | (err) => 117 | err instanceof S.FluentSchemaError && 118 | err.message === 119 | "'format' must be one of relative-json-pointer, json-pointer, uuid, regex, ipv6, ipv4, hostname, email, url, uri-template, uri-reference, uri, time, date, date-time, iso-time, iso-date-time" 120 | ) 121 | }) 122 | }) 123 | describe('pattern', () => { 124 | it('as a string', () => { 125 | assert.deepStrictEqual(StringSchema().pattern('\\/.*\\/').valueOf(), { 126 | type: 'string', 127 | pattern: '\\/.*\\/' 128 | }) 129 | }) 130 | it('as a regex without flags', () => { 131 | assert.deepStrictEqual( 132 | StringSchema() 133 | .pattern(/\/.*\//) 134 | .valueOf(), 135 | { 136 | type: 'string', 137 | pattern: '\\/.*\\/' 138 | } 139 | ) 140 | }) 141 | 142 | it('as a regex with flags', () => { 143 | assert.deepStrictEqual( 144 | StringSchema() 145 | .pattern(/\/.*\//gi) 146 | .valueOf(), 147 | { 148 | type: 'string', 149 | pattern: '\\/.*\\/' 150 | } 151 | ) 152 | }) 153 | 154 | it('invalid value', () => { 155 | assert.throws( 156 | () => StringSchema().pattern(1111), 157 | (err) => 158 | err instanceof S.FluentSchemaError && 159 | err.message === "'pattern' must be a string or a RegEx (e.g. /.*/)" 160 | ) 161 | }) 162 | }) 163 | describe('contentEncoding', () => { 164 | it('valid', () => { 165 | assert.deepStrictEqual( 166 | StringSchema().contentEncoding('base64').valueOf(), 167 | { 168 | type: 'string', 169 | contentEncoding: 'base64' 170 | } 171 | ) 172 | }) 173 | it('invalid', () => { 174 | assert.throws( 175 | () => StringSchema().contentEncoding(1000), 176 | (err) => 177 | err instanceof S.FluentSchemaError && 178 | err.message === "'contentEncoding' must be a string" 179 | ) 180 | }) 181 | }) 182 | describe('contentMediaType', () => { 183 | it('valid', () => { 184 | assert.deepStrictEqual( 185 | StringSchema().contentMediaType('image/png').valueOf(), 186 | { 187 | type: 'string', 188 | contentMediaType: 'image/png' 189 | } 190 | ) 191 | }) 192 | it('invalid', () => { 193 | assert.throws( 194 | () => StringSchema().contentMediaType(1000), 195 | (err) => 196 | err instanceof S.FluentSchemaError && 197 | err.message === "'contentMediaType' must be a string" 198 | ) 199 | }) 200 | }) 201 | 202 | describe('raw', () => { 203 | it('allows to add a custom attribute', () => { 204 | const schema = StringSchema().raw({ customKeyword: true }).valueOf() 205 | 206 | assert.deepStrictEqual(schema, { 207 | type: 'string', 208 | customKeyword: true 209 | }) 210 | }) 211 | it('allows to mix custom attibutes with regular one', () => { 212 | const schema = StringSchema() 213 | .format('date') 214 | .raw({ formatMaximum: '2020-01-01' }) 215 | .valueOf() 216 | 217 | assert.deepStrictEqual(schema, { 218 | type: 'string', 219 | formatMaximum: '2020-01-01', 220 | format: 'date' 221 | }) 222 | }) 223 | }) 224 | }) 225 | 226 | it('works', () => { 227 | const schema = S.object() 228 | .id('http://bar.com/object') 229 | .title('A object') 230 | .description('A object desc') 231 | .prop( 232 | 'name', 233 | StringSchema() 234 | .id('http://foo.com/string') 235 | .title('A string') 236 | .description('A string desc') 237 | .pattern(/.*/g) 238 | .format('date-time') 239 | ) 240 | .valueOf() 241 | 242 | assert.deepStrictEqual(schema, { 243 | $id: 'http://bar.com/object', 244 | $schema: 'http://json-schema.org/draft-07/schema#', 245 | description: 'A object desc', 246 | properties: { 247 | name: { 248 | $id: 'http://foo.com/string', 249 | description: 'A string desc', 250 | title: 'A string', 251 | type: 'string', 252 | format: 'date-time', 253 | pattern: '.*' 254 | } 255 | }, 256 | title: 'A object', 257 | type: 'object' 258 | }) 259 | }) 260 | }) 261 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fluent-json-schema 2 | 3 | A fluent API to generate JSON schemas (draft-07) for Node.js and browser. Framework agnostic. 4 | 5 | [![view on npm](https://img.shields.io/npm/v/fluent-json-schema.svg)](https://www.npmjs.org/package/fluent-json-schema) 6 | [![CI](https://github.com/fastify/fluent-json-schema/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fluent-json-schema/actions/workflows/ci.yml) 7 | [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) 8 | 9 | ## Features 10 | 11 | - Fluent schema implements JSON Schema draft-07 standards 12 | - Faster and shorter way to write a JSON Schema via a [fluent API](https://en.wikipedia.org/wiki/Fluent_interface) 13 | - Runtime errors for invalid options or keywords misuse 14 | - JavaScript constants can be used in the JSON schema (e.g. _enum_, _const_, _default_ ) avoiding discrepancies between model and schema 15 | - TypeScript definitions 16 | - Coverage 100% 17 | 18 | ## Install 19 | 20 | npm i fluent-json-schema 21 | 22 | or 23 | 24 | yarn add fluent-json-schema 25 | 26 | ## Usage 27 | 28 | ```javascript 29 | const S = require('fluent-json-schema') 30 | 31 | const ROLES = { 32 | ADMIN: 'ADMIN', 33 | USER: 'USER', 34 | } 35 | 36 | const schema = S.object() 37 | .id('http://foo/user') 38 | .title('My First Fluent JSON Schema') 39 | .description('A simple user') 40 | .prop('email', S.string().format(S.FORMATS.EMAIL).required()) 41 | .prop('password', S.string().minLength(8).required()) 42 | .prop('role', S.string().enum(Object.values(ROLES)).default(ROLES.USER)) 43 | .prop( 44 | 'birthday', 45 | S.raw({ type: 'string', format: 'date', formatMaximum: '2020-01-01' }) // formatMaximum is an AJV custom keywords 46 | ) 47 | .definition( 48 | 'address', 49 | S.object() 50 | .id('#address') 51 | .prop('line1', S.anyOf([S.string(), S.null()])) // JSON Schema nullable 52 | .prop('line2', S.string().raw({ nullable: true })) // Open API / Swagger nullable 53 | .prop('country', S.string()) 54 | .prop('city', S.string()) 55 | .prop('zipcode', S.string()) 56 | .required(['line1', 'country', 'city', 'zipcode']) 57 | ) 58 | .prop('address', S.ref('#address')) 59 | 60 | console.log(JSON.stringify(schema.valueOf(), undefined, 2)) 61 | ``` 62 | 63 | Schema generated: 64 | 65 | ```json 66 | { 67 | "$schema": "http://json-schema.org/draft-07/schema#", 68 | "definitions": { 69 | "address": { 70 | "type": "object", 71 | "$id": "#address", 72 | "properties": { 73 | "line1": { 74 | "anyOf": [ 75 | { 76 | "type": "string" 77 | }, 78 | { 79 | "type": "null" 80 | } 81 | ] 82 | }, 83 | "line2": { 84 | "type": "string", 85 | "nullable": true 86 | }, 87 | "country": { 88 | "type": "string" 89 | }, 90 | "city": { 91 | "type": "string" 92 | }, 93 | "zipcode": { 94 | "type": "string" 95 | } 96 | }, 97 | "required": ["line1", "country", "city", "zipcode"] 98 | } 99 | }, 100 | "type": "object", 101 | "$id": "http://foo/user", 102 | "title": "My First Fluent JSON Schema", 103 | "description": "A simple user", 104 | "properties": { 105 | "email": { 106 | "type": "string", 107 | "format": "email" 108 | }, 109 | "password": { 110 | "type": "string", 111 | "minLength": 8 112 | }, 113 | "birthday": { 114 | "type": "string", 115 | "format": "date", 116 | "formatMaximum": "2020-01-01" 117 | }, 118 | "role": { 119 | "type": "string", 120 | "enum": ["ADMIN", "USER"], 121 | "default": "USER" 122 | }, 123 | "address": { 124 | "$ref": "#address" 125 | } 126 | }, 127 | "required": ["email", "password"] 128 | } 129 | ``` 130 | 131 | ## TypeScript 132 | 133 | ### CommonJS 134 | 135 | With `"esModuleInterop": true` activated in the `tsconfig.json`: 136 | 137 | ```typescript 138 | import S from 'fluent-json-schema' 139 | 140 | const schema = S.object() 141 | .prop('foo', S.string()) 142 | .prop('bar', S.number()) 143 | .valueOf() 144 | ``` 145 | 146 | With `"esModuleInterop": false` in the `tsconfig.json`: 147 | 148 | ```typescript 149 | import * as S from 'fluent-json-schema' 150 | 151 | const schema = S.object() 152 | .prop('foo', S.string()) 153 | .prop('bar', S.number()) 154 | .valueOf() 155 | ``` 156 | 157 | ### ESM 158 | 159 | A named export is also available to work with native ESM modules: 160 | 161 | ```typescript 162 | import { S } from 'fluent-json-schema' 163 | 164 | const schema = S.object() 165 | .prop('foo', S.string()) 166 | .prop('bar', S.number()) 167 | .valueOf() 168 | ``` 169 | 170 | ## Validation 171 | 172 | Fluent schema **does not** validate a JSON schema. However, many libraries can do that for you. 173 | Below are a few examples using [AJV](https://ajv.js.org/): 174 | 175 | npm i ajv 176 | 177 | or 178 | 179 | yarn add ajv 180 | 181 | ### Validate an empty model 182 | 183 | Snippet: 184 | 185 | ```javascript 186 | const ajv = new Ajv({ allErrors: true }) 187 | const validate = ajv.compile(schema.valueOf()) 188 | let user = {} 189 | let valid = validate(user) 190 | console.log({ valid }) //=> {valid: false} 191 | console.log(validate.errors) //=> {valid: false} 192 | ``` 193 | 194 | Output: 195 | 196 | ``` 197 | {valid: false} 198 | errors: [ 199 | { 200 | keyword: 'required', 201 | dataPath: '', 202 | schemaPath: '#/required', 203 | params: { missingProperty: 'email' }, 204 | message: "should have required property 'email'", 205 | }, 206 | { 207 | keyword: 'required', 208 | dataPath: '', 209 | schemaPath: '#/required', 210 | params: { missingProperty: 'password' }, 211 | message: "should have required property 'password'", 212 | }, 213 | ] 214 | 215 | ``` 216 | 217 | ### Validate a partially filled model 218 | 219 | Snippet: 220 | 221 | ```javascript 222 | user = { email: 'test', password: 'password' } 223 | valid = validate(user) 224 | console.log({ valid }) 225 | console.log(validate.errors) 226 | ``` 227 | 228 | Output: 229 | 230 | ``` 231 | {valid: false} 232 | errors: 233 | [ { keyword: 'format', 234 | dataPath: '.email', 235 | schemaPath: '#/properties/email/format', 236 | params: { format: 'email' }, 237 | message: 'should match format "email"' } ] 238 | 239 | ``` 240 | 241 | ### Validate a model with a wrong format attribute 242 | 243 | Snippet: 244 | 245 | ```javascript 246 | user = { email: 'test@foo.com', password: 'password' } 247 | valid = validate(user) 248 | console.log({ valid }) 249 | console.log('errors:', validate.errors) 250 | ``` 251 | 252 | Output: 253 | 254 | ``` 255 | {valid: false} 256 | errors: [ { keyword: 'required', 257 | dataPath: '.address', 258 | schemaPath: '#definitions/address/required', 259 | params: { missingProperty: 'country' }, 260 | message: 'should have required property \'country\'' }, 261 | { keyword: 'required', 262 | dataPath: '.address', 263 | schemaPath: '#definitions/address/required', 264 | params: { missingProperty: 'city' }, 265 | message: 'should have required property \'city\'' }, 266 | { keyword: 'required', 267 | dataPath: '.address', 268 | schemaPath: '#definitions/address/required', 269 | params: { missingProperty: 'zipcoce' }, 270 | message: 'should have required property \'zipcode\'' } ] 271 | ``` 272 | 273 | ### Valid model 274 | 275 | Snippet: 276 | 277 | ```javascript 278 | user = { email: 'test@foo.com', password: 'password' } 279 | valid = validate(user) 280 | console.log({ valid }) 281 | ``` 282 | 283 | Output: 284 | 285 | {valid: true} 286 | 287 | ## Extend schema 288 | 289 | Normally inheritance with JSON Schema is achieved with `allOf`. However, when `.additionalProperties(false)` is used the validator won't 290 | understand which properties come from the base schema. `S.extend` creates a schema merging the base into the new one so 291 | that the validator knows all the properties because it evaluates only a single schema. 292 | For example, in a CRUD API `POST /users` could use the `userBaseSchema` rather than `GET /users` or `PATCH /users` use the `userSchema` 293 | which contains the `id`, `createdAt`, and `updatedAt` generated server side. 294 | 295 | ```js 296 | const S = require('fluent-json-schema') 297 | const userBaseSchema = S.object() 298 | .additionalProperties(false) 299 | .prop('username', S.string()) 300 | .prop('password', S.string()) 301 | 302 | const userSchema = S.object() 303 | .prop('id', S.string().format('uuid')) 304 | .prop('createdAt', S.string().format('time')) 305 | .prop('updatedAt', S.string().format('time')) 306 | .extend(userBaseSchema) 307 | 308 | console.log(userSchema) 309 | ``` 310 | 311 | ## Selecting certain properties of your schema 312 | 313 | In addition to extending schemas, it is also possible to reduce them into smaller schemas. This comes in handy 314 | when you have a large Fluent Schema, and would like to re-use some of its properties. 315 | 316 | Select only properties you want to keep. 317 | 318 | ```js 319 | const S = require('fluent-json-schema') 320 | const userSchema = S.object() 321 | .prop('username', S.string()) 322 | .prop('password', S.string()) 323 | .prop('id', S.string().format('uuid')) 324 | .prop('createdAt', S.string().format('time')) 325 | .prop('updatedAt', S.string().format('time')) 326 | 327 | const loginSchema = userSchema.only(['username', 'password']) 328 | ``` 329 | 330 | Or remove properties you dont want to keep. 331 | 332 | ```js 333 | const S = require('fluent-json-schema') 334 | const personSchema = S.object() 335 | .prop('name', S.string()) 336 | .prop('age', S.number()) 337 | .prop('id', S.string().format('uuid')) 338 | .prop('createdAt', S.string().format('time')) 339 | .prop('updatedAt', S.string().format('time')) 340 | 341 | const bodySchema = personSchema.without(['createdAt', 'updatedAt']) 342 | ``` 343 | 344 | ### Detect Fluent Schema objects 345 | 346 | Every Fluent Schema object contains a boolean `isFluentSchema`. In this way, you can write your own utilities that understand the Fluent Schema API and improve the user experience of your tool. 347 | 348 | ```js 349 | const S = require('fluent-json-schema') 350 | const schema = S.object().prop('foo', S.string()).prop('bar', S.number()) 351 | console.log(schema.isFluentSchema) // true 352 | ``` 353 | 354 | ## Documentation 355 | 356 | - [Full API Documentation](./docs/API.md). 357 | - [JSON schema draft-07 reference](https://json-schema.org/draft-07/draft-handrews-json-schema-01). 358 | 359 | ## Acknowledgments 360 | 361 | Thanks to [Matteo Collina](https://twitter.com/matteocollina) for pushing me to implement this utility! 🙏 362 | 363 | ## Related projects 364 | 365 | - JSON Schema [Draft 7](http://json-schema.org/specification-links.html#draft-7) 366 | - [Understanding JSON Schema](https://json-schema.org/understanding-json-schema/) (despite referring to draft 6 the guide is still good for grasping the main concepts) 367 | - [AJV](https://ajv.js.org/) JSON Schema validator 368 | - [jsonschema.net](https://www.jsonschema.net/) an online JSON Schema visual editor (it does not support advanced features) 369 | 370 | ## License 371 | 372 | Licensed under [MIT](./LICENSE). 373 | -------------------------------------------------------------------------------- /src/ObjectSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { BaseSchema } = require('./BaseSchema') 3 | const { 4 | omit, 5 | setAttribute, 6 | isFluentSchema, 7 | hasCombiningKeywords, 8 | patchIdsWithParentId, 9 | appendRequired, 10 | FluentSchemaError, 11 | combineDeepmerge 12 | } = require('./utils') 13 | 14 | const initialState = { 15 | type: 'object', 16 | definitions: [], 17 | properties: [], 18 | required: [] 19 | } 20 | 21 | /** 22 | * Represents a ObjectSchema. 23 | * @param {Object} [options] - Options 24 | * @param {StringSchema} [options.schema] - Default schema 25 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 26 | * @returns {StringSchema} 27 | */ 28 | 29 | const ObjectSchema = ({ schema = initialState, ...options } = {}) => { 30 | // TODO LS think about default values and how pass all of them through the functions 31 | options = { 32 | generateIds: false, 33 | factory: ObjectSchema, 34 | ...options 35 | } 36 | return { 37 | ...BaseSchema({ ...options, schema }), 38 | 39 | /** 40 | * It defines a URI for the schema, and the base URI that other URI references within the schema are resolved against. 41 | * Calling `id` on an ObjectSchema will alway set the id on the root of the object rather than in its "properties", which 42 | * differs from other schema types. 43 | * 44 | * {@link https://tools.ietf.org/html/draft-handrews-json-schema-01#section-8.2|reference} 45 | * @param {string} id - an #id 46 | **/ 47 | id: id => { 48 | if (!id) { 49 | throw new FluentSchemaError( 50 | 'id should not be an empty fragment <#> or an empty string <> (e.g. #myId)' 51 | ) 52 | } 53 | return options.factory({ schema: { ...schema, $id: id }, ...options }) 54 | }, 55 | /** 56 | * This keyword determines how child instances validate for objects, and does not directly validate the immediate instance itself. 57 | * Validation with "additionalProperties" applies only to the child values of instance names that do not match any names in "properties", 58 | * and do not match any regular expression in "patternProperties". 59 | * For all such properties, validation succeeds if the child instance validates against the "additionalProperties" schema. 60 | * Omitting this keyword has the same behavior as an empty schema. 61 | * 62 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.6|reference} 63 | * @param {FluentSchema|boolean} value 64 | * @returns {FluentSchema} 65 | */ 66 | 67 | additionalProperties: value => { 68 | if (typeof value === 'boolean') { 69 | return setAttribute({ schema, ...options }, [ 70 | 'additionalProperties', 71 | value, 72 | 'object' 73 | ]) 74 | } 75 | if (isFluentSchema(value)) { 76 | const { $schema, ...rest } = value.valueOf({ isRoot: false }) 77 | return setAttribute({ schema, ...options }, [ 78 | 'additionalProperties', 79 | { ...rest }, 80 | 'array' 81 | ]) 82 | } 83 | 84 | throw new FluentSchemaError( 85 | "'additionalProperties' must be a boolean or a S" 86 | ) 87 | }, 88 | 89 | /** 90 | * An object instance is valid against "maxProperties" if its number of properties is less than, or equal to, the value of this keyword. 91 | * 92 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.1|reference} 93 | * @param {number} max 94 | * @returns {FluentSchema} 95 | */ 96 | 97 | maxProperties: max => { 98 | if (!Number.isInteger(max)) { throw new FluentSchemaError("'maxProperties' must be a Integer") } 99 | return setAttribute({ schema, ...options }, [ 100 | 'maxProperties', 101 | max, 102 | 'object' 103 | ]) 104 | }, 105 | 106 | /** 107 | * An object instance is valid against "minProperties" if its number of properties is greater than, or equal to, the value of this keyword. 108 | * 109 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.2|reference} 110 | * @param {number} min 111 | * @returns {FluentSchema} 112 | */ 113 | 114 | minProperties: min => { 115 | if (!Number.isInteger(min)) { throw new FluentSchemaError("'minProperties' must be a Integer") } 116 | return setAttribute({ schema, ...options }, [ 117 | 'minProperties', 118 | min, 119 | 'object' 120 | ]) 121 | }, 122 | 123 | /** 124 | * Each property name of this object SHOULD be a valid regular expression, according to the ECMA 262 regular expression dialect. 125 | * Each property value of this object MUST be a valid JSON Schema. 126 | * This keyword determines how child instances validate for objects, and does not directly validate the immediate instance itself. 127 | * Validation of the primitive instance type against this keyword always succeeds. 128 | * Validation succeeds if, for each instance name that matches any regular expressions that appear as a property name in this keyword's value, the child instance for that name successfully validates against each schema that corresponds to a matching regular expression. 129 | * 130 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.5|reference} 131 | * @param {object} opts 132 | * @returns {FluentSchema} 133 | */ 134 | 135 | patternProperties: opts => { 136 | const values = Object.entries(opts).reduce((memo, [pattern, schema]) => { 137 | if (!isFluentSchema(schema)) { 138 | throw new FluentSchemaError( 139 | "'patternProperties' invalid options. Provide a valid map e.g. { '^fo.*$': S.string() }" 140 | ) 141 | } 142 | memo[pattern] = omit(schema.valueOf({ isRoot: false }), ['$schema']) 143 | return memo 144 | }, {}) 145 | return setAttribute({ schema, ...options }, [ 146 | 'patternProperties', 147 | values, 148 | 'object' 149 | ]) 150 | }, 151 | 152 | /** 153 | * This keyword specifies rules that are evaluated if the instance is an object and contains a certain property. 154 | * This keyword's value MUST be an object. Each property specifies a dependency. Each dependency value MUST be an array or a valid JSON Schema. 155 | * If the dependency value is a subschema, and the dependency key is a property in the instance, the entire instance must validate against the dependency value. 156 | * If the dependency value is an array, each element in the array, if any, MUST be a string, and MUST be unique. If the dependency key is a property in the instance, each of the items in the dependency value must be a property that exists in the instance. 157 | * 158 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.7|reference} 159 | * @param {object} opts 160 | * @returns {FluentSchema} 161 | */ 162 | 163 | dependencies: opts => { 164 | const values = Object.entries(opts).reduce((memo, [prop, schema]) => { 165 | if (!isFluentSchema(schema) && !Array.isArray(schema)) { 166 | throw new FluentSchemaError( 167 | "'dependencies' invalid options. Provide a valid map e.g. { 'foo': ['bar'] } or { 'foo': S.string() }" 168 | ) 169 | } 170 | memo[prop] = Array.isArray(schema) 171 | ? schema 172 | : omit(schema.valueOf({ isRoot: false }), ['$schema', 'type', 'definitions']) 173 | return memo 174 | }, {}) 175 | return setAttribute({ schema, ...options }, [ 176 | 'dependencies', 177 | values, 178 | 'object' 179 | ]) 180 | }, 181 | 182 | /** 183 | * The value of "properties" MUST be an object. Each dependency value MUST be an array. 184 | * Each element in the array MUST be a string and MUST be unique. If the dependency key is a property in the instance, each of the items in the dependency value must be a property that exists in the instance. 185 | * 186 | * {@link https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.5.4|reference} 187 | * @param {object} opts 188 | * @returns {FluentSchema} 189 | */ 190 | 191 | dependentRequired: opts => { 192 | const values = Object.entries(opts).reduce((memo, [prop, schema]) => { 193 | if (!Array.isArray(schema)) { 194 | throw new FluentSchemaError( 195 | "'dependentRequired' invalid options. Provide a valid array e.g. { 'foo': ['bar'] }" 196 | ) 197 | } 198 | memo[prop] = schema 199 | return memo 200 | }, {}) 201 | 202 | return setAttribute({ schema, ...options }, [ 203 | 'dependentRequired', 204 | values, 205 | 'object' 206 | ]) 207 | }, 208 | 209 | /** 210 | * The value of "properties" MUST be an object. The dependency value MUST be a valid JSON Schema. 211 | * Each dependency key is a property in the instance and the entire instance must validate against the dependency value. 212 | * 213 | * {@link https://json-schema.org/draft/2019-09/json-schema-core.html#rfc.section.9.2.2.4|reference} 214 | * @param {object} opts 215 | * @returns {FluentSchema} 216 | */ 217 | dependentSchemas: opts => { 218 | const values = Object.entries(opts).reduce((memo, [prop, schema]) => { 219 | if (!isFluentSchema(schema)) { 220 | throw new FluentSchemaError( 221 | "'dependentSchemas' invalid options. Provide a valid schema e.g. { 'foo': S.string() }" 222 | ) 223 | } 224 | 225 | memo[prop] = omit(schema.valueOf({ isRoot: false }), ['$schema', 'type', 'definitions']) 226 | return memo 227 | }, {}) 228 | 229 | return setAttribute({ schema, ...options }, [ 230 | 'dependentSchemas', 231 | values, 232 | 'object' 233 | ]) 234 | }, 235 | 236 | /** 237 | * If the instance is an object, this keyword validates if every property name in the instance validates against the provided schema. 238 | * Note the property name that the schema is testing will always be a string. 239 | * 240 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.8|reference} 241 | * @param {FluentSchema} value 242 | * @returns {FluentSchema} 243 | */ 244 | 245 | propertyNames: value => { 246 | if (!isFluentSchema(value)) { throw new FluentSchemaError("'propertyNames' must be a S") } 247 | return setAttribute({ schema, ...options }, [ 248 | 'propertyNames', 249 | omit(value.valueOf({ isRoot: false }), ['$schema']), 250 | 'object' 251 | ]) 252 | }, 253 | 254 | /** 255 | * The value of "properties" MUST be an object. Each value of this object MUST be a valid JSON Schema. 256 | * 257 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.4|reference} 258 | * @param {string} name 259 | * @param {FluentSchema} props 260 | * @returns {FluentSchema} 261 | */ 262 | 263 | prop: (name, props = {}) => { 264 | if (Array.isArray(props) || typeof props !== 'object') { 265 | throw new FluentSchemaError( 266 | `'${name}' doesn't support value '${JSON.stringify( 267 | props 268 | )}'. Pass a FluentSchema object` 269 | ) 270 | } 271 | const target = props.def ? 'definitions' : 'properties' 272 | let attributes = props.valueOf({ isRoot: false }) 273 | 274 | const { $ref, $id: attributeId, required, ...restAttributes } = attributes 275 | const $id = 276 | attributeId || 277 | (options.generateIds ? `#${target}/${name}` : undefined) 278 | if (isFluentSchema(props)) { 279 | attributes = patchIdsWithParentId({ 280 | schema: attributes, 281 | parentId: $id, 282 | ...options 283 | }) 284 | 285 | const [schemaPatched, attributesPatched] = appendRequired({ 286 | schema, 287 | attributes: { 288 | ...attributes, 289 | name 290 | } 291 | }) 292 | 293 | schema = schemaPatched 294 | attributes = attributesPatched 295 | } 296 | 297 | const type = hasCombiningKeywords(attributes) 298 | ? undefined 299 | : attributes.type 300 | 301 | // strip undefined values or empty arrays or internals 302 | attributes = Object.entries({ ...attributes, $id, type }).reduce( 303 | (memo, [key, value]) => { 304 | if ( 305 | key !== '$schema' && 306 | key !== 'def' && 307 | value !== undefined && 308 | !(Array.isArray(value) && value.length === 0 && key !== 'default') 309 | ) { 310 | memo[key] = value 311 | } 312 | return memo 313 | }, 314 | {} 315 | ) 316 | 317 | return ObjectSchema({ 318 | schema: { 319 | ...schema, 320 | [target]: [ 321 | ...schema[target], 322 | $ref ? { name, $ref, ...restAttributes } : { name, ...attributes } 323 | ] 324 | }, 325 | ...options 326 | }) 327 | }, 328 | 329 | extend: base => { 330 | if (!base) { 331 | throw new FluentSchemaError("Schema can't be null or undefined") 332 | } 333 | if (!base.isFluentSchema) { 334 | throw new FluentSchemaError("Schema isn't FluentSchema type") 335 | } 336 | const src = base._getState() 337 | const extended = combineDeepmerge(src, schema) 338 | const { 339 | valueOf, 340 | isFluentSchema, 341 | FLUENT_SCHEMA, 342 | _getState, 343 | extend 344 | } = ObjectSchema({ schema: extended, ...options }) 345 | return { valueOf, isFluentSchema, FLUENT_SCHEMA, _getState, extend } 346 | }, 347 | 348 | /** 349 | * Returns an object schema with only a subset of keys provided. If called on an ObjectSchema with an 350 | * `$id`, it will be removed and the return value will be considered a new schema. 351 | * 352 | * @param properties a list of properties you want to keep 353 | * @returns {ObjectSchema} 354 | */ 355 | only: properties => { 356 | return ObjectSchema({ 357 | schema: { 358 | ...omit(schema, ['$id', 'properties']), 359 | properties: schema.properties.filter(({ name }) => properties.includes(name)), 360 | required: schema.required.filter(p => properties.includes(p)) 361 | }, 362 | ...options 363 | }) 364 | }, 365 | 366 | /** 367 | * Returns an object schema without a subset of keys provided. If called on an ObjectSchema with an 368 | * `$id`, it will be removed and the return value will be considered a new schema. 369 | * 370 | * @param properties a list of properties you dont want to keep 371 | * @returns {ObjectSchema} 372 | */ 373 | without: properties => { 374 | return ObjectSchema({ 375 | schema: { 376 | ...omit(schema, ['$id', 'properties']), 377 | properties: schema.properties.filter(({ name }) => !properties.includes(name)), 378 | required: schema.required.filter(p => !properties.includes(p)) 379 | }, 380 | ...options 381 | }) 382 | }, 383 | 384 | /** 385 | * The "definitions" keywords provides a standardized location for schema authors to inline re-usable JSON Schemas into a more general schema. 386 | * There are no restrictions placed on the values within the array. 387 | * 388 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.9|reference} 389 | * @param {string} name 390 | * @param {FluentSchema} props 391 | * @returns {FluentSchema} 392 | */ 393 | // FIXME LS move to BaseSchema and remove .prop 394 | // TODO LS Is a definition a proper schema? 395 | definition: (name, props = {}) => 396 | ObjectSchema({ schema, ...options }).prop(name, { 397 | ...props.valueOf({ isRoot: false }), 398 | def: true 399 | }) 400 | } 401 | } 402 | 403 | module.exports = { 404 | ObjectSchema, 405 | default: ObjectSchema 406 | } 407 | -------------------------------------------------------------------------------- /src/BaseSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const { 3 | flat, 4 | omit, 5 | isFluentSchema, 6 | last, 7 | isBoolean, 8 | isUniq, 9 | patchIdsWithParentId, 10 | REQUIRED, 11 | setAttribute, 12 | setRaw, 13 | setComposeType, 14 | FluentSchemaError, 15 | FLUENT_SCHEMA 16 | } = require('./utils') 17 | 18 | const initialState = { 19 | properties: [], 20 | required: [] 21 | } 22 | 23 | /** 24 | * Represents a BaseSchema. 25 | * @param {Object} [options] - Options 26 | * @param {BaseSchema} [options.schema] - Default schema 27 | * @param {boolean} [options.generateIds = false] - generate the id automatically e.g. #properties.foo 28 | * @returns {BaseSchema} 29 | */ 30 | 31 | const BaseSchema = ( 32 | { schema = initialState, ...options } = { 33 | generateIds: false, 34 | factory: BaseSchema 35 | } 36 | ) => ({ 37 | [FLUENT_SCHEMA]: true, 38 | isFluentSchema: true, 39 | isFluentJSONSchema: true, 40 | 41 | /** 42 | * It defines a URI for the schema, and the base URI that other URI references within the schema are resolved against. 43 | * 44 | * {@link https://tools.ietf.org/html/draft-handrews-json-schema-01#section-8.2|reference} 45 | * @param {string} id - an #id 46 | * @returns {BaseSchema} 47 | */ 48 | 49 | id: id => { 50 | if (!id) { 51 | throw new FluentSchemaError( 52 | 'id should not be an empty fragment <#> or an empty string <> (e.g. #myId)' 53 | ) 54 | } 55 | return setAttribute({ schema, ...options }, ['$id', id, 'any']) 56 | }, 57 | 58 | /** 59 | * It can be used to decorate a user interface with information about the data produced by this user interface. A title will preferably be short. 60 | * 61 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.10.1|reference} 62 | * @param {string} title 63 | * @returns {BaseSchema} 64 | */ 65 | 66 | title: title => { 67 | return setAttribute({ schema, ...options }, ['title', title, 'any']) 68 | }, 69 | 70 | /** 71 | * It can be used to decorate a user interface with information about the data 72 | * produced by this user interface. A description provides explanation about 73 | * the purpose of the instance described by the schema. 74 | * 75 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.10.1|reference} 76 | * @param {string} description 77 | * @returns {BaseSchema} 78 | */ 79 | description: description => { 80 | return setAttribute({ schema, ...options }, [ 81 | 'description', 82 | description, 83 | 'any' 84 | ]) 85 | }, 86 | 87 | /** 88 | * The value of this keyword MUST be an array. 89 | * There are no restrictions placed on the values within the array. 90 | * 91 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.10.4|reference} 92 | * @param {string} examples 93 | * @returns {BaseSchema} 94 | */ 95 | 96 | examples: examples => { 97 | if (!Array.isArray(examples)) { 98 | throw new FluentSchemaError( 99 | "'examples' must be an array e.g. ['1', 'one', 'foo']" 100 | ) 101 | } 102 | return setAttribute({ schema, ...options }, ['examples', examples, 'any']) 103 | }, 104 | 105 | /** 106 | * The value must be a valid id e.g. #properties/foo 107 | * 108 | * @param {string} ref 109 | * @returns {BaseSchema} 110 | */ 111 | 112 | ref: ref => { 113 | return setAttribute({ schema, ...options }, ['$ref', ref, 'any']) 114 | }, 115 | 116 | /** 117 | * The value of this keyword MUST be an array. This array SHOULD have at least one element. Elements in the array SHOULD be unique. 118 | * 119 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.1.2|reference} 120 | * @param {array} values 121 | * @returns {BaseSchema} 122 | */ 123 | 124 | enum: values => { 125 | if (!Array.isArray(values)) { 126 | throw new FluentSchemaError( 127 | "'enums' must be an array with at least an element e.g. ['1', 'one', 'foo']" 128 | ) 129 | } 130 | return setAttribute({ schema, ...options }, ['enum', values, 'any']) 131 | }, 132 | 133 | /** 134 | * The value of this keyword MAY be of any type, including null. 135 | * 136 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.1.3|reference} 137 | * @param value 138 | * @returns {BaseSchema} 139 | */ 140 | 141 | const: value => { 142 | return setAttribute({ schema, ...options }, ['const', value, 'any']) 143 | }, 144 | 145 | /** 146 | * There are no restrictions placed on the value of this keyword. 147 | * 148 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.10.2|reference} 149 | * @param defaults 150 | * @returns {BaseSchema} 151 | */ 152 | 153 | default: defaults => { 154 | return setAttribute({ schema, ...options }, ['default', defaults, 'any']) 155 | }, 156 | 157 | /** 158 | * The value of readOnly can be left empty to indicate the property is readOnly. 159 | * It takes an optional boolean which can be used to explicitly set readOnly true/false. 160 | * 161 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.10.3|reference} 162 | * @param {boolean|undefined} isReadOnly 163 | * @returns {BaseSchema} 164 | */ 165 | 166 | readOnly: isReadOnly => { 167 | const value = isReadOnly !== undefined ? isReadOnly : true 168 | return setAttribute({ schema, ...options }, ['readOnly', value, 'boolean']) 169 | }, 170 | 171 | /** 172 | * The value of writeOnly can be left empty to indicate the property is writeOnly. 173 | * It takes an optional boolean which can be used to explicitly set writeOnly true/false. 174 | * 175 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.10.3|reference} 176 | * @param {boolean|undefined} isWriteOnly 177 | * @returns {BaseSchema} 178 | */ 179 | 180 | writeOnly: isWriteOnly => { 181 | const value = isWriteOnly !== undefined ? isWriteOnly : true 182 | return setAttribute({ schema, ...options }, ['writeOnly', value, 'boolean']) 183 | }, 184 | 185 | /** 186 | * The value of deprecated can be left empty to indicate the property is deprecated. 187 | * It takes an optional boolean which can be used to explicitly set deprecated true/false. 188 | * 189 | * {@link https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.9.3|reference} 190 | * @param {Boolean} isDeprecated 191 | * @returns {BaseSchema} 192 | */ 193 | deprecated: (isDeprecated) => { 194 | if (isDeprecated && !isBoolean(isDeprecated)) throw new FluentSchemaError("'deprecated' must be a boolean value") 195 | const value = isDeprecated !== undefined ? isDeprecated : true 196 | return setAttribute({ schema, ...options }, ['deprecated', value, 'boolean']) 197 | }, 198 | 199 | /** 200 | * Required has to be chained to a property: 201 | * Examples: 202 | * - S.prop('prop').required() 203 | * - S.prop('prop', S.number()).required() 204 | * - S.required(['foo', 'bar']) 205 | * 206 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.5.3|reference} 207 | * @returns {FluentSchema} 208 | */ 209 | required: props => { 210 | const currentProp = last(schema.properties) 211 | const required = Array.isArray(props) 212 | ? [...schema.required, ...props] 213 | : currentProp 214 | ? [...schema.required, currentProp.name] 215 | : [REQUIRED] 216 | 217 | if (!isUniq(required)) { 218 | throw new FluentSchemaError("'required' has repeated keys, check your calls to .required()") 219 | } 220 | 221 | return options.factory({ 222 | schema: { ...schema, required }, 223 | ...options 224 | }) 225 | }, 226 | 227 | /** 228 | * This keyword's value MUST be a valid JSON Schema. 229 | * An instance is valid against this keyword if it fails to validate successfully against the schema defined by this keyword. 230 | * 231 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.7.4|reference} 232 | * @param {FluentSchema} not 233 | * @returns {BaseSchema} 234 | */ 235 | not: not => { 236 | if (!isFluentSchema(not)) { throw new FluentSchemaError("'not' must be a BaseSchema") } 237 | const notSchema = omit(not.valueOf(), ['$schema', 'definitions']) 238 | 239 | return BaseSchema({ 240 | schema: { 241 | ...schema, 242 | not: patchIdsWithParentId({ 243 | schema: notSchema, 244 | ...options, 245 | parentId: '#not' 246 | }) 247 | }, 248 | ...options 249 | }) 250 | }, 251 | // return setAttribute({ schema, ...options }, ['defaults', defaults, 'any']) 252 | 253 | /** 254 | * It MUST be a non-empty array. Each item of the array MUST be a valid JSON Schema. 255 | * 256 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.7.2|reference} 257 | * @param {array} schemas 258 | * @returns {BaseSchema} 259 | */ 260 | 261 | anyOf: schemas => setComposeType({ prop: 'anyOf', schemas, schema, options }), 262 | 263 | /** 264 | * It MUST be a non-empty array. Each item of the array MUST be a valid JSON Schema. 265 | * 266 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.7.1|reference} 267 | * @param {array} schemas 268 | * @returns {BaseSchema} 269 | */ 270 | 271 | allOf: schemas => setComposeType({ prop: 'allOf', schemas, schema, options }), 272 | 273 | /** 274 | * It MUST be a non-empty array. Each item of the array MUST be a valid JSON Schema. 275 | * 276 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.7.3|reference} 277 | * @param {array} schemas 278 | * @returns {BaseSchema} 279 | */ 280 | 281 | oneOf: schemas => setComposeType({ prop: 'oneOf', schemas, schema, options }), 282 | 283 | /** 284 | * @private set a property to a type. Use string number etc. 285 | * @returns {BaseSchema} 286 | */ 287 | as: type => setAttribute({ schema, ...options }, ['type', type]), 288 | 289 | /** 290 | * This validation outcome of this keyword's subschema has no direct effect on the overall validation result. 291 | * Rather, it controls which of the "then" or "else" keywords are evaluated. 292 | * When "if" is present, and the instance successfully validates against its subschema, then 293 | * validation succeeds against this keyword if the instance also successfully validates against this keyword's subschema. 294 | * 295 | * @param {BaseSchema} ifClause 296 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.6.1|reference} 297 | * @param {BaseSchema} thenClause 298 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.6.2|reference} 299 | * @returns {BaseSchema} 300 | */ 301 | 302 | ifThen: (ifClause, thenClause) => { 303 | if (!isFluentSchema(ifClause)) { throw new FluentSchemaError("'ifClause' must be a BaseSchema") } 304 | if (!isFluentSchema(thenClause)) { throw new FluentSchemaError("'thenClause' must be a BaseSchema") } 305 | 306 | const ifClauseSchema = omit(ifClause.valueOf(), [ 307 | '$schema', 308 | 'definitions', 309 | 'type' 310 | ]) 311 | const thenClauseSchema = omit(thenClause.valueOf(), [ 312 | '$schema', 313 | 'definitions', 314 | 'type' 315 | ]) 316 | 317 | return options.factory({ 318 | schema: { 319 | ...schema, 320 | if: patchIdsWithParentId({ 321 | schema: ifClauseSchema, 322 | ...options, 323 | parentId: '#if' 324 | }), 325 | then: patchIdsWithParentId({ 326 | schema: thenClauseSchema, 327 | ...options, 328 | parentId: '#then' 329 | }) 330 | }, 331 | ...options 332 | }) 333 | }, 334 | 335 | /** 336 | * When "if" is present, and the instance fails to validate against its subschema, 337 | * then validation succeeds against this keyword if the instance successfully validates against this keyword's subschema. 338 | * 339 | * @param {BaseSchema} ifClause 340 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.6.1|reference} 341 | * @param {BaseSchema} thenClause 342 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.6.2|reference} 343 | * @param {BaseSchema} elseClause 344 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.6.3|reference} 345 | * @returns {BaseSchema} 346 | */ 347 | 348 | ifThenElse: (ifClause, thenClause, elseClause) => { 349 | if (!isFluentSchema(ifClause)) { throw new FluentSchemaError("'ifClause' must be a BaseSchema") } 350 | if (!isFluentSchema(thenClause)) { throw new FluentSchemaError("'thenClause' must be a BaseSchema") } 351 | if (!isFluentSchema(elseClause)) { 352 | throw new FluentSchemaError( 353 | "'elseClause' must be a BaseSchema or a false boolean value" 354 | ) 355 | } 356 | const ifClauseSchema = omit(ifClause.valueOf(), [ 357 | '$schema', 358 | 'definitions', 359 | 'type' 360 | ]) 361 | const thenClauseSchema = omit(thenClause.valueOf(), [ 362 | '$schema', 363 | 'definitions', 364 | 'type' 365 | ]) 366 | const elseClauseSchema = omit(elseClause.valueOf(), [ 367 | '$schema', 368 | 'definitions', 369 | 'type' 370 | ]) 371 | 372 | return options.factory({ 373 | schema: { 374 | ...schema, 375 | if: patchIdsWithParentId({ 376 | schema: ifClauseSchema, 377 | ...options, 378 | parentId: '#if' 379 | }), 380 | then: patchIdsWithParentId({ 381 | schema: thenClauseSchema, 382 | ...options, 383 | parentId: '#then' 384 | }), 385 | else: patchIdsWithParentId({ 386 | schema: elseClauseSchema, 387 | ...options, 388 | parentId: '#else' 389 | }) 390 | }, 391 | ...options 392 | }) 393 | }, 394 | 395 | /** 396 | * Because the differences between JSON Schemas and Open API (Swagger) 397 | * it can be handy to arbitrary modify the schema injecting a fragment 398 | * 399 | * * Examples: 400 | * - S.number().raw({ nullable:true }) 401 | * - S.string().format('date').raw({ formatMaximum: '2020-01-01' }) 402 | * 403 | * @param {string} fragment an arbitrary JSON Schema to inject 404 | * {@link https://tools.ietf.org/id/draft-handrews-json-schema-validation-01.html#rfc.section.6.3.3|reference} 405 | * @returns {BaseSchema} 406 | */ 407 | raw: fragment => { 408 | return setRaw({ schema, ...options }, fragment) 409 | }, 410 | 411 | /** 412 | * @private It returns the internal schema data structure 413 | * @returns {object} 414 | */ 415 | // TODO LS if we implement S.raw() we can drop this hack because from a JSON we can rebuild a fluent-json-schema 416 | _getState: () => { 417 | return schema 418 | }, 419 | 420 | /** 421 | * It returns all the schema values 422 | * 423 | * @param {Object} [options] - Options 424 | * @param {boolean} [options.isRoot = true] - Is a root level schema 425 | * @returns {object} 426 | */ 427 | valueOf: ({ isRoot } = { isRoot: true }) => { 428 | const { properties, definitions, required, $schema, ...rest } = schema 429 | 430 | if (isRoot && required && !required.every((v) => typeof v === 'string')) { 431 | throw new FluentSchemaError("'required' has called on root-level schema, check your calls to .required()") 432 | } 433 | 434 | return Object.assign( 435 | $schema ? { $schema } : {}, 436 | Object.keys(definitions || []).length > 0 437 | ? { definitions: flat(definitions) } 438 | : undefined, 439 | { ...omit(rest, ['if', 'then', 'else']) }, 440 | Object.keys(properties || []).length > 0 441 | ? { properties: flat(properties) } 442 | : undefined, 443 | required && required.length > 0 ? { required } : undefined, 444 | schema.if ? { if: schema.if } : undefined, 445 | schema.then ? { then: schema.then } : undefined, 446 | schema.else ? { else: schema.else } : undefined 447 | ) 448 | } 449 | }) 450 | 451 | module.exports = { 452 | BaseSchema, 453 | default: BaseSchema 454 | } 455 | -------------------------------------------------------------------------------- /src/FluentSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const S = require('./FluentJSONSchema') 7 | 8 | describe('S', () => { 9 | it('defined', () => { 10 | assert.notStrictEqual(S, undefined) 11 | }) 12 | 13 | describe('factory', () => { 14 | it('without params', () => { 15 | assert.deepStrictEqual(S.object().valueOf(), { 16 | $schema: 'http://json-schema.org/draft-07/schema#', 17 | type: 'object' 18 | }) 19 | }) 20 | 21 | describe('generatedIds', () => { 22 | describe('properties', () => { 23 | it('true', () => { 24 | assert.deepStrictEqual( 25 | S.withOptions({ generateIds: true }) 26 | .object() 27 | .prop('prop', S.string()) 28 | .valueOf(), 29 | { 30 | $schema: 'http://json-schema.org/draft-07/schema#', 31 | properties: { prop: { $id: '#properties/prop', type: 'string' } }, 32 | type: 'object' 33 | } 34 | ) 35 | }) 36 | 37 | it('false', () => { 38 | assert.deepStrictEqual( 39 | S.object().prop('prop', S.string()).valueOf(), 40 | { 41 | $schema: 'http://json-schema.org/draft-07/schema#', 42 | properties: { prop: { type: 'string' } }, 43 | type: 'object' 44 | } 45 | ) 46 | }) 47 | 48 | describe('nested', () => { 49 | it('true', () => { 50 | assert.deepStrictEqual( 51 | S.withOptions({ generateIds: true }) 52 | .object() 53 | .prop('foo', S.object().prop('bar', S.string()).required()) 54 | .valueOf(), 55 | { 56 | $schema: 'http://json-schema.org/draft-07/schema#', 57 | properties: { 58 | foo: { 59 | $id: '#properties/foo', 60 | properties: { 61 | bar: { 62 | $id: '#properties/foo/properties/bar', 63 | type: 'string' 64 | } 65 | }, 66 | required: ['bar'], 67 | type: 'object' 68 | } 69 | }, 70 | type: 'object' 71 | } 72 | ) 73 | }) 74 | it('false', () => { 75 | const id = 'myId' 76 | assert.deepStrictEqual( 77 | S.object() 78 | .prop( 79 | 'foo', 80 | S.object() 81 | .prop('bar', S.string().id(id)) 82 | 83 | .required() 84 | ) 85 | .valueOf(), 86 | { 87 | $schema: 'http://json-schema.org/draft-07/schema#', 88 | properties: { 89 | foo: { 90 | properties: { 91 | bar: { $id: 'myId', type: 'string' } 92 | }, 93 | required: ['bar'], 94 | type: 'object' 95 | } 96 | }, 97 | type: 'object' 98 | } 99 | ) 100 | }) 101 | }) 102 | }) 103 | // TODO LS not sure the test makes sense 104 | describe('definitions', () => { 105 | it('true', () => { 106 | assert.deepStrictEqual( 107 | S.withOptions({ generateIds: true }) 108 | .object() 109 | .definition( 110 | 'entity', 111 | S.object().prop('foo', S.string()).prop('bar', S.string()) 112 | ) 113 | .prop('prop') 114 | .ref('entity') 115 | .valueOf(), 116 | { 117 | $schema: 'http://json-schema.org/draft-07/schema#', 118 | definitions: { 119 | entity: { 120 | $id: '#definitions/entity', 121 | properties: { 122 | bar: { 123 | type: 'string' 124 | }, 125 | foo: { 126 | type: 'string' 127 | } 128 | }, 129 | type: 'object' 130 | } 131 | }, 132 | properties: { 133 | prop: { 134 | $ref: 'entity' 135 | } 136 | }, 137 | type: 'object' 138 | } 139 | ) 140 | }) 141 | 142 | it('false', () => { 143 | assert.deepStrictEqual( 144 | S.withOptions({ generateIds: false }) 145 | .object() 146 | .definition( 147 | 'entity', 148 | S.object().id('myCustomId').prop('foo', S.string()) 149 | ) 150 | .prop('prop') 151 | .ref('entity') 152 | .valueOf(), 153 | { 154 | $schema: 'http://json-schema.org/draft-07/schema#', 155 | definitions: { 156 | entity: { 157 | $id: 'myCustomId', 158 | properties: { 159 | foo: { type: 'string' } 160 | }, 161 | type: 'object' 162 | } 163 | }, 164 | properties: { 165 | prop: { 166 | $ref: 'entity' 167 | } 168 | }, 169 | type: 'object' 170 | } 171 | ) 172 | }) 173 | 174 | it('nested', () => { 175 | const id = 'myId' 176 | assert.deepStrictEqual( 177 | S.object() 178 | .prop('foo', S.object().prop('bar', S.string().id(id)).required()) 179 | .valueOf(), 180 | { 181 | $schema: 'http://json-schema.org/draft-07/schema#', 182 | properties: { 183 | foo: { 184 | properties: { 185 | bar: { $id: 'myId', type: 'string' } 186 | }, 187 | required: ['bar'], 188 | type: 'object' 189 | } 190 | }, 191 | type: 'object' 192 | } 193 | ) 194 | }) 195 | }) 196 | }) 197 | }) 198 | 199 | describe('composition', () => { 200 | it('anyOf', () => { 201 | const schema = S.object() 202 | .prop('foo', S.anyOf([S.string()])) 203 | .valueOf() 204 | assert.deepStrictEqual(schema, { 205 | $schema: 'http://json-schema.org/draft-07/schema#', 206 | properties: { foo: { anyOf: [{ type: 'string' }] } }, 207 | type: 'object' 208 | }) 209 | }) 210 | 211 | it('oneOf', () => { 212 | const schema = S.object() 213 | .prop( 214 | 'multipleRestrictedTypesKey', 215 | S.oneOf([S.string(), S.number().minimum(10)]) 216 | ) 217 | .prop('notTypeKey', S.not(S.oneOf([S.string().pattern('js$')]))) 218 | .valueOf() 219 | assert.deepStrictEqual(schema, { 220 | $schema: 'http://json-schema.org/draft-07/schema#', 221 | properties: { 222 | multipleRestrictedTypesKey: { 223 | oneOf: [{ type: 'string' }, { minimum: 10, type: 'number' }] 224 | }, 225 | notTypeKey: { not: { oneOf: [{ pattern: 'js$', type: 'string' }] } } 226 | }, 227 | type: 'object' 228 | }) 229 | }) 230 | }) 231 | 232 | it('valueOf', () => { 233 | assert.deepStrictEqual(S.object().prop('foo', S.string()).valueOf(), { 234 | $schema: 'http://json-schema.org/draft-07/schema#', 235 | properties: { foo: { type: 'string' } }, 236 | type: 'object' 237 | }) 238 | }) 239 | 240 | it('works', () => { 241 | const schema = S.object() 242 | .id('http://foo.com/user') 243 | .title('A User') 244 | .description('A User desc') 245 | .definition( 246 | 'address', 247 | S.object() 248 | .id('#address') 249 | .prop('country', S.string()) 250 | .prop('city', S.string()) 251 | .prop('zipcode', S.string()) 252 | ) 253 | .prop('username', S.string()) 254 | .required() 255 | .prop('password', S.string()) 256 | .required() 257 | .prop('address', S.ref('#address')) 258 | 259 | .required() 260 | .prop( 261 | 'role', 262 | S.object() 263 | .id('http://foo.com/role') 264 | .prop('name', S.string()) 265 | .prop('permissions', S.string()) 266 | ) 267 | .required() 268 | .prop('age', S.number()) 269 | 270 | .valueOf() 271 | 272 | assert.deepStrictEqual(schema, { 273 | definitions: { 274 | address: { 275 | type: 'object', 276 | $id: '#address', 277 | properties: { 278 | country: { 279 | type: 'string' 280 | }, 281 | city: { 282 | type: 'string' 283 | }, 284 | zipcode: { 285 | type: 'string' 286 | } 287 | } 288 | } 289 | }, 290 | $schema: 'http://json-schema.org/draft-07/schema#', 291 | type: 'object', 292 | required: ['username', 'password', 'address', 'role'], 293 | $id: 'http://foo.com/user', 294 | title: 'A User', 295 | description: 'A User desc', 296 | properties: { 297 | username: { 298 | type: 'string' 299 | }, 300 | password: { 301 | type: 'string' 302 | }, 303 | address: { 304 | $ref: '#address' 305 | }, 306 | age: { 307 | type: 'number' 308 | }, 309 | role: { 310 | type: 'object', 311 | $id: 'http://foo.com/role', 312 | properties: { 313 | name: { 314 | $id: undefined, 315 | type: 'string' 316 | }, 317 | permissions: { 318 | $id: undefined, 319 | type: 'string' 320 | } 321 | } 322 | } 323 | } 324 | }) 325 | }) 326 | 327 | describe('raw', () => { 328 | describe('base', () => { 329 | it('parses type', () => { 330 | const input = S.enum(['foo']).valueOf() 331 | const schema = S.raw(input) 332 | assert.ok(schema.isFluentSchema) 333 | assert.deepStrictEqual(schema.valueOf(), { 334 | ...input 335 | }) 336 | }) 337 | 338 | it('adds an attribute', () => { 339 | const input = S.enum(['foo']).valueOf() 340 | const schema = S.raw(input) 341 | const attribute = 'title' 342 | const modified = schema.title(attribute) 343 | assert.ok(schema.isFluentSchema) 344 | assert.deepStrictEqual(modified.valueOf(), { 345 | ...input, 346 | title: attribute 347 | }) 348 | }) 349 | }) 350 | 351 | describe('string', () => { 352 | it('parses type', () => { 353 | const input = S.string().valueOf() 354 | const schema = S.raw(input) 355 | assert.ok(schema.isFluentSchema) 356 | assert.deepStrictEqual(schema.valueOf(), { 357 | ...input 358 | }) 359 | }) 360 | 361 | it('adds an attribute', () => { 362 | const input = S.string().valueOf() 363 | const schema = S.raw(input) 364 | const modified = schema.minLength(3) 365 | assert.ok(schema.isFluentSchema) 366 | assert.deepStrictEqual(modified.valueOf(), { 367 | minLength: 3, 368 | ...input 369 | }) 370 | }) 371 | 372 | it('parses a prop', () => { 373 | const input = S.string().minLength(5).valueOf() 374 | const schema = S.raw(input) 375 | assert.ok(schema.isFluentSchema) 376 | assert.deepStrictEqual(schema.valueOf(), { 377 | ...input 378 | }) 379 | }) 380 | }) 381 | 382 | describe('number', () => { 383 | it('parses type', () => { 384 | const input = S.number().valueOf() 385 | const schema = S.raw(input) 386 | assert.ok(schema.isFluentSchema) 387 | assert.deepStrictEqual(schema.valueOf(), { 388 | ...input 389 | }) 390 | }) 391 | 392 | it('adds an attribute', () => { 393 | const input = S.number().valueOf() 394 | const schema = S.raw(input) 395 | const modified = schema.maximum(3) 396 | assert.ok(schema.isFluentSchema) 397 | assert.deepStrictEqual(modified.valueOf(), { 398 | maximum: 3, 399 | ...input 400 | }) 401 | }) 402 | 403 | it('parses a prop', () => { 404 | const input = S.number().maximum(5).valueOf() 405 | const schema = S.raw(input) 406 | assert.ok(schema.isFluentSchema) 407 | assert.deepStrictEqual(schema.valueOf(), { 408 | ...input 409 | }) 410 | }) 411 | }) 412 | 413 | describe('integer', () => { 414 | it('parses type', () => { 415 | const input = S.integer().valueOf() 416 | const schema = S.raw(input) 417 | assert.ok(schema.isFluentSchema) 418 | assert.deepStrictEqual(schema.valueOf(), { 419 | ...input 420 | }) 421 | }) 422 | 423 | it('adds an attribute', () => { 424 | const input = S.integer().valueOf() 425 | const schema = S.raw(input) 426 | const modified = schema.maximum(3) 427 | assert.ok(schema.isFluentSchema) 428 | assert.deepStrictEqual(modified.valueOf(), { 429 | maximum: 3, 430 | ...input 431 | }) 432 | }) 433 | 434 | it('parses a prop', () => { 435 | const input = S.integer().maximum(5).valueOf() 436 | const schema = S.raw(input) 437 | assert.ok(schema.isFluentSchema) 438 | assert.deepStrictEqual(schema.valueOf(), { 439 | ...input 440 | }) 441 | }) 442 | }) 443 | 444 | describe('boolean', () => { 445 | it('parses type', () => { 446 | const input = S.boolean().valueOf() 447 | const schema = S.raw(input) 448 | assert.ok(schema.isFluentSchema) 449 | assert.deepStrictEqual(schema.valueOf(), { 450 | ...input 451 | }) 452 | }) 453 | }) 454 | 455 | describe('object', () => { 456 | it('parses type', () => { 457 | const input = S.object().valueOf() 458 | const schema = S.raw(input) 459 | assert.ok(schema.isFluentSchema) 460 | assert.deepStrictEqual(schema.valueOf(), { 461 | ...input 462 | }) 463 | }) 464 | 465 | it('parses properties', () => { 466 | const input = S.object().prop('foo').prop('bar', S.string()).valueOf() 467 | const schema = S.raw(input) 468 | assert.ok(schema.isFluentSchema) 469 | assert.deepStrictEqual(schema.valueOf(), { 470 | ...input 471 | }) 472 | }) 473 | 474 | it('parses nested properties', () => { 475 | const input = S.object() 476 | .prop('foo', S.object().prop('bar', S.string().minLength(3))) 477 | .valueOf() 478 | const schema = S.raw(input) 479 | const modified = schema.prop('boom') 480 | assert.ok(modified.isFluentSchema) 481 | assert.deepStrictEqual(modified.valueOf(), { 482 | ...input, 483 | properties: { 484 | ...input.properties, 485 | boom: {} 486 | } 487 | }) 488 | }) 489 | 490 | it('parses definitions', () => { 491 | const input = S.object().definition('foo', S.string()).valueOf() 492 | const schema = S.raw(input) 493 | assert.ok(schema.isFluentSchema) 494 | assert.deepStrictEqual(schema.valueOf(), { 495 | ...input 496 | }) 497 | }) 498 | }) 499 | 500 | describe('array', () => { 501 | it('parses type', () => { 502 | const input = S.array().items(S.string()).valueOf() 503 | const schema = S.raw(input) 504 | assert.ok(schema.isFluentSchema) 505 | assert.deepStrictEqual(schema.valueOf(), { 506 | ...input 507 | }) 508 | }) 509 | 510 | it('parses properties', () => { 511 | const input = S.array().items(S.string()).valueOf() 512 | 513 | const schema = S.raw(input).maxItems(1) 514 | assert.ok(schema.isFluentSchema) 515 | assert.deepStrictEqual(schema.valueOf(), { 516 | ...input, 517 | maxItems: 1 518 | }) 519 | }) 520 | 521 | it('parses nested properties', () => { 522 | const input = S.array() 523 | .items( 524 | S.object().prop( 525 | 'foo', 526 | S.object().prop('bar', S.string().minLength(3)) 527 | ) 528 | ) 529 | .valueOf() 530 | const schema = S.raw(input) 531 | const modified = schema.maxItems(1) 532 | assert.ok(modified.isFluentSchema) 533 | assert.deepStrictEqual(modified.valueOf(), { 534 | ...input, 535 | maxItems: 1 536 | }) 537 | }) 538 | 539 | it('parses definitions', () => { 540 | const input = S.object().definition('foo', S.string()).valueOf() 541 | const schema = S.raw(input) 542 | assert.ok(schema.isFluentSchema) 543 | assert.deepStrictEqual(schema.valueOf(), { 544 | ...input 545 | }) 546 | }) 547 | }) 548 | }) 549 | }) 550 | -------------------------------------------------------------------------------- /src/FluentSchema.integration.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const Ajv = require('ajv') 7 | 8 | const basic = require('./schemas/basic') 9 | const S = require('./FluentJSONSchema') 10 | 11 | // TODO pick some ideas from here:https://github.com/json-schema-org/JSON-Schema-Test-Suite/tree/main/tests/draft7 12 | 13 | describe('S', () => { 14 | it('compiles', () => { 15 | const ajv = new Ajv() 16 | const schema = S.valueOf() 17 | const validate = ajv.compile(schema) 18 | const valid = validate({}) 19 | assert.ok(valid) 20 | }) 21 | 22 | describe('basic', () => { 23 | const ajv = new Ajv() 24 | const schema = S.object() 25 | .prop('username', S.string()) 26 | .prop('password', S.string()) 27 | .valueOf() 28 | const validate = ajv.compile(schema) 29 | 30 | it('valid', () => { 31 | const valid = validate({ 32 | username: 'username', 33 | password: 'password' 34 | }) 35 | assert.ok(valid) 36 | }) 37 | 38 | it('invalid', () => { 39 | const valid = validate({ 40 | username: 'username', 41 | password: 1 42 | }) 43 | assert.deepStrictEqual(validate.errors, [ 44 | { 45 | instancePath: '/password', 46 | keyword: 'type', 47 | message: 'must be string', 48 | params: { type: 'string' }, 49 | schemaPath: '#/properties/password/type' 50 | } 51 | ]) 52 | assert.ok(!valid) 53 | }) 54 | }) 55 | 56 | describe('ifThen', () => { 57 | const ajv = new Ajv() 58 | const schema = S.object() 59 | .prop('prop', S.string().maxLength(5)) 60 | .ifThen( 61 | S.object().prop('prop', S.string().maxLength(5)), 62 | S.object().prop('extraProp', S.string()).required() 63 | ) 64 | .valueOf() 65 | const validate = ajv.compile(schema) 66 | 67 | it('valid', () => { 68 | const valid = validate({ 69 | prop: '12345', 70 | extraProp: 'foo' 71 | }) 72 | assert.ok(valid) 73 | }) 74 | 75 | it('invalid', () => { 76 | const valid = validate({ 77 | prop: '12345' 78 | }) 79 | assert.deepStrictEqual(validate.errors, [ 80 | { 81 | instancePath: '', 82 | keyword: 'required', 83 | message: "must have required property 'extraProp'", 84 | params: { missingProperty: 'extraProp' }, 85 | schemaPath: '#/then/required' 86 | } 87 | ]) 88 | assert.ok(!valid) 89 | }) 90 | }) 91 | 92 | describe('ifThenElse', () => { 93 | const ajv = new Ajv() 94 | 95 | const VALUES = ['ONE', 'TWO'] 96 | const schema = S.object() 97 | .prop('ifProp') 98 | .ifThenElse( 99 | S.object().prop('ifProp', S.string().enum([VALUES[0]])), 100 | S.object().prop('thenProp', S.string()).required(), 101 | S.object().prop('elseProp', S.string()).required() 102 | ) 103 | .valueOf() 104 | 105 | const validate = ajv.compile(schema) 106 | 107 | it('then', () => { 108 | const valid = validate({ 109 | ifProp: 'ONE', 110 | thenProp: 'foo' 111 | }) 112 | assert.ok(valid) 113 | }) 114 | 115 | it('else', () => { 116 | const valid = validate({ 117 | prop: '123456' 118 | }) 119 | assert.deepStrictEqual(validate.errors, [ 120 | { 121 | instancePath: '', 122 | keyword: 'required', 123 | message: "must have required property 'thenProp'", 124 | params: { missingProperty: 'thenProp' }, 125 | schemaPath: '#/then/required' 126 | } 127 | ]) 128 | assert.ok(!valid) 129 | }) 130 | }) 131 | 132 | describe('combine and definition', () => { 133 | const ajv = new Ajv() 134 | const schema = S.object() // FIXME LS it shouldn't be object() 135 | .definition( 136 | 'address', 137 | S.object() 138 | .id('#/definitions/address') 139 | .prop('street_address', S.string()) 140 | .required() 141 | .prop('city', S.string()) 142 | .required() 143 | .prop('state', S.string().required()) 144 | ) 145 | .allOf([ 146 | S.ref('#/definitions/address'), 147 | S.object().prop('type', S.string()).enum(['residential', 'business']) 148 | ]) 149 | .valueOf() 150 | const validate = ajv.compile(schema) 151 | it('matches', () => { 152 | assert.deepStrictEqual(schema, { 153 | $schema: 'http://json-schema.org/draft-07/schema#', 154 | type: 'object', 155 | definitions: { 156 | address: { 157 | $id: '#/definitions/address', 158 | type: 'object', 159 | properties: { 160 | street_address: { type: 'string' }, 161 | city: { type: 'string' }, 162 | state: { type: 'string' } 163 | }, 164 | required: ['street_address', 'city', 'state'] 165 | } 166 | }, 167 | allOf: [ 168 | { $ref: '#/definitions/address' }, 169 | { 170 | type: 'object', 171 | properties: { 172 | type: { type: 'string', enum: ['residential', 'business'] } 173 | } 174 | } 175 | ] 176 | }) 177 | }) 178 | 179 | it('valid', () => { 180 | const valid = validate({ 181 | street_address: 'via Paolo Rossi', 182 | city: 'Topolinia', 183 | state: 'Disney World', 184 | type: 'business' 185 | }) 186 | assert.strictEqual(validate.errors, null) 187 | assert.ok(valid) 188 | }) 189 | }) 190 | 191 | // https://github.com/fastify/fluent-json-schema/pull/40 192 | describe('cloning objects retains boolean', () => { 193 | const ajv = new Ajv() 194 | const config = { 195 | schema: S.object().prop('foo', S.string().enum(['foo'])) 196 | } 197 | const _config = require('lodash.merge')({}, config) 198 | const schema = _config.schema.valueOf() 199 | const validate = ajv.compile(schema) 200 | it('matches', () => { 201 | assert.notStrictEqual( 202 | config.schema[Symbol.for('fluent-schema-object')], 203 | undefined 204 | ) 205 | assert.ok(_config.schema.isFluentJSONSchema) 206 | assert.strictEqual( 207 | _config.schema[Symbol.for('fluent-schema-object')], 208 | undefined 209 | ) 210 | assert.deepStrictEqual(schema, { 211 | $schema: 'http://json-schema.org/draft-07/schema#', 212 | type: 'object', 213 | properties: { 214 | foo: { 215 | type: 'string', 216 | enum: ['foo'] 217 | } 218 | } 219 | }) 220 | }) 221 | 222 | it('valid', () => { 223 | const valid = validate({ foo: 'foo' }) 224 | assert.strictEqual(validate.errors, null) 225 | assert.ok(valid) 226 | }) 227 | }) 228 | 229 | describe('compose keywords', () => { 230 | const ajv = new Ajv() 231 | const schema = S.object() 232 | .prop('foo', S.anyOf([S.string()])) 233 | .prop('bar', S.not(S.anyOf([S.integer()]))) 234 | .prop('prop', S.allOf([S.string(), S.boolean()])) 235 | .prop('anotherProp', S.oneOf([S.string(), S.boolean()])) 236 | .required() 237 | .valueOf() 238 | 239 | const validate = ajv.compile(schema) 240 | 241 | it('valid', () => { 242 | const valid = validate({ 243 | foo: 'foo', 244 | anotherProp: true 245 | }) 246 | assert.ok(valid) 247 | }) 248 | 249 | it('invalid', () => { 250 | const valid = validate({ 251 | foo: 'foo', 252 | bar: 1 253 | }) 254 | assert.ok(!valid) 255 | }) 256 | }) 257 | 258 | describe('compose ifThen', () => { 259 | const ajv = new Ajv() 260 | const schema = S.object() 261 | .prop('foo', S.string().default(false).required()) 262 | .prop('bar', S.string().default(false).required()) 263 | .prop('thenFooA', S.string()) 264 | .prop('thenFooB', S.string()) 265 | .allOf([ 266 | S.ifThen( 267 | S.object().prop('foo', S.string()).enum(['foo']), 268 | S.required(['thenFooA', 'thenFooB']) 269 | ), 270 | S.ifThen( 271 | S.object().prop('bar', S.string()).enum(['BAR']), 272 | S.required(['thenBarA', 'thenBarB']) 273 | ) 274 | ]) 275 | .valueOf() 276 | 277 | const validate = ajv.compile(schema) 278 | it('matches', () => { 279 | assert.deepStrictEqual(schema, { 280 | $schema: 'http://json-schema.org/draft-07/schema#', 281 | allOf: [ 282 | { 283 | if: { 284 | properties: { 285 | foo: { $id: undefined, enum: ['foo'], type: 'string' } 286 | } 287 | }, 288 | then: { required: ['thenFooA', 'thenFooB'] } 289 | }, 290 | { 291 | if: { 292 | properties: { 293 | bar: { $id: undefined, enum: ['BAR'], type: 'string' } 294 | } 295 | }, 296 | then: { required: ['thenBarA', 'thenBarB'] } 297 | } 298 | ], 299 | properties: { 300 | bar: { default: false, type: 'string' }, 301 | foo: { default: false, type: 'string' }, 302 | thenFooA: { type: 'string' }, 303 | thenFooB: { type: 'string' } 304 | }, 305 | required: ['foo', 'bar'], 306 | type: 'object' 307 | }) 308 | }) 309 | 310 | it('valid', () => { 311 | const valid = validate({ 312 | foo: 'foo', 313 | thenFooA: 'thenFooA', 314 | thenFooB: 'thenFooB', 315 | bar: 'BAR', 316 | thenBarA: 'thenBarA', 317 | thenBarB: 'thenBarB' 318 | }) 319 | assert.strictEqual(validate.errors, null) 320 | assert.ok(valid) 321 | }) 322 | }) 323 | 324 | describe('complex', () => { 325 | const ajv = new Ajv() 326 | const schema = S.object() 327 | .id('http://foo.com/user') 328 | .title('A User') 329 | .description('A User desc') 330 | .definition( 331 | 'address', 332 | S.object() 333 | .id('#address') 334 | .prop('country', S.string()) 335 | .prop('city', S.string()) 336 | .prop('zipcode', S.string()) 337 | ) 338 | .prop('username', S.string()) 339 | .required() 340 | .prop('password', S.string().required()) 341 | .prop('address', S.object().ref('#address')) 342 | 343 | .required() 344 | .prop( 345 | 'role', 346 | S.object() 347 | .id('http://foo.com/role') 348 | .required() 349 | .prop('name', S.string()) 350 | .prop('permissions') 351 | ) 352 | .prop('age', S.number()) 353 | .valueOf() 354 | const validate = ajv.compile(schema) 355 | it('valid', () => { 356 | const valid = validate({ 357 | username: 'aboutlo', 358 | password: 'pwsd', 359 | address: { 360 | country: 'Italy', 361 | city: 'Milan', 362 | zipcode: '20100' 363 | }, 364 | role: { 365 | name: 'admin', 366 | permissions: 'read:write' 367 | }, 368 | age: 30 369 | }) 370 | assert.ok(valid) 371 | }) 372 | 373 | describe('invalid', () => { 374 | const model = { 375 | username: 'aboutlo', 376 | password: 'pswd', 377 | address: { 378 | country: 'Italy', 379 | city: 'Milan', 380 | zipcode: '20100' 381 | }, 382 | role: { 383 | name: 'admin', 384 | permissions: 'read:write' 385 | }, 386 | age: 30 387 | } 388 | it('password', () => { 389 | const { password, ...data } = model 390 | const valid = validate(data) 391 | assert.deepStrictEqual(validate.errors, [ 392 | { 393 | instancePath: '', 394 | keyword: 'required', 395 | message: "must have required property 'password'", 396 | params: { missingProperty: 'password' }, 397 | schemaPath: '#/required' 398 | } 399 | ]) 400 | assert.ok(!valid) 401 | }) 402 | it('address', () => { 403 | const { address, ...data } = model 404 | const valid = validate({ 405 | ...data, 406 | address: { 407 | ...address, 408 | city: 1234 409 | } 410 | }) 411 | assert.deepStrictEqual(validate.errors, [ 412 | { 413 | instancePath: '/address/city', 414 | keyword: 'type', 415 | message: 'must be string', 416 | params: { type: 'string' }, 417 | schemaPath: '#address/properties/city/type' 418 | } 419 | ]) 420 | assert.ok(!valid) 421 | }) 422 | }) 423 | }) 424 | 425 | describe('basic.json', () => { 426 | it('generate', () => { 427 | const [step] = basic 428 | assert.deepStrictEqual( 429 | S.array() 430 | .title('Product set') 431 | .items( 432 | S.object() 433 | .title('Product') 434 | .prop( 435 | 'uuid', 436 | S.number() 437 | .description('The unique identifier for a product') 438 | .required() 439 | ) 440 | .prop('name', S.string()) 441 | .required() 442 | .prop('price', S.number().exclusiveMinimum(0).required()) 443 | .prop( 444 | 'tags', 445 | S.array().items(S.string()).minItems(1).uniqueItems(true) 446 | ) 447 | .prop( 448 | 'dimensions', 449 | S.object() 450 | .prop('length', S.number().required()) 451 | .prop('width', S.number().required()) 452 | .prop('height', S.number().required()) 453 | ) 454 | .prop( 455 | 'warehouseLocation', 456 | S.string().description( 457 | 'Coordinates of the warehouse with the product' 458 | ) 459 | ) 460 | ) 461 | .valueOf(), 462 | { 463 | ...step.schema, 464 | items: { 465 | ...step.schema.items, 466 | properties: { 467 | ...step.schema.items.properties, 468 | dimensions: { 469 | ...step.schema.items.properties.dimensions, 470 | properties: { 471 | length: { $id: undefined, type: 'number' }, 472 | width: { $id: undefined, type: 'number' }, 473 | height: { $id: undefined, type: 'number' } 474 | } 475 | } 476 | } 477 | } 478 | } 479 | ) 480 | }) 481 | }) 482 | 483 | describe('raw', () => { 484 | describe('swaggger', () => { 485 | describe('nullable', () => { 486 | it('allows nullable', () => { 487 | const ajv = new Ajv() 488 | const schema = S.object() 489 | .prop('foo', S.raw({ nullable: true, type: 'string' })) 490 | .valueOf() 491 | const validate = ajv.compile(schema) 492 | const valid = validate({ 493 | test: null 494 | }) 495 | assert.strictEqual(validate.errors, null) 496 | assert.ok(valid) 497 | }) 498 | }) 499 | }) 500 | 501 | describe('ajv', () => { 502 | describe('formatMaximum', () => { 503 | it('checks custom keyword formatMaximum', () => { 504 | const ajv = new Ajv() 505 | require('ajv-formats')(ajv) 506 | /* const schema = S.string() 507 | .raw({ nullable: false }) 508 | .valueOf() */ 509 | // { type: 'number', nullable: true } 510 | const schema = S.object() 511 | .prop( 512 | 'birthday', 513 | S.raw({ 514 | format: 'date', 515 | formatMaximum: '2020-01-01', 516 | type: 'string' 517 | }) 518 | ) 519 | .valueOf() 520 | 521 | const validate = ajv.compile(schema) 522 | const valid = validate({ 523 | birthday: '2030-01-01' 524 | }) 525 | assert.deepStrictEqual(validate.errors, [ 526 | { 527 | instancePath: '/birthday', 528 | keyword: 'formatMaximum', 529 | message: 'should be <= 2020-01-01', 530 | params: { 531 | comparison: '<=', 532 | limit: '2020-01-01' 533 | }, 534 | schemaPath: '#/properties/birthday/formatMaximum' 535 | } 536 | ]) 537 | assert.ok(!valid) 538 | }) 539 | it('checks custom keyword larger with $data', () => { 540 | const ajv = new Ajv({ $data: true }) 541 | require('ajv-formats')(ajv) 542 | /* const schema = S.string() 543 | .raw({ nullable: false }) 544 | .valueOf() */ 545 | // { type: 'number', nullable: true } 546 | const schema = S.object() 547 | .prop('smaller', S.number().raw({ maximum: { $data: '1/larger' } })) 548 | .prop('larger', S.number()) 549 | .valueOf() 550 | 551 | const validate = ajv.compile(schema) 552 | const valid = validate({ 553 | smaller: 10, 554 | larger: 7 555 | }) 556 | assert.deepStrictEqual(validate.errors, [ 557 | { 558 | instancePath: '/smaller', 559 | keyword: 'maximum', 560 | message: 'must be <= 7', 561 | params: { 562 | comparison: '<=', 563 | limit: 7 564 | }, 565 | schemaPath: '#/properties/smaller/maximum' 566 | } 567 | ]) 568 | assert.ok(!valid) 569 | }) 570 | }) 571 | }) 572 | }) 573 | }) 574 | -------------------------------------------------------------------------------- /src/BaseSchema.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { describe, it } = require('node:test') 4 | const assert = require('node:assert/strict') 5 | 6 | const { BaseSchema } = require('./BaseSchema') 7 | const S = require('./FluentJSONSchema') 8 | 9 | describe('BaseSchema', () => { 10 | it('defined', () => { 11 | assert.notStrictEqual(BaseSchema, undefined) 12 | }) 13 | 14 | it('Expose symbol', () => { 15 | assert.notStrictEqual( 16 | BaseSchema()[Symbol.for('fluent-schema-object')], 17 | undefined 18 | ) 19 | }) 20 | 21 | it('Expose legacy plain boolean', () => { 22 | assert.notStrictEqual(BaseSchema().isFluentSchema, undefined) 23 | }) 24 | 25 | it('Expose plain boolean', () => { 26 | assert.notStrictEqual(BaseSchema().isFluentJSONSchema, undefined) 27 | }) 28 | 29 | describe('factory', () => { 30 | it('without params', () => { 31 | assert.deepStrictEqual(BaseSchema().valueOf(), {}) 32 | }) 33 | 34 | describe('factory', () => { 35 | it('default', () => { 36 | const title = 'title' 37 | assert.deepStrictEqual(BaseSchema().title(title).valueOf(), { 38 | title 39 | }) 40 | }) 41 | 42 | it('override', () => { 43 | const title = 'title' 44 | assert.deepStrictEqual( 45 | BaseSchema({ factory: BaseSchema }).title(title).valueOf(), 46 | { 47 | title 48 | } 49 | ) 50 | }) 51 | }) 52 | }) 53 | 54 | describe('keywords (any):', () => { 55 | describe('id', () => { 56 | const value = 'customId' 57 | it('to root', () => { 58 | assert.strictEqual(BaseSchema().id(value).valueOf().$id, value) 59 | }) 60 | 61 | it('nested', () => { 62 | assert.strictEqual( 63 | S.object().prop('foo', BaseSchema().id(value).required()).valueOf() 64 | .properties.foo.$id, 65 | value 66 | ) 67 | }) 68 | 69 | it('invalid', () => { 70 | assert.throws( 71 | () => BaseSchema().id(''), 72 | (err) => 73 | err instanceof S.FluentSchemaError && 74 | err.message === 75 | 'id should not be an empty fragment <#> or an empty string <> (e.g. #myId)' 76 | ) 77 | }) 78 | }) 79 | 80 | describe('title', () => { 81 | const value = 'title' 82 | it('adds to root', () => { 83 | assert.strictEqual(BaseSchema().title(value).valueOf().title, value) 84 | }) 85 | }) 86 | 87 | describe('description', () => { 88 | it('add to root', () => { 89 | const value = 'description' 90 | assert.strictEqual( 91 | BaseSchema().description(value).valueOf().description, 92 | value 93 | ) 94 | }) 95 | }) 96 | 97 | describe('examples', () => { 98 | it('adds to root', () => { 99 | const value = ['example'] 100 | assert.deepStrictEqual( 101 | BaseSchema().examples(value).valueOf().examples, 102 | value 103 | ) 104 | }) 105 | 106 | it('invalid', () => { 107 | const value = 'examples' 108 | assert.throws( 109 | () => BaseSchema().examples(value).valueOf().examples, 110 | (err) => 111 | err instanceof S.FluentSchemaError && 112 | err.message === 113 | "'examples' must be an array e.g. ['1', 'one', 'foo']" 114 | ) 115 | }) 116 | }) 117 | 118 | describe('required', () => { 119 | it('in line valid', () => { 120 | const prop = 'foo' 121 | assert.deepStrictEqual( 122 | S.object().prop(prop).required().valueOf().required, 123 | [prop] 124 | ) 125 | }) 126 | it('nested valid', () => { 127 | const prop = 'foo' 128 | assert.deepStrictEqual( 129 | S.object().prop(prop, S.string().required().minLength(3)).valueOf() 130 | .required, 131 | [prop] 132 | ) 133 | }) 134 | 135 | describe('unique keys on required', () => { 136 | it('repeated calls to required()', () => { 137 | assert.throws( 138 | () => S.object().prop('A', S.string()).required().required(), 139 | (err) => 140 | err instanceof S.FluentSchemaError && 141 | err.message === 142 | "'required' has repeated keys, check your calls to .required()" 143 | ) 144 | }) 145 | it('repeated props on appendRequired()', () => { 146 | assert.throws( 147 | () => 148 | S.object() 149 | .prop('A', S.string().required()) 150 | .prop('A', S.string().required()), 151 | (err) => 152 | err instanceof S.FluentSchemaError && 153 | err.message === 154 | "'required' has repeated keys, check your calls to .required()" 155 | ) 156 | }) 157 | }) 158 | 159 | it('root-level required', () => { 160 | assert.throws( 161 | () => S.object().required().valueOf(), 162 | (err) => 163 | err instanceof S.FluentSchemaError && 164 | err.message === 165 | "'required' has called on root-level schema, check your calls to .required()" 166 | ) 167 | }) 168 | 169 | describe('array', () => { 170 | it('simple', () => { 171 | const required = ['foo', 'bar'] 172 | assert.deepStrictEqual(S.required(required).valueOf(), { 173 | required 174 | }) 175 | }) 176 | it('nested', () => { 177 | assert.deepStrictEqual( 178 | S.object() 179 | .prop('foo', S.string()) 180 | .prop('bar', S.string().required()) 181 | .required(['foo']) 182 | .valueOf(), 183 | { 184 | $schema: 'http://json-schema.org/draft-07/schema#', 185 | properties: { bar: { type: 'string' }, foo: { type: 'string' } }, 186 | required: ['bar', 'foo'], 187 | type: 'object' 188 | } 189 | ) 190 | }) 191 | }) 192 | }) 193 | 194 | describe('deprecated', () => { 195 | it('valid', () => { 196 | assert.strictEqual( 197 | BaseSchema().deprecated(true).valueOf().deprecated, 198 | true 199 | ) 200 | }) 201 | it('invalid', () => { 202 | assert.throws( 203 | () => 204 | BaseSchema().deprecated('somethingNotBoolean').valueOf().deprecated, 205 | (err) => 206 | err instanceof S.FluentSchemaError && 207 | err.message === "'deprecated' must be a boolean value" 208 | ) 209 | }) 210 | it('valid with no value', () => { 211 | assert.strictEqual(BaseSchema().deprecated().valueOf().deprecated, true) 212 | }) 213 | it('can be set to false', () => { 214 | assert.strictEqual( 215 | BaseSchema().deprecated(false).valueOf().deprecated, 216 | false 217 | ) 218 | }) 219 | it('property', () => { 220 | assert.deepStrictEqual( 221 | S.object() 222 | .prop('foo', S.string()) 223 | .prop('bar', S.string().deprecated()) 224 | .valueOf(), 225 | { 226 | $schema: 'http://json-schema.org/draft-07/schema#', 227 | properties: { 228 | bar: { type: 'string', deprecated: true }, 229 | foo: { type: 'string' } 230 | }, 231 | type: 'object' 232 | } 233 | ) 234 | }) 235 | it('object', () => { 236 | assert.deepStrictEqual( 237 | S.object() 238 | .prop('foo', S.string()) 239 | .prop( 240 | 'bar', 241 | S.object() 242 | .deprecated() 243 | .prop('raz', S.string()) 244 | .prop('iah', S.number()) 245 | ) 246 | .valueOf(), 247 | { 248 | $schema: 'http://json-schema.org/draft-07/schema#', 249 | properties: { 250 | foo: { type: 'string' }, 251 | bar: { 252 | type: 'object', 253 | deprecated: true, 254 | properties: { 255 | raz: { $id: undefined, type: 'string' }, 256 | iah: { $id: undefined, type: 'number' } 257 | } 258 | } 259 | }, 260 | type: 'object' 261 | } 262 | ) 263 | }) 264 | it('object property', () => { 265 | assert.deepStrictEqual( 266 | S.object() 267 | .prop('foo', S.string()) 268 | .prop( 269 | 'bar', 270 | S.object() 271 | .prop('raz', S.string().deprecated()) 272 | .prop('iah', S.number()) 273 | ) 274 | .valueOf(), 275 | { 276 | $schema: 'http://json-schema.org/draft-07/schema#', 277 | properties: { 278 | foo: { type: 'string' }, 279 | bar: { 280 | type: 'object', 281 | properties: { 282 | raz: { $id: undefined, type: 'string', deprecated: true }, 283 | iah: { $id: undefined, type: 'number' } 284 | } 285 | } 286 | }, 287 | type: 'object' 288 | } 289 | ) 290 | }) 291 | it('array', () => { 292 | assert.deepStrictEqual( 293 | S.object() 294 | .prop('foo', S.string()) 295 | .prop('bar', S.array().deprecated().items(S.number())) 296 | .valueOf(), 297 | { 298 | $schema: 'http://json-schema.org/draft-07/schema#', 299 | type: 'object', 300 | properties: { 301 | foo: { type: 'string' }, 302 | bar: { 303 | type: 'array', 304 | deprecated: true, 305 | items: { type: 'number' } 306 | } 307 | } 308 | } 309 | ) 310 | }) 311 | it('array item', () => { 312 | assert.deepStrictEqual( 313 | S.object() 314 | .prop('foo', S.string()) 315 | .prop( 316 | 'bar', 317 | S.array().items([ 318 | S.object().prop('zoo', S.string()).prop('biz', S.string()), 319 | S.object() 320 | .deprecated() 321 | .prop('zal', S.string()) 322 | .prop('boz', S.string()) 323 | ]) 324 | ) 325 | .valueOf(), 326 | { 327 | $schema: 'http://json-schema.org/draft-07/schema#', 328 | type: 'object', 329 | properties: { 330 | foo: { type: 'string' }, 331 | bar: { 332 | type: 'array', 333 | items: [ 334 | { 335 | type: 'object', 336 | properties: { 337 | zoo: { type: 'string' }, 338 | biz: { type: 'string' } 339 | } 340 | }, 341 | { 342 | type: 'object', 343 | deprecated: true, 344 | properties: { 345 | zal: { type: 'string' }, 346 | boz: { type: 'string' } 347 | } 348 | } 349 | ] 350 | } 351 | } 352 | } 353 | ) 354 | }) 355 | }) 356 | 357 | describe('enum', () => { 358 | it('valid', () => { 359 | const value = ['VALUE'] 360 | assert.deepStrictEqual(BaseSchema().enum(value).valueOf().enum, value) 361 | }) 362 | 363 | it('invalid', () => { 364 | const value = 'VALUE' 365 | assert.throws( 366 | () => BaseSchema().enum(value).valueOf().examples, 367 | (err) => 368 | err instanceof S.FluentSchemaError && 369 | err.message === 370 | "'enums' must be an array with at least an element e.g. ['1', 'one', 'foo']" 371 | ) 372 | }) 373 | }) 374 | 375 | describe('const', () => { 376 | it('valid', () => { 377 | const value = 'VALUE' 378 | assert.strictEqual(BaseSchema().const(value).valueOf().const, value) 379 | }) 380 | }) 381 | 382 | describe('default', () => { 383 | it('valid', () => { 384 | const value = 'VALUE' 385 | assert.strictEqual(BaseSchema().default(value).valueOf().default, value) 386 | }) 387 | }) 388 | 389 | describe('readOnly', () => { 390 | it('valid', () => { 391 | assert.strictEqual(BaseSchema().readOnly(true).valueOf().readOnly, true) 392 | }) 393 | it('valid with no value', () => { 394 | assert.strictEqual(BaseSchema().readOnly().valueOf().readOnly, true) 395 | }) 396 | it('can be set to false', () => { 397 | assert.strictEqual( 398 | BaseSchema().readOnly(false).valueOf().readOnly, 399 | false 400 | ) 401 | }) 402 | }) 403 | 404 | describe('writeOnly', () => { 405 | it('valid', () => { 406 | assert.strictEqual( 407 | BaseSchema().writeOnly(true).valueOf().writeOnly, 408 | true 409 | ) 410 | }) 411 | it('valid with no value', () => { 412 | assert.strictEqual(BaseSchema().writeOnly().valueOf().writeOnly, true) 413 | }) 414 | it('can be set to false', () => { 415 | assert.strictEqual( 416 | BaseSchema().writeOnly(false).valueOf().writeOnly, 417 | false 418 | ) 419 | }) 420 | }) 421 | 422 | describe('ref', () => { 423 | it('base', () => { 424 | const ref = 'myRef' 425 | assert.deepStrictEqual(BaseSchema().ref(ref).valueOf(), { $ref: ref }) 426 | }) 427 | 428 | it('S', () => { 429 | const ref = 'myRef' 430 | assert.deepStrictEqual(S.ref(ref).valueOf(), { 431 | $ref: ref 432 | }) 433 | }) 434 | }) 435 | }) 436 | 437 | describe('combining keywords:', () => { 438 | describe('allOf', () => { 439 | it('base', () => { 440 | assert.deepStrictEqual( 441 | BaseSchema() 442 | .allOf([BaseSchema().id('foo')]) 443 | .valueOf(), 444 | { 445 | allOf: [{ $id: 'foo' }] 446 | } 447 | ) 448 | }) 449 | it('S', () => { 450 | assert.deepStrictEqual(S.allOf([S.id('foo')]).valueOf(), { 451 | allOf: [{ $id: 'foo' }] 452 | }) 453 | }) 454 | describe('invalid', () => { 455 | it('not an array', () => { 456 | assert.throws( 457 | () => BaseSchema().allOf('test'), 458 | (err) => 459 | err instanceof S.FluentSchemaError && 460 | err.message === 461 | "'allOf' must be a an array of FluentSchema rather than a 'string'" 462 | ) 463 | }) 464 | it('not an array of FluentSchema', () => { 465 | assert.throws( 466 | () => BaseSchema().allOf(['test']), 467 | (err) => 468 | err instanceof S.FluentSchemaError && 469 | err.message === 470 | "'allOf' must be a an array of FluentSchema rather than a 'object'" 471 | ) 472 | }) 473 | }) 474 | }) 475 | 476 | describe('anyOf', () => { 477 | it('valid', () => { 478 | assert.deepStrictEqual( 479 | BaseSchema() 480 | .anyOf([BaseSchema().id('foo')]) 481 | .valueOf(), 482 | { 483 | anyOf: [{ $id: 'foo' }] 484 | } 485 | ) 486 | }) 487 | it('S nested', () => { 488 | assert.deepStrictEqual( 489 | S.object() 490 | .prop('prop', S.anyOf([S.string(), S.null()])) 491 | .valueOf(), 492 | { 493 | $schema: 'http://json-schema.org/draft-07/schema#', 494 | properties: { 495 | prop: { anyOf: [{ type: 'string' }, { type: 'null' }] } 496 | }, 497 | type: 'object' 498 | } 499 | ) 500 | }) 501 | 502 | it('S nested required', () => { 503 | assert.deepStrictEqual( 504 | S.object().prop('prop', S.anyOf([]).required()).valueOf(), 505 | { 506 | $schema: 'http://json-schema.org/draft-07/schema#', 507 | properties: { prop: {} }, 508 | required: ['prop'], 509 | type: 'object' 510 | } 511 | ) 512 | }) 513 | 514 | describe('invalid', () => { 515 | it('not an array', () => { 516 | assert.throws( 517 | () => BaseSchema().anyOf('test'), 518 | (err) => 519 | err instanceof S.FluentSchemaError && 520 | err.message === 521 | "'anyOf' must be a an array of FluentSchema rather than a 'string'" 522 | ) 523 | }) 524 | it('not an array of FluentSchema', () => { 525 | assert.throws( 526 | () => BaseSchema().anyOf(['test']), 527 | (err) => 528 | err instanceof S.FluentSchemaError && 529 | err.message === 530 | "'anyOf' must be a an array of FluentSchema rather than a 'object'" 531 | ) 532 | }) 533 | }) 534 | }) 535 | 536 | describe('oneOf', () => { 537 | it('valid', () => { 538 | assert.deepStrictEqual( 539 | BaseSchema() 540 | .oneOf([BaseSchema().id('foo')]) 541 | .valueOf(), 542 | { 543 | oneOf: [{ $id: 'foo' }] 544 | } 545 | ) 546 | }) 547 | describe('invalid', () => { 548 | it('not an array', () => { 549 | assert.throws( 550 | () => BaseSchema().oneOf('test'), 551 | (err) => 552 | err instanceof S.FluentSchemaError && 553 | err.message === 554 | "'oneOf' must be a an array of FluentSchema rather than a 'string'" 555 | ) 556 | }) 557 | it('not an array of FluentSchema', () => { 558 | assert.throws( 559 | () => BaseSchema().oneOf(['test']), 560 | (err) => 561 | err instanceof S.FluentSchemaError && 562 | err.message === 563 | "'oneOf' must be a an array of FluentSchema rather than a 'object'" 564 | ) 565 | }) 566 | }) 567 | }) 568 | 569 | describe('not', () => { 570 | describe('valid', () => { 571 | it('simple', () => { 572 | assert.deepStrictEqual( 573 | BaseSchema().not(S.string().maxLength(10)).valueOf(), 574 | { 575 | not: { type: 'string', maxLength: 10 } 576 | } 577 | ) 578 | }) 579 | 580 | it('complex', () => { 581 | assert.deepStrictEqual( 582 | BaseSchema() 583 | .not(BaseSchema().anyOf([BaseSchema().id('foo')])) 584 | .valueOf(), 585 | { 586 | not: { anyOf: [{ $id: 'foo' }] } 587 | } 588 | ) 589 | }) 590 | 591 | // .prop('notTypeKey', S.not(S.string().maxLength(10))) => notTypeKey: { not: { type: 'string', "maxLength": 10 } } 592 | }) 593 | 594 | it('invalid', () => { 595 | assert.throws( 596 | () => BaseSchema().not(undefined), 597 | (err) => 598 | err instanceof S.FluentSchemaError && 599 | err.message === "'not' must be a BaseSchema" 600 | ) 601 | }) 602 | }) 603 | }) 604 | 605 | describe('ifThen', () => { 606 | describe('valid', () => { 607 | it('returns a schema', () => { 608 | const id = 'http://foo.com/user' 609 | const schema = BaseSchema() 610 | .id(id) 611 | .title('A User') 612 | .ifThen(BaseSchema().id(id), BaseSchema().description('A User desc')) 613 | .valueOf() 614 | 615 | assert.deepStrictEqual(schema, { 616 | $id: 'http://foo.com/user', 617 | title: 'A User', 618 | if: { $id: 'http://foo.com/user' }, 619 | then: { description: 'A User desc' } 620 | }) 621 | }) 622 | 623 | it('appends a prop after the clause', () => { 624 | const id = 'http://foo.com/user' 625 | const schema = S.object() 626 | .id(id) 627 | .title('A User') 628 | .prop('bar') 629 | .ifThen( 630 | S.object().prop('foo', S.null()), 631 | S.object().prop('bar', S.string().required()) 632 | ) 633 | .prop('foo') 634 | .valueOf() 635 | 636 | assert.deepStrictEqual(schema, { 637 | $schema: 'http://json-schema.org/draft-07/schema#', 638 | type: 'object', 639 | $id: 'http://foo.com/user', 640 | title: 'A User', 641 | properties: { bar: {}, foo: {} }, 642 | if: { properties: { foo: { $id: undefined, type: 'null' } } }, 643 | then: { 644 | properties: { bar: { $id: undefined, type: 'string' } }, 645 | required: ['bar'] 646 | } 647 | }) 648 | }) 649 | }) 650 | 651 | describe('invalid', () => { 652 | it('ifClause', () => { 653 | assert.throws( 654 | () => 655 | BaseSchema().ifThen( 656 | undefined, 657 | BaseSchema().description('A User desc') 658 | ), 659 | (err) => 660 | err instanceof S.FluentSchemaError && 661 | err.message === "'ifClause' must be a BaseSchema" 662 | ) 663 | }) 664 | it('thenClause', () => { 665 | assert.throws( 666 | () => BaseSchema().ifThen(BaseSchema().id('id'), undefined), 667 | (err) => 668 | err instanceof S.FluentSchemaError && 669 | err.message === "'thenClause' must be a BaseSchema" 670 | ) 671 | }) 672 | }) 673 | }) 674 | 675 | describe('ifThenElse', () => { 676 | describe('valid', () => { 677 | it('returns a schema', () => { 678 | const id = 'http://foo.com/user' 679 | const schema = BaseSchema() 680 | .id(id) 681 | .title('A User') 682 | .ifThenElse( 683 | BaseSchema().id(id), 684 | BaseSchema().description('then'), 685 | BaseSchema().description('else') 686 | ) 687 | .valueOf() 688 | 689 | assert.deepStrictEqual(schema, { 690 | $id: 'http://foo.com/user', 691 | title: 'A User', 692 | if: { $id: 'http://foo.com/user' }, 693 | then: { description: 'then' }, 694 | else: { description: 'else' } 695 | }) 696 | }) 697 | 698 | it('appends a prop after the clause', () => { 699 | const id = 'http://foo.com/user' 700 | const schema = S.object() 701 | .id(id) 702 | .title('A User') 703 | .prop('bar') 704 | .ifThenElse( 705 | S.object().prop('foo', S.null()), 706 | S.object().prop('bar', S.string().required()), 707 | S.object().prop('bar', S.string()) 708 | ) 709 | .prop('foo') 710 | .valueOf() 711 | 712 | assert.deepStrictEqual(schema, { 713 | $schema: 'http://json-schema.org/draft-07/schema#', 714 | type: 'object', 715 | $id: 'http://foo.com/user', 716 | title: 'A User', 717 | properties: { bar: {}, foo: {} }, 718 | if: { properties: { foo: { $id: undefined, type: 'null' } } }, 719 | then: { 720 | properties: { bar: { $id: undefined, type: 'string' } }, 721 | required: ['bar'] 722 | }, 723 | else: { properties: { bar: { $id: undefined, type: 'string' } } } 724 | }) 725 | }) 726 | 727 | describe('invalid', () => { 728 | it('ifClause', () => { 729 | assert.throws( 730 | () => 731 | BaseSchema().ifThenElse( 732 | undefined, 733 | BaseSchema().description('then'), 734 | BaseSchema().description('else') 735 | ), 736 | (err) => 737 | err instanceof S.FluentSchemaError && 738 | err.message === "'ifClause' must be a BaseSchema" 739 | ) 740 | }) 741 | it('thenClause', () => { 742 | assert.throws( 743 | () => 744 | BaseSchema().ifThenElse( 745 | BaseSchema().id('id'), 746 | undefined, 747 | BaseSchema().description('else') 748 | ), 749 | (err) => 750 | err instanceof S.FluentSchemaError && 751 | err.message === "'thenClause' must be a BaseSchema" 752 | ) 753 | }) 754 | it('elseClause', () => { 755 | assert.throws( 756 | () => 757 | BaseSchema().ifThenElse( 758 | BaseSchema().id('id'), 759 | BaseSchema().description('then'), 760 | undefined 761 | ), 762 | (err) => 763 | err instanceof S.FluentSchemaError && 764 | err.message === 765 | "'elseClause' must be a BaseSchema or a false boolean value" 766 | ) 767 | }) 768 | }) 769 | }) 770 | }) 771 | 772 | describe('raw', () => { 773 | it('allows to add a custom attribute', () => { 774 | const schema = BaseSchema() 775 | .title('foo') 776 | .raw({ customKeyword: true }) 777 | .valueOf() 778 | 779 | assert.deepStrictEqual(schema, { 780 | title: 'foo', 781 | customKeyword: true 782 | }) 783 | }) 784 | }) 785 | }) 786 | --------------------------------------------------------------------------------