diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a0e7df93 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Set default behavior to automatically convert line endings +* text=auto eol=lf diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dfa7fa6c..35d66ca7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,5 +9,5 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "weekly" + interval: "monthly" open-pull-requests-limit: 10 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index ca62a071..00000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,111 +0,0 @@ -name: Benchmark - -on: - pull_request_target: - types: [labeled] - -jobs: - benchmark: - if: ${{ github.event.label.name == 'benchmark' }} - runs-on: ubuntu-latest - outputs: - PR-BENCH-12: ${{ steps.benchmark-pr.outputs.BENCH_RESULT12 }} - PR-BENCH-14: ${{ steps.benchmark-pr.outputs.BENCH_RESULT14 }} - PR-BENCH-16: ${{ steps.benchmark-pr.outputs.BENCH_RESULT16 }} - MAIN-BENCH-12: ${{ steps.benchmark-main.outputs.BENCH_RESULT12 }} - MAIN-BENCH-14: ${{ steps.benchmark-main.outputs.BENCH_RESULT14 }} - MAIN-BENCH-16: ${{ steps.benchmark-main.outputs.BENCH_RESULT16 }} - strategy: - matrix: - node-version: [12, 14, 16] - steps: - - uses: actions/checkout@v2 - with: - ref: ${{github.event.pull_request.head.ref}} - repository: ${{github.event.pull_request.head.repo.full_name}} - - - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node-version }} - - - name: Install - run: | - npm install --ignore-scripts - - - name: Run benchmark - id: benchmark-pr - run: | - npm run --silent benchmark > ./bench-result - content=$(cat ./bench-result) - content="${content//'%'/'%25'}" - content="${content//$'\n'/'%0A'}" - content="${content//$'\r'/'%0D'}" - echo "::set-output name=BENCH_RESULT${{matrix.node-version}}::$content" - - # main benchmark - - uses: actions/checkout@v2 - with: - ref: 'master' - - - name: Install - run: | - npm install --ignore-scripts - - - name: Run benchmark - id: benchmark-master - run: | - npm run --silent benchmark > ./bench-result - content=$(cat ./bench-result) - content="${content//'%'/'%25'}" - content="${content//$'\n'/'%0A'}" - content="${content//$'\r'/'%0D'}" - echo "::set-output name=BENCH_RESULT${{matrix.node-version}}::$content" - - output-benchmark: - needs: [benchmark] - runs-on: ubuntu-latest - steps: - - name: Comment PR - uses: thollander/actions-comment-pull-request@v1 - with: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - message: | - **Node**: 12 - **PR**: - ``` - ${{ needs.benchmark.outputs.PR-BENCH-12 }} - ``` - **MAIN**: - ``` - ${{ needs.benchmark.outputs.MAIN-BENCH-12 }} - ``` - - --- - - **Node**: 14 - **PR**: - ``` - ${{ needs.benchmark.outputs.PR-BENCH-14 }} - ``` - **MAIN**: - ``` - ${{ needs.benchmark.outputs.MAIN-BENCH-14 }} - ``` - - --- - - **Node**: 16 - **PR**: - ``` - ${{ needs.benchmark.outputs.PR-BENCH-16 }} - ``` - **MAIN**: - ``` - ${{ needs.benchmark.outputs.MAIN-BENCH-16 }} - ``` - - - uses: actions-ecosystem/action-remove-labels@v1 - with: - labels: | - benchmark - github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ead0f3a..5508c503 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,10 @@ name: CI on: push: + branches: + - main + - next + - 'v*' paths-ignore: - 'docs/**' - '*.md' @@ -10,38 +14,20 @@ on: - 'docs/**' - '*.md' -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - node-version: [14, 16, 17] - os: [macos-latest, ubuntu-latest, windows-latest] - - steps: - - uses: actions/checkout@v2 - - - name: Use Node.js - uses: actions/setup-node@v2 - with: - node-version: ${{ matrix.node-version }} - - - name: Install Dependencies - run: | - npm install --ignore-scripts +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}" + cancel-in-progress: true - - name: Run Tests - run: | - npm test +permissions: + contents: read - automerge: - needs: test - runs-on: ubuntu-latest +jobs: + test: permissions: - pull-requests: write contents: write - steps: - - uses: fastify/github-action-merge-dependabot@v3 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - target: minor + pull-requests: write + uses: fastify/workflows/.github/workflows/plugins-ci.yml@v5 + with: + license-check: true + lint: true diff --git a/.gitignore b/.gitignore index 19929526..3c21249e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +.pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json @@ -53,6 +54,9 @@ web_modules/ # Optional eslint cache .eslintcache +# Optional stylelint cache +.stylelintcache + # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ @@ -68,9 +72,12 @@ web_modules/ # Yarn Integrity file .yarn-integrity -# dotenv environment variables file +# dotenv environment variable files .env -.env.test +.env.development.local +.env.test.local +.env.production.local +.env.local # parcel-bundler cache (https://parceljs.org/) .cache @@ -93,6 +100,13 @@ dist # vuepress build output .vuepress/dist +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + # Serverless directories .serverless/ @@ -121,10 +135,15 @@ dist # macOS files .DS_Store +# Clinic +.clinic + # lock files +bun.lockb package-lock.json +pnpm-lock.yaml yarn.lock # editor files .vscode -.idea \ No newline at end of file +.idea diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..3757b304 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +package-lock=false diff --git a/.taprc b/.taprc deleted file mode 100644 index 31e63a78..00000000 --- a/.taprc +++ /dev/null @@ -1,3 +0,0 @@ -ts: false -jsx: false -coverage: false diff --git a/LICENSE b/LICENSE index 23b64424..e4277758 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,9 @@ -The MIT License (MIT) +MIT License -Copyright (c) 2016-2018 Matteo Collina +Copyright (c) 2016-present Matteo Collina +Copyright (c) 2016-present The Fastify team + +The Fastify team members are listed at https://github.com/fastify/fastify#team. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f4606ccd..03b079a5 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,12 @@ # fast-json-stringify -![CI](https://github.com/fastify/fast-json-stringify/workflows/CI/badge.svg) +[![CI](https://github.com/fastify/fast-json-stringify/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fastify/fast-json-stringify/actions/workflows/ci.yml) [![NPM version](https://img.shields.io/npm/v/fast-json-stringify.svg?style=flat)](https://www.npmjs.com/package/fast-json-stringify) -[![Known Vulnerabilities](https://snyk.io/test/github/fastify/fast-json-stringify/badge.svg)](https://snyk.io/test/github/fastify/fast-json-stringify) -[![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat)](https://standardjs.com/) +[![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-brightgreen?style=flat)](https://github.com/neostandard/neostandard) [![NPM downloads](https://img.shields.io/npm/dm/fast-json-stringify.svg?style=flat)](https://www.npmjs.com/package/fast-json-stringify) - -__fast-json-stringify__ is significantly faster than `JSON.stringify()` for small payloads. -Its performance advantage shrinks as your payload grows. -It pairs well with [__flatstr__](https://www.npmjs.com/package/flatstr), which triggers a V8 optimization that improves performance when eventually converting the string to a `Buffer`. +__fast-json-stringify__ is significantly faster than `JSON.stringify()` for small payloads. +Its performance advantage shrinks as your payload grows. ### How it works @@ -19,31 +16,43 @@ fast-json-stringify requires a [JSON Schema Draft 7](https://json-schema.org/spe ##### Benchmarks - Machine: `EX41S-SSD, Intel Core i7, 4Ghz, 64GB RAM, 4C/8T, SSD`. -- Node.js `v16.9.1` +- Node.js `v22.14.0` ``` -FJS creation x 8,443 ops/sec ±1.01% (90 runs sampled) -CJS creation x 183,219 ops/sec ±0.13% (96 runs sampled) -AJV Serialize creation x 83,541,848 ops/sec ±0.24% (98 runs sampled) -JSON.stringify array x 5,363 ops/sec ±0.11% (100 runs sampled) -fast-json-stringify array x 6,747 ops/sec ±0.13% (98 runs sampled) -compile-json-stringify array x 7,121 ops/sec ±0.42% (98 runs sampled) -AJV Serialize array x 7,533 ops/sec ±0.13% (98 runs sampled) -JSON.stringify long string x 16,461 ops/sec ±0.12% (98 runs sampled) -fast-json-stringify long string x 16,443 ops/sec ±0.37% (99 runs sampled) -compile-json-stringify long string x 16,458 ops/sec ±0.09% (98 runs sampled) -AJV Serialize long string x 21,433 ops/sec ±0.08% (95 runs sampled) -JSON.stringify short string x 12,035,664 ops/sec ±0.62% (96 runs sampled) -fast-json-stringify short string x 38,281,060 ops/sec ±0.24% (98 runs sampled) -compile-json-stringify short string x 32,388,037 ops/sec ±0.27% (97 runs sampled) -AJV Serialize short string x 32,288,612 ops/sec ±0.32% (95 runs sampled) -JSON.stringify obj x 3,068,185 ops/sec ±0.16% (98 runs sampled) -fast-json-stringify obj x 10,082,694 ops/sec ±0.10% (97 runs sampled) -compile-json-stringify obj x 17,037,963 ops/sec ±1.17% (97 runs sampled) -AJV Serialize obj x 9,660,041 ops/sec ±0.11% (97 runs sampled) -JSON stringify date x 1,084,008 ops/sec ±0.16% (98 runs sampled) -fast-json-stringify date format x 1,781,044 ops/sec ±0.48% (99 runs sampled) -compile-json-stringify date format x 1,086,187 ops/sec ±0.16% (99 runs sampled) +FJS creation x 9,696 ops/sec ±0.77% (94 runs sampled) +CJS creation x 197,267 ops/sec ±0.22% (95 runs sampled) +AJV Serialize creation x 48,302,927 ops/sec ±2.09% (90 runs sampled) +json-accelerator creation x 668,430 ops/sec ±0.43% (95 runs sampled) +JSON.stringify array x 7,924 ops/sec ±0.11% (98 runs sampled) +fast-json-stringify array default x 7,183 ops/sec ±0.09% (97 runs sampled) +json-accelerator array x 5,762 ops/sec ±0.27% (99 runs sampled) +fast-json-stringify array json-stringify x 7,171 ops/sec ±0.17% (97 runs sampled) +compile-json-stringify array x 6,889 ops/sec ±0.41% (96 runs sampled) +AJV Serialize array x 6,945 ops/sec ±0.17% (98 runs sampled) +JSON.stringify large array x 331 ops/sec ±0.17% (93 runs sampled) +fast-json-stringify large array default x 208 ops/sec ±0.21% (91 runs sampled) +fast-json-stringify large array json-stringify x 330 ops/sec ±0.17% (93 runs sampled) +compile-json-stringify large array x 318 ops/sec ±0.11% (90 runs sampled) +AJV Serialize large array x 114 ops/sec ±0.27% (74 runs sampled) +JSON.stringify long string x 13,452 ops/sec ±0.15% (99 runs sampled) +fast-json-stringify long string x 13,454 ops/sec ±0.10% (99 runs sampled) +json-accelerator long string x 13,439 ops/sec ±0.09% (98 runs sampled) +compile-json-stringify long string x 13,380 ops/sec ±0.12% (100 runs sampled) +AJV Serialize long string x 21,932 ops/sec ±0.06% (99 runs sampled) +JSON.stringify short string x 12,114,052 ops/sec ±0.59% (97 runs sampled) +fast-json-stringify short string x 29,408,175 ops/sec ±1.12% (91 runs sampled) +json-accelerator short string x 29,431,694 ops/sec ±1.05% (93 runs sampled) +compile-json-stringify short string x 24,740,338 ops/sec ±1.02% (91 runs sampled) +AJV Serialize short string x 17,841,869 ops/sec ±0.90% (91 runs sampled) +JSON.stringify obj x 4,577,494 ops/sec ±0.25% (94 runs sampled) +fast-json-stringify obj x 7,291,157 ops/sec ±0.40% (97 runs sampled) +json-accelerator obj x 6,473,194 ops/sec ±0.31% (99 runs sampled) +compile-json-stringify obj x 14,724,935 ops/sec ±0.50% (96 runs sampled) +AJV Serialize obj x 8,782,944 ops/sec ±0.45% (93 runs sampled) +JSON stringify date x 803,522 ops/sec ±0.47% (98 runs sampled) +fast-json-stringify date format x 1,117,776 ops/sec ±0.69% (95 runs sampled) +json-accelerate date format x 1,122,419 ops/sec ±0.20% (97 runs sampled) +compile-json-stringify date format x 803,214 ops/sec ±0.23% (97 runs sampled) ``` #### Table of contents: @@ -56,13 +65,16 @@ compile-json-stringify date format x 1,086,187 ops/sec ±0.16% (99 runs sampled) - `Missing fields` - `Pattern Properties` - `Additional Properties` - - `AnyOf` and `OneOf` + - `AnyOf` and `OneOf` - `Reuse - $ref` - `Long integers` - `Integers` - `Nullable` + - `Large Arrays` - `Security Notice` -- `Acknowledgements` +- `Debug Mode` +- `Standalone Mode` +- `Acknowledgments` - `License` @@ -103,7 +115,7 @@ console.log(stringify({ ## Options -Optionally, you may provide to `fast-json-stringify` an option object as second parameter: +Optionally, you may provide to `fast-json-stringify` an option object as the second parameter: ```js const fastJson = require('fast-json-stringify') @@ -117,6 +129,8 @@ const stringify = fastJson(mySchema, { - `schema`: external schemas references by $ref property. [More details](#ref) - `ajv`: [ajv v8 instance's settings](https://ajv.js.org/options.html) for those properties that require `ajv`. [More details](#anyof) - `rounding`: setup how the `integer` types will be rounded when not integers. [More details](#integer) +- `largeArrayMechanism`: set the mechanism that should be used to handle large +(by default `20000` or more items) arrays. [More details](#largearrays) @@ -155,20 +169,19 @@ And nested ones, too. | `date` | `2020-04-03` | | `time` | `09:11:08` | -**Note**: In the case of string formatted Date and not Date Object, there will be no manipulation on it. It should be properly formatted. +**Note**: In the case of a string formatted Date and not Date Object, there will be no manipulation on it. It should be properly formatted. -Example with a MomentJS object: +Example with a Date object: ```javascript -const moment = require('moment') - const stringify = fastJson({ title: 'Example Schema with string date-time field', type: 'string', format: 'date-time' }) -console.log(stringify(moment())) // '"YYYY-MM-DDTHH:mm:ss.sssZ"' +const date = new Date() +console.log(stringify(date)) // '"YYYY-MM-DDTHH:mm:ss.sssZ"' ``` @@ -268,7 +281,7 @@ const stringify = fastJson({ const obj = { nickname: 'nick', matchfoo: 42, - otherfoo: 'str' + otherfoo: 'str', matchnum: 3 } @@ -281,10 +294,10 @@ console.log(stringify(obj)) // '{"matchfoo":"42","otherfoo":"str","matchnum":3," *additionalProperties* must be an object or a boolean, declared in this way: `{ type: 'type' }`. *additionalProperties* will work only for the properties that are not explicitly listed in the *properties* and *patternProperties* objects. -If *additionalProperties* is not present or is set to `false`, every property that is not explicitly listed in the *properties* and *patternProperties* objects,will be ignored, as described in Missing fields. +If *additionalProperties* is not present or is set to `false`, every property that is not explicitly listed in the *properties* and *patternProperties* objects will be ignored, as described in Missing fields. Missing fields are ignored to avoid having to rewrite objects before serializing. However, other schema rules would throw in similar situations. If *additionalProperties* is set to `true`, it will be used by `JSON.stringify` to stringify the additional properties. If you want to achieve maximum performance, we strongly encourage you to use a fixed schema where possible. -The additional properties will always be serialzied at the end of the object. +The additional properties will always be serialized at the end of the object. Example: ```javascript const stringify = fastJson({ @@ -311,7 +324,7 @@ const stringify = fastJson({ const obj = { nickname: 'nick', matchfoo: 42, - otherfoo: 'str' + otherfoo: 'str', matchnum: 3, nomatchstr: 'valar morghulis', nomatchint: 313 @@ -534,12 +547,12 @@ const stringify = fastJson(schema, { schema: externalSchema }) #### Long integers -By default the library will handle automatically [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt). +By default, the library will handle automatically [BigInt](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt). #### Integers The `type: integer` property will be truncated if a floating point is provided. -You can customize this behaviour with the `rounding` option that will accept [`round`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round), [`ceil`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/ceil) or [`floor`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/floor): +You can customize this behavior with the `rounding` option that will accept [`round`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/round), [`ceil`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/ceil), [`floor`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/floor), or [`trunc`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc). Default is `trunc`: ```js const stringify = fastJson(schema, { rounding: 'ceil' }) @@ -581,6 +594,79 @@ Otherwise, instead of raising an error, null values will be coerced as follows: - `number` -> `0` - `string` -> `""` - `boolean` -> `false` +- `object` -> `{}` +- `array` -> `[]` + + +#### Large Arrays + +Large arrays are, for the scope of this document, defined as arrays containing, +by default, `20000` elements or more. That value can be adjusted via the option +parameter `largeArraySize`. + +At some point the overhead caused by the default mechanism used by +`fast-json-stringify` to handle arrays starts increasing exponentially, leading +to slow overall executions. + +##### Settings + +In order to improve that the user can set the `largeArrayMechanism` and +`largeArraySize` options. + +`largeArrayMechanism`'s default value is `default`. Valid values for it are: + +- `default` - This option is a compromise between performance and feature set by +still providing the expected functionality out of this lib but giving up some +possible performance gain. With this option set, **large arrays** would be +stringified by joining their stringified elements using `Array.join` instead of +string concatenation for better performance +- `json-stringify` - This option will remove support for schema validation +within **large arrays** completely. By doing so the overhead previously +mentioned is nulled, greatly improving execution time. Mind there's no change +in behavior for arrays not considered _large_ + +`largeArraySize`'s default value is `20000`. Valid values for it are +integer-like values, such as: + +- `20000` +- `2e4` +- `'20000'` +- `'2e4'` - _note this will be converted to `2`, not `20000`_ +- `1.5` - _note this will be converted to `1`_ + + +#### Unsafe string +By default, the library escapes all strings. With the 'unsafe' format, the string isn't escaped. This has a potentially dangerous security issue. You can use it only if you are sure that your data doesn't need escaping. The advantage is a significant performance improvement. + +Example: +```javascript +const stringify = fastJson({ + title: 'Example Schema', + type: 'object', + properties: { + 'code': { + type: 'string', + format 'unsafe' + } + } +}) +``` + +##### Benchmarks + +For reference, here are some benchmarks for comparison over the three +mechanisms. Benchmarks were conducted on an old machine. + +- Machine: `ST1000LM024 HN-M 1TB HDD, Intel Core i7-3610QM @ 2.3GHz, 12GB RAM, 4C/8T`. +- Node.js `v16.13.1` + +``` +JSON.stringify large array x 157 ops/sec ±0.73% (86 runs sampled) +fast-json-stringify large array default x 48.72 ops/sec ±4.92% (48 runs sampled) +fast-json-stringify large array json-stringify x 157 ops/sec ±0.76% (86 runs sampled) +compile-json-stringify large array x 175 ops/sec ±4.47% (79 runs sampled) +AJV Serialize large array x 58.76 ops/sec ±4.59% (60 runs sampled) +``` ## Security notice @@ -588,12 +674,16 @@ Otherwise, instead of raising an error, null values will be coerced as follows: Treat the schema definition as application code, it is not safe to use user-provided schemas. -In order to achieve lowest cost/highest performance redaction `fast-json-stringify` +To achieve low cost and high performance redaction `fast-json-stringify` creates and compiles a function (using the `Function` constructor) on initialization. While the `schema` is currently validated for any developer errors, there is no guarantee that supplying user-generated schema could not expose your application to remote attacks. +Users are responsible for sending trusted data. `fast-json-stringify` guarantees that you will get +a valid output only if your input matches the schema or can be coerced to the schema. If your input +doesn't match the schema, you will get undefined behavior. + ### Debug Mode @@ -609,22 +699,45 @@ const debugCompiled = fastJson({ type: 'string' } } -}, { debugMode: true }) +}, { mode: 'debug' }) + +console.log(debugCompiled) // it is a object contain code, ajv instance +const rawString = debugCompiled.code // it is the generated code +console.log(rawString) + +const stringify = fastJson.restore(debugCompiled) // use the generated string to get back the `stringify` function +console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' +``` + + +### Standalone Mode + +The standalone mode is used to compile the code that can be directly run by `node` +itself. You need to have `fast-json-stringify` installed for the standalone code to work. -console.log(debugCompiled) // it is an array of functions that can create your `stringify` function -console.log(debugCompiled.toString()) // print a "ready to read" string function, you can save it to a file +```js +const fs = require('fs') +const code = fastJson({ + title: 'default string', + type: 'object', + properties: { + firstName: { + type: 'string' + } + } +}, { mode: 'standalone' }) -const rawString = debugCompiled.toString() -const stringify = fastJson.restore(rawString) // use the generated string to get back the `stringify` function +fs.writeFileSync('stringify.js', code) +const stringify = require('stringify.js') console.log(stringify({ firstName: 'Foo', surname: 'bar' })) // '{"firstName":"Foo"}' ``` - -## Acknowledgements + +## Acknowledgments This project was kindly sponsored by [nearForm](https://nearform.com). ## License -MIT +Licensed under [MIT](./LICENSE). diff --git a/benchmark/bench-cmp-branch.js b/benchmark/bench-cmp-branch.js new file mode 100644 index 00000000..0834ad15 --- /dev/null +++ b/benchmark/bench-cmp-branch.js @@ -0,0 +1,116 @@ +'use strict' + +const { spawn } = require('child_process') + +const cliSelect = require('cli-select') +const simpleGit = require('simple-git') + +const git = simpleGit(process.cwd()) + +const COMMAND = 'npm run bench' +const DEFAULT_BRANCH = 'main' +const PERCENT_THRESHOLD = 5 +const greyColor = '\x1b[30m' +const redColor = '\x1b[31m' +const greenColor = '\x1b[32m' +const resetColor = '\x1b[0m' + +async function selectBranchName (message, branches) { + console.log(message) + const result = await cliSelect({ + type: 'list', + name: 'branch', + values: branches + }) + console.log(result.value) + return result.value +} + +async function executeCommandOnBranch (command, branch) { + console.log(`${greyColor}Checking out "${branch}"${resetColor}`) + await git.checkout(branch) + + console.log(`${greyColor}Execute "${command}"${resetColor}`) + const childProcess = spawn(command, { stdio: 'pipe', shell: true }) + + let result = '' + childProcess.stdout.on('data', (data) => { + process.stdout.write(data.toString()) + result += data.toString() + }) + + await new Promise(resolve => childProcess.on('close', resolve)) + + console.log() + + return parseBenchmarksStdout(result) +} + +function parseBenchmarksStdout (text) { + const results = [] + + const lines = text.split('\n') + for (const line of lines) { + const match = /^(.+?)(\.*) x (.+) ops\/sec .*$/.exec(line) + if (match !== null) { + results.push({ + name: match[1], + alignedName: match[1] + match[2], + result: parseInt(match[3].split(',').join('')) + }) + } + } + + return results +} + +function compareResults (featureBranch, mainBranch) { + for (const { name, alignedName, result: mainBranchResult } of mainBranch) { + const featureBranchBenchmark = featureBranch.find(result => result.name === name) + if (featureBranchBenchmark) { + const featureBranchResult = featureBranchBenchmark.result + const percent = (featureBranchResult - mainBranchResult) * 100 / mainBranchResult + const roundedPercent = Math.round(percent * 100) / 100 + + const percentString = roundedPercent > 0 ? `+${roundedPercent}%` : `${roundedPercent}%` + const message = alignedName + percentString.padStart(7, '.') + + if (roundedPercent > PERCENT_THRESHOLD) { + console.log(`${greenColor}${message}${resetColor}`) + } else if (roundedPercent < -PERCENT_THRESHOLD) { + console.log(`${redColor}${message}${resetColor}`) + } else { + console.log(message) + } + } + } +} + +(async function () { + const branches = await git.branch() + const currentBranch = branches.branches[branches.current] + + let featureBranch = null + let mainBranch = null + + if (process.argv[2] === '--ci') { + featureBranch = currentBranch.name + mainBranch = DEFAULT_BRANCH + } else { + featureBranch = await selectBranchName('Select the branch you want to compare (feature branch):', branches.all) + mainBranch = await selectBranchName('Select the branch you want to compare with (main branch):', branches.all) + } + + try { + const featureBranchResult = await executeCommandOnBranch(COMMAND, featureBranch) + const mainBranchResult = await executeCommandOnBranch(COMMAND, mainBranch) + compareResults(featureBranchResult, mainBranchResult) + } catch (error) { + console.error('Switch to origin branch due to an error', error.message) + } + + await git.checkout(currentBranch.commit) + await git.checkout(currentBranch.name) + + console.log(`${greyColor}Back to ${currentBranch.name} ${currentBranch.commit}${resetColor}`) +})() diff --git a/bench.js b/benchmark/bench-cmp-lib.js similarity index 59% rename from bench.js rename to benchmark/bench-cmp-lib.js index 2b353297..ef5e6265 100644 --- a/bench.js +++ b/benchmark/bench-cmp-lib.js @@ -3,6 +3,10 @@ const benchmark = require('benchmark') const suite = new benchmark.Suite() +const STR_LEN = 1e4 +const LARGE_ARRAY_SIZE = 2e4 +const MULTI_ARRAY_LENGTH = 1e3 + const schema = { title: 'Example Schema', type: 'object', @@ -89,7 +93,8 @@ const obj = { const date = new Date() -const multiArray = [] +const multiArray = new Array(MULTI_ARRAY_LENGTH) +const largeArray = new Array(LARGE_ARRAY_SIZE) const CJS = require('compile-json-stringify') const CJSStringify = CJS(schemaCJS) @@ -97,9 +102,12 @@ const CJSStringifyArray = CJS(arraySchemaCJS) const CJSStringifyDate = CJS(dateFormatSchemaCJS) const CJSStringifyString = CJS({ type: 'string' }) -const FJS = require('.') +const FJS = require('..') const stringify = FJS(schema) -const stringifyArray = FJS(arraySchema) +const stringifyArrayDefault = FJS(arraySchema) +const stringifyArrayJSONStringify = FJS(arraySchema, { + largeArrayMechanism: 'json-stringify' +}) const stringifyDate = FJS(dateFormatSchema) const stringifyString = FJS({ type: 'string' }) let str = '' @@ -110,18 +118,53 @@ const ajvSerialize = ajv.compileSerializer(schemaAJVJTD) const ajvSerializeArray = ajv.compileSerializer(arraySchemaAJVJTD) const ajvSerializeString = ajv.compileSerializer({ type: 'string' }) -// eslint-disable-next-line -for (var i = 0; i < 10000; i++) { +const { createAccelerator } = require('json-accelerator') +const accelStringify = createAccelerator(schema) +const accelArray = createAccelerator(arraySchema) +const accelDate = FJS(dateFormatSchema) +const accelString = FJS({ type: 'string' }) + +const getRandomString = (length) => { + if (!Number.isInteger(length)) { + throw new Error('Expected integer length') + } + + const validCharacters = 'abcdefghijklmnopqrstuvwxyz' + const nValidCharacters = 26 + + let result = '' + for (let i = 0; i < length; ++i) { + result += validCharacters[Math.floor(Math.random() * nValidCharacters)] + } + + return result[0].toUpperCase() + result.slice(1) +} + +for (let i = 0; i < STR_LEN; i++) { + largeArray[i] = { + firstName: getRandomString(8), + lastName: getRandomString(6), + age: Math.ceil(Math.random() * 99) + } + str += i if (i % 100 === 0) { str += '"' } } +for (let i = STR_LEN; i < LARGE_ARRAY_SIZE; ++i) { + largeArray[i] = { + firstName: getRandomString(10), + lastName: getRandomString(4), + age: Math.ceil(Math.random() * 99) + } +} + Number(str) -for (i = 0; i < 1000; i++) { - multiArray.push(obj) +for (let i = 0; i < MULTI_ARRAY_LENGTH; i++) { + multiArray[i] = obj } suite.add('FJS creation', function () { @@ -133,13 +176,24 @@ suite.add('CJS creation', function () { suite.add('AJV Serialize creation', function () { ajv.compileSerializer(schemaAJVJTD) }) +suite.add('json-accelerator creation', function () { + createAccelerator(schema) +}) suite.add('JSON.stringify array', function () { JSON.stringify(multiArray) }) -suite.add('fast-json-stringify array', function () { - stringifyArray(multiArray) +suite.add('fast-json-stringify array default', function () { + stringifyArrayDefault(multiArray) +}) + +suite.add('json-accelerator array', function () { + accelArray(multiArray) +}) + +suite.add('fast-json-stringify array json-stringify', function () { + stringifyArrayJSONStringify(multiArray) }) suite.add('compile-json-stringify array', function () { @@ -150,6 +204,26 @@ suite.add('AJV Serialize array', function () { ajvSerializeArray(multiArray) }) +suite.add('JSON.stringify large array', function () { + JSON.stringify(largeArray) +}) + +suite.add('fast-json-stringify large array default', function () { + stringifyArrayDefault(largeArray) +}) + +suite.add('fast-json-stringify large array json-stringify', function () { + stringifyArrayJSONStringify(largeArray) +}) + +suite.add('compile-json-stringify large array', function () { + CJSStringifyArray(largeArray) +}) + +suite.add('AJV Serialize large array', function () { + ajvSerializeArray(largeArray) +}) + suite.add('JSON.stringify long string', function () { JSON.stringify(str) }) @@ -158,6 +232,10 @@ suite.add('fast-json-stringify long string', function () { stringifyString(str) }) +suite.add('json-accelerator long string', function () { + stringifyString(str) +}) + suite.add('compile-json-stringify long string', function () { CJSStringifyString(str) }) @@ -174,6 +252,10 @@ suite.add('fast-json-stringify short string', function () { stringifyString('hello world') }) +suite.add('json-accelerator short string', function () { + accelString('hello world') +}) + suite.add('compile-json-stringify short string', function () { CJSStringifyString('hello world') }) @@ -190,6 +272,10 @@ suite.add('fast-json-stringify obj', function () { stringify(obj) }) +suite.add('json-accelerator obj', function () { + accelStringify(obj) +}) + suite.add('compile-json-stringify obj', function () { CJSStringify(obj) }) @@ -206,6 +292,10 @@ suite.add('fast-json-stringify date format', function () { stringifyDate(date) }) +suite.add('json-accelerate date format', function () { + accelDate(date) +}) + suite.add('compile-json-stringify date format', function () { CJSStringifyDate(date) }) diff --git a/benchmark/bench-thread.js b/benchmark/bench-thread.js new file mode 100644 index 00000000..7e3ba73e --- /dev/null +++ b/benchmark/bench-thread.js @@ -0,0 +1,21 @@ +'use strict' + +const { workerData: benchmark, parentPort } = require('worker_threads') + +const Benchmark = require('benchmark') +Benchmark.options.minSamples = 100 + +const suite = Benchmark.Suite() + +const FJS = require('..') +const stringify = FJS(benchmark.schema) + +suite + .add(benchmark.name, () => { + stringify(benchmark.input) + }) + .on('cycle', (event) => { + parentPort.postMessage(String(event.target)) + }) + .on('complete', () => {}) + .run() diff --git a/benchmark/bench.js b/benchmark/bench.js new file mode 100644 index 00000000..acb87b94 --- /dev/null +++ b/benchmark/bench.js @@ -0,0 +1,391 @@ +'use strict' + +const path = require('path') +const { Worker } = require('worker_threads') + +const BENCH_THREAD_PATH = path.join(__dirname, 'bench-thread.js') + +const LONG_STRING_LENGTH = 1e4 +const SHORT_ARRAY_SIZE = 1e3 + +const shortArrayOfNumbers = new Array(SHORT_ARRAY_SIZE) +const shortArrayOfIntegers = new Array(SHORT_ARRAY_SIZE) +const shortArrayOfShortStrings = new Array(SHORT_ARRAY_SIZE) +const shortArrayOfLongStrings = new Array(SHORT_ARRAY_SIZE) +const shortArrayOfMultiObject = new Array(SHORT_ARRAY_SIZE) + +function getRandomInt (max) { + return Math.floor(Math.random() * max) +} + +let longSimpleString = '' +for (let i = 0; i < LONG_STRING_LENGTH; i++) { + longSimpleString += i +} + +let longString = '' +for (let i = 0; i < LONG_STRING_LENGTH; i++) { + longString += i + if (i % 100 === 0) { + longString += '"' + } +} + +for (let i = 0; i < SHORT_ARRAY_SIZE; i++) { + shortArrayOfNumbers[i] = getRandomInt(1000) + shortArrayOfIntegers[i] = getRandomInt(1000) + shortArrayOfShortStrings[i] = 'hello world' + shortArrayOfLongStrings[i] = longString + shortArrayOfMultiObject[i] = { s: 'hello world', n: 42, b: true } +} + +const benchmarks = [ + { + name: 'short string', + schema: { + type: 'string' + }, + input: 'hello world' + }, + { + name: 'unsafe short string', + schema: { + type: 'string', + format: 'unsafe' + }, + input: 'hello world' + }, + { + name: 'short string with double quote', + schema: { + type: 'string' + }, + input: 'hello " world' + }, + { + name: 'long string without double quotes', + schema: { + type: 'string' + }, + input: longSimpleString + }, + { + name: 'unsafe long string without double quotes', + schema: { + type: 'string', + format: 'unsafe' + }, + input: longSimpleString + }, + { + name: 'long string', + schema: { + type: 'string' + }, + input: longString + }, + { + name: 'unsafe long string', + schema: { + type: 'string', + format: 'unsafe' + }, + input: longString + }, + { + name: 'number', + schema: { + type: 'number' + }, + input: 42 + }, + { + name: 'integer', + schema: { + type: 'integer' + }, + input: 42 + }, + { + name: 'formatted date-time', + schema: { + type: 'string', + format: 'date-time' + }, + input: new Date() + }, + { + name: 'formatted date', + schema: { + type: 'string', + format: 'date' + }, + input: new Date() + }, + { + name: 'formatted time', + schema: { + type: 'string', + format: 'time' + }, + input: new Date() + }, + { + name: 'short array of numbers', + schema: { + type: 'array', + items: { type: 'number' } + }, + input: shortArrayOfNumbers + }, + { + name: 'short array of integers', + schema: { + type: 'array', + items: { type: 'integer' } + }, + input: shortArrayOfIntegers + }, + { + name: 'short array of short strings', + schema: { + type: 'array', + items: { type: 'string' } + }, + input: shortArrayOfShortStrings + }, + { + name: 'short array of long strings', + schema: { + type: 'array', + items: { type: 'string' } + }, + input: shortArrayOfShortStrings + }, + { + name: 'short array of objects with properties of different types', + schema: { + type: 'array', + items: { + type: 'object', + properties: { + s: { type: 'string' }, + n: { type: 'number' }, + b: { type: 'boolean' } + } + } + }, + input: shortArrayOfMultiObject + }, + { + name: 'object with number property', + schema: { + type: 'object', + properties: { + a: { type: 'number' } + } + }, + input: { a: 42 } + }, + { + name: 'object with integer property', + schema: { + type: 'object', + properties: { + a: { type: 'integer' } + } + }, + input: { a: 42 } + }, + { + name: 'object with short string property', + schema: { + type: 'object', + properties: { + a: { type: 'string' } + } + }, + input: { a: 'hello world' } + }, + { + name: 'object with long string property', + schema: { + type: 'object', + properties: { + a: { type: 'string' } + } + }, + input: { a: longString } + }, + { + name: 'object with properties of different types', + schema: { + type: 'object', + properties: { + s1: { type: 'string' }, + n1: { type: 'number' }, + b1: { type: 'boolean' }, + s2: { type: 'string' }, + n2: { type: 'number' }, + b2: { type: 'boolean' }, + s3: { type: 'string' }, + n3: { type: 'number' }, + b3: { type: 'boolean' }, + s4: { type: 'string' }, + n4: { type: 'number' }, + b4: { type: 'boolean' }, + s5: { type: 'string' }, + n5: { type: 'number' }, + b5: { type: 'boolean' } + } + }, + input: { + s1: 'hello world', + n1: 42, + b1: true, + s2: 'hello world', + n2: 42, + b2: true, + s3: 'hello world', + n3: 42, + b3: true, + s4: 'hello world', + n4: 42, + b4: true, + s5: 'hello world', + n5: 42, + b5: true + } + }, + { + name: 'simple object', + schema: { + title: 'Example Schema', + type: 'object', + properties: { + firstName: { + type: 'string' + }, + lastName: { + type: ['string', 'null'] + }, + age: { + description: 'Age in years', + type: 'integer', + minimum: 0 + } + } + }, + input: { firstName: 'Max', lastName: 'Power', age: 22 } + }, + { + name: 'simple object with required fields', + schema: { + title: 'Example Schema', + type: 'object', + properties: { + firstName: { + type: 'string' + }, + lastName: { + type: ['string', 'null'] + }, + age: { + description: 'Age in years', + type: 'integer', + minimum: 0 + } + }, + required: ['firstName', 'lastName', 'age'] + }, + input: { firstName: 'Max', lastName: 'Power', age: 22 } + }, + { + name: 'object with const string property', + schema: { + type: 'object', + properties: { + a: { const: 'const string' } + } + }, + input: { a: 'const string' } + }, + { + name: 'object with const number property', + schema: { + type: 'object', + properties: { + a: { const: 1 } + } + }, + input: { a: 1 } + }, + { + name: 'object with const bool property', + schema: { + type: 'object', + properties: { + a: { const: true } + } + }, + input: { a: true } + }, + { + name: 'object with const object property', + schema: { + type: 'object', + properties: { + foo: { const: { bar: 'baz' } } + } + }, + input: { + foo: { bar: 'baz' } + } + }, + { + name: 'object with const null property', + schema: { + type: 'object', + properties: { + foo: { const: null } + } + }, + input: { + foo: null + } + } +] + +async function runBenchmark (benchmark) { + const worker = new Worker(BENCH_THREAD_PATH, { workerData: benchmark }) + + return new Promise((resolve, reject) => { + let result = null + worker.on('error', reject) + worker.on('message', (benchResult) => { + result = benchResult + }) + worker.on('exit', (code) => { + if (code === 0) { + resolve(result) + } else { + reject(new Error(`Worker stopped with exit code ${code}`)) + } + }) + }) +} + +async function runBenchmarks () { + let maxNameLength = 0 + for (const benchmark of benchmarks) { + maxNameLength = Math.max(benchmark.name.length, maxNameLength) + } + + for (const benchmark of benchmarks) { + benchmark.name = benchmark.name.padEnd(maxNameLength, '.') + const resultMessage = await runBenchmark(benchmark) + console.log(resultMessage) + } +} + +runBenchmarks() diff --git a/build-schema-validator.js b/build/build-schema-validator.js similarity index 88% rename from build-schema-validator.js rename to build/build-schema-validator.js index abf3e799..0c188cc4 100644 --- a/build-schema-validator.js +++ b/build/build-schema-validator.js @@ -23,4 +23,4 @@ const validationCode = standaloneCode(ajv, validate) const moduleCode = `/* CODE GENERATED BY '${path.basename(__filename)}' DO NOT EDIT! */\n${validationCode}` -fs.writeFileSync(path.join(__dirname, 'schema-validator.js'), moduleCode) +fs.writeFileSync(path.join(__dirname, '../lib/schema-validator.js'), moduleCode) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..252fdf4b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,9 @@ +'use strict' + +module.exports = require('neostandard')({ + ignores: [ + ...require('neostandard').resolveIgnoresFromGitignore(), + 'lib/schema-validator.js' + ], + ts: true +}) diff --git a/example.js b/examples/example.js similarity index 91% rename from example.js rename to examples/example.js index 791d9025..4f372dc6 100644 --- a/example.js +++ b/examples/example.js @@ -1,5 +1,6 @@ -const moment = require('moment') -const fastJson = require('fast-json-stringify') +'use strict' + +const fastJson = require('..') const stringify = fastJson({ title: 'Example Schema', type: 'object', @@ -67,12 +68,10 @@ console.log(stringify({ lastName: 'Collina', age: 32, now: new Date(), - birthdate: moment(), reg: /"([^"]|\\")*"/, foo: 'hello', numfoo: 42, test: 42, - date: moment(), strtest: '23', arr: [{ str: 'stark' }, { str: 'lannister' }], obj: { bool: true }, diff --git a/server.js b/examples/server.js similarity index 91% rename from server.js rename to examples/server.js index 9d61677b..706653b8 100644 --- a/server.js +++ b/examples/server.js @@ -2,7 +2,7 @@ const http = require('http') -const stringify = require('.')({ +const stringify = require('fast-json-stringify')({ type: 'object', properties: { hello: { diff --git a/index.js b/index.js index a7b4736f..f8701c90 100644 --- a/index.js +++ b/index.js @@ -2,24 +2,48 @@ /* eslint no-prototype-builtins: 0 */ -const Ajv = require('ajv') -const ajvFormats = require('ajv-formats') -const merge = require('deepmerge') -const clone = require('rfdc')({ proto: true }) -const fjsCloned = Symbol('fast-json-stringify.cloned') -const { randomUUID } = require('crypto') - -const validate = require('./schema-validator') -let stringSimilarity = null - -const addComma = ` - if (addComma) { - json += ',' - } else { - addComma = true - } +const { RefResolver } = require('json-schema-ref-resolver') + +const Serializer = require('./lib/serializer') +const Validator = require('./lib/validator') +const Location = require('./lib/location') +const validate = require('./lib/schema-validator') +const mergeSchemas = require('./lib/merge-schemas') + +const SINGLE_TICK = /'/g + +let largeArraySize = 2e4 +let largeArrayMechanism = 'default' + +const serializerFns = ` +const { + asString, + asNumber, + asBoolean, + asDateTime, + asDate, + asTime, + asUnsafeString +} = serializer + +const asInteger = serializer.asInteger.bind(serializer) + ` +const validRoundingMethods = new Set([ + 'floor', + 'ceil', + 'round', + 'trunc' +]) + +const validLargeArrayMechanisms = new Set([ + 'default', + 'json-stringify' +]) + +let schemaIdCounter = 0 + function isValidSchema (schema, name) { if (!validate(schema)) { if (name) { @@ -34,133 +58,188 @@ function isValidSchema (schema, name) { } } -function mergeLocation (source, dest) { - return { - schema: dest.schema || source.schema, - root: dest.root || source.root, - externalSchema: dest.externalSchema || source.externalSchema +function resolveRef (context, location) { + const ref = location.schema.$ref + + let hashIndex = ref.indexOf('#') + if (hashIndex === -1) { + hashIndex = ref.length + } + + const schemaId = ref.slice(0, hashIndex) || location.schemaId + const jsonPointer = ref.slice(hashIndex) || '#' + + const schema = context.refResolver.getSchema(schemaId, jsonPointer) + if (schema === null) { + throw new Error(`Cannot find reference "${ref}"`) + } + + const newLocation = new Location(schema, schemaId, jsonPointer) + if (schema.$ref !== undefined) { + return resolveRef(context, newLocation) } + + return newLocation +} + +function getMergedLocation (context, mergedSchemaId) { + const mergedSchema = context.refResolver.getSchema(mergedSchemaId, '#') + return new Location(mergedSchema, mergedSchemaId, '#') } -const arrayItemsReferenceSerializersMap = new Map() -const objectReferenceSerializersMap = new Map() -let ajvInstance = null +function getSchemaId (schema, rootSchemaId) { + if (schema.$id && schema.$id.charAt(0) !== '#') { + return schema.$id + } + return rootSchemaId +} function build (schema, options) { - arrayItemsReferenceSerializersMap.clear() - objectReferenceSerializersMap.clear() + isValidSchema(schema) options = options || {} - ajvInstance = new Ajv({ ...options.ajv, strictSchema: false }) - ajvFormats(ajvInstance) + const context = { + functions: [], + functionsCounter: 0, + functionsNamesBySchema: new Map(), + options, + refResolver: new RefResolver(), + rootSchemaId: schema.$id || `__fjs_root_${schemaIdCounter++}`, + validatorSchemasIds: new Set(), + mergedSchemasIds: new Map() + } + + const schemaId = getSchemaId(schema, context.rootSchemaId) + if (!context.refResolver.hasSchema(schemaId)) { + context.refResolver.addSchema(schema, context.rootSchemaId) + } - isValidSchema(schema) if (options.schema) { - // eslint-disable-next-line - for (var key of Object.keys(options.schema)) { - isValidSchema(options.schema[key], key) + for (const key in options.schema) { + const schema = options.schema[key] + const schemaId = getSchemaId(schema, key) + if (!context.refResolver.hasSchema(schemaId)) { + isValidSchema(schema, key) + context.refResolver.addSchema(schema, key) + } } } - let intParseFunctionName = 'trunc' if (options.rounding) { - if (['floor', 'ceil', 'round'].includes(options.rounding)) { - intParseFunctionName = options.rounding - } else { + if (!validRoundingMethods.has(options.rounding)) { throw new Error(`Unsupported integer rounding method ${options.rounding}`) } } - /* eslint no-new-func: "off" */ - let code = ` - 'use strict' - ` - - code += ` - ${asFunctions} - function parseInteger(int) { return Math.${intParseFunctionName}(int) } - ` - - let location = { - schema, - root: schema, - externalSchema: options.schema + if (options.largeArrayMechanism) { + if (validLargeArrayMechanisms.has(options.largeArrayMechanism)) { + largeArrayMechanism = options.largeArrayMechanism + } else { + throw new Error(`Unsupported large array mechanism ${options.largeArrayMechanism}`) + } } - if (schema.$ref) { - location = refFinder(schema.$ref, location) - schema = location.schema - } + if (options.largeArraySize) { + const largeArraySizeType = typeof options.largeArraySize + let parsedNumber - if (schema.type === undefined) { - schema.type = inferTypeByKeyword(schema) + if (largeArraySizeType === 'string' && Number.isFinite((parsedNumber = Number.parseInt(options.largeArraySize, 10)))) { + largeArraySize = parsedNumber + } else if (largeArraySizeType === 'number' && Number.isInteger(options.largeArraySize)) { + largeArraySize = options.largeArraySize + } else if (largeArraySizeType === 'bigint') { + largeArraySize = Number(options.largeArraySize) + } else { + throw new Error(`Unsupported large array size. Expected integer-like, got ${typeof options.largeArraySize} with value ${options.largeArraySize}`) + } } - let main + const location = new Location(schema, context.rootSchemaId) + const code = buildValue(context, location, 'input') + + let contextFunctionCode = ` + ${serializerFns} + const JSON_STR_BEGIN_OBJECT = '{' + const JSON_STR_END_OBJECT = '}' + const JSON_STR_BEGIN_ARRAY = '[' + const JSON_STR_END_ARRAY = ']' + const JSON_STR_COMMA = ',' + const JSON_STR_COLONS = ':' + const JSON_STR_QUOTE = '"' + const JSON_STR_EMPTY_OBJECT = JSON_STR_BEGIN_OBJECT + JSON_STR_END_OBJECT + const JSON_STR_EMPTY_ARRAY = JSON_STR_BEGIN_ARRAY + JSON_STR_END_ARRAY + const JSON_STR_EMPTY_STRING = JSON_STR_QUOTE + JSON_STR_QUOTE + const JSON_STR_NULL = 'null' + ` - switch (schema.type) { - case 'object': - main = '$main' - code = buildObject(location, code, main) - break - case 'string': - main = schema.nullable ? '$asStringNullable' : getStringSerializer(schema.format) - break - case 'integer': - main = schema.nullable ? '$asIntegerNullable' : '$asInteger' - break - case 'number': - main = schema.nullable ? '$asNumberNullable' : '$asNumber' - break - case 'boolean': - main = schema.nullable ? '$asBooleanNullable' : '$asBoolean' - break - case 'null': - main = '$asNull' - break - case 'array': - main = '$main' - code = buildArray(location, code, main) - schema = location.schema - break - case undefined: - main = '$asAny' - break - default: - throw new Error(`${schema.type} unsupported`) + // If we have only the invocation of the 'anonymous0' function, we would + // basically just wrap the 'anonymous0' function in the 'main' function and + // and the overhead of the intermediate variable 'json'. We can avoid the + // wrapping and the unnecessary memory allocation by aliasing 'anonymous0' to + // 'main' + if (code === 'json += anonymous0(input)') { + contextFunctionCode += ` + ${context.functions.join('\n')} + const main = anonymous0 + return main + ` + } else { + contextFunctionCode += ` + function main (input) { + let json = '' + ${code} + return json + } + ${context.functions.join('\n')} + return main + ` } - code += ` - ; - return ${main} - ` - const dependencies = [ajvInstance] - const dependenciesName = ['ajv'] - ajvInstance = null + const serializer = new Serializer(options) + const validator = new Validator(options.ajv) + + for (const schemaId of context.validatorSchemasIds) { + const schema = context.refResolver.getSchema(schemaId) + validator.addSchema(schema, schemaId) - dependenciesName.push(code) + const dependencies = context.refResolver.getSchemaDependencies(schemaId) + for (const [schemaId, schema] of Object.entries(dependencies)) { + validator.addSchema(schema, schemaId) + } + } if (options.debugMode) { + options.mode = 'debug' + } + + if (options.mode === 'debug') { return { - code: dependenciesName.join('\n'), - ajv: dependencies[0] + validator, + serializer, + code: `validator\nserializer\n${contextFunctionCode}`, + ajv: validator.ajv } } - arrayItemsReferenceSerializersMap.clear() - objectReferenceSerializersMap.clear() + /* eslint no-new-func: "off" */ + const contextFunc = new Function('validator', 'serializer', contextFunctionCode) + + if (options.mode === 'standalone') { + const buildStandaloneCode = require('./lib/standalone') + return buildStandaloneCode(contextFunc, context, serializer, validator) + } - return (Function.apply(null, dependenciesName).apply(null, dependencies)) + return contextFunc(validator, serializer) } const objectKeywords = [ - 'maxProperties', - 'minProperties', - 'required', 'properties', - 'patternProperties', + 'required', 'additionalProperties', + 'patternProperties', + 'maxProperties', + 'minProperties', 'dependencies' ] @@ -189,948 +268,736 @@ const numberKeywords = [ /** * Infer type based on keyword in order to generate optimized code - * https://json-schema.org/latest/json-schema-validation.html#rfc.section.6 + * https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-01#section-6 */ function inferTypeByKeyword (schema) { - // eslint-disable-next-line - for (var keyword of objectKeywords) { + for (const keyword of objectKeywords) { if (keyword in schema) return 'object' } - // eslint-disable-next-line - for (var keyword of arrayKeywords) { + for (const keyword of arrayKeywords) { if (keyword in schema) return 'array' } - // eslint-disable-next-line - for (var keyword of stringKeywords) { + for (const keyword of stringKeywords) { if (keyword in schema) return 'string' } - // eslint-disable-next-line - for (var keyword of numberKeywords) { + for (const keyword of numberKeywords) { if (keyword in schema) return 'number' } return schema.type } -const stringSerializerMap = { - 'date-time': '$asDatetime', - date: '$asDate', - time: '$asTime' -} - -function getStringSerializer (format) { - return stringSerializerMap[format] || - '$asString' -} - -function getTestSerializer (format) { - return stringSerializerMap[format] -} +function buildExtraObjectPropertiesSerializer (context, location, addComma) { + const schema = location.schema + const propertiesKeys = Object.keys(schema.properties || {}) -const asFunctions = ` -function $pad2Zeros (num) { - const s = '00' + num - return s[s.length - 2] + s[s.length - 1] -} + let code = ` + const propertiesKeys = ${JSON.stringify(propertiesKeys)} + for (const [key, value] of Object.entries(obj)) { + if ( + propertiesKeys.includes(key) || + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) continue + ` -function $asAny (i) { - return JSON.stringify(i) -} + const patternPropertiesLocation = location.getPropertyLocation('patternProperties') + const patternPropertiesSchema = patternPropertiesLocation.schema -function $asNull () { - return 'null' -} + if (patternPropertiesSchema !== undefined) { + for (const propertyKey in patternPropertiesSchema) { + const propertyLocation = patternPropertiesLocation.getPropertyLocation(propertyKey) -function $asInteger (i) { - if (typeof i === 'bigint') { - return i.toString() - } else if (Number.isInteger(i)) { - return $asNumber(i) - } else { - /* eslint no-undef: "off" */ - return $asNumber(parseInteger(i)) + code += ` + if (/${propertyKey.replace(/\\*\//g, '\\/')}/.test(key)) { + ${addComma} + json += asString(key) + JSON_STR_COLONS + ${buildValue(context, propertyLocation, 'value')} + continue + } + ` + } } -} -function $asIntegerNullable (i) { - return i === null ? null : $asInteger(i) -} + const additionalPropertiesLocation = location.getPropertyLocation('additionalProperties') + const additionalPropertiesSchema = additionalPropertiesLocation.schema -function $asNumber (i) { - const num = Number(i) - if (isNaN(num)) { - return 'null' - } else { - return '' + num + if (additionalPropertiesSchema !== undefined) { + if (additionalPropertiesSchema === true) { + code += ` + ${addComma} + json += asString(key) + JSON_STR_COLONS + JSON.stringify(value) + ` + } else { + const propertyLocation = location.getPropertyLocation('additionalProperties') + code += ` + ${addComma} + json += asString(key) + JSON_STR_COLONS + ${buildValue(context, propertyLocation, 'value')} + ` + } } -} -function $asNumberNullable (i) { - return i === null ? null : $asNumber(i) + code += ` + } + ` + return code } -function $asBoolean (bool) { - return bool && 'true' || 'false' // eslint-disable-line -} +function buildInnerObject (context, location) { + const schema = location.schema -function $asBooleanNullable (bool) { - return bool === null ? null : $asBoolean(bool) -} + const propertiesLocation = location.getPropertyLocation('properties') + const requiredProperties = schema.required || [] -function $asDatetime (date, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (date instanceof Date) { - return quotes + date.toISOString() + quotes - } else if (date && typeof date.toISOString === 'function') { - return quotes + date.toISOString() + quotes - } else { - return $asString(date, skipQuotes) - } -} + // Should serialize required properties first + const propertiesKeys = new Set(Object.keys(schema.properties || {}).sort( + (key1, key2) => { + const required1 = requiredProperties.includes(key1) + const required2 = requiredProperties.includes(key2) + return required1 === required2 ? 0 : required1 ? -1 : 1 + } + )) + const hasRequiredProperties = requiredProperties.includes(propertiesKeys[0]) -function $asDate (date, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (date instanceof Date) { - return quotes + new Date(date.getTime() - (date.getTimezoneOffset() * 60000 )).toISOString().slice(0, 10) + quotes - } else if (date && typeof date.format === 'function') { - return quotes + date.format('YYYY-MM-DD') + quotes - } else { - return $asString(date, skipQuotes) - } -} + let code = 'let value\n' -function $asTime (date, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (date instanceof Date) { - const hour = new Intl.DateTimeFormat('en', { hour: 'numeric', hour12: false }).format(date) - const minute = new Intl.DateTimeFormat('en', { minute: 'numeric' }).format(date) - const second = new Intl.DateTimeFormat('en', { second: 'numeric' }).format(date) - return quotes + $pad2Zeros(hour) + ':' + $pad2Zeros(minute) + ':' + $pad2Zeros(second) + quotes - } else if (date && typeof date.format === 'function') { - return quotes + date.format('HH:mm:ss') + quotes - } else { - return $asString(date, skipQuotes) + for (const key of requiredProperties) { + if (!propertiesKeys.has(key)) { + const sanitizedKey = JSON.stringify(key) + code += `if (obj[${sanitizedKey}] === undefined) throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!')\n` + } } -} -function $asString (str, skipQuotes) { - const quotes = skipQuotes === true ? '' : '"' - if (str instanceof Date) { - return quotes + str.toISOString() + quotes - } else if (str === null) { - return quotes + quotes - } else if (str instanceof RegExp) { - str = str.source - } else if (typeof str !== 'string') { - str = str.toString() - } - // If we skipQuotes it means that we are using it as test - // no need to test the string length for the render - if (skipQuotes) { - return str - } + code += 'let json = JSON_STR_BEGIN_OBJECT\n' - if (str.length < 42) { - return $asStringSmall(str) - } else { - return JSON.stringify(str) + let addComma = '' + if (!hasRequiredProperties) { + code += 'let addComma = false\n' + addComma = '!addComma && (addComma = true) || (json += JSON_STR_COMMA)' } -} - -function $asStringNullable (str) { - return str === null ? null : $asString(str) -} -// magically escape strings for json -// relying on their charCodeAt -// everything below 32 needs JSON.stringify() -// every string that contain surrogate needs JSON.stringify() -// 34 and 92 happens all the time, so we -// have a fast case for them -function $asStringSmall (str) { - const l = str.length - let result = '' - let last = 0 - let found = false - let surrogateFound = false - let point = 255 - // eslint-disable-next-line - for (var i = 0; i < l && point >= 32; i++) { - point = str.charCodeAt(i) - if (point >= 0xD800 && point <= 0xDFFF) { - // The current character is a surrogate. - surrogateFound = true - } - if (point === 34 || point === 92) { - result += str.slice(last, i) + '\\\\' - last = i - found = true + for (const key of propertiesKeys) { + let propertyLocation = propertiesLocation.getPropertyLocation(key) + if (propertyLocation.schema.$ref) { + propertyLocation = resolveRef(context, propertyLocation) } - } - - if (!found) { - result = str - } else { - result += str.slice(last) - } - return ((point < 32) || (surrogateFound === true)) ? JSON.stringify(str) : '"' + result + '"' -} -` -function addPatternProperties (location) { - const schema = location.schema - const pp = schema.patternProperties - let code = ` - var properties = ${JSON.stringify(schema.properties)} || {} - var keys = Object.keys(obj) - for (var i = 0; i < keys.length; i++) { - if (properties[keys[i]]) continue - ` - Object.keys(pp).forEach((regex, index) => { - let ppLocation = mergeLocation(location, { schema: pp[regex] }) - if (pp[regex].$ref) { - ppLocation = refFinder(pp[regex].$ref, location) - pp[regex] = ppLocation.schema - } - const type = pp[regex].type - const format = pp[regex].format - const stringSerializer = getStringSerializer(format) - try { - RegExp(regex) - } catch (err) { - throw new Error(`${err.message}. Found at ${regex} matching ${JSON.stringify(pp[regex])}`) - } + const sanitizedKey = JSON.stringify(key) + const defaultValue = propertyLocation.schema.default + const isRequired = requiredProperties.includes(key) - const ifPpKeyExists = `if (/${regex.replace(/\\*\//g, '\\/')}/.test(keys[i])) {` + code += ` + value = obj[${sanitizedKey}] + if (value !== undefined) { + ${addComma} + json += ${JSON.stringify(sanitizedKey + ':')} + ${buildValue(context, propertyLocation, 'value')} + }` - if (type === 'object') { - code += `${buildObject(ppLocation, '', 'buildObjectPP' + index)} - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + buildObjectPP${index}(obj[keys[i]]) - ` - } else if (type === 'array') { - code += `${buildArray(ppLocation, '', 'buildArrayPP' + index)} - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + buildArrayPP${index}(obj[keys[i]]) - ` - } else if (type === 'null') { - code += ` - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) +':null' - ` - } else if (type === 'string') { - code += ` - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + ${stringSerializer}(obj[keys[i]]) - ` - } else if (type === 'integer') { - code += ` - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + $asInteger(obj[keys[i]]) - ` - } else if (type === 'number') { - code += ` - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + $asNumber(obj[keys[i]]) - ` - } else if (type === 'boolean') { - code += ` - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + $asBoolean(obj[keys[i]]) + if (defaultValue !== undefined) { + code += ` else { + ${addComma} + json += ${JSON.stringify(sanitizedKey + ':' + JSON.stringify(defaultValue))} + } ` - } else if (type === undefined) { - code += ` - ${ifPpKeyExists} - ${addComma} - json += $asString(keys[i]) + ':' + $asAny(obj[keys[i]]) + } else if (isRequired) { + code += ` else { + throw new Error('${sanitizedKey.replace(/'/g, '\\\'')} is required!') + } ` } else { - code += ` - ${ifPpKeyExists} - throw new Error('Cannot coerce ' + obj[keys[i]] + ' to ' + ${JSON.stringify(type)}) - ` + code += '\n' } - code += ` - continue - } - ` - }) - if (schema.additionalProperties) { - code += additionalProperty(location) + if (hasRequiredProperties) { + addComma = 'json += \',\'' + } + } + + if (schema.patternProperties || schema.additionalProperties) { + code += buildExtraObjectPropertiesSerializer(context, location, addComma) } code += ` - } + return json + JSON_STR_END_OBJECT ` return code } -function additionalProperty (location) { - let ap = location.schema.additionalProperties - let code = '' - if (ap === true) { - return ` - if (obj[keys[i]] !== undefined && typeof obj[keys[i]] !== 'function' && typeof obj[keys[i]] !== 'symbol') { - ${addComma} - json += $asString(keys[i]) + ':' + JSON.stringify(obj[keys[i]]) - } - ` - } - let apLocation = mergeLocation(location, { schema: ap }) - if (ap.$ref) { - apLocation = refFinder(ap.$ref, location) - ap = apLocation.schema +function mergeLocations (context, mergedSchemaId, mergedLocations) { + for (let i = 0, mergedLocationsLength = mergedLocations.length; i < mergedLocationsLength; i++) { + const location = mergedLocations[i] + const schema = location.schema + if (schema.$ref) { + mergedLocations[i] = resolveRef(context, location) + } } - const type = ap.type - const format = ap.format - const stringSerializer = getStringSerializer(format) - if (type === 'object') { - code += `${buildObject(apLocation, '', 'buildObjectAP')} - ${addComma} - json += $asString(keys[i]) + ':' + buildObjectAP(obj[keys[i]]) - ` - } else if (type === 'array') { - code += `${buildArray(apLocation, '', 'buildArrayAP')} - ${addComma} - json += $asString(keys[i]) + ':' + buildArrayAP(obj[keys[i]]) - ` - } else if (type === 'null') { - code += ` - ${addComma} - json += $asString(keys[i]) +':null' - ` - } else if (type === 'string') { - code += ` - ${addComma} - json += $asString(keys[i]) + ':' + ${stringSerializer}(obj[keys[i]]) - ` - } else if (type === 'integer') { - code += ` - var t = Number(obj[keys[i]]) - if (!isNaN(t)) { - ${addComma} - json += $asString(keys[i]) + ':' + t - } - ` - } else if (type === 'number') { - code += ` - var t = Number(obj[keys[i]]) - if (!isNaN(t)) { - ${addComma} - json += $asString(keys[i]) + ':' + t - } - ` - } else if (type === 'boolean') { - code += ` - ${addComma} - json += $asString(keys[i]) + ':' + $asBoolean(obj[keys[i]]) - ` - } else if (type === undefined) { - code += ` - ${addComma} - json += $asString(keys[i]) + ':' + $asAny(obj[keys[i]]) - ` - } else { - code += ` - throw new Error('Cannot coerce ' + obj[keys[i]] + ' to ' + ${JSON.stringify(type)}) - ` + const mergedSchemas = [] + for (const location of mergedLocations) { + const schema = cloneOriginSchema(context, location.schema, location.schemaId) + delete schema.$id + + mergedSchemas.push(schema) } - return code -} -function addAdditionalProperties (location) { - return ` - var properties = ${JSON.stringify(location.schema.properties)} || {} - var keys = Object.keys(obj) - for (var i = 0; i < keys.length; i++) { - if (properties[keys[i]]) continue - ${additionalProperty(location)} - } - ` -} + const mergedSchema = mergeSchemas(mergedSchemas) + const mergedLocation = new Location(mergedSchema, mergedSchemaId) -function idFinder (schema, searchedId) { - let objSchema - const explore = (schema, searchedId) => { - Object.keys(schema || {}).forEach((key, i, a) => { - if (key === '$id' && schema[key] === searchedId) { - objSchema = schema - } else if (objSchema === undefined && typeof schema[key] === 'object') { - explore(schema[key], searchedId) - } - }) - } - explore(schema, searchedId) - return objSchema + context.refResolver.addSchema(mergedSchema, mergedSchemaId) + return mergedLocation } -function refFinder (ref, location) { - const externalSchema = location.externalSchema - let root = location.root - let schema = location.schema +function cloneOriginSchema (context, schema, schemaId) { + const clonedSchema = Array.isArray(schema) ? [] : {} - if (externalSchema && externalSchema[ref]) { - return { - schema: externalSchema[ref], - root: externalSchema[ref], - externalSchema: externalSchema - } + if ( + schema.$id !== undefined && + schema.$id.charAt(0) !== '#' + ) { + schemaId = schema.$id } - // Split file from walk - ref = ref.split('#') + const mergedSchemaRef = context.mergedSchemasIds.get(schema) + if (mergedSchemaRef) { + context.mergedSchemasIds.set(clonedSchema, mergedSchemaRef) + } - // If external file - if (ref[0]) { - schema = externalSchema[ref[0]] - root = externalSchema[ref[0]] + for (const key in schema) { + let value = schema[key] - if (schema === undefined) { - findBadKey(externalSchema, [ref[0]]) + if (key === '$ref' && typeof value === 'string' && value.charAt(0) === '#') { + value = schemaId + value } - if (schema.$ref) { - return refFinder(schema.$ref, { - schema: schema, - root: root, - externalSchema: externalSchema - }) + if (typeof value === 'object' && value !== null) { + value = cloneOriginSchema(context, value, schemaId) } - } - - let code = 'return schema' - // If it has a path - if (ref[1]) { - // ref[1] could contain a JSON pointer - ex: /definitions/num - // or plain name fragment id without suffix # - ex: customId - const walk = ref[1].split('/') - if (walk.length === 1) { - const targetId = `#${ref[1]}` - let dereferenced = idFinder(schema, targetId) - if (dereferenced === undefined && !ref[0]) { - // eslint-disable-next-line - for (var key of Object.keys(externalSchema)) { - dereferenced = idFinder(externalSchema[key], targetId) - if (dereferenced !== undefined) { - root = externalSchema[key] - break - } - } - } - return { - schema: dereferenced, - root: root, - externalSchema: externalSchema - } - } else { - // eslint-disable-next-line - for (var i = 1; i < walk.length; i++) { - code += `[${JSON.stringify(walk[i])}]` - } - } - } - let result - try { - result = (new Function('schema', code))(root) - } catch (err) {} - - if (result === undefined && ref[1]) { - const walk = ref[1].split('/') - findBadKey(schema, walk.slice(1)) + clonedSchema[key] = value } - if (result.$ref) { - return refFinder(result.$ref, { - schema: schema, - root: root, - externalSchema: externalSchema - }) - } + return clonedSchema +} - return { - schema: result, - root: root, - externalSchema: externalSchema - } +function toJSON (variableName) { + return `(${variableName} && typeof ${variableName}.toJSON === 'function') + ? ${variableName}.toJSON() + : ${variableName} + ` +} - function findBadKey (obj, keys) { - if (keys.length === 0) return null - const key = keys.shift() - if (obj[key] === undefined) { - stringSimilarity = stringSimilarity || require('string-similarity') - const { bestMatch } = stringSimilarity.findBestMatch(key, Object.keys(obj)) - if (bestMatch.rating >= 0.5) { - throw new Error(`Cannot find reference ${JSON.stringify(key)}, did you mean ${JSON.stringify(bestMatch.target)}?`) - } else { - throw new Error(`Cannot find reference ${JSON.stringify(key)}`) - } - } - return findBadKey(obj[key], keys) +function buildObject (context, location) { + const schema = location.schema + + if (context.functionsNamesBySchema.has(schema)) { + return context.functionsNamesBySchema.get(schema) } -} -function buildCode (location, code, laterCode, name) { - if (location.schema.$ref) { - location = refFinder(location.schema.$ref, location) + const functionName = generateFuncName(context) + context.functionsNamesBySchema.set(schema, functionName) + + let schemaRef = location.getSchemaRef() + if (schemaRef.startsWith(context.rootSchemaId)) { + schemaRef = schemaRef.replace(context.rootSchemaId, '') } - const schema = location.schema - let required = schema.required + let functionCode = ` + ` + + const nullable = schema.nullable === true + functionCode += ` + // ${schemaRef} + function ${functionName} (input) { + const obj = ${toJSON('input')} + ${!nullable ? 'if (obj === null) return JSON_STR_EMPTY_OBJECT' : ''} - Object.keys(schema.properties || {}).forEach((key, i, a) => { - let propertyLocation = mergeLocation(location, { schema: schema.properties[key] }) - if (schema.properties[key].$ref) { - propertyLocation = refFinder(schema.properties[key].$ref, location) - schema.properties[key] = propertyLocation.schema + ${buildInnerObject(context, location)} } + ` - // Using obj['key'] !== undefined instead of obj.hasOwnProperty(prop) for perf reasons, - // see https://github.com/mcollina/fast-json-stringify/pull/3 for discussion. + context.functions.push(functionCode) + return functionName +} - const type = schema.properties[key].type - const nullable = schema.properties[key].nullable - const sanitized = JSON.stringify(key) - const asString = JSON.stringify(sanitized) +function buildArray (context, location) { + const schema = location.schema - if (nullable) { - code += ` - if (obj[${sanitized}] === null) { - ${addComma} - json += ${asString} + ':null' - var rendered = true - } else { - ` - } + let itemsLocation = location.getPropertyLocation('items') + itemsLocation.schema = itemsLocation.schema || {} - if (type === 'number') { - code += ` - var t = Number(obj[${sanitized}]) - if (!isNaN(t)) { - ${addComma} - json += ${asString} + ':' + t - ` - } else if (type === 'integer') { - code += ` - var rendered = false - var t = $asInteger(obj[${sanitized}]) - if (!isNaN(t)) { - ${addComma} - json += ${asString} + ':' + t - rendered = true - } - if (rendered) { - ` - } else { - code += ` - if (obj[${sanitized}] !== undefined) { - ${addComma} - json += ${asString} + ':' - ` + if (itemsLocation.schema.$ref) { + itemsLocation = resolveRef(context, itemsLocation) + } - const result = nested(laterCode, name, key, mergeLocation(propertyLocation, { schema: schema.properties[key] }), undefined, false) - code += result.code - laterCode = result.laterCode - } + const itemsSchema = itemsLocation.schema - const defaultValue = schema.properties[key].default - if (defaultValue !== undefined) { - required = filterRequired(required, key) - code += ` - } else { - ${addComma} - json += ${asString} + ':' + ${JSON.stringify(JSON.stringify(defaultValue))} - ` - } else if (required && required.indexOf(key) !== -1) { - required = filterRequired(required, key) - code += ` - } else { - throw new Error('${sanitized} is required!') - ` - } + if (context.functionsNamesBySchema.has(schema)) { + return context.functionsNamesBySchema.get(schema) + } - code += ` - } - ` + const functionName = generateFuncName(context) + context.functionsNamesBySchema.set(schema, functionName) - if (nullable) { - code += ` - } - ` - } - }) + let schemaRef = location.getSchemaRef() + if (schemaRef.startsWith(context.rootSchemaId)) { + schemaRef = schemaRef.replace(context.rootSchemaId, '') + } - if (required && required.length > 0) { - code += 'var required = [' - // eslint-disable-next-line - for (var i = 0; i < required.length; i++) { - if (i > 0) { - code += ',' - } - code += `${JSON.stringify(required[i])}` + let functionCode = ` + function ${functionName} (obj) { + // ${schemaRef} + ` + + const nullable = schema.nullable === true + functionCode += ` + ${!nullable ? 'if (obj === null) return JSON_STR_EMPTY_ARRAY' : ''} + if (!Array.isArray(obj)) { + throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) } - code += `] - for (var i = 0; i < required.length; i++) { - if (obj[required[i]] === undefined) throw new Error('"' + required[i] + '" is required!') + const arrayLength = obj.length + ` + + if (!schema.additionalItems && Array.isArray(itemsSchema)) { + functionCode += ` + if (arrayLength > ${itemsSchema.length}) { + throw new Error(\`Item at ${itemsSchema.length} does not match schema definition.\`) } ` } - if (schema.allOf) { - const builtCode = buildCodeWithAllOfs(location, code, laterCode, name) - code = builtCode.code - laterCode = builtCode.laterCode + if (largeArrayMechanism === 'json-stringify') { + functionCode += `if (arrayLength >= ${largeArraySize}) return JSON.stringify(obj)\n` } - return { code: code, laterCode: laterCode } -} + functionCode += ` + const arrayEnd = arrayLength - 1 + let value + let json = '' + ` -function filterRequired (required, key) { - if (!required) { - return required - } - return required.filter(k => k !== key) -} + if (Array.isArray(itemsSchema)) { + for (let i = 0, itemsSchemaLength = itemsSchema.length; i < itemsSchemaLength; i++) { + const item = itemsSchema[i] + functionCode += `value = obj[${i}]` + const tmpRes = buildValue(context, itemsLocation.getPropertyLocation(i), 'value') + functionCode += ` + if (${i} < arrayLength) { + if (${buildArrayTypeCondition(item.type, 'value')}) { + ${tmpRes} + if (${i} < arrayEnd) { + json += JSON_STR_COMMA + } + } else { + throw new Error(\`Item at ${i} does not match schema definition.\`) + } + } + ` + } -function buildCodeWithAllOfs (location, code, laterCode, name) { - if (location.schema.allOf) { - location.schema.allOf.forEach((ss) => { - const builtCode = buildCodeWithAllOfs(mergeLocation(location, { schema: ss }), code, laterCode, name) - code = builtCode.code - laterCode = builtCode.laterCode - }) + if (schema.additionalItems) { + functionCode += ` + for (let i = ${itemsSchema.length}; i < arrayLength; i++) { + value = obj[i] + json += JSON.stringify(value) + if (i < arrayEnd) { + json += JSON_STR_COMMA + } + }` + } } else { - const builtCode = buildCode(location, code, laterCode, name) - - code = builtCode.code - laterCode = builtCode.laterCode + const code = buildValue(context, itemsLocation, 'value') + functionCode += ` + for (let i = 0; i < arrayLength; i++) { + value = obj[i] + ${code} + if (i < arrayEnd) { + json += JSON_STR_COMMA + } + }` } - return { code: code, laterCode: laterCode } + functionCode += ` + return JSON_STR_BEGIN_ARRAY + json + JSON_STR_END_ARRAY + }` + + context.functions.push(functionCode) + return functionName } -function buildInnerObject (location, name) { - const schema = location.schema - const result = buildCodeWithAllOfs(location, '', '', name) - if (schema.patternProperties) { - result.code += addPatternProperties(location) - } else if (schema.additionalProperties && !schema.patternProperties) { - result.code += addAdditionalProperties(location) +function buildArrayTypeCondition (type, accessor) { + let condition + switch (type) { + case 'null': + condition = 'value === null' + break + case 'string': + condition = `typeof value === 'string' || + value === null || + value instanceof Date || + value instanceof RegExp || + ( + typeof value === "object" && + typeof value.toString === "function" && + value.toString !== Object.prototype.toString + )` + break + case 'integer': + condition = 'Number.isInteger(value)' + break + case 'number': + condition = 'Number.isFinite(value)' + break + case 'boolean': + condition = 'typeof value === \'boolean\'' + break + case 'object': + condition = 'value && typeof value === \'object\' && value.constructor === Object' + break + case 'array': + condition = 'Array.isArray(value)' + break + default: + if (Array.isArray(type)) { + const conditions = type.map((subType) => { + return buildArrayTypeCondition(subType, accessor) + }) + condition = `(${conditions.join(' || ')})` + } } - return result + return condition } -function addIfThenElse (location, name) { - let code = '' - let r - let laterCode = '' - let innerR +function generateFuncName (context) { + return 'anonymous' + context.functionsCounter++ +} +function buildMultiTypeSerializer (context, location, input) { const schema = location.schema - const copy = merge({}, schema) - const i = copy.if - const then = copy.then - const e = copy.else ? copy.else : { additionalProperties: true } - delete copy.if - delete copy.then - delete copy.else - let merged = merge(copy, then) - let mergedLocation = mergeLocation(location, { schema: merged }) - - const schemaKey = i.$id || randomUUID() - ajvInstance.addSchema(i, schemaKey) + const types = schema.type.sort(t1 => t1 === 'null' ? -1 : 1) - code += ` - valid = ajv.validate("${schemaKey}", obj) - if (valid) { - ` - if (merged.if && merged.then) { - innerR = addIfThenElse(mergedLocation, name + 'Then') - code += innerR.code - laterCode = innerR.laterCode - } + let code = '' - r = buildInnerObject(mergedLocation, name + 'Then') - code += r.code - laterCode += r.laterCode + types.forEach((type, index) => { + location.schema = { ...location.schema, type } + const nestedResult = buildSingleTypeSerializer(context, location, input) - code += ` + const statement = index === 0 ? 'if' : 'else if' + switch (type) { + case 'null': + code += ` + ${statement} (${input} === null) + ${nestedResult} + ` + break + case 'string': { + code += ` + ${statement}( + typeof ${input} === "string" || + ${input} === null || + ${input} instanceof Date || + ${input} instanceof RegExp || + ( + typeof ${input} === "object" && + typeof ${input}.toString === "function" && + ${input}.toString !== Object.prototype.toString + ) + ) + ${nestedResult} + ` + break + } + case 'array': { + code += ` + ${statement}(Array.isArray(${input})) + ${nestedResult} + ` + break + } + case 'integer': { + code += ` + ${statement}(Number.isInteger(${input}) || ${input} === null) + ${nestedResult} + ` + break + } + default: { + code += ` + ${statement}(typeof ${input} === "${type}" || ${input} === null) + ${nestedResult} + ` + break + } } - ` - merged = merge(copy, e) - mergedLocation = mergeLocation(mergedLocation, { schema: merged }) - - code += ` - else { - ` - - if (merged.if && merged.then) { - innerR = addIfThenElse(mergedLocation, name + 'Else') - code += innerR.code - laterCode += innerR.laterCode + }) + let schemaRef = location.getSchemaRef() + if (schemaRef.startsWith(context.rootSchemaId)) { + schemaRef = schemaRef.replace(context.rootSchemaId, '') } - - r = buildInnerObject(mergedLocation, name + 'Else') - code += r.code - laterCode += r.laterCode - code += ` - } - ` - return { code: code, laterCode: laterCode } -} - -function toJSON (variableName) { - return `(${variableName} && typeof ${variableName}.toJSON === 'function') - ? ${variableName}.toJSON() - : ${variableName} + else throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) ` + + return code } -function buildObject (location, code, name) { +function buildSingleTypeSerializer (context, location, input) { const schema = location.schema - code += ` - function ${name} (input) { - ` - if (schema.nullable) { - code += ` - if(input === null) { - return 'null'; + switch (schema.type) { + case 'null': + return 'json += JSON_STR_NULL' + case 'string': { + if (schema.format === 'date-time') { + return `json += asDateTime(${input})` + } else if (schema.format === 'date') { + return `json += asDate(${input})` + } else if (schema.format === 'time') { + return `json += asTime(${input})` + } else if (schema.format === 'unsafe') { + return `json += asUnsafeString(${input})` + } else { + return ` + if (typeof ${input} !== 'string') { + if (${input} === null) { + json += JSON_STR_EMPTY_STRING + } else if (${input} instanceof Date) { + json += JSON_STR_QUOTE + ${input}.toISOString() + JSON_STR_QUOTE + } else if (${input} instanceof RegExp) { + json += asString(${input}.source) + } else { + json += asString(${input}.toString()) + } + } else { + json += asString(${input}) + } + ` } - ` + } + case 'integer': + return `json += asInteger(${input})` + case 'number': + return `json += asNumber(${input})` + case 'boolean': + return `json += asBoolean(${input})` + case 'object': { + const funcName = buildObject(context, location) + return `json += ${funcName}(${input})` + } + case 'array': { + const funcName = buildArray(context, location) + return `json += ${funcName}(${input})` + } + case undefined: + return `json += JSON.stringify(${input})` + default: + throw new Error(`${schema.type} unsupported`) } +} + +function buildConstSerializer (location, input) { + const schema = location.schema + const type = schema.type + + const hasNullType = Array.isArray(type) && type.includes('null') + + let code = '' - if (objectReferenceSerializersMap.has(schema)) { + if (hasNullType) { code += ` - return ${objectReferenceSerializersMap.get(schema)}(input) - } + if (${input} === null) { + json += JSON_STR_NULL + } else { ` - return code } - objectReferenceSerializersMap.set(schema, name) - code += ` - var obj = ${toJSON('input')} - var json = '{' - var addComma = false - ` + code += `json += '${JSON.stringify(schema.const).replace(SINGLE_TICK, "\\'")}'` - let r - if (schema.if && schema.then) { + if (hasNullType) { code += ` - var valid + } ` - r = addIfThenElse(location, name) - } else { - r = buildInnerObject(location, name) } - // Removes the comma if is the last element of the string (in case there are not properties) - code += `${r.code} - json += '}' - return json - } - ${r.laterCode} - ` - return code } -function buildArray (location, code, name, key = null) { - let schema = location.schema - code += ` - function ${name} (obj) { - ` - if (schema.nullable) { - code += ` - if(obj === null) { - return 'null'; - } - ` +function buildAllOf (context, location, input) { + const schema = location.schema + + let mergedSchemaId = context.mergedSchemasIds.get(schema) + if (mergedSchemaId) { + const mergedLocation = getMergedLocation(context, mergedSchemaId) + return buildValue(context, mergedLocation, input) } - const laterCode = '' - // default to any items type - if (!schema.items) { - schema.items = {} + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + context.mergedSchemasIds.set(schema, mergedSchemaId) + + const { allOf, ...schemaWithoutAllOf } = location.schema + const locations = [ + new Location( + schemaWithoutAllOf, + location.schemaId, + location.jsonPointer + ) + ] + + const allOfsLocation = location.getPropertyLocation('allOf') + for (let i = 0, allOfLength = allOf.length; i < allOfLength; i++) { + locations.push(allOfsLocation.getPropertyLocation(i)) } - if (schema.items.$ref) { - if (!schema[fjsCloned]) { - location.schema = clone(location.schema) - schema = location.schema - schema[fjsCloned] = true - } + const mergedLocation = mergeLocations(context, mergedSchemaId, locations) + return buildValue(context, mergedLocation, input) +} - location = refFinder(schema.items.$ref, location) - schema.items = location.schema +function buildOneOf (context, location, input) { + context.validatorSchemasIds.add(location.schemaId) - if (arrayItemsReferenceSerializersMap.has(schema.items)) { - code += ` - return ${arrayItemsReferenceSerializersMap.get(schema.items)}(obj) - } - ` - return code - } - arrayItemsReferenceSerializersMap.set(schema.items, name) - } + const schema = location.schema - let result = { code: '', laterCode: '' } - const accessor = '[i]' - if (Array.isArray(schema.items)) { - result = schema.items.reduce((res, item, i) => { - const tmpRes = nested(laterCode, name, accessor, mergeLocation(location, { schema: item }), i, true) - const condition = `i === ${i} && ${buildArrayTypeCondition(item.type, accessor)}` - return { - code: `${res.code} - ${i > 0 ? 'else' : ''} if (${condition}) { - ${tmpRes.code} - }`, - laterCode: `${res.laterCode} - ${tmpRes.laterCode}` - } - }, result) + const type = schema.anyOf ? 'anyOf' : 'oneOf' + const { [type]: oneOfs, ...schemaWithoutAnyOf } = location.schema - if (schema.additionalItems) { - const tmpRes = nested(laterCode, name, accessor, mergeLocation(location, { schema: schema.items }), undefined, true) - result.code += ` - else if (i >= ${schema.items.length}) { - ${tmpRes.code} - } - ` - } + const locationWithoutOneOf = new Location( + schemaWithoutAnyOf, + location.schemaId, + location.jsonPointer + ) + const oneOfsLocation = location.getPropertyLocation(type) - result.code += ` - else { - throw new Error(\`Item at $\{i} does not match schema definition.\`) + let code = '' + + for (let index = 0, oneOfsLength = oneOfs.length; index < oneOfsLength; index++) { + const optionLocation = oneOfsLocation.getPropertyLocation(index) + const optionSchema = optionLocation.schema + + let mergedSchemaId = context.mergedSchemasIds.get(optionSchema) + let mergedLocation = null + if (mergedSchemaId) { + mergedLocation = getMergedLocation(context, mergedSchemaId) + } else { + mergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + context.mergedSchemasIds.set(optionSchema, mergedSchemaId) + + mergedLocation = mergeLocations(context, mergedSchemaId, [ + locationWithoutOneOf, + optionLocation + ]) } - ` - } else { - result = nested(laterCode, name, accessor, mergeLocation(location, { schema: schema.items }), undefined, true) - } - if (key) { + const nestedResult = buildValue(context, mergedLocation, input) + const schemaRef = optionLocation.getSchemaRef() code += ` - if(!Array.isArray(obj)) { - throw new TypeError(\`Property '${key}' should be of type array, received '$\{obj}' instead.\`) - } + ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) + ${nestedResult} ` } - code += ` - var l = obj.length - var jsonOutput= '' - for (var i = 0; i < l; i++) { - var json = '' - ${result.code} - jsonOutput += json - - if (json.length > 0 && i < l - 1) { - jsonOutput += ',' - } - } - return \`[\${jsonOutput}]\` + let schemaRef = location.getSchemaRef() + if (schemaRef.startsWith(context.rootSchemaId)) { + schemaRef = schemaRef.replace(context.rootSchemaId, '') } - ${result.laterCode} + + code += ` + else throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) ` return code } -function buildArrayTypeCondition (type, accessor) { - let condition - switch (type) { - case 'null': - condition = `obj${accessor} === null` - break - case 'string': - condition = `typeof obj${accessor} === 'string'` - break - case 'integer': - condition = `Number.isInteger(obj${accessor})` - break - case 'number': - condition = `Number.isFinite(obj${accessor})` - break - case 'boolean': - condition = `typeof obj${accessor} === 'boolean'` - break - case 'object': - condition = `obj${accessor} && typeof obj${accessor} === 'object' && obj${accessor}.constructor === Object` - break - case 'array': - condition = `Array.isArray(obj${accessor})` - break - default: - if (Array.isArray(type)) { - const conditions = type.map((subType) => { - return buildArrayTypeCondition(subType, accessor) - }) - condition = `(${conditions.join(' || ')})` +function buildIfThenElse (context, location, input) { + context.validatorSchemasIds.add(location.schemaId) + + const { + if: ifSchema, + then: thenSchema, + else: elseSchema, + ...schemaWithoutIfThenElse + } = location.schema + + const rootLocation = new Location( + schemaWithoutIfThenElse, + location.schemaId, + location.jsonPointer + ) + + const ifLocation = location.getPropertyLocation('if') + const ifSchemaRef = ifLocation.getSchemaRef() + + const thenLocation = location.getPropertyLocation('then') + let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema) + let thenMergedLocation = null + if (thenMergedSchemaId) { + thenMergedLocation = getMergedLocation(context, thenMergedSchemaId) + } else { + thenMergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + context.mergedSchemasIds.set(thenSchema, thenMergedSchemaId) + + thenMergedLocation = mergeLocations(context, thenMergedSchemaId, [ + rootLocation, + thenLocation + ]) + } + + if (!elseSchema) { + return ` + if (validator.validate("${ifSchemaRef}", ${input})) { + ${buildValue(context, thenMergedLocation, input)} } else { - throw new Error(`${type} unsupported`) + ${buildValue(context, rootLocation, input)} } + ` } - return condition -} -function dereferenceOfRefs (location, type) { - if (!location.schema[fjsCloned]) { - const schemaClone = clone(location.schema) - schemaClone[fjsCloned] = true - location.schema = schemaClone + const elseLocation = location.getPropertyLocation('else') + let elseMergedSchemaId = context.mergedSchemasIds.get(elseSchema) + let elseMergedLocation = null + if (elseMergedSchemaId) { + elseMergedLocation = getMergedLocation(context, elseMergedSchemaId) + } else { + elseMergedSchemaId = `__fjs_merged_${schemaIdCounter++}` + context.mergedSchemasIds.set(elseSchema, elseMergedSchemaId) + + elseMergedLocation = mergeLocations(context, elseMergedSchemaId, [ + rootLocation, + elseLocation + ]) } - const schema = location.schema - const locations = [] - - schema[type].forEach((s, index) => { - // follow the refs - let sLocation = mergeLocation(location, { schema: s }) - while (s.$ref) { - sLocation = refFinder(s.$ref, sLocation) - schema[type][index] = sLocation.schema - s = schema[type][index] + return ` + if (validator.validate("${ifSchemaRef}", ${input})) { + ${buildValue(context, thenMergedLocation, input)} + } else { + ${buildValue(context, elseMergedLocation, input)} } - locations[index] = sLocation - }) - - return locations + ` } -let strNameCounter = 0 -function asFuncName (str) { - // only allow chars that can work - let rep = str.replace(/[^a-zA-Z0-9$_]/g, '') +function buildValue (context, location, input) { + let schema = location.schema - if (rep.length === 0) { - return 'anan' + strNameCounter++ - } else if (rep !== str) { - rep += strNameCounter++ + if (typeof schema === 'boolean') { + return `json += JSON.stringify(${input})` } - return rep -} - -function nested (laterCode, name, key, location, subKey, isArray) { - let code = '' - let funcName + if (schema.$ref) { + location = resolveRef(context, location) + schema = location.schema + } - subKey = subKey || '' + if (schema.allOf) { + return buildAllOf(context, location, input) + } - let schema = location.schema + if (schema.anyOf || schema.oneOf) { + return buildOneOf(context, location, input) + } - if (schema.$ref) { - schema = refFinder(schema.$ref, location) + if (schema.if && schema.then) { + return buildIfThenElse(context, location, input) } if (schema.type === undefined) { @@ -1140,202 +1007,43 @@ function nested (laterCode, name, key, location, subKey, isArray) { } } + let code = '' + const type = schema.type const nullable = schema.nullable === true - - const accessor = isArray ? key : `[${JSON.stringify(key)}]` - - switch (type) { - case 'null': - code += ` - json += $asNull() - ` - break - case 'string': { - const stringSerializer = getStringSerializer(schema.format) - code += nullable ? `json += obj${accessor} === null ? null : ${stringSerializer}(obj${accessor})` : `json += ${stringSerializer}(obj${accessor})` - break - } - case 'integer': - code += nullable ? `json += obj${accessor} === null ? null : $asInteger(obj${accessor})` : `json += $asInteger(obj${accessor})` - break - case 'number': - code += nullable ? `json += obj${accessor} === null ? null : $asNumber(obj${accessor})` : `json += $asNumber(obj${accessor})` - break - case 'boolean': - code += nullable ? `json += obj${accessor} === null ? null : $asBoolean(obj${accessor})` : `json += $asBoolean(obj${accessor})` - break - case 'object': - funcName = asFuncName(name + key + subKey) - laterCode = buildObject(location, laterCode, funcName) - code += ` - json += ${funcName}(obj${accessor}) - ` - break - case 'array': - funcName = asFuncName('$arr' + name + key + subKey) // eslint-disable-line - laterCode = buildArray(location, laterCode, funcName, key) - code += ` - json += ${funcName}(obj${accessor}) - ` - break - case undefined: - if ('anyOf' in schema) { - // beware: dereferenceOfRefs has side effects and changes schema.anyOf - const anyOfLocations = dereferenceOfRefs(location, 'anyOf') - anyOfLocations.forEach((location, index) => { - const nestedResult = nested(laterCode, name, key, location, subKey !== '' ? subKey : 'i' + index, isArray) - // We need a test serializer as the String serializer will not work with - // date/time ajv validations - // see: https://github.com/fastify/fast-json-stringify/issues/325 - const testSerializer = getTestSerializer(location.schema.format) - const testValue = testSerializer !== undefined ? `${testSerializer}(obj${accessor}, true)` : `obj${accessor}` - - // Since we are only passing the relevant schema to ajv.validate, it needs to be full dereferenced - // otherwise any $ref pointing to an external schema would result in an error. - // Full dereference of the schema happens as side effect of two functions: - // 1. `dereferenceOfRefs` loops through the `schema.anyOf`` array and replaces any top level reference - // with the actual schema - // 2. `nested`, through `buildCode`, replaces any reference in object properties with the actual schema - // (see https://github.com/fastify/fast-json-stringify/blob/6da3b3e8ac24b1ca5578223adedb4083b7adf8db/index.js#L631) - - const schemaKey = location.schema.$id || randomUUID() - ajvInstance.addSchema(location.schema, schemaKey) - - code += ` - ${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaKey}", ${testValue})) - ${nestedResult.code} - ` - laterCode = nestedResult.laterCode - }) - code += ` - else json+= null - ` - } else if ('oneOf' in schema) { - // beware: dereferenceOfRefs has side effects and changes schema.oneOf - const oneOfLocations = dereferenceOfRefs(location, 'oneOf') - oneOfLocations.forEach((location, index) => { - const nestedResult = nested(laterCode, name, key, location, subKey !== '' ? subKey : 'i' + index, isArray) - const testSerializer = getTestSerializer(location.schema.format) - const testValue = testSerializer !== undefined ? `${testSerializer}(obj${accessor}, true)` : `obj${accessor}` - // see comment on anyOf about dereferencing the schema before calling ajv.validate - - const schemaKey = location.schema.$id || randomUUID() - ajvInstance.addSchema(location.schema, schemaKey) - - code += ` - ${index === 0 ? 'if' : 'else if'}(ajv.validate("${schemaKey}", ${testValue})) - ${nestedResult.code} - ` - laterCode = nestedResult.laterCode - }) - - if (!isArray) { - code += ` - else json+= null - ` - } - } else if (isEmpty(schema)) { - code += ` - json += JSON.stringify(obj${accessor}) - ` - } else if ('const' in schema) { - code += ` - if(ajv.validate(${JSON.stringify(schema)}, obj${accessor})) - json += '${JSON.stringify(schema.const)}' - else - throw new Error(\`Item $\{JSON.stringify(obj${accessor})} does not match schema definition.\`) - ` - } else if (schema.type === undefined) { - code += ` - json += JSON.stringify(obj${accessor}) - ` - } else { - throw new Error(`${schema.type} unsupported`) - } - break - default: - if (Array.isArray(type)) { - const nullIndex = type.indexOf('null') - const sortedTypes = nullIndex !== -1 ? [type[nullIndex]].concat(type.slice(0, nullIndex)).concat(type.slice(nullIndex + 1)) : type - sortedTypes.forEach((type, index) => { - const statement = index === 0 ? 'if' : 'else if' - const tempSchema = Object.assign({}, schema, { type }) - const nestedResult = nested(laterCode, name, key, mergeLocation(location, { schema: tempSchema }), subKey, isArray) - switch (type) { - case 'string': { - code += ` - ${statement}(obj${accessor} === null || typeof obj${accessor} === "${type}" || obj${accessor} instanceof Date || typeof obj${accessor}.toISOString === "function" || obj${accessor} instanceof RegExp || (typeof obj${accessor} === "object" && Object.hasOwnProperty.call(obj${accessor}, "toString"))) - ${nestedResult.code} - ` - break - } - case 'null': { - code += ` - ${statement}(obj${accessor} == null) - ${nestedResult.code} - ` - break - } - case 'array': { - code += ` - ${statement}(Array.isArray(obj${accessor})) - ${nestedResult.code} - ` - break - } - case 'integer': { - code += ` - ${statement}(Number.isInteger(obj${accessor}) || obj${accessor} === null) - ${nestedResult.code} - ` - break - } - case 'number': { - code += ` - ${statement}(isNaN(obj${accessor}) === false) - ${nestedResult.code} - ` - break - } - default: { - code += ` - ${statement}(typeof obj${accessor} === "${type}") - ${nestedResult.code} - ` - break - } - } - laterCode = nestedResult.laterCode - }) - code += ` - else json+= null - ` + if (nullable) { + code += ` + if (${input} === null) { + json += JSON_STR_NULL } else { - throw new Error(`${type} unsupported`) - } + ` } - return { - code, - laterCode + if (schema.const !== undefined) { + code += buildConstSerializer(location, input) + } else if (Array.isArray(type)) { + code += buildMultiTypeSerializer(context, location, input) + } else { + code += buildSingleTypeSerializer(context, location, input) } -} -function isEmpty (schema) { - // eslint-disable-next-line - for (var key in schema) { - if (schema.hasOwnProperty(key) && schema[key] !== undefined) { - return false - } + if (nullable) { + code += ` + } + ` } - return true + + return code } module.exports = build +module.exports.default = build +module.exports.build = build + +module.exports.validLargeArrayMechanisms = validLargeArrayMechanisms -module.exports.restore = function ({ code, ajv }) { +module.exports.restore = function ({ code, validator, serializer }) { // eslint-disable-next-line - return (Function.apply(null, ['ajv', code]) - .apply(null, [ajv])) + return (Function.apply(null, ['validator', 'serializer', code]) + .apply(null, [validator, serializer])) } diff --git a/lib/location.js b/lib/location.js new file mode 100644 index 00000000..0d9acb2d --- /dev/null +++ b/lib/location.js @@ -0,0 +1,24 @@ +'use strict' + +class Location { + constructor (schema, schemaId, jsonPointer = '#') { + this.schema = schema + this.schemaId = schemaId + this.jsonPointer = jsonPointer + } + + getPropertyLocation (propertyName) { + const propertyLocation = new Location( + this.schema[propertyName], + this.schemaId, + this.jsonPointer + '/' + propertyName + ) + return propertyLocation + } + + getSchemaRef () { + return this.schemaId + this.jsonPointer + } +} + +module.exports = Location diff --git a/lib/merge-schemas.js b/lib/merge-schemas.js new file mode 100644 index 00000000..bb27a8bf --- /dev/null +++ b/lib/merge-schemas.js @@ -0,0 +1,9 @@ +'use strict' + +const { mergeSchemas: _mergeSchemas } = require('@fastify/merge-json-schemas') + +function mergeSchemas (schemas) { + return _mergeSchemas(schemas, { onConflict: 'skip' }) +} + +module.exports = mergeSchemas diff --git a/schema-validator.js b/lib/schema-validator.js similarity index 100% rename from schema-validator.js rename to lib/schema-validator.js diff --git a/lib/serializer.js b/lib/serializer.js new file mode 100644 index 00000000..d8ff4b87 --- /dev/null +++ b/lib/serializer.js @@ -0,0 +1,141 @@ +'use strict' + +// eslint-disable-next-line +const STR_ESCAPE = /[\u0000-\u001f\u0022\u005c\ud800-\udfff]/ + +module.exports = class Serializer { + constructor (options) { + switch (options && options.rounding) { + case 'floor': + this.parseInteger = Math.floor + break + case 'ceil': + this.parseInteger = Math.ceil + break + case 'round': + this.parseInteger = Math.round + break + case 'trunc': + default: + this.parseInteger = Math.trunc + break + } + this._options = options + } + + asInteger (i) { + if (Number.isInteger(i)) { + return '' + i + } else if (typeof i === 'bigint') { + return i.toString() + } + /* eslint no-undef: "off" */ + const integer = this.parseInteger(i) + // check if number is Infinity or NaN + // eslint-disable-next-line no-self-compare + if (integer === Infinity || integer === -Infinity || integer !== integer) { + throw new Error(`The value "${i}" cannot be converted to an integer.`) + } + return '' + integer + } + + asNumber (i) { + // fast cast to number + const num = Number(i) + // check if number is NaN + // eslint-disable-next-line no-self-compare + if (num !== num) { + throw new Error(`The value "${i}" cannot be converted to a number.`) + } else if (num === Infinity || num === -Infinity) { + return 'null' + } else { + return '' + num + } + } + + asBoolean (bool) { + return bool && 'true' || 'false' // eslint-disable-line + } + + asDateTime (date) { + if (date === null) return '""' + if (date instanceof Date) { + return '"' + date.toISOString() + '"' + } + if (typeof date === 'string') { + return '"' + date + '"' + } + throw new Error(`The value "${date}" cannot be converted to a date-time.`) + } + + asDate (date) { + if (date === null) return '""' + if (date instanceof Date) { + return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(0, 10) + '"' + } + if (typeof date === 'string') { + return '"' + date + '"' + } + throw new Error(`The value "${date}" cannot be converted to a date.`) + } + + asTime (date) { + if (date === null) return '""' + if (date instanceof Date) { + return '"' + new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().slice(11, 19) + '"' + } + if (typeof date === 'string') { + return '"' + date + '"' + } + throw new Error(`The value "${date}" cannot be converted to a time.`) + } + + asString (str) { + const len = str.length + if (len === 0) { + return '""' + } else if (len < 42) { + // magically escape strings for json + // relying on their charCodeAt + // everything below 32 needs JSON.stringify() + // every string that contain surrogate needs JSON.stringify() + // 34 and 92 happens all the time, so we + // have a fast case for them + let result = '' + let last = -1 + let point = 255 + for (let i = 0; i < len; i++) { + point = str.charCodeAt(i) + if ( + point === 0x22 || // '"' + point === 0x5c // '\' + ) { + last === -1 && (last = 0) + result += str.slice(last, i) + '\\' + last = i + } else if (point < 32 || (point >= 0xD800 && point <= 0xDFFF)) { + // The current character is non-printable characters or a surrogate. + return JSON.stringify(str) + } + } + return (last === -1 && ('"' + str + '"')) || ('"' + result + str.slice(last) + '"') + } else if (len < 5000 && STR_ESCAPE.test(str) === false) { + // Only use the regular expression for shorter input. The overhead is otherwise too much. + return '"' + str + '"' + } else { + return JSON.stringify(str) + } + } + + asUnsafeString (str) { + return '"' + str + '"' + } + + getState () { + return this._options + } + + static restoreFromState (state) { + return new Serializer(state) + } +} diff --git a/lib/standalone.js b/lib/standalone.js new file mode 100644 index 00000000..0ba3ac3f --- /dev/null +++ b/lib/standalone.js @@ -0,0 +1,34 @@ +'use strict' + +function buildStandaloneCode (contextFunc, context, serializer, validator) { + let ajvDependencyCode = '' + if (context.validatorSchemasIds.size > 0) { + ajvDependencyCode += 'const Validator = require(\'fast-json-stringify/lib/validator\')\n' + ajvDependencyCode += `const validatorState = ${JSON.stringify(validator.getState())}\n` + ajvDependencyCode += 'const validator = Validator.restoreFromState(validatorState)\n' + } else { + ajvDependencyCode += 'const validator = null\n' + } + + // Don't need to keep external schemas once compiled + // validatorState will hold external schemas if it needs them + const { schema, ...serializerState } = serializer.getState() + + return ` + 'use strict' + + const Serializer = require('fast-json-stringify/lib/serializer') + const serializerState = ${JSON.stringify(serializerState)} + const serializer = Serializer.restoreFromState(serializerState) + + ${ajvDependencyCode} + + module.exports = ${contextFunc.toString()}(validator, serializer)` +} + +module.exports = buildStandaloneCode + +module.exports.dependencies = { + Serializer: require('./serializer'), + Validator: require('./validator') +} diff --git a/lib/validator.js b/lib/validator.js new file mode 100644 index 00000000..77bbd101 --- /dev/null +++ b/lib/validator.js @@ -0,0 +1,96 @@ +'use strict' + +const Ajv = require('ajv') +const fastUri = require('fast-uri') +const ajvFormats = require('ajv-formats') +const clone = require('rfdc')({ proto: true }) + +class Validator { + constructor (ajvOptions) { + this.ajv = new Ajv({ + ...ajvOptions, + strictSchema: false, + validateSchema: false, + allowUnionTypes: true, + uriResolver: fastUri + }) + + ajvFormats(this.ajv) + + this.ajv.addKeyword({ + keyword: 'fjs_type', + type: 'object', + errors: false, + validate: (_type, data) => { + return data && typeof data.toJSON === 'function' + } + }) + + this._ajvSchemas = {} + this._ajvOptions = ajvOptions || {} + } + + addSchema (schema, schemaName) { + let schemaKey = schema.$id || schemaName + if (schema.$id !== undefined && schema.$id[0] === '#') { + schemaKey = schemaName + schema.$id // relative URI + } + + if ( + this.ajv.refs[schemaKey] === undefined && + this.ajv.schemas[schemaKey] === undefined + ) { + const ajvSchema = clone(schema) + this.convertSchemaToAjvFormat(ajvSchema) + this.ajv.addSchema(ajvSchema, schemaKey) + this._ajvSchemas[schemaKey] = schema + } + } + + validate (schemaRef, data) { + return this.ajv.validate(schemaRef, data) + } + + // Ajv does not natively support JavaScript objects like Date or other types + // that rely on a custom .toJSON() representation. To properly validate schemas + // that may contain such objects (e.g. Date, ObjectId, etc.), we replace all + // occurrences of the string type with a custom keyword fjs_type + // (see https://github.com/fastify/fast-json-stringify/pull/441) + convertSchemaToAjvFormat (schema) { + if (schema === null) return + + if (schema.type === 'string') { + schema.fjs_type = 'string' + schema.type = ['string', 'object'] + } else if ( + Array.isArray(schema.type) && + schema.type.includes('string') && + !schema.type.includes('object') + ) { + schema.fjs_type = 'string' + schema.type.push('object') + } + for (const property in schema) { + if (typeof schema[property] === 'object') { + this.convertSchemaToAjvFormat(schema[property]) + } + } + } + + getState () { + return { + ajvOptions: this._ajvOptions, + ajvSchemas: this._ajvSchemas + } + } + + static restoreFromState (state) { + const validator = new Validator(state.ajvOptions) + for (const [id, ajvSchema] of Object.entries(state.ajvSchemas)) { + validator.ajv.addSchema(ajvSchema, id) + } + return validator + } +} + +module.exports = Validator diff --git a/package.json b/package.json index b5835f20..7e0655bd 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,21 @@ { "name": "fast-json-stringify", - "version": "3.0.2", + "version": "6.1.1", "description": "Stringify your JSON at max speed", "main": "index.js", - "types": "index.d.ts", + "type": "commonjs", + "types": "types/index.d.ts", "scripts": { - "benchmark": "node bench.js", - "lint:fix": "standard --fix", - "test:lint": "standard", - "test:typescript": "tsc --project ./test/types/tsconfig.json", - "test:unit": "tap -J test/*.test.js test/**/*.test.js", - "test": "npm run test:lint && npm run test:unit && npm run test:typescript" + "bench": "node ./benchmark/bench.js", + "bench:cmp": "node ./benchmark/bench-cmp-branch.js", + "bench:cmp:ci": "node ./benchmark/bench-cmp-branch.js --ci", + "benchmark": "node ./benchmark/bench-cmp-lib.js", + "lint": "eslint", + "lint:fix": "eslint --fix", + "test:typescript": "tsd", + "test:unit": "c8 node --test", + "test": "npm run test:unit && npm run test:typescript" }, - "precommit": "test", "repository": { "type": "git", "url": "git+https://github.com/fastify/fast-json-stringify.git" @@ -24,38 +27,62 @@ "fast" ], "author": "Matteo Collina ", + "contributors": [ + { + "name": "Tomas Della Vedova", + "url": "http://delved.org" + }, + { + "name": "Aras Abbasi", + "email": "aras.abbasi@gmail.com" + }, + { + "name": "Manuel Spigolon", + "email": "behemoth89@gmail.com" + }, + { + "name": "Frazer Smith", + "email": "frazer.dev@icloud.com", + "url": "https://github.com/fdawgs" + } + ], "license": "MIT", "bugs": { "url": "https://github.com/fastify/fast-json-stringify/issues" }, "homepage": "https://github.com/fastify/fast-json-stringify#readme", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], "devDependencies": { + "@sinclair/typebox": "^0.34.3", "benchmark": "^2.1.4", + "c8": "^10.1.2", + "cli-select": "^1.1.2", "compile-json-stringify": "^0.1.2", - "is-my-json-valid": "^2.20.0", - "moment": "^2.24.0", - "pre-commit": "^1.2.2", - "proxyquire": "^2.1.3", - "semver": "^7.1.0", - "standard": "^16.0.1", - "tap": "^15.0.0", - "typescript": "^4.0.2", - "webpack": "^5.40.0" + "eslint": "^9.17.0", + "fast-json-stringify": ".", + "is-my-json-valid": "^2.20.6", + "json-accelerator": "^0.0.2", + "neostandard": "^0.12.0", + "simple-git": "^3.23.0", + "tsd": "^0.32.0", + "webpack": "^5.90.3" }, "dependencies": { - "ajv": "^8.6.2", - "ajv-formats": "^2.1.1", - "deepmerge": "^4.2.2", - "rfdc": "^1.2.0", - "string-similarity": "^4.0.1" - }, - "engines": { - "node": ">= 10.0.0" - }, - "standard": { - "ignore": [ - "schema-validator.js" - ] + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" }, - "runkitExampleFilename": "example.js" + "runkitExampleFilename": "./examples/example.js" } diff --git a/test/additionalProperties.test.js b/test/additionalProperties.test.js index 23c8ac55..a42e176b 100644 --- a/test/additionalProperties.test.js +++ b/test/additionalProperties.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('additionalProperties', (t) => { @@ -19,7 +19,7 @@ test('additionalProperties', (t) => { }) const obj = { str: 'test', foo: 42, ofoo: true, foof: 'string', objfoo: { a: true } } - t.equal('{"str":"test","foo":"42","ofoo":"true","foof":"string","objfoo":"[object Object]"}', stringify(obj)) + t.assert.equal(stringify(obj), '{"str":"test","foo":"42","ofoo":"true","foof":"string","objfoo":"[object Object]"}') }) test('additionalProperties should not change properties', (t) => { @@ -38,7 +38,7 @@ test('additionalProperties should not change properties', (t) => { }) const obj = { foo: '42', ofoo: 42 } - t.equal('{"foo":"42","ofoo":42}', stringify(obj)) + t.assert.equal(stringify(obj), '{"foo":"42","ofoo":42}') }) test('additionalProperties should not change properties and patternProperties', (t) => { @@ -62,7 +62,7 @@ test('additionalProperties should not change properties and patternProperties', }) const obj = { foo: '42', ofoo: 42, test: '42' } - t.equal('{"foo":"42","ofoo":"42","test":42}', stringify(obj)) + t.assert.equal(stringify(obj), '{"foo":"42","ofoo":"42","test":42}') }) test('additionalProperties set to true, use of fast-safe-stringify', (t) => { @@ -75,7 +75,7 @@ test('additionalProperties set to true, use of fast-safe-stringify', (t) => { }) const obj = { foo: true, ofoo: 42, arrfoo: ['array', 'test'], objfoo: { a: 'world' } } - t.equal('{"foo":true,"ofoo":42,"arrfoo":["array","test"],"objfoo":{"a":"world"}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"foo":true,"ofoo":42,"arrfoo":["array","test"],"objfoo":{"a":"world"}}') }) test('additionalProperties - string coerce', (t) => { @@ -90,7 +90,7 @@ test('additionalProperties - string coerce', (t) => { }) const obj = { foo: true, ofoo: 42, arrfoo: ['array', 'test'], objfoo: { a: 'world' } } - t.equal('{"foo":"true","ofoo":"42","arrfoo":"array,test","objfoo":"[object Object]"}', stringify(obj)) + t.assert.equal(stringify(obj), '{"foo":"true","ofoo":"42","arrfoo":"array,test","objfoo":"[object Object]"}') }) test('additionalProperties - number skip', (t) => { @@ -104,8 +104,9 @@ test('additionalProperties - number skip', (t) => { } }) - const obj = { foo: true, ofoo: '42', xfoo: 'string', arrfoo: [1, 2], objfoo: { num: 42 } } - t.equal(stringify(obj), '{"foo":1,"ofoo":42}') + // const obj = { foo: true, ofoo: '42', xfoo: 'string', arrfoo: [1, 2], objfoo: { num: 42 } } + const obj = { foo: true, ofoo: '42' } + t.assert.equal(stringify(obj), '{"foo":1,"ofoo":42}') }) test('additionalProperties - boolean coerce', (t) => { @@ -120,7 +121,7 @@ test('additionalProperties - boolean coerce', (t) => { }) const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { a: true } } - t.equal(stringify(obj), '{"foo":true,"ofoo":false,"arrfoo":true,"objfoo":true}') + t.assert.equal(stringify(obj), '{"foo":true,"ofoo":false,"arrfoo":true,"objfoo":true}') }) test('additionalProperties - object coerce', (t) => { @@ -140,11 +141,11 @@ test('additionalProperties - object coerce', (t) => { }) const obj = { objfoo: { answer: 42 } } - t.equal('{"objfoo":{"answer":42}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"objfoo":{"answer":42}}') }) test('additionalProperties - array coerce', (t) => { - t.plan(1) + t.plan(2) const stringify = build({ title: 'check array coerce', type: 'object', @@ -157,8 +158,11 @@ test('additionalProperties - array coerce', (t) => { } }) - const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { tyrion: 'lannister' } } - t.equal('{"foo":["t","r","u","e"],"ofoo":[],"arrfoo":["1","2"],"objfoo":[]}', stringify(obj)) + const coercibleValues = { arrfoo: [1, 2] } + t.assert.equal(stringify(coercibleValues), '{"arrfoo":["1","2"]}') + + const incoercibleValues = { foo: 'true', ofoo: 0, objfoo: { tyrion: 'lannister' } } + t.assert.throws(() => stringify(incoercibleValues)) }) test('additionalProperties with empty schema', (t) => { @@ -169,7 +173,7 @@ test('additionalProperties with empty schema', (t) => { }) const obj = { a: 1, b: true, c: null } - t.equal('{"a":1,"b":true,"c":null}', stringify(obj)) + t.assert.equal(stringify(obj), '{"a":1,"b":true,"c":null}') }) test('additionalProperties with nested empty schema', (t) => { @@ -183,7 +187,7 @@ test('additionalProperties with nested empty schema', (t) => { }) const obj = { data: { a: 1, b: true, c: null } } - t.equal('{"data":{"a":1,"b":true,"c":null}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"data":{"a":1,"b":true,"c":null}}') }) test('nested additionalProperties', (t) => { @@ -203,7 +207,7 @@ test('nested additionalProperties', (t) => { }) const obj = [{ ap: { value: 'string' } }] - t.equal('[{"ap":{"value":"string"}}]', stringify(obj)) + t.assert.equal(stringify(obj), '[{"ap":{"value":"string"}}]') }) test('very nested additionalProperties', (t) => { @@ -240,7 +244,7 @@ test('very nested additionalProperties', (t) => { }) const obj = [{ ap: { nested: { moarNested: { finally: { value: 'str' } } } } }] - t.equal('[{"ap":{"nested":{"moarNested":{"finally":{"value":"str"}}}}}]', stringify(obj)) + t.assert.equal(stringify(obj), '[{"ap":{"nested":{"moarNested":{"finally":{"value":"str"}}}}}]') }) test('nested additionalProperties set to true', (t) => { @@ -257,7 +261,7 @@ test('nested additionalProperties set to true', (t) => { }) const obj = { ap: { value: 'string', someNumber: 42 } } - t.equal('{"ap":{"value":"string","someNumber":42}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"ap":{"value":"string","someNumber":42}}') }) test('field passed to fastSafeStringify as undefined should be removed', (t) => { @@ -274,7 +278,7 @@ test('field passed to fastSafeStringify as undefined should be removed', (t) => }) const obj = { ap: { value: 'string', someNumber: undefined } } - t.equal('{"ap":{"value":"string"}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"ap":{"value":"string"}}') }) test('property without type but with enum, will acts as additionalProperties', (t) => { @@ -290,7 +294,7 @@ test('property without type but with enum, will acts as additionalProperties', ( }) const obj = { ap: { additional: 'field' } } - t.equal('{"ap":{"additional":"field"}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"ap":{"additional":"field"}}') }) test('property without type but with enum, will acts as additionalProperties without overwriting', (t) => { @@ -307,7 +311,7 @@ test('property without type but with enum, will acts as additionalProperties wit }) const obj = { ap: { additional: 'field' } } - t.equal('{"ap":{}}', stringify(obj)) + t.assert.equal(stringify(obj), '{"ap":{}}') }) test('function and symbol references are not serialized as undefined', (t) => { @@ -324,5 +328,5 @@ test('function and symbol references are not serialized as undefined', (t) => { }) const obj = { str: 'x', test: 'test', meth: () => 'x', sym: Symbol('x') } - t.equal('{"str":"x","test":"test"}', stringify(obj)) + t.assert.equal(stringify(obj), '{"str":"x","test":"test"}') }) diff --git a/test/allof.test.js b/test/allof.test.js index 002da9bc..fe3d2c20 100644 --- a/test/allof.test.js +++ b/test/allof.test.js @@ -1,8 +1,65 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') +process.env.TZ = 'UTC' + +test('allOf: combine type and format ', (t) => { + t.plan(1) + + const schema = { + allOf: [ + { type: 'string' }, + { format: 'time' } + ] + } + const stringify = build(schema) + const date = new Date(1674263005800) + const value = stringify(date) + t.assert.equal(value, '"01:03:25"') +}) + +test('allOf: combine additional properties ', (t) => { + t.plan(1) + + const schema = { + allOf: [ + { type: 'object' }, + { + type: 'object', + additionalProperties: { type: 'boolean' } + } + ] + } + const stringify = build(schema) + const data = { property: true } + const value = stringify(data) + t.assert.equal(value, JSON.stringify(data)) +}) + +test('allOf: combine pattern properties', (t) => { + t.plan(1) + + const schema = { + allOf: [ + { type: 'object' }, + { + type: 'object', + patternProperties: { + foo: { + type: 'number' + } + } + } + ] + } + const stringify = build(schema) + const data = { foo: 42 } + const value = stringify(data) + t.assert.equal(value, JSON.stringify(data)) +}) + test('object with allOf and multiple schema on the allOf', (t) => { t.plan(4) @@ -44,7 +101,7 @@ test('object with allOf and multiple schema on the allOf', (t) => { id: 1 }) } catch (e) { - t.equal(e.message, '"name" is required!') + t.assert.equal(e.message, '"name" is required!') } try { @@ -52,19 +109,19 @@ test('object with allOf and multiple schema on the allOf', (t) => { name: 'string' }) } catch (e) { - t.equal(e.message, '"id" is required!') + t.assert.equal(e.message, '"id" is required!') } - t.equal(stringify({ + t.assert.equal(stringify({ id: 1, name: 'string' }), '{"name":"string","id":1}') - t.equal(stringify({ + t.assert.equal(stringify({ id: 1, name: 'string', tag: 'otherString' - }), '{"name":"string","tag":"otherString","id":1}') + }), '{"name":"string","id":1,"tag":"otherString"}') }) test('object with allOf and one schema on the allOf', (t) => { @@ -92,7 +149,7 @@ test('object with allOf and one schema on the allOf', (t) => { const value = stringify({ id: 1 }) - t.equal(value, '{"id":1}') + t.assert.equal(value, '{"id":1}') }) test('object with allOf and no schema on the allOf', (t) => { @@ -108,7 +165,7 @@ test('object with allOf and no schema on the allOf', (t) => { build(schema) t.fail() } catch (e) { - t.equal(e.message, 'schema is invalid: data/allOf must NOT have fewer than 1 items') + t.assert.equal(e.message, 'schema is invalid: data/allOf must NOT have fewer than 1 items') } }) @@ -160,7 +217,76 @@ test('object with nested allOfs', (t) => { id3: 3, id4: 4 // extra prop shouldn't be in result }) - t.equal(value, '{"id1":1,"id2":2,"id3":3}') + t.assert.equal(value, '{"id1":1,"id2":2,"id3":3}') +}) + +test('object with anyOf nested inside allOf', (t) => { + t.plan(1) + + const schema = { + title: 'object with anyOf nested inside allOf', + type: 'object', + allOf: [ + { + required: ['id1', 'obj'], + type: 'object', + properties: { + id1: { + type: 'integer' + }, + obj: { + type: 'object', + properties: { + nested: { type: 'string' } + } + } + } + }, + { + anyOf: [ + { + type: 'object', + properties: { + id2: { type: 'string' } + }, + required: ['id2'] + }, + { + type: 'object', + properties: { + id3: { + type: 'integer' + }, + nestedObj: { + type: 'object', + properties: { + nested: { type: 'string' } + } + } + }, + required: ['id3'] + }, + { + type: 'object', + properties: { + id4: { type: 'integer' } + }, + required: ['id4'] + } + ] + } + ] + } + + const stringify = build(schema) + const value = stringify({ + id1: 1, + id3: 3, + id4: 4, // extra prop shouldn't be in result + obj: { nested: 'yes' }, + nestedObj: { nested: 'yes' } + }) + t.assert.equal(value, '{"id1":1,"obj":{"nested":"yes"},"id3":3,"nestedObj":{"nested":"yes"}}') }) test('object with $ref in allOf', (t) => { @@ -191,7 +317,7 @@ test('object with $ref in allOf', (t) => { id1: 1, id2: 2 // extra prop shouldn't be in result }) - t.equal(value, '{"id1":1}') + t.assert.equal(value, '{"id1":1}') }) test('object with $ref and other object in allOf', (t) => { @@ -231,7 +357,7 @@ test('object with $ref and other object in allOf', (t) => { id2: 2, id3: 3 // extra prop shouldn't be in result }) - t.equal(value, '{"id1":1,"id2":2}') + t.assert.equal(value, '{"id1":1,"id2":2}') }) test('object with multiple $refs in allOf', (t) => { @@ -274,7 +400,7 @@ test('object with multiple $refs in allOf', (t) => { id2: 2, id3: 3 // extra prop shouldn't be in result }) - t.equal(value, '{"id1":1,"id2":2}') + t.assert.equal(value, '{"id1":1,"id2":2}') }) test('allOf with nested allOf in $ref', (t) => { @@ -326,7 +452,7 @@ test('allOf with nested allOf in $ref', (t) => { id3: 3, id4: 4 // extra prop shouldn't be in result }) - t.equal(value, '{"id1":1,"id2":2,"id3":3}') + t.assert.equal(value, '{"id1":1,"id2":2,"id3":3}') }) test('object with external $refs in allOf', (t) => { @@ -346,12 +472,14 @@ test('object with external $refs in allOf', (t) => { } }, second: { - id2: { - $id: '#id2', - type: 'object', - properties: { - id2: { - type: 'integer' + definitions: { + id2: { + $id: '#id2', + type: 'object', + properties: { + id2: { + type: 'integer' + } } } } @@ -366,7 +494,7 @@ test('object with external $refs in allOf', (t) => { $ref: 'first#/definitions/id1' }, { - $ref: 'second#id2' + $ref: 'second#/definitions/id2' } ] } @@ -377,5 +505,247 @@ test('object with external $refs in allOf', (t) => { id2: 2, id3: 3 // extra prop shouldn't be in result }) - t.equal(value, '{"id1":1,"id2":2}') + t.assert.equal(value, '{"id1":1,"id2":2}') +}) + +test('allof with local anchor reference', (t) => { + t.plan(1) + + const externalSchemas = { + Test: { + $id: 'Test', + definitions: { + Problem: { + type: 'object', + properties: { + type: { + type: 'string' + } + } + }, + ValidationFragment: { + type: 'string' + }, + ValidationErrorProblem: { + type: 'object', + allOf: [ + { + $ref: '#/definitions/Problem' + }, + { + type: 'object', + properties: { + validation: { + $ref: '#/definitions/ValidationFragment' + } + } + } + ] + } + } + } + } + + const schema = { $ref: 'Test#/definitions/ValidationErrorProblem' } + const stringify = build(schema, { schema: externalSchemas }) + const data = { type: 'foo', validation: 'bar' } + + t.assert.equal(stringify(data), JSON.stringify(data)) +}) + +test('allOf: multiple nested $ref properties', (t) => { + t.plan(2) + + const externalSchema1 = { + $id: 'externalSchema1', + oneOf: [ + { $ref: '#/definitions/id1' } + ], + definitions: { + id1: { + type: 'object', + properties: { + id1: { + type: 'integer' + } + }, + additionalProperties: false + } + } + } + + const externalSchema2 = { + $id: 'externalSchema2', + oneOf: [ + { $ref: '#/definitions/id2' } + ], + definitions: { + id2: { + type: 'object', + properties: { + id2: { + type: 'integer' + } + }, + additionalProperties: false + } + } + } + + const schema = { + anyOf: [ + { $ref: 'externalSchema1' }, + { $ref: 'externalSchema2' } + ] + } + + const stringify = build(schema, { schema: [externalSchema1, externalSchema2] }) + + t.assert.equal(stringify({ id1: 1 }), JSON.stringify({ id1: 1 })) + t.assert.equal(stringify({ id2: 2 }), JSON.stringify({ id2: 2 })) +}) + +test('allOf: throw Error if types mismatch ', (t) => { + t.plan(1) + + const schema = { + allOf: [ + { type: 'string' }, + { type: 'number' } + ] + } + t.assert.throws(() => { + build(schema) + }, { + message: 'Failed to merge "type" keyword schemas.', + schemas: [['string'], ['number']] + }) +}) + +test('allOf: throw Error if format mismatch ', (t) => { + t.plan(1) + + const schema = { + allOf: [ + { format: 'date' }, + { format: 'time' } + ] + } + t.assert.throws(() => { + build(schema) + }, { + message: 'Failed to merge "format" keyword schemas.' + // schemas: ['date', 'time'] + }) +}) + +test('recursive nested allOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + allOf: [{ $ref: '#' }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.assert.equal(stringify(data), JSON.stringify(data)) +}) + +test('recursive nested allOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + allOf: [{ allOf: [{ $ref: '#' }] }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.assert.equal(stringify(data), JSON.stringify(data)) +}) + +test('external recursive allOfs', (t) => { + t.plan(1) + + const externalSchema = { + type: 'object', + properties: { + foo: { + properties: { + bar: { type: 'string' } + }, + allOf: [{ $ref: '#' }] + } + } + } + + const schema = { + type: 'object', + properties: { + a: { $ref: 'externalSchema#/properties/foo' }, + b: { $ref: 'externalSchema#/properties/foo' } + } + } + + const data = { + a: { + foo: {}, + bar: '42', + baz: 42 + }, + b: { + foo: {}, + bar: '42', + baz: 42 + } + } + const stringify = build(schema, { schema: { externalSchema } }) + t.assert.equal(stringify(data), '{"a":{"bar":"42","foo":{}},"b":{"bar":"42","foo":{}}}') +}) + +test('do not crash with $ref prop', (t) => { + t.plan(1) + + const schema = { + title: 'object with $ref', + type: 'object', + properties: { + outside: { + $ref: '#/$defs/outside' + } + }, + $defs: { + inside: { + type: 'object', + properties: { + $ref: { + type: 'string' + } + } + }, + outside: { + allOf: [{ + $ref: '#/$defs/inside' + }] + } + } + } + const stringify = build(schema) + const value = stringify({ + outside: { + $ref: 'true' + } + }) + t.assert.equal(value, '{"outside":{"$ref":"true"}}') }) diff --git a/test/any.test.js b/test/any.test.js index 21a6fc15..a73aa0c1 100644 --- a/test/any.test.js +++ b/test/any.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('object with nested random property', (t) => { @@ -16,19 +16,19 @@ test('object with nested random property', (t) => { } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ id: 1, name: 'string' }), '{"id":1,"name":"string"}') - t.equal(stringify({ + t.assert.equal(stringify({ id: 1, name: { first: 'name', last: 'last' } }), '{"id":1,"name":{"first":"name","last":"last"}}') - t.equal(stringify({ + t.assert.equal(stringify({ id: 1, name: null }), '{"id":1,"name":null}') - t.equal(stringify({ + t.assert.equal(stringify({ id: 1, name: ['first', 'last'] }), '{"id":1,"name":["first","last"]}') }) @@ -45,7 +45,7 @@ test('object with empty schema with $id: undefined set', (t) => { } } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ name: 'string' }), '{"name":"string"}') }) @@ -61,7 +61,7 @@ test('array with random items', (t) => { const stringify = build(schema) const value = stringify([1, 'string', null]) - t.equal(value, '[1,"string",null]') + t.assert.equal(value, '[1,"string",null]') }) test('empty schema', (t) => { @@ -71,13 +71,13 @@ test('empty schema', (t) => { const stringify = build(schema) - t.equal(stringify(null), 'null') - t.equal(stringify(1), '1') - t.equal(stringify(true), 'true') - t.equal(stringify('hello'), '"hello"') - t.equal(stringify({}), '{}') - t.equal(stringify({ x: 10 }), '{"x":10}') - t.equal(stringify([true, 1, 'hello']), '[true,1,"hello"]') + t.assert.equal(stringify(null), 'null') + t.assert.equal(stringify(1), '1') + t.assert.equal(stringify(true), 'true') + t.assert.equal(stringify('hello'), '"hello"') + t.assert.equal(stringify({}), '{}') + t.assert.equal(stringify({ x: 10 }), '{"x":10}') + t.assert.equal(stringify([true, 1, 'hello']), '[true,1,"hello"]') }) test('empty schema on nested object', (t) => { @@ -92,13 +92,13 @@ test('empty schema on nested object', (t) => { const stringify = build(schema) - t.equal(stringify({ x: null }), '{"x":null}') - t.equal(stringify({ x: 1 }), '{"x":1}') - t.equal(stringify({ x: true }), '{"x":true}') - t.equal(stringify({ x: 'hello' }), '{"x":"hello"}') - t.equal(stringify({ x: {} }), '{"x":{}}') - t.equal(stringify({ x: { x: 10 } }), '{"x":{"x":10}}') - t.equal(stringify({ x: [true, 1, 'hello'] }), '{"x":[true,1,"hello"]}') + t.assert.equal(stringify({ x: null }), '{"x":null}') + t.assert.equal(stringify({ x: 1 }), '{"x":1}') + t.assert.equal(stringify({ x: true }), '{"x":true}') + t.assert.equal(stringify({ x: 'hello' }), '{"x":"hello"}') + t.assert.equal(stringify({ x: {} }), '{"x":{}}') + t.assert.equal(stringify({ x: { x: 10 } }), '{"x":{"x":10}}') + t.assert.equal(stringify({ x: [true, 1, 'hello'] }), '{"x":[true,1,"hello"]}') }) test('empty schema on array', (t) => { @@ -111,7 +111,7 @@ test('empty schema on array', (t) => { const stringify = build(schema) - t.equal(stringify([1, true, 'hello', [], { x: 1 }]), '[1,true,"hello",[],{"x":1}]') + t.assert.equal(stringify([1, true, 'hello', [], { x: 1 }]), '[1,true,"hello",[],{"x":1}]') }) test('empty schema on anyOf', (t) => { @@ -147,8 +147,85 @@ test('empty schema on anyOf', (t) => { const stringify = build(schema) - t.equal(stringify({ kind: 'Bar', value: 1 }), '{"kind":"Bar","value":1}') - t.equal(stringify({ kind: 'Foo', value: 1 }), '{"kind":"Foo","value":1}') - t.equal(stringify({ kind: 'Foo', value: true }), '{"kind":"Foo","value":true}') - t.equal(stringify({ kind: 'Foo', value: 'hello' }), '{"kind":"Foo","value":"hello"}') + t.assert.equal(stringify({ kind: 'Bar', value: 1 }), '{"kind":"Bar","value":1}') + t.assert.equal(stringify({ kind: 'Foo', value: 1 }), '{"kind":"Foo","value":1}') + t.assert.equal(stringify({ kind: 'Foo', value: true }), '{"kind":"Foo","value":true}') + t.assert.equal(stringify({ kind: 'Foo', value: 'hello' }), '{"kind":"Foo","value":"hello"}') +}) + +test('should throw a TypeError with the path to the key of the invalid value /1', (t) => { + t.plan(1) + + // any on Foo codepath. + const schema = { + anyOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + enum: ['Foo'] + }, + value: {} + } + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + enum: ['Bar'] + }, + value: { + type: 'number' + } + } + } + ] + } + + const stringify = build(schema) + + t.assert.throws(() => stringify({ kind: 'Baz', value: 1 }), new TypeError('The value of \'#\' does not match schema definition.')) +}) + +test('should throw a TypeError with the path to the key of the invalid value /2', (t) => { + t.plan(1) + + // any on Foo codepath. + const schema = { + type: 'object', + properties: { + data: { + anyOf: [ + { + type: 'object', + properties: { + kind: { + type: 'string', + enum: ['Foo'] + }, + value: {} + } + }, + { + type: 'object', + properties: { + kind: { + type: 'string', + enum: ['Bar'] + }, + value: { + type: 'number' + } + } + } + ] + } + } + } + + const stringify = build(schema) + + t.assert.throws(() => stringify({ data: { kind: 'Baz', value: 1 } }), new TypeError('The value of \'#/properties/data\' does not match schema definition.')) }) diff --git a/test/anyof.test.js b/test/anyof.test.js index 47f1dd73..baa81060 100644 --- a/test/anyof.test.js +++ b/test/anyof.test.js @@ -1,8 +1,10 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const build = require('..') +process.env.TZ = 'UTC' + test('object with multiple types field', (t) => { t.plan(2) @@ -21,11 +23,11 @@ test('object with multiple types field', (t) => { } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ str: 'string' }), '{"str":"string"}') - t.equal(stringify({ + t.assert.equal(stringify({ str: true }), '{"str":true}') }) @@ -53,11 +55,11 @@ test('object with field of type object or null', (t) => { } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ prop: null }), '{"prop":null}') - t.equal(stringify({ + t.assert.equal(stringify({ prop: { str: 'string' } @@ -87,13 +89,13 @@ test('object with field of type object or array', (t) => { } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ prop: { str: 'string' } }), '{"prop":{"str":"string"}}') - t.equal(stringify({ + t.assert.equal(stringify({ prop: ['string'] }), '{"prop":["string"]}') }) @@ -113,11 +115,7 @@ test('object with field of type string and coercion disable ', (t) => { } } const stringify = build(schema) - - const value = stringify({ - str: 1 - }) - t.equal(value, '{"str":null}') + t.assert.throws(() => stringify({ str: 1 })) }) test('object with field of type string and coercion enable ', (t) => { @@ -145,7 +143,7 @@ test('object with field of type string and coercion enable ', (t) => { const value = stringify({ str: 1 }) - t.equal(value, '{"str":"1"}') + t.assert.equal(value, '{"str":"1"}') }) test('object with field with type union of multiple objects', (t) => { @@ -179,9 +177,9 @@ test('object with field with type union of multiple objects', (t) => { const stringify = build(schema) - t.equal(stringify({ anyOfSchema: { baz: 5 } }), '{"anyOfSchema":{"baz":5}}') + t.assert.equal(stringify({ anyOfSchema: { baz: 5 } }), '{"anyOfSchema":{"baz":5}}') - t.equal(stringify({ anyOfSchema: { bar: 'foo' } }), '{"anyOfSchema":{"bar":"foo"}}') + t.assert.equal(stringify({ anyOfSchema: { bar: 'foo' } }), '{"anyOfSchema":{"bar":"foo"}}') }) test('null value in schema', (t) => { @@ -221,10 +219,10 @@ test('symbol value in schema', (t) => { } const stringify = build(schema) - t.equal(stringify({ value: 'foo' }), '{"value":"foo"}') - t.equal(stringify({ value: 'bar' }), '{"value":"bar"}') - t.equal(stringify({ value: 'baz' }), '{"value":"baz"}') - t.equal(stringify({ value: 'qux' }), '{"value":null}') + t.assert.equal(stringify({ value: 'foo' }), '{"value":"foo"}') + t.assert.equal(stringify({ value: 'bar' }), '{"value":"bar"}') + t.assert.equal(stringify({ value: 'baz' }), '{"value":"baz"}') + t.assert.throws(() => stringify({ value: 'qux' })) }) test('anyOf and $ref together', (t) => { @@ -253,9 +251,9 @@ test('anyOf and $ref together', (t) => { const stringify = build(schema) - t.equal(stringify({ cs: 'franco' }), '{"cs":"franco"}') + t.assert.equal(stringify({ cs: 'franco' }), '{"cs":"franco"}') - t.equal(stringify({ cs: true }), '{"cs":true}') + t.assert.equal(stringify({ cs: true }), '{"cs":true}') }) test('anyOf and $ref: 2 levels are fine', (t) => { @@ -291,7 +289,7 @@ test('anyOf and $ref: 2 levels are fine', (t) => { const stringify = build(schema) const value = stringify({ cs: 3 }) - t.equal(value, '{"cs":3}') + t.assert.equal(value, '{"cs":3}') }) test('anyOf and $ref: multiple levels should throw at build.', (t) => { @@ -330,9 +328,9 @@ test('anyOf and $ref: multiple levels should throw at build.', (t) => { const stringify = build(schema) - t.equal(stringify({ cs: 3 }), '{"cs":3}') - t.equal(stringify({ cs: true }), '{"cs":true}') - t.equal(stringify({ cs: 'pippo' }), '{"cs":"pippo"}') + t.assert.equal(stringify({ cs: 3 }), '{"cs":3}') + t.assert.equal(stringify({ cs: true }), '{"cs":true}') + t.assert.equal(stringify({ cs: 'pippo' }), '{"cs":"pippo"}') }) test('anyOf and $ref - multiple external $ref', (t) => { @@ -385,10 +383,8 @@ test('anyOf and $ref - multiple external $ref', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"prop":{"prop2":"test"}}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"prop":{"prop2":"test"}}}') }) test('anyOf looks for all of the array items', (t) => { @@ -423,7 +419,7 @@ test('anyOf looks for all of the array items', (t) => { const stringify = build(schema) const value = stringify([{ savedId: 'great' }, { error: 'oops' }]) - t.equal(value, '[{"savedId":"great"},{"error":"oops"}]') + t.assert.equal(value, '[{"savedId":"great"},{"error":"oops"}]') }) test('anyOf with enum with more than 100 entries', (t) => { @@ -445,7 +441,7 @@ test('anyOf with enum with more than 100 entries', (t) => { const stringify = build(schema) const value = stringify(['EUR', 'USD', null]) - t.equal(value, '["EUR","USD",null]') + t.assert.equal(value, '["EUR","USD",null]') }) test('anyOf object with field date-time of type string with format or null', (t) => { @@ -467,11 +463,94 @@ test('anyOf object with field date-time of type string with format or null', (t) const withOneOfStringify = build(withOneOfSchema) - t.equal(withOneOfStringify({ + t.assert.equal(withOneOfStringify({ prop: toStringify }), `{"prop":"${toStringify.toISOString()}"}`) }) +test('anyOf object with nested field date-time of type string with format or null', (t) => { + t.plan(1) + const withOneOfSchema = { + type: 'object', + properties: { + prop: { + anyOf: [{ + type: 'object', + properties: { + nestedProp: { + type: 'string', + format: 'date-time' + } + } + }] + } + } + } + + const withOneOfStringify = build(withOneOfSchema) + + const data = { + prop: { nestedProp: new Date() } + } + + t.assert.equal(withOneOfStringify(data), JSON.stringify(data)) +}) + +test('anyOf object with nested field date of type string with format or null', (t) => { + t.plan(1) + const withOneOfSchema = { + type: 'object', + properties: { + prop: { + anyOf: [{ + type: 'object', + properties: { + nestedProp: { + type: 'string', + format: 'date' + } + } + }] + } + } + } + + const withOneOfStringify = build(withOneOfSchema) + + const data = { + prop: { nestedProp: new Date(1674263005800) } + } + + t.assert.equal(withOneOfStringify(data), '{"prop":{"nestedProp":"2023-01-21"}}') +}) + +test('anyOf object with nested field time of type string with format or null', (t) => { + t.plan(1) + const withOneOfSchema = { + type: 'object', + properties: { + prop: { + anyOf: [{ + type: 'object', + properties: { + nestedProp: { + type: 'string', + format: 'time' + } + } + }] + } + } + } + + const withOneOfStringify = build(withOneOfSchema) + + const data = { + prop: { nestedProp: new Date(1674263005800) } + } + t.assert.equal(withOneOfStringify(data), '{"prop":{"nestedProp":"01:03:25"}}') +}) + test('anyOf object with field date of type string with format or null', (t) => { t.plan(1) const toStringify = '2011-01-01' @@ -490,7 +569,7 @@ test('anyOf object with field date of type string with format or null', (t) => { } const withOneOfStringify = build(withOneOfSchema) - t.equal(withOneOfStringify({ + t.assert.equal(withOneOfStringify({ prop: toStringify }), '{"prop":"2011-01-01"}') }) @@ -513,7 +592,201 @@ test('anyOf object with invalid field date of type string with format or null', } const withOneOfStringify = build(withOneOfSchema) - t.equal(withOneOfStringify({ - prop: toStringify - }), '{"prop":null}') + t.assert.throws(() => withOneOfStringify({ prop: toStringify })) +}) + +test('anyOf with a nested external schema', (t) => { + t.plan(1) + + const externalSchemas = { + schema1: { + definitions: { + def1: { + $id: 'external', + type: 'string' + } + }, + type: 'number' + } + } + const schema = { anyOf: [{ $ref: 'external' }] } + + const stringify = build(schema, { schema: externalSchemas }) + t.assert.equal(stringify('foo'), '"foo"') +}) + +test('object with ref and validated properties', (t) => { + t.plan(1) + + const externalSchemas = { + RefSchema: { + $id: 'RefSchema', + type: 'string' + } + } + + const schema = { + $id: 'root', + type: 'object', + properties: { + id: { + anyOf: [ + { type: 'string' }, + { type: 'number' } + ] + }, + reference: { $ref: 'RefSchema' } + } + } + + const stringify = build(schema, { schema: externalSchemas }) + t.assert.equal(stringify({ id: 1, reference: 'hi' }), '{"id":1,"reference":"hi"}') +}) + +test('anyOf required props', (t) => { + t.plan(3) + + const schema = { + type: 'object', + properties: { + prop1: { type: 'string' }, + prop2: { type: 'string' }, + prop3: { type: 'string' } + }, + required: ['prop1'], + anyOf: [{ required: ['prop2'] }, { required: ['prop3'] }] + } + const stringify = build(schema) + t.assert.equal(stringify({ prop1: 'test', prop2: 'test2' }), '{"prop1":"test","prop2":"test2"}') + t.assert.equal(stringify({ prop1: 'test', prop3: 'test3' }), '{"prop1":"test","prop3":"test3"}') + t.assert.equal(stringify({ prop1: 'test', prop2: 'test2', prop3: 'test3' }), '{"prop1":"test","prop2":"test2","prop3":"test3"}') +}) + +test('anyOf required props', (t) => { + t.plan(3) + + const schema = { + type: 'object', + properties: { + prop1: { type: 'string' } + }, + anyOf: [ + { + properties: { + prop2: { type: 'string' } + } + }, + { + properties: { + prop3: { type: 'string' } + } + } + ] + } + const stringify = build(schema) + t.assert.equal(stringify({ prop1: 'test1' }), '{"prop1":"test1"}') + t.assert.equal(stringify({ prop2: 'test2' }), '{"prop2":"test2"}') + t.assert.equal(stringify({ prop1: 'test1', prop2: 'test2' }), '{"prop1":"test1","prop2":"test2"}') +}) + +test('recursive nested anyOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + anyOf: [{ $ref: '#' }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.assert.equal(stringify(data), JSON.stringify(data)) +}) + +test('recursive nested anyOfs', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + additionalProperties: false, + anyOf: [{ anyOf: [{ $ref: '#' }] }] + } + } + } + + const data = { foo: {} } + const stringify = build(schema) + t.assert.equal(stringify(data), JSON.stringify(data)) +}) + +test('external recursive anyOfs', (t) => { + t.plan(1) + + const externalSchema = { + type: 'object', + properties: { + foo: { + properties: { + bar: { type: 'string' } + }, + anyOf: [{ $ref: '#' }] + } + } + } + + const schema = { + type: 'object', + properties: { + a: { $ref: 'externalSchema#/properties/foo' }, + b: { $ref: 'externalSchema#/properties/foo' } + } + } + + const data = { + a: { + foo: {}, + bar: '42', + baz: 42 + }, + b: { + foo: {}, + bar: '42', + baz: 42 + } + } + const stringify = build(schema, { schema: { externalSchema } }) + t.assert.equal(stringify(data), '{"a":{"bar":"42","foo":{}},"b":{"bar":"42","foo":{}}}') +}) + +test('should build merged schemas twice', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + enums: { + type: 'string', + anyOf: [ + { type: 'string', const: 'FOO' }, + { type: 'string', const: 'BAR' } + ] + } + } + } + + { + const stringify = build(schema) + t.assert.equal(stringify({ enums: 'FOO' }), '{"enums":"FOO"}') + } + + { + const stringify = build(schema) + t.assert.equal(stringify({ enums: 'BAR' }), '{"enums":"BAR"}') + } }) diff --git a/test/array.test.js b/test/array.test.js index 92431f88..dc643226 100644 --- a/test/array.test.js +++ b/test/array.test.js @@ -1,24 +1,66 @@ 'use strict' -const moment = require('moment') -const test = require('tap').test +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') +const Ajv = require('ajv') -function buildTest (schema, toStringify) { +test('error on invalid largeArrayMechanism', (t) => { + t.plan(1) + + t.assert.throws(() => build({ + title: 'large array of null values with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'null' } + } + } + }, { + largeArraySize: 2e4, + largeArrayMechanism: 'invalid' + }), Error('Unsupported large array mechanism invalid')) +}) + +function buildTest (schema, toStringify, options) { test(`render a ${schema.title} as JSON`, (t) => { t.plan(3) const validate = validator(schema) - const stringify = build(schema) + const stringify = build(schema, options) const output = stringify(toStringify) - t.same(JSON.parse(output), toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.deepStrictEqual(JSON.parse(output), JSON.parse(JSON.stringify(toStringify))) + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) } +buildTest({ + title: 'dates tuple', + type: 'object', + properties: { + dates: { + type: 'array', + minItems: 2, + maxItems: 2, + items: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'string', + format: 'date-time' + } + ] + } + } +}, { + dates: [new Date(1), new Date(2)] +}) + buildTest({ title: 'string array', type: 'object', @@ -174,28 +216,7 @@ test('invalid items throw', (t) => { } } const stringify = build(schema) - t.throws(() => stringify({ args: ['invalid'] })) -}) - -test('moment array', (t) => { - t.plan(1) - const schema = { - type: 'object', - properties: { - times: { - type: 'array', - items: { - type: 'string', - format: 'date-time' - } - } - } - } - const stringify = build(schema) - const value = stringify({ - times: [moment('2018-04-21T07:52:31.017Z')] - }) - t.equal(value, '{"times":["2018-04-21T07:52:31.017Z"]}') + t.assert.throws(() => stringify({ args: ['invalid'] })) }) buildTest({ @@ -237,10 +258,10 @@ test('array items is a list of schema and additionalItems is true, just the desc ] }) - t.equal(result, '{"foo":["foo","bar",1]}') + t.assert.equal(result, '{"foo":["foo","bar",1]}') }) -test('array items is a list of schema and additionalItems is false', (t) => { +test('array items is a list of schema and additionalItems is true, just the described item is validated', (t) => { t.plan(1) const schema = { @@ -251,26 +272,90 @@ test('array items is a list of schema and additionalItems is false', (t) => { items: [ { type: 'string' + }, + { + type: 'number' } ], + additionalItems: true + } + } + } + + const stringify = build(schema) + const result = stringify({ + foo: ['foo'] + }) + + t.assert.equal(result, '{"foo":["foo"]}') +}) + +test('array items is a list of schema and additionalItems is false /1', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + foo: { + type: 'array', + items: [ + { type: 'string' } + ], additionalItems: false } } } const stringify = build(schema) + t.assert.throws(() => stringify({ foo: ['foo', 'bar'] }), new Error('Item at 1 does not match schema definition.')) +}) - try { - stringify({ - foo: [ - 'foo', - 'bar' - ] - }) - t.fail() - } catch (error) { - t.ok(/does not match schema definition./.test(error.message)) +test('array items is a list of schema and additionalItems is false /2', (t) => { + t.plan(3) + + const schema = { + type: 'object', + properties: { + foo: { + type: 'array', + items: [ + { type: 'string' }, + { type: 'string' } + ], + additionalItems: false + } + } + } + + const stringify = build(schema) + + t.assert.throws(() => stringify({ foo: [1, 'bar'] }), new Error('Item at 0 does not match schema definition.')) + t.assert.throws(() => stringify({ foo: ['foo', 1] }), new Error('Item at 1 does not match schema definition.')) + t.assert.throws(() => stringify({ foo: ['foo', 'bar', 'baz'] }), new Error('Item at 2 does not match schema definition.')) +}) + +test('array items is a schema and additionalItems is false', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { + type: 'array', + items: { type: 'string' }, + additionalItems: false + } + } } + + const stringify = build(schema) + + // ajv ignores additionalItems if items is not an Array + const ajv = new Ajv({ allErrors: true, strict: false }) + + const validate = ajv.compile(schema) + t.assert.equal(stringify({ foo: ['foo', 'bar'] }), '{"foo":["foo","bar"]}') + t.assert.equal(validate({ foo: ['foo', 'bar'] }), true) }) // https://github.com/fastify/fast-json-stringify/issues/279 @@ -317,5 +402,237 @@ test('object array with anyOf and symbol', (t) => { { name: 'name-0', option: 'Foo' }, { name: 'name-1', option: 'Bar' } ]) - t.equal(value, '[{"name":"name-0","option":"Foo"},{"name":"name-1","option":"Bar"}]') + t.assert.equal(value, '[{"name":"name-0","option":"Foo"},{"name":"name-1","option":"Bar"}]') +}) + +test('different arrays with same item schemas', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + array1: { + type: 'array', + items: [{ type: 'string' }], + additionalItems: false + }, + array2: { + type: 'array', + items: { $ref: '#/properties/array1/items' }, + additionalItems: true + } + } + } + + const stringify = build(schema) + const data = { array1: ['bar'], array2: ['foo', 'bar'] } + + t.assert.equal(stringify(data), '{"array1":["bar"],"array2":["foo","bar"]}') +}) + +const largeArray = new Array(2e4).fill({ a: 'test', b: 1 }) +buildTest({ + title: 'large array with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' } + } + } + } + } +}, { + ids: largeArray +}, { + largeArraySize: 2e4, + largeArrayMechanism: 'default' +}) + +buildTest({ + title: 'large array of objects with json-stringify mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'object', + properties: { + a: { type: 'string' }, + b: { type: 'number' } + } + } + } + } +}, { + ids: largeArray +}, { + largeArrayMechanism: 'json-stringify' +}) + +buildTest({ + title: 'large array of strings with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'string' } + } + } +}, { + ids: new Array(2e4).fill('string') +}, { + largeArraySize: 2e4, + largeArrayMechanism: 'default' +}) + +buildTest({ + title: 'large array of numbers with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'number' } + } + } +}, { + ids: new Array(2e4).fill(42) +}, { + largeArraySize: 2e4, + largeArrayMechanism: 'default' +}) + +buildTest({ + title: 'large array of integers with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'integer' } + } + } +}, { + ids: new Array(2e4).fill(42) +}, { + largeArraySize: 2e4, + largeArrayMechanism: 'default' +}) + +buildTest({ + title: 'large array of booleans with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'boolean' } + } + } +}, { + ids: new Array(2e4).fill(true) +}, { + largeArraySize: 2e4, + largeArrayMechanism: 'default' +}) + +buildTest({ + title: 'large array of null values with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'null' } + } + } +}, { + ids: new Array(2e4).fill(null) +}, { + largeArraySize: 2e4, + largeArrayMechanism: 'default' +}) + +test('error on invalid value for largeArraySize /1', (t) => { + t.plan(1) + + t.assert.throws(() => build({ + title: 'large array of null values with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'null' } + } + } + }, { + largeArraySize: 'invalid' + }), Error('Unsupported large array size. Expected integer-like, got string with value invalid')) +}) + +test('error on invalid value for largeArraySize /2', (t) => { + t.plan(1) + + t.assert.throws(() => build({ + title: 'large array of null values with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'null' } + } + } + }, { + largeArraySize: Infinity + }), Error('Unsupported large array size. Expected integer-like, got number with value Infinity')) +}) + +test('error on invalid value for largeArraySize /3', (t) => { + t.plan(1) + + t.assert.throws(() => build({ + title: 'large array of null values with default mechanism', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'null' } + } + } + }, { + largeArraySize: [200] + }), Error('Unsupported large array size. Expected integer-like, got object with value 200')) +}) + +buildTest({ + title: 'large array of integers with largeArraySize is bigint', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'integer' } + } + } +}, { + ids: new Array(2e4).fill(42) +}, { + largeArraySize: 20000n, + largeArrayMechanism: 'default' +}) + +buildTest({ + title: 'large array of integers with largeArraySize is valid string', + type: 'object', + properties: { + ids: { + type: 'array', + items: { type: 'integer' } + } + } +}, { + ids: new Array(1e4).fill(42) +}, { + largeArraySize: '10000', + largeArrayMechanism: 'default' }) diff --git a/test/asNumber.test.js b/test/asNumber.test.js new file mode 100644 index 00000000..94a40b43 --- /dev/null +++ b/test/asNumber.test.js @@ -0,0 +1,13 @@ +'use strict' + +const { test } = require('node:test') + +test('asNumber should convert BigInt', (t) => { + t.plan(1) + const Serializer = require('../lib/serializer') + const serializer = new Serializer() + + const number = serializer.asNumber(11753021440n) + + t.assert.equal(number, '11753021440') +}) diff --git a/test/basic.test.js b/test/basic.test.js index 756913b4..754f4489 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') @@ -12,12 +12,18 @@ function buildTest (schema, toStringify) { const stringify = build(schema) const output = stringify(toStringify) - t.same(JSON.parse(output), toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.deepStrictEqual(JSON.parse(output), toStringify) + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) } +buildTest({ + title: 'string', + type: 'string', + format: 'unsafe' +}, 'hello world') + buildTest({ title: 'basic', type: 'object', @@ -261,7 +267,7 @@ buildTest({ readonly: true }) -test('skip or coerce numbers and integers that are not numbers', (t) => { +test('throw an error or coerce numbers and integers that are not numbers', (t) => { const stringify = build({ title: 'basic', type: 'object', @@ -275,41 +281,34 @@ test('skip or coerce numbers and integers that are not numbers', (t) => { } }) - let result = stringify({ - age: 'hello ', - distance: 'long' - }) - - t.same(JSON.parse(result), {}) + t.assert.throws(() => { + stringify({ age: 'hello ', distance: 'long' }) + }, { message: 'The value "hello " cannot be converted to a number.' }) - result = stringify({ + const result = stringify({ age: '42', distance: true }) - t.same(JSON.parse(result), { age: 42, distance: 1 }) - t.end() + t.assert.deepStrictEqual(JSON.parse(result), { age: 42, distance: 1 }) }) test('Should throw on invalid schema', t => { t.plan(1) - try { + t.assert.throws(() => { build({ type: 'Dinosaur', properties: { claws: { type: 'sharp' } } }) - t.fail('should be an invalid schema') - } catch (err) { - t.ok(err) - } + }, { message: 'schema is invalid: data/properties/claws/type must be equal to one of the allowed values' }) }) test('additionalProperties - throw on unknown type', (t) => { t.plan(1) - try { + t.assert.throws(() => { build({ title: 'check array coerce', type: 'object', @@ -319,15 +318,13 @@ test('additionalProperties - throw on unknown type', (t) => { } }) t.fail('should be an invalid schema') - } catch (err) { - t.ok(err) - } + }, { message: 'schema is invalid: data/additionalProperties/type must be equal to one of the allowed values' }) }) test('patternProperties - throw on unknown type', (t) => { t.plan(1) - try { + t.assert.throws(() => { build({ title: 'check array coerce', type: 'object', @@ -338,13 +335,10 @@ test('patternProperties - throw on unknown type', (t) => { } } }) - t.fail('should be an invalid schema') - } catch (err) { - t.ok(err) - } + }, { message: 'schema is invalid: data/patternProperties/foo/type must be equal to one of the allowed values' }) }) -test('render a single quote as JSON', (t) => { +test('render a double quote as JSON /1', (t) => { t.plan(2) const schema = { @@ -356,6 +350,51 @@ test('render a single quote as JSON', (t) => { const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('render a double quote as JSON /2', (t) => { + t.plan(2) + + const schema = { + type: 'string' + } + const toStringify = 'double quote " 2' + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify(toStringify) + + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('render a long string', (t) => { + t.plan(2) + + const schema = { + type: 'string' + } + const toStringify = 'the Ultimate Question of Life, the Universe, and Everything.' + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify(toStringify) + + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('returns JSON.stringify if schema type is boolean', t => { + t.plan(1) + + const schema = { + type: 'array', + items: true + } + + const array = [1, true, 'test'] + const stringify = build(schema) + t.assert.equal(stringify(array), JSON.stringify(array)) }) diff --git a/test/bigint.test.js b/test/bigint.test.js index 02a75d4d..86bb4fa8 100644 --- a/test/bigint.test.js +++ b/test/bigint.test.js @@ -1,7 +1,7 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') + const build = require('..') test('render a bigint as JSON', (t) => { @@ -15,7 +15,7 @@ test('render a bigint as JSON', (t) => { const stringify = build(schema) const output = stringify(1615n) - t.equal(output, '1615') + t.assert.equal(output, '1615') }) test('render an object with a bigint as JSON', (t) => { @@ -36,7 +36,7 @@ test('render an object with a bigint as JSON', (t) => { id: 1615n }) - t.equal(output, '{"id":1615}') + t.assert.equal(output, '{"id":1615}') }) test('render an array with a bigint as JSON', (t) => { @@ -53,7 +53,7 @@ test('render an array with a bigint as JSON', (t) => { const stringify = build(schema) const output = stringify([1615n]) - t.equal(output, '[1615]') + t.assert.equal(output, '[1615]') }) test('render an object with an additionalProperty of type bigint as JSON', (t) => { @@ -72,5 +72,5 @@ test('render an object with an additionalProperty of type bigint as JSON', (t) = num: 1615n }) - t.equal(output, '{"num":1615}') + t.assert.equal(output, '{"num":1615}') }) diff --git a/test/clean-cache.test.js b/test/clean-cache.test.js index f7b3ef1d..958fbcaa 100644 --- a/test/clean-cache.test.js +++ b/test/clean-cache.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('Should clean the cache', (t) => { @@ -11,9 +11,10 @@ test('Should clean the cache', (t) => { type: 'string' } - build(schema) - build(schema) - t.pass() + t.assert.doesNotThrow(() => { + build(schema) + build(schema) + }) }) test('Should clean the cache with external schemas', (t) => { @@ -39,7 +40,8 @@ test('Should clean the cache with external schemas', (t) => { } } - build(schema) - build(schema) - t.pass() + t.assert.doesNotThrow(() => { + build(schema) + build(schema) + }) }) diff --git a/test/const.test.js b/test/const.test.js index 72def12f..48e10525 100644 --- a/test/const.test.js +++ b/test/const.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') @@ -20,8 +20,206 @@ test('schema with const string', (t) => { foo: 'bar' }) - t.equal(output, '{"foo":"bar"}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"foo":"bar"}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const string and different input', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: 'bar' } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: 'baz' + }) + + t.assert.equal(output, '{"foo":"bar"}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const string and different type input', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: 'bar' } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: 1 + }) + + t.assert.equal(output, '{"foo":"bar"}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const string and no input', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: 'bar' } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({}) + + t.assert.equal(output, '{}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const string that contains \'', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: "'bar'" } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: "'bar'" + }) + + t.assert.equal(output, '{"foo":"\'bar\'"}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const number', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: 1 } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: 1 + }) + + t.assert.equal(output, '{"foo":1}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const number and different input', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: 1 } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: 2 + }) + + t.assert.equal(output, '{"foo":1}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const bool', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: true } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: true + }) + + t.assert.equal(output, '{"foo":true}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const number', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: 1 } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: 1 + }) + + t.assert.equal(output, '{"foo":1}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const null', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: null } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: null + }) + + t.assert.equal(output, '{"foo":null}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const array', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + foo: { const: [1, 2, 3] } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: [1, 2, 3] + }) + + t.assert.equal(output, '{"foo":[1,2,3]}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('schema with const object', (t) => { @@ -40,8 +238,58 @@ test('schema with const object', (t) => { foo: { bar: 'baz' } }) - t.equal(output, '{"foo":{"bar":"baz"}}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"foo":{"bar":"baz"}}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) + +test('schema with const and null as type', (t) => { + t.plan(4) + + const schema = { + type: 'object', + properties: { + foo: { type: ['string', 'null'], const: 'baz' } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: null + }) + + t.assert.equal(output, '{"foo":null}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') + + const output2 = stringify({ foo: 'baz' }) + t.assert.equal(output2, '{"foo":"baz"}') + t.assert.ok(validate(JSON.parse(output2)), 'valid schema') +}) + +test('schema with const as nullable', (t) => { + t.plan(4) + + const schema = { + type: 'object', + properties: { + foo: { nullable: true, const: 'baz' } + } + } + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify({ + foo: null + }) + + t.assert.equal(output, '{"foo":null}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') + + const output2 = stringify({ + foo: 'baz' + }) + t.assert.equal(output2, '{"foo":"baz"}') + t.assert.ok(validate(JSON.parse(output2)), 'valid schema') }) test('schema with const and invalid object', (t) => { @@ -55,13 +303,12 @@ test('schema with const and invalid object', (t) => { required: ['foo'] } + const validate = validator(schema) const stringify = build(schema) - try { - stringify({ - foo: { foo: 'baz' } - }) - } catch (err) { - t.match(err.message, /^Item .* does not match schema definition/, 'Given object has invalid const value') - t.ok(err) - } + const result = stringify({ + foo: { foo: 'baz' } + }) + + t.assert.equal(result, '{"foo":{"foo":"bar"}}') + t.assert.ok(validate(JSON.parse(result)), 'valid schema') }) diff --git a/test/date.test.js b/test/date.test.js index c092616a..3d143fa8 100644 --- a/test/date.test.js +++ b/test/date.test.js @@ -1,10 +1,11 @@ 'use strict' -const test = require('tap').test -const moment = require('moment') +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') +process.env.TZ = 'UTC' + test('render a date in a string as JSON', (t) => { t.plan(2) @@ -12,14 +13,14 @@ test('render a date in a string as JSON', (t) => { title: 'a date in a string', type: 'string' } - const toStringify = new Date() + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render a date in a string when format is date-format as ISOString', (t) => { @@ -30,35 +31,36 @@ test('render a date in a string when format is date-format as ISOString', (t) => type: 'string', format: 'date-time' } - const toStringify = new Date() + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('render a date in a string when format is date as YYYY-MM-DD', (t) => { +test('render a nullable date in a string when format is date-format as ISOString', (t) => { t.plan(2) const schema = { title: 'a date in a string', type: 'string', - format: 'date' + format: 'date-time', + nullable: true } - const toStringify = new Date() + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, `"${moment(toStringify).format('YYYY-MM-DD')}"`) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('verify padding for rendered date in a string when format is date', (t) => { +test('render a date in a string when format is date as YYYY-MM-DD', (t) => { t.plan(2) const schema = { @@ -66,38 +68,54 @@ test('verify padding for rendered date in a string when format is date', (t) => type: 'string', format: 'date' } - const toStringify = new Date(2020, 0, 1, 0, 0, 0, 0) + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, `"${moment(toStringify).format('YYYY-MM-DD')}"`) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '"2023-01-21"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('render a date in a string when format is time as kk:mm:ss', (t) => { - t.plan(3) +test('render a nullable date in a string when format is date as YYYY-MM-DD', (t) => { + t.plan(2) const schema = { title: 'a date in a string', type: 'string', - format: 'time' + format: 'date', + nullable: true } - const toStringify = new Date() + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - validate(JSON.parse(output)) - t.equal(validate.errors, null) + t.assert.equal(output, '"2023-01-21"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') +}) - t.equal(output, `"${moment(toStringify).format('HH:mm:ss')}"`) - t.ok(validate(JSON.parse(output)), 'valid schema') +test('verify padding for rendered date in a string when format is date', (t) => { + t.plan(2) + + const schema = { + title: 'a date in a string', + type: 'string', + format: 'date' + } + const toStringify = new Date(2020, 0, 1, 0, 0, 0, 0) + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify(toStringify) + + t.assert.equal(output, '"2020-01-01"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('verify padding for rendered date in a string when format is time', (t) => { +test('render a date in a string when format is time as kk:mm:ss', (t) => { t.plan(3) const schema = { @@ -105,71 +123,81 @@ test('verify padding for rendered date in a string when format is time', (t) => type: 'string', format: 'time' } - const toStringify = new Date(2020, 0, 1, 1, 1, 1, 1) + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) validate(JSON.parse(output)) - t.equal(validate.errors, null) + t.assert.equal(validate.errors, null) - t.equal(output, `"${moment(toStringify).format('HH:mm:ss')}"`) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '"01:03:25"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('render a moment.js instance in a string when format is date-time as ISOString', (t) => { - t.plan(2) +test('render a nullable date in a string when format is time as kk:mm:ss', (t) => { + t.plan(3) const schema = { - title: 'a moment.js object in a string', + title: 'a date in a string', type: 'string', - format: 'date-time' + format: 'time', + nullable: true } - const toStringify = moment() + const toStringify = new Date(1674263005800) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + validate(JSON.parse(output)) + t.assert.equal(validate.errors, null) + + t.assert.equal(output, '"01:03:25"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('render a moment.js instance in a string when format is date as YYYY-MM-DD', (t) => { - t.plan(2) +test('render a midnight time', (t) => { + t.plan(3) const schema = { - title: 'a moment.js object in a string', + title: 'a date in a string', type: 'string', - format: 'date' + format: 'time' } - const toStringify = moment() + const midnight = new Date(new Date(1674263005800).setHours(24)) const validate = validator(schema) const stringify = build(schema) - const output = stringify(toStringify) + const output = stringify(midnight) - t.equal(output, `"${toStringify.format('YYYY-MM-DD')}"`) - t.ok(validate(JSON.parse(output)), 'valid schema') + validate(JSON.parse(output)) + t.assert.equal(validate.errors, null) + + t.assert.equal(output, '"00:03:25"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('render a moment.js instance in a string when format is time as HH:mm:ss', (t) => { - t.plan(2) +test('verify padding for rendered date in a string when format is time', (t) => { + t.plan(3) const schema = { - title: 'a moment.js object in a string', + title: 'a date in a string', type: 'string', format: 'time' } - const toStringify = moment() + const toStringify = new Date(2020, 0, 1, 1, 1, 1, 1) const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, `"${toStringify.format('HH:mm:ss')}"`) - t.ok(validate(JSON.parse(output)), 'valid schema') + validate(JSON.parse(output)) + t.assert.equal(validate.errors, null) + + t.assert.equal(output, '"01:01:01"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render a nested object in a string when type is date-format as ISOString', (t) => { @@ -185,17 +213,17 @@ test('render a nested object in a string when type is date-format as ISOString', } } } - const toStringify = { date: moment() } + const toStringify = { date: new Date(1674263005800) } const validate = validator(schema) const stringify = build(schema) const output = stringify(toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) -test('serializing null value', t => { +test('serializing null value', async t => { const input = { updatedAt: null } function createSchema (properties) { @@ -219,10 +247,10 @@ test('serializing null value', t => { t.plan(3) - t.test('type::string', t => { + await t.test('type::string', async t => { t.plan(3) - t.test('format::date-time', t => { + await t.test('format::date-time', t => { t.plan(2) const prop = { @@ -237,11 +265,11 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":""}') - t.notOk(validate(JSON.parse(output)), 'an empty string is not a date-time format') + t.assert.equal(output, '{"updatedAt":""}') + t.assert.equal(validate(JSON.parse(output)), false, 'an empty string is not a date-time format') }) - t.test('format::date', t => { + await t.test('format::date', t => { t.plan(2) const prop = { @@ -256,11 +284,11 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":""}') - t.notOk(validate(JSON.parse(output)), 'an empty string is not a date format') + t.assert.equal(output, '{"updatedAt":""}') + t.assert.equal(validate(JSON.parse(output)), false, 'an empty string is not a date format') }) - t.test('format::time', t => { + await t.test('format::time', t => { t.plan(2) const prop = { @@ -275,15 +303,15 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":""}') - t.notOk(validate(JSON.parse(output)), 'an empty string is not a time format') + t.assert.equal(output, '{"updatedAt":""}') + t.assert.equal(validate(JSON.parse(output)), false, 'an empty string is not a time format') }) }) - t.test('type::array', t => { - t.plan(3) + await t.test('type::array', async t => { + t.plan(6) - t.test('format::date-time', t => { + await t.test('format::date-time', t => { t.plan(2) const prop = { @@ -298,11 +326,11 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":""}') - t.notOk(validate(JSON.parse(output)), 'an empty string is not a date-time format') + t.assert.equal(output, '{"updatedAt":""}') + t.assert.equal(validate(JSON.parse(output)), false, 'an empty string is not a date-time format') }) - t.test('format::date', t => { + await t.test('format::date', t => { t.plan(2) const prop = { @@ -317,17 +345,17 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":""}') - t.notOk(validate(JSON.parse(output)), 'an empty string is not a date format') + t.assert.equal(output, '{"updatedAt":""}') + t.assert.equal(validate(JSON.parse(output)), false, 'an empty string is not a date format') }) - t.test('format::time', t => { + await t.test('format::date', t => { t.plan(2) const prop = { updatedAt: { type: ['string'], - format: 'time' + format: 'date' } } @@ -336,15 +364,74 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":""}') - t.notOk(validate(JSON.parse(output)), 'an empty string is not a time format') + t.assert.equal(output, '{"updatedAt":""}') + t.assert.equal(validate(JSON.parse(output)), false, 'an empty string is not a date format') + }) + + await t.test('format::time, Date object', t => { + t.plan(1) + + const schema = { + oneOf: [ + { + type: 'object', + properties: { + updatedAt: { + type: ['string', 'number'], + format: 'time' + } + } + } + ] + } + + const date = new Date(1674263005800) + const input = { updatedAt: date } + const { output } = serialize(schema, input) + + t.assert.equal(output, JSON.stringify({ updatedAt: '01:03:25' })) + }) + + await t.test('format::time, Date object', t => { + t.plan(1) + + const schema = { + oneOf: [ + { + type: ['string', 'number'], + format: 'time' + } + ] + } + + const date = new Date(1674263005800) + const { output } = serialize(schema, date) + + t.assert.equal(output, '"01:03:25"') + }) + + await t.test('format::time, Date object', t => { + t.plan(1) + + const schema = { + oneOf: [ + { + type: ['string', 'number'], + format: 'time' + } + ] + } + + const { output } = serialize(schema, 42) + + t.assert.equal(output, JSON.stringify(42)) }) }) - t.test('type::array::nullable', t => { + await t.test('type::array::nullable', async t => { t.plan(3) - t.test('format::date-time', t => { + await t.test('format::date-time', t => { t.plan(2) const prop = { @@ -359,11 +446,11 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":null}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"updatedAt":null}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) - t.test('format::date', t => { + await t.test('format::date', t => { t.plan(2) const prop = { @@ -378,11 +465,11 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":null}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"updatedAt":null}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) - t.test('format::time', t => { + await t.test('format::time', t => { t.plan(2) const prop = { @@ -397,8 +484,156 @@ test('serializing null value', t => { validate } = serialize(createSchema(prop), input) - t.equal(output, '{"updatedAt":null}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"updatedAt":null}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) }) }) + +test('Validate Date object as string type', (t) => { + t.plan(1) + + const schema = { + oneOf: [ + { type: 'string' } + ] + } + const toStringify = new Date(1674263005800) + + const stringify = build(schema) + const output = stringify(toStringify) + + t.assert.equal(output, JSON.stringify(toStringify)) +}) + +test('nullable date', (t) => { + t.plan(1) + + const schema = { + anyOf: [ + { + format: 'date', + type: 'string', + nullable: true + } + ] + } + + const stringify = build(schema) + + const data = new Date(1674263005800) + const result = stringify(data) + + t.assert.equal(result, '"2023-01-21"') +}) + +test('non-date format should not affect data serialization (issue #491)', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + hello: { + type: 'string', + format: 'int64', + pattern: '^[0-9]*$' + } + } + } + + const stringify = build(schema) + const data = { hello: 123n } + t.assert.equal(stringify(data), '{"hello":"123"}') +}) + +test('should serialize also an invalid string value, even if it is not a valid date', (t) => { + t.plan(2) + + const schema = { + title: 'a date in a string', + type: 'string', + format: 'date-time', + nullable: true + } + const toStringify = 'invalid' + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify(toStringify) + + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.equal(validate(JSON.parse(output)), false, 'valid schema') +}) + +test('should throw an error if value can not be transformed to date-time', (t) => { + t.plan(2) + + const schema = { + title: 'a date in a string', + type: 'string', + format: 'date-time', + nullable: true + } + const toStringify = true + + const validate = validator(schema) + const stringify = build(schema) + + t.assert.throws(() => stringify(toStringify), new Error('The value "true" cannot be converted to a date-time.')) + t.assert.equal(validate(toStringify), false) +}) + +test('should throw an error if value can not be transformed to date', (t) => { + t.plan(2) + + const schema = { + title: 'a date in a string', + type: 'string', + format: 'date', + nullable: true + } + const toStringify = true + + const validate = validator(schema) + const stringify = build(schema) + + t.assert.throws(() => stringify(toStringify), new Error('The value "true" cannot be converted to a date.')) + t.assert.equal(validate(toStringify), false) +}) + +test('should throw an error if value can not be transformed to time', (t) => { + t.plan(2) + + const schema = { + title: 'a time in a string', + type: 'string', + format: 'time', + nullable: true + } + const toStringify = true + + const validate = validator(schema) + const stringify = build(schema) + + t.assert.throws(() => stringify(toStringify), new Error('The value "true" cannot be converted to a time.')) + t.assert.equal(validate(toStringify), false) +}) + +test('should serialize also an invalid string value, even if it is not a valid time', (t) => { + t.plan(2) + + const schema = { + title: 'a time in a string', + type: 'string', + format: 'time', + nullable: true + } + const toStringify = 'invalid' + + const validate = validator(schema) + const stringify = build(schema) + const output = stringify(toStringify) + + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.equal(validate(JSON.parse(output)), false, 'valid schema') +}) diff --git a/test/debug-mode.test.js b/test/debug-mode.test.js index cf3973f8..3e998a73 100644 --- a/test/debug-mode.test.js +++ b/test/debug-mode.test.js @@ -1,8 +1,11 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const fjs = require('..') + const Ajv = require('ajv').default +const Validator = require('../lib/validator') +const Serializer = require('../lib/serializer') function build (opts) { return fjs({ @@ -18,39 +21,45 @@ function build (opts) { } test('activate debug mode', t => { - t.plan(3) + t.plan(5) const debugMode = build({ debugMode: true }) - t.type(debugMode, 'object') - t.ok(debugMode.ajv instanceof Ajv) - t.type(debugMode.code, 'string') + t.assert.ok(typeof debugMode === 'object') + t.assert.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(debugMode.validator instanceof Validator) + t.assert.ok(debugMode.serializer instanceof Serializer) + t.assert.ok(typeof debugMode.code === 'string') }) test('activate debug mode truthy', t => { - t.plan(3) + t.plan(5) const debugMode = build({ debugMode: 'yes' }) - t.type(debugMode, 'object') - t.type(debugMode.code, 'string') - t.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(typeof debugMode === 'object') + t.assert.ok(typeof debugMode.code === 'string') + t.assert.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(debugMode.validator instanceof Validator) + t.assert.ok(debugMode.serializer instanceof Serializer) }) test('to string auto-consistent', t => { - t.plan(4) + t.plan(6) const debugMode = build({ debugMode: 1 }) - t.type(debugMode, 'object') - t.type(debugMode.code, 'string') - t.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(typeof debugMode === 'object') + t.assert.ok(typeof debugMode.code === 'string') + t.assert.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(debugMode.serializer instanceof Serializer) + t.assert.ok(debugMode.validator instanceof Validator) const compiled = fjs.restore(debugMode) const tobe = JSON.stringify({ firstName: 'Foo' }) - t.same(compiled({ firstName: 'Foo', surname: 'bar' }), tobe, 'surname evicted') + t.assert.equal(compiled({ firstName: 'Foo', surname: 'bar' }), tobe, 'surname evicted') }) test('to string auto-consistent with ajv', t => { - t.plan(4) + t.plan(6) const debugMode = fjs({ title: 'object with multiple types field', @@ -66,13 +75,15 @@ test('to string auto-consistent with ajv', t => { } }, { debugMode: 1 }) - t.type(debugMode, 'object') - t.type(debugMode.code, 'string') - t.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(typeof debugMode === 'object') + t.assert.ok(typeof debugMode.code === 'string') + t.assert.ok(debugMode.ajv instanceof Ajv) + t.assert.ok(debugMode.validator instanceof Validator) + t.assert.ok(debugMode.serializer instanceof Serializer) const compiled = fjs.restore(debugMode) const tobe = JSON.stringify({ str: 'Foo' }) - t.same(compiled({ str: 'Foo', void: 'me' }), tobe) + t.assert.equal(compiled({ str: 'Foo', void: 'me' }), tobe) }) test('to string auto-consistent with ajv-formats', t => { @@ -93,10 +104,18 @@ test('to string auto-consistent with ajv-formats', t => { } }, { debugMode: 1 }) - t.type(debugMode, 'object') + t.assert.ok(typeof debugMode === 'object') const compiled = fjs.restore(debugMode) const tobe = JSON.stringify({ str: 'foo@bar.com' }) - t.same(compiled({ str: 'foo@bar.com' }), tobe) - t.same(compiled({ str: 'foo' }), JSON.stringify({ str: null }), 'invalid format is ignored') + t.assert.equal(compiled({ str: 'foo@bar.com' }), tobe) + t.assert.throws(() => compiled({ str: 'foo' })) +}) + +test('debug should restore the same serializer instance', t => { + t.plan(1) + + const debugMode = fjs({ type: 'integer' }, { debugMode: 1, rounding: 'ceil' }) + const compiled = fjs.restore(debugMode) + t.assert.equal(compiled(3.95), 4) }) diff --git a/test/defaults.test.js b/test/defaults.test.js index 3f4ee2e8..f0430a98 100644 --- a/test/defaults.test.js +++ b/test/defaults.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') function buildTest (schema, toStringify, expected) { @@ -11,7 +11,7 @@ function buildTest (schema, toStringify, expected) { const output = stringify(toStringify) - t.equal(output, JSON.stringify(expected)) + t.assert.equal(output, JSON.stringify(expected)) }) } diff --git a/test/enum.test.js b/test/enum.test.js index ab861686..9e702242 100644 --- a/test/enum.test.js +++ b/test/enum.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('use enum without type', (t) => { @@ -17,7 +17,7 @@ test('use enum without type', (t) => { }) const obj = { order: 'asc' } - t.equal('{"order":"asc"}', stringify(obj)) + t.assert.equal('{"order":"asc"}', stringify(obj)) }) test('use enum without type', (t) => { @@ -33,5 +33,5 @@ test('use enum without type', (t) => { }) const obj = { order: 'asc' } - t.equal('{"order":"asc"}', stringify(obj)) + t.assert.equal('{"order":"asc"}', stringify(obj)) }) diff --git a/test/fix-604.test.js b/test/fix-604.test.js new file mode 100644 index 00000000..239075af --- /dev/null +++ b/test/fix-604.test.js @@ -0,0 +1,25 @@ +'use strict' + +const { test } = require('node:test') +const fjs = require('..') + +test('fix-604', t => { + const schema = { + type: 'object', + properties: { + fullName: { type: 'string' }, + phone: { type: 'number' } + } + } + + const input = { + fullName: 'Jone', + phone: 'phone' + } + + const render = fjs(schema) + + t.assert.throws(() => { + render(input) + }, { message: 'The value "phone" cannot be converted to a number.' }) +}) diff --git a/test/fixtures/.keep b/test/fixtures/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/if-then-else.test.js b/test/if-then-else.test.js index e5df10ac..dfe25e9b 100644 --- a/test/if-then-else.test.js +++ b/test/if-then-else.test.js @@ -1,8 +1,10 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') +process.env.TZ = 'UTC' + const schema = { type: 'object', properties: { @@ -247,27 +249,19 @@ const alphabetOutput = JSON.stringify({ const deepFoobarOutput = JSON.stringify({ foobar: JSON.parse(foobarOutput) }) -const noElseGreetingOutput = JSON.stringify({ - kind: 'greeting', - foo: 'FOO', - bar: 42, - hi: 'HI', - hello: 45, - a: 'A', - b: 35 -}) +const noElseGreetingOutput = JSON.stringify({}) -t.test('if-then-else', t => { +test('if-then-else', async t => { const tests = [ { name: 'foobar', - schema: schema, + schema, input: fooBarInput, expected: foobarOutput }, { name: 'greeting', - schema: schema, + schema, input: greetingInput, expected: greetingOutput }, @@ -321,15 +315,154 @@ t.test('if-then-else', t => { } ] - tests.forEach(test => { - t.test(test.name + ' - normal', t => { + for (const { name, schema, input, expected } of tests) { + await t.test(name + ' - normal', async t => { t.plan(1) - const stringify = build(JSON.parse(JSON.stringify(test.schema)), { ajv: { strictTypes: false } }) - const serialized = stringify(test.input) - t.equal(serialized, test.expected) + const stringify = build(JSON.parse(JSON.stringify(schema)), { ajv: { strictTypes: false } }) + const serialized = stringify(input) + t.assert.equal(serialized, expected) }) - }) + } +}) + +test('nested if/then', t => { + t.plan(2) - t.end() + const schema = { + type: 'object', + properties: { a: { type: 'string' } }, + if: { + type: 'object', + properties: { foo: { type: 'string' } } + }, + then: { + properties: { bar: { type: 'string' } }, + if: { + type: 'object', + properties: { foo1: { type: 'string' } } + }, + then: { + properties: { bar1: { type: 'string' } } + } + } + } + + const stringify = build(schema) + + t.assert.equal( + stringify({ a: 'A', foo: 'foo', bar: 'bar' }), + JSON.stringify({ a: 'A', bar: 'bar' }) + ) + + t.assert.equal( + stringify({ a: 'A', foo: 'foo', bar: 'bar', foo1: 'foo1', bar1: 'bar1' }), + JSON.stringify({ a: 'A', bar: 'bar', bar1: 'bar1' }) + ) +}) + +test('if/else with string format', (t) => { + t.plan(2) + + const schema = { + if: { type: 'string' }, + then: { type: 'string', format: 'date' }, + else: { const: 'Invalid' } + } + + const stringify = build(schema) + + const date = new Date(1674263005800) + + t.assert.equal(stringify(date), '"2023-01-21"') + t.assert.equal(stringify('Invalid'), '"Invalid"') +}) + +test('if/else with const integers', (t) => { + t.plan(2) + + const schema = { + type: 'number', + if: { type: 'number', minimum: 42 }, + then: { const: 66 }, + else: { const: 33 } + } + + const stringify = build(schema) + + t.assert.equal(stringify(100.32), '66') + t.assert.equal(stringify(10.12), '33') +}) + +test('if/else with array', (t) => { + t.plan(2) + + const schema = { + type: 'array', + if: { type: 'array', maxItems: 1 }, + then: { items: { type: 'string' } }, + else: { items: { type: 'number' } } + } + + const stringify = build(schema) + + t.assert.equal(stringify(['1']), JSON.stringify(['1'])) + t.assert.equal(stringify(['1', '2']), JSON.stringify([1, 2])) +}) + +test('external recursive if/then/else', (t) => { + t.plan(1) + + const externalSchema = { + type: 'object', + properties: { + base: { type: 'string' }, + self: { $ref: 'externalSchema#' } + }, + if: { + type: 'object', + properties: { + foo: { type: 'string', const: '41' } + } + }, + then: { + type: 'object', + properties: { + bar: { type: 'string', const: '42' } + } + }, + else: { + type: 'object', + properties: { + baz: { type: 'string', const: '43' } + } + } + } + + const schema = { + type: 'object', + properties: { + a: { $ref: 'externalSchema#/properties/self' }, + b: { $ref: 'externalSchema#/properties/self' } + } + } + + const data = { + a: { + base: 'a', + foo: '41', + bar: '42', + baz: '43', + ignore: 'ignored' + }, + b: { + base: 'b', + foo: 'not-41', + bar: '42', + baz: '43', + ignore: 'ignored' + } + } + const stringify = build(schema, { schema: { externalSchema } }) + t.assert.equal(stringify(data), '{"a":{"base":"a","bar":"42"},"b":{"base":"b","baz":"43"}}') }) diff --git a/test/inferType.test.js b/test/inferType.test.js index 9648c03f..e2a8d254 100644 --- a/test/inferType.test.js +++ b/test/inferType.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') @@ -12,9 +12,9 @@ function buildTest (schema, toStringify) { const stringify = build(schema) const output = stringify(toStringify) - t.same(JSON.parse(output), toStringify) - t.equal(output, JSON.stringify(toStringify)) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.deepStrictEqual(JSON.parse(output), toStringify) + t.assert.equal(output, JSON.stringify(toStringify)) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) } diff --git a/test/infinity.test.js b/test/infinity.test.js new file mode 100644 index 00000000..28799e65 --- /dev/null +++ b/test/infinity.test.js @@ -0,0 +1,55 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +test('Finite numbers', t => { + const values = [-5, 0, -0, 1.33, 99, 100.0, + Math.E, Number.EPSILON, + Number.MAX_SAFE_INTEGER, Number.MAX_VALUE, + Number.MIN_SAFE_INTEGER, Number.MIN_VALUE] + + t.plan(values.length) + + const schema = { + type: 'number' + } + + const stringify = build(schema) + + values.forEach(v => t.assert.equal(stringify(v), JSON.stringify(v))) +}) + +test('Infinite integers', t => { + const values = [Infinity, -Infinity] + + t.plan(values.length) + + const schema = { + type: 'integer' + } + + const stringify = build(schema) + + values.forEach(v => { + try { + stringify(v) + } catch (err) { + t.assert.equal(err.message, `The value "${v}" cannot be converted to an integer.`) + } + }) +}) + +test('Infinite numbers', t => { + const values = [Infinity, -Infinity] + + t.plan(values.length) + + const schema = { + type: 'number' + } + + const stringify = build(schema) + + values.forEach(v => t.assert.equal(stringify(v), JSON.stringify(v))) +}) diff --git a/test/integer.test.js b/test/integer.test.js index a8729454..d76261f4 100644 --- a/test/integer.test.js +++ b/test/integer.test.js @@ -1,7 +1,7 @@ 'use strict' -const t = require('tap') -const test = t.test +const { test } = require('node:test') + const validator = require('is-my-json-valid') const build = require('..') const ROUNDING_TYPES = ['ceil', 'floor', 'round'] @@ -18,8 +18,8 @@ test('render an integer as JSON', (t) => { const stringify = build(schema) const output = stringify(1615) - t.equal(output, '1615') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '1615') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render a float as an integer', (t) => { @@ -30,9 +30,21 @@ test('render a float as an integer', (t) => { type: 'integer' }, { rounding: 'foobar' }) } catch (error) { - t.ok(error) - t.equal(error.message, 'Unsupported integer rounding method foobar') + t.assert.ok(error) + t.assert.equal(error.message, 'Unsupported integer rounding method foobar') + } +}) + +test('throws on NaN', (t) => { + t.plan(1) + + const schema = { + title: 'integer', + type: 'integer' } + + const stringify = build(schema) + t.assert.throws(() => stringify(NaN), new Error('The value "NaN" cannot be converted to an integer.')) }) test('render a float as an integer', (t) => { @@ -45,6 +57,15 @@ test('render a float as an integer', (t) => { { input: 42, output: '42' }, { input: 1.99999, output: '1' }, { input: -45.05, output: '-45' }, + { input: 3333333333333333, output: '3333333333333333' }, + { input: Math.PI, output: '3', rounding: 'trunc' }, + { input: 5.0, output: '5', rounding: 'trunc' }, + { input: null, output: '0', rounding: 'trunc' }, + { input: 0, output: '0', rounding: 'trunc' }, + { input: 0.0, output: '0', rounding: 'trunc' }, + { input: 42, output: '42', rounding: 'trunc' }, + { input: 1.99999, output: '1', rounding: 'trunc' }, + { input: -45.05, output: '-45', rounding: 'trunc' }, { input: 0.95, output: '1', rounding: 'ceil' }, { input: 0.2, output: '1', rounding: 'ceil' }, { input: 45.95, output: '45', rounding: 'floor' }, @@ -66,8 +87,8 @@ test('render a float as an integer', (t) => { const stringify = build(schema, { rounding }) const str = stringify(input) - t.equal(str, output) - t.ok(validate(JSON.parse(str)), 'valid schema') + t.assert.equal(str, output) + t.assert.ok(validate(JSON.parse(str)), 'valid schema') } }) @@ -90,8 +111,8 @@ test('render an object with an integer as JSON', (t) => { id: 1615 }) - t.equal(output, '{"id":1615}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"id":1615}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render an array with an integer as JSON', (t) => { @@ -109,8 +130,8 @@ test('render an array with an integer as JSON', (t) => { const stringify = build(schema) const output = stringify([1615]) - t.equal(output, '[1615]') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '[1615]') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render an object with an additionalProperty of type integer as JSON', (t) => { @@ -130,8 +151,8 @@ test('render an object with an additionalProperty of type integer as JSON', (t) num: 1615 }) - t.equal(output, '{"num":1615}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"num":1615}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('should round integer object parameter', t => { @@ -142,8 +163,8 @@ test('should round integer object parameter', t => { const stringify = build(schema, { rounding: 'ceil' }) const output = stringify({ magic: 4.2 }) - t.equal(output, '{"magic":5}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{"magic":5}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('should not stringify a property if it does not exist', t => { @@ -154,8 +175,8 @@ test('should not stringify a property if it does not exist', t => { const stringify = build(schema) const output = stringify({}) - t.equal(output, '{}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) ROUNDING_TYPES.forEach((rounding) => { @@ -167,7 +188,7 @@ ROUNDING_TYPES.forEach((rounding) => { const stringify = build(schema, { rounding }) const output = stringify({}) - t.equal(output, '{}') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '{}') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) }) diff --git a/test/invalidSchema.test.js b/test/invalidSchema.test.js index 3b61badf..4402484b 100644 --- a/test/invalidSchema.test.js +++ b/test/invalidSchema.test.js @@ -1,12 +1,12 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') // Covers issue #139 test('Should throw on invalid schema', t => { - t.plan(2) - try { + t.plan(1) + t.assert.throws(() => { build({}, { schema: { invalid: { @@ -14,9 +14,5 @@ test('Should throw on invalid schema', t => { } } }) - t.fail('should be an invalid schema') - } catch (err) { - t.match(err.message, /^"invalid" schema is invalid:.*/, 'Schema contains invalid key') - t.ok(err) - } + }, { message: /^"invalid" schema is invalid:.*/ }) }) diff --git a/test/issue-479.test.js b/test/issue-479.test.js new file mode 100644 index 00000000..10b33e07 --- /dev/null +++ b/test/issue-479.test.js @@ -0,0 +1,57 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +test('should validate anyOf after allOf merge', (t) => { + t.plan(1) + + const schema = { + $id: 'schema', + type: 'object', + allOf: [ + { + $id: 'base', + type: 'object', + properties: { + name: { + type: 'string' + } + }, + required: [ + 'name' + ] + }, + { + $id: 'inner_schema', + type: 'object', + properties: { + union: { + $id: '#id', + anyOf: [ + { + + $id: 'guid', + type: 'string' + }, + { + + $id: 'email', + type: 'string' + } + ] + } + }, + required: [ + 'union' + ] + } + ] + } + + const stringify = build(schema) + + t.assert.equal( + stringify({ name: 'foo', union: 'a8f1cc50-5530-5c62-9109-5ba9589a6ae1' }), + '{"name":"foo","union":"a8f1cc50-5530-5c62-9109-5ba9589a6ae1"}') +}) diff --git a/test/issue-793.test.js b/test/issue-793.test.js new file mode 100644 index 00000000..0fd5681e --- /dev/null +++ b/test/issue-793.test.js @@ -0,0 +1,107 @@ +'use strict' + +const { test } = require('node:test') + +const build = require('..') + +test('serialize string with newlines - issue #793', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + message: { + type: 'string' + } + } + } + + const input = { + message: `This is a string +with multiple +newlines in it +Foo` + } + + const stringify = build(schema) + const output = stringify(input) + + // The output should be valid JSON + t.assert.doesNotThrow(() => { + JSON.parse(output) + }, 'JSON output should be parseable') + + // The parsed output should match the input + const parsed = JSON.parse(output) + t.assert.equal(parsed.message, input.message) +}) + +test('serialize string with various newline characters - issue #793', (t) => { + t.plan(4) + + const schema = { + type: 'string' + } + + const stringify = build(schema) + + // Test \n (line feed) + const inputLF = 'line1\nline2' + const outputLF = stringify(inputLF) + t.assert.equal(JSON.parse(outputLF), inputLF) + + // Test \r (carriage return) + const inputCR = 'line1\rline2' + const outputCR = stringify(inputCR) + t.assert.equal(JSON.parse(outputCR), inputCR) + + // Test \r\n (CRLF) + const inputCRLF = 'line1\r\nline2' + const outputCRLF = stringify(inputCRLF) + t.assert.equal(JSON.parse(outputCRLF), inputCRLF) + + // Test mixed newlines + const inputMixed = 'line1\nline2\rline3\r\nline4' + const outputMixed = stringify(inputMixed) + t.assert.equal(JSON.parse(outputMixed), inputMixed) +}) + +test('serialize object with newlines in multiple properties - issue #793', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + message: { + type: 'string' + }, + description: { + type: 'string' + }, + timestamp: { + type: 'string' + } + } + } + + const input = { + message: `This is a string +with multiple +newlines in it +Foo`, + description: 'This JSON response contains a field with newline characters', + timestamp: new Date().toISOString() + } + + const stringify = build(schema) + const output = stringify(input) + + // The output should be valid JSON + t.assert.doesNotThrow(() => { + JSON.parse(output) + }, 'JSON output should be parseable') + + // The parsed output should match the input + const parsed = JSON.parse(output) + t.assert.deepEqual(parsed, input) +}) diff --git a/test/issue-794.test.js b/test/issue-794.test.js new file mode 100644 index 00000000..dda9a6c7 --- /dev/null +++ b/test/issue-794.test.js @@ -0,0 +1,177 @@ +'use strict' + +const { test } = require('node:test') + +const build = require('..') + +test('serialize string with quotes - issue #794', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + message: { + type: 'string' + } + } + } + + const input = { + message: 'Error: Property "name" is required' + } + + const stringify = build(schema) + const output = stringify(input) + + // The output should be valid JSON + t.assert.doesNotThrow(() => { + JSON.parse(output) + }, 'JSON output should be parseable') + + // The parsed output should match the input + const parsed = JSON.parse(output) + t.assert.equal(parsed.message, input.message) +}) + +test('serialize string with various quote types - issue #794', (t) => { + t.plan(6) + + const schema = { + type: 'string' + } + + const stringify = build(schema) + + // Test double quotes + const inputDoubleQuotes = 'Property "name" is required' + const outputDoubleQuotes = stringify(inputDoubleQuotes) + t.assert.doesNotThrow(() => JSON.parse(outputDoubleQuotes)) + t.assert.equal(JSON.parse(outputDoubleQuotes), inputDoubleQuotes) + + // Test single quotes (should be fine but test for completeness) + const inputSingleQuotes = "Property 'name' is required" + const outputSingleQuotes = stringify(inputSingleQuotes) + t.assert.doesNotThrow(() => JSON.parse(outputSingleQuotes)) + t.assert.equal(JSON.parse(outputSingleQuotes), inputSingleQuotes) + + // Test mixed quotes + const inputMixedQuotes = 'Error: "Property \'name\' is required"' + const outputMixedQuotes = stringify(inputMixedQuotes) + t.assert.doesNotThrow(() => JSON.parse(outputMixedQuotes)) + t.assert.equal(JSON.parse(outputMixedQuotes), inputMixedQuotes) +}) + +test('serialize error-like object with quotes in message - issue #794', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + message: { + type: 'string' + }, + code: { + type: 'string' + } + } + } + } + } + + const input = { + error: { + message: 'Validation failed: Property "email" must be a valid email address', + code: 'VALIDATION_ERROR' + } + } + + const stringify = build(schema) + const output = stringify(input) + + // The output should be valid JSON + t.assert.doesNotThrow(() => { + JSON.parse(output) + }, 'JSON output should be parseable') + + // The parsed output should match the input + const parsed = JSON.parse(output) + t.assert.deepEqual(parsed, input) +}) + +test('serialize validation errors array with quotes - issue #794', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + errors: { + type: 'array', + items: { + type: 'object', + properties: { + message: { + type: 'string' + }, + field: { + type: 'string' + } + } + } + } + } + } + + const input = { + errors: [ + { + message: 'Property "name" is required', + field: 'name' + }, + { + message: 'Property "email" must be a valid email address', + field: 'email' + }, + { + message: 'Value must be between "1" and "100"', + field: 'age' + } + ] + } + + const stringify = build(schema) + const output = stringify(input) + + // The output should be valid JSON + t.assert.doesNotThrow(() => { + JSON.parse(output) + }, 'JSON output should be parseable') + + // The parsed output should match the input + const parsed = JSON.parse(output) + t.assert.deepEqual(parsed, input) +}) + +test('serialize string with backslashes and quotes - issue #794', (t) => { + t.plan(4) + + const schema = { + type: 'string' + } + + const stringify = build(schema) + + // Test backslashes + const inputBackslash = 'Path: C:\\Users\\test\\file.json' + const outputBackslash = stringify(inputBackslash) + t.assert.doesNotThrow(() => JSON.parse(outputBackslash)) + t.assert.equal(JSON.parse(outputBackslash), inputBackslash) + + // Test combination of backslashes and quotes + const inputMixed = 'Error: Could not find file "C:\\Users\\test\\config.json"' + const outputMixed = stringify(inputMixed) + t.assert.doesNotThrow(() => JSON.parse(outputMixed)) + t.assert.equal(JSON.parse(outputMixed), inputMixed) +}) diff --git a/test/json-schema-test-suite/README.md b/test/json-schema-test-suite/README.md index 8e4f954a..de524458 100644 --- a/test/json-schema-test-suite/README.md +++ b/test/json-schema-test-suite/README.md @@ -5,6 +5,6 @@ It contains a set of JSON objects that implementors of JSON Schema validation li # How to add another test case? -1. Navigate to [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite/tree/master/tests) +1. Navigate to [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite/tree/main/tests) 2. Choose a draft `draft4`, `draft6` or `draft7` 3. Copy & paste the `test-case.json` to the project and add a test like in the `draft4.test.js` \ No newline at end of file diff --git a/test/json-schema-test-suite/draft4.test.js b/test/json-schema-test-suite/draft4.test.js index 560943a5..f71afe45 100644 --- a/test/json-schema-test-suite/draft4.test.js +++ b/test/json-schema-test-suite/draft4.test.js @@ -1,12 +1,12 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const { counTests, runTests } = require('./util') const requiredTestSuite = require('./draft4/required.json') -test('required', (t) => { +test('required', async (t) => { const skippedTests = ['ignores arrays', 'ignores strings', 'ignores other non-objects'] t.plan(counTests(requiredTestSuite, skippedTests)) - runTests(t, requiredTestSuite, skippedTests) + await runTests(t, requiredTestSuite, skippedTests) }) diff --git a/test/json-schema-test-suite/draft6.test.js b/test/json-schema-test-suite/draft6.test.js index ec75003f..072b7cfc 100644 --- a/test/json-schema-test-suite/draft6.test.js +++ b/test/json-schema-test-suite/draft6.test.js @@ -1,12 +1,12 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const { counTests, runTests } = require('./util') const requiredTestSuite = require('./draft6/required.json') -test('required', (t) => { +test('required', async (t) => { const skippedTests = ['ignores arrays', 'ignores strings', 'ignores other non-objects'] t.plan(counTests(requiredTestSuite, skippedTests)) - runTests(t, requiredTestSuite, skippedTests) + await runTests(t, requiredTestSuite, skippedTests) }) diff --git a/test/json-schema-test-suite/draft7.test.js b/test/json-schema-test-suite/draft7.test.js index 0da48b37..9c6422a7 100644 --- a/test/json-schema-test-suite/draft7.test.js +++ b/test/json-schema-test-suite/draft7.test.js @@ -1,12 +1,12 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const { counTests, runTests } = require('./util') const requiredTestSuite = require('./draft7/required.json') -test('required', (t) => { +test('required', async (t) => { const skippedTests = ['ignores arrays', 'ignores strings', 'ignores other non-objects'] t.plan(counTests(requiredTestSuite, skippedTests)) - runTests(t, requiredTestSuite, skippedTests) + await runTests(t, requiredTestSuite, skippedTests) }) diff --git a/test/json-schema-test-suite/util.js b/test/json-schema-test-suite/util.js index 837f92aa..c4cd4ec8 100644 --- a/test/json-schema-test-suite/util.js +++ b/test/json-schema-test-suite/util.js @@ -2,25 +2,22 @@ const build = require('../..') -function runTests (t, testsuite, skippedTests) { +async function runTests (t, testsuite, skippedTests) { for (const scenario of testsuite) { const stringify = build(scenario.schema) for (const test of scenario.tests) { if (skippedTests.indexOf(test.description) !== -1) { - t.comment('skip %s', test.description) + console.log(`skip ${test.description}`) continue } - t.test(test.description, (t) => { + + await t.test(test.description, (t) => { t.plan(1) try { const output = stringify(test.data) - t.equal(output, JSON.stringify(test.data), 'compare payloads') + t.assert.equal(output, JSON.stringify(test.data), 'compare payloads') } catch (err) { - if (test.valid === false) { - t.pass('payload is invalid') - } else { - t.fail('payload should be valid: ' + err.message) - } + t.assert.ok(test.valid === false, 'payload should be valid: ' + err.message) } }) } diff --git a/test/missing-values.test.js b/test/missing-values.test.js index c08110da..5ddf4771 100644 --- a/test/missing-values.test.js +++ b/test/missing-values.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('missing values', (t) => { @@ -22,9 +22,9 @@ test('missing values', (t) => { } }) - t.equal('{"val":"value"}', stringify({ val: 'value' })) - t.equal('{"str":"string","val":"value"}', stringify({ str: 'string', val: 'value' })) - t.equal('{"str":"string","num":42,"val":"value"}', stringify({ str: 'string', num: 42, val: 'value' })) + t.assert.equal('{"val":"value"}', stringify({ val: 'value' })) + t.assert.equal('{"str":"string","val":"value"}', stringify({ str: 'string', val: 'value' })) + t.assert.equal('{"str":"string","num":42,"val":"value"}', stringify({ str: 'string', num: 42, val: 'value' })) }) test('handle null when value should be string', (t) => { @@ -39,7 +39,7 @@ test('handle null when value should be string', (t) => { } }) - t.equal('{"str":""}', stringify({ str: null })) + t.assert.equal('{"str":""}', stringify({ str: null })) }) test('handle null when value should be integer', (t) => { @@ -54,7 +54,7 @@ test('handle null when value should be integer', (t) => { } }) - t.equal('{"int":0}', stringify({ int: null })) + t.assert.equal('{"int":0}', stringify({ int: null })) }) test('handle null when value should be number', (t) => { @@ -69,7 +69,7 @@ test('handle null when value should be number', (t) => { } }) - t.equal('{"num":0}', stringify({ num: null })) + t.assert.equal('{"num":0}', stringify({ num: null })) }) test('handle null when value should be boolean', (t) => { @@ -84,5 +84,5 @@ test('handle null when value should be boolean', (t) => { } }) - t.equal('{"bool":false}', stringify({ bool: null })) + t.assert.equal('{"bool":false}', stringify({ bool: null })) }) diff --git a/test/multi-type-serializer.test.js b/test/multi-type-serializer.test.js new file mode 100644 index 00000000..146b42f2 --- /dev/null +++ b/test/multi-type-serializer.test.js @@ -0,0 +1,19 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +test('should throw a TypeError with the path to the key of the invalid value', (t) => { + t.plan(1) + const schema = { + type: 'object', + properties: { + num: { + type: ['number'] + } + } + } + + const stringify = build(schema) + t.assert.throws(() => stringify({ num: { bla: 123 } }), new TypeError('The value of \'#/properties/num\' does not match schema definition.')) +}) diff --git a/test/nestedObjects.test.js b/test/nestedObjects.test.js index ab7e8131..f683cba2 100644 --- a/test/nestedObjects.test.js +++ b/test/nestedObjects.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('nested objects with same properties', (t) => { @@ -28,5 +28,36 @@ test('nested objects with same properties', (t) => { numberProperty: 42 } }) - t.equal(value, '{"stringProperty":"string1","objectProperty":{"stringProperty":"string2","numberProperty":42}}') + t.assert.equal(value, '{"stringProperty":"string1","objectProperty":{"stringProperty":"string2","numberProperty":42}}') +}) + +test('names collision', (t) => { + t.plan(1) + + const schema = { + title: 'nested objects with same properties', + type: 'object', + properties: { + test: { + type: 'object', + properties: { + a: { type: 'string' } + } + }, + tes: { + type: 'object', + properties: { + b: { type: 'string' }, + t: { type: 'object' } + } + } + } + } + const stringify = build(schema) + const data = { + test: { a: 'a' }, + tes: { b: 'b', t: {} } + } + + t.assert.equal(stringify(data), JSON.stringify(data)) }) diff --git a/test/nullable.test.js b/test/nullable.test.js index 346b559d..a6d7eed9 100644 --- a/test/nullable.test.js +++ b/test/nullable.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') @@ -9,11 +9,11 @@ const nullable = true const complexObject = { type: 'object', properties: { - nullableString: { type: 'string', nullable: nullable }, - nullableNumber: { type: 'number', nullable: nullable }, - nullableInteger: { type: 'integer', nullable: nullable }, - nullableBoolean: { type: 'boolean', nullable: nullable }, - nullableNull: { type: 'null', nullable: nullable }, + nullableString: { type: 'string', nullable }, + nullableNumber: { type: 'number', nullable }, + nullableInteger: { type: 'integer', nullable }, + nullableBoolean: { type: 'boolean', nullable }, + nullableNull: { type: 'null', nullable }, nullableArray: { type: 'array', nullable: true, @@ -25,11 +25,11 @@ const complexObject = { nullable: false, additionalProperties: true, properties: { - nullableString: { type: 'string', nullable: nullable }, - nullableNumber: { type: 'number', nullable: nullable }, - nullableInteger: { type: 'integer', nullable: nullable }, - nullableBoolean: { type: 'boolean', nullable: nullable }, - nullableNull: { type: 'null', nullable: nullable }, + nullableString: { type: 'string', nullable }, + nullableNumber: { type: 'number', nullable }, + nullableInteger: { type: 'integer', nullable }, + nullableBoolean: { type: 'boolean', nullable }, + nullableNull: { type: 'null', nullable }, nullableArray: { type: 'array', nullable: true, @@ -86,11 +86,11 @@ const complexExpectedResult = { } const testSet = { - nullableString: [{ type: 'string', nullable: nullable }, null, null], - nullableNumber: [{ type: 'number', nullable: nullable }, null, null], - nullableInteger: [{ type: 'integer', nullable: nullable }, null, null], - nullableBoolean: [{ type: 'boolean', nullable: nullable }, null, null], - nullableNull: [{ type: 'null', nullable: nullable }, null, null], + nullableString: [{ type: 'string', nullable }, null, null], + nullableNumber: [{ type: 'number', nullable }, null, null], + nullableInteger: [{ type: 'integer', nullable }, null, null], + nullableBoolean: [{ type: 'boolean', nullable }, null, null], + nullableNull: [{ type: 'null', nullable }, null, null], nullableArray: [{ type: 'array', nullable: true, @@ -113,6 +113,431 @@ Object.keys(testSet).forEach(key => { const stringifier = build(schema, extraOptions) const result = stringifier(data) - t.same(JSON.parse(result), expected) + t.assert.deepStrictEqual(JSON.parse(result), expected) }) }) + +test('handle nullable number correctly', (t) => { + t.plan(2) + + const schema = { + type: 'number', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('handle nullable integer correctly', (t) => { + t.plan(2) + + const schema = { + type: 'integer', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('handle nullable boolean correctly', (t) => { + t.plan(2) + + const schema = { + type: 'boolean', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('handle nullable string correctly', (t) => { + t.plan(2) + + const schema = { + type: 'string', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('handle nullable date-time correctly', (t) => { + t.plan(2) + + const schema = { + type: 'string', + format: 'date-time', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('handle nullable date correctly', (t) => { + t.plan(2) + + const schema = { + type: 'string', + format: 'date', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('handle nullable time correctly', (t) => { + t.plan(2) + + const schema = { + type: 'string', + format: 'time', + nullable: true + } + const stringify = build(schema) + + const data = null + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.equal(JSON.parse(result), data) +}) + +test('large array of nullable strings with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'string', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('large array of nullable date-time strings with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'string', + format: 'date-time', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('large array of nullable date-time strings with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'string', + format: 'date', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('large array of nullable date-time strings with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'string', + format: 'time', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('large array of nullable numbers with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'number', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('large array of nullable integers with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'integer', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('large array of nullable booleans with default mechanism', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + ids: { + type: 'array', + items: { + type: 'boolean', + nullable: true + } + } + } + } + + const options = { + largeArraySize: 2e4, + largeArrayMechanism: 'default' + } + + const stringify = build(schema, options) + + const data = { ids: new Array(2e4).fill(null) } + const result = stringify(data) + + t.assert.equal(result, JSON.stringify(data)) + t.assert.deepStrictEqual(JSON.parse(result), data) +}) + +test('nullable type in the schema', (t) => { + t.plan(2) + + const schema = { + type: ['object', 'null'], + properties: { + foo: { + type: 'string' + } + } + } + + const stringify = build(schema) + + const data = { foo: 'bar' } + + t.assert.equal(stringify(data), JSON.stringify(data)) + t.assert.equal(stringify(null), JSON.stringify(null)) +}) + +test('throw an error if the value doesn\'t match the type', (t) => { + t.plan(2) + + const schema = { + type: 'object', + additionalProperties: false, + required: ['data'], + properties: { + data: { + type: 'array', + minItems: 1, + items: { + oneOf: [ + { + type: 'string' + }, + { + type: 'number' + } + ] + } + } + } + } + + const stringify = build(schema) + + const validData = { data: [1, 'testing'] } + t.assert.equal(stringify(validData), JSON.stringify(validData)) + + const invalidData = { data: [false, 'testing'] } + t.assert.throws(() => stringify(invalidData)) +}) + +test('nullable value in oneOf', (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + data: { + oneOf: [ + { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'integer', minimum: 1 } + }, + additionalProperties: false, + required: ['id'] + } + }, + { + type: 'array', + items: { + type: 'object', + properties: { + job: { type: 'string', nullable: true } + }, + additionalProperties: false, + required: ['job'] + } + } + ] + } + }, + required: ['data'], + additionalProperties: false + } + + const stringify = build(schema) + + const data = { data: [{ job: null }] } + t.assert.equal(stringify(data), JSON.stringify(data)) +}) diff --git a/test/oneof.test.js b/test/oneof.test.js index 920ba918..f3b29f4b 100644 --- a/test/oneof.test.js +++ b/test/oneof.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const build = require('..') test('object with multiple types field', (t) => { @@ -21,8 +21,8 @@ test('object with multiple types field', (t) => { } const stringify = build(schema) - t.equal(stringify({ str: 'string' }), '{"str":"string"}') - t.equal(stringify({ str: true }), '{"str":true}') + t.assert.equal(stringify({ str: 'string' }), '{"str":"string"}') + t.assert.equal(stringify({ str: true }), '{"str":true}') }) test('object with field of type object or null', (t) => { @@ -48,9 +48,9 @@ test('object with field of type object or null', (t) => { } const stringify = build(schema) - t.equal(stringify({ prop: null }), '{"prop":null}') + t.assert.equal(stringify({ prop: null }), '{"prop":null}') - t.equal(stringify({ + t.assert.equal(stringify({ prop: { str: 'string', remove: 'this' } @@ -80,11 +80,11 @@ test('object with field of type object or array', (t) => { } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ prop: { str: 'string' } }), '{"prop":{"str":"string"}}') - t.equal(stringify({ + t.assert.equal(stringify({ prop: ['string'] }), '{"prop":["string"]}') }) @@ -104,11 +104,7 @@ test('object with field of type string and coercion disable ', (t) => { } } const stringify = build(schema) - - const value = stringify({ - str: 1 - }) - t.equal(value, '{"str":null}') + t.assert.throws(() => stringify({ str: 1 })) }) test('object with field of type string and coercion enable ', (t) => { @@ -136,7 +132,7 @@ test('object with field of type string and coercion enable ', (t) => { const value = stringify({ str: 1 }) - t.equal(value, '{"str":"1"}') + t.assert.equal(value, '{"str":"1"}') }) test('object with field with type union of multiple objects', (t) => { @@ -170,9 +166,9 @@ test('object with field with type union of multiple objects', (t) => { const stringify = build(schema) - t.equal(stringify({ oneOfSchema: { baz: 5 } }), '{"oneOfSchema":{"baz":5}}') + t.assert.equal(stringify({ oneOfSchema: { baz: 5 } }), '{"oneOfSchema":{"baz":5}}') - t.equal(stringify({ oneOfSchema: { bar: 'foo' } }), '{"oneOfSchema":{"bar":"foo"}}') + t.assert.equal(stringify({ oneOfSchema: { bar: 'foo' } }), '{"oneOfSchema":{"bar":"foo"}}') }) test('null value in schema', (t) => { @@ -214,9 +210,9 @@ test('oneOf and $ref together', (t) => { const stringify = build(schema) - t.equal(stringify({ cs: 'franco' }), '{"cs":"franco"}') + t.assert.equal(stringify({ cs: 'franco' }), '{"cs":"franco"}') - t.equal(stringify({ cs: true }), '{"cs":true}') + t.assert.equal(stringify({ cs: true }), '{"cs":true}') }) test('oneOf and $ref: 2 levels are fine', (t) => { @@ -254,7 +250,7 @@ test('oneOf and $ref: 2 levels are fine', (t) => { const value = stringify({ cs: 3 }) - t.equal(value, '{"cs":3}') + t.assert.equal(value, '{"cs":3}') }) test('oneOf and $ref: multiple levels should throw at build.', (t) => { @@ -293,9 +289,9 @@ test('oneOf and $ref: multiple levels should throw at build.', (t) => { const stringify = build(schema) - t.equal(stringify({ cs: 3 }), '{"cs":3}') - t.equal(stringify({ cs: true }), '{"cs":true}') - t.equal(stringify({ cs: 'pippo' }), '{"cs":"pippo"}') + t.assert.equal(stringify({ cs: 3 }), '{"cs":3}') + t.assert.equal(stringify({ cs: true }), '{"cs":true}') + t.assert.equal(stringify({ cs: 'pippo' }), '{"cs":"pippo"}') }) test('oneOf and $ref - multiple external $ref', (t) => { @@ -348,10 +344,8 @@ test('oneOf and $ref - multiple external $ref', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"prop":{"prop2":"test"}}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"prop":{"prop2":"test"}}}') }) test('oneOf with enum with more than 100 entries', (t) => { @@ -373,7 +367,7 @@ test('oneOf with enum with more than 100 entries', (t) => { const stringify = build(schema) const value = stringify(['EUR', 'USD', null]) - t.equal(value, '["EUR","USD",null]') + t.assert.equal(value, '["EUR","USD",null]') }) test('oneOf object with field of type string with format or null', (t) => { @@ -397,13 +391,13 @@ test('oneOf object with field of type string with format or null', (t) => { const withOneOfStringify = build(withOneOfSchema) - t.equal(withOneOfStringify({ + t.assert.equal(withOneOfStringify({ prop: toStringify }), `{"prop":"${toStringify.toISOString()}"}`) }) test('one array item match oneOf types', (t) => { - t.plan(1) + t.plan(3) const schema = { type: 'object', @@ -429,15 +423,13 @@ test('one array item match oneOf types', (t) => { const stringify = build(schema) - const responseWithMappedType = stringify({ - data: [false, 'foo'] - }) - - t.equal('{"data":["foo"]}', responseWithMappedType) + t.assert.equal(stringify({ data: ['foo'] }), '{"data":["foo"]}') + t.assert.equal(stringify({ data: [1] }), '{"data":[1]}') + t.assert.throws(() => stringify({ data: [false, 'foo'] })) }) test('some array items match oneOf types', (t) => { - t.plan(1) + t.plan(2) const schema = { type: 'object', @@ -463,11 +455,8 @@ test('some array items match oneOf types', (t) => { const stringify = build(schema) - const responseWithMappedTypes = stringify({ - data: [false, 'foo', true, 5] - }) - - t.equal('{"data":["foo",5]}', responseWithMappedTypes) + t.assert.equal(stringify({ data: ['foo', 5] }), '{"data":["foo",5]}') + t.assert.throws(() => stringify({ data: [false, 'foo', true, 5] })) }) test('all array items does not match oneOf types', (t) => { @@ -497,9 +486,5 @@ test('all array items does not match oneOf types', (t) => { const stringify = build(schema) - const emptyResponse = stringify({ - data: [null, false, true, undefined, [], {}] - }) - - t.equal('{"data":[]}', emptyResponse) + t.assert.throws(() => stringify({ data: [null, false, true, undefined, [], {}] })) }) diff --git a/test/patternProperties.test.js b/test/patternProperties.test.js index 009ee9bd..d80a7f1b 100644 --- a/test/patternProperties.test.js +++ b/test/patternProperties.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('patternProperties', (t) => { @@ -21,7 +21,7 @@ test('patternProperties', (t) => { }) const obj = { str: 'test', foo: 42, ofoo: true, foof: 'string', objfoo: { a: true }, notMe: false } - t.equal(stringify(obj), '{"str":"test","foo":"42","ofoo":"true","foof":"string","objfoo":"[object Object]"}') + t.assert.equal(stringify(obj), '{"str":"test","foo":"42","ofoo":"true","foof":"string","objfoo":"[object Object]"}') }) test('patternProperties should not change properties', (t) => { @@ -42,7 +42,7 @@ test('patternProperties should not change properties', (t) => { }) const obj = { foo: '42', ofoo: 42 } - t.equal(stringify(obj), '{"foo":"42","ofoo":42}') + t.assert.equal(stringify(obj), '{"foo":"42","ofoo":42}') }) test('patternProperties - string coerce', (t) => { @@ -59,11 +59,11 @@ test('patternProperties - string coerce', (t) => { }) const obj = { foo: true, ofoo: 42, arrfoo: ['array', 'test'], objfoo: { a: 'world' } } - t.equal(stringify(obj), '{"foo":"true","ofoo":"42","arrfoo":"array,test","objfoo":"[object Object]"}') + t.assert.equal(stringify(obj), '{"foo":"true","ofoo":"42","arrfoo":"array,test","objfoo":"[object Object]"}') }) test('patternProperties - number coerce', (t) => { - t.plan(1) + t.plan(2) const stringify = build({ title: 'check number coerce', type: 'object', @@ -75,8 +75,16 @@ test('patternProperties - number coerce', (t) => { } }) - const obj = { foo: true, ofoo: '42', xfoo: 'string', arrfoo: [1, 2], objfoo: { num: 42 } } - t.equal(stringify(obj), '{"foo":1,"ofoo":42,"xfoo":null,"arrfoo":null,"objfoo":null}') + const coercibleValues = { foo: true, ofoo: '42' } + t.assert.equal(stringify(coercibleValues), '{"foo":1,"ofoo":42}') + + const incoercibleValues = { xfoo: 'string', arrfoo: [1, 2], objfoo: { num: 42 } } + try { + stringify(incoercibleValues) + t.fail('should throw an error') + } catch (err) { + t.assert.ok(err) + } }) test('patternProperties - boolean coerce', (t) => { @@ -93,7 +101,7 @@ test('patternProperties - boolean coerce', (t) => { }) const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { a: true } } - t.equal(stringify(obj), '{"foo":true,"ofoo":false,"arrfoo":true,"objfoo":true}') + t.assert.equal(stringify(obj), '{"foo":true,"ofoo":false,"arrfoo":true,"objfoo":true}') }) test('patternProperties - object coerce', (t) => { @@ -115,11 +123,11 @@ test('patternProperties - object coerce', (t) => { }) const obj = { objfoo: { answer: 42 } } - t.equal(stringify(obj), '{"objfoo":{"answer":42}}') + t.assert.equal(stringify(obj), '{"objfoo":{"answer":42}}') }) test('patternProperties - array coerce', (t) => { - t.plan(1) + t.plan(2) const stringify = build({ title: 'check array coerce', type: 'object', @@ -134,6 +142,27 @@ test('patternProperties - array coerce', (t) => { } }) - const obj = { foo: 'true', ofoo: 0, arrfoo: [1, 2], objfoo: { tyrion: 'lannister' } } - t.equal(stringify(obj), '{"foo":["t","r","u","e"],"ofoo":[],"arrfoo":["1","2"],"objfoo":[]}') + const coercibleValues = { arrfoo: [1, 2] } + t.assert.equal(stringify(coercibleValues), '{"arrfoo":["1","2"]}') + + const incoercibleValues = { foo: 'true', ofoo: 0, objfoo: { tyrion: 'lannister' } } + t.assert.throws(() => stringify(incoercibleValues)) +}) + +test('patternProperties - fail on invalid regex, handled by ajv', (t) => { + t.plan(1) + + t.assert.throws(() => build({ + title: 'check array coerce', + type: 'object', + properties: {}, + patternProperties: { + 'foo/\\': { + type: 'array', + items: { + type: 'string' + } + } + } + }), new Error('schema is invalid: data/patternProperties must match format "regex"')) }) diff --git a/test/recursion.test.js b/test/recursion.test.js index 8188ae63..78be6261 100644 --- a/test/recursion.test.js +++ b/test/recursion.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('can stringify recursive directory tree (issue #181)', (t) => { @@ -25,7 +25,7 @@ test('can stringify recursive directory tree (issue #181)', (t) => { } const stringify = build(schema) - t.equal(stringify([ + t.assert.equal(stringify([ { name: 'directory 1', subDirectories: [] }, { name: 'directory 2', @@ -68,7 +68,7 @@ test('can stringify when recursion in external schema', t => { }) const value = stringify({ people: { name: 'Elizabeth', children: [{ name: 'Charles' }] } }) - t.equal(value, '{"people":{"name":"Elizabeth","children":[{"name":"Charles"}]}}') + t.assert.equal(value, '{"people":{"name":"Elizabeth","children":[{"name":"Charles"}]}}') }) test('use proper serialize function', t => { @@ -133,7 +133,7 @@ test('use proper serialize function', t => { ] } }) - t.equal(value, '{"people":{"name":"Elizabeth","children":[{"name":"Charles","children":[{"name":"William","children":[{"name":"George"},{"name":"Charlotte"}]},{"name":"Harry"}]}]},"directory":{"name":"directory 1","subDirectories":[{"name":"directory 1.1","subDirectories":[]},{"name":"directory 1.2","subDirectories":[{"name":"directory 1.2.1","subDirectories":[]},{"name":"directory 1.2.2","subDirectories":[]}]}]}}') + t.assert.equal(value, '{"people":{"name":"Elizabeth","children":[{"name":"Charles","children":[{"name":"William","children":[{"name":"George"},{"name":"Charlotte"}]},{"name":"Harry"}]}]},"directory":{"name":"directory 1","subDirectories":[{"name":"directory 1.1","subDirectories":[]},{"name":"directory 1.2","subDirectories":[{"name":"directory 1.2.1","subDirectories":[]},{"name":"directory 1.2.2","subDirectories":[]}]}]}}') }) test('can stringify recursive references in object types (issue #365)', t => { @@ -176,5 +176,70 @@ test('can stringify recursive references in object types (issue #365)', t => { } } const value = stringify(data) - t.equal(value, '{"category":{"parent":{"parent":{"parent":{"parent":{}}}}}}') + t.assert.equal(value, '{"category":{"parent":{"parent":{"parent":{"parent":{}}}}}}') +}) + +test('can stringify recursive inline $id references (issue #410)', t => { + t.plan(1) + const schema = { + $id: 'Node', + type: 'object', + properties: { + id: { + type: 'string' + }, + nodes: { + type: 'array', + items: { + $ref: 'Node' + } + } + }, + required: [ + 'id', + 'nodes' + ] + } + + const stringify = build(schema) + const data = { + id: '0', + nodes: [ + { + id: '1', + nodes: [{ + id: '2', + nodes: [ + { id: '3', nodes: [] }, + { id: '4', nodes: [] }, + { id: '5', nodes: [] } + ] + }] + }, + { + id: '6', + nodes: [{ + id: '7', + nodes: [ + { id: '8', nodes: [] }, + { id: '9', nodes: [] }, + { id: '10', nodes: [] } + ] + }] + }, + { + id: '11', + nodes: [{ + id: '12', + nodes: [ + { id: '13', nodes: [] }, + { id: '14', nodes: [] }, + { id: '15', nodes: [] } + ] + }] + } + ] + } + const value = stringify(data) + t.assert.equal(value, '{"id":"0","nodes":[{"id":"1","nodes":[{"id":"2","nodes":[{"id":"3","nodes":[]},{"id":"4","nodes":[]},{"id":"5","nodes":[]}]}]},{"id":"6","nodes":[{"id":"7","nodes":[{"id":"8","nodes":[]},{"id":"9","nodes":[]},{"id":"10","nodes":[]}]}]},{"id":"11","nodes":[{"id":"12","nodes":[{"id":"13","nodes":[]},{"id":"14","nodes":[]},{"id":"15","nodes":[]}]}]}]}') }) diff --git a/test/ref.test.js b/test/ref.test.js index fbf9d117..eae4703a 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -1,6 +1,8 @@ 'use strict' -const test = require('tap').test +const clone = require('rfdc')({ proto: true }) + +const { test } = require('node:test') const build = require('..') test('ref internal - properties', (t) => { @@ -35,10 +37,8 @@ test('ref internal - properties', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"str":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"}}') }) test('ref internal - items', (t) => { @@ -67,10 +67,8 @@ test('ref internal - items', (t) => { const stringify = build(schema) const output = stringify(array) - JSON.parse(output) - t.pass() - - t.equal(output, '[{"str":"test"}]') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '[{"str":"test"}]') }) test('ref external - properties', (t) => { @@ -128,10 +126,8 @@ test('ref external - properties', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"str":"test"},"num":{"int":42},"strPlain":"test","strHash":"test"}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"},"num":{"int":42},"strPlain":"test","strHash":"test"}') }) test('ref internal - patternProperties', (t) => { @@ -167,10 +163,8 @@ test('ref internal - patternProperties', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"str":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"}}') }) test('ref internal - additionalProperties', (t) => { @@ -204,10 +198,8 @@ test('ref internal - additionalProperties', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"str":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"}}') }) test('ref internal - pattern-additional Properties', (t) => { @@ -249,10 +241,8 @@ test('ref internal - pattern-additional Properties', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"reg":{"str":"test"},"obj":{"str":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"reg":{"str":"test"},"obj":{"str":"test"}}') }) test('ref external - pattern-additional Properties', (t) => { @@ -300,10 +290,8 @@ test('ref external - pattern-additional Properties', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"reg":{"str":"test"},"obj":{"int":42}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"reg":{"str":"test"},"obj":{"int":42}}') }) test('ref internal - deepObject schema', (t) => { @@ -352,10 +340,8 @@ test('ref internal - deepObject schema', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"winter":{"is":{"coming":{"where":"to town"}}}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"winter":{"is":{"coming":{"where":"to town"}}}}') }) test('ref internal - plain name fragment', (t) => { @@ -392,10 +378,8 @@ test('ref internal - plain name fragment', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"str":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"}}') }) test('ref external - plain name fragment', (t) => { @@ -431,10 +415,10 @@ test('ref external - plain name fragment', (t) => { type: 'object', properties: { first: { - $ref: '#first-schema' + $ref: 'first#first-schema' }, second: { - $ref: '#second-schema' + $ref: 'second#second-schema' } } } @@ -451,10 +435,203 @@ test('ref external - plain name fragment', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"first":{"str":"test"},"second":{"int":42}}') +}) + +test('external reference to $id', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'external-reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"first":{"str":"test"}}') +}) + +test('external reference to key#id', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: '#external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'first#external-reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"first":{"str":"test"}}') +}) + +test('external and inner reference', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'reference', + $ref: '#reference', + definitions: { + inner: { + $id: '#reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"first":{"str":"test"}}') +}) + +test('external reference to key', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'external-reference', + type: 'object', + properties: { + str: { + type: 'string' + } + } + } + } + + const schema = { + type: 'object', + properties: { + first: { + $ref: 'external-reference' + } + } + } + + const object = { first: { str: 'test' } } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"first":{"str":"test"}}') +}) + +test('ref external - plain name fragment', (t) => { + t.plan(2) + + const externalSchema = { + first: { + $id: 'first-schema', + type: 'object', + properties: { + str: { + type: 'string' + } + } + }, + second: { + definitions: { + second: { + $id: 'second-schema', + type: 'object', + properties: { + int: { + type: 'integer' + } + } + } + } + } + } + + const schema = { + title: 'object with $ref to external plain name fragment', + type: 'object', + properties: { + first: { + $ref: 'first-schema' + }, + second: { + $ref: 'second-schema' + } + } + } + + const object = { + first: { + str: 'test' + }, + second: { + int: 42 + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) - t.equal(output, '{"first":{"str":"test"},"second":{"int":42}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"first":{"str":"test"},"second":{"int":42}}') }) test('ref external - duplicate plain name fragment', (t) => { @@ -503,7 +680,7 @@ test('ref external - duplicate plain name fragment', (t) => { $ref: 'external#duplicateSchema' }, other: { - $ref: '#otherSchema' + $ref: 'other#otherSchema' } } } @@ -523,10 +700,8 @@ test('ref external - duplicate plain name fragment', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"local":{"prop":"test"},"external":{"prop":true},"other":{"prop":42}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"local":{"prop":"test"},"external":{"prop":true},"other":{"prop":42}}') }) test('ref external - explicit external plain name fragment must not fallback to other external schemas', (t) => { @@ -580,14 +755,13 @@ test('ref external - explicit external plain name fragment must not fallback to } } - try { + t.assert.throws(() => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) JSON.parse(output) - t.fail() - } catch (e) { - t.pass() - } + }, { + message: 'Cannot find reference "first#wrong"' + }) }) test('ref internal - multiple $ref format', (t) => { @@ -631,10 +805,8 @@ test('ref internal - multiple $ref format', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"zero":"test","a":"test","b":"test","c":"test","d":"test","e":"test"}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"zero":"test","a":"test","b":"test","c":"test","d":"test","e":"test"}') }) test('ref external - external schema with internal ref (object property)', (t) => { @@ -673,10 +845,8 @@ test('ref external - external schema with internal ref (object property)', (t) = const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"prop":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"prop":"test"}}') }) test('ref external - external schema with internal ref (array items)', (t) => { @@ -718,10 +888,8 @@ test('ref external - external schema with internal ref (array items)', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"arr":[{"prop":"test"}]}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"arr":[{"prop":"test"}]}') }) test('ref external - external schema with internal ref (root)', (t) => { @@ -753,10 +921,8 @@ test('ref external - external schema with internal ref (root)', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"prop":"test"}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"prop":"test"}') }) test('ref external - external schema with internal ref (pattern properties)', (t) => { @@ -795,10 +961,8 @@ test('ref external - external schema with internal ref (pattern properties)', (t const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"obj":{"prop":"test"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"prop":"test"}}') }) test('ref in root internal', (t) => { @@ -826,10 +990,8 @@ test('ref in root internal', (t) => { const stringify = build(schema) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"int":42}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"int":42}') }) test('ref in root external', (t) => { @@ -861,10 +1023,8 @@ test('ref in root external', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"int":42}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"int":42}') }) test('ref in root external multiple times', (t) => { @@ -893,17 +1053,15 @@ test('ref in root external multiple times', (t) => { const schema = { title: 'object with $ref in root schema', type: 'object', - $ref: 'numbers#/definitions/num' + $ref: 'numbers' } const object = { int: 42 } const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"int":42}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"int":42}') }) test('ref external to relative definition', (t) => { @@ -934,10 +1092,8 @@ test('ref external to relative definition', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"fooParent":{"foo":"bar"}}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"fooParent":{"foo":"bar"}}') }) test('ref to nested ref definition', (t) => { @@ -972,40 +1128,12 @@ test('ref to nested ref definition', (t) => { const stringify = build(schema, { schema: externalSchema }) const output = stringify(object) - JSON.parse(output) - t.pass() - - t.equal(output, '{"foo":"foo"}') -}) - -test('ref in definition with exact match', (t) => { - t.plan(2) - - const externalSchema = { - '#/definitions/foo': { - type: 'string' - } - } - - const schema = { - type: 'object', - properties: { - foo: { $ref: '#/definitions/foo' } - } - } - - const object = { foo: 'foo' } - const stringify = build(schema, { schema: externalSchema }) - const output = stringify(object) - - JSON.parse(output) - t.pass() - - t.equal(output, '{"foo":"foo"}') + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"foo":"foo"}') }) -test('Bad key', t => { - t.test('Find match', t => { +test('Bad key', async t => { + await t.test('Find match', t => { t.plan(1) try { build({ @@ -1026,13 +1154,14 @@ test('Bad key', t => { }) t.fail('Should throw') } catch (err) { - t.equal(err.message, 'Cannot find reference "porjectId", did you mean "projectId"?') + t.assert.equal(err.message, 'Cannot find reference "#/definitions/porjectId"') } }) - t.test('No match', t => { + await t.test('No match', t => { t.plan(1) - try { + + t.assert.throws(() => { build({ definitions: { projectId: { @@ -1049,15 +1178,12 @@ test('Bad key', t => { } } }) - t.fail('Should throw') - } catch (err) { - t.equal(err.message, 'Cannot find reference "foobar"') - } + }, { message: 'Cannot find reference "#/definitions/foobar"' }) }) - t.test('Find match (external schema)', t => { + await t.test('Find match (external schema)', t => { t.plan(1) - try { + t.assert.throws(() => { build({ type: 'object', properties: { @@ -1080,14 +1206,12 @@ test('Bad key', t => { } }) t.fail('Should throw') - } catch (err) { - t.equal(err.message, 'Cannot find reference "porjectId", did you mean "projectId"?') - } + }, { message: 'Cannot find reference "external#/definitions/porjectId"' }) }) - t.test('No match (external schema)', t => { + await t.test('No match (external schema)', t => { t.plan(1) - try { + t.assert.throws(() => { build({ type: 'object', properties: { @@ -1109,15 +1233,12 @@ test('Bad key', t => { } } }) - t.fail('Should throw') - } catch (err) { - t.equal(err.message, 'Cannot find reference "foobar"') - } + }, { message: 'Cannot find reference "external#/definitions/foobar"' }) }) - t.test('Find match (external definitions typo)', t => { + await t.test('Find match (external definitions typo)', t => { t.plan(1) - try { + t.assert.throws(() => { build({ type: 'object', properties: { @@ -1139,15 +1260,12 @@ test('Bad key', t => { } } }) - t.fail('Should throw') - } catch (err) { - t.equal(err.message, 'Cannot find reference "deifnitions", did you mean "definitions"?') - } + }, { message: 'Cannot find reference "external#/deifnitions/projectId"' }) }) - t.test('Find match (definitions typo)', t => { + await t.test('Find match (definitions typo)', t => { t.plan(1) - try { + t.assert.throws(() => { build({ definitions: { projectId: { @@ -1164,15 +1282,12 @@ test('Bad key', t => { } } }) - t.fail('Should throw') - } catch (err) { - t.equal(err.message, 'Cannot find reference "deifnitions", did you mean "definitions"?') - } + }, { message: 'Cannot find reference "#/deifnitions/projectId"' }) }) - t.test('Find match (external schema typo)', t => { + await t.test('Find match (external schema typo)', t => { t.plan(1) - try { + t.assert.throws(() => { build({ type: 'object', properties: { @@ -1194,13 +1309,8 @@ test('Bad key', t => { } } }) - t.fail('Should throw') - } catch (err) { - t.equal(err.message, 'Cannot find reference "extrenal", did you mean "external"?') - } + }, { message: 'Cannot resolve ref "extrenal#/definitions/projectId". Schema with id "extrenal" is not found.' }) }) - - t.end() }) test('Regression 2.5.2', t => { @@ -1247,5 +1357,690 @@ test('Regression 2.5.2', t => { const stringify = build(schema, { schema: externalSchema }) const output = stringify([{ field: 'parent', sub: { field: 'joined' } }]) - t.equal(output, '[{"field":"parent","sub":{"field":"joined"}}]') + t.assert.equal(output, '[{"field":"parent","sub":{"field":"joined"}}]') +}) + +test('Reference through multiple definitions', (t) => { + t.plan(2) + + const schema = { + $ref: '#/definitions/A', + definitions: { + A: { + type: 'object', + additionalProperties: false, + properties: { a: { anyOf: [{ $ref: '#/definitions/B' }] } }, + required: ['a'] + }, + B: { + type: 'object', + properties: { b: { anyOf: [{ $ref: '#/definitions/C' }] } }, + required: ['b'], + additionalProperties: false + }, + C: { + type: 'object', + properties: { c: { type: 'string', const: 'd' } }, + required: ['c'], + additionalProperties: false + } + } + } + + const object = { a: { b: { c: 'd' } } } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, JSON.stringify(object)) +}) + +test('issue #350', (t) => { + t.plan(2) + + const schema = { + title: 'Example Schema', + type: 'object', + properties: { + firstName: { $ref: '#foo' }, + lastName: { $ref: '#foo' }, + nested: { + type: 'object', + properties: { + firstName: { $ref: '#foo' }, + lastName: { $ref: '#foo' } + } + } + }, + definitions: { + foo: { + $id: '#foo', + type: 'string' + } + } + } + + const object = { + firstName: 'Matteo', + lastName: 'Collina', + nested: { + firstName: 'Matteo', + lastName: 'Collina' + } + } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, JSON.stringify(object)) +}) + +test('deep union type', (t) => { + t.plan(1) + + const stringify = build({ + schema: { + type: 'array', + items: { + oneOf: [ + { + $ref: 'components#/schemas/IDirectory' + }, + { + $ref: 'components#/schemas/IImageFile' + }, + { + $ref: 'components#/schemas/ITextFile' + }, + { + $ref: 'components#/schemas/IZipFile' + } + ] + }, + nullable: false + }, + components: { + schemas: { + IDirectory: { + $id: 'IDirectory', + $recursiveAnchor: true, + type: 'object', + properties: { + children: { + type: 'array', + items: { + oneOf: [ + { + $recursiveRef: '#' + }, + { + $ref: 'components#/schemas/IImageFile' + }, + { + $ref: 'components#/schemas/ITextFile' + }, + { + $ref: 'components#/schemas/IZipFile' + } + ] + }, + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'children', + 'type', + 'id', + 'name' + ] + }, + IImageFile: { + $id: 'IImageFile', + type: 'object', + properties: { + width: { + type: 'number', + nullable: false + }, + height: { + type: 'number', + nullable: false + }, + url: { + type: 'string', + nullable: false + }, + extension: { + type: 'string', + nullable: false + }, + size: { + type: 'number', + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'width', + 'height', + 'url', + 'extension', + 'size', + 'type', + 'id', + 'name' + ] + }, + ITextFile: { + $id: 'ITextFile', + type: 'object', + properties: { + content: { + type: 'string', + nullable: false + }, + extension: { + type: 'string', + nullable: false + }, + size: { + type: 'number', + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'content', + 'extension', + 'size', + 'type', + 'id', + 'name' + ] + }, + IZipFile: { + $id: 'IZipFile', + type: 'object', + properties: { + files: { + type: 'number', + nullable: false + }, + extension: { + type: 'string', + nullable: false + }, + size: { + type: 'number', + nullable: false + }, + type: { + type: 'string', + nullable: false + }, + id: { + type: 'string', + nullable: false + }, + name: { + type: 'string', + nullable: false + } + }, + nullable: false, + required: [ + 'files', + 'extension', + 'size', + 'type', + 'id', + 'name' + ] + } + } + } + }) + + const obj = [ + { + type: 'directory', + id: '7b1068a4-dd6e-474a-8d85-09a2d77639cb', + name: 'ixcWGOKI', + children: [ + { + type: 'directory', + id: '5883e17c-b207-46d4-ad2d-be72249711ce', + name: 'vecQwFGS', + children: [] + }, + { + type: 'file', + id: '670b6556-a610-4a48-8a16-9c2da97a0d18', + name: 'eStFddzX', + extension: 'jpg', + size: 7, + width: 300, + height: 1200, + url: 'https://github.com/samchon/typescript-json' + }, + { + type: 'file', + id: '85dc796d-9593-4833-b1a1-addc8ebf74ea', + name: 'kTdUfwRJ', + extension: 'ts', + size: 86, + content: 'console.log("Hello world");' + }, + { + type: 'file', + id: '8933c86a-7a1e-4d4a-b0a6-17d6896fdf89', + name: 'NBPkefUG', + extension: 'zip', + size: 22, + files: 20 + } + ] + } + ] + t.assert.equal(JSON.stringify(obj), stringify(obj)) +}) + +test('ref with same id in properties', async (t) => { + t.plan(2) + + const externalSchema = { + ObjectId: { + $id: 'ObjectId', + type: 'string' + }, + File: { + $id: 'File', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + name: { type: 'string' }, + owner: { $ref: 'ObjectId' } + } + } + } + + await t.test('anyOf', (t) => { + t.plan(1) + + const schema = { + $id: 'Article', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + anyOf: [ + { $ref: 'File' }, + { type: 'null' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify({ _id: 'foo', image: { _id: 'bar', name: 'hello', owner: 'baz' } }) + + t.assert.equal(output, '{"_id":"foo","image":{"_id":"bar","name":"hello","owner":"baz"}}') + }) + + await t.test('oneOf', (t) => { + t.plan(1) + + const schema = { + $id: 'Article', + type: 'object', + properties: { + _id: { $ref: 'ObjectId' }, + image: { + oneOf: [ + { $ref: 'File' }, + { type: 'null' } + ] + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + const output = stringify({ _id: 'foo', image: { _id: 'bar', name: 'hello', owner: 'baz' } }) + + t.assert.equal(output, '{"_id":"foo","image":{"_id":"bar","name":"hello","owner":"baz"}}') + }) +}) + +test('Should not modify external schemas', (t) => { + t.plan(2) + + const externalSchema = { + uuid: { + format: 'uuid', + $id: 'UUID', + type: 'string' + }, + Entity: { + $id: 'Entity', + type: 'object', + properties: { + id: { $ref: 'UUID' }, + id2: { $ref: 'UUID' } + } + } + } + + const options = { schema: externalSchema } + const optionsClone = clone(options) + + const stringify = build({ $ref: 'Entity' }, options) + + const data = { id: 'a4e4c954-9f5f-443a-aa65-74d95732249a' } + const output = stringify(data) + + t.assert.equal(output, JSON.stringify(data)) + t.assert.deepStrictEqual(options, optionsClone) +}) + +test('input schema is not mutated', (t) => { + t.plan(3) + + const schema = { + title: 'object with $ref', + type: 'object', + definitions: { + def: { type: 'string' } + }, + properties: { + obj: { + $ref: '#/definitions/def' + } + } + } + + const clonedSchema = JSON.parse(JSON.stringify(schema)) + + const object = { + obj: 'test' + } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":"test"}') + t.assert.deepStrictEqual(schema, clonedSchema) +}) + +test('anyOf inside allOf', (t) => { + t.plan(1) + + const schema = { + anyOf: [ + { + type: 'object', + allOf: [ + { + properties: { + a: { + anyOf: [ + { const: 'A1' }, + { const: 'A2' } + ] + } + } + }, + { + properties: { + b: { const: 'B' } + } + } + ] + } + ] + } + + const object = { a: 'A1', b: 'B' } + const stringify = build(schema) + const output = stringify(object) + + t.assert.equal(output, JSON.stringify(object)) +}) + +test('should resolve absolute $refs', (t) => { + t.plan(1) + + const externalSchema = { + FooSchema: { + $id: 'FooSchema', + type: 'object', + properties: { + type: { + anyOf: [ + { type: 'string', const: 'bar' }, + { type: 'string', const: 'baz' } + ] + } + } + } + } + + const schema = { $ref: 'FooSchema' } + + const object = { type: 'bar' } + const stringify = build(schema, { schema: externalSchema }) + const output = stringify(object) + + t.assert.equal(output, JSON.stringify(object)) +}) + +test('nested schema should overwrite anchor scope', (t) => { + t.plan(2) + + const externalSchema = { + root: { + $id: 'root', + definitions: { + subschema: { + $id: 'subschema', + definitions: { + anchorSchema: { + $id: '#anchor', + type: 'string' + } + } + } + } + } + } + + const data = 'test' + const stringify = build({ $ref: 'subschema#anchor' }, { schema: externalSchema }) + const output = stringify(data) + + t.assert.equal(output, JSON.stringify(data)) + t.assert.throws(() => build({ $ref: 'root#anchor' }, { schema: externalSchema })) +}) + +test('object property reference with default value', (t) => { + t.plan(1) + + const schema = { + definitions: { + prop: { + type: 'string', + default: 'foo' + } + }, + type: 'object', + properties: { + prop: { + $ref: '#/definitions/prop' + } + } + } + + const stringify = build(schema) + const output = stringify({}) + + t.assert.equal(output, '{"prop":"foo"}') +}) + +test('should throw an Error if two non-identical schemas with same id are provided', (t) => { + t.plan(1) + + const schema = { + $id: 'schema', + type: 'object', + allOf: [ + { + $id: 'base', + type: 'object', + properties: { + name: { + type: 'string' + } + }, + required: [ + 'name' + ] + }, + { + $id: 'inner_schema', + type: 'object', + properties: { + union: { + $id: '#id', + anyOf: [ + { + + $id: 'guid', + type: 'string' + }, + { + + $id: 'email', + type: 'string' + } + ] + } + }, + required: [ + 'union' + ] + }, + { + $id: 'inner_schema', + type: 'object', + properties: { + union: { + $id: '#id', + anyOf: [ + { + + $id: 'guid', + type: 'string' + }, + { + + $id: 'mail', + type: 'string' + } + ] + } + }, + required: [ + 'union' + ] + } + ] + } + + try { + build(schema) + } catch (err) { + t.assert.equal(err.message, 'There is already another schema with id "inner_schema".') + } +}) + +test('ref internal - throw if schema has definition twice with different shape', (t) => { + t.plan(1) + + const schema = { + $id: 'test', + title: 'object with $ref', + definitions: { + def: { + $id: '#uri', + type: 'object', + properties: { + str: { + type: 'string' + } + }, + required: ['str'] + }, + def2: { + $id: '#uri', + type: 'object', + properties: { + num: { + type: 'number' + } + }, + required: ['num'] + } + }, + type: 'object', + properties: { + obj: { + $ref: '#uri' + } + } + } + + try { + build(schema) + } catch (err) { + t.assert.equal(err.message, 'There is already another anchor "#uri" in schema "test".') + } }) diff --git a/test/regex.test.js b/test/regex.test.js index 56706424..e08f8672 100644 --- a/test/regex.test.js +++ b/test/regex.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') @@ -25,9 +25,8 @@ test('object with RexExp', (t) => { const validate = validator(schema) const output = stringify(obj) - JSON.parse(output) - t.pass() + t.assert.doesNotThrow(() => JSON.parse(output)) - t.equal(obj.reg.source, new RegExp(JSON.parse(output).reg).source) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(obj.reg.source, new RegExp(JSON.parse(output).reg).source) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) diff --git a/test/required.test.js b/test/required.test.js index 6ce91328..96d74705 100644 --- a/test/required.test.js +++ b/test/required.test.js @@ -1,10 +1,10 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('object with required field', (t) => { - t.plan(3) + t.plan(2) const schema = { title: 'object with required field', @@ -21,24 +21,21 @@ test('object with required field', (t) => { } const stringify = build(schema) - stringify({ - str: 'string' + t.assert.doesNotThrow(() => { + stringify({ + str: 'string' + }) }) - t.pass() - try { + t.assert.throws(() => { stringify({ num: 42 }) - t.fail() - } catch (e) { - t.equal(e.message, '"str" is required!') - t.pass() - } + }, { message: '"str" is required!' }) }) test('object with required field not in properties schema', (t) => { - t.plan(4) + t.plan(2) const schema = { title: 'object with required field', @@ -52,27 +49,19 @@ test('object with required field not in properties schema', (t) => { } const stringify = build(schema) - try { + t.assert.throws(() => { stringify({}) - t.fail() - } catch (e) { - t.equal(e.message, '"str" is required!') - t.pass() - } + }, { message: '"str" is required!' }) - try { + t.assert.throws(() => { stringify({ num: 42 }) - t.fail() - } catch (e) { - t.equal(e.message, '"str" is required!') - t.pass() - } + }, { message: '"str" is required!' }) }) test('object with required field not in properties schema with additional properties true', (t) => { - t.plan(4) + t.plan(2) const schema = { title: 'object with required field', @@ -87,27 +76,19 @@ test('object with required field not in properties schema with additional proper } const stringify = build(schema) - try { + t.assert.throws(() => { stringify({}) - t.fail() - } catch (e) { - t.equal(e.message, '"str" is required!') - t.pass() - } + }, { message: '"str" is required!' }) - try { + t.assert.throws(() => { stringify({ num: 42 }) - t.fail() - } catch (e) { - t.equal(e.message, '"str" is required!') - t.pass() - } + }, { message: '"str" is required!' }) }) test('object with multiple required field not in properties schema', (t) => { - t.plan(6) + t.plan(3) const schema = { title: 'object with required field', @@ -122,34 +103,23 @@ test('object with multiple required field not in properties schema', (t) => { } const stringify = build(schema) - try { + t.assert.throws(() => { stringify({}) - t.fail() - } catch (e) { - t.equal(e.message, '"num" is required!') - t.pass() - } + }, { message: '"key1" is required!' }) - try { + t.assert.throws(() => { stringify({ - num: 42 + key1: 42, + key2: 42 }) - t.fail() - } catch (e) { - t.equal(e.message, '"key1" is required!') - t.pass() - } + }, { message: '"num" is required!' }) - try { + t.assert.throws(() => { stringify({ num: 42, key1: 'some' }) - t.fail() - } catch (e) { - t.equal(e.message, '"key2" is required!') - t.pass() - } + }, { message: '"key2" is required!' }) }) test('object with required bool', (t) => { @@ -168,16 +138,14 @@ test('object with required bool', (t) => { } const stringify = build(schema) - try { + t.assert.throws(() => { stringify({}) - t.fail() - } catch (e) { - t.equal(e.message, '"bool" is required!') - t.pass() - } + }, { message: '"bool" is required!' }) - stringify({ - bool: false + t.assert.doesNotThrow(() => { + stringify({ + bool: false + }) }) }) @@ -197,14 +165,15 @@ test('required nullable', (t) => { } const stringify = build(schema) - stringify({ - null: null + t.assert.doesNotThrow(() => { + stringify({ + null: null + }) }) - t.pass() }) test('required numbers', (t) => { - t.plan(3) + t.plan(2) const schema = { title: 'object with required field', @@ -221,18 +190,15 @@ test('required numbers', (t) => { } const stringify = build(schema) - stringify({ - num: 42 + t.assert.doesNotThrow(() => { + stringify({ + num: 42 + }) }) - t.pass() - try { + t.assert.throws(() => { stringify({ num: 'aaa' }) - t.fail() - } catch (e) { - t.equal(e.message, '"num" is required!') - t.pass() - } + }, { message: 'The value "aaa" cannot be converted to an integer.' }) }) diff --git a/test/requiresAjv.test.js b/test/requiresAjv.test.js index fdf4c1fd..c62289ab 100644 --- a/test/requiresAjv.test.js +++ b/test/requiresAjv.test.js @@ -1,48 +1,50 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') -t.test('nested ref requires ajv', async t => { - const schemaA = { - $id: 'urn:schema:a', - definitions: { - foo: { anyOf: [{ type: 'string' }, { type: 'null' }] } +test('nested ref requires ajv', async t => { + t.test('nested ref requires ajv', async t => { + const schemaA = { + $id: 'urn:schema:a', + definitions: { + foo: { anyOf: [{ type: 'string' }, { type: 'null' }] } + } } - } - const schemaB = { - $id: 'urn:schema:b', - type: 'object', - properties: { - results: { - type: 'object', - properties: { - items: { - type: 'object', - properties: { - bar: { - type: 'array', - items: { $ref: 'urn:schema:a#/definitions/foo' } + const schemaB = { + $id: 'urn:schema:b', + type: 'object', + properties: { + results: { + type: 'object', + properties: { + items: { + type: 'object', + properties: { + bar: { + type: 'array', + items: { $ref: 'urn:schema:a#/definitions/foo' } + } } } } } } } - } - const stringify = build(schemaB, { - schema: { - [schemaA.$id]: schemaA - } - }) - const result = stringify({ - results: { - items: { - bar: ['baz'] + const stringify = build(schemaB, { + schema: { + [schemaA.$id]: schemaA } - } + }) + const result = stringify({ + results: { + items: { + bar: ['baz'] + } + } + }) + t.assert.equal(result, '{"results":{"items":{"bar":["baz"]}}}') }) - t.same(result, '{"results":{"items":{"bar":["baz"]}}}') }) diff --git a/test/sanitize.test.js b/test/sanitize.test.js index 0cf6febe..18bd90e7 100644 --- a/test/sanitize.test.js +++ b/test/sanitize.test.js @@ -1,6 +1,6 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') const stringify = build({ @@ -91,52 +91,51 @@ const obj = { notmatchnum: 42 } -// pass if it does not crash -const json = stringify(obj) -JSON.parse(json) +test('sanitize', t => { + const json = stringify(obj) + t.assert.doesNotThrow(() => JSON.parse(json)) -const stringify2 = build({ - title: 'Example Schema', - type: 'object', - patternProperties: { - '"\'w00t.*////': { - type: 'number' + const stringify2 = build({ + title: 'Example Schema', + type: 'object', + patternProperties: { + '"\'w00t.*////': { + type: 'number' + } } - } -}) + }) -t.same(JSON.parse(stringify2({ - '"\'phra////': 42, - asd: 42 -})), { -}) + t.assert.deepStrictEqual(JSON.parse(stringify2({ + '"\'phra////': 42, + asd: 42 + })), { + }) -const stringify3 = build({ - title: 'Example Schema', - type: 'object', - properties: { - "\"phra\\'&&(console.log(42))//||'phra": {} - } -}) + const stringify3 = build({ + title: 'Example Schema', + type: 'object', + properties: { + "\"phra\\'&&(console.log(42))//||'phra": {} + } + }) -// this verifies the escaping -JSON.parse(stringify3({ - '"phra\'&&(console.log(42))//||\'phra': 42 -})) + // this verifies the escaping + JSON.parse(stringify3({ + '"phra\'&&(console.log(42))//||\'phra': 42 + })) -const stringify4 = build({ - title: 'Example Schema', - type: 'object', - properties: { - '"\\\\\\\\\'w00t': { - type: 'string', - default: '"\'w00t' + const stringify4 = build({ + title: 'Example Schema', + type: 'object', + properties: { + '"\\\\\\\\\'w00t': { + type: 'string', + default: '"\'w00t' + } } - } -}) + }) -t.same(JSON.parse(stringify4({})), { - '"\\\\\\\\\'w00t': '"\'w00t' + t.assert.deepStrictEqual(JSON.parse(stringify4({})), { + '"\\\\\\\\\'w00t': '"\'w00t' + }) }) - -t.pass('no crashes') diff --git a/test/sanitize2.test.js b/test/sanitize2.test.js index 06290247..9c7382d3 100644 --- a/test/sanitize2.test.js +++ b/test/sanitize2.test.js @@ -1,18 +1,18 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') -const payload = '(throw "pwoned")' +test('sanitize 2', t => { + const payload = '(throw "pwoned")' -const stringify = build({ - properties: { - [`*///\\\\\\']);${payload};{/*`]: { - type: 'number' + const stringify = build({ + properties: { + [`*///\\\\\\']);${payload};{/*`]: { + type: 'number' + } } - } -}) - -stringify({}) + }) -t.pass('no crashes') + t.assert.doesNotThrow(() => stringify({})) +}) diff --git a/test/sanitize3.test.js b/test/sanitize3.test.js index 042ebe90..ac3bab5a 100644 --- a/test/sanitize3.test.js +++ b/test/sanitize3.test.js @@ -1,17 +1,17 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') -const stringify = build({ - $defs: { - type: 'foooo"bar' - }, - patternProperties: { - x: { $ref: '#/$defs' } - } +test('sanitize 3', t => { + t.assert.throws(() => { + build({ + $defs: { + type: 'foooo"bar' + }, + patternProperties: { + x: { $ref: '#/$defs' } + } + }) + }, { message: 'foooo"bar unsupported' }) }) - -t.throws(() => { - stringify({ x: 0 }) -}, 'Cannot coerce 0 to "foo"bar"') diff --git a/test/sanitize4.test.js b/test/sanitize4.test.js index 5ec869a4..c1486dfe 100644 --- a/test/sanitize4.test.js +++ b/test/sanitize4.test.js @@ -1,14 +1,16 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') -const payload = '(throw "pwoned")' +test('sanitize 4', t => { + const payload = '(throw "pwoned")' -const stringify = build({ - required: [`"];${payload}//`] -}) + const stringify = build({ + required: [`"];${payload}//`] + }) -t.throws(() => { - stringify({}) -}, 'Error: ""];(throw "pwoned")//" is required!') + t.assert.throws(() => { + stringify({}) + }, { message: '""];(throw "pwoned")//" is required!' }) +}) diff --git a/test/sanitize5.test.js b/test/sanitize5.test.js index ec45927b..039a355c 100644 --- a/test/sanitize5.test.js +++ b/test/sanitize5.test.js @@ -1,16 +1,16 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') -const payload = '(throw "pwoned")' +test('sanitize 5', t => { + const payload = '(throw "pwoned")' -const expected = 'Error: Invalid regular expression: /*/: Nothing to repeat. Found at * matching {"type":"*/(throw \\"pwoned\\")){//"}' - -t.throws(() => { - build({ - patternProperties: { - '*': { type: `*/${payload}){//` } - } - }) -}, expected) + t.assert.throws(() => { + build({ + patternProperties: { + '*': { type: `*/${payload}){//` } + } + }) + }, { message: 'schema is invalid: data/patternProperties must match format "regex"' }) +}) diff --git a/test/sanitize6.test.js b/test/sanitize6.test.js index 68b09968..e8dfb4c8 100644 --- a/test/sanitize6.test.js +++ b/test/sanitize6.test.js @@ -1,22 +1,22 @@ 'use strict' -const t = require('tap') +const { test } = require('node:test') const build = require('..') -const payload = '(throw "pwoned")' +test('sanitize 6', t => { + const payload = '(throw "pwoned")' -const stringify = build({ - type: 'object', - properties: { - '/*': { type: 'object' }, - x: { - type: 'object', - properties: { - a: { type: 'string', default: `*/}${payload};{//` } + const stringify = build({ + type: 'object', + properties: { + '/*': { type: 'object' }, + x: { + type: 'object', + properties: { + a: { type: 'string', default: `*/}${payload};{//` } + } } } - } + }) + t.assert.doesNotThrow(() => { stringify({}) }) }) -stringify({}) - -t.pass('no crashes') diff --git a/test/sanitize7.test.js b/test/sanitize7.test.js new file mode 100644 index 00000000..d678bfd7 --- /dev/null +++ b/test/sanitize7.test.js @@ -0,0 +1,68 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +test('required property containing single quote, contains property', (t) => { + t.plan(1) + + const stringify = build({ + type: 'object', + properties: { + '\'': { type: 'string' } + }, + required: [ + '\'' + ] + }) + + t.assert.throws(() => stringify({}), new Error('"\'" is required!')) +}) + +test('required property containing double quote, contains property', (t) => { + t.plan(1) + + const stringify = build({ + type: 'object', + properties: { + '"': { type: 'string' } + }, + required: [ + '"' + ] + }) + + t.assert.throws(() => stringify({}), new Error('""" is required!')) +}) + +test('required property containing single quote, does not contain property', (t) => { + t.plan(1) + + const stringify = build({ + type: 'object', + properties: { + a: { type: 'string' } + }, + required: [ + '\'' + ] + }) + + t.assert.throws(() => stringify({}), new Error('"\'" is required!')) +}) + +test('required property containing double quote, does not contain property', (t) => { + t.plan(1) + + const stringify = build({ + type: 'object', + properties: { + a: { type: 'string' } + }, + required: [ + '"' + ] + }) + + t.assert.throws(() => stringify({}), new Error('""" is required!')) +}) diff --git a/test/side-effect.test.js b/test/side-effect.test.js index a3eece9e..f1a1f30d 100644 --- a/test/side-effect.test.js +++ b/test/side-effect.test.js @@ -1,6 +1,6 @@ 'use strict' -const { test } = require('tap') +const { test } = require('node:test') const clone = require('rfdc/default') const build = require('..') @@ -32,8 +32,8 @@ test('oneOf with $ref should not change the input schema', t => { }) const value = stringify({ people: { name: 'hello', foo: 'bar' } }) - t.equal(value, '{"people":{"name":"hello"}}') - t.same(schema, clonedSchema) + t.assert.equal(value, '{"people":{"name":"hello"}}') + t.assert.deepStrictEqual(schema, clonedSchema) }) test('oneOf and anyOf with $ref should not change the input schema', t => { @@ -75,9 +75,9 @@ test('oneOf and anyOf with $ref should not change the input schema', t => { const valueAny1 = stringify({ people: { name: 'hello', foo: 'bar' }, love: 'music' }) const valueAny2 = stringify({ people: { name: 'hello', foo: 'bar' }, love: true }) - t.equal(valueAny1, '{"people":{"name":"hello"},"love":"music"}') - t.equal(valueAny2, '{"people":{"name":"hello"},"love":true}') - t.same(schema, clonedSchema) + t.assert.equal(valueAny1, '{"people":{"name":"hello"},"love":"music"}') + t.assert.equal(valueAny2, '{"people":{"name":"hello"},"love":true}') + t.assert.deepStrictEqual(schema, clonedSchema) }) test('multiple $ref tree', t => { @@ -118,8 +118,8 @@ test('multiple $ref tree', t => { }) const value = stringify({ people: { name: 'hello', foo: 'bar', age: 42 } }) - t.equal(value, '{"people":{"name":"hello","age":42}}') - t.same(schema, clonedSchema) + t.assert.equal(value, '{"people":{"name":"hello","age":42}}') + t.assert.deepStrictEqual(schema, clonedSchema) }) test('must not mutate items $ref', t => { @@ -152,8 +152,8 @@ test('must not mutate items $ref', t => { }) const value = stringify([{ name: 'foo' }]) - t.equal(value, '[{"name":"foo"}]') - t.same(schema, clonedSchema) + t.assert.equal(value, '[{"name":"foo"}]') + t.assert.deepStrictEqual(schema, clonedSchema) }) test('must not mutate items referred by $ref', t => { @@ -191,6 +191,6 @@ test('must not mutate items referred by $ref', t => { }) const value = stringify({ name: { name: 'foo' } }) - t.equal(value, '{"name":{"name":"foo"}}') - t.same(firstSchema, clonedSchema) + t.assert.equal(value, '{"name":{"name":"foo"}}') + t.assert.deepStrictEqual(firstSchema, clonedSchema) }) diff --git a/test/standalone-mode.test.js b/test/standalone-mode.test.js new file mode 100644 index 00000000..528d05fa --- /dev/null +++ b/test/standalone-mode.test.js @@ -0,0 +1,219 @@ +'use strict' + +const { test, after } = require('node:test') +const fjs = require('..') +const fs = require('fs') +const path = require('path') + +function build (opts, schema) { + return fjs(schema || { + title: 'default string', + type: 'object', + properties: { + firstName: { + type: 'string' + } + }, + required: ['firstName'] + }, opts) +} + +const tmpDir = 'test/fixtures' + +test('activate standalone mode', async (t) => { + t.plan(3) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const code = build({ mode: 'standalone' }) + t.assert.ok(typeof code === 'string') + t.assert.equal(code.indexOf('ajv'), -1) + + const destination = path.resolve(tmpDir, 'standalone.js') + + await fs.promises.writeFile(destination, code) + const standalone = require(destination) + t.assert.equal(standalone({ firstName: 'Foo', surname: 'bar' }), JSON.stringify({ firstName: 'Foo' }), 'surname evicted') +}) + +test('test ajv schema', async (t) => { + t.plan(3) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const code = build({ mode: 'standalone' }, { + type: 'object', + properties: { + }, + if: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['foobar'] } + } + }, + then: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['foobar'] }, + foo: { type: 'string' }, + bar: { type: 'number' }, + list: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'string' } + } + } + } + } + }, + else: { + type: 'object', + properties: { + kind: { type: 'string', enum: ['greeting'] }, + hi: { type: 'string' }, + hello: { type: 'number' }, + list: { + type: 'array', + items: { + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'string' } + } + } + } + } + } + }) + t.assert.ok(typeof code === 'string') + t.assert.equal(code.indexOf('ajv') > 0, true) + + const destination = path.resolve(tmpDir, 'standalone2.js') + + await fs.promises.writeFile(destination, code) + const standalone = require(destination) + t.assert.equal(standalone({ + kind: 'foobar', + foo: 'FOO', + list: [{ + name: 'name', + value: 'foo' + }], + bar: 42, + hi: 'HI', + hello: 45, + a: 'A', + b: 35 + }), JSON.stringify({ + kind: 'foobar', + foo: 'FOO', + bar: 42, + list: [{ + name: 'name', + value: 'foo' + }] + })) +}) + +test('no need to keep external schemas once compiled', async (t) => { + t.plan(1) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const externalSchema = { + first: { + definitions: { + id1: { + type: 'object', + properties: { + id1: { + type: 'integer' + } + } + } + } + } + } + const code = fjs({ + $ref: 'first#/definitions/id1' + }, { + mode: 'standalone', + schema: externalSchema + }) + + const destination = path.resolve(tmpDir, 'standalone3.js') + + await fs.promises.writeFile(destination, code) + const standalone = require(destination) + + t.assert.equal(standalone({ id1: 5 }), JSON.stringify({ id1: 5 }), 'serialization works with external schemas') +}) + +test('no need to keep external schemas once compiled - with oneOf validator', async (t) => { + t.plan(2) + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + const externalSchema = { + ext: { + definitions: { + oBaz: { + type: 'object', + properties: { + baz: { type: 'number' } + }, + required: ['baz'] + }, + oBar: { + type: 'object', + properties: { + bar: { type: 'string' } + }, + required: ['bar'] + }, + other: { + type: 'string', + const: 'other' + } + } + } + } + + const schema = { + title: 'object with oneOf property value containing refs to external schema', + type: 'object', + properties: { + oneOfSchema: { + oneOf: [ + { $ref: 'ext#/definitions/oBaz' }, + { $ref: 'ext#/definitions/oBar' } + ] + } + }, + required: ['oneOfSchema'] + } + + const code = fjs(schema, { + mode: 'standalone', + schema: externalSchema + }) + + const destination = path.resolve(tmpDir, 'standalone-oneOf-ref.js') + + await fs.promises.writeFile(destination, code) + const stringify = require(destination) + + t.assert.equal(stringify({ oneOfSchema: { baz: 5 } }), '{"oneOfSchema":{"baz":5}}') + t.assert.equal(stringify({ oneOfSchema: { bar: 'foo' } }), '{"oneOfSchema":{"bar":"foo"}}') +}) diff --git a/test/string.test.js b/test/string.test.js new file mode 100644 index 00000000..518513da --- /dev/null +++ b/test/string.test.js @@ -0,0 +1,84 @@ +'use strict' + +const { test } = require('node:test') + +const build = require('..') + +test('serialize short string', (t) => { + t.plan(2) + + const schema = { + type: 'string' + } + + const input = 'abcd' + const stringify = build(schema) + const output = stringify(input) + + t.assert.equal(output, '"abcd"') + t.assert.equal(JSON.parse(output), input) +}) + +test('serialize short string', (t) => { + t.plan(2) + + const schema = { + type: 'string' + } + + const input = '\x00' + const stringify = build(schema) + const output = stringify(input) + + t.assert.equal(output, '"\\u0000"') + t.assert.equal(JSON.parse(output), input) +}) + +test('serialize long string', (t) => { + t.plan(2) + + const schema = { + type: 'string' + } + + const input = new Array(2e4).fill('\x00').join('') + const stringify = build(schema) + const output = stringify(input) + + t.assert.equal(output, `"${new Array(2e4).fill('\\u0000').join('')}"`) + t.assert.equal(JSON.parse(output), input) +}) + +test('unsafe string', (t) => { + t.plan(2) + + const schema = { + type: 'string', + format: 'unsafe' + } + + const input = 'abcd' + const stringify = build(schema) + const output = stringify(input) + + t.assert.equal(output, `"${input}"`) + t.assert.equal(JSON.parse(output), input) +}) + +test('unsafe unescaped string', (t) => { + t.plan(2) + + const schema = { + type: 'string', + format: 'unsafe' + } + + const input = 'abcd "abcd"' + const stringify = build(schema) + const output = stringify(input) + + t.assert.equal(output, `"${input}"`) + t.assert.throws(function () { + JSON.parse(output) + }) +}) diff --git a/test/surrogate.test.js b/test/surrogate.test.js index f45e2b4a..37943bd7 100644 --- a/test/surrogate.test.js +++ b/test/surrogate.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const validator = require('is-my-json-valid') const build = require('..') @@ -16,8 +16,8 @@ test('render a string with surrogate pairs as JSON:test 1', (t) => { const stringify = build(schema) const output = stringify('𝌆') - t.equal(output, '"𝌆"') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '"𝌆"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render a string with surrogate pairs as JSON: test 2', (t) => { @@ -32,8 +32,8 @@ test('render a string with surrogate pairs as JSON: test 2', (t) => { const stringify = build(schema) const output = stringify('\uD834\uDF06') - t.equal(output, '"𝌆"') - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, '"𝌆"') + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render a string with Unpaired surrogate code as JSON', (t) => { @@ -47,8 +47,8 @@ test('render a string with Unpaired surrogate code as JSON', (t) => { const validate = validator(schema) const stringify = build(schema) const output = stringify('\uDF06\uD834') - t.equal(output, JSON.stringify('\uDF06\uD834')) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify('\uDF06\uD834')) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) test('render a string with lone surrogate code as JSON', (t) => { @@ -62,6 +62,6 @@ test('render a string with lone surrogate code as JSON', (t) => { const validate = validator(schema) const stringify = build(schema) const output = stringify('\uDEAD') - t.equal(output, JSON.stringify('\uDEAD')) - t.ok(validate(JSON.parse(output)), 'valid schema') + t.assert.equal(output, JSON.stringify('\uDEAD')) + t.assert.ok(validate(JSON.parse(output)), 'valid schema') }) diff --git a/test/toJSON.test.js b/test/toJSON.test.js index 04ac2975..61010b1b 100644 --- a/test/toJSON.test.js +++ b/test/toJSON.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('use toJSON method on object types', (t) => { @@ -22,7 +22,7 @@ test('use toJSON method on object types', (t) => { } } - t.equal('{"productName":"cola"}', stringify(object)) + t.assert.equal('{"productName":"cola"}', stringify(object)) }) test('use toJSON method on nested object types', (t) => { @@ -55,7 +55,7 @@ test('use toJSON method on nested object types', (t) => { } ] - t.equal('[{"productName":"cola"},{"productName":"sprite"}]', stringify(array)) + t.assert.equal('[{"productName":"cola"},{"productName":"sprite"}]', stringify(array)) }) test('not use toJSON if does not exist', (t) => { @@ -79,7 +79,7 @@ test('not use toJSON if does not exist', (t) => { product: { name: 'cola' } } - t.equal('{"product":{"name":"cola"}}', stringify(object)) + t.assert.equal('{"product":{"name":"cola"}}', stringify(object)) }) test('not fail on null object declared nullable', (t) => { @@ -100,7 +100,7 @@ test('not fail on null object declared nullable', (t) => { } } }) - t.equal('null', stringify(null)) + t.assert.equal('null', stringify(null)) }) test('not fail on null sub-object declared nullable', (t) => { @@ -124,10 +124,10 @@ test('not fail on null sub-object declared nullable', (t) => { const object = { product: null } - t.equal('{"product":null}', stringify(object)) + t.assert.equal('{"product":null}', stringify(object)) }) -test('throw an error on non nullable null sub-object', (t) => { +test('on non nullable null sub-object it should coerce to {}', (t) => { t.plan(1) const stringify = build({ @@ -148,10 +148,12 @@ test('throw an error on non nullable null sub-object', (t) => { const object = { product: null } - t.throws(() => { stringify(object) }) + + const result = stringify(object) + t.assert.equal(result, JSON.stringify({ product: {} })) }) -test('throw an error on non nullable null object', (t) => { +test('on non nullable null object it should coerce to {}', (t) => { t.plan(1) const stringify = build({ @@ -170,5 +172,32 @@ test('throw an error on non nullable null object', (t) => { } } }) - t.throws(() => { stringify(null) }) + + const result = stringify(null) + t.assert.equal(result, '{}') +}) + +test('on non-nullable null object it should skip rendering, skipping required fields checks', (t) => { + t.plan(1) + + const stringify = build({ + title: 'simple object', + nullable: false, + type: 'object', + properties: { + product: { + nullable: false, + type: 'object', + properties: { + name: { + type: 'string' + } + } + } + }, + required: ['product'] + }) + + const result = stringify(null) + t.assert.equal(result, '{}') }) diff --git a/test/typebox.test.js b/test/typebox.test.js new file mode 100644 index 00000000..9bf8f28d --- /dev/null +++ b/test/typebox.test.js @@ -0,0 +1,36 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +test('nested object in pattern properties for typebox', (t) => { + const { Type } = require('@sinclair/typebox') + + t.plan(1) + + const nestedSchema = Type.Object({ + nestedKey1: Type.String() + }) + + const RootSchema = Type.Object({ + key1: Type.Record(Type.String(), nestedSchema), + key2: Type.Record(Type.String(), nestedSchema) + }) + + const schema = RootSchema + const stringify = build(schema) + + const value = stringify({ + key1: { + nestedKey: { + nestedKey1: 'value1' + } + }, + key2: { + nestedKey: { + nestedKey1: 'value2' + } + } + }) + t.assert.equal(value, '{"key1":{"nestedKey":{"nestedKey1":"value1"}},"key2":{"nestedKey":{"nestedKey1":"value2"}}}') +}) diff --git a/test/types/test.ts b/test/types/test.ts deleted file mode 100644 index 939d5317..00000000 --- a/test/types/test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import build, { Schema } from '../..' - -// Number schemas -const schema1: Schema = { - type: 'number' -} -const schema2: Schema = { - type: 'integer' -} - -build(schema1)(25) -build(schema2)(-5) - -// String schema -const schema3: Schema = { - type: 'string' -} - -build(schema3)('foobar') - -// Boolean schema -const schema4: Schema = { - type: 'boolean' -} - -build(schema4)(true) - -// Null schema -const schema5: Schema = { - type: 'null' -} - -build(schema5)(null) - -// Array schemas -const schema6: Schema = { - type: 'array', - items: { type: 'number' } -} -const schema7: Schema = { - type: 'array', - items: [{ type: 'string'}, {type: 'integer'}] -} - -build(schema6)([25]) -build(schema7)(['hello', 42]) - -// Object schemas -const schema8: Schema = { - type: 'object' -} -const schema9: Schema = { - type: 'object', - properties: { - foo: { type: 'string' }, - bar: { type: 'integer' } - }, - required: ['foo'], - patternProperties: { - 'baz*': { type: 'null' } - }, - additionalProperties: { - type: 'boolean' - } -} - -build(schema8)({}) -build(schema9)({ foo: 'bar' }) -build(schema9, { rounding: 'floor' })({ foo: 'bar' }) - -// Reference schemas -const schema10: Schema = { - title: 'Example Schema', - definitions: { - num: { - type: 'object', - properties: { - int: { - type: 'integer' - } - } - }, - str: { - type: 'string' - }, - def: { - type: 'null' - } - }, - type: 'object', - properties: { - nickname: { - $ref: '#/definitions/str' - } - }, - patternProperties: { - 'num': { - $ref: '#/definitions/num' - } - }, - additionalProperties: { - $ref: '#/definitions/def' - } -} - -build(schema10)({ nickname: '', num: { int: 5 }, other: null }) - -// Conditional/Combined schemas -const schema11: Schema = { - title: 'Conditional/Combined Schema', - type: 'object', - properties: { - something: { - anyOf: [ - { type: 'string' }, - { type: 'boolean' } - ] - } - }, - if: { - properties: { - something: { type: 'string' } - } - }, - then: { - properties: { - somethingElse: { type: 'number' } - } - }, - else: { - properties: { - somethingElse: { type: 'null' } - } - } -} - -build(schema11)({ something: 'a string', somethingElse: 42 }) - -// String schema with format -const schema12: Schema = { - type: 'string', - format: 'date-time' -} - -build(schema12)(new Date()) \ No newline at end of file diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json deleted file mode 100644 index acbda84e..00000000 --- a/test/types/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "esModuleInterop": true, - "noEmit": true, - "rootDir": "../..", - "strict": true, - "target": "ES2015", - "moduleResolution": "node" - }, - "include": [ - "./test.ts" - ] -} diff --git a/test/typesArray.test.js b/test/typesArray.test.js index e85855b4..341cc032 100644 --- a/test/typesArray.test.js +++ b/test/typesArray.test.js @@ -1,7 +1,6 @@ 'use strict' -const test = require('tap').test -const moment = require('moment') +const { test } = require('node:test') const build = require('..') test('possibly nullable integer primitive alternative', (t) => { @@ -22,7 +21,7 @@ test('possibly nullable integer primitive alternative', (t) => { const value = stringify({ data: 4 }) - t.equal(value, '{"data":4}') + t.assert.equal(value, '{"data":4}') }) test('possibly nullable number primitive alternative', (t) => { @@ -43,7 +42,7 @@ test('possibly nullable number primitive alternative', (t) => { const value = stringify({ data: 4 }) - t.equal(value, '{"data":4}') + t.assert.equal(value, '{"data":4}') }) test('possibly nullable integer primitive alternative with null value', (t) => { @@ -64,7 +63,7 @@ test('possibly nullable integer primitive alternative with null value', (t) => { const value = stringify({ data: null }) - t.equal(value, '{"data":0}') + t.assert.equal(value, '{"data":0}') }) test('possibly nullable number primitive alternative with null value', (t) => { @@ -85,7 +84,28 @@ test('possibly nullable number primitive alternative with null value', (t) => { const value = stringify({ data: null }) - t.equal(value, '{"data":0}') + t.assert.equal(value, '{"data":0}') +}) + +test('possibly nullable number primitive alternative with null value', (t) => { + t.plan(1) + + const schema = { + title: 'simple object with multi-type nullable primitive', + type: 'object', + properties: { + data: { + type: ['boolean'] + } + } + } + + const stringify = build(schema) + + const value = stringify({ + data: null + }) + t.assert.equal(value, '{"data":false}') }) test('nullable integer primitive', (t) => { @@ -106,7 +126,7 @@ test('nullable integer primitive', (t) => { const value = stringify({ data: 4 }) - t.equal(value, '{"data":4}') + t.assert.equal(value, '{"data":4}') }) test('nullable number primitive', (t) => { @@ -127,7 +147,7 @@ test('nullable number primitive', (t) => { const value = stringify({ data: 4 }) - t.equal(value, '{"data":4}') + t.assert.equal(value, '{"data":4}') }) test('nullable primitive with null value', (t) => { @@ -148,7 +168,7 @@ test('nullable primitive with null value', (t) => { const value = stringify({ data: null }) - t.equal(value, '{"data":null}') + t.assert.equal(value, '{"data":null}') }) test('nullable number primitive with null value', (t) => { @@ -169,7 +189,7 @@ test('nullable number primitive with null value', (t) => { const value = stringify({ data: null }) - t.equal(value, '{"data":null}') + t.assert.equal(value, '{"data":null}') }) test('possibly null object with multi-type property', (t) => { @@ -191,19 +211,19 @@ test('possibly null object with multi-type property', (t) => { } const stringify = build(schema) - t.equal(stringify({ + t.assert.equal(stringify({ objectOrNull: { stringOrNumber: 'string' } }), '{"objectOrNull":{"stringOrNumber":"string"}}') - t.equal(stringify({ + t.assert.equal(stringify({ objectOrNull: { stringOrNumber: 42 } }), '{"objectOrNull":{"stringOrNumber":42}}') - t.equal(stringify({ + t.assert.equal(stringify({ objectOrNull: null }), '{"objectOrNull":null}') }) @@ -229,7 +249,7 @@ test('object with possibly null array of multiple types', (t) => { const value = stringify({ arrayOfStringsAndNumbers: null }) - t.equal(value, '{"arrayOfStringsAndNumbers":null}') + t.assert.equal(value, '{"arrayOfStringsAndNumbers":null}') } catch (e) { console.log(e) t.fail() @@ -239,21 +259,21 @@ test('object with possibly null array of multiple types', (t) => { const value = stringify({ arrayOfStringsAndNumbers: ['string1', 'string2'] }) - t.equal(value, '{"arrayOfStringsAndNumbers":["string1","string2"]}') + t.assert.equal(value, '{"arrayOfStringsAndNumbers":["string1","string2"]}') } catch (e) { console.log(e) t.fail() } - t.equal(stringify({ + t.assert.equal(stringify({ arrayOfStringsAndNumbers: [42, 7] }), '{"arrayOfStringsAndNumbers":[42,7]}') - t.equal(stringify({ + t.assert.equal(stringify({ arrayOfStringsAndNumbers: ['string1', 42, 7, 'string2'] }), '{"arrayOfStringsAndNumbers":["string1",42,7,"string2"]}') - t.equal(stringify({ + t.assert.equal(stringify({ arrayOfStringsAndNumbers: ['string1', null, 42, 7, 'string2', null] }), '{"arrayOfStringsAndNumbers":["string1",null,42,7,"string2",null]}') }) @@ -287,7 +307,7 @@ test('object with tuple of multiple types', (t) => { const value = stringify({ fixedTupleOfStringsAndNumbers: ['string1', 42, 7] }) - t.equal(value, '{"fixedTupleOfStringsAndNumbers":["string1",42,7]}') + t.assert.equal(value, '{"fixedTupleOfStringsAndNumbers":["string1",42,7]}') } catch (e) { console.log(e) t.fail() @@ -297,7 +317,7 @@ test('object with tuple of multiple types', (t) => { const value = stringify({ fixedTupleOfStringsAndNumbers: ['string1', 42, 'string2'] }) - t.equal(value, '{"fixedTupleOfStringsAndNumbers":["string1",42,"string2"]}') + t.assert.equal(value, '{"fixedTupleOfStringsAndNumbers":["string1",42,"string2"]}') } catch (e) { console.log(e) t.fail() @@ -334,17 +354,17 @@ test('object with anyOf and multiple types', (t) => { const value = stringify({ objectOrBoolean: { stringOrNumber: 'string' } }) - t.equal(value, '{"objectOrBoolean":{"stringOrNumber":"string"}}') + t.assert.equal(value, '{"objectOrBoolean":{"stringOrNumber":"string"}}') } catch (e) { console.log(e) t.fail() } - t.equal(stringify({ + t.assert.equal(stringify({ objectOrBoolean: { stringOrNumber: 42 } }), '{"objectOrBoolean":{"stringOrNumber":42}}') - t.equal(stringify({ + t.assert.equal(stringify({ objectOrBoolean: true }), '{"objectOrBoolean":true}') }) @@ -361,9 +381,9 @@ test('string type array can handle dates', (t) => { const stringify = build(schema) const value = stringify({ date: new Date('2018-04-20T07:52:31.017Z'), - dateObject: moment('2018-04-21T07:52:31.017Z') + dateObject: new Date('2018-04-21T07:52:31.017Z') }) - t.equal(value, '{"date":"2018-04-20T07:52:31.017Z","dateObject":"2018-04-21T07:52:31.017Z"}') + t.assert.equal(value, '{"date":"2018-04-20T07:52:31.017Z","dateObject":"2018-04-21T07:52:31.017Z"}') }) test('object that is simultaneously a string and a json', (t) => { @@ -386,10 +406,10 @@ test('object that is simultaneously a string and a json', (t) => { const stringify = build(schema) const valueStr = stringify({ simultaneously: likeObjectId }) - t.equal(valueStr, '{"simultaneously":"hello"}') + t.assert.equal(valueStr, '{"simultaneously":"hello"}') const valueObj = stringify({ simultaneously: { foo: likeObjectId } }) - t.equal(valueObj, '{"simultaneously":{"foo":"hello"}}') + t.assert.equal(valueObj, '{"simultaneously":{"foo":"hello"}}') }) test('object that is simultaneously a string and a json switched', (t) => { @@ -412,13 +432,42 @@ test('object that is simultaneously a string and a json switched', (t) => { const stringify = build(schema) const valueStr = stringify({ simultaneously: likeObjectId }) - t.equal(valueStr, '{"simultaneously":{}}') + t.assert.equal(valueStr, '{"simultaneously":{}}') + + const valueObj = stringify({ simultaneously: { foo: likeObjectId } }) + t.assert.equal(valueObj, '{"simultaneously":{"foo":"hello"}}') +}) + +test('class instance that is simultaneously a string and a json', (t) => { + t.plan(2) + + const schema = { + type: 'object', + properties: { + simultaneously: { + type: ['string', 'object'], + properties: { + foo: { type: 'string' } + } + } + } + } + + class Test { + toString () { return 'hello' } + } + + const likeObjectId = new Test() + + const stringify = build(schema) + const valueStr = stringify({ simultaneously: likeObjectId }) + t.assert.equal(valueStr, '{"simultaneously":"hello"}') const valueObj = stringify({ simultaneously: { foo: likeObjectId } }) - t.equal(valueObj, '{"simultaneously":{"foo":"hello"}}') + t.assert.equal(valueObj, '{"simultaneously":{"foo":"hello"}}') }) -test('should throw an error when type is array and object is null', (t) => { +test('should not throw an error when type is array and object is null, it should instead coerce to []', (t) => { t.plan(1) const schema = { type: 'object', @@ -433,5 +482,69 @@ test('should throw an error when type is array and object is null', (t) => { } const stringify = build(schema) - t.throws(() => stringify({ arr: null }), new TypeError('Property \'arr\' should be of type array, received \'null\' instead.')) + const result = stringify({ arr: null }) + t.assert.equal(result, JSON.stringify({ arr: [] })) +}) + +test('should throw an error when type is array and object is not an array', (t) => { + t.plan(1) + const schema = { + type: 'object', + properties: { + arr: { + type: 'array', + items: { + type: 'number' + } + } + } + } + + const stringify = build(schema) + t.assert.throws(() => stringify({ arr: { foo: 'hello' } }), new TypeError('The value of \'#/properties/arr\' does not match schema definition.')) +}) + +test('should throw an error when type is array and object is not an array with external schema', (t) => { + t.plan(1) + const schema = { + type: 'object', + properties: { + arr: { + $ref: 'arrayOfNumbers#/definitions/arr' + } + } + } + + const externalSchema = { + arrayOfNumbers: { + definitions: { + arr: { + type: 'array', + items: { + type: 'number' + } + } + } + } + } + + const stringify = build(schema, { schema: externalSchema }) + t.assert.throws(() => stringify({ arr: { foo: 'hello' } }), new TypeError('The value of \'arrayOfNumbers#/definitions/arr\' does not match schema definition.')) +}) + +test('throw an error if none of types matches', (t) => { + t.plan(1) + + const schema = { + title: 'simple object with multi-type nullable primitive', + type: 'object', + properties: { + data: { + type: ['number', 'boolean'] + } + } + } + + const stringify = build(schema) + t.assert.throws(() => stringify({ data: 'string' }), 'The value "string" does not match schema definition.') }) diff --git a/test/unknownFormats.test.js b/test/unknownFormats.test.js index 92d22d5c..b38363dd 100644 --- a/test/unknownFormats.test.js +++ b/test/unknownFormats.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const build = require('..') test('object with custom format field', (t) => { @@ -19,9 +19,9 @@ test('object with custom format field', (t) => { const stringify = build(schema) - stringify({ - str: 'string' + t.assert.doesNotThrow(() => { + stringify({ + str: 'string' + }) }) - - t.pass() }) diff --git a/test/webpack.test.js b/test/webpack.test.js index 075d64c6..6a27c166 100644 --- a/test/webpack.test.js +++ b/test/webpack.test.js @@ -1,6 +1,6 @@ 'use strict' -const test = require('tap').test +const { test } = require('node:test') const webpack = require('webpack') const path = require('path') @@ -46,5 +46,5 @@ test('the library should work with webpack', async (t) => { }) const obj = { foo: '42', bar: true } - t.equal(stringify(obj), '{"foo":"42","bar":true}') + t.assert.equal(stringify(obj), '{"foo":"42","bar":true}') }) diff --git a/index.d.ts b/types/index.d.ts similarity index 54% rename from index.d.ts rename to types/index.d.ts index 31f23ebc..84721295 100644 --- a/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,13 @@ -import { Options as AjvOptions } from "ajv" +import Ajv, { Options as AjvOptions } from 'ajv' + +type Build = typeof build + declare namespace build { interface BaseSchema { + /** + * Schema id + */ + $id?: string /** * Schema title */ @@ -58,33 +65,33 @@ declare namespace build { */ $ref: string; } - + export interface AnySchema extends BaseSchema { } export interface StringSchema extends BaseSchema { - type: "string"; + type: 'string'; format?: string; } export interface IntegerSchema extends BaseSchema { - type: "integer"; + type: 'integer'; } export interface NumberSchema extends BaseSchema { - type: "number"; + type: 'number'; } export interface NullSchema extends BaseSchema { - type: "null"; + type: 'null'; } export interface BooleanSchema extends BaseSchema { - type: "boolean"; + type: 'boolean'; } export interface ArraySchema extends BaseSchema { - type: "array"; + type: 'array'; /** * The schema for the items in the array */ @@ -92,7 +99,7 @@ declare namespace build { } export interface TupleSchema extends BaseSchema { - type: "array"; + type: 'array'; /** * The schemas for the items in the tuple */ @@ -108,7 +115,7 @@ declare namespace build { } export interface ObjectSchema extends BaseSchema { - type: "object"; + type: 'object'; /** * Describe the properties of the object */ @@ -138,7 +145,7 @@ declare namespace build { | BooleanSchema | ArraySchema | TupleSchema - | ObjectSchema; + | ObjectSchema export interface Options { /** @@ -151,23 +158,74 @@ declare namespace build { ajv?: AjvOptions /** * Optionally configure how the integer will be rounded + * + * @default 'trunc' + */ + rounding?: 'ceil' | 'floor' | 'round' | 'trunc' + /** + * @deprecated + * Enable debug mode. Please use `mode: "debug"` instead */ - rounding?: 'ceil' | 'floor' | 'round' + debugMode?: boolean + /** + * Running mode of fast-json-stringify + */ + mode?: 'debug' | 'standalone' + + /** + * Large arrays are defined as arrays containing, by default, `20000` + * elements or more. That value can be adjusted via the option parameter + * `largeArraySize`. + * + * @default 20000 + */ + largeArraySize?: number | string | BigInt + + /** + * Specify the function on how large Arrays should be stringified. + * + * @default 'default' + */ + largeArrayMechanism?: 'default' | 'json-stringify' } + + export const validLargeArrayMechanisms: string[] + export function restore (value: (doc: TDoc) => string): ReturnType + + export const build: Build + export { build as default } +} + +interface DebugOption extends build.Options { + mode: 'debug' } +interface DeprecateDebugOption extends build.Options { + debugMode: true +} + +interface StandaloneOption extends build.Options { + mode: 'standalone' +} + +type StringCoercible = string | Date | RegExp +type IntegerCoercible = number | BigInt + /** * Build a stringify function using a schema of the documents that should be stringified * @param schema The schema used to stringify values * @param options The options to use (optional) */ -declare function build(schema: build.AnySchema, options?: build.Options): (doc: any) => any; -declare function build(schema: build.StringSchema, options?: build.Options): (doc: string) => string; -declare function build(schema: build.IntegerSchema | build.NumberSchema, options?: build.Options): (doc: number) => string; -declare function build(schema: build.NullSchema, options?: build.Options): (doc: null) => "null"; -declare function build(schema: build.BooleanSchema, options?: build.Options): (doc: boolean) => string; -declare function build(schema: build.ArraySchema | build.TupleSchema, options?: build.Options): (doc: any[]) => string; -declare function build(schema: build.ObjectSchema, options?: build.Options): (doc: object) => string; -declare function build(schema: build.Schema, options?: build.Options): (doc: object | any[] | string | number | boolean | null) => string; - -export = build; +declare function build (schema: build.AnySchema, options: DebugOption): { code: string, ajv: Ajv } +declare function build (schema: build.AnySchema, options: DeprecateDebugOption): { code: string, ajv: Ajv } +declare function build (schema: build.AnySchema, options: StandaloneOption): string +declare function build (schema: build.AnySchema, options?: build.Options): (doc: TDoc) => any +declare function build (schema: build.StringSchema, options?: build.Options): (doc: TDoc) => string +declare function build (schema: build.IntegerSchema | build.NumberSchema, options?: build.Options): (doc: TDoc) => string +declare function build (schema: build.NullSchema, options?: build.Options): (doc: TDoc) => 'null' +declare function build (schema: build.BooleanSchema, options?: build.Options): (doc: TDoc) => string +declare function build (schema: build.ArraySchema | build.TupleSchema, options?: build.Options): (doc: TDoc) => string +declare function build (schema: build.ObjectSchema, options?: build.Options): (doc: TDoc) => string +declare function build (schema: build.Schema, options?: build.Options): (doc: TDoc) => string + +export = build diff --git a/types/index.test-d.ts b/types/index.test-d.ts new file mode 100644 index 00000000..50da4201 --- /dev/null +++ b/types/index.test-d.ts @@ -0,0 +1,259 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Test using this disabled, see https://github.com/fastify/fast-json-stringify/pull/683 +import Ajv from 'ajv' +import build, { restore, Schema, validLargeArrayMechanisms } from '..' +import { expectError, expectType } from 'tsd' + +// Number schemas +build({ + type: 'number' +})(25) +build({ + type: 'integer' +})(-5) +build({ + type: 'integer' +})(5n) + +build({ + type: 'number' +}, { rounding: 'ceil' }) +build({ + type: 'number' +}, { rounding: 'floor' }) +build({ + type: 'number' +}, { rounding: 'round' }) +build({ + type: 'number' +}, { rounding: 'trunc' }) +expectError(build({ + type: 'number' +}, { rounding: 'invalid' })) + +// String schema +build({ + type: 'string' +})('foobar') + +// Boolean schema +build({ + type: 'boolean' +})(true) + +// Null schema +build({ + type: 'null' +})(null) + +// Array schemas +build({ + type: 'array', + items: { type: 'number' } +})([25]) +build({ + type: 'array', + items: [{ type: 'string' }, { type: 'integer' }] +})(['hello', 42]) + +// Object schemas +build({ + type: 'object' +})({}) +build({ + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'integer' } + }, + required: ['foo'], + patternProperties: { + 'baz*': { type: 'null' } + }, + additionalProperties: { + type: 'boolean' + } +})({ foo: 'bar' }) +build({ + type: 'object', + properties: { + foo: { type: 'string' }, + bar: { type: 'integer' } + }, + required: ['foo'], + patternProperties: { + 'baz*': { type: 'null' } + }, + additionalProperties: { + type: 'boolean' + } +}, { rounding: 'floor' })({ foo: 'bar' }) + +// Reference schemas +build({ + title: 'Example Schema', + definitions: { + num: { + type: 'object', + properties: { + int: { + type: 'integer' + } + } + }, + str: { + type: 'string' + }, + def: { + type: 'null' + } + }, + type: 'object', + properties: { + nickname: { + $ref: '#/definitions/str' + } + }, + patternProperties: { + num: { + $ref: '#/definitions/num' + } + }, + additionalProperties: { + $ref: '#/definitions/def' + } +})({ nickname: '', num: { int: 5 }, other: null }) + +// Conditional/Combined schemas +build({ + title: 'Conditional/Combined Schema', + type: 'object', + properties: { + something: { + anyOf: [ + { type: 'string' }, + { type: 'boolean' } + ] + } + }, + if: { + properties: { + something: { type: 'string' } + } + }, + then: { + properties: { + somethingElse: { type: 'number' } + } + }, + else: { + properties: { + somethingElse: { type: 'null' } + } + } +})({ something: 'a string', somethingElse: 42 }) + +// String schema with format + +build({ + type: 'string', + format: 'date-time' +})(new Date()) + +/* +This overload doesn't work yet - +TypeScript chooses the generic for the schema +before it chooses the overload for the options +parameter. +let str: string, ajv: Ajv +str = build({ + type: 'number' +}, { debugMode: true }).code +ajv = build({ + type: 'number' +}, { debugMode: true }).ajv +str = build({ + type: 'number' +}, { mode: 'debug' }).code +ajv = build({ + type: 'number' +}, { mode: 'debug' }).ajv +str = build({ + type: 'number' +}, { mode: 'standalone' }) +*/ + +const debugCompiled = build({ + title: 'default string', + type: 'object', + properties: { + firstName: { + type: 'string' + } + } +}, { mode: 'debug' }) +expectType>(build.restore(debugCompiled)) +expectType>(restore(debugCompiled)) + +expectType(build.validLargeArrayMechanisms) +expectType(validLargeArrayMechanisms) + +/** + * Schema inference + */ + +// With inference +interface InferenceSchema { + id: string; + a?: number; +} + +const stringify3 = build({ + type: 'object', + properties: { a: { type: 'string' } }, +}) +stringify3({ id: '123' }) +stringify3({ a: 123, id: '123' }) +expectError(stringify3({ anotherOne: 'bar' })) +expectError(stringify3({ a: 'bar' })) + +// Without inference +const stringify4 = build({ + type: 'object', + properties: { a: { type: 'string' } }, +}) +stringify4({ id: '123' }) +stringify4({ a: 123, id: '123' }) +stringify4({ anotherOne: 'bar' }) +stringify4({ a: 'bar' }) + +// Without inference - string type +const stringify5 = build({ + type: 'string', +}) +stringify5('foo') +expectError(stringify5({ id: '123' })) + +// Without inference - null type +const stringify6 = build({ + type: 'null', +}) +stringify6(null) +expectError(stringify6('a string')) + +// Without inference - boolean type +const stringify7 = build({ + type: 'boolean', +}) +stringify7(true) +expectError(stringify7('a string')) + +// largeArrayMechanism + +build({}, { largeArrayMechanism: 'json-stringify' }) +build({}, { largeArrayMechanism: 'default' }) +expectError(build({} as Schema, { largeArrayMechanism: 'invalid' })) + +build({}, { largeArraySize: 2000 }) +build({}, { largeArraySize: '2e4' }) +build({}, { largeArraySize: 2n }) +expectError(build({} as Schema, { largeArraySize: ['asdf'] }))