├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── badges ├── badge-branches.svg ├── badge-functions.svg ├── badge-lines.svg └── badge-statements.svg ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── 2020.ts └── index.ts ├── test-node.js ├── tests ├── 2020.test.ts ├── __snapshots__ │ └── readme.test.ts.snap ├── index.test.ts └── readme.test.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test-node.js 3 | lib -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "prettier/prettier": 2 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: "16.x" 13 | registry-url: "https://registry.npmjs.org" 14 | - run: npm ci 15 | - run: npm publish 16 | env: 17 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v2 9 | with: 10 | node-version: "16.x" 11 | registry-url: "https://registry.npmjs.org" 12 | - run: npm ci 13 | - run: npm test 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | lib/**/* 39 | 40 | # ignore yarn.lock 41 | yarn.lock 42 | /tests/test.sqlite 43 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/triggerdotdev/json-schema-fns/719f04c131471ebba0a8e2afc08beeccbc747ca0/.npmignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 100 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest All Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/.bin/jest", 14 | "--runInBand" 15 | ], 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen" 18 | }, 19 | { 20 | "name": "Debug Jest Test File", 21 | "type": "node", 22 | "request": "launch", 23 | "runtimeArgs": [ 24 | "--inspect-brk", 25 | "${workspaceRoot}/node_modules/.bin/jest", 26 | "--runInBand" 27 | ], 28 | "args": ["${fileBasename}", "--no-cache"], 29 | "console": "integratedTerminal", 30 | "internalConsoleOptions": "neverOpen" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eric Allam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-schema-fns 2 | 3 | > Modern utility library and typescript typings for building JSON Schema documents dynamically 4 | 5 | 6 | 7 | 8 | 9 | 10 | ## Features 11 | 12 | - Build JSON Schema documents for various drafts (currently only draft-2020-12 but more coming soon) 13 | - Strongly typed documents using Typescript 14 | - Allows you to build correct JSON Schema documents using dynamic data 15 | 16 | ## Usage 17 | 18 | Create a simple draft-2020-12 document: 19 | 20 | ```ts 21 | import { s } from "json-schema-fns"; 22 | 23 | const schema = s.object({ 24 | properties: [s.requiredProperty("foo", s.string()), s.property("bar", s.int())], 25 | }); 26 | 27 | schema.toSchemaDocument(); 28 | ``` 29 | 30 | Will result in 31 | 32 | ```json 33 | { 34 | "$schema": "https://json-schema.org/draft/2020-12/schema#", 35 | "$id": "https://jsonhero.io/schemas/root.json", 36 | "type": "object", 37 | "properties": { 38 | "foo": { "type": "string" }, 39 | "bar": { "type": "integer" } 40 | }, 41 | "required": ["foo"] 42 | } 43 | ``` 44 | 45 | You can also import the types for a specific draft to use, like so: 46 | 47 | ```typescript 48 | import { s, Schema, IntSchema, StringSchema, StringFormat } from "json-schema-fns"; 49 | 50 | function buildIntSchema(maximum: number, minimum: number): IntSchema { 51 | return s.int({ minimum, maximum }); 52 | } 53 | 54 | function buildStringFormat(format: JSONStriStringFormatgFormat): StringSchema { 55 | return s.string({ format }); 56 | } 57 | ``` 58 | 59 | `json-schema-fns` support all the features of JSON schema: 60 | 61 | ```typescript 62 | import { s } from "json-schema-fns"; 63 | 64 | const phoneNumber = s.def("phoneNumber", s.string({ pattern: "^[0-9]{3}-[0-9]{3}-[0-9]{4}$" })); 65 | const usAddress = s.def( 66 | "usAddress", 67 | s.object({ 68 | properties: [s.requiredProperty("zipCode", s.string())], 69 | }), 70 | ); 71 | 72 | const ukAddress = s.def( 73 | "ukAddress", 74 | s.object({ 75 | properties: [s.requiredProperty("postCode", s.string())], 76 | }), 77 | ); 78 | 79 | s.object({ 80 | $id: "/schemas/person", 81 | title: "Person Profile", 82 | description: "Attributes of a person object", 83 | examples: [ 84 | { 85 | name: "Eric", 86 | email: "eric@stackhero.dev", 87 | }, 88 | ], 89 | $comment: "This is just a preview", 90 | default: {}, 91 | properties: [ 92 | s.requiredProperty("name", s.string()), 93 | s.property("email", s.string({ format: "email" })), 94 | s.property("phoneNumber", s.ref("phoneNumber")), 95 | s.property("billingAddress", s.oneOf(s.ref("ukAddress"), s.ref("usAddress"))), 96 | s.patternProperty("^[A-Za-z]$", s.string()), 97 | ], 98 | additionalProperties: s.array({ 99 | items: s.number({ minimum: 0, maximum: 5000 }), 100 | }), 101 | propertyNames: "^[A-Za-z_][A-Za-z0-9_]*$", 102 | minProperties: 3, 103 | maxProperties: 20, 104 | unevaluatedProperties: false, 105 | defs: [phoneNumber, usAddress, ukAddress], 106 | }).toSchemaDocument(); 107 | ``` 108 | 109 | Will result in 110 | 111 | ```json 112 | { 113 | "$schema": "https://json-schema.org/draft/2020-12/schema", 114 | "type": "object", 115 | "$id": "/schemas/person", 116 | "title": "Person Profile", 117 | "description": "Attributes of a person object", 118 | "examples": [ 119 | { 120 | "name": "Eric", 121 | "email": "eric@stackhero.dev" 122 | } 123 | ], 124 | "$comment": "This is just a preview", 125 | "default": {}, 126 | "minProperties": 3, 127 | "maxProperties": 20, 128 | "unevaluatedProperties": false, 129 | "properties": { 130 | "name": { 131 | "type": "string" 132 | }, 133 | "email": { 134 | "type": "string", 135 | "format": "email" 136 | }, 137 | "phoneNumber": { 138 | "$ref": "#/$defs/phoneNumber" 139 | }, 140 | "billingAddress": { 141 | "oneOf": [ 142 | { 143 | "$ref": "#/$defs/ukAddress" 144 | }, 145 | { 146 | "$ref": "#/$defs/usAddress" 147 | } 148 | ] 149 | } 150 | }, 151 | "required": ["name"], 152 | "patternProperties": { 153 | "^[A-Za-z]$": { 154 | "type": "string" 155 | } 156 | }, 157 | "propertyNames": { 158 | "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" 159 | }, 160 | "additionalProperties": { 161 | "type": "array", 162 | "items": { 163 | "type": "number", 164 | "minimum": 0, 165 | "maximum": 5000 166 | } 167 | }, 168 | "$defs": { 169 | "phoneNumber": { 170 | "type": "string", 171 | "pattern": "^[0-9]{3}-[0-9]{3}-[0-9]{4}$" 172 | }, 173 | "usAddress": { 174 | "type": "object", 175 | "properties": { 176 | "zipCode": { 177 | "type": "string" 178 | } 179 | }, 180 | "required": ["zipCode"] 181 | }, 182 | "ukAddress": { 183 | "type": "object", 184 | "properties": { 185 | "postCode": { 186 | "type": "string" 187 | } 188 | }, 189 | "required": ["postCode"] 190 | } 191 | } 192 | } 193 | ``` 194 | 195 | # API 196 | 197 | ## `s` 198 | 199 | All the builder methods for creating subschemas are available on the `s` object 200 | 201 | ```typescript 202 | import { s } from "json-schema-fns"; 203 | ``` 204 | 205 | Or if you want to import a specific dialect: 206 | 207 | ```typescript 208 | import { s } from "json-schema-fns/2020"; 209 | ``` 210 | 211 | All builder methods return a `SchemaBuilder`, and you can generate the JSON schema created by the builder using `toSchemaDocument` like so 212 | 213 | ```typescript 214 | s.object().toSchemaDocument(); 215 | ``` 216 | 217 | Which will result in the following document 218 | 219 | ```json 220 | { 221 | "$schema": "https://json-schema.org/draft/2020-12/schema", 222 | "type": "object" 223 | } 224 | ``` 225 | 226 | If you don't want the `$schema` property, use `toSchema` instead: 227 | 228 | ```typescript 229 | s.object().toSchema(); // { "type": "object" } 230 | ``` 231 | 232 | All builder methods also support the options in `AnnotationSchema`: 233 | 234 | ```typescript 235 | s.object({ 236 | $id: "#/foobar", 237 | $comment: "This is a comment", 238 | default: {}, 239 | title: "FooBar Object Schema", 240 | description: "This is the FooBar schema description", 241 | examples: [{ foo: "bar" }], 242 | deprecated: true, 243 | readOnly: true, 244 | writeOnly: false, 245 | }).toSchema(); 246 | ``` 247 | 248 | Produces the schema 249 | 250 | ```json 251 | { 252 | "type": "object", 253 | "$id": "#/foobar", 254 | "$comment": "This is a comment", 255 | "default": {}, 256 | "title": "FooBar Object Schema", 257 | "description": "This is the FooBar schema description", 258 | "examples": [{ "foo": "bar" }], 259 | "deprecated": true, 260 | "readOnly": true, 261 | "writeOnly": false 262 | } 263 | ``` 264 | 265 | ### `s.object(options: ObjectOptions)` 266 | 267 | Builds a schema of type `object`, accepting a single argument of type `ObjectOptions` 268 | 269 | #### `ObjectOptions.properties` 270 | 271 | An array of optional and required properties 272 | 273 | ```typescript 274 | s.object({ 275 | properties: [ 276 | s.property("name", s.string()), 277 | s.requiredProperty("email", s.string({ format: "email" })), 278 | ], 279 | }).toSchema(); 280 | ``` 281 | 282 | Produces the schema 283 | 284 | ```json 285 | { 286 | "type": "object", 287 | "properties": { 288 | "name": { "type": "string" }, 289 | "email": { "type": "string", "format": "email" } 290 | }, 291 | "required": ["email"] 292 | } 293 | ``` 294 | 295 | You can also add [patternProperties](https://json-schema.org/understanding-json-schema/reference/object.html#pattern-properties) 296 | 297 | ```typescript 298 | s.object({ properties: [s.patternProperty("^S_", s.string())] }).toSchema(); 299 | ``` 300 | 301 | which produces the schema 302 | 303 | ```json 304 | { 305 | "type": "object", 306 | "patternProperties": { 307 | "^S_": { "type": "string" } 308 | } 309 | } 310 | ``` 311 | 312 | #### `ObjectOptions.additonalProperties` 313 | 314 | Add an [additonalProperties](https://json-schema.org/understanding-json-schema/reference/object.html#additional-properties) schema: 315 | 316 | ```typescript 317 | s.object({ additionalProperties: s.number() }).toSchema(); 318 | ``` 319 | 320 | Produces the schema 321 | 322 | ```json 323 | { 324 | "type": "object", 325 | "additionalProperties": { 326 | "type": "number" 327 | } 328 | } 329 | ``` 330 | 331 | #### `ObjectOptions.propertyNames` 332 | 333 | Add a [propertyNames](https://json-schema.org/understanding-json-schema/reference/object.html#property-names) pattern: 334 | 335 | ```typescript 336 | s.object({ propertyNames: "^[A-Za-z_][A-Za-z0-9_]*$" }).toSchema(); 337 | ``` 338 | 339 | Produces the schema 340 | 341 | ```json 342 | { 343 | "type": "object", 344 | "propertyNames": { 345 | "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" 346 | } 347 | } 348 | ``` 349 | 350 | #### `ObjectOptions.minProperties` and `ObjectOptions.maxProperties` 351 | 352 | Validate the number of properties in an object using [min/maxProperties](https://json-schema.org/understanding-json-schema/reference/object.html#size) 353 | 354 | ```typescript 355 | s.object({ minProperties: 4, maxProperties: 10 }).toSchema(); 356 | ``` 357 | 358 | Produces the schema 359 | 360 | ```json 361 | { 362 | "type": "object", 363 | "minProperties": 4, 364 | "maxProperties": 10 365 | } 366 | ``` 367 | 368 | #### `ObjectOptions.unevaluatedProperties` 369 | 370 | Specify the handling of [unevaluatedProperties](https://json-schema.org/understanding-json-schema/reference/object.html#unevaluated-properties) 371 | 372 | ```typescript 373 | s.object({ unevaluatedProperties: false }).toSchema(); 374 | ``` 375 | 376 | Produces the schema 377 | 378 | ```json 379 | { 380 | "type": "object", 381 | "unevaluatedProperties": false 382 | } 383 | ``` 384 | 385 | ### `s.array(options: ArrayOptions)` 386 | 387 | Builds a schema of type `array`, accepting a single argument of type `ArrayOptions` 388 | 389 | #### `ArrayOptions.items` 390 | 391 | Define the [items](https://json-schema.org/understanding-json-schema/reference/array.html#items) schema for an array: 392 | 393 | ```typescript 394 | s.array({ items: s.string() }).toSchema(); 395 | ``` 396 | 397 | Produces the schema 398 | 399 | ```json 400 | { 401 | "type": "array", 402 | "items": { "type": "string" } 403 | } 404 | ``` 405 | 406 | #### `ArrayOptions.minItems` and `ArrayOptions.maxItems` 407 | 408 | Define the array [length](https://json-schema.org/understanding-json-schema/reference/array.html#length) 409 | 410 | ```typescript 411 | s.array({ contains: { schema: s.number(), min: 1, max: 3 }).toSchema(); 412 | ``` 413 | 414 | Produces the schema 415 | 416 | ```json 417 | { 418 | "type": "array", 419 | "contains": { "type": "number" }, 420 | "minContains": 1, 421 | "maxContains": 3 422 | } 423 | ``` 424 | 425 | #### `ArrayOptions.prefixItems` 426 | 427 | Allows you to perform [tuple validation](https://json-schema.org/understanding-json-schema/reference/array.html#tuple-validation): 428 | 429 | ```typescript 430 | s.array({ prefixItems: [s.string(), s.number()] }).toSchema(); 431 | ``` 432 | 433 | Produces the schema 434 | 435 | ```json 436 | { 437 | "type": "array", 438 | "prefixItems": [{ "type": "string" }, { "type": "number" }] 439 | } 440 | ``` 441 | 442 | #### `ArrayOptions.unevaluatedItems` 443 | 444 | Define the schema for [unevaluatedItems](https://json-schema.org/understanding-json-schema/reference/array.html#unevaluated-items) 445 | 446 | ```typescript 447 | s.array({ unevaluatedItems: s.object() }).toSchema(); 448 | ``` 449 | 450 | Produces the schema 451 | 452 | ```json 453 | { 454 | "type": "array", 455 | "unevaluatedItems": { "type": "object" } 456 | } 457 | ``` 458 | 459 | #### `ArrayOptions.contains` 460 | 461 | Define the schema [contains](https://json-schema.org/understanding-json-schema/reference/array.html#contains) 462 | 463 | ```typescript 464 | s.array({ contains: { schema: s.number(), min: 1, max: 3 }).toSchema(); 465 | ``` 466 | 467 | Produces the schema 468 | 469 | ```json 470 | { 471 | "type": "array", 472 | "contains": { "type": "number" }, 473 | "minContains": 1, 474 | "maxContains": 3 475 | } 476 | ``` 477 | 478 | ### `string` 479 | 480 | ### `integer` and `number` 481 | 482 | ### `boolean` 483 | 484 | ### `nil` 485 | 486 | ### `nullable` 487 | 488 | ### `anyOf` | `allOf` | `oneOf` 489 | 490 | ### `ifThenElse` and `ifThen` 491 | 492 | ### `not` 493 | 494 | ### `def` and `ref` 495 | 496 | ### `$const` 497 | 498 | ### `$enumerator` 499 | 500 | ### `$true` and `$false` 501 | 502 | ## Roadmap 503 | 504 | - Support draft-04 505 | - Support draft-06 506 | - Support draft-07 507 | - Support draft/2019-09 508 | -------------------------------------------------------------------------------- /badges/badge-branches.svg: -------------------------------------------------------------------------------- 1 | Coverage:branches: 94.11%Coverage:branches94.11% -------------------------------------------------------------------------------- /badges/badge-functions.svg: -------------------------------------------------------------------------------- 1 | Coverage:functions: 100%Coverage:functions100% -------------------------------------------------------------------------------- /badges/badge-lines.svg: -------------------------------------------------------------------------------- 1 | Coverage:lines: 100%Coverage:lines100% -------------------------------------------------------------------------------- /badges/badge-statements.svg: -------------------------------------------------------------------------------- 1 | Coverage:statements: 100%Coverage:statements100% -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jsonhero/json-schema-fns", 3 | "version": "0.0.1", 4 | "description": "Modern utility library and typescript typings for building JSON Schema documents", 5 | "homepage": "https://github.com/jsonhero-io/json-schema-fns", 6 | "bugs": { 7 | "url": "https://github.com/jsonhero-io/json-schema-fns/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/jsonhero-io/json-schema-fns.git" 12 | }, 13 | "exports": "./lib/index.js", 14 | "types": "lib/index.d.ts", 15 | "main": "./lib/index.js", 16 | "module": "./lib/index.mjs", 17 | "files": [ 18 | "/lib" 19 | ], 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "scripts": { 24 | "clean": "rimraf lib", 25 | "check-types": "tsc --noEmit", 26 | "test": "jest --runInBand --coverage", 27 | "test:badges": "npm t && jest-coverage-badges --output ./badges", 28 | "build": "rollup -c", 29 | "compile": "tsc", 30 | "prepublishOnly": "npm run clean && npm run check-types && npm run format:check && npm run lint && npm test && npm run build", 31 | "lint": "eslint . --ext .ts", 32 | "lint-and-fix": "eslint . --ext .ts --fix", 33 | "format": "prettier --config .prettierrc 'src/**/*.ts' --write && prettier --config .prettierrc 'tests/**/*.ts' --write", 34 | "format:check": "prettier --config .prettierrc --list-different 'src/**/*.ts'" 35 | }, 36 | "engines": { 37 | "node": "16" 38 | }, 39 | "keywords": [], 40 | "author": "Author Name", 41 | "license": "MIT", 42 | "devDependencies": { 43 | "@rollup/plugin-commonjs": "^21.0.1", 44 | "@rollup/plugin-node-resolve": "^13.1.2", 45 | "@types/jest": "^27.0.2", 46 | "@types/lodash": "^4.14.178", 47 | "@types/node": "^16.11.7", 48 | "@typescript-eslint/eslint-plugin": "^5.8.1", 49 | "@typescript-eslint/parser": "^5.8.1", 50 | "eslint": "^8.5.0", 51 | "eslint-config-prettier": "^8.3.0", 52 | "eslint-plugin-prettier": "^4.0.0", 53 | "jest": "^27.3.1", 54 | "jest-coverage-badges": "^1.1.2", 55 | "prettier": "^2.5.1", 56 | "rimraf": "^3.0.2", 57 | "rollup": "^2.62.0", 58 | "rollup-plugin-typescript2": "^0.31.1", 59 | "ts-jest": "^27.0.7", 60 | "ts-node": "^10.4.0", 61 | "typescript": "^4.4.4" 62 | }, 63 | "jest": { 64 | "preset": "ts-jest", 65 | "testEnvironment": "node", 66 | "coverageReporters": [ 67 | "json-summary", 68 | "text", 69 | "lcov" 70 | ] 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "npm run prettier-format && npm run lint" 75 | } 76 | }, 77 | "dependencies": { 78 | "deepmerge": "^4.2.2", 79 | "lodash.omit": "^4.5.0", 80 | "ts-pattern": "^3.3.4" 81 | } 82 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import commonjs from "@rollup/plugin-commonjs"; 2 | import nodeResolve from "@rollup/plugin-node-resolve"; 3 | import typescript from "rollup-plugin-typescript2"; 4 | import pkg from "./package.json"; 5 | 6 | export default [ 7 | // CommonJS 8 | { 9 | input: "src/index.ts", 10 | external: [...Object.keys(pkg.dependencies || {})], 11 | plugins: [ 12 | commonjs(), 13 | nodeResolve({ 14 | extensions: [".ts"], 15 | }), 16 | typescript(), 17 | ], 18 | output: [{ file: pkg.main, format: "cjs" }], 19 | }, 20 | // ES 21 | { 22 | input: "src/index.ts", 23 | external: [...Object.keys(pkg.dependencies || {})], 24 | plugins: [ 25 | nodeResolve({ 26 | extensions: [".ts"], 27 | }), 28 | typescript(), 29 | ], 30 | output: [{ file: pkg.module, format: "es" }], 31 | }, 32 | { 33 | input: "src/2020.ts", 34 | external: [...Object.keys(pkg.dependencies || {})], 35 | plugins: [ 36 | commonjs(), 37 | nodeResolve({ 38 | extensions: [".ts"], 39 | }), 40 | typescript(), 41 | ], 42 | output: [{ file: "lib/2020.js", format: "cjs" }], 43 | }, 44 | // ES 45 | { 46 | input: "src/2020.ts", 47 | external: [...Object.keys(pkg.dependencies || {})], 48 | plugins: [ 49 | nodeResolve({ 50 | extensions: [".ts"], 51 | }), 52 | typescript(), 53 | ], 54 | output: [{ file: "lib/2020.mjs", format: "es" }], 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /src/2020.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import deepmerge from "deepmerge"; 3 | import { access } from "fs"; 4 | import omit from "lodash/omit"; 5 | 6 | type TypeName = "string" | "number" | "integer" | "boolean" | "object" | "array" | "null"; 7 | 8 | type AnnotationSchema = { 9 | $id?: string; 10 | $comment?: string; 11 | default?: any; 12 | title?: string; 13 | description?: string; 14 | examples?: any[]; 15 | deprecated?: boolean; 16 | readOnly?: boolean; 17 | writeOnly?: boolean; 18 | }; 19 | 20 | type BaseSchema = AnnotationSchema & { 21 | $schema?: string; 22 | $ref?: string; 23 | $anchor?: string; 24 | $defs?: { [key: string]: Schema }; 25 | 26 | type?: TypeName | TypeName[]; 27 | enum?: any[]; 28 | const?: any; 29 | 30 | allOf?: Schema[]; 31 | anyOf?: Schema[]; 32 | oneOf?: Schema[]; 33 | not?: Schema; 34 | 35 | // Conditional schemas 36 | if?: Schema; 37 | then?: Schema; 38 | else?: Schema; 39 | }; 40 | 41 | export type Schema = boolean | SchemaDocument | AnySchema; 42 | 43 | export type SchemaDocument = 44 | | StringSchema 45 | | NumberSchema 46 | | IntSchema 47 | | ObjectSchema 48 | | ArraySchema 49 | | BooleanSchema 50 | | NullSchema; 51 | 52 | export type AnySchema = BaseSchema; 53 | 54 | export type StringFormat = 55 | | "date-time" 56 | | "time" 57 | | "date" 58 | | "duration" 59 | | "email" 60 | | "idn-email" 61 | | "hostname" 62 | | "idn-hostname" 63 | | "ipv4" 64 | | "ipv6" 65 | | "uuid" 66 | | "uri" 67 | | "uri-reference" 68 | | "iri" 69 | | "iri-reference" 70 | | "uri-template" 71 | | "json-pointer" 72 | | "relative-json-pointer" 73 | | "regex"; 74 | 75 | type MimeType = 76 | | "application/json" 77 | | "application/xml" 78 | | "text/xml" 79 | | "text/html" 80 | | "text/plain" 81 | | "application/octet-stream" 82 | | "text/css" 83 | | "text/csv" 84 | | "text/javascript" 85 | | "image/jpeg" 86 | | "image/png" 87 | | "image/gif" 88 | | "image/webp" 89 | | "image/bmp" 90 | | "image/apng" 91 | | "image/svg+xml" 92 | | "image/avif" 93 | | "video/webm" 94 | | "video/mp4" 95 | | "video/ogg" 96 | | "multipart/form-data"; 97 | 98 | type Encoding = "7bit" | "8bit" | "binary" | "quoted-printable" | "base16" | "base32" | "base64"; 99 | 100 | export type StringSchema = BaseSchema & { 101 | type: "string"; 102 | minLength?: number; 103 | maxLength?: number; 104 | pattern?: string; 105 | format?: StringFormat; 106 | contentMediaType?: MimeType; 107 | contentEncoding?: Encoding; 108 | }; 109 | 110 | type NumericSchema = BaseSchema & { 111 | minimum?: number; 112 | maximum?: number; 113 | exclusiveMinimum?: number; 114 | exclusiveMaximum?: number; 115 | multipleOf?: number; 116 | }; 117 | 118 | export type IntSchema = NumericSchema & { 119 | type: "integer"; 120 | }; 121 | 122 | export type NumberSchema = NumericSchema & { 123 | type: "number"; 124 | }; 125 | 126 | export type PropertiesSchema = { 127 | properties?: Record; 128 | required?: string[]; 129 | patternProperties?: Record; 130 | additionalProperties?: Schema; 131 | unevaluatedProperties?: boolean; 132 | propertyNames?: { pattern: string }; 133 | minProperties?: number; 134 | maxProperties?: number; 135 | }; 136 | 137 | export type ObjectSchema = BaseSchema & 138 | PropertiesSchema & { 139 | type: "object" | undefined; 140 | dependentRequired?: Record; 141 | dependentSchemas?: Record; 142 | }; 143 | 144 | export type ArraySchema = BaseSchema & { 145 | type: "array" | undefined; 146 | items?: Schema; 147 | prefixItems?: Schema[]; 148 | unevaluatedItems?: Schema; 149 | minItems?: number; 150 | maxItems?: number; 151 | uniqueItems?: boolean; 152 | contains?: Schema; 153 | maxContains?: number; 154 | minContains?: number; 155 | }; 156 | 157 | export type BooleanSchema = BaseSchema & { 158 | type: "boolean"; 159 | }; 160 | 161 | export type NullSchema = BaseSchema & { 162 | type: "null"; 163 | }; 164 | 165 | export const $schema = "https://json-schema.org/draft/2020-12/schema"; 166 | 167 | export class SchemaBuilder { 168 | schema: S; 169 | 170 | constructor(s: S) { 171 | this.schema = s; 172 | } 173 | 174 | apply(builder: SchemaBuilder) { 175 | this.schema = deepmerge(this.schema as any, builder.schema as any); 176 | } 177 | 178 | toSchema(): S { 179 | return this.schema; 180 | } 181 | 182 | toSchemaDocument(): Schema { 183 | if (typeof this.schema === "boolean") { 184 | return this.schema; 185 | } 186 | 187 | return { 188 | $schema, 189 | ...this.schema, 190 | }; 191 | } 192 | } 193 | 194 | const objectBuilder = (schema: Partial): SchemaBuilder => 195 | new SchemaBuilder({ 196 | ...schema, 197 | } as ObjectSchema); 198 | 199 | type ObjectOptions = { 200 | properties?: Array>; 201 | propertyNames?: string; 202 | additionalProperties?: SchemaBuilder; 203 | minProperties?: number; 204 | maxProperties?: number; 205 | unevaluatedProperties?: boolean; 206 | defs?: Array>; 207 | } & AnnotationSchema; 208 | 209 | function object(options?: ObjectOptions): SchemaBuilder { 210 | const properties = options?.properties || []; 211 | 212 | const additionalOptions = omit(options, [ 213 | "properties", 214 | "propertyNames", 215 | "additionalProperties", 216 | "defs", 217 | ]) as Omit; 218 | 219 | const schema = new SchemaBuilder({ 220 | type: "object", 221 | ...additionalOptions, 222 | }); 223 | 224 | for (const property of properties) { 225 | schema.apply(property); 226 | } 227 | 228 | if (options?.propertyNames) { 229 | schema.apply( 230 | objectBuilder({ 231 | propertyNames: { 232 | pattern: options.propertyNames, 233 | }, 234 | }), 235 | ); 236 | } 237 | 238 | if (options?.additionalProperties) { 239 | schema.apply( 240 | objectBuilder({ 241 | additionalProperties: options.additionalProperties?.toSchema(), 242 | }), 243 | ); 244 | } 245 | 246 | if (options?.defs) { 247 | for (const def of options.defs) { 248 | schema.apply(def as SchemaBuilder); 249 | } 250 | } 251 | 252 | return schema; 253 | } 254 | 255 | function properties(...props: Array>): SchemaBuilder { 256 | const schema = new SchemaBuilder({} as ObjectSchema); 257 | 258 | for (const property of props) { 259 | schema.apply(property); 260 | } 261 | 262 | return schema; 263 | } 264 | 265 | type RequiredPropertyOptions = { 266 | dependentSchema?: SchemaBuilder; 267 | }; 268 | 269 | function requiredProperty( 270 | name: string, 271 | schema: SchemaBuilder, 272 | options?: RequiredPropertyOptions, 273 | ): SchemaBuilder { 274 | return objectBuilder( 275 | Object.assign( 276 | { 277 | properties: { 278 | [name]: schema.toSchema(), 279 | }, 280 | required: [name], 281 | }, 282 | options?.dependentSchema 283 | ? { dependentSchemas: { [name]: options.dependentSchema?.toSchema() } } 284 | : {}, 285 | ), 286 | ); 287 | } 288 | 289 | type OptionalPropertyOptions = RequiredPropertyOptions & { 290 | dependsOn?: string[]; 291 | }; 292 | 293 | function property( 294 | name: string, 295 | schema: SchemaBuilder, 296 | options?: OptionalPropertyOptions, 297 | ): SchemaBuilder { 298 | return objectBuilder( 299 | Object.assign( 300 | { 301 | properties: { 302 | [name]: schema.toSchema(), 303 | }, 304 | }, 305 | options?.dependsOn ? { dependentRequired: { [name]: options.dependsOn } } : {}, 306 | options?.dependentSchema 307 | ? { dependentSchemas: { [name]: options.dependentSchema?.toSchema() } } 308 | : {}, 309 | ), 310 | ); 311 | } 312 | 313 | function patternProperty( 314 | pattern: string, 315 | schema: SchemaBuilder, 316 | ): SchemaBuilder { 317 | return objectBuilder({ 318 | patternProperties: { 319 | [pattern]: schema.toSchema(), 320 | }, 321 | }); 322 | } 323 | 324 | const arrayBuilder = (schema: Partial): SchemaBuilder => 325 | new SchemaBuilder({ 326 | ...schema, 327 | } as ArraySchema); 328 | 329 | type ArrayOptions = { 330 | items?: SchemaBuilder | boolean; 331 | prefixItems?: Array>; 332 | unevaluatedItems?: SchemaBuilder | boolean; 333 | minItems?: number; 334 | maxItems?: number; 335 | uniqueItems?: boolean; 336 | contains?: { schema: SchemaBuilder; max?: number; min?: number }; 337 | defs?: Array>; 338 | } & AnnotationSchema; 339 | 340 | function array(options?: ArrayOptions): SchemaBuilder { 341 | const additionalOptions = omit(options, [ 342 | "items", 343 | "prefixItems", 344 | "unevaluatedItems", 345 | "contains", 346 | "defs", 347 | ]) as Omit; 348 | 349 | const schema = new SchemaBuilder({ 350 | type: "array", 351 | ...additionalOptions, 352 | }); 353 | 354 | const items = options?.items; 355 | 356 | if (typeof items !== "undefined") { 357 | if (typeof items === "boolean") { 358 | schema.apply( 359 | arrayBuilder({ 360 | items, 361 | }), 362 | ); 363 | } else { 364 | schema.apply(arrayBuilder({ items: items.toSchema() })); 365 | } 366 | } 367 | 368 | if (options?.prefixItems) { 369 | for (const item of options.prefixItems) { 370 | schema.apply(arrayBuilder({ prefixItems: [item.toSchema()] })); 371 | } 372 | } 373 | 374 | const unevaluatedItems = options?.unevaluatedItems; 375 | 376 | if (typeof unevaluatedItems !== "undefined") { 377 | if (typeof unevaluatedItems === "boolean") { 378 | schema.apply( 379 | arrayBuilder({ 380 | unevaluatedItems, 381 | }), 382 | ); 383 | } else { 384 | schema.apply(arrayBuilder({ unevaluatedItems: unevaluatedItems.toSchema() })); 385 | } 386 | } 387 | 388 | if (options?.contains) { 389 | schema.apply( 390 | arrayBuilder({ 391 | contains: options.contains.schema.toSchema(), 392 | minContains: options.contains.min, 393 | maxContains: options.contains.max, 394 | }), 395 | ); 396 | } 397 | 398 | if (options?.defs) { 399 | for (const def of options.defs) { 400 | schema.apply(def as SchemaBuilder); 401 | } 402 | } 403 | 404 | return schema; 405 | } 406 | 407 | function string(options?: Omit): SchemaBuilder { 408 | return new SchemaBuilder({ 409 | type: "string", 410 | ...options, 411 | }); 412 | } 413 | 414 | function integer(options?: Omit): SchemaBuilder { 415 | return new SchemaBuilder({ 416 | type: "integer", 417 | ...options, 418 | }); 419 | } 420 | 421 | function number(options?: Omit): SchemaBuilder { 422 | return new SchemaBuilder({ 423 | type: "number", 424 | ...options, 425 | }); 426 | } 427 | 428 | function nil(options?: Omit): SchemaBuilder { 429 | return new SchemaBuilder({ 430 | type: "null", 431 | ...options, 432 | }); 433 | } 434 | 435 | function boolean(options?: Omit): SchemaBuilder { 436 | return new SchemaBuilder({ 437 | type: "boolean", 438 | ...options, 439 | }); 440 | } 441 | 442 | function nullable(schema: SchemaBuilder): SchemaBuilder { 443 | const nullableSchema = schema.toSchema(); 444 | 445 | if ( 446 | typeof nullableSchema === "boolean" || 447 | nullableSchema.type === "null" || 448 | typeof nullableSchema.type === "undefined" 449 | ) { 450 | return schema; 451 | } 452 | 453 | const type = Array.isArray(nullableSchema.type) 454 | ? nullableSchema.type.concat("null") 455 | : [nullableSchema.type, "null"]; 456 | 457 | return new SchemaBuilder({ 458 | ...nullableSchema, 459 | type, 460 | }); 461 | } 462 | 463 | function anyOf(...schemas: SchemaBuilder[]): SchemaBuilder { 464 | return new SchemaBuilder({ 465 | anyOf: schemas.map((s) => s.toSchema()), 466 | }); 467 | } 468 | 469 | function allOf(...schemas: SchemaBuilder[]): SchemaBuilder { 470 | return new SchemaBuilder({ 471 | allOf: schemas.map((s) => s.toSchema()), 472 | }); 473 | } 474 | 475 | function oneOf(...schemas: SchemaBuilder[]): SchemaBuilder { 476 | return new SchemaBuilder({ 477 | oneOf: schemas.map((s) => s.toSchema()), 478 | }); 479 | } 480 | 481 | function not(schema: SchemaBuilder): SchemaBuilder { 482 | return new SchemaBuilder({ 483 | not: schema.toSchema(), 484 | }); 485 | } 486 | 487 | function concat(...schemas: SchemaBuilder[]): SchemaBuilder { 488 | return new SchemaBuilder( 489 | schemas.reduce((acc, s) => { 490 | const schema = s.toSchema(); 491 | 492 | if (typeof acc === "boolean") { 493 | if (typeof schema === "boolean") { 494 | return acc || schema; 495 | } else { 496 | return schema; 497 | } 498 | } else if (typeof schema === "boolean") { 499 | return acc; 500 | } 501 | 502 | return { ...acc, ...schema }; 503 | }, {} as Schema), 504 | ); 505 | } 506 | 507 | function ifThenElse( 508 | condition: SchemaBuilder, 509 | then: SchemaBuilder, 510 | thenElse: SchemaBuilder, 511 | ): SchemaBuilder { 512 | return new SchemaBuilder({ 513 | if: condition.toSchema(), 514 | then: then.toSchema(), 515 | else: thenElse.toSchema(), 516 | }); 517 | } 518 | 519 | function ifThen( 520 | condition: SchemaBuilder, 521 | then: SchemaBuilder, 522 | ): SchemaBuilder { 523 | return new SchemaBuilder({ 524 | if: condition.toSchema(), 525 | then: then.toSchema(), 526 | }); 527 | } 528 | 529 | function def(name: string, schema: SchemaBuilder): SchemaBuilder { 530 | return new SchemaBuilder({ 531 | $defs: { 532 | [name]: schema.toSchema(), 533 | }, 534 | }); 535 | } 536 | 537 | function ref(def: string): SchemaBuilder { 538 | return new SchemaBuilder({ 539 | $ref: `#/$defs/${def}`, 540 | }); 541 | } 542 | 543 | function constant(value: any): SchemaBuilder { 544 | return new SchemaBuilder({ 545 | const: value, 546 | }); 547 | } 548 | 549 | function enumerator(...values: any[]): SchemaBuilder { 550 | return new SchemaBuilder({ 551 | enum: values, 552 | }); 553 | } 554 | 555 | function $false(): SchemaBuilder { 556 | return new SchemaBuilder(false); 557 | } 558 | 559 | function $true(): SchemaBuilder { 560 | return new SchemaBuilder(true); 561 | } 562 | 563 | export const s = { 564 | object, 565 | properties, 566 | requiredProperty, 567 | property, 568 | patternProperty, 569 | array, 570 | string, 571 | integer, 572 | number, 573 | nil, 574 | boolean, 575 | nullable, 576 | anyOf, 577 | allOf, 578 | oneOf, 579 | not, 580 | concat, 581 | ifThenElse, 582 | ifThen, 583 | def, 584 | ref, 585 | constant, 586 | enumerator, 587 | $false, 588 | $true, 589 | }; 590 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | Schema, 3 | ObjectSchema, 4 | StringSchema, 5 | NumberSchema, 6 | IntSchema, 7 | ArraySchema, 8 | BooleanSchema, 9 | NullSchema, 10 | AnySchema, 11 | StringFormat, 12 | PropertiesSchema, 13 | SchemaBuilder, 14 | } from "./2020"; 15 | 16 | export { $schema, s } from "./2020"; 17 | -------------------------------------------------------------------------------- /test-node.js: -------------------------------------------------------------------------------- 1 | const { s } = require("./lib"); 2 | 3 | console.log(s.string().toSchemaDocument()); 4 | -------------------------------------------------------------------------------- /tests/2020.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | s, 3 | $schema, 4 | ArraySchema, 5 | BooleanSchema, 6 | IntSchema, 7 | NullSchema, 8 | NumberSchema, 9 | ObjectSchema, 10 | StringSchema, 11 | } from "../src/2020"; 12 | 13 | describe("simple types", () => { 14 | test("it should support null schemas", () => { 15 | expect(s.nil().toSchemaDocument()).toEqual({ 16 | $schema, 17 | type: "null", 18 | }); 19 | }); 20 | 21 | test("it should support boolean schemas", () => { 22 | expect(s.boolean().toSchemaDocument()).toEqual({ 23 | $schema, 24 | type: "boolean", 25 | }); 26 | }); 27 | 28 | test("It should support numeric schemas", () => { 29 | expect(s.integer().toSchemaDocument()).toEqual({ 30 | $schema, 31 | type: "integer", 32 | }); 33 | 34 | expect(s.integer({ minimum: 0, maximum: 10 }).toSchemaDocument()).toStrictEqual({ 35 | $schema, 36 | type: "integer", 37 | minimum: 0, 38 | maximum: 10, 39 | }); 40 | 41 | expect( 42 | s.integer({ exclusiveMinimum: 0, exclusiveMaximum: 10 }).toSchemaDocument(), 43 | ).toStrictEqual({ 44 | $schema, 45 | type: "integer", 46 | exclusiveMinimum: 0, 47 | exclusiveMaximum: 10, 48 | }); 49 | 50 | expect(s.integer({ multipleOf: 5 }).toSchemaDocument()).toStrictEqual({ 51 | $schema, 52 | type: "integer", 53 | multipleOf: 5, 54 | }); 55 | 56 | expect(s.number().toSchemaDocument()).toEqual({ 57 | $schema, 58 | type: "number", 59 | }); 60 | 61 | expect(s.number({ minimum: 0, maximum: 10 }).toSchemaDocument()).toStrictEqual({ 62 | $schema, 63 | type: "number", 64 | minimum: 0, 65 | maximum: 10, 66 | }); 67 | 68 | expect( 69 | s.number({ exclusiveMinimum: 0, exclusiveMaximum: 10 }).toSchemaDocument(), 70 | ).toStrictEqual({ 71 | $schema, 72 | type: "number", 73 | exclusiveMinimum: 0, 74 | exclusiveMaximum: 10, 75 | }); 76 | 77 | expect(s.number({ multipleOf: 5 }).toSchemaDocument()).toStrictEqual({ 78 | $schema, 79 | type: "number", 80 | multipleOf: 5, 81 | }); 82 | }); 83 | 84 | test("strings", () => { 85 | expect(s.string().toSchemaDocument()).toStrictEqual({ 86 | $schema, 87 | type: "string", 88 | }); 89 | 90 | expect(s.string({ format: "ipv6" }).toSchemaDocument()).toStrictEqual({ 91 | $schema, 92 | type: "string", 93 | format: "ipv6", 94 | }); 95 | 96 | expect(s.string({ minLength: 8, maxLength: 32 }).toSchemaDocument()).toStrictEqual({ 97 | $schema, 98 | type: "string", 99 | minLength: 8, 100 | maxLength: 32, 101 | }); 102 | 103 | expect(s.string({ pattern: "*" }).toSchemaDocument()).toStrictEqual({ 104 | $schema, 105 | type: "string", 106 | pattern: "*", 107 | }); 108 | 109 | expect( 110 | s 111 | .string({ contentMediaType: "application/json", contentEncoding: "base64" }) 112 | .toSchemaDocument(), 113 | ).toStrictEqual({ 114 | $schema, 115 | type: "string", 116 | contentMediaType: "application/json", 117 | contentEncoding: "base64", 118 | }); 119 | }); 120 | 121 | test("it should support annotations", () => { 122 | expect( 123 | s 124 | .nil({ title: "Hello", description: "This is a description", examples: [1, 2] }) 125 | .toSchemaDocument(), 126 | ).toEqual({ 127 | $schema, 128 | title: "Hello", 129 | description: "This is a description", 130 | examples: [1, 2], 131 | type: "null", 132 | }); 133 | 134 | expect( 135 | s 136 | .integer({ title: "Hello", description: "This is a description", examples: [1, 2] }) 137 | .toSchemaDocument(), 138 | ).toEqual({ 139 | $schema, 140 | title: "Hello", 141 | description: "This is a description", 142 | examples: [1, 2], 143 | type: "integer", 144 | }); 145 | 146 | expect( 147 | s 148 | .boolean({ title: "Hello", description: "This is a description", examples: [1, 2] }) 149 | .toSchemaDocument(), 150 | ).toEqual({ 151 | $schema, 152 | title: "Hello", 153 | description: "This is a description", 154 | examples: [1, 2], 155 | type: "boolean", 156 | }); 157 | 158 | expect( 159 | s 160 | .string({ title: "Hello", description: "This is a description", examples: [1, 2] }) 161 | .toSchemaDocument(), 162 | ).toEqual({ 163 | $schema, 164 | title: "Hello", 165 | description: "This is a description", 166 | examples: [1, 2], 167 | type: "string", 168 | }); 169 | }); 170 | }); 171 | 172 | describe("objects", () => { 173 | test("it should be able to create simple schemas with annotations", () => { 174 | expect( 175 | s 176 | .object({ title: "Hello", description: "This is a description", examples: [1, 2] }) 177 | .toSchemaDocument(), 178 | ).toEqual({ 179 | $schema, 180 | title: "Hello", 181 | description: "This is a description", 182 | examples: [1, 2], 183 | type: "object", 184 | }); 185 | }); 186 | 187 | test("it should support optional properties", () => { 188 | expect( 189 | s.object({ properties: [s.property("name", s.string())] }).toSchemaDocument(), 190 | ).toStrictEqual({ 191 | $schema, 192 | type: "object", 193 | properties: { 194 | name: { type: "string" }, 195 | }, 196 | }); 197 | }); 198 | 199 | test("it should support required properties", () => { 200 | expect( 201 | s.object({ properties: [s.requiredProperty("name", s.string())] }).toSchemaDocument(), 202 | ).toStrictEqual({ 203 | $schema, 204 | type: "object", 205 | properties: { 206 | name: { type: "string" }, 207 | }, 208 | required: ["name"], 209 | }); 210 | }); 211 | 212 | test("it should support pattern properties", () => { 213 | expect( 214 | s.object({ properties: [s.patternProperty("^[A-Za-z]$", s.string())] }).toSchemaDocument(), 215 | ).toStrictEqual({ 216 | $schema, 217 | type: "object", 218 | patternProperties: { 219 | "^[A-Za-z]$": { type: "string" }, 220 | }, 221 | }); 222 | }); 223 | 224 | test("it should support additional properties", () => { 225 | expect(s.object({ additionalProperties: s.string() }).toSchemaDocument()).toStrictEqual({ 226 | $schema, 227 | type: "object", 228 | additionalProperties: { 229 | type: "string", 230 | }, 231 | }); 232 | }); 233 | 234 | test("it should support property names", () => { 235 | expect( 236 | s.object({ propertyNames: "^[A-Za-z_][A-Za-z0-9_]*$" }).toSchemaDocument(), 237 | ).toStrictEqual({ 238 | $schema, 239 | type: "object", 240 | propertyNames: { 241 | pattern: "^[A-Za-z_][A-Za-z0-9_]*$", 242 | }, 243 | }); 244 | }); 245 | 246 | test("it should support key ranges", () => { 247 | expect(s.object({ minProperties: 3, maxProperties: 10 }).toSchemaDocument()).toStrictEqual({ 248 | $schema, 249 | type: "object", 250 | minProperties: 3, 251 | maxProperties: 10, 252 | }); 253 | }); 254 | 255 | test("it should support unevaluatedProperties", () => { 256 | expect(s.object({ unevaluatedProperties: false }).toSchemaDocument()).toStrictEqual({ 257 | $schema, 258 | type: "object", 259 | unevaluatedProperties: false, 260 | }); 261 | }); 262 | 263 | test("it should support dependent properties", () => { 264 | expect( 265 | s 266 | .object({ 267 | properties: [ 268 | s.property("name", s.string()), 269 | s.property("email", s.string({ format: "email" }), { dependsOn: ["name"] }), 270 | ], 271 | }) 272 | .toSchemaDocument(), 273 | ).toStrictEqual({ 274 | $schema, 275 | type: "object", 276 | properties: { 277 | name: { type: "string" }, 278 | email: { type: "string", format: "email" }, 279 | }, 280 | dependentRequired: { 281 | email: ["name"], 282 | }, 283 | }); 284 | }); 285 | 286 | test("it should support dependent schemas", () => { 287 | expect( 288 | s 289 | .object({ 290 | properties: [ 291 | s.property("name", s.string()), 292 | s.property("creditCard", s.string(), { 293 | dependentSchema: s.properties(s.requiredProperty("billing", s.string())), 294 | }), 295 | ], 296 | }) 297 | .toSchemaDocument(), 298 | ).toStrictEqual({ 299 | $schema, 300 | type: "object", 301 | properties: { 302 | name: { type: "string" }, 303 | creditCard: { type: "string" }, 304 | }, 305 | dependentSchemas: { 306 | creditCard: { 307 | properties: { 308 | billing: { type: "string" }, 309 | }, 310 | required: ["billing"], 311 | }, 312 | }, 313 | }); 314 | 315 | expect( 316 | s 317 | .object({ 318 | properties: [ 319 | s.property("name", s.string()), 320 | s.requiredProperty("creditCard", s.string(), { 321 | dependentSchema: s.properties(s.requiredProperty("billing", s.string())), 322 | }), 323 | ], 324 | }) 325 | .toSchemaDocument(), 326 | ).toStrictEqual({ 327 | $schema, 328 | type: "object", 329 | properties: { 330 | name: { type: "string" }, 331 | creditCard: { type: "string" }, 332 | }, 333 | required: ["creditCard"], 334 | dependentSchemas: { 335 | creditCard: { 336 | properties: { 337 | billing: { type: "string" }, 338 | }, 339 | required: ["billing"], 340 | }, 341 | }, 342 | }); 343 | }); 344 | }); 345 | 346 | describe("arrays", () => { 347 | test("it should be able to create simple schemas", () => { 348 | expect(s.array().toSchemaDocument()).toEqual({ 349 | $schema, 350 | type: "array", 351 | }); 352 | 353 | expect( 354 | s 355 | .array({ title: "Hello", description: "This is a description", examples: [1, 2] }) 356 | .toSchemaDocument(), 357 | ).toEqual({ 358 | $schema, 359 | title: "Hello", 360 | description: "This is a description", 361 | examples: [1, 2], 362 | type: "array", 363 | }); 364 | }); 365 | 366 | test("it should support item schemas", () => { 367 | expect(s.array({ items: s.string() }).toSchemaDocument()).toEqual({ 368 | $schema, 369 | type: "array", 370 | items: { 371 | type: "string", 372 | }, 373 | }); 374 | }); 375 | 376 | test("it should support tuple validation", () => { 377 | expect( 378 | s.array({ prefixItems: [s.string(), s.integer(), s.boolean()] }).toSchemaDocument(), 379 | ).toEqual({ 380 | $schema, 381 | type: "array", 382 | prefixItems: [{ type: "string" }, { type: "integer" }, { type: "boolean" }], 383 | }); 384 | 385 | expect( 386 | s 387 | .array({ prefixItems: [s.string(), s.integer(), s.boolean()], items: s.nil() }) 388 | .toSchemaDocument(), 389 | ).toEqual({ 390 | $schema, 391 | type: "array", 392 | prefixItems: [{ type: "string" }, { type: "integer" }, { type: "boolean" }], 393 | items: { type: "null" }, 394 | }); 395 | 396 | expect( 397 | s 398 | .array({ prefixItems: [s.string(), s.integer(), s.boolean()], items: false }) 399 | .toSchemaDocument(), 400 | ).toEqual({ 401 | $schema, 402 | type: "array", 403 | prefixItems: [{ type: "string" }, { type: "integer" }, { type: "boolean" }], 404 | items: false, 405 | }); 406 | }); 407 | 408 | test("it should support unevaluated items", () => { 409 | expect(s.array({ items: s.string(), unevaluatedItems: false }).toSchemaDocument()).toEqual({ 410 | $schema, 411 | type: "array", 412 | items: { type: "string" }, 413 | unevaluatedItems: false, 414 | }); 415 | 416 | expect(s.array({ items: s.string(), unevaluatedItems: s.string() }).toSchemaDocument()).toEqual( 417 | { 418 | $schema, 419 | type: "array", 420 | items: { type: "string" }, 421 | unevaluatedItems: { type: "string" }, 422 | }, 423 | ); 424 | }); 425 | 426 | test("it should support range schemas", () => { 427 | expect(s.array({ items: s.string(), minItems: 4, maxItems: 10 }).toSchemaDocument()).toEqual({ 428 | $schema, 429 | type: "array", 430 | items: { type: "string" }, 431 | minItems: 4, 432 | maxItems: 10, 433 | }); 434 | }); 435 | 436 | test("it should support unique items", () => { 437 | expect(s.array({ items: s.string(), uniqueItems: true }).toSchemaDocument()).toEqual({ 438 | $schema, 439 | type: "array", 440 | items: { type: "string" }, 441 | uniqueItems: true, 442 | }); 443 | }); 444 | 445 | test("it should support contain schemas", () => { 446 | expect( 447 | s.array({ contains: { schema: s.string(), min: 1, max: 3 } }).toSchemaDocument(), 448 | ).toEqual({ 449 | $schema, 450 | type: "array", 451 | contains: { type: "string" }, 452 | minContains: 1, 453 | maxContains: 3, 454 | }); 455 | }); 456 | }); 457 | 458 | describe("schema composition", () => { 459 | test("it should support allOf", () => { 460 | expect(s.allOf(s.string(), s.integer()).toSchemaDocument()).toStrictEqual({ 461 | $schema, 462 | allOf: [{ type: "string" }, { type: "integer" }], 463 | }); 464 | }); 465 | 466 | test("it should support anyOf", () => { 467 | expect(s.anyOf(s.string(), s.integer()).toSchemaDocument()).toStrictEqual({ 468 | $schema, 469 | anyOf: [{ type: "string" }, { type: "integer" }], 470 | }); 471 | }); 472 | 473 | test("it should support oneOf", () => { 474 | expect(s.oneOf(s.string(), s.integer()).toSchemaDocument()).toStrictEqual({ 475 | $schema, 476 | oneOf: [{ type: "string" }, { type: "integer" }], 477 | }); 478 | }); 479 | 480 | test("it should support not", () => { 481 | expect(s.not(s.string()).toSchemaDocument()).toStrictEqual({ 482 | $schema, 483 | not: { type: "string" }, 484 | }); 485 | }); 486 | 487 | test("it should support concatenation two schemas together", () => { 488 | const schema = s.concat(s.object(), s.allOf(s.object())); 489 | 490 | expect(schema.toSchemaDocument()).toStrictEqual({ 491 | $schema, 492 | type: "object", 493 | allOf: [ 494 | { 495 | type: "object", 496 | }, 497 | ], 498 | }); 499 | }); 500 | }); 501 | 502 | describe("conditionals", () => { 503 | test("should support if-then-else", () => { 504 | expect(s.ifThenElse(s.boolean(), s.string(), s.integer()).toSchemaDocument()).toStrictEqual({ 505 | $schema, 506 | if: { type: "boolean" }, 507 | then: { type: "string" }, 508 | else: { type: "integer" }, 509 | }); 510 | 511 | expect(s.ifThen(s.boolean(), s.string()).toSchemaDocument()).toStrictEqual({ 512 | $schema, 513 | if: { type: "boolean" }, 514 | then: { type: "string" }, 515 | }); 516 | }); 517 | }); 518 | 519 | describe("structuring", () => { 520 | test("should support referencing a definition", () => { 521 | const emailDefinition = s.def("email", s.string({ format: "email" })); 522 | 523 | const objectSchema = s.object({ 524 | properties: [s.property("email", s.ref("email")), s.property("friend", s.ref("email"))], 525 | defs: [emailDefinition], 526 | }); 527 | 528 | expect(objectSchema.toSchemaDocument()).toStrictEqual({ 529 | $schema, 530 | type: "object", 531 | properties: { 532 | email: { $ref: "#/$defs/email" }, 533 | friend: { $ref: "#/$defs/email" }, 534 | }, 535 | $defs: { 536 | email: { type: "string", format: "email" }, 537 | }, 538 | }); 539 | 540 | const arraySchema = s.array({ 541 | items: s.ref("email"), 542 | defs: [emailDefinition], 543 | }); 544 | 545 | expect(arraySchema.toSchemaDocument()).toStrictEqual({ 546 | $schema, 547 | type: "array", 548 | items: { 549 | $ref: "#/$defs/email", 550 | }, 551 | $defs: { 552 | email: { type: "string", format: "email" }, 553 | }, 554 | }); 555 | }); 556 | }); 557 | 558 | describe("const and enums", () => { 559 | test("They should work", () => { 560 | expect(s.constant("foo").toSchemaDocument()).toStrictEqual({ 561 | $schema, 562 | const: "foo", 563 | }); 564 | 565 | expect(s.enumerator("foo", "bar").toSchemaDocument()).toStrictEqual({ 566 | $schema, 567 | enum: ["foo", "bar"], 568 | }); 569 | }); 570 | }); 571 | 572 | describe("nullables", () => { 573 | test("Should allow a type to be null", () => { 574 | expect(s.nullable(s.string()).toSchemaDocument()).toStrictEqual({ 575 | $schema, 576 | type: ["string", "null"], 577 | }); 578 | 579 | expect(s.nullable(s.string({ format: "email" })).toSchemaDocument()).toStrictEqual({ 580 | $schema, 581 | type: ["string", "null"], 582 | format: "email", 583 | }); 584 | 585 | expect( 586 | s.nullable(s.object({ properties: [s.property("foo", s.string())] })).toSchemaDocument(), 587 | ).toStrictEqual({ 588 | $schema, 589 | type: ["object", "null"], 590 | properties: { 591 | foo: { type: "string" }, 592 | }, 593 | }); 594 | }); 595 | }); 596 | 597 | describe("types", () => { 598 | test("Types to create object schemas", () => { 599 | const foo: ObjectSchema = { 600 | $schema, 601 | type: "object", 602 | properties: { 603 | bar: { 604 | type: "string", 605 | }, 606 | }, 607 | required: ["bar"], 608 | additionalProperties: { 609 | type: "number", 610 | }, 611 | unevaluatedProperties: true, 612 | propertyNames: { 613 | pattern: "^[a-z]+$", 614 | }, 615 | patternProperties: { 616 | "^[a-z]+$": { 617 | type: "string", 618 | }, 619 | }, 620 | maxProperties: 10, 621 | minProperties: 1, 622 | dependentRequired: { 623 | foo: ["bar"], 624 | }, 625 | dependentSchemas: { 626 | foo: { 627 | properties: { 628 | bar: { 629 | type: "string", 630 | }, 631 | }, 632 | required: ["bar"], 633 | }, 634 | }, 635 | $id: "foo", 636 | title: "foo", 637 | description: "bar", 638 | $comment: "baz", 639 | default: [], 640 | examples: [[1, 2, 3]], 641 | deprecated: true, 642 | readOnly: true, 643 | writeOnly: false, 644 | }; 645 | 646 | expect(foo.type).toBe("object"); 647 | }); 648 | 649 | test("Types to create string schemas", () => { 650 | const foo: StringSchema = { 651 | $schema, 652 | type: "string", 653 | enum: ["foo", "bar"], 654 | format: "email", 655 | pattern: "^[a-z]+$", 656 | minLength: 1, 657 | maxLength: 10, 658 | $id: "foo", 659 | title: "foo", 660 | description: "bar", 661 | $comment: "baz", 662 | default: [], 663 | examples: [[1, 2, 3]], 664 | deprecated: true, 665 | readOnly: true, 666 | writeOnly: false, 667 | }; 668 | 669 | expect(foo.type).toBe("string"); 670 | }); 671 | 672 | test("Types to create array schemas", () => { 673 | const foo: ArraySchema = { 674 | $schema, 675 | type: "array", 676 | items: { 677 | type: "string", 678 | }, 679 | prefixItems: [{ type: "string" }, { type: "number" }], 680 | unevaluatedItems: { type: "string" }, 681 | minItems: 1, 682 | maxItems: 10, 683 | uniqueItems: true, 684 | contains: { type: "string" }, 685 | maxContains: 10, 686 | minContains: 1, 687 | $id: "foo", 688 | title: "foo", 689 | description: "bar", 690 | $comment: "baz", 691 | default: [], 692 | examples: [[1, 2, 3]], 693 | deprecated: true, 694 | readOnly: true, 695 | writeOnly: false, 696 | }; 697 | 698 | expect(foo.type).toBe("array"); 699 | }); 700 | 701 | test("types to create integer schemas", () => { 702 | const foo: IntSchema = { 703 | $schema, 704 | type: "integer", 705 | enum: [1, 2, 3], 706 | minimum: 1, 707 | maximum: 10, 708 | $id: "foo", 709 | title: "foo", 710 | description: "bar", 711 | $comment: "baz", 712 | default: [], 713 | examples: [1, 2, 3], 714 | deprecated: true, 715 | readOnly: true, 716 | writeOnly: false, 717 | }; 718 | 719 | expect(foo.type).toBe("integer"); 720 | }); 721 | 722 | test("types to create number schemas", () => { 723 | const foo: NumberSchema = { 724 | $schema, 725 | type: "number", 726 | enum: [1, 2, 3], 727 | minimum: 1, 728 | maximum: 10, 729 | $id: "foo", 730 | title: "foo", 731 | description: "bar", 732 | $comment: "baz", 733 | default: [], 734 | examples: [1, 2, 3], 735 | deprecated: true, 736 | readOnly: true, 737 | writeOnly: false, 738 | }; 739 | 740 | expect(foo.type).toBe("number"); 741 | }); 742 | 743 | test("other types schemas", () => { 744 | const boolSchema: BooleanSchema = { 745 | $schema, 746 | type: "boolean", 747 | $id: "foo", 748 | title: "foo", 749 | description: "bar", 750 | $comment: "baz", 751 | default: [], 752 | examples: [1, 2, 3], 753 | deprecated: true, 754 | readOnly: true, 755 | writeOnly: false, 756 | }; 757 | 758 | expect(boolSchema.type).toBe("boolean"); 759 | 760 | const nullSchema: NullSchema = { 761 | $schema, 762 | type: "null", 763 | $id: "foo", 764 | title: "foo", 765 | description: "bar", 766 | $comment: "baz", 767 | default: [], 768 | examples: [1, 2, 3], 769 | deprecated: true, 770 | readOnly: true, 771 | writeOnly: false, 772 | }; 773 | 774 | expect(nullSchema.type).toBe("null"); 775 | }); 776 | }); 777 | -------------------------------------------------------------------------------- /tests/__snapshots__/readme.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Readme examples work 1`] = ` 4 | Object { 5 | "$comment": "This is just a preview", 6 | "$defs": Object { 7 | "phoneNumber": Object { 8 | "pattern": "^[0-9]{3}-[0-9]{3}-[0-9]{4}$", 9 | "type": "string", 10 | }, 11 | "ukAddress": Object { 12 | "properties": Object { 13 | "postCode": Object { 14 | "type": "string", 15 | }, 16 | }, 17 | "required": Array [ 18 | "postCode", 19 | ], 20 | "type": "object", 21 | }, 22 | "usAddress": Object { 23 | "properties": Object { 24 | "zipCode": Object { 25 | "type": "string", 26 | }, 27 | }, 28 | "required": Array [ 29 | "zipCode", 30 | ], 31 | "type": "object", 32 | }, 33 | }, 34 | "$id": "/schemas/person", 35 | "$schema": "https://json-schema.org/draft/2020-12/schema", 36 | "additionalProperties": Object { 37 | "items": Object { 38 | "maximum": 5000, 39 | "minimum": 0, 40 | "type": "number", 41 | }, 42 | "type": "array", 43 | }, 44 | "default": Object {}, 45 | "description": "Attributes of a person object", 46 | "examples": Array [ 47 | Object { 48 | "email": "eric@stackhero.dev", 49 | "name": "Eric", 50 | }, 51 | ], 52 | "maxProperties": 20, 53 | "minProperties": 3, 54 | "patternProperties": Object { 55 | "^[A-Za-z]$": Object { 56 | "type": "string", 57 | }, 58 | }, 59 | "properties": Object { 60 | "billingAddress": Object { 61 | "oneOf": Array [ 62 | Object { 63 | "$ref": "#/$defs/ukAddress", 64 | }, 65 | Object { 66 | "$ref": "#/$defs/usAddress", 67 | }, 68 | ], 69 | }, 70 | "email": Object { 71 | "format": "email", 72 | "type": "string", 73 | }, 74 | "name": Object { 75 | "type": "string", 76 | }, 77 | "phoneNumber": Object { 78 | "$ref": "#/$defs/phoneNumber", 79 | }, 80 | }, 81 | "propertyNames": Object { 82 | "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", 83 | }, 84 | "required": Array [ 85 | "name", 86 | ], 87 | "title": "Person Profile", 88 | "type": "object", 89 | "unevaluatedProperties": false, 90 | } 91 | `; 92 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { s } from "../src"; 2 | 3 | test("it should use draft 2020 by default", () => { 4 | expect(s.string().toSchemaDocument()).toEqual({ 5 | $schema: "https://json-schema.org/draft/2020-12/schema", 6 | type: "string", 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /tests/readme.test.ts: -------------------------------------------------------------------------------- 1 | import { s } from "../src"; 2 | 3 | test("Readme examples work", () => { 4 | const phoneNumber = s.def("phoneNumber", s.string({ pattern: "^[0-9]{3}-[0-9]{3}-[0-9]{4}$" })); 5 | const usAddress = s.def( 6 | "usAddress", 7 | s.object({ 8 | properties: [s.requiredProperty("zipCode", s.string())], 9 | }), 10 | ); 11 | 12 | const ukAddress = s.def( 13 | "ukAddress", 14 | s.object({ 15 | properties: [s.requiredProperty("postCode", s.string())], 16 | }), 17 | ); 18 | 19 | const schema = s.object({ 20 | $id: "/schemas/person", 21 | title: "Person Profile", 22 | description: "Attributes of a person object", 23 | examples: [ 24 | { 25 | name: "Eric", 26 | email: "eric@stackhero.dev", 27 | }, 28 | ], 29 | $comment: "This is just a preview", 30 | default: {}, 31 | properties: [ 32 | s.requiredProperty("name", s.string()), 33 | s.property("email", s.string({ format: "email" })), 34 | s.property("phoneNumber", s.ref("phoneNumber")), 35 | s.property("billingAddress", s.oneOf(s.ref("ukAddress"), s.ref("usAddress"))), 36 | s.patternProperty("^[A-Za-z]$", s.string()), 37 | ], 38 | additionalProperties: s.array({ 39 | items: s.number({ minimum: 0, maximum: 5000 }), 40 | }), 41 | propertyNames: "^[A-Za-z_][A-Za-z0-9_]*$", 42 | minProperties: 3, 43 | maxProperties: 20, 44 | unevaluatedProperties: false, 45 | defs: [phoneNumber, usAddress, ukAddress], 46 | }); 47 | 48 | expect(schema.toSchemaDocument()).toMatchSnapshot(); 49 | }); 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "preserveConstEnums": true, 5 | "outDir": "./lib", 6 | "declaration": true, 7 | "allowJs": true, 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "strictPropertyInitialization": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules", "**/*.test.ts"] 14 | } 15 | --------------------------------------------------------------------------------