├── .github └── workflows │ ├── ci.yml │ └── semantic_release.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts ├── generateTypes.ts └── json-schema-draft-07.json ├── src ├── diagnostics.ts ├── index.test.ts ├── index.ts ├── nodes.test.ts ├── nodes.ts ├── parser.test.ts ├── parser.ts ├── parserContext.ts ├── references.ts ├── schema.ts ├── types.ts └── uris.ts ├── tsconfig.json └── types └── is-valid-variable.d.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: ${{ matrix.node-version }} ${{ matrix.os }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | os: [macOS-latest, windows-latest, ubuntu-latest] 11 | node-version: [18, 20] 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Install 19 | run: npm i 20 | - name: Tests 21 | run: npm run test 22 | -------------------------------------------------------------------------------- /.github/workflows/semantic_release.yml: -------------------------------------------------------------------------------- 1 | name: Semantic Release 2 | on: 3 | workflow_dispatch: {} 4 | 5 | permissions: 6 | contents: read # for checkout 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write # to be able to publish a GitHub release 14 | issues: write # to be able to comment on released issues 15 | pull-requests: write # to be able to comment on released pull requests 16 | id-token: write # to enable use of OIDC for npm provenance 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 'lts/*' 26 | - name: Install dependencies 27 | run: npm clean-install 28 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 29 | run: npm audit signatures 30 | - name: Release 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 34 | run: npx semantic-release 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | coverage 107 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit" 5 | }, 6 | "typescript.tsdk": "/Users/ggoodman/Projects/ggoodman/json-schema-compiler/node_modules/typescript/lib" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | ### Changed 10 | - [SEMVER MAJOR] Deprecate support for node versions earlier than Node 16. Supported versions are now 16, 18 and 20. 11 | 12 | ### Fixed 13 | - Change `hasAnyProperty` type argument to extend `{}` so that TypeScript can understand that the iteration protocol is supported over properties. 14 | - Detect `"properties"` keys that are invalid JavaScript identifier names and escape them in the generated code. 15 | 16 | For example, the property `"app.prop"` will no longer produce invalid types and will instead be encoded as `["app.prop"]`. 17 | 18 | Fixes #7. 19 | 20 | ## [1.5.0] - 2022-08-31 21 | ### Added 22 | - Added support for opting out of emitting the `@see` directives in schema doc comments through the `omitIdComments` option. By default, these tags _will_ be emitted to preserve backwards-compatibility. To omit these comments, `omitIdComments: true` can be specified as an option. 23 | - Introduce the `shouldOmitTypeEmit` option to the `compile` method of `Parser` instances. This option allows consumers to conditionally skip the emit of some sub-schemas' types. This function receives references to the child node being emitted and the parent node. 24 | 25 | ## [1.4.1] - 2021-04-05 26 | ### Fixed 27 | - Added `package-lock.json` to fix release tooling. 28 | 29 | ## [1.4.0] - 2021-04-05 30 | ### Added 31 | - The type used for open-ended schemas can be passed via the `anyType` option in the Partser's `generateTypings` method. 32 | 33 | Some use-cases may demand strict typing for values whose shape cannot be known a priori, in which case `"unknown"` would be a good fit. In other cases, a consumer of an object typed using this library might find it helpful to know that only JSON-serializable values can be present. In that case, the `"JSONValue"` option would be suitable. Finally, in cases where consumers of the type definitions may want minimal friction from the type-checker, `"any"` might be the best choice. 34 | 35 | ## 1.3.0 - 2021-01-22 36 | ### Added 37 | - Add support for passing a `preferredName` for the generated type when calling `.addSchema` in a new `options` argument. If the preferred name is already taken, the returned type name will be ``. [#2] 38 | 39 | [#2]: https://github.com/ggoodman/json-schema-to-dts/issues/2 40 | 41 | [Unreleased]: https://github.com/ggoodman/json-schema-to-dts/compare/v1.5.0...HEAD 42 | [1.5.0]: https://github.com/ggoodman/json-schema-to-dts/compare/v1.4.1...v1.5.0 43 | [1.4.1]: https://github.com/ggoodman/json-schema-to-dts/compare/v1.4.0...v1.4.1 44 | [1.4.0]: https://github.com/ggoodman/json-schema-to-dts/compare/v1.3.0...v1.4.0 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-schema-to-dts 2 | 3 | Convert JSON Schema definitions into accurate (as possible) TypeScript definitions, specifying how the main schema types and lifted sub-schemas should be declared / exported. 4 | 5 | ## Example 6 | 7 | Given the schema 8 | 9 | ```json 10 | { 11 | "type": "object", 12 | "properties": { 13 | "name": { "type": "string", "description": "The name of an object" }, 14 | "not_annotated": { "type": "null" }, 15 | "command": { 16 | "oneOf": [{ "const": "a constant!" }, { "enum": ["multiple", { "options": "are allowed" }] }] 17 | } 18 | } 19 | } 20 | ``` 21 | 22 | And these options: 23 | 24 | ```ts 25 | const options = { 26 | topLevel: { 27 | isExported: true, 28 | }, 29 | }; 30 | ``` 31 | 32 | We get the following result: 33 | 34 | ```ts 35 | type JSONPrimitive = boolean | null | number | string; 36 | type JSONValue = 37 | | JSONPrimitive 38 | | JSONValue[] 39 | | { 40 | [key: string]: JSONValue; 41 | }; 42 | export type Test = { 43 | /** The name of an object */ 44 | name?: string; 45 | not_annotated?: null; 46 | command?: 47 | | 'a constant!' 48 | | ( 49 | | 'multiple' 50 | | { 51 | options: 'are allowed'; 52 | } 53 | ); 54 | }; 55 | ``` 56 | 57 | ## API 58 | 59 | ### `new Parser()` 60 | 61 | Produce a new `Parser` instance. 62 | 63 | #### `.addSchema(uri, schema)` 64 | 65 | Add a schema to the parser where: 66 | 67 | - `uri` - is a string representing the schema's uri (ie: `file:///path/to/schema.json`) 68 | - `schema` - is the json object representation of the schema 69 | 70 | #### `.compile(options)` 71 | 72 | Compile all added schemas where: 73 | 74 | - `topLevel` - options for root schemas 75 | - `hasDeclareKeyword` - _(optional)_ mark the type declaration as `declare` 76 | - `isExported` - _(optional)_ `export` the type declaration 77 | - `lifted` - options for sub-schemas that have been lifted during compilation 78 | - `hasDeclareKeyword` - _(optional)_ mark the type declaration as `declare` 79 | - `isExported` - _(optional)_ `export` the type declaration 80 | 81 | Returns an object `{ diagnostics, text }` where: 82 | 83 | - `diagnostics` - is an array of diagnostics 84 | - `text` - is the resulting typescript definitions 85 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "/private/var/folders/k0/9ybx4mp53tj8p1x5qgtlv10m0000gn/T/jest_dx", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | // clearMocks: false, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | // collectCoverage: false, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | coverageDirectory: 'coverage', 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | // coveragePathIgnorePatterns: [ 28 | // "/node_modules/" 29 | // ], 30 | 31 | // A list of reporter names that Jest uses when writing coverage reports 32 | // coverageReporters: [ 33 | // "json", 34 | // "text", 35 | // "lcov", 36 | // "clover" 37 | // ], 38 | 39 | // An object that configures minimum threshold enforcement for coverage results 40 | // coverageThreshold: undefined, 41 | 42 | // A path to a custom dependency extractor 43 | // dependencyExtractor: undefined, 44 | 45 | // Make calling deprecated APIs throw helpful error messages 46 | // errorOnDeprecated: false, 47 | 48 | // Force coverage collection from ignored files using an array of glob patterns 49 | // forceCoverageMatch: [], 50 | 51 | // A path to a module which exports an async function that is triggered once before all test suites 52 | // globalSetup: undefined, 53 | 54 | // A path to a module which exports an async function that is triggered once after all test suites 55 | // globalTeardown: undefined, 56 | 57 | // A set of global variables that need to be available in all test environments 58 | // globals: {}, 59 | 60 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 61 | // maxWorkers: "50%", 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | preset: 'ts-jest', 92 | 93 | // Run tests from one or more projects 94 | // projects: undefined, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | reporters: [ 98 | 'default', 99 | [ 100 | 'jest-junit', 101 | { 102 | outputDirectory: 'coverage', 103 | }, 104 | ], 105 | ], 106 | 107 | // Automatically reset mock state between every test 108 | // resetMocks: false, 109 | 110 | // Reset the module registry before running each individual test 111 | // resetModules: false, 112 | 113 | // A path to a custom resolver 114 | // resolver: undefined, 115 | 116 | // Automatically restore mock state between every test 117 | // restoreMocks: false, 118 | 119 | // The root directory that Jest should scan for tests and modules within 120 | // rootDir: undefined, 121 | 122 | // A list of paths to directories that Jest should use to search for files in 123 | roots: ['src'], 124 | 125 | // Allows you to use a custom runner instead of Jest's default test runner 126 | // runner: "jest-runner", 127 | 128 | // The paths to modules that run some code to configure or set up the testing environment before each test 129 | // setupFiles: [], 130 | 131 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 132 | // setupFilesAfterEnv: [], 133 | 134 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 135 | // snapshotSerializers: [], 136 | 137 | // The test environment that will be used for testing 138 | testEnvironment: 'node', 139 | 140 | // Options that will be passed to the testEnvironment 141 | // testEnvironmentOptions: {}, 142 | 143 | // Adds a location field to test results 144 | // testLocationInResults: false, 145 | 146 | // The glob patterns Jest uses to detect test files 147 | testMatch: ['**/tests/**/*.[jt]s?(x)', '**/*.test.[tj]s?(x)'], 148 | 149 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 150 | // testPathIgnorePatterns: [ 151 | // "/node_modules/" 152 | // ], 153 | 154 | // The regexp pattern or array of patterns that Jest uses to detect test files 155 | // testRegex: [], 156 | 157 | // This option allows the use of a custom results processor 158 | // testResultsProcessor: undefined, 159 | 160 | // This option allows use of a custom test runner 161 | // testRunner: "jasmine2", 162 | 163 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 164 | // testURL: "http://localhost", 165 | 166 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 167 | // timers: "real", 168 | 169 | // A map from regular expressions to paths to transformers 170 | // transform: undefined, 171 | 172 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 173 | // transformIgnorePatterns: [ 174 | // "/node_modules/" 175 | // ], 176 | 177 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 178 | // unmockedModulePathPatterns: undefined, 179 | 180 | // Indicates whether each individual test should be reported during the run 181 | // verbose: undefined, 182 | 183 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 184 | // watchPathIgnorePatterns: [], 185 | 186 | // Whether to use watchman for file crawling 187 | // watchman: true, 188 | 189 | prettierPath: null, 190 | }; 191 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-schema-to-dts", 3 | "version": "1.5.0", 4 | "description": "Create TypeScript types from json-schema v7", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "dependencies": { 11 | "is-valid-variable": "^1.0.1" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/json-schema": "^7.0.6", 16 | "@types/node": "^18.19.31", 17 | "esbuild": "^0.20.2", 18 | "gh-release": "^5.0.0", 19 | "jest": "^29.7.0", 20 | "jest-junit": "^16.0.0", 21 | "json-schema-test-suite": "github:json-schema-org/JSON-Schema-Test-Suite#0a0f0cd", 22 | "kacl": "^1.1.1", 23 | "prettier": "^3.2.5", 24 | "rollup-plugin-ts": "^3.0.2", 25 | "ts-jest": "^29.1.2", 26 | "ts-morph": "^22.0.0", 27 | "ts-node": "^10.9.2", 28 | "tslib": "^2.0.2", 29 | "typescript": "^5.4.5" 30 | }, 31 | "scripts": { 32 | "build:dts": "npx tsc --emitDeclarationOnly --sourceMap false", 33 | "build:js": "npx esbuild --bundle --tree-shaking=true --outfile=dist/index.js --platform=node src/index.ts", 34 | "build": "npm run build:js && npm run build:dts", 35 | "generate": "ts-node ./scripts/generateTypes.ts", 36 | "lint": "prettier --check src/**/*", 37 | "test": "jest --verbose", 38 | "prepack": "npm run build" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/ggoodman/json-schema-to-dts.git" 43 | }, 44 | "keywords": [], 45 | "author": "Geoffrey Goodman", 46 | "license": "MIT", 47 | "bugs": { 48 | "url": "https://github.com/ggoodman/json-schema-to-dts/issues" 49 | }, 50 | "homepage": "https://github.com/ggoodman/json-schema-to-dts#readme", 51 | "engines": { 52 | "node": ">=18.12.0" 53 | }, 54 | "prettier": { 55 | "printWidth": 100, 56 | "tabWidth": 2, 57 | "singleQuote": true 58 | }, 59 | "volta": { 60 | "node": "20.12.2", 61 | "npm": "10.5.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /scripts/generateTypes.ts: -------------------------------------------------------------------------------- 1 | import { promises as Fs } from 'fs'; 2 | import * as Path from 'path'; 3 | import { Parser } from '../src/parser'; 4 | 5 | async function main() { 6 | const schemaPath = Path.resolve(__dirname, './json-schema-draft-07.json'); 7 | const schemaData = await Fs.readFile(schemaPath, 'utf-8'); 8 | const schema = JSON.parse(schemaData); 9 | const parser = new Parser({ 10 | defaultUnknownPropertiesSchema: true, 11 | }); 12 | 13 | parser.addSchema(`file://${schemaPath}`, schema); 14 | 15 | const { text } = parser.compile({ 16 | topLevel: { isExported: true }, 17 | lifted: { isExported: true }, 18 | }); 19 | 20 | const prelude = '// IMPORTANT: Do not edit this file by hand; it is automatically generated\n'; 21 | 22 | await Fs.writeFile(Path.resolve(__dirname, '../src/schema.ts'), `${prelude}\n${text}`); 23 | } 24 | 25 | main().catch((err) => { 26 | console.trace(err); 27 | process.exit(1); 28 | }); 29 | -------------------------------------------------------------------------------- /scripts/json-schema-draft-07.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "$id": "http://json-schema.org/draft-07/schema#", 4 | "title": "Core schema meta-schema", 5 | "definitions": { 6 | "schemaArray": { 7 | "type": "array", 8 | "minItems": 1, 9 | "items": { "$ref": "#" } 10 | }, 11 | "nonNegativeInteger": { 12 | "type": "integer", 13 | "minimum": 0 14 | }, 15 | "nonNegativeIntegerDefault0": { 16 | "allOf": [{ "$ref": "#/definitions/nonNegativeInteger" }, { "default": 0 }] 17 | }, 18 | "simpleTypes": { 19 | "enum": ["array", "boolean", "integer", "null", "number", "object", "string"] 20 | }, 21 | "stringArray": { 22 | "type": "array", 23 | "items": { "type": "string" }, 24 | "uniqueItems": true, 25 | "default": [] 26 | } 27 | }, 28 | "type": ["object", "boolean"], 29 | "properties": { 30 | "$id": { 31 | "type": "string", 32 | "format": "uri-reference" 33 | }, 34 | "$schema": { 35 | "type": "string", 36 | "format": "uri" 37 | }, 38 | "$ref": { 39 | "type": "string", 40 | "format": "uri-reference" 41 | }, 42 | "$comment": { 43 | "type": "string" 44 | }, 45 | "title": { 46 | "type": "string" 47 | }, 48 | "description": { 49 | "type": "string" 50 | }, 51 | "default": true, 52 | "readOnly": { 53 | "type": "boolean", 54 | "default": false 55 | }, 56 | "writeOnly": { 57 | "type": "boolean", 58 | "default": false 59 | }, 60 | "examples": { 61 | "type": "array", 62 | "items": true 63 | }, 64 | "multipleOf": { 65 | "type": "number", 66 | "exclusiveMinimum": 0 67 | }, 68 | "maximum": { 69 | "type": "number" 70 | }, 71 | "exclusiveMaximum": { 72 | "type": "number" 73 | }, 74 | "minimum": { 75 | "type": "number" 76 | }, 77 | "exclusiveMinimum": { 78 | "type": "number" 79 | }, 80 | "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, 81 | "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, 82 | "pattern": { 83 | "type": "string", 84 | "format": "regex" 85 | }, 86 | "additionalItems": { "$ref": "#" }, 87 | "items": { 88 | "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/schemaArray" }], 89 | "default": true 90 | }, 91 | "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, 92 | "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, 93 | "uniqueItems": { 94 | "type": "boolean", 95 | "default": false 96 | }, 97 | "contains": { "$ref": "#" }, 98 | "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, 99 | "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, 100 | "required": { "$ref": "#/definitions/stringArray" }, 101 | "additionalProperties": { "$ref": "#" }, 102 | "definitions": { 103 | "type": "object", 104 | "additionalProperties": { "$ref": "#" }, 105 | "default": {} 106 | }, 107 | "properties": { 108 | "type": "object", 109 | "additionalProperties": { "$ref": "#" }, 110 | "default": {} 111 | }, 112 | "patternProperties": { 113 | "type": "object", 114 | "additionalProperties": { "$ref": "#" }, 115 | "propertyNames": { "format": "regex" }, 116 | "default": {} 117 | }, 118 | "dependencies": { 119 | "type": "object", 120 | "additionalProperties": { 121 | "anyOf": [{ "$ref": "#" }, { "$ref": "#/definitions/stringArray" }] 122 | } 123 | }, 124 | "propertyNames": { "$ref": "#" }, 125 | "const": true, 126 | "enum": { 127 | "type": "array", 128 | "items": true, 129 | "minItems": 1, 130 | "uniqueItems": true 131 | }, 132 | "type": { 133 | "anyOf": [ 134 | { "$ref": "#/definitions/simpleTypes" }, 135 | { 136 | "type": "array", 137 | "items": { "$ref": "#/definitions/simpleTypes" }, 138 | "minItems": 1, 139 | "uniqueItems": true 140 | } 141 | ] 142 | }, 143 | "format": { "type": "string" }, 144 | "contentMediaType": { "type": "string" }, 145 | "contentEncoding": { "type": "string" }, 146 | "if": { "$ref": "#" }, 147 | "then": { "$ref": "#" }, 148 | "else": { "$ref": "#" }, 149 | "allOf": { "$ref": "#/definitions/schemaArray" }, 150 | "anyOf": { "$ref": "#/definitions/schemaArray" }, 151 | "oneOf": { "$ref": "#/definitions/schemaArray" }, 152 | "not": { "$ref": "#" } 153 | }, 154 | "default": true 155 | } 156 | -------------------------------------------------------------------------------- /src/diagnostics.ts: -------------------------------------------------------------------------------- 1 | export enum ParserDiagnosticKind { 2 | Warn = 'warn', 3 | Error = 'error', 4 | } 5 | 6 | export interface IParserDiagnostic { 7 | severity: ParserDiagnosticKind; 8 | code: string; 9 | message: string; 10 | uri: string; 11 | baseUri: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Parser } from './'; 2 | 3 | describe('Definition generation', () => { 4 | it('will annotate nested object properties with doc comments', () => { 5 | const parser = new Parser(); 6 | parser.addSchema('file:///test.json', { 7 | type: 'object', 8 | properties: { 9 | name: { 10 | type: 'string', 11 | description: 'The name of an object', 12 | }, 13 | not_annotated: { 14 | type: 'null', 15 | }, 16 | command: { 17 | oneOf: [ 18 | { 19 | const: 'a constant!', 20 | }, 21 | { 22 | enum: ['multiple', { options: 'are allowed' }], 23 | }, 24 | ], 25 | }, 26 | }, 27 | }); 28 | 29 | const result = parser.compile(); 30 | 31 | expect(result.text).toMatchInlineSnapshot(` 32 | "type JSONPrimitive = boolean | null | number | string; 33 | type JSONValue = JSONPrimitive | JSONValue[] | { 34 | [key: string]: JSONValue; 35 | }; 36 | export type Test = { 37 | /** The name of an object */ 38 | name?: string; 39 | not_annotated?: null; 40 | command?: ("a constant!" | ("multiple" | { 41 | "options": "are allowed"; 42 | })); 43 | }; 44 | " 45 | `); 46 | }); 47 | 48 | it('will optionally omit sub schemas', () => { 49 | const parser = new Parser(); 50 | parser.addSchema('file:///test.json', { 51 | type: 'object', 52 | properties: { 53 | name: { 54 | type: 'string', 55 | description: 'The name of an object', 56 | }, 57 | not_annotated: { 58 | type: 'null', 59 | 'x-omit-types': true, 60 | }, 61 | command: { 62 | oneOf: [ 63 | { 64 | const: 'a constant!', 65 | }, 66 | { 67 | enum: ['multiple', { options: 'are allowed' }], 68 | 'x-omit-types': true, 69 | }, 70 | ], 71 | }, 72 | }, 73 | }); 74 | 75 | expect(parser.compile().text).toMatchInlineSnapshot(` 76 | "type JSONPrimitive = boolean | null | number | string; 77 | type JSONValue = JSONPrimitive | JSONValue[] | { 78 | [key: string]: JSONValue; 79 | }; 80 | export type Test = { 81 | /** The name of an object */ 82 | name?: string; 83 | not_annotated?: null; 84 | command?: ("a constant!" | ("multiple" | { 85 | "options": "are allowed"; 86 | })); 87 | }; 88 | " 89 | `); 90 | 91 | expect( 92 | parser.compile({ 93 | shouldOmitTypeEmit(node) { 94 | return typeof node.schema === 'object' && !!node.schema['x-omit-types']; 95 | } 96 | }).text 97 | ).toMatchInlineSnapshot(` 98 | "type JSONPrimitive = boolean | null | number | string; 99 | type JSONValue = JSONPrimitive | JSONValue[] | { 100 | [key: string]: JSONValue; 101 | }; 102 | export type Test = { 103 | /** The name of an object */ 104 | name?: string; 105 | command?: "a constant!"; 106 | }; 107 | " 108 | `); 109 | }); 110 | 111 | it('will correctly quote properties with special characters', () => { 112 | // See: https://github.com/ggoodman/json-schema-to-dts/issues/7 113 | const parser = new Parser(); 114 | parser.addSchema('urn:test', { 115 | $id: 'urn:test', 116 | type: 'object', 117 | properties: { 118 | name: { 119 | type: 'string', 120 | description: 'The name of an object', 121 | }, 122 | 'app.prop': { 123 | type: 'null', 124 | }, 125 | }, 126 | }); 127 | 128 | const result = parser.compile(); 129 | 130 | expect(result.text).toMatchInlineSnapshot(` 131 | "type JSONPrimitive = boolean | null | number | string; 132 | type JSONValue = JSONPrimitive | JSONValue[] | { 133 | [key: string]: JSONValue; 134 | }; 135 | export type Test = { 136 | /** The name of an object */ 137 | name?: string; 138 | ["app.prop"]?: null; 139 | }; 140 | " 141 | `); 142 | }); 143 | 144 | describe('will produce schemas that reflect the selected anyType', () => { 145 | it('when anyType is unspecified', () => { 146 | const parser = new Parser(); 147 | parser.addSchema('file:///test.json', true); 148 | 149 | const result = parser.compile(); 150 | 151 | expect(result.text).toMatchInlineSnapshot(` 152 | "type JSONPrimitive = boolean | null | number | string; 153 | type JSONValue = JSONPrimitive | JSONValue[] | { 154 | [key: string]: JSONValue; 155 | }; 156 | export type Test = JSONValue; 157 | " 158 | `); 159 | }); 160 | 161 | it('when anyType is "any"', () => { 162 | const parser = new Parser(); 163 | parser.addSchema('file:///test.json', true); 164 | 165 | const result = parser.compile({ anyType: 'any' }); 166 | 167 | expect(result.text).toMatchInlineSnapshot(` 168 | "export type Test = any; 169 | " 170 | `); 171 | }); 172 | 173 | it('when anyType is "JSONValue"', () => { 174 | const parser = new Parser(); 175 | parser.addSchema('file:///test.json', true); 176 | 177 | const result = parser.compile({ anyType: 'JSONValue' }); 178 | 179 | expect(result.text).toMatchInlineSnapshot(` 180 | "type JSONPrimitive = boolean | null | number | string; 181 | type JSONValue = JSONPrimitive | JSONValue[] | { 182 | [key: string]: JSONValue; 183 | }; 184 | export type Test = JSONValue; 185 | " 186 | `); 187 | }); 188 | 189 | it('when anyType is "unknown"', () => { 190 | const parser = new Parser(); 191 | parser.addSchema('file:///test.json', true); 192 | 193 | const result = parser.compile({ anyType: 'unknown' }); 194 | 195 | expect(result.text).toMatchInlineSnapshot(` 196 | "export type Test = unknown; 197 | " 198 | `); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { CoreSchemaMetaSchema } from './schema'; 2 | 3 | export * from './parser'; 4 | export type { JSONSchema7Type as JSONValue } from './types'; 5 | export type { CoreSchemaMetaSchema }; 6 | export type JSONSchema = Exclude; 7 | -------------------------------------------------------------------------------- /src/nodes.test.ts: -------------------------------------------------------------------------------- 1 | import { SchemaNode, SchemaNodeOptions } from './nodes'; 2 | import type { CoreSchemaMetaSchema } from './schema'; 3 | 4 | describe('SchemaNode', () => { 5 | it('will only emit a `@see` directive when requested', () => { 6 | const schema: CoreSchemaMetaSchema = { 7 | type: 'boolean', 8 | $id: 'file://path', 9 | }; 10 | 11 | const node = new SchemaNode('file://path', 'file://path', schema, schema as SchemaNodeOptions); 12 | 13 | expect(node.provideDocs({ emitSeeDirective: false })).toMatchInlineSnapshot(`undefined`); 14 | expect(node.provideDocs({ emitSeeDirective: true })).toMatchInlineSnapshot(` 15 | { 16 | "description": "", 17 | "tags": [ 18 | { 19 | "tagName": "see", 20 | "text": "file://path", 21 | }, 22 | ], 23 | } 24 | `); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/nodes.ts: -------------------------------------------------------------------------------- 1 | import isValidVariable from 'is-valid-variable'; 2 | import { 3 | CodeBlockWriter, 4 | IndexSignatureDeclarationStructure, 5 | JSDocStructure, 6 | JSDocTagStructure, 7 | OptionalKind, 8 | PropertySignatureStructure, 9 | WriterFunction, 10 | Writers, 11 | } from 'ts-morph'; 12 | import { IReference } from './references'; 13 | import { CoreSchemaMetaSchema } from './schema'; 14 | import { JSONSchema7, JSONSchema7Definition, JSONSchema7Type, JSONSchema7TypeName } from './types'; 15 | 16 | export type AnyType = 'any' | 'JSONValue' | 'unknown'; 17 | 18 | export interface IDocEmitOptions { 19 | emitSeeDirective?: boolean; 20 | } 21 | export interface ITypingContext { 22 | anyType: AnyType; 23 | getNameForReference(ref: IReference): string; 24 | shouldEmitTypes( 25 | schema: ISchemaNode, 26 | parentNode: ISchemaNode 27 | ): boolean; 28 | } 29 | 30 | export interface ISchemaNode { 31 | readonly kind: SchemaNodeKind; 32 | readonly baseUri: string; 33 | readonly schema: T; 34 | readonly uri: string; 35 | 36 | provideWriterFunction(ctx: ITypingContext): WriterFunction; 37 | provideDocs(options?: IDocEmitOptions): OptionalKind | undefined; 38 | } 39 | 40 | export enum SchemaNodeKind { 41 | Boolean = 'Boolean', 42 | Schema = 'Schema', 43 | } 44 | 45 | export interface SchemaNodeOptions { 46 | $id?: string; 47 | $ref?: IReference; 48 | $schema?: string; 49 | $comment?: string; 50 | 51 | /** 52 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.1 53 | */ 54 | type?: JSONSchema7TypeName | JSONSchema7TypeName[]; 55 | enum?: JSONSchema7Type[]; 56 | const?: JSONSchema7Type; 57 | 58 | /** 59 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.2 60 | */ 61 | multipleOf?: number; 62 | maximum?: number; 63 | exclusiveMaximum?: number; 64 | minimum?: number; 65 | exclusiveMinimum?: number; 66 | 67 | /** 68 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.3 69 | */ 70 | maxLength?: number; 71 | minLength?: number; 72 | pattern?: string; 73 | 74 | /** 75 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.4 76 | */ 77 | items?: ISchemaNode | ISchemaNode[]; 78 | additionalItems?: ISchemaNode; 79 | maxItems?: number; 80 | minItems?: number; 81 | uniqueItems?: boolean; 82 | contains?: ISchemaNode; 83 | 84 | /** 85 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.5 86 | */ 87 | maxProperties?: number; 88 | minProperties?: number; 89 | required?: string[]; 90 | properties?: { 91 | [key: string]: ISchemaNode; 92 | }; 93 | patternProperties?: { 94 | [key: string]: ISchemaNode; 95 | }; 96 | additionalProperties?: ISchemaNode; 97 | dependencies?: { 98 | [key: string]: ISchemaNode | string[]; 99 | }; 100 | propertyNames?: ISchemaNode; 101 | 102 | /** 103 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.6 104 | */ 105 | if?: ISchemaNode; 106 | then?: ISchemaNode; 107 | else?: ISchemaNode; 108 | 109 | /** 110 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-6.7 111 | */ 112 | allOf?: ISchemaNode[]; 113 | anyOf?: ISchemaNode[]; 114 | oneOf?: ISchemaNode[]; 115 | not?: ISchemaNode; 116 | 117 | /** 118 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-7 119 | */ 120 | format?: string; 121 | 122 | /** 123 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-8 124 | */ 125 | contentMediaType?: string; 126 | contentEncoding?: string; 127 | 128 | /** 129 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-9 130 | */ 131 | definitions?: { 132 | [key: string]: ISchemaNode; 133 | }; 134 | 135 | /** 136 | * @see https://tools.ietf.org/html/draft-handrews-json-schema-validation-01#section-10 137 | */ 138 | title?: string; 139 | description?: string; 140 | default?: JSONSchema7Type; 141 | readOnly?: boolean; 142 | writeOnly?: boolean; 143 | examples?: JSONSchema7Type; 144 | } 145 | 146 | abstract class BaseSchemaNode 147 | implements ISchemaNode 148 | { 149 | abstract readonly kind: SchemaNodeKind; 150 | 151 | constructor( 152 | readonly uri: string, 153 | readonly baseUri: string, 154 | readonly schema: TSchema, 155 | protected readonly options: TOptions 156 | ) {} 157 | 158 | provideDocs(): OptionalKind | undefined { 159 | return undefined; 160 | } 161 | 162 | abstract provideWriterFunction(ctx: ITypingContext): WriterFunction; 163 | } 164 | 165 | export class BooleanSchemaNode extends BaseSchemaNode { 166 | readonly kind = SchemaNodeKind.Boolean; 167 | 168 | provideWriterFunction(ctx: ITypingContext): WriterFunction { 169 | return createLiteralWriterFunction(this.schema ? ctx.anyType : 'never'); 170 | } 171 | } 172 | 173 | export class SchemaNode extends BaseSchemaNode { 174 | readonly kind = SchemaNodeKind.Schema; 175 | 176 | provideDocs(options?: IDocEmitOptions) { 177 | const lines: string[] = []; 178 | const tags: OptionalKind[] = []; 179 | 180 | if (this.schema.title) { 181 | lines.push(this.schema.title); 182 | } 183 | 184 | if (this.schema.description) { 185 | lines.push(this.schema.description); 186 | } 187 | 188 | if (options?.emitSeeDirective && this.schema.$id) { 189 | tags.push({ tagName: 'see', text: this.schema.$id }); 190 | } 191 | 192 | if (!lines.length && !tags.length) { 193 | return; 194 | } 195 | 196 | return { 197 | description: lines.join('\n\n'), 198 | tags, 199 | }; 200 | } 201 | 202 | provideWriterFunction(ctx: ITypingContext): WriterFunction { 203 | const typeWriters: WriterFunction[] = []; 204 | 205 | if (this.options.$ref) { 206 | const targetTypeName = ctx.getNameForReference(this.options.$ref); 207 | 208 | typeWriters.push(createLiteralWriterFunction(targetTypeName)); 209 | } 210 | 211 | if (this.options.anyOf) { 212 | const writerFunctions = this.options.anyOf 213 | .filter((node) => ctx.shouldEmitTypes(node, this)) 214 | .map((node) => node.provideWriterFunction(ctx)); 215 | 216 | if (writerFunctions.length) { 217 | typeWriters.push(createUnionTypeWriterFunction(writerFunctions)); 218 | } 219 | } 220 | 221 | if (this.options.allOf) { 222 | const writerFunctions = this.options.allOf 223 | .filter((node) => ctx.shouldEmitTypes(node, this)) 224 | .map((node) => node.provideWriterFunction(ctx)); 225 | 226 | if (writerFunctions.length) { 227 | typeWriters.push(createIntersectionTypeWriterFunction(writerFunctions)); 228 | } 229 | } 230 | 231 | if (this.options.oneOf) { 232 | const writerFunctions = this.options.oneOf 233 | .filter((node) => ctx.shouldEmitTypes(node, this)) 234 | .map((node) => node.provideWriterFunction(ctx)); 235 | 236 | if (writerFunctions.length) { 237 | typeWriters.push(createUnionTypeWriterFunction(writerFunctions)); 238 | } 239 | } 240 | 241 | if (typeof this.schema.const !== 'undefined') { 242 | typeWriters.push(createLiteralWriterFunction(JSON.stringify(this.schema.const))); 243 | } 244 | 245 | if (this.schema.enum) { 246 | const unionWriters: WriterFunction[] = this.schema.enum.map((value) => 247 | createLiteralWriterFunction(JSON.stringify(value)) 248 | ); 249 | 250 | if (unionWriters.length) { 251 | typeWriters.push(createUnionTypeWriterFunction(unionWriters)); 252 | } 253 | } 254 | 255 | const writeForTypeName = (typeName: JSONSchema7TypeName): WriterFunction => { 256 | switch (typeName) { 257 | case 'array': 258 | return this.provideWritersForTypeArray(ctx); 259 | case 'boolean': 260 | return createLiteralWriterFunction('boolean'); 261 | case 'null': 262 | return createLiteralWriterFunction('null'); 263 | case 'object': 264 | return this.provideWritersForTypeObject(ctx); 265 | case 'integer': 266 | case 'number': 267 | return createLiteralWriterFunction('number'); 268 | case 'string': 269 | return createLiteralWriterFunction('string'); 270 | default: 271 | throw new Error( 272 | `Invariant violation: Unable to handle unknown type name ${JSON.stringify(typeName)}` 273 | ); 274 | } 275 | }; 276 | 277 | if (Array.isArray(this.schema.type)) { 278 | const writerFunctions = this.schema.type.map(writeForTypeName); 279 | 280 | typeWriters.push(createUnionTypeWriterFunction(writerFunctions)); 281 | } else if (this.schema.type) { 282 | const typeWriter = writeForTypeName(this.schema.type); 283 | 284 | typeWriters.push(typeWriter); 285 | } else { 286 | // We have no explicit type so we'll infer from assertions 287 | if ( 288 | typeof this.schema.uniqueItems === 'boolean' || 289 | typeof this.schema.maxItems === 'number' || 290 | typeof this.schema.minItems === 'number' || 291 | this.options.contains 292 | ) { 293 | typeWriters.push(writeForTypeName('array')); 294 | } 295 | } 296 | 297 | if (!typeWriters.length) { 298 | typeWriters.push(createLiteralWriterFunction(ctx.anyType)); 299 | } 300 | 301 | return createIntersectionTypeWriterFunction(typeWriters); 302 | } 303 | 304 | private provideWritersForTuple(ctx: ITypingContext, items: ISchemaNode[]): WriterFunction { 305 | return (writer) => { 306 | writer.write('['); 307 | 308 | for (let i = 0; i < items.length; i++) { 309 | const typeWriter = items[i].provideWriterFunction(ctx); 310 | 311 | if (i > 0) { 312 | writer.write(','); 313 | } 314 | 315 | typeWriter(writer); 316 | } 317 | 318 | if (this.options.additionalItems && ctx.shouldEmitTypes(this.options.additionalItems, this)) { 319 | const typeWriter = this.options.additionalItems.provideWriterFunction(ctx); 320 | 321 | writer.write('...'), typeWriter(writer); 322 | writer.write('[]'); 323 | } 324 | 325 | writer.write(']'); 326 | }; 327 | } 328 | 329 | private provideWritersForTypeArray(ctx: ITypingContext): WriterFunction { 330 | const items = this.options.items; 331 | 332 | if (Array.isArray(items)) { 333 | return this.provideWritersForTuple(ctx, items); 334 | } 335 | 336 | let typeWriter = createLiteralWriterFunction('unknown[]'); 337 | 338 | if (items && ctx.shouldEmitTypes(items, this)) { 339 | const writerFunction = items.provideWriterFunction(ctx); 340 | 341 | typeWriter = (writer) => { 342 | writer.write('('); 343 | writerFunction(writer); 344 | writer.write(')[]'); 345 | }; 346 | } 347 | 348 | return typeWriter; 349 | } 350 | 351 | private provideWritersForTypeObject(ctx: ITypingContext): WriterFunction { 352 | const required = new Set(this.schema.required); 353 | const writers: WriterFunction[] = []; 354 | 355 | if (this.options.properties) { 356 | const properties: OptionalKind[] = []; 357 | 358 | for (const name in this.options.properties) { 359 | const node = this.options.properties[name]; 360 | 361 | if (!ctx.shouldEmitTypes(node, this)) { 362 | continue; 363 | } 364 | 365 | const typeWriter = node.provideWriterFunction(ctx); 366 | const docs = node.provideDocs(); 367 | const safeName = propertyNameRequiresQuoting(name) ? `[${JSON.stringify(name)}]` : name; 368 | 369 | properties.push({ 370 | docs: docs ? [docs] : undefined, 371 | name: safeName, 372 | hasQuestionToken: !required.has(name), 373 | type: typeWriter, 374 | }); 375 | } 376 | 377 | if (properties.length) { 378 | writers.push(Writers.objectType({ properties })); 379 | } 380 | } 381 | 382 | const indexSignatures: OptionalKind[] = []; 383 | 384 | if ( 385 | this.options.additionalProperties && 386 | ctx.shouldEmitTypes(this.options.additionalProperties, this) 387 | ) { 388 | const typeWriter = this.options.additionalProperties.provideWriterFunction(ctx); 389 | 390 | indexSignatures.push({ 391 | keyName: 'additionalProperties', 392 | keyType: 'string', 393 | returnType: typeWriter, 394 | }); 395 | } 396 | 397 | if (this.options.patternProperties) { 398 | for (const name in this.options.patternProperties) { 399 | const node = this.options.patternProperties[name]; 400 | 401 | if (!ctx.shouldEmitTypes(node, this)) { 402 | continue; 403 | } 404 | 405 | const typeWriter = node.provideWriterFunction(ctx); 406 | 407 | indexSignatures.push({ 408 | keyName: 'patternProperties', 409 | keyType: 'string', 410 | returnType: typeWriter, 411 | }); 412 | } 413 | } 414 | 415 | if (indexSignatures.length) { 416 | writers.push(Writers.objectType({ indexSignatures })); 417 | } 418 | 419 | if (!writers.length) { 420 | writers.push(createLiteralWriterFunction(`{ [property: string]: ${ctx.anyType} }`)); 421 | } 422 | 423 | return createIntersectionTypeWriterFunction(writers); 424 | } 425 | } 426 | 427 | function createIntersectionTypeWriterFunction(options: WriterFunction[]): WriterFunction { 428 | if (!options.length) { 429 | throw new Error(`Invariant violation: options should always have length > 0`); 430 | } 431 | 432 | if (options.length === 1) { 433 | return options[0]; 434 | } 435 | 436 | const writerFn = Writers.intersectionType(...(options as [WriterFunction, WriterFunction])); 437 | 438 | return (writer) => { 439 | writer.write('('); 440 | writerFn(writer); 441 | writer.write(')'); 442 | }; 443 | } 444 | 445 | function createLiteralWriterFunction(value: string): WriterFunction { 446 | return (writer: CodeBlockWriter) => writer.write(value); 447 | } 448 | 449 | function createUnionTypeWriterFunction(options: WriterFunction[]): WriterFunction { 450 | if (!options.length) { 451 | throw new Error(`Invariant violation: options should always have length > 0`); 452 | } 453 | 454 | if (options.length === 1) { 455 | return options[0]; 456 | } 457 | 458 | const writerFn = Writers.unionType(...(options as [WriterFunction, WriterFunction])); 459 | 460 | return (writer) => { 461 | writer.write('('); 462 | writerFn(writer); 463 | writer.write(')'); 464 | }; 465 | } 466 | 467 | function propertyNameRequiresQuoting(name: string): boolean { 468 | return !isValidVariable(name); 469 | } 470 | -------------------------------------------------------------------------------- /src/parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as Fs from 'fs'; 2 | import * as Path from 'path'; 3 | import { URL } from 'url'; 4 | import { ParserDiagnosticKind } from './diagnostics'; 5 | import { Parser } from './parser'; 6 | import { JSONSchema7Definition } from './types'; 7 | 8 | const baseRemoteUrl = new URL('http://localhost:1234/'); 9 | const testCasesPackageDir = Path.dirname(require.resolve('json-schema-test-suite/package.json')); 10 | const testCasesDir = Path.join(testCasesPackageDir, 'tests/draft7'); 11 | const testCasesEntries = Fs.readdirSync(testCasesDir, { withFileTypes: true }); 12 | const suite = testCasesEntries 13 | .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) 14 | .map((entry) => { 15 | const contents = Fs.readFileSync(Path.join(testCasesDir, entry.name), 'utf-8'); 16 | const cases = JSON.parse(contents) as Array<{ 17 | description: string; 18 | schema: JSONSchema7Definition; 19 | tests: Array<{ description: string; data: unknown; valid: boolean }>; 20 | }>; 21 | 22 | return { 23 | name: Path.basename(entry.name), 24 | cases, 25 | uri: new URL(entry.name, baseRemoteUrl).href, 26 | }; 27 | }); 28 | const draft07Schema = JSON.parse( 29 | Fs.readFileSync(Path.join(__dirname, '../scripts/json-schema-draft-07.json'), 'utf-8') 30 | ) as JSONSchema7Definition; 31 | const remoteDir = Path.join(testCasesPackageDir, 'remotes'); 32 | const remoteSchemas: Array<{ schema: JSONSchema7Definition; uri: string }> = [ 33 | { 34 | schema: draft07Schema, 35 | uri: 'http://json-schema.org/draft-07/schema', 36 | }, 37 | ]; 38 | 39 | const addRemotesAtPath = (relPath: string) => { 40 | const entries = Fs.readdirSync(Path.join(remoteDir, relPath), { withFileTypes: true }); 41 | 42 | for (const entry of entries) { 43 | if (entry.isDirectory()) { 44 | addRemotesAtPath(Path.join(relPath, entry.name)); 45 | continue; 46 | } 47 | 48 | if (entry.isFile()) { 49 | const relPathName = Path.join(relPath, entry.name); 50 | const content = Fs.readFileSync(Path.join(remoteDir, relPathName), 'utf-8'); 51 | const schema = JSON.parse(content) as JSONSchema7Definition; 52 | 53 | remoteSchemas.push({ schema, uri: new URL(relPathName, baseRemoteUrl).href }); 54 | } 55 | } 56 | }; 57 | 58 | addRemotesAtPath(''); 59 | 60 | const matrix: [ 61 | string, 62 | typeof suite[number]['cases'], 63 | string 64 | ][] = suite.map(({ name, cases, uri }) => [name, cases, uri]); 65 | 66 | describe.each(matrix)('%s', (_name, cases, uri) => { 67 | const matrix: [ 68 | string, 69 | JSONSchema7Definition, 70 | typeof suite[number]['cases'][number]['tests'] 71 | ][] = cases.map(({ description, schema, tests }) => [description, schema, tests]); 72 | 73 | it.each(matrix)('%s', (_name, schema, tests) => { 74 | const parser = new Parser(); 75 | 76 | remoteSchemas.forEach(({ schema, uri }) => parser.addSchema(uri, schema)); 77 | 78 | parser.addSchema(uri, schema); 79 | 80 | const result = parser.compile(); 81 | const errorDiagnostics = result.diagnostics.filter( 82 | (d) => d.severity === ParserDiagnosticKind.Error 83 | ); 84 | 85 | expect(errorDiagnostics).toHaveLength(0); 86 | }); 87 | }); 88 | 89 | describe('A Parser instance', () => { 90 | it('will use a preferred name', () => { 91 | const parser = new Parser(); 92 | const typeName = parser.addSchema( 93 | 'file:///foo.json', 94 | { 95 | title: 'Title of the type', 96 | }, 97 | { 98 | preferredName: 'MyType', 99 | } 100 | ); 101 | 102 | expect(typeName).toBe('MyType'); 103 | }); 104 | 105 | it('will use a preferred name but will coerce it to a safe string', () => { 106 | const parser = new Parser(); 107 | const typeName = parser.addSchema( 108 | 'file:///foo.json', 109 | { 110 | title: 'Title of the type', 111 | }, 112 | { 113 | preferredName: 'My Type', 114 | } 115 | ); 116 | 117 | expect(typeName).toBe('MyType'); 118 | }); 119 | 120 | it('will use a preferred name but will add an ordinal suffix if already present', () => { 121 | const parser = new Parser(); 122 | const typeName1 = parser.addSchema( 123 | 'file:///foo.json', 124 | { 125 | title: 'Title of the type', 126 | }, 127 | { 128 | preferredName: 'My Type', 129 | } 130 | ); 131 | const typeName2 = parser.addSchema( 132 | 'file:///bar.json', 133 | { 134 | title: 'Title of the type', 135 | }, 136 | { 137 | preferredName: 'My Type', 138 | } 139 | ); 140 | 141 | expect(typeName1).toBe('MyType'); 142 | expect(typeName2).toBe('MyType0'); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/parser.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IndentationText, 3 | ModuleKind, 4 | NewLineKind, 5 | Project, 6 | QuoteKind, 7 | ScriptTarget, 8 | } from 'ts-morph'; 9 | import { URL } from 'url'; 10 | import { IParserDiagnostic, ParserDiagnosticKind } from './diagnostics'; 11 | import { 12 | AnyType, 13 | BooleanSchemaNode, 14 | ISchemaNode, 15 | ITypingContext, 16 | SchemaNode, 17 | SchemaNodeOptions, 18 | } from './nodes'; 19 | import { IParserContext, ParserContext } from './parserContext'; 20 | import { IReference } from './references'; 21 | import { CoreSchemaMetaSchema } from './schema'; 22 | import { JSONSchema7, JSONSchema7Definition, JSONSchema7TypeName } from './types'; 23 | 24 | interface ParserCompileOptions { 25 | /** 26 | * The type name for schemas that are functionally equivalent to TypeScript's `any` 27 | * or `unknown` type. 28 | */ 29 | anyType?: AnyType; 30 | 31 | /** 32 | * Skip emitting a `@see` directive when generating doc comments on schemas having 33 | * an `$id` property. 34 | */ 35 | omitIdComments?: boolean; 36 | 37 | /** 38 | * Declaration options for the top-level schemas in each added schema file. 39 | */ 40 | topLevel?: ParserCompileTypeOptions; 41 | 42 | /** 43 | * Declaration options for the sub-schemas depended-upon by the top-level schemas. 44 | */ 45 | lifted?: ParserCompileTypeOptions; 46 | 47 | shouldOmitTypeEmit?( 48 | node: ISchemaNode, 49 | parentNode: ISchemaNode 50 | ): boolean; 51 | } 52 | 53 | interface ParserCompileTypeOptions { 54 | hasDeclareKeyword?: boolean; 55 | isExported?: boolean; 56 | } 57 | 58 | interface AddSchemaOptions { 59 | preferredName?: string; 60 | } 61 | 62 | interface GenerateDeconflictedNameOptions { 63 | preferredName?: string; 64 | } 65 | 66 | const alwaysEmit = () => true; 67 | 68 | export interface ParserOptions { 69 | /** 70 | * Specify the default schema to be used for unknown properties. 71 | * 72 | * For example, you could pass the schema `true` which will allow any valid 73 | * JSON value to be used as an unknown property. 74 | */ 75 | defaultUnknownPropertiesSchema?: JSONSchema7Definition; 76 | } 77 | 78 | 79 | export class Parser { 80 | private readonly ctx: ParserContext; 81 | private readonly uriToTypeName = new Map(); 82 | private readonly uriByTypeName = new Map(); 83 | private readonly rootNodes = new Map(); 84 | 85 | constructor(options: ParserOptions = {}) { 86 | this.ctx = new ParserContext({ 87 | defaultUnknownPropertiesSchema: options.defaultUnknownPropertiesSchema ?? false 88 | }) 89 | } 90 | 91 | addSchema(uri: string, schema: JSONSchema7Definition, options: AddSchemaOptions = {}) { 92 | const node = this.ctx.enterUri(uri, schema, parseSchemaDefinition); 93 | const name = this.generateDeconflictedName(node, { preferredName: options.preferredName }); 94 | this.rootNodes.set(uri, node); 95 | 96 | return name; 97 | } 98 | 99 | compile(options: ParserCompileOptions = {}) { 100 | const diagnostics = this.checkReferences(); 101 | const text = this.generateTypings(options); 102 | 103 | return { 104 | diagnostics, 105 | text, 106 | }; 107 | } 108 | 109 | private checkReferences(): IParserDiagnostic[] { 110 | const diagnostics: IParserDiagnostic[] = []; 111 | 112 | for (const reference of this.ctx.references) { 113 | const node = this.ctx.getSchemaByReference(reference); 114 | 115 | if (typeof node === 'undefined') { 116 | diagnostics.push({ 117 | code: 'EUNRESOLVED', 118 | severity: ParserDiagnosticKind.Error, 119 | message: `Missing schema for the reference ${JSON.stringify( 120 | reference.ref 121 | )} at ${JSON.stringify(reference.fromUri)} that resolved to ${JSON.stringify( 122 | reference.toUri 123 | )}.`, 124 | uri: reference.fromUri, 125 | baseUri: reference.fromBaseUri, 126 | }); 127 | } 128 | } 129 | 130 | return diagnostics; 131 | } 132 | 133 | private generateDeconflictedName( 134 | node: ISchemaNode, 135 | options: GenerateDeconflictedNameOptions = {} 136 | ): string { 137 | const cached = this.uriToTypeName.get(node.uri); 138 | 139 | if (cached) { 140 | return cached; 141 | } 142 | 143 | let candidate: string = options.preferredName ? toSafeString(options.preferredName) : ''; 144 | 145 | if (!candidate) { 146 | if (typeof node.schema !== 'boolean' && node.schema.title) { 147 | candidate = toSafeString(node.schema.title); 148 | } else { 149 | const url = new URL(node.uri); 150 | const matches = url.hash.match(/^#\/(?:\w+\/)*(\w+)$/); 151 | 152 | if (matches && matches[1]) { 153 | candidate = toSafeString(matches[1]); 154 | } else { 155 | candidate = toSafeString( 156 | new URL((node.schema as JSONSchema7).$id || node.uri).pathname.replace(/\.[\w\.]*$/, '') 157 | ); 158 | } 159 | } 160 | } 161 | 162 | if (!candidate) { 163 | candidate = 'AnonymousSchema'; 164 | } 165 | 166 | if (this.uriByTypeName.has(candidate)) { 167 | let suffix = 0; 168 | const baseName = candidate; 169 | 170 | for ( 171 | candidate = `${baseName}${suffix++}`; 172 | this.uriByTypeName.has(candidate); 173 | candidate = `${baseName}${suffix++}` 174 | ); 175 | } 176 | 177 | this.uriToTypeName.set(node.uri, candidate); 178 | this.uriByTypeName.set(candidate, node.uri); 179 | 180 | return candidate; 181 | } 182 | 183 | private getNodeByReference(ref: IReference): ISchemaNode { 184 | let node = this.ctx.nodesByUri.get(ref.toUri) || this.ctx.nodesByUri.get(ref.toBaseUri); 185 | 186 | if (!node) { 187 | const schema = this.ctx.getSchemaByReference(ref); 188 | 189 | if (schema) { 190 | node = this.ctx.enterUri(ref.toUri, schema, parseSchemaDefinition); 191 | } 192 | } 193 | 194 | if (!node) { 195 | throw new Error( 196 | `Missing schema for the reference ${JSON.stringify(ref.ref)} at ${JSON.stringify( 197 | ref.fromUri 198 | )} that resolved to ${JSON.stringify(ref.toUri)}.` 199 | ); 200 | } 201 | 202 | return node; 203 | } 204 | 205 | private generateTypings(options: ParserCompileOptions) { 206 | const liftedTypes = new Map(); 207 | const shouldOmitTypeEmit = options.shouldOmitTypeEmit; 208 | const ctx: ITypingContext = { 209 | anyType: options.anyType || 'JSONValue', 210 | getNameForReference: (ref: IReference) => { 211 | const node = this.getNodeByReference(ref); 212 | const name = this.generateDeconflictedName(node); 213 | 214 | liftedTypes.set(name, node); 215 | 216 | return name; 217 | }, 218 | shouldEmitTypes: shouldOmitTypeEmit 219 | ? (node, parentNode) => !shouldOmitTypeEmit(node, parentNode) 220 | : alwaysEmit, 221 | }; 222 | 223 | const project = new Project({ 224 | compilerOptions: { 225 | alwaysStrict: true, 226 | declaration: true, 227 | downlevelIteration: true, 228 | esModuleInterop: true, 229 | isolatedModules: true, 230 | lib: ['esnext'], 231 | module: ModuleKind.CommonJS, 232 | removeComments: true, 233 | strict: true, 234 | suppressExcessPropertyErrors: true, 235 | target: ScriptTarget.ES2018, 236 | }, 237 | useInMemoryFileSystem: true, 238 | manipulationSettings: { 239 | indentationText: IndentationText.TwoSpaces, 240 | useTrailingCommas: true, 241 | newLineKind: NewLineKind.LineFeed, 242 | quoteKind: QuoteKind.Single, 243 | insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true, 244 | }, 245 | }); 246 | const sourceFile = project.createSourceFile( 247 | 'schema.ts', 248 | ctx.anyType === 'JSONValue' 249 | ? ` 250 | type JSONPrimitive = boolean | null | number | string; 251 | type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue }; 252 | `.trim() + '\n' 253 | : '' 254 | ); 255 | const printed = new Set(); 256 | 257 | for (const node of this.rootNodes.values()) { 258 | // Exported schemas should get first pick 259 | const name = this.generateDeconflictedName(node); 260 | const writerFunction = node.provideWriterFunction(ctx); 261 | const docs = node.provideDocs(); 262 | 263 | sourceFile.addTypeAlias({ 264 | name, 265 | docs: docs ? [docs] : undefined, 266 | type: writerFunction, 267 | isExported: options.topLevel?.isExported ?? true, 268 | hasDeclareKeyword: options.topLevel?.hasDeclareKeyword ?? false, 269 | }); 270 | 271 | printed.add(node); 272 | } 273 | 274 | for (const [name, node] of liftedTypes) { 275 | if (!printed.has(node)) { 276 | printed.add(node); 277 | 278 | const writerFunction = node.provideWriterFunction(ctx); 279 | const docs = node.provideDocs(); 280 | 281 | sourceFile.addTypeAlias({ 282 | name, 283 | docs: docs ? [docs] : undefined, 284 | type: writerFunction, 285 | isExported: options.lifted?.isExported ?? options.topLevel?.isExported ?? true, 286 | hasDeclareKeyword: 287 | options.lifted?.hasDeclareKeyword ?? options.topLevel?.hasDeclareKeyword ?? false, 288 | }); 289 | } 290 | } 291 | 292 | return sourceFile.print(); 293 | } 294 | } 295 | 296 | function hasAnyProperty(value: T, ...keys: K[]): boolean { 297 | for (const key of keys) { 298 | if (key in value) return true; 299 | } 300 | 301 | return false; 302 | } 303 | 304 | function parseSchemaDefinition(ctx: IParserContext, schema: JSONSchema7Definition): ISchemaNode { 305 | return ctx.enterSchemaNode(schema, () => 306 | typeof schema === 'boolean' 307 | ? new BooleanSchemaNode(ctx.uri, ctx.baseUri, schema, undefined) 308 | : parseSchema(ctx, schema) 309 | ); 310 | } 311 | 312 | function parseSchema(ctx: IParserContext, schema: JSONSchema7): ISchemaNode { 313 | const o: SchemaNodeOptions = {}; 314 | if (typeof schema.$id !== 'undefined') o.$id = schema.$id; 315 | if (typeof schema.$ref !== 'undefined') o.$ref = ctx.createReference(schema.$ref); 316 | if (typeof schema.$schema !== 'undefined') o.$schema = schema.$schema; 317 | if (typeof schema.$comment !== 'undefined') o.$comment = schema.$comment; 318 | if (typeof schema.type !== 'undefined') o.type = schema.type; 319 | if (typeof schema.enum !== 'undefined') o.enum = schema.enum; 320 | if (typeof schema.const !== 'undefined') o.const = schema.const; 321 | if (typeof schema.format !== 'undefined') o.format = schema.format; 322 | if (typeof schema.contentMediaType !== 'undefined') o.contentMediaType = schema.contentMediaType; 323 | if (typeof schema.contentEncoding !== 'undefined') o.contentEncoding = schema.contentEncoding; 324 | if (typeof schema.title !== 'undefined') o.title = schema.title; 325 | if (typeof schema.description !== 'undefined') o.description = schema.description; 326 | if (typeof schema.default !== 'undefined') o.default = schema.default; 327 | if (typeof schema.readOnly !== 'undefined') o.readOnly = schema.readOnly; 328 | if (typeof schema.writeOnly !== 'undefined') o.writeOnly = schema.writeOnly; 329 | if (typeof schema.examples !== 'undefined') o.examples = schema.examples; 330 | 331 | // 1. Depth-first traversal 332 | const parseNodeForType = (typeName: JSONSchema7TypeName) => { 333 | switch (typeName) { 334 | case 'array': { 335 | visitAsArray(ctx, schema, o); 336 | break; 337 | } 338 | case 'boolean': { 339 | // Nothing special 340 | break; 341 | } 342 | case 'integer': { 343 | visitAsNumber(ctx, schema, o); 344 | break; 345 | } 346 | case 'object': { 347 | visitAsObject(ctx, schema, o); 348 | break; 349 | } 350 | case 'number': { 351 | visitAsNumber(ctx, schema, o); 352 | break; 353 | } 354 | case 'null': { 355 | // Nothing special 356 | break; 357 | } 358 | case 'string': { 359 | visitAsString(ctx, schema, o); 360 | break; 361 | } 362 | } 363 | }; 364 | 365 | if (Array.isArray(schema.type)) { 366 | for (const typeName of schema.type) { 367 | parseNodeForType(typeName); 368 | } 369 | } else if (schema.type) { 370 | parseNodeForType(schema.type); 371 | } else { 372 | if (hasAnyProperty(schema, 'maxItems', 'minItems', 'uniqueItems', 'contains')) { 373 | parseNodeForType('array'); 374 | } 375 | } 376 | 377 | if (typeof schema.if !== 'undefined') ctx.enterPath(['if'], schema.if, parseSchemaDefinition); 378 | if (typeof schema.then !== 'undefined') 379 | ctx.enterPath(['then'], schema.then, parseSchemaDefinition); 380 | if (typeof schema.else !== 'undefined') 381 | ctx.enterPath(['else'], schema.else, parseSchemaDefinition); 382 | 383 | if (typeof schema.allOf !== 'undefined') { 384 | o.allOf = schema.allOf.map((item, idx) => 385 | ctx.enterPath(['allOf', idx.toString()], item, parseSchemaDefinition) 386 | ); 387 | } 388 | 389 | if (typeof schema.anyOf !== 'undefined') { 390 | o.anyOf = schema.anyOf.map((item, idx) => 391 | ctx.enterPath(['anyOf', idx.toString()], item, parseSchemaDefinition) 392 | ); 393 | } 394 | 395 | if (typeof schema.oneOf !== 'undefined') { 396 | o.oneOf = schema.oneOf.map((item, idx) => 397 | ctx.enterPath(['oneOf', idx.toString()], item, parseSchemaDefinition) 398 | ); 399 | } 400 | 401 | // TODO: Need to propagate negation context because of special 'require' semantics 402 | if (typeof schema.not !== 'undefined') 403 | o.not = ctx.enterPath(['not'], schema.not, parseSchemaDefinition); 404 | 405 | if (typeof schema.definitions !== 'undefined') { 406 | o.definitions = {}; 407 | for (const key in schema.definitions) { 408 | o.definitions[key] = ctx.enterPath( 409 | ['definitions', key], 410 | schema.definitions[key], 411 | parseSchemaDefinition 412 | ); 413 | } 414 | } 415 | 416 | return new SchemaNode(ctx.uri, ctx.baseUri, schema, o); 417 | } 418 | 419 | function toSafeString(str: string) { 420 | return ( 421 | str 422 | .replace(/(^\s*[^a-zA-Z_$])|([^a-zA-Z_$\d])/g, ' ') 423 | // uppercase leading underscores followed by lowercase 424 | .replace(/^_[a-z]/g, (match) => match.toUpperCase()) 425 | // remove non-leading underscores followed by lowercase (convert snake_case) 426 | .replace(/_[a-z]/g, (match) => match.substr(1, match.length).toUpperCase()) 427 | // uppercase letters after digits, dollars 428 | .replace(/([\d$]+[a-zA-Z])/g, (match) => match.toUpperCase()) 429 | // uppercase first letter after whitespace 430 | .replace(/\s+([a-zA-Z])/g, (match) => match.toUpperCase()) 431 | // remove remaining whitespace 432 | .replace(/\s/g, '') 433 | .replace(/^[a-z]/, (match) => match.toUpperCase()) 434 | ); 435 | } 436 | 437 | function visitAsArray(ctx: IParserContext, schema: JSONSchema7, o: SchemaNodeOptions) { 438 | if (typeof schema.maxItems !== 'undefined') o.maxItems = schema.maxItems; 439 | if (typeof schema.minItems !== 'undefined') o.minItems = schema.minItems; 440 | if (typeof schema.uniqueItems !== 'undefined') o.uniqueItems = schema.uniqueItems; 441 | 442 | const items = schema.items; 443 | if (typeof items !== 'undefined') { 444 | if (Array.isArray(items)) { 445 | o.items = items.map((item, idx) => 446 | ctx.enterPath(['items', idx.toString()], item, parseSchemaDefinition) 447 | ); 448 | } else { 449 | o.items = ctx.enterPath(['items'], items, parseSchemaDefinition); 450 | } 451 | } 452 | 453 | const additionalItems = schema.additionalItems; 454 | if (typeof additionalItems !== 'undefined') { 455 | o.additionalItems = ctx.enterPath(['additionalItems'], additionalItems, parseSchemaDefinition); 456 | } 457 | 458 | const contains = schema.contains; 459 | if (typeof contains !== 'undefined') { 460 | o.contains = ctx.enterPath(['contains'], contains, parseSchemaDefinition); 461 | } 462 | } 463 | 464 | function visitAsObject(ctx: IParserContext, schema: JSONSchema7, o: SchemaNodeOptions) { 465 | if (typeof schema.maxProperties !== 'undefined') o.maxProperties = schema.maxProperties; 466 | if (typeof schema.minProperties !== 'undefined') o.minProperties = schema.minProperties; 467 | if (typeof schema.required !== 'undefined') o.required = schema.required; 468 | 469 | const properties = schema.properties; 470 | if (typeof properties !== 'undefined') { 471 | o.properties = {}; 472 | for (const key in properties) { 473 | o.properties[key] = ctx.enterPath( 474 | ['properties', key], 475 | properties[key], 476 | parseSchemaDefinition 477 | ); 478 | } 479 | } 480 | 481 | const patternProperties = schema.patternProperties; 482 | if (typeof patternProperties !== 'undefined') { 483 | o.patternProperties = {}; 484 | for (const key in patternProperties) { 485 | o.patternProperties[key] = ctx.enterPath( 486 | ['patternProperties', key], 487 | patternProperties[key], 488 | parseSchemaDefinition 489 | ); 490 | } 491 | } 492 | 493 | const additionalProperties = schema.additionalProperties ?? ctx.defaultUnknownPropertiesSchema; 494 | if (typeof additionalProperties !== 'undefined' && additionalProperties !== false) { 495 | o.additionalProperties = ctx.enterPath( 496 | ['additionalProperties'], 497 | additionalProperties, 498 | parseSchemaDefinition 499 | ); 500 | } 501 | 502 | const dependencies = schema.dependencies; 503 | if (typeof dependencies !== 'undefined') { 504 | o.dependencies = {}; 505 | for (const key in dependencies) { 506 | const value = dependencies[key]; 507 | o.dependencies[key] = Array.isArray(value) 508 | ? value 509 | : ctx.enterPath(['dependencies', key], value, parseSchemaDefinition); 510 | } 511 | } 512 | 513 | const propertyNames = schema.propertyNames; 514 | if (typeof propertyNames !== 'undefined') { 515 | o.propertyNames = ctx.enterPath(['propertyNames'], propertyNames, parseSchemaDefinition); 516 | } 517 | } 518 | 519 | function visitAsNumber(_ctx: IParserContext, schema: JSONSchema7, o: SchemaNodeOptions) { 520 | if (typeof schema.multipleOf !== 'undefined') o.multipleOf = schema.multipleOf; 521 | if (typeof schema.maximum !== 'undefined') o.maximum = schema.maximum; 522 | if (typeof schema.exclusiveMaximum !== 'undefined') o.exclusiveMaximum = schema.exclusiveMaximum; 523 | if (typeof schema.minimum !== 'undefined') o.minimum = schema.minimum; 524 | if (typeof schema.exclusiveMinimum !== 'undefined') o.exclusiveMinimum = schema.exclusiveMinimum; 525 | } 526 | 527 | function visitAsString(_ctx: IParserContext, schema: JSONSchema7, o: SchemaNodeOptions) { 528 | if (typeof schema.maxLength !== 'undefined') o.maxLength = schema.maxLength; 529 | if (typeof schema.minLength !== 'undefined') o.minLength = schema.minLength; 530 | if (typeof schema.pattern !== 'undefined') o.pattern = schema.pattern; 531 | } 532 | -------------------------------------------------------------------------------- /src/parserContext.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | import { IParserDiagnostic, ParserDiagnosticKind } from './diagnostics'; 3 | import { ISchemaNode } from './nodes'; 4 | import { IReference } from './references'; 5 | import { CoreSchemaMetaSchema } from './schema'; 6 | import type { JSONSchema7Definition } from './types'; 7 | import { resolveRelativeJSONPath, resolveRelativeUri } from './uris'; 8 | 9 | export interface IParserContext { 10 | defaultUnknownPropertiesSchema: JSONSchema7Definition; 11 | 12 | readonly diagnostics: IParserDiagnostic[]; 13 | readonly baseUri: string; 14 | readonly uri: string; 15 | 16 | addDiagnostic(info: Pick): void; 17 | 18 | createReference(ref: string): IReference; 19 | 20 | enterPath( 21 | path: [string, ...string[]], 22 | schema: CoreSchemaMetaSchema, 23 | fn: (ctx: IParserContext, schema: CoreSchemaMetaSchema) => ISchemaNode 24 | ): ISchemaNode; 25 | 26 | enterSchemaNode(schema: CoreSchemaMetaSchema, fn: () => ISchemaNode): ISchemaNode; 27 | } 28 | 29 | export interface ParserContextOptions { 30 | /** 31 | * Specify the default schema to be used for unknown properties. 32 | * 33 | * For example, you could pass the schema `true` which will allow any valid 34 | * JSON value to be used as an unknown property. 35 | */ 36 | defaultUnknownPropertiesSchema: JSONSchema7Definition; 37 | } 38 | 39 | export class ParserContext implements IParserContext { 40 | baseUri: string = ''; 41 | uri: string = ''; 42 | 43 | readonly defaultUnknownPropertiesSchema: JSONSchema7Definition = false; 44 | readonly diagnostics: IParserDiagnostic[] = []; 45 | readonly schemasByUri = new Map(); 46 | readonly nodesByUri = new Map(); 47 | readonly references = new Set(); 48 | 49 | constructor(options: ParserContextOptions) { 50 | this.defaultUnknownPropertiesSchema = options.defaultUnknownPropertiesSchema; 51 | } 52 | 53 | addDiagnostic(info: Pick) { 54 | this.diagnostics.push({ 55 | severity: ParserDiagnosticKind.Error, 56 | baseUri: this.baseUri, 57 | code: info.code, 58 | message: info.message, 59 | uri: this.uri, 60 | }); 61 | } 62 | 63 | createReference(ref: string): IReference { 64 | const fromUri = this.uri; 65 | const fromBaseUri = this.baseUri; 66 | const toUri = resolveRelativeUri(fromUri, ref); 67 | const toBaseUri = resolveRelativeUri(fromBaseUri, ref); 68 | const reference = { 69 | ref, 70 | fromUri, 71 | fromBaseUri, 72 | toUri, 73 | toBaseUri, 74 | }; 75 | 76 | this.references.add(reference); 77 | 78 | return reference; 79 | } 80 | 81 | enterUri( 82 | enteredUri: string, 83 | schema: CoreSchemaMetaSchema, 84 | fn: (ctx: IParserContext, schema: CoreSchemaMetaSchema) => ISchemaNode 85 | ) { 86 | const uri = this.uri; 87 | const baseUri = this.baseUri; 88 | 89 | this.uri = enteredUri; 90 | this.baseUri = enteredUri; 91 | 92 | const node = fn(this, schema); 93 | 94 | this.schemasByUri.set(this.uri, schema); 95 | 96 | this.uri = uri; 97 | this.baseUri = baseUri; 98 | 99 | return node; 100 | } 101 | 102 | enterPath( 103 | path: [string, ...string[]], 104 | schema: CoreSchemaMetaSchema, 105 | fn: (ctx: IParserContext, schema: CoreSchemaMetaSchema) => ISchemaNode 106 | ): ISchemaNode { 107 | const uri = this.uri; 108 | const baseUri = this.baseUri; 109 | 110 | this.uri = resolveRelativeJSONPath(uri, path); 111 | this.baseUri = resolveRelativeJSONPath(baseUri, path); 112 | 113 | const node = fn(this, schema); 114 | 115 | this.uri = uri; 116 | this.baseUri = baseUri; 117 | 118 | return node; 119 | } 120 | 121 | enterSchemaNode(schema: CoreSchemaMetaSchema, fn: () => ISchemaNode): ISchemaNode { 122 | const uri = this.uri; 123 | const baseUri = this.baseUri; 124 | 125 | if (typeof schema !== 'boolean' && schema.$id) { 126 | this.baseUri = resolveRelativeUri(baseUri, schema.$id); 127 | 128 | // The schema indicated that it wants to treat this as a 'location-independent' uri 129 | this.schemasByUri.set(this.baseUri, schema); 130 | } 131 | 132 | const node = fn(); 133 | 134 | this.nodesByUri.set(this.uri, node); 135 | 136 | if (this.baseUri !== this.uri) { 137 | this.nodesByUri.set(this.baseUri, node); 138 | } 139 | 140 | this.uri = uri; 141 | this.baseUri = baseUri; 142 | 143 | return node; 144 | } 145 | 146 | getSchemaByReference(ref: IReference) { 147 | return this.getSchemaByUri(ref.toUri) || this.getSchemaByUri(ref.toBaseUri); 148 | } 149 | 150 | getSchemaByUri(uri: string) { 151 | const found = this.schemasByUri.get(uri); 152 | 153 | if (found) { 154 | return found; 155 | } 156 | 157 | const url = new URL(uri); 158 | const path = url.hash.startsWith('#/') ? url.hash.slice(2).split('/') : []; 159 | 160 | url.hash = ''; 161 | 162 | const schemaUri = url.href; 163 | let schema = this.schemasByUri.get(schemaUri); 164 | 165 | while (schema && path.length) { 166 | const segment = path.shift()!; 167 | 168 | schema = (schema as any)[segment]; 169 | } 170 | 171 | return schema; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/references.ts: -------------------------------------------------------------------------------- 1 | export interface IReference { 2 | ref: string; 3 | fromUri: string; 4 | fromBaseUri: string; 5 | toUri: string; 6 | toBaseUri: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | // IMPORTANT: Do not edit this file by hand; it is automatically generated 2 | 3 | type JSONPrimitive = boolean | null | number | string; 4 | type JSONValue = JSONPrimitive | JSONValue[] | { 5 | [key: string]: JSONValue; 6 | }; 7 | /** Core schema meta-schema */ 8 | export type CoreSchemaMetaSchema = (({ 9 | $id?: string; 10 | $schema?: string; 11 | $ref?: string; 12 | $comment?: string; 13 | title?: string; 14 | description?: string; 15 | ["default"]?: JSONValue; 16 | readOnly?: boolean; 17 | writeOnly?: boolean; 18 | examples?: (JSONValue)[]; 19 | multipleOf?: number; 20 | maximum?: number; 21 | exclusiveMaximum?: number; 22 | minimum?: number; 23 | exclusiveMinimum?: number; 24 | maxLength?: NonNegativeInteger; 25 | minLength?: NonNegativeIntegerDefault0; 26 | pattern?: string; 27 | additionalItems?: CoreSchemaMetaSchema; 28 | items?: (CoreSchemaMetaSchema | SchemaArray); 29 | maxItems?: NonNegativeInteger; 30 | minItems?: NonNegativeIntegerDefault0; 31 | uniqueItems?: boolean; 32 | contains?: CoreSchemaMetaSchema; 33 | maxProperties?: NonNegativeInteger; 34 | minProperties?: NonNegativeIntegerDefault0; 35 | required?: StringArray; 36 | additionalProperties?: CoreSchemaMetaSchema; 37 | definitions?: { 38 | [additionalProperties: string]: CoreSchemaMetaSchema; 39 | }; 40 | properties?: { 41 | [additionalProperties: string]: CoreSchemaMetaSchema; 42 | }; 43 | patternProperties?: { 44 | [additionalProperties: string]: CoreSchemaMetaSchema; 45 | }; 46 | dependencies?: { 47 | [additionalProperties: string]: (CoreSchemaMetaSchema | StringArray); 48 | }; 49 | propertyNames?: CoreSchemaMetaSchema; 50 | ["const"]?: JSONValue; 51 | ["enum"]?: (JSONValue)[]; 52 | type?: (SimpleTypes | (SimpleTypes)[]); 53 | format?: string; 54 | contentMediaType?: string; 55 | contentEncoding?: string; 56 | ["if"]?: CoreSchemaMetaSchema; 57 | then?: CoreSchemaMetaSchema; 58 | ["else"]?: CoreSchemaMetaSchema; 59 | allOf?: SchemaArray; 60 | anyOf?: SchemaArray; 61 | oneOf?: SchemaArray; 62 | not?: CoreSchemaMetaSchema; 63 | } & { 64 | [additionalProperties: string]: JSONValue; 65 | }) | boolean); 66 | export type NonNegativeInteger = number; 67 | export type NonNegativeIntegerDefault0 = (NonNegativeInteger & JSONValue); 68 | export type SchemaArray = (CoreSchemaMetaSchema)[]; 69 | export type StringArray = (string)[]; 70 | export type SimpleTypes = ("array" | "boolean" | "integer" | "null" | "number" | "object" | "string"); 71 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { CoreSchemaMetaSchema, SimpleTypes } from './schema'; 2 | 3 | type JSONPrimitive = boolean | null | number | string; 4 | type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue }; 5 | 6 | export type JSONSchema7Definition = CoreSchemaMetaSchema; 7 | export type JSONSchema7 = Exclude; 8 | export type JSONSchema7Type = JSONValue; 9 | export type JSONSchema7TypeName = SimpleTypes; 10 | -------------------------------------------------------------------------------- /src/uris.ts: -------------------------------------------------------------------------------- 1 | import { URL } from 'url'; 2 | 3 | export function resolveRelativeJSONPath(fromUri: string, path: string[]) { 4 | const fromUrl = new URL(fromUri); 5 | 6 | if (path.length) { 7 | if (!fromUrl.hash) fromUrl.hash = '#'; 8 | 9 | fromUrl.hash += `/${path.join('/')}`; 10 | } 11 | 12 | const uri = fromUrl.href; 13 | 14 | return uri.endsWith('#') ? uri.slice(0, -1) : uri; 15 | } 16 | 17 | export function resolveRelativeUri(fromUri: string, rel: string): string { 18 | let relUrl: URL; 19 | 20 | try { 21 | relUrl = new URL(rel); 22 | } catch { 23 | relUrl = new URL(rel, fromUri); 24 | } 25 | 26 | const uri = relUrl.href; 27 | 28 | return uri.endsWith('#') ? uri.slice(0, -1) : uri; 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "alwaysStrict": true, 5 | "declaration": true, 6 | "declarationMap": true, 7 | "diagnostics": true, 8 | "downlevelIteration": true, 9 | "esModuleInterop": true, 10 | "importHelpers": true, 11 | "incremental": true, 12 | "lib": ["esnext"], 13 | "skipLibCheck": true, 14 | "skipDefaultLibCheck": true, 15 | "module": "CommonJS", 16 | "moduleResolution": "Node", 17 | "noErrorTruncation": true, 18 | "outDir": "./dist", 19 | "pretty": true, 20 | "resolveJsonModule": true, 21 | "sourceMap": true, 22 | "strict": true, 23 | "target": "ES2018", 24 | "typeRoots": ["node_modules/@types", "types"] 25 | }, 26 | "include": ["src", ""] 27 | } 28 | -------------------------------------------------------------------------------- /types/is-valid-variable.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'is-valid-variable' { 2 | function isValidVariable(str: string): boolean; 3 | export = isValidVariable; 4 | } 5 | --------------------------------------------------------------------------------