├── .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 |
--------------------------------------------------------------------------------
/badges/badge-functions.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/badges/badge-lines.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/badges/badge-statements.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------