├── .npmignore ├── .eslintignore ├── .prettierrc ├── tsconfig.json ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── .eslintrc ├── src ├── index.ts ├── inferredSchema.ts ├── jsonSchema │ └── index.ts └── SchemaInference.ts ├── .gitignore ├── tests ├── readme.test.ts ├── json │ ├── mux-list-delivery-usage.json │ ├── tweets.json │ └── airtable.json ├── jsonSchema.test.ts └── __snapshots__ │ └── jsonSchema.test.ts.snap ├── cli └── schema-infer.js ├── .vscode └── launch.json ├── LICENSE ├── badges ├── badge-lines.svg ├── badge-branches.svg ├── badge-functions.svg └── badge-statements.svg ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | cli -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 100 6 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "module": "ES2020", 4 | "compilerOptions": { 5 | "preserveConstEnums": true, 6 | "outDir": "./lib", 7 | "declaration": true, 8 | "allowJs": true 9 | }, 10 | "include": ["src/**/*"], 11 | "exclude": ["node_modules", "**/*.spec.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: push 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions/setup-node@v2 9 | with: 10 | node-version: "16.x" 11 | registry-url: "https://registry.npmjs.org" 12 | - run: npm ci 13 | - run: npm test 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint", "prettier"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "rules": { 12 | "prettier/prettier": 2 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: "16.x" 13 | registry-url: "https://registry.npmjs.org" 14 | - run: npm ci 15 | - run: npm test 16 | - run: npm publish 17 | env: 18 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { InferredSchema } from "./inferredSchema"; 2 | import SchemaInferrer from "./SchemaInference"; 3 | 4 | export function inferSchema(value: unknown, inference?: SchemaInferrer): SchemaInferrer { 5 | const schemaInferrer = new SchemaInferrer(); 6 | schemaInferrer.infer(value, inference); 7 | return schemaInferrer; 8 | } 9 | 10 | export function restoreSnapshot(snapshot: InferredSchema): SchemaInferrer { 11 | return new SchemaInferrer(snapshot); 12 | } 13 | 14 | export type { SchemaInferrer }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | *.swp 10 | 11 | pids 12 | logs 13 | results 14 | tmp 15 | 16 | # Build 17 | public/css/main.css 18 | 19 | # Coverage reports 20 | coverage 21 | 22 | # API keys and secrets 23 | .env 24 | 25 | # Dependency directory 26 | node_modules 27 | bower_components 28 | 29 | # Editors 30 | .idea 31 | *.iml 32 | 33 | # OS metadata 34 | .DS_Store 35 | Thumbs.db 36 | 37 | # Ignore built ts files 38 | lib/**/* 39 | 40 | # ignore yarn.lock 41 | yarn.lock 42 | /tests/test.sqlite 43 | -------------------------------------------------------------------------------- /tests/readme.test.ts: -------------------------------------------------------------------------------- 1 | import { inferSchema } from "../src"; 2 | 3 | test("example 1", () => { 4 | expect( 5 | inferSchema([ 6 | { rank: 1, name: "Eric", winner: true }, 7 | { rank: 2, name: "Matt" }, 8 | ]).toJSONSchema(), 9 | ).toMatchInlineSnapshot(` 10 | Object { 11 | "items": Object { 12 | "properties": Object { 13 | "name": Object { 14 | "type": "string", 15 | }, 16 | "rank": Object { 17 | "type": "integer", 18 | }, 19 | "winner": Object { 20 | "type": "boolean", 21 | }, 22 | }, 23 | "required": Array [ 24 | "rank", 25 | "name", 26 | ], 27 | "type": "object", 28 | }, 29 | "type": "array", 30 | } 31 | `); 32 | }); 33 | -------------------------------------------------------------------------------- /cli/schema-infer.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { readFileSync } = require("fs"); 4 | const yargs = require("yargs"); 5 | const { inferSchema } = require("../lib"); 6 | 7 | const builder = (command) => 8 | command.positional("file", { 9 | describe: "The file to infer the schema from", 10 | type: "string", 11 | }); 12 | 13 | const handler = ({ file }) => { 14 | // Read the file and parse the json 15 | const raw = readFileSync(file, "utf8").toString(); 16 | 17 | const document = JSON.parse(raw); 18 | 19 | const inferredSchema = inferSchema(document); 20 | 21 | const schema = inferredSchema.toJSONSchema({ includeSchema: true }); 22 | 23 | console.log(JSON.stringify(schema, null, 2)); 24 | }; 25 | 26 | yargs.command("$0 ", "Infer the schema from a json file", builder, handler).parse(); 27 | -------------------------------------------------------------------------------- /tests/json/mux-list-delivery-usage.json: -------------------------------------------------------------------------------- 1 | { 2 | "total_row_count": 2, 3 | "timeframe": [ 4 | 1607817600, 5 | 1607990400 6 | ], 7 | "page": 1, 8 | "limit": 100, 9 | "data": [ 10 | { 11 | "live_stream_id": "B65hEUWW01ErVKDDGImKcBquYhwEAkjW6Ic3lPY0299Cc", 12 | "delivered_seconds": 206.366667, 13 | "deleted_at": "1607945257", 14 | "created_at": "1607939184", 15 | "asset_state": "deleted", 16 | "asset_id": "Ww4v2q2H4MNbHIAM2wApKb3cmrh7eHjGLUjdKohR5wM", 17 | "asset_duration": 154.366667 18 | }, 19 | { 20 | "delivered_seconds": 30, 21 | "deleted_at": "1607935288", 22 | "created_at": "1607617107", 23 | "asset_state": "deleted", 24 | "asset_id": "Qlb007on1TwN43XLIG027QJlUxm3jd01v5PRi1aXhnyFZY", 25 | "asset_duration": 98.773667 26 | } 27 | ] 28 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest All Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeArgs": [ 12 | "--inspect-brk", 13 | "${workspaceRoot}/node_modules/.bin/jest", 14 | "--runInBand" 15 | ], 16 | "console": "integratedTerminal", 17 | "internalConsoleOptions": "neverOpen" 18 | }, 19 | { 20 | "name": "Debug Jest Test File", 21 | "type": "node", 22 | "request": "launch", 23 | "runtimeArgs": [ 24 | "--inspect-brk", 25 | "${workspaceRoot}/node_modules/.bin/jest", 26 | "--runInBand" 27 | ], 28 | "args": ["${fileBasename}", "--no-cache"], 29 | "console": "integratedTerminal", 30 | "internalConsoleOptions": "neverOpen" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Eric Allam 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /badges/badge-lines.svg: -------------------------------------------------------------------------------- 1 | Coverage:lines: 100%Coverage:lines100% -------------------------------------------------------------------------------- /badges/badge-branches.svg: -------------------------------------------------------------------------------- 1 | Coverage:branches: 94.11%Coverage:branches94.11% -------------------------------------------------------------------------------- /badges/badge-functions.svg: -------------------------------------------------------------------------------- 1 | Coverage:functions: 100%Coverage:functions100% -------------------------------------------------------------------------------- /badges/badge-statements.svg: -------------------------------------------------------------------------------- 1 | Coverage:statements: 100%Coverage:statements100% -------------------------------------------------------------------------------- /src/inferredSchema.ts: -------------------------------------------------------------------------------- 1 | import { JSONStringFormat } from "@jsonhero/json-infer-types"; 2 | 3 | type NumberRange = { 4 | min: number; 5 | max: number; 6 | }; 7 | 8 | type InferredUnknown = { 9 | type: "unknown"; 10 | }; 11 | 12 | type InferredAny = { 13 | type: "any"; 14 | schemas: Set; 15 | }; 16 | 17 | type InferredBoolean = { 18 | type: "boolean"; 19 | }; 20 | 21 | type InferredInt = { 22 | type: "int"; 23 | range: NumberRange; 24 | }; 25 | 26 | type InferredFloat = { 27 | type: "float"; 28 | range: NumberRange; 29 | }; 30 | 31 | type InferredString = { 32 | type: "string"; 33 | format?: JSONStringFormat; 34 | }; 35 | 36 | type InferredArray = { 37 | type: "array"; 38 | items: InferredSchema; 39 | }; 40 | 41 | type InferredObject = { 42 | type: "object"; 43 | properties: { 44 | required: Record; 45 | optional: Record; 46 | }; 47 | }; 48 | 49 | type InferredNullable = { 50 | type: "nullable"; 51 | schema: InferredSchema; 52 | }; 53 | 54 | export type InferredSchema = 55 | | InferredUnknown 56 | | InferredAny 57 | | InferredBoolean 58 | | InferredInt 59 | | InferredFloat 60 | | InferredString 61 | | InferredArray 62 | | InferredObject 63 | | InferredNullable; 64 | 65 | export function inferRange( 66 | value: number, 67 | range: NumberRange = { min: Number.MAX_SAFE_INTEGER, max: Number.MIN_SAFE_INTEGER }, 68 | ): NumberRange { 69 | return { 70 | min: Math.min(range.min, value), 71 | max: Math.max(range.max, value), 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jsonhero/schema-infer", 3 | "version": "0.1.5", 4 | "description": "Infers JSON Schemas from example JSON", 5 | "homepage": "https://github.com/jsonhero-io/schema-infer", 6 | "bugs": { 7 | "url": "https://github.com/jsonhero-io/schema-infer/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/jsonhero-io/schema-infer.git" 12 | }, 13 | "exports": "./lib/index.js", 14 | "types": "lib/index.d.ts", 15 | "files": [ 16 | "/lib" 17 | ], 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "scripts": { 22 | "test": "jest --runInBand --coverage", 23 | "test:watch": "jest --runInBand --watch", 24 | "test:badges": "npm t && jest-coverage-badges --output ./badges", 25 | "build": "tsc", 26 | "build:watch": "tsc --watch", 27 | "prepublishOnly": "tsc", 28 | "lint": "eslint . --ext .ts", 29 | "lint-and-fix": "eslint . --ext .ts --fix", 30 | "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write && prettier --config .prettierrc 'tests/**/*.ts' --write" 31 | }, 32 | "engines": { 33 | "node": ">=16" 34 | }, 35 | "keywords": [ 36 | "json", 37 | "schema", 38 | "json-schema" 39 | ], 40 | "author": "Eric Allam", 41 | "license": "MIT", 42 | "devDependencies": { 43 | "@tsconfig/node16": "^1.0.2", 44 | "@types/jest": "^27.0.2", 45 | "@types/lodash.omit": "^4.5.6", 46 | "@types/node": "^16.11.7", 47 | "@typescript-eslint/eslint-plugin": "^5.8.1", 48 | "@typescript-eslint/parser": "^5.8.1", 49 | "ajv": "^8.8.2", 50 | "ajv-formats": "^2.1.1", 51 | "eslint": "^8.5.0", 52 | "eslint-config-prettier": "^8.3.0", 53 | "eslint-plugin-prettier": "^4.0.0", 54 | "jest": "^27.3.1", 55 | "jest-coverage-badges": "^1.1.2", 56 | "lodash.omit": "^4.5.0", 57 | "prettier": "^2.5.1", 58 | "ts-jest": "^27.0.7", 59 | "ts-node": "^10.4.0", 60 | "typescript": "^4.4.4", 61 | "yargs": "^17.3.1" 62 | }, 63 | "jest": { 64 | "preset": "ts-jest", 65 | "testEnvironment": "node", 66 | "coverageReporters": [ 67 | "json-summary", 68 | "text", 69 | "lcov" 70 | ] 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "pre-commit": "npm run prettier-format && npm run lint" 75 | } 76 | }, 77 | "dependencies": { 78 | "@jsonhero/json-infer-types": "1.2.x", 79 | "@jsonhero/json-schema-fns": "^0.0.1", 80 | "ts-pattern": "^3.3.4", 81 | "lodash.omit": "^4.5.0" 82 | }, 83 | "bin": { 84 | "schema-infer": "./cli/schema-infer.js" 85 | } 86 | } -------------------------------------------------------------------------------- /src/jsonSchema/index.ts: -------------------------------------------------------------------------------- 1 | import { match } from "ts-pattern"; 2 | import { s, Schema, SchemaBuilder, StringFormat } from "@jsonhero/json-schema-fns"; 3 | import { InferredSchema } from "../inferredSchema"; 4 | import { JSONStringFormat } from "@jsonhero/json-infer-types"; 5 | 6 | export function toJSONSchema(inferredSchema: InferredSchema): SchemaBuilder { 7 | return match>(inferredSchema) 8 | .with({ type: "unknown" }, () => s.$false()) // This should never be reached 9 | .with({ type: "boolean" }, () => s.boolean()) 10 | .with({ type: "nullable" }, ({ schema }) => 11 | schema.type == "unknown" 12 | ? s.nil() 13 | : schema.type === "nullable" 14 | ? toJSONSchema(schema) 15 | : s.nullable(toJSONSchema(schema)), 16 | ) 17 | .with({ type: "int" }, () => { 18 | return s.integer(); 19 | }) 20 | .with({ type: "float" }, () => { 21 | return s.number(); 22 | }) 23 | .with({ type: "string" }, ({ format }) => { 24 | const formatString = toJSONStringFormat(format); 25 | 26 | return s.string(formatString ? { format: formatString } : {}); 27 | }) 28 | .with({ type: "array" }, (inferredArray) => { 29 | const items = toJSONSchema(inferredArray.items); 30 | 31 | return s.array({ items }); 32 | }) 33 | .with({ type: "object" }, (inferredObject) => { 34 | const requiredProperties = Object.entries(inferredObject.properties.required).map( 35 | ([key, value]) => s.requiredProperty(key, toJSONSchema(value)), 36 | ); 37 | 38 | const optionalProperties = Object.entries(inferredObject.properties.optional).map( 39 | ([key, value]) => s.property(key, toJSONSchema(value)), 40 | ); 41 | 42 | return s.object({ properties: requiredProperties.concat(optionalProperties) }); 43 | }) 44 | .with({ type: "any" }, ({ schemas }) => { 45 | return s.anyOf(...Array.from(schemas).map(toJSONSchema)); 46 | }) 47 | .exhaustive(); 48 | } 49 | 50 | function toJSONStringFormat(format?: JSONStringFormat): StringFormat | undefined { 51 | if (!format) { 52 | return undefined; 53 | } 54 | 55 | switch (format.name) { 56 | case "hostname": 57 | return "hostname"; 58 | case "ip": 59 | return format.variant == "v4" ? "ipv4" : "ipv6"; 60 | case "uri": 61 | return "uri"; 62 | case "email": 63 | return "email"; 64 | case "datetime": 65 | switch (format.parts) { 66 | case "datetime": 67 | return "date-time"; 68 | case "date": 69 | return "date"; 70 | case "time": 71 | return "time"; 72 | default: 73 | return undefined; 74 | } 75 | case "uuid": 76 | return "uuid"; 77 | } 78 | 79 | return undefined; 80 | } 81 | -------------------------------------------------------------------------------- /src/SchemaInference.ts: -------------------------------------------------------------------------------- 1 | import { match, __ } from "ts-pattern"; 2 | import { inferType, JSONValueType } from "@jsonhero/json-infer-types"; 3 | import { InferredSchema, inferRange } from "./inferredSchema"; 4 | import { Schema } from "@jsonhero/json-schema-fns"; 5 | import { toJSONSchema } from "./jsonSchema"; 6 | import omit from "lodash.omit"; 7 | 8 | function convertToAnySchema(schema: InferredSchema, value: unknown) { 9 | const schemas = new Set([schema]); 10 | 11 | schemas.add(infer({ type: "unknown" }, value)); 12 | 13 | return { 14 | type: "any", 15 | schemas, 16 | }; 17 | } 18 | 19 | function infer(inferredSchema: InferredSchema, value: unknown): InferredSchema { 20 | const inferredValueType = inferType(value); 21 | 22 | const result = match<[InferredSchema, JSONValueType], InferredSchema>([ 23 | inferredSchema, 24 | inferredValueType, 25 | ]) 26 | .with([__, { name: "null" }], ([subSchema]) => ({ 27 | type: "nullable", 28 | schema: subSchema, 29 | })) 30 | .with([{ type: "nullable" }, __], ([nullable, { value }]) => { 31 | const subSchema = infer(nullable.schema, value); 32 | 33 | return { 34 | type: "nullable", 35 | schema: subSchema, 36 | }; 37 | }) 38 | .with([{ type: "unknown" }, { name: "bool" }], () => ({ type: "boolean" })) 39 | .with([{ type: "unknown" }, { name: "int" }], ([, inferredInt]) => ({ 40 | type: "int", 41 | range: inferRange(inferredInt.value), 42 | })) 43 | .with([{ type: "unknown" }, { name: "float" }], ([, inferredFloat]) => ({ 44 | type: "float", 45 | range: inferRange(inferredFloat.value), 46 | })) 47 | .with([{ type: "unknown" }, { name: "string" }], ([, { format }]) => ({ 48 | type: "string", 49 | format: format, 50 | })) 51 | .with([{ type: "unknown" }, { name: "array" }], ([, inferredArray]) => { 52 | let itemInferredSchema = { 53 | type: "unknown", 54 | } as InferredSchema; 55 | 56 | for (const item of inferredArray.value) { 57 | itemInferredSchema = infer(itemInferredSchema, item); 58 | } 59 | 60 | return { 61 | type: "array", 62 | items: itemInferredSchema, 63 | }; 64 | }) 65 | .with([{ type: "array" }, { name: "array" }], ([arraySchema, inferredArray]) => { 66 | let itemInferredSchema = arraySchema.items; 67 | 68 | for (const item of inferredArray.value) { 69 | itemInferredSchema = infer(itemInferredSchema, item); 70 | } 71 | 72 | return { 73 | type: "array", 74 | items: itemInferredSchema, 75 | }; 76 | }) 77 | .with([{ type: "array" }, __], ([inferredArray]) => convertToAnySchema(inferredArray, value)) 78 | .with([{ type: "unknown" }, { name: "object" }], ([, inferredType]) => { 79 | const required = Object.entries(inferredType.value).reduce( 80 | (acc, [key, value]) => ({ 81 | ...acc, 82 | [key]: infer({ type: "unknown" }, value), 83 | }), 84 | {} as Record, 85 | ); 86 | 87 | return { 88 | type: "object", 89 | properties: { 90 | required, 91 | optional: {}, 92 | }, 93 | }; 94 | }) 95 | .with([{ type: "object" }, { name: "object" }], ([{ properties }, { value }]) => { 96 | const { required, optional } = properties; 97 | 98 | const missingRequiredKeys = Object.keys(required).filter( 99 | (key) => !Object.prototype.hasOwnProperty.call(value, key), 100 | ); 101 | 102 | for (const missingRequiredKey of missingRequiredKeys) { 103 | optional[missingRequiredKey] = required[missingRequiredKey]; 104 | } 105 | 106 | const nextRequired = omit(required, missingRequiredKeys) as Record; 107 | 108 | for (const [k, v] of Object.entries(value)) { 109 | if (Object.prototype.hasOwnProperty.call(nextRequired, k)) { 110 | nextRequired[k] = infer(required[k], v); 111 | } else if (Object.prototype.hasOwnProperty.call(optional, k)) { 112 | optional[k] = infer(optional[k], v); 113 | } else { 114 | optional[k] = infer({ type: "unknown" }, v); 115 | } 116 | } 117 | 118 | return { 119 | type: "object", 120 | properties: { 121 | required: nextRequired, 122 | optional, 123 | }, 124 | }; 125 | }) 126 | .with([{ type: "object" }, __], ([inferredObject]) => convertToAnySchema(inferredObject, value)) 127 | .with([{ type: "any" }, __], ([anySchema]) => { 128 | const schemas = new Set(anySchema.schemas); 129 | 130 | schemas.add(infer({ type: "unknown" }, value)); 131 | 132 | return { 133 | type: "any", 134 | schemas, 135 | }; 136 | }) 137 | .with([{ type: "boolean" }, { name: "bool" }], () => ({ type: "boolean" })) 138 | .with([{ type: "boolean" }, __], ([inferredBool]) => convertToAnySchema(inferredBool, value)) 139 | .with([{ type: "int" }, { name: "int" }], ([intSchema, inferredInt]) => ({ 140 | type: "int", 141 | range: inferRange(inferredInt.value, intSchema.range), 142 | })) 143 | .with([{ type: "int" }, { name: "float" }], ([intSchema, inferredFloat]) => ({ 144 | type: "float", 145 | range: inferRange(inferredFloat.value, intSchema.range), 146 | })) 147 | .with([{ type: "int" }, __], ([inferredInt]) => convertToAnySchema(inferredInt, value)) 148 | .with([{ type: "float" }, { name: "float" }], ([floatSchema, inferredFloat]) => ({ 149 | type: "float", 150 | range: inferRange(inferredFloat.value, floatSchema.range), 151 | })) 152 | .with([{ type: "float" }, { name: "int" }], ([floatSchema, inferredInt]) => ({ 153 | type: "float", 154 | range: inferRange(inferredInt.value, floatSchema.range), 155 | })) 156 | .with([{ type: "float" }, __], ([inferredFloat]) => convertToAnySchema(inferredFloat, value)) 157 | .with( 158 | [ 159 | { type: "string", format: __.nullish }, 160 | { name: "string", format: __.nullish }, 161 | ], 162 | () => ({ type: "string" }), 163 | ) 164 | .with( 165 | [ 166 | { type: "string", format: __.nullish }, 167 | { name: "string", format: { name: __.string } }, 168 | ], 169 | () => ({ type: "string" }), 170 | ) 171 | .with( 172 | [ 173 | { type: "string", format: { name: __.string } }, 174 | { name: "string", format: __.nullish }, 175 | ], 176 | () => ({ type: "string" }), 177 | ) 178 | .with( 179 | [ 180 | { type: "string", format: { name: __.string } }, 181 | { name: "string", format: { name: __.string } }, 182 | ], 183 | ([{ format: schemaFormat }, { format }]) => { 184 | if (schemaFormat.name !== format.name) { 185 | return { 186 | type: "string", 187 | }; 188 | } 189 | 190 | return { type: "string", format }; 191 | }, 192 | ) 193 | .with([{ type: "string" }, { name: "string" }], () => ({ 194 | type: "string", 195 | })) 196 | .with([{ type: "string" }, __], ([inferredString]) => convertToAnySchema(inferredString, value)) 197 | .exhaustive(); 198 | 199 | return result; 200 | } 201 | 202 | export default class SchemaInferrer { 203 | inferredSchema: InferredSchema = { type: "unknown" }; 204 | 205 | constructor(snapshot?: InferredSchema) { 206 | if (snapshot) { 207 | this.inferredSchema = snapshot; 208 | } 209 | } 210 | 211 | infer(value: unknown, inference?: SchemaInferrer) { 212 | this.inferredSchema = infer(inference ? inference.inferredSchema : this.inferredSchema, value); 213 | } 214 | 215 | toJSONSchema(options?: { includeSchema?: boolean }): Schema { 216 | if (options?.includeSchema) { 217 | return toJSONSchema(this.inferredSchema).toSchemaDocument(); 218 | } else { 219 | return toJSONSchema(this.inferredSchema).toSchema(); 220 | } 221 | } 222 | 223 | toSnapshot(): InferredSchema { 224 | return this.inferredSchema; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /tests/jsonSchema.test.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from "@jsonhero/json-schema-fns"; 2 | import { readFileSync } from "fs"; 3 | import { inferSchema, restoreSnapshot, SchemaInferrer } from "../src"; 4 | 5 | const toSchema = (s: SchemaInferrer): Schema => s.toJSONSchema({ includeSchema: false }); 6 | const readFixture = (s: string): unknown => 7 | JSON.parse(readFileSync(`./tests/json/${s}.json`, "utf8").toString()); 8 | const fixtureToSchema = (s: string): Schema => 9 | inferSchema(readFixture(s)).toJSONSchema({ 10 | includeSchema: false, 11 | }); 12 | 13 | describe("toJSONSchema()", () => { 14 | it("should work with the given options", () => { 15 | expect(inferSchema(1).toJSONSchema({ includeSchema: true })).toEqual({ 16 | $schema: "https://json-schema.org/draft/2020-12/schema", 17 | type: "integer", 18 | }); 19 | 20 | expect(inferSchema(1).toJSONSchema({ includeSchema: false })).toEqual({ 21 | type: "integer", 22 | }); 23 | }); 24 | }); 25 | 26 | describe("toSnapshot()/restoreSnapshot()", () => { 27 | it("Should allow resuming an inferring session", () => { 28 | let schema = inferSchema({ 29 | foo: "bar", 30 | }); 31 | 32 | const snapshot = schema.toSnapshot(); 33 | 34 | schema = inferSchema( 35 | { 36 | bar: "baz", 37 | }, 38 | restoreSnapshot(snapshot), 39 | ); 40 | 41 | expect(toSchema(schema)).toStrictEqual({ 42 | type: "object", 43 | properties: { 44 | foo: { type: "string" }, 45 | bar: { type: "string" }, 46 | }, 47 | }); 48 | }); 49 | }); 50 | 51 | describe("simple types", () => { 52 | it("should infer nulls", () => { 53 | expect(toSchema(inferSchema(null))).toEqual({ type: "null" }); 54 | }); 55 | 56 | it("should infer boolean", () => { 57 | const inference = inferSchema(true); 58 | 59 | expect(toSchema(inference)).toStrictEqual({ 60 | type: "boolean", 61 | }); 62 | }); 63 | 64 | it("should infer boolean over multiple iterations", () => { 65 | let inference = inferSchema(true); 66 | inference = inferSchema(false, inference); 67 | 68 | expect(toSchema(inference)).toStrictEqual({ 69 | type: "boolean", 70 | }); 71 | 72 | inference = inferSchema(1, inference); 73 | 74 | expect(toSchema(inference)).toStrictEqual({ 75 | anyOf: [{ type: "boolean" }, { type: "integer" }], 76 | }); 77 | }); 78 | 79 | it("should infer int", () => { 80 | expect(toSchema(inferSchema(1))).toStrictEqual({ 81 | type: "integer", 82 | }); 83 | }); 84 | 85 | it("should infer int over multiple iterations", () => { 86 | let inference = inferSchema(1); 87 | inference = inferSchema(2, inference); 88 | inference = inferSchema(10, inference); 89 | 90 | expect(toSchema(inference)).toStrictEqual({ 91 | type: "integer", 92 | }); 93 | }); 94 | 95 | it("should infer floats as numbers", () => { 96 | expect(toSchema(inferSchema(1.1))).toStrictEqual({ 97 | type: "number", 98 | }); 99 | }); 100 | 101 | it("should infer floats as numbers even over multiple iterations", () => { 102 | let inference = inferSchema(1.1); 103 | inference = inferSchema(2.5, inference); 104 | inference = inferSchema(10.8, inference); 105 | 106 | expect(toSchema(inference)).toStrictEqual({ 107 | type: "number", 108 | }); 109 | 110 | inference = inferSchema("hello world", inference); 111 | 112 | expect(toSchema(inference)).toStrictEqual({ 113 | anyOf: [{ type: "number" }, { type: "string" }], 114 | }); 115 | }); 116 | 117 | it("should infer floats and ints as numbers", () => { 118 | let inference = inferSchema(1.1); 119 | inference = inferSchema(2, inference); 120 | 121 | expect(toSchema(inference)).toStrictEqual({ 122 | type: "number", 123 | }); 124 | 125 | let inferenceIntFirst = inferSchema(1); 126 | inferenceIntFirst = inferSchema(2.5, inferenceIntFirst); 127 | 128 | expect(toSchema(inferenceIntFirst)).toStrictEqual({ 129 | type: "number", 130 | }); 131 | }); 132 | 133 | it("should infer strings", () => { 134 | expect(toSchema(inferSchema("hello world"))).toStrictEqual({ 135 | type: "string", 136 | }); 137 | 138 | expect(toSchema(inferSchema("https://google.com/#"))).toStrictEqual({ 139 | type: "string", 140 | format: "uri", 141 | }); 142 | 143 | expect(toSchema(inferSchema("eric@stackhero.dev"))).toStrictEqual({ 144 | type: "string", 145 | format: "email", 146 | }); 147 | 148 | expect(toSchema(inferSchema("AEA9CF21-965A-46C0-A4DD-3652B0BDC56D"))).toStrictEqual({ 149 | type: "string", 150 | format: "uuid", 151 | }); 152 | 153 | expect(toSchema(inferSchema("google.com"))).toStrictEqual({ 154 | type: "string", 155 | format: "hostname", 156 | }); 157 | 158 | expect(toSchema(inferSchema("192.168.1.0"))).toStrictEqual({ 159 | type: "string", 160 | format: "ipv4", 161 | }); 162 | 163 | expect(toSchema(inferSchema("2001:db8:1234::1"))).toStrictEqual({ 164 | type: "string", 165 | format: "ipv6", 166 | }); 167 | 168 | expect(toSchema(inferSchema("2019-01-01 00:00:00.000Z"))).toStrictEqual({ 169 | type: "string", 170 | format: "date-time", 171 | }); 172 | 173 | expect(toSchema(inferSchema("2016-05-25"))).toStrictEqual({ 174 | type: "string", 175 | format: "date", 176 | }); 177 | 178 | expect(toSchema(inferSchema("09:24:15"))).toStrictEqual({ 179 | type: "string", 180 | format: "time", 181 | }); 182 | 183 | expect(toSchema(inferSchema("192.168.1.0", inferSchema("2019-01-01")))).toStrictEqual({ 184 | type: "string", 185 | }); 186 | 187 | expect(toSchema(inferSchema("2020-12-01", inferSchema("2019-01-01")))).toStrictEqual({ 188 | type: "string", 189 | format: "date", 190 | }); 191 | }); 192 | 193 | it("should not infer string formats when all strings don't have the same format", () => { 194 | expect(toSchema(inferSchema("2020-12-01", inferSchema("this is not formatted")))).toStrictEqual( 195 | { type: "string" }, 196 | ); 197 | 198 | expect(toSchema(inferSchema("this is not formatted", inferSchema("2020-12-01")))).toStrictEqual( 199 | { type: "string" }, 200 | ); 201 | 202 | expect(toSchema(inferSchema("US", inferSchema("2020-12-01")))).toStrictEqual({ 203 | type: "string", 204 | }); 205 | 206 | expect(toSchema(inferSchema("google.com", inferSchema("2020-12-01")))).toStrictEqual({ 207 | type: "string", 208 | }); 209 | 210 | expect( 211 | toSchema(inferSchema("ee487ff7-374d-4e50-9b72-ac51e4459c5f", inferSchema("2020-12-01"))), 212 | ).toStrictEqual({ 213 | type: "string", 214 | }); 215 | 216 | expect(toSchema(inferSchema("http://google.com", inferSchema("2020-12-01")))).toStrictEqual({ 217 | type: "string", 218 | }); 219 | 220 | expect(toSchema(inferSchema("+447456001234", inferSchema("2020-12-01")))).toStrictEqual({ 221 | type: "string", 222 | }); 223 | 224 | expect(toSchema(inferSchema("ES", inferSchema("2020-12-01")))).toStrictEqual({ 225 | type: "string", 226 | }); 227 | }); 228 | 229 | it("Should infer anyOf when a string is followed by something else", () => { 230 | expect(toSchema(inferSchema(1, inferSchema("hello world")))).toStrictEqual({ 231 | anyOf: [{ type: "string" }, { type: "integer" }], 232 | }); 233 | }); 234 | }); 235 | 236 | describe("arrays", () => { 237 | test("inferring a homogeneous array", () => { 238 | expect(toSchema(inferSchema([1, 2, 3]))).toStrictEqual({ 239 | type: "array", 240 | items: { type: "integer" }, 241 | }); 242 | }); 243 | 244 | test("inferring a heterogeneous array", () => { 245 | expect(toSchema(inferSchema([1, "hello world", false]))).toStrictEqual({ 246 | type: "array", 247 | items: { anyOf: [{ type: "integer" }, { type: "string" }, { type: "boolean" }] }, 248 | }); 249 | }); 250 | 251 | test("inferring an array multiple times", () => { 252 | expect(toSchema(inferSchema([4, 5, 6], inferSchema([1, 2, 3])))).toStrictEqual({ 253 | type: "array", 254 | items: { type: "integer" }, 255 | }); 256 | }); 257 | 258 | test("following an array inferring with something other than an array", () => { 259 | expect(toSchema(inferSchema("hello world", inferSchema([1, 2, 3])))).toStrictEqual({ 260 | anyOf: [ 261 | { 262 | type: "array", 263 | items: { type: "integer" }, 264 | }, 265 | { type: "string" }, 266 | ], 267 | }); 268 | }); 269 | }); 270 | 271 | describe("objects", () => { 272 | test("inferring an object", () => { 273 | expect(toSchema(inferSchema({ foo: "bar" }))).toStrictEqual({ 274 | type: "object", 275 | properties: { 276 | foo: { type: "string" }, 277 | }, 278 | required: ["foo"], 279 | }); 280 | }); 281 | 282 | test("inferring optional properties", () => { 283 | let schema = inferSchema({ foo: "bar", baz: "qux" }); 284 | schema = inferSchema({ foo: "bar", banana: 1 }, schema); 285 | 286 | expect(toSchema(schema)).toStrictEqual({ 287 | type: "object", 288 | properties: { 289 | foo: { type: "string" }, 290 | baz: { type: "string" }, 291 | banana: { type: "integer" }, 292 | }, 293 | required: ["foo"], 294 | }); 295 | 296 | schema = inferSchema({ baz: "qux" }, schema); 297 | 298 | expect(toSchema(schema)).toStrictEqual({ 299 | type: "object", 300 | properties: { 301 | foo: { type: "string" }, 302 | baz: { type: "string" }, 303 | banana: { type: "integer" }, 304 | }, 305 | }); 306 | }); 307 | 308 | test("following an object inferring with something other than an object", () => { 309 | expect(toSchema(inferSchema("hello world", inferSchema({})))).toStrictEqual({ 310 | anyOf: [{ type: "object" }, { type: "string" }], 311 | }); 312 | }); 313 | }); 314 | 315 | describe("array of objects", () => { 316 | test("infers the items of the array as an object", () => { 317 | expect(toSchema(inferSchema([{ foo: "bar", baz: "qux" }, { foo: "bar" }]))).toStrictEqual({ 318 | type: "array", 319 | items: { 320 | type: "object", 321 | properties: { 322 | foo: { type: "string" }, 323 | baz: { type: "string" }, 324 | }, 325 | required: ["foo"], 326 | }, 327 | }); 328 | }); 329 | }); 330 | 331 | describe("nullables", () => { 332 | test("infers the schema as allowing null as well as another type", () => { 333 | const schema = inferSchema([1, 2, null, 3]); 334 | 335 | expect(toSchema(schema)).toStrictEqual({ 336 | type: "array", 337 | items: { 338 | type: ["integer", "null"], 339 | }, 340 | }); 341 | }); 342 | 343 | test("infers just a single null type if multiple nulls are found", () => { 344 | const schema = inferSchema([1, 2, null, 3, null, 4]); 345 | 346 | expect(toSchema(schema)).toStrictEqual({ 347 | type: "array", 348 | items: { 349 | type: ["integer", "null"], 350 | }, 351 | }); 352 | }); 353 | }); 354 | 355 | describe("real world tests", () => { 356 | test("infers the airtable schema correctly", () => { 357 | expect(fixtureToSchema("airtable")).toMatchSnapshot(); 358 | }); 359 | 360 | test("infers the mux-list-delivery-usage schema correctly", () => { 361 | expect(fixtureToSchema("mux-list-delivery-usage")).toMatchSnapshot(); 362 | }); 363 | 364 | test("infers the tweets schema correctly", () => { 365 | expect(fixtureToSchema("tweets")).toMatchSnapshot(); 366 | }); 367 | }); 368 | 369 | import Ajv2020 from "ajv/dist/2020"; 370 | import addFormats from "ajv-formats"; 371 | const ajv = new Ajv2020(); 372 | addFormats(ajv); 373 | 374 | describe("validation", () => { 375 | it("should pass validation if given the same json the schema was inferred from", () => { 376 | const schema = fixtureToSchema("tweets"); 377 | const validate = ajv.compile(schema); 378 | const tweetsJson = readFixture("tweets"); 379 | 380 | expect(validate(tweetsJson)).toBe(true); 381 | }); 382 | }); 383 | -------------------------------------------------------------------------------- /tests/json/tweets.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "data": [ 4 | { 5 | "conversation_id": "1304102743196356610", 6 | "id": "1307025659294674945", 7 | "possibly_sensitive": false, 8 | "public_metrics": { 9 | "retweet_count": 11, 10 | "reply_count": 2, 11 | "like_count": 70, 12 | "quote_count": 1 13 | }, 14 | "entities": { 15 | "urls": [ 16 | { 17 | "start": 74, 18 | "end": 97, 19 | "url": "https://t.co/oeF3ZHeKQQ", 20 | "expanded_url": "https://dev.to/twitterdev/understanding-the-new-tweet-payload-in-the-twitter-api-v2-1fg5", 21 | "display_url": "dev.to/twitterdev/und…", 22 | "images": [ 23 | { 24 | "url": "https://pbs.twimg.com/news_img/1317156296982867969/2uLfv-Bh?format=jpg&name=orig", 25 | "width": 1128, 26 | "height": 600 27 | }, 28 | { 29 | "url": "https://pbs.twimg.com/news_img/1317156296982867969/2uLfv-Bh?format=jpg&name=150x150", 30 | "width": 150, 31 | "height": 150 32 | } 33 | ], 34 | "status": 200, 35 | "title": "Understanding the new Tweet payload in the Twitter API v2", 36 | "description": "Twitter recently announced the new Twitter API v2, rebuilt from the ground up to deliver new features...", 37 | "unwound_url": "https://dev.to/twitterdev/understanding-the-new-tweet-payload-in-the-twitter-api-v2-1fg5" 38 | } 39 | ] 40 | }, 41 | "text": "Here’s an article that highlights the updates in the new Tweet payload v2 https://t.co/oeF3ZHeKQQ", 42 | "in_reply_to_user_id": "2244994945", 43 | "created_at": "2020-09-18T18:36:15.000Z", 44 | "author_id": "2244994945", 45 | "referenced_tweets": [ 46 | { 47 | "type": "replied_to", 48 | "id": "1304102743196356610" 49 | } 50 | ], 51 | "lang": "en", 52 | "source": "Twitter Web App" 53 | } 54 | ], 55 | "includes": { 56 | "users": [ 57 | { 58 | "created_at": "2013-12-14T04:35:55.000Z", 59 | "profile_image_url": "https://pbs.twimg.com/profile_images/1283786620521652229/lEODkLTh_normal.jpg", 60 | "entities": { 61 | "url": { 62 | "urls": [ 63 | { 64 | "start": 0, 65 | "end": 23, 66 | "url": "https://t.co/3ZX3TNiZCY", 67 | "expanded_url": "https://developer.twitter.com/en/community", 68 | "display_url": "developer.twitter.com/en/community" 69 | } 70 | ] 71 | }, 72 | "description": { 73 | "hashtags": [ 74 | { 75 | "start": 17, 76 | "end": 28, 77 | "tag": "TwitterDev" 78 | }, 79 | { 80 | "start": 105, 81 | "end": 116, 82 | "tag": "TwitterAPI" 83 | } 84 | ] 85 | } 86 | }, 87 | "id": "2244994945", 88 | "verified": true, 89 | "location": "127.0.0.1", 90 | "description": "The voice of the #TwitterDev team and your official source for updates, news, and events, related to the #TwitterAPI.", 91 | "pinned_tweet_id": "1293593516040269825", 92 | "username": "TwitterDev", 93 | "public_metrics": { 94 | "followers_count": 513961, 95 | "following_count": 2039, 96 | "tweet_count": 3635, 97 | "listed_count": 1672 98 | }, 99 | "name": "Twitter Dev", 100 | "url": "https://t.co/3ZX3TNiZCY", 101 | "protected": false 102 | } 103 | ], 104 | "tweets": [ 105 | { 106 | "conversation_id": "1304102743196356610", 107 | "id": "1304102743196356610", 108 | "possibly_sensitive": false, 109 | "public_metrics": { 110 | "retweet_count": 31, 111 | "reply_count": 12, 112 | "like_count": 104, 113 | "quote_count": 4 114 | }, 115 | "entities": { 116 | "mentions": [ 117 | { 118 | "start": 146, 119 | "end": 158, 120 | "username": "suhemparack" 121 | } 122 | ], 123 | "urls": [ 124 | { 125 | "start": 237, 126 | "end": 260, 127 | "url": "https://t.co/CjneyMpgCq", 128 | "expanded_url": "https://twitter.com/TwitterDev/status/1304102743196356610/video/1", 129 | "display_url": "pic.twitter.com/CjneyMpgCq" 130 | } 131 | ], 132 | "hashtags": [ 133 | { 134 | "start": 8, 135 | "end": 19, 136 | "tag": "TwitterAPI" 137 | } 138 | ] 139 | }, 140 | "attachments": { 141 | "media_keys": [ 142 | "13_1303848070984024065" 143 | ] 144 | }, 145 | "text": "The new #TwitterAPI includes some improvements to the Tweet payload. You’re probably wondering — what are the main differences? 🧐\n\nIn this video, @SuhemParack compares the v1.1 Tweet payload with what you’ll find using our v2 endpoints. https://t.co/CjneyMpgCq", 146 | "created_at": "2020-09-10T17:01:37.000Z", 147 | "author_id": "2244994945", 148 | "lang": "en", 149 | "source": "Twitter Media Studio" 150 | } 151 | ] 152 | } 153 | }, 154 | { 155 | "data": [ 156 | { 157 | "lang": "en", 158 | "conversation_id": "1296887091901718529", 159 | "text": "See how @PennMedCDH are using Twitter data to understand the COVID-19 health crisis 📊\n\nhttps://t.co/1tdA8uDWes", 160 | "referenced_tweets": [ 161 | { 162 | "type": "replied_to", 163 | "id": "1296887091901718529" 164 | } 165 | ], 166 | "possibly_sensitive": false, 167 | "entities": { 168 | "annotations": [ 169 | { 170 | "start": 30, 171 | "end": 36, 172 | "probability": 0.6318, 173 | "type": "Product", 174 | "normalized_text": "Twitter" 175 | } 176 | ], 177 | "mentions": [ 178 | { 179 | "start": 8, 180 | "end": 19, 181 | "username": "PennMedCDH" 182 | } 183 | ], 184 | "urls": [ 185 | { 186 | "start": 87, 187 | "end": 110, 188 | "url": "https://t.co/1tdA8uDWes", 189 | "expanded_url": "https://developer.twitter.com/en/use-cases/success-stories/penn", 190 | "display_url": "developer.twitter.com/en/use-cases/s…", 191 | "status": 200, 192 | "title": "Penn Medicine Center for Digital Health", 193 | "description": "Penn Med Center for Digital Health has created a COVID-19 Twitter map that includes charts detailing sentiment, symptoms reported, state-by-state data cuts, and border data on the COVID-19 outbreak. In addition, their Penn Med With You initiative uses aggregate regional information from Twitter to inform their website and text-messaging service. The service uses this information to disseminate relevant and timely resources.", 194 | "unwound_url": "https://developer.twitter.com/en/use-cases/success-stories/penn" 195 | } 196 | ] 197 | }, 198 | "id": "1296887316556980230", 199 | "public_metrics": { 200 | "retweet_count": 9, 201 | "reply_count": 3, 202 | "like_count": 26, 203 | "quote_count": 2 204 | }, 205 | "author_id": "2244994945", 206 | "in_reply_to_user_id": "2244994945", 207 | "context_annotations": [ 208 | { 209 | "domain": { 210 | "id": "46", 211 | "name": "Brand Category", 212 | "description": "Categories within Brand Verticals that narrow down the scope of Brands" 213 | }, 214 | "entity": { 215 | "id": "781974596752842752", 216 | "name": "Services" 217 | } 218 | }, 219 | { 220 | "domain": { 221 | "id": "47", 222 | "name": "Brand", 223 | "description": "Brands and Companies" 224 | }, 225 | "entity": { 226 | "id": "10045225402", 227 | "name": "Twitter" 228 | } 229 | }, 230 | { 231 | "domain": { 232 | "id": "123", 233 | "name": "Ongoing News Story", 234 | "description": "Ongoing News Stories like 'Brexit'" 235 | }, 236 | "entity": { 237 | "id": "1220701888179359745", 238 | "name": "COVID-19" 239 | } 240 | } 241 | ], 242 | "source": "Twitter Web App", 243 | "created_at": "2020-08-21T19:10:05.000Z" 244 | } 245 | ], 246 | "includes": { 247 | "users": [ 248 | { 249 | "created_at": "2013-12-14T04:35:55.000Z", 250 | "id": "2244994945", 251 | "protected": false, 252 | "username": "TwitterDev", 253 | "verified": true, 254 | "entities": { 255 | "url": { 256 | "urls": [ 257 | { 258 | "start": 0, 259 | "end": 23, 260 | "url": "https://t.co/3ZX3TNiZCY", 261 | "expanded_url": "https://developer.twitter.com/en/community", 262 | "display_url": "developer.twitter.com/en/community" 263 | } 264 | ] 265 | }, 266 | "description": { 267 | "hashtags": [ 268 | { 269 | "start": 17, 270 | "end": 28, 271 | "tag": "TwitterDev" 272 | }, 273 | { 274 | "start": 105, 275 | "end": 116, 276 | "tag": "TwitterAPI" 277 | } 278 | ] 279 | } 280 | }, 281 | "description": "The voice of the #TwitterDev team and your official source for updates, news, and events, related to the #TwitterAPI.", 282 | "pinned_tweet_id": "1293593516040269825", 283 | "public_metrics": { 284 | "followers_count": 513962, 285 | "following_count": 2039, 286 | "tweet_count": 3635, 287 | "listed_count": 1672 288 | }, 289 | "location": "127.0.0.1", 290 | "name": "Twitter Dev", 291 | "profile_image_url": "https://pbs.twimg.com/profile_images/1283786620521652229/lEODkLTh_normal.jpg", 292 | "url": "https://t.co/3ZX3TNiZCY" 293 | }, 294 | { 295 | "created_at": "2013-07-23T16:58:03.000Z", 296 | "id": "1615654896", 297 | "protected": false, 298 | "username": "PennMedCDH", 299 | "verified": false, 300 | "entities": { 301 | "url": { 302 | "urls": [ 303 | { 304 | "start": 0, 305 | "end": 23, 306 | "url": "https://t.co/7eS9RuwIb9", 307 | "expanded_url": "http://centerfordigitalhealth.upenn.edu/", 308 | "display_url": "centerfordigitalhealth.upenn.edu" 309 | } 310 | ] 311 | }, 312 | "description": { 313 | "mentions": [ 314 | { 315 | "start": 0, 316 | "end": 13, 317 | "username": "PennMedicine" 318 | } 319 | ] 320 | } 321 | }, 322 | "description": "@PennMedicine's Center for Digital Health advances science by researching the implications of the advancement of digital health technology in health care.", 323 | "public_metrics": { 324 | "followers_count": 1348, 325 | "following_count": 455, 326 | "tweet_count": 1288, 327 | "listed_count": 92 328 | }, 329 | "location": "Philadelphia, PA", 330 | "name": "Penn Med CDH", 331 | "profile_image_url": "https://pbs.twimg.com/profile_images/1067488849725726723/MoO3FQ44_normal.jpg", 332 | "url": "https://t.co/7eS9RuwIb9" 333 | } 334 | ], 335 | "tweets": [ 336 | { 337 | "lang": "en", 338 | "conversation_id": "1296887091901718529", 339 | "text": "Dr. @RainaMerchant and her team at the Penn Medicine CDH are helping build the future of health care.\n\nThe team is using insights from social data in many different ways — ranging from uncovering risk factors to shedding light on public sentiment. 🔎", 340 | "possibly_sensitive": false, 341 | "entities": { 342 | "annotations": [ 343 | { 344 | "start": 39, 345 | "end": 55, 346 | "probability": 0.8274, 347 | "type": "Organization", 348 | "normalized_text": "Penn Medicine CDH" 349 | } 350 | ], 351 | "mentions": [ 352 | { 353 | "start": 4, 354 | "end": 18, 355 | "username": "RainaMerchant" 356 | } 357 | ] 358 | }, 359 | "id": "1296887091901718529", 360 | "public_metrics": { 361 | "retweet_count": 9, 362 | "reply_count": 7, 363 | "like_count": 32, 364 | "quote_count": 0 365 | }, 366 | "author_id": "2244994945", 367 | "source": "Twitter Web App", 368 | "created_at": "2020-08-21T19:09:12.000Z" 369 | } 370 | ] 371 | } 372 | } 373 | ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSON Schema Infer 2 | 3 | > Infers JSON Schemas from example JSON. Powers the schema inference of [jsonhero.io](https://jsonhero.io) 4 | 5 | ![Coverage lines](./badges/badge-lines.svg) 6 | ![Tests](https://github.com/jsonhero-io/schema-infer/actions/workflows/test.yml/badge.svg?branch=main) 7 | [![Downloads](https://img.shields.io/npm/dm/%40jsonhero%2Fschema-infer.svg)](https://npmjs.com/@jsonhero/schema-infer) 8 | [![Install size](https://packagephobia.com/badge?p=%40jsonhero%2Fschema-infer)](https://packagephobia.com/result?p=@jsonhero/schema-infer) 9 | 10 | ## Features 11 | 12 | - Written in typescript 13 | - Inspired by [jtd-infer](https://jsontypedef.com/docs/jtd-infer/) 14 | - Generates valid 2020-12 JSON schema documents from example data 15 | - Supports most string formats through [json-infer-types](https://github.com/jsonhero-io/json-infer-types) 16 | - Date and times 17 | - URIs 18 | - Email Addresses 19 | - Hostnames 20 | - IP Addresses 21 | - uuids 22 | - Supports snapshotting and restoring inference sessions 23 | - Handles nullable values and required/optional properties 24 | 25 | ## Usage 26 | 27 | Install `schema-infer`: 28 | 29 | ```bash 30 | npm install --save @jsonhero/schema-infer 31 | ``` 32 | 33 | To produce a JSON Schema document, pass in the example JSON to `inferSchema` and call `toJSONSchema` on the result: 34 | 35 | ```ts 36 | import { inferSchema } from "@jsonhero/schema-infer"; 37 | 38 | inferSchema({ 39 | id: "abeb8b52-e960-44dc-9e09-57bb00d6b441", 40 | name: "Eric", 41 | emailAddress: "eric@jsonhero.io", 42 | website: "https://github.com/ericallam", 43 | joined: "2022-01-01", 44 | }).toJSONSchema(); 45 | ``` 46 | 47 | Infers the following JSON schema: 48 | 49 | ```json 50 | { 51 | "$schema": "https://json-schema.org/draft/2020-12/schema", 52 | "type": "object", 53 | "properties": { 54 | "id": { "type": "string", "format": "uuid" }, 55 | "name": { "type": "string" }, 56 | "emailAddress": { "type": "string", "format": "email" }, 57 | "website": { "type": "string", "format": "uri" }, 58 | "joined": { "type": "string", "format": "date" } 59 | }, 60 | "required": ["id", "name", "emailAddress", "website", "joined"] 61 | } 62 | ``` 63 | 64 | Inferring an array of objects, with some properties being optional: 65 | 66 | ```ts 67 | inferSchema([ 68 | { rank: 1, name: "Eric", winner: true }, 69 | { rank: 2, name: "Matt" }, 70 | ]).toJSONSchema(); 71 | ``` 72 | 73 | Produces the following schema: 74 | 75 | ```json 76 | { 77 | "items": { 78 | "properties": { 79 | "name": { 80 | "type": "string" 81 | }, 82 | "rank": { 83 | "type": "integer" 84 | }, 85 | "winner": { 86 | "type": "boolean" 87 | } 88 | }, 89 | "required": ["rank", "name"], 90 | "type": "object" 91 | }, 92 | "type": "array" 93 | } 94 | ``` 95 | 96 | You can produce better results by inferring from more than 1 example JSON, like so: 97 | 98 | ```ts 99 | let inference = inferSchema({ name: "Eric" }); 100 | inference = inferSchema({ name: "James", age: 87 }); 101 | 102 | inference.toJSONSchema(); 103 | ``` 104 | 105 | Produces: 106 | 107 | ```json 108 | { 109 | "type": "object", 110 | "properties": { 111 | "name": { "type": "string" }, 112 | "age": { "type": "integer" } 113 | }, 114 | "required": ["name"] 115 | } 116 | ``` 117 | 118 | If you need to save the inference session for later use you can use the `toSnapshot` and `restoreSnapshot` functions: 119 | 120 | ```ts 121 | let inference = inferSchema({ name: "Eric" }); 122 | let snapshot = inference.toSnapshot(); 123 | 124 | await writeFile("./inference.json", JSON.stringify(snapshot)); 125 | 126 | // Later: 127 | let snapshot = JSON.parse(await readFile("./inference.json")); 128 | inferSchema({ email: "eric@jsonhero.io" }, restoreSnapshot(snapshot)); 129 | ``` 130 | 131 | This library makes use of `anyOf` to handle a value that can be multiple conflicting types: 132 | 133 | ```ts 134 | inferSchema([1, "three"]).toJSONSchema(); 135 | ``` 136 | 137 | Will produce 138 | 139 | ```json 140 | { 141 | "type": "array", 142 | "items": { 143 | "anyOf": [{ "type": "integer" }, { "type": "string" }] 144 | } 145 | } 146 | ``` 147 | 148 | ## Examples 149 | 150 | ### Airtable API 151 | 152 |
153 | JSON 154 | 155 | ```json 156 | [ 157 | { 158 | "id": "rec3SDRbI5izJ0ENy", 159 | "fields": { 160 | "Link": "www.examplelink.com", 161 | "Name": "Ikrore chair", 162 | "Settings": [ 163 | "Office", 164 | "Bedroom", 165 | "Living room" 166 | ], 167 | "Vendor": [ 168 | "reczC9ifQTdJpMZcx" 169 | ], 170 | "Color": [ 171 | "Grey", 172 | "Green", 173 | "Red", 174 | "White", 175 | "Blue purple" 176 | ], 177 | "Designer": [ 178 | "recJ76rS7fEJi03wW" 179 | ], 180 | "Type": "Chairs", 181 | "Images": [ 182 | { 183 | "id": "atten0ycxONEmeKfu", 184 | "width": 501, 185 | "height": 750, 186 | "url": "https://dl.airtable.com/.attachments/e13d90aafb01450314538eee5398abb3/ea5e6e6f/pexels-photo-1166406.jpegautocompresscstinysrgbh750w1260", 187 | "filename": "pexels-photo-1166406.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260", 188 | "size": 33496, 189 | "type": "image/jpeg", 190 | "thumbnails": { 191 | "small": { 192 | "url": "https://dl.airtable.com/.attachmentThumbnails/ff3db1021522f6100afa7e09ab42b187/9bc0dc81", 193 | "width": 24, 194 | "height": 36 195 | }, 196 | "large": { 197 | "url": "https://dl.airtable.com/.attachmentThumbnails/15421f668579a7d75c506253b61668d6/f7c14834", 198 | "width": 501, 199 | "height": 750 200 | }, 201 | "full": { 202 | "url": "https://dl.airtable.com/.attachmentThumbnails/bd297cad0f2acb7da5d63e0692934def/3053bea3", 203 | "width": 3000, 204 | "height": 3000 205 | } 206 | } 207 | } 208 | ], 209 | "Description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 210 | "Materials": [ 211 | "Tech suede", 212 | "Light wood" 213 | ], 214 | "Size (WxLxH)": "40x32x19", 215 | "Unit cost": 1300.5, 216 | "Total units sold": 0, 217 | "Gross sales": 0 218 | }, 219 | "createdTime": "2015-01-27T20:16:05.000Z" 220 | }, 221 | { 222 | "id": "rec4gR4daG7FLbTss", 223 | "fields": { 224 | "Link": "www.examplelink.com", 225 | "Name": "Angular pendant", 226 | "Settings": [ 227 | "Office" 228 | ], 229 | "Vendor": [ 230 | "reczC9ifQTdJpMZcx" 231 | ], 232 | "Color": [ 233 | "Silver", 234 | "Black", 235 | "White", 236 | "Gold" 237 | ], 238 | "Designer": [ 239 | "recoh9S9UjHVUpcPy" 240 | ], 241 | "In stock": true, 242 | "Type": "Lighting", 243 | "Orders": [ 244 | "recspa0dTuVfr5Tji" 245 | ], 246 | "Images": [ 247 | { 248 | "id": "attViFaKwjE6WJ3iD", 249 | "width": 1000, 250 | "height": 1500, 251 | "url": "https://dl.airtable.com/.attachments/ce5d081b96ad1d4ef7aa3003c77fb761/4e9b68ae/photo-1546902172-146006dcd1e6ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 252 | "filename": "photo-1546902172-146006dcd1e6?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 253 | "size": 163784, 254 | "type": "image/jpeg", 255 | "thumbnails": { 256 | "small": { 257 | "url": "https://dl.airtable.com/.attachmentThumbnails/ffa7089696c170c6be567d5f34b4ed66/e1046fbc", 258 | "width": 24, 259 | "height": 36 260 | }, 261 | "large": { 262 | "url": "https://dl.airtable.com/.attachmentThumbnails/e66162154bfa7eacd377d40266f57316/39fb0eac", 263 | "width": 512, 264 | "height": 768 265 | }, 266 | "full": { 267 | "url": "https://dl.airtable.com/.attachmentThumbnails/7070d3cb16ad9d18e4fa5bbedb4e740b/460fd6c4", 268 | "width": 3000, 269 | "height": 3000 270 | } 271 | } 272 | } 273 | ], 274 | "Description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 275 | "Materials": [ 276 | "Steel" 277 | ], 278 | "Size (WxLxH)": "7.5 x 12.75, 10.5 x 17.5 ", 279 | "Unit cost": 295, 280 | "Total units sold": 2, 281 | "Gross sales": 590 282 | }, 283 | "createdTime": "2015-01-27T19:14:59.000Z" 284 | }, 285 | { 286 | "id": "rec4rIuzOPQA07c3M", 287 | "fields": { 288 | "Link": "www.examplelink.com", 289 | "Name": "Madrid chair", 290 | "Settings": [ 291 | "Living room", 292 | "Office" 293 | ], 294 | "Vendor": [ 295 | "reczC9ifQTdJpMZcx" 296 | ], 297 | "Color": [ 298 | "White", 299 | "Brown", 300 | "Black" 301 | ], 302 | "Designer": [ 303 | "recqx2njQY1QqkcaV" 304 | ], 305 | "In stock": true, 306 | "Type": "Chairs", 307 | "Orders": [ 308 | "rec0jJArKIPxTddSX", 309 | "rec3mEIxLONBSab4Y" 310 | ], 311 | "Images": [ 312 | { 313 | "id": "attYAf0fLp3H3OdGk", 314 | "width": 1000, 315 | "height": 477, 316 | "url": "https://dl.airtable.com/.attachments/c717b870174222c61991d81d32e6faa4/1ef6556a/photo-1505843490538-5133c6c7d0e1ixlibrb-1.2.1ixideyJhcHBfaWQiOjEyMDd9autoformatfitcropw1000q80", 317 | "filename": "photo-1505843490538-5133c6c7d0e1?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1000&q=80", 318 | "size": 17498, 319 | "type": "image/jpeg", 320 | "thumbnails": { 321 | "small": { 322 | "url": "https://dl.airtable.com/.attachmentThumbnails/c3e8f6f2189b0d9eb14cb58b9c653f42/3b76d95a", 323 | "width": 75, 324 | "height": 36 325 | }, 326 | "large": { 327 | "url": "https://dl.airtable.com/.attachmentThumbnails/e222fd421eddb24f9b5171a25adaa9ec/3cf86de6", 328 | "width": 1000, 329 | "height": 477 330 | }, 331 | "full": { 332 | "url": "https://dl.airtable.com/.attachmentThumbnails/4cae754b4adc96820e98a79ca8ebdcbd/09040841", 333 | "width": 3000, 334 | "height": 3000 335 | } 336 | } 337 | } 338 | ], 339 | "Description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", 340 | "Materials": [ 341 | "Light wood", 342 | "Metal" 343 | ], 344 | "Size (WxLxH)": "3x1x5", 345 | "Unit cost": 5429, 346 | "Total units sold": 36, 347 | "Gross sales": 195444 348 | }, 349 | "createdTime": "2014-09-24T05:48:20.000Z" 350 | } 351 | ] 352 | ``` 353 |
354 | 355 |
356 | Inferred Schema 357 | 358 | ```json 359 | { 360 | "type": "object", 361 | "properties": { 362 | "id": { 363 | "type": "string" 364 | }, 365 | "fields": { 366 | "type": "object", 367 | "properties": { 368 | "Link": { 369 | "type": "string", 370 | "format": "hostname" 371 | }, 372 | "Name": { 373 | "type": "string" 374 | }, 375 | "Settings": { 376 | "type": "array", 377 | "items": { 378 | "type": "string" 379 | } 380 | }, 381 | "Vendor": { 382 | "type": "array", 383 | "items": { 384 | "type": "string" 385 | } 386 | }, 387 | "Color": { 388 | "type": "array", 389 | "items": { 390 | "type": "string" 391 | } 392 | }, 393 | "Designer": { 394 | "type": "array", 395 | "items": { 396 | "type": "string" 397 | } 398 | }, 399 | "Type": { 400 | "type": "string" 401 | }, 402 | "Images": { 403 | "type": "array", 404 | "items": { 405 | "type": "object", 406 | "properties": { 407 | "id": { 408 | "type": "string" 409 | }, 410 | "width": { 411 | "type": "integer" 412 | }, 413 | "height": { 414 | "type": "integer" 415 | }, 416 | "url": { 417 | "type": "string", 418 | "format": "uri" 419 | }, 420 | "filename": { 421 | "type": "string" 422 | }, 423 | "size": { 424 | "type": "integer" 425 | }, 426 | "type": { 427 | "type": "string" 428 | }, 429 | "thumbnails": { 430 | "type": "object", 431 | "properties": { 432 | "small": { 433 | "type": "object", 434 | "properties": { 435 | "url": { 436 | "type": "string", 437 | "format": "uri" 438 | }, 439 | "width": { 440 | "type": "integer" 441 | }, 442 | "height": { 443 | "type": "integer" 444 | } 445 | }, 446 | "required": [ 447 | "url", 448 | "width", 449 | "height" 450 | ] 451 | }, 452 | "large": { 453 | "type": "object", 454 | "properties": { 455 | "url": { 456 | "type": "string", 457 | "format": "uri" 458 | }, 459 | "width": { 460 | "type": "integer" 461 | }, 462 | "height": { 463 | "type": "integer" 464 | } 465 | }, 466 | "required": [ 467 | "url", 468 | "width", 469 | "height" 470 | ] 471 | }, 472 | "full": { 473 | "type": "object", 474 | "properties": { 475 | "url": { 476 | "type": "string", 477 | "format": "uri" 478 | }, 479 | "width": { 480 | "type": "integer" 481 | }, 482 | "height": { 483 | "type": "integer" 484 | } 485 | }, 486 | "required": [ 487 | "url", 488 | "width", 489 | "height" 490 | ] 491 | } 492 | }, 493 | "required": [ 494 | "small", 495 | "large", 496 | "full" 497 | ] 498 | } 499 | }, 500 | "required": [ 501 | "id", 502 | "width", 503 | "height", 504 | "url", 505 | "filename", 506 | "size", 507 | "type", 508 | "thumbnails" 509 | ] 510 | } 511 | }, 512 | "Description": { 513 | "type": "string" 514 | }, 515 | "Materials": { 516 | "type": "array", 517 | "items": { 518 | "type": "string" 519 | } 520 | }, 521 | "Size (WxLxH)": { 522 | "type": "string" 523 | }, 524 | "Unit cost": { 525 | "type": "number" 526 | }, 527 | "Total units sold": { 528 | "type": "integer" 529 | }, 530 | "Gross sales": { 531 | "type": "integer" 532 | }, 533 | "In stock": { 534 | "type": "boolean" 535 | }, 536 | "Orders": { 537 | "type": "array", 538 | "items": { 539 | "type": "string" 540 | } 541 | } 542 | }, 543 | "required": [ 544 | "Link", 545 | "Name", 546 | "Settings", 547 | "Vendor", 548 | "Color", 549 | "Designer", 550 | "Type", 551 | "Images", 552 | "Description", 553 | "Materials", 554 | "Size (WxLxH)", 555 | "Unit cost", 556 | "Total units sold", 557 | "Gross sales" 558 | ] 559 | }, 560 | "createdTime": { 561 | "type": "string", 562 | "format": "date-time" 563 | } 564 | }, 565 | "required": [ 566 | "id", 567 | "fields", 568 | "createdTime" 569 | ] 570 | } 571 | ``` 572 |
573 | 574 | ## Roadmap 575 | 576 | - Add support for hints for discriminators (tagged unions), value-only schemas, and enums 577 | - Add support for [JSON Typedefs](https://jsontypedef.com) 578 | - Add "verbose" mode to include `$id`, `examples`, etc. 579 | -------------------------------------------------------------------------------- /tests/json/airtable.json: -------------------------------------------------------------------------------- 1 | { 2 | "records": [ 3 | { 4 | "id": "recZFXddleNKXyYKk", 5 | "fields": { 6 | "Tags": [ 7 | "awkward", 8 | "portrait", 9 | "couple" 10 | ], 11 | "ID": 1, 12 | "Description": "Awkward Nerd Couple in a heart and a unicorn", 13 | "Faces": [ 14 | "rec71k3Hn6VkTu0zR", 15 | "recbvQkZVR1SjkSna", 16 | "recmUOSnaXbaWWjNa", 17 | "rechNo0fIppcYANvb" 18 | ], 19 | "Image": [ 20 | { 21 | "id": "attbY1NS1ukLaAfC9", 22 | "width": 870, 23 | "height": 1304, 24 | "url": "https://dl.airtable.com/.attachments/1e94aec88dfabd43f15b44896d63623a/eff784fe/Screen_Shot_2021-08-16_at_11_12_27_AM.png", 25 | "filename": "Screen_Shot_2021-08-16_at_11_12_27_AM.png", 26 | "size": 1965826, 27 | "type": "image/png", 28 | "thumbnails": { 29 | "small": { 30 | "url": "https://dl.airtable.com/.attachmentThumbnails/b2149402d1597241f977e53fc5f605fa/1d70a2a9", 31 | "width": 24, 32 | "height": 36 33 | }, 34 | "large": { 35 | "url": "https://dl.airtable.com/.attachmentThumbnails/c0f1c600566b44718fee565a99d2808c/ec996aa2", 36 | "width": 512, 37 | "height": 767 38 | }, 39 | "full": { 40 | "url": "https://dl.airtable.com/.attachmentThumbnails/b73b8dd2ea4db93385d4bc18ce60d399/4dd25228", 41 | "width": 3000, 42 | "height": 3000 43 | } 44 | } 45 | } 46 | ], 47 | "Status": "Faces Detected", 48 | "Notes": "Two people in the image twice", 49 | "People": [ 50 | "reclXIPl2uXXci3rl", 51 | "recTMA54Sgi4P0N2k" 52 | ], 53 | "Face Images": [ 54 | { 55 | "id": "attcZ5TGtQDvV4TKX", 56 | "width": 400, 57 | "height": 515, 58 | "url": "https://dl.airtable.com/.attachments/5a5a6c62aed62e4ba5e079b3eb5ff316/ad03d5e6/11033fe5-c8fa-4bc3-9b01-2678e35c08b1.png", 59 | "filename": "11033fe5-c8fa-4bc3-9b01-2678e35c08b1.png", 60 | "size": 504421, 61 | "type": "image/png", 62 | "thumbnails": { 63 | "small": { 64 | "url": "https://dl.airtable.com/.attachmentThumbnails/abba4e12ee5731ce8799cfd8f9b707bb/790780fa", 65 | "width": 28, 66 | "height": 36 67 | }, 68 | "large": { 69 | "url": "https://dl.airtable.com/.attachmentThumbnails/3d2f0fe8643c36609ca2e6b0eaa50276/5dc036c5", 70 | "width": 400, 71 | "height": 515 72 | }, 73 | "full": { 74 | "url": "https://dl.airtable.com/.attachmentThumbnails/d5e1a451b847352d2c70baf12675a94e/39562a23", 75 | "width": 3000, 76 | "height": 3000 77 | } 78 | } 79 | }, 80 | { 81 | "id": "attoN5UqhmVjeODuF", 82 | "width": 234, 83 | "height": 272, 84 | "url": "https://dl.airtable.com/.attachments/4e977e36990e7ad4a4b81cbb981ad3c5/7a04f950/9bb4a406-5504-460c-b861-be9c944e136c.png", 85 | "filename": "9bb4a406-5504-460c-b861-be9c944e136c.png", 86 | "size": 180265, 87 | "type": "image/png", 88 | "thumbnails": { 89 | "small": { 90 | "url": "https://dl.airtable.com/.attachmentThumbnails/2c84be579be9a43eb8b7a31d30790a41/93486ed7", 91 | "width": 31, 92 | "height": 36 93 | }, 94 | "large": { 95 | "url": "https://dl.airtable.com/.attachmentThumbnails/65bb34c5be1289abc2e130064106b0f8/2ae6cb60", 96 | "width": 234, 97 | "height": 272 98 | }, 99 | "full": { 100 | "url": "https://dl.airtable.com/.attachmentThumbnails/6b69bf5fbd0f6a16db911f4e814f2a57/3c4ec403", 101 | "width": 3000, 102 | "height": 3000 103 | } 104 | } 105 | }, 106 | { 107 | "id": "attQw8fuQgq0Z1AEq", 108 | "width": 419, 109 | "height": 522, 110 | "url": "https://dl.airtable.com/.attachments/2a87571956fe04f3955b4a96574b272f/c66ec26a/ad3f7861-7ae7-47a6-a5bc-f9b09d6c449a.png", 111 | "filename": "ad3f7861-7ae7-47a6-a5bc-f9b09d6c449a.png", 112 | "size": 541515, 113 | "type": "image/png", 114 | "thumbnails": { 115 | "small": { 116 | "url": "https://dl.airtable.com/.attachmentThumbnails/591bcb08d64c4dc3cf579eece49e5108/d06c29fa", 117 | "width": 29, 118 | "height": 36 119 | }, 120 | "large": { 121 | "url": "https://dl.airtable.com/.attachmentThumbnails/bd99140abd6999664f72ec8238ee66a1/88be17cb", 122 | "width": 419, 123 | "height": 522 124 | }, 125 | "full": { 126 | "url": "https://dl.airtable.com/.attachmentThumbnails/e3adb4a6d7268c7a039d4c1a3075d6bb/bbf6fc8e", 127 | "width": 3000, 128 | "height": 3000 129 | } 130 | } 131 | }, 132 | { 133 | "id": "att1tzrlZA173BYA3", 134 | "width": 218, 135 | "height": 254, 136 | "url": "https://dl.airtable.com/.attachments/9df75ce73c9bfd2ec24235268147271b/b8adc194/fb3ca768-3d2e-4382-828c-cc5354a3e53d.png", 137 | "filename": "fb3ca768-3d2e-4382-828c-cc5354a3e53d.png", 138 | "size": 157187, 139 | "type": "image/png", 140 | "thumbnails": { 141 | "small": { 142 | "url": "https://dl.airtable.com/.attachmentThumbnails/6d390d4f485e5b32e3e9104c87957bfc/c5089cc0", 143 | "width": 31, 144 | "height": 36 145 | }, 146 | "large": { 147 | "url": "https://dl.airtable.com/.attachmentThumbnails/da28337d7b7095ae17b91a497367d9e8/d3fa78d7", 148 | "width": 218, 149 | "height": 254 150 | }, 151 | "full": { 152 | "url": "https://dl.airtable.com/.attachmentThumbnails/a6e75f2241ed647b32c48c9f1dcf78c5/19e4315e", 153 | "width": 3000, 154 | "height": 3000 155 | } 156 | } 157 | } 158 | ] 159 | }, 160 | "createdTime": "2021-09-16T09:23:17.000Z" 161 | }, 162 | { 163 | "id": "rec1HWKmpb8njLevb", 164 | "fields": { 165 | "Tags": [ 166 | "awkward", 167 | "portrait", 168 | "cats", 169 | "family" 170 | ], 171 | "ID": 2, 172 | "Description": "Family portrait, each holding a cat", 173 | "Faces": [ 174 | "recOE1Lw6atgaiwe4", 175 | "rec0ZbteicEpPBNh6", 176 | "recdVSIiOCEm7fwW1" 177 | ], 178 | "Image": [ 179 | { 180 | "id": "attXghFsF80wNaA9T", 181 | "width": 1274, 182 | "height": 1650, 183 | "url": "https://dl.airtable.com/.attachments/af14bbfcd9a92178a9c27a58006d3891/425a4022/Screen_Shot_2021-08-16_at_11.16.56_AM.png", 184 | "filename": "Screen_Shot_2021-08-16_at_11.16.56_AM.png", 185 | "size": 3334442, 186 | "type": "image/png", 187 | "thumbnails": { 188 | "small": { 189 | "url": "https://dl.airtable.com/.attachmentThumbnails/bf4c3076f3760b393ad34405d115039c/c12efbe7", 190 | "width": 28, 191 | "height": 36 192 | }, 193 | "large": { 194 | "url": "https://dl.airtable.com/.attachmentThumbnails/3fca21cf02eb5279550da7c70b3538b4/d21ff2b5", 195 | "width": 512, 196 | "height": 663 197 | }, 198 | "full": { 199 | "url": "https://dl.airtable.com/.attachmentThumbnails/fe509c9c139a695820103dec61e19f42/440a59b5", 200 | "width": 3000, 201 | "height": 3000 202 | } 203 | } 204 | } 205 | ], 206 | "Status": "Faces Detected", 207 | "Notes": "Cats in the image", 208 | "People": [ 209 | "recC22lBKnvFnZMTu", 210 | "recQusPYTiwTtMFlN", 211 | "recU1xsZpgRx2Al4I" 212 | ], 213 | "Face Images": [ 214 | { 215 | "id": "attSEjboyLzjkqzeJ", 216 | "width": 343, 217 | "height": 398, 218 | "url": "https://dl.airtable.com/.attachments/3dffa12a2a23a51d26e498cfab18c328/ba252d6f/c146a9dd-0811-4140-80d9-5f147afd6ee6.png", 219 | "filename": "c146a9dd-0811-4140-80d9-5f147afd6ee6.png", 220 | "size": 328356, 221 | "type": "image/png", 222 | "thumbnails": { 223 | "small": { 224 | "url": "https://dl.airtable.com/.attachmentThumbnails/eea5950da74c4e411753dd7eddd1a411/8afc8777", 225 | "width": 31, 226 | "height": 36 227 | }, 228 | "large": { 229 | "url": "https://dl.airtable.com/.attachmentThumbnails/e139b17a74d707a7eaaee5c5030a99a1/84cc975e", 230 | "width": 343, 231 | "height": 398 232 | }, 233 | "full": { 234 | "url": "https://dl.airtable.com/.attachmentThumbnails/8b6bd198a82311fd4975c764fa904c60/7aab297b", 235 | "width": 3000, 236 | "height": 3000 237 | } 238 | } 239 | }, 240 | { 241 | "id": "attit9VfYXuyywMUY", 242 | "width": 293, 243 | "height": 341, 244 | "url": "https://dl.airtable.com/.attachments/c5ad91a88bf80eedc9f76bc62c5d75b4/6f668efd/61caa98a-6a5d-48f4-88c3-b7467497dadf.png", 245 | "filename": "61caa98a-6a5d-48f4-88c3-b7467497dadf.png", 246 | "size": 217566, 247 | "type": "image/png", 248 | "thumbnails": { 249 | "small": { 250 | "url": "https://dl.airtable.com/.attachmentThumbnails/0db7b890887d72eba7990c646b4816d2/633c5d83", 251 | "width": 31, 252 | "height": 36 253 | }, 254 | "large": { 255 | "url": "https://dl.airtable.com/.attachmentThumbnails/fb54735d122ae0f6eddbf8d332f8d35c/adc8f635", 256 | "width": 293, 257 | "height": 341 258 | }, 259 | "full": { 260 | "url": "https://dl.airtable.com/.attachmentThumbnails/2f1131b7e38c59d551826c07e411abbb/ad6b8d48", 261 | "width": 3000, 262 | "height": 3000 263 | } 264 | } 265 | }, 266 | { 267 | "id": "attkguUjatUXP6JSs", 268 | "width": 249, 269 | "height": 289, 270 | "url": "https://dl.airtable.com/.attachments/ac631a9cf7ccbcd3c080f9fc78f64535/e01dd30e/4729ed90-942c-479b-9942-7381e7103ee6.png", 271 | "filename": "4729ed90-942c-479b-9942-7381e7103ee6.png", 272 | "size": 159685, 273 | "type": "image/png", 274 | "thumbnails": { 275 | "small": { 276 | "url": "https://dl.airtable.com/.attachmentThumbnails/a2fb3a03c9165b09bc9bdeb335ae5cd9/b5e92aa9", 277 | "width": 31, 278 | "height": 36 279 | }, 280 | "large": { 281 | "url": "https://dl.airtable.com/.attachmentThumbnails/4604f8223a138b469a841b759db1f0b5/4786b1ce", 282 | "width": 249, 283 | "height": 289 284 | }, 285 | "full": { 286 | "url": "https://dl.airtable.com/.attachmentThumbnails/27ad0d70f0ca5fd8a3bffb04ce9c5419/c3c803d1", 287 | "width": 3000, 288 | "height": 3000 289 | } 290 | } 291 | } 292 | ] 293 | }, 294 | "createdTime": "2021-09-16T14:50:26.000Z" 295 | }, 296 | { 297 | "id": "rec4uVlND3bSL3Tav", 298 | "fields": { 299 | "Tags": [ 300 | "awkward", 301 | "portrait", 302 | "family" 303 | ], 304 | "ID": 3, 305 | "Description": "Family portrait with everyone smoking", 306 | "Faces": [ 307 | "reczuTfJFLOeWMWW7", 308 | "recYSMZvsLHAsKLvW", 309 | "recMMWqVoGgCOuffo", 310 | "recNj6rxhKq952OJo", 311 | "rec5QPaYU8bMTe5po" 312 | ], 313 | "Image": [ 314 | { 315 | "id": "attmfNCFAx6YdLZlh", 316 | "width": 1290, 317 | "height": 836, 318 | "url": "https://dl.airtable.com/.attachments/9ed3bc14707b8861349c0c30cb24684a/dff2594b/Screen_Shot_2021-08-16_at_11.20.11_AM.png", 319 | "filename": "Screen_Shot_2021-08-16_at_11.20.11_AM.png", 320 | "size": 2060806, 321 | "type": "image/png", 322 | "thumbnails": { 323 | "small": { 324 | "url": "https://dl.airtable.com/.attachmentThumbnails/4ee7628cb76add1e1d4a8b8f6df8f925/5b684db3", 325 | "width": 56, 326 | "height": 36 327 | }, 328 | "large": { 329 | "url": "https://dl.airtable.com/.attachmentThumbnails/9b943a80e7d9ef44ec910ed286ab772b/c7989d80", 330 | "width": 790, 331 | "height": 512 332 | }, 333 | "full": { 334 | "url": "https://dl.airtable.com/.attachmentThumbnails/74801716716e1aa3e982ed780dbc40f2/3245024d", 335 | "width": 3000, 336 | "height": 3000 337 | } 338 | } 339 | } 340 | ], 341 | "Status": "Faces Detected", 342 | "Notes": "Cigarettes in mouths", 343 | "People": [ 344 | "recm5UvbHx0dh7rno", 345 | "reci68YgKn5AkVgbm", 346 | "recAL2ZJjvysWkFci", 347 | "rec8KsZ1D9sdaiI4w", 348 | "recbbXCQTPBqaJX0h" 349 | ], 350 | "Face Images": [ 351 | { 352 | "id": "attxmFiDZfJ73xi1s", 353 | "width": 237, 354 | "height": 276, 355 | "url": "https://dl.airtable.com/.attachments/4297c843c4a29ad34be01318cd5d9ea0/bc538740/6a025c77-e14d-474d-8c29-c7db7ba0428b.png", 356 | "filename": "6a025c77-e14d-474d-8c29-c7db7ba0428b.png", 357 | "size": 193654, 358 | "type": "image/png", 359 | "thumbnails": { 360 | "small": { 361 | "url": "https://dl.airtable.com/.attachmentThumbnails/80f607aef1176e51073d064878b73734/9d0f069d", 362 | "width": 31, 363 | "height": 36 364 | }, 365 | "large": { 366 | "url": "https://dl.airtable.com/.attachmentThumbnails/53e397c760a13f830c3efb94a86ba1d2/66ad03f0", 367 | "width": 237, 368 | "height": 276 369 | }, 370 | "full": { 371 | "url": "https://dl.airtable.com/.attachmentThumbnails/3878f5123535ee713acefce9904b720f/1d7bce26", 372 | "width": 3000, 373 | "height": 3000 374 | } 375 | } 376 | }, 377 | { 378 | "id": "atti0q4APzNOW8fa7", 379 | "width": 211, 380 | "height": 246, 381 | "url": "https://dl.airtable.com/.attachments/961ce8194b0d3af4273b9ab3a8fa37bd/11a96575/d3e67d09-d894-4718-ae72-acb05b3976b3.png", 382 | "filename": "d3e67d09-d894-4718-ae72-acb05b3976b3.png", 383 | "size": 158562, 384 | "type": "image/png", 385 | "thumbnails": { 386 | "small": { 387 | "url": "https://dl.airtable.com/.attachmentThumbnails/6b42535548341d41e3767c017ea580a7/816357fe", 388 | "width": 31, 389 | "height": 36 390 | }, 391 | "large": { 392 | "url": "https://dl.airtable.com/.attachmentThumbnails/2e47beabbece5fd5bd93c4a12c2ee544/92e59d79", 393 | "width": 211, 394 | "height": 246 395 | }, 396 | "full": { 397 | "url": "https://dl.airtable.com/.attachmentThumbnails/150402a5c6e67cb333a97ca71ad821a5/9fdaa99c", 398 | "width": 3000, 399 | "height": 3000 400 | } 401 | } 402 | }, 403 | { 404 | "id": "att2ipjjTDS7Ej9V0", 405 | "width": 241, 406 | "height": 280, 407 | "url": "https://dl.airtable.com/.attachments/97a92bbaa6ed8ad5b5ed31ca16805aad/41cb1596/6e4020d1-9b41-4ad9-9ac1-726dec8c4d52.png", 408 | "filename": "6e4020d1-9b41-4ad9-9ac1-726dec8c4d52.png", 409 | "size": 199381, 410 | "type": "image/png", 411 | "thumbnails": { 412 | "small": { 413 | "url": "https://dl.airtable.com/.attachmentThumbnails/876d63e2b9df01cecc82e35e0b9c2ab7/1e2235c2", 414 | "width": 31, 415 | "height": 36 416 | }, 417 | "large": { 418 | "url": "https://dl.airtable.com/.attachmentThumbnails/9bc54c433b24a25e3e250f5068ea64f3/bd18b808", 419 | "width": 241, 420 | "height": 280 421 | }, 422 | "full": { 423 | "url": "https://dl.airtable.com/.attachmentThumbnails/1ed03f936accf5cbfa23a8bb3311524e/9eaaf9fe", 424 | "width": 3000, 425 | "height": 3000 426 | } 427 | } 428 | }, 429 | { 430 | "id": "attNwNcFRlFALiopv", 431 | "width": 228, 432 | "height": 265, 433 | "url": "https://dl.airtable.com/.attachments/be85f9bf960e081141253e3cecae442f/87b51c39/6d411cfa-39b8-46d3-bdfa-b7daf283f10d.png", 434 | "filename": "6d411cfa-39b8-46d3-bdfa-b7daf283f10d.png", 435 | "size": 179650, 436 | "type": "image/png", 437 | "thumbnails": { 438 | "small": { 439 | "url": "https://dl.airtable.com/.attachmentThumbnails/1b869da3a9475a0ec773a618b1b59cb0/4933835a", 440 | "width": 31, 441 | "height": 36 442 | }, 443 | "large": { 444 | "url": "https://dl.airtable.com/.attachmentThumbnails/728db499c804f5a9357fbf643be37e12/6eec4938", 445 | "width": 228, 446 | "height": 265 447 | }, 448 | "full": { 449 | "url": "https://dl.airtable.com/.attachmentThumbnails/12ea2a98d0150e680dff131b48653e0d/cfb3bd5b", 450 | "width": 3000, 451 | "height": 3000 452 | } 453 | } 454 | }, 455 | { 456 | "id": "attZCoYH1P9TQ790L", 457 | "width": 214, 458 | "height": 249, 459 | "url": "https://dl.airtable.com/.attachments/9fbf008a52d4e0d7028deecef147dac2/dbbb8f21/b16569b3-f310-43a6-a2dd-c42c628b7e17.png", 460 | "filename": "b16569b3-f310-43a6-a2dd-c42c628b7e17.png", 461 | "size": 159657, 462 | "type": "image/png", 463 | "thumbnails": { 464 | "small": { 465 | "url": "https://dl.airtable.com/.attachmentThumbnails/14212bc0406f8fb7fa5bf6009db2f493/cf10726f", 466 | "width": 31, 467 | "height": 36 468 | }, 469 | "large": { 470 | "url": "https://dl.airtable.com/.attachmentThumbnails/6d6a563770ad3bd85fd513faa881acda/6ba8495e", 471 | "width": 214, 472 | "height": 249 473 | }, 474 | "full": { 475 | "url": "https://dl.airtable.com/.attachmentThumbnails/6b35eba3f2924e356cc4c21883e3f2e8/65d91783", 476 | "width": 3000, 477 | "height": 3000 478 | } 479 | } 480 | } 481 | ] 482 | }, 483 | "createdTime": "2021-09-16T15:35:45.000Z" 484 | } 485 | ], 486 | "offset": "rec4uVlND3bSL3Tav" 487 | } -------------------------------------------------------------------------------- /tests/__snapshots__/jsonSchema.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`real world tests infers the airtable schema correctly 1`] = ` 4 | Object { 5 | "properties": Object { 6 | "offset": Object { 7 | "type": "string", 8 | }, 9 | "records": Object { 10 | "items": Object { 11 | "properties": Object { 12 | "createdTime": Object { 13 | "format": "date-time", 14 | "type": "string", 15 | }, 16 | "fields": Object { 17 | "properties": Object { 18 | "Description": Object { 19 | "type": "string", 20 | }, 21 | "Face Images": Object { 22 | "items": Object { 23 | "properties": Object { 24 | "filename": Object { 25 | "type": "string", 26 | }, 27 | "height": Object { 28 | "type": "integer", 29 | }, 30 | "id": Object { 31 | "type": "string", 32 | }, 33 | "size": Object { 34 | "type": "integer", 35 | }, 36 | "thumbnails": Object { 37 | "properties": Object { 38 | "full": Object { 39 | "properties": Object { 40 | "height": Object { 41 | "type": "integer", 42 | }, 43 | "url": Object { 44 | "format": "uri", 45 | "type": "string", 46 | }, 47 | "width": Object { 48 | "type": "integer", 49 | }, 50 | }, 51 | "required": Array [ 52 | "url", 53 | "width", 54 | "height", 55 | ], 56 | "type": "object", 57 | }, 58 | "large": Object { 59 | "properties": Object { 60 | "height": Object { 61 | "type": "integer", 62 | }, 63 | "url": Object { 64 | "format": "uri", 65 | "type": "string", 66 | }, 67 | "width": Object { 68 | "type": "integer", 69 | }, 70 | }, 71 | "required": Array [ 72 | "url", 73 | "width", 74 | "height", 75 | ], 76 | "type": "object", 77 | }, 78 | "small": Object { 79 | "properties": Object { 80 | "height": Object { 81 | "type": "integer", 82 | }, 83 | "url": Object { 84 | "format": "uri", 85 | "type": "string", 86 | }, 87 | "width": Object { 88 | "type": "integer", 89 | }, 90 | }, 91 | "required": Array [ 92 | "url", 93 | "width", 94 | "height", 95 | ], 96 | "type": "object", 97 | }, 98 | }, 99 | "required": Array [ 100 | "small", 101 | "large", 102 | "full", 103 | ], 104 | "type": "object", 105 | }, 106 | "type": Object { 107 | "type": "string", 108 | }, 109 | "url": Object { 110 | "format": "uri", 111 | "type": "string", 112 | }, 113 | "width": Object { 114 | "type": "integer", 115 | }, 116 | }, 117 | "required": Array [ 118 | "id", 119 | "width", 120 | "height", 121 | "url", 122 | "filename", 123 | "size", 124 | "type", 125 | "thumbnails", 126 | ], 127 | "type": "object", 128 | }, 129 | "type": "array", 130 | }, 131 | "Faces": Object { 132 | "items": Object { 133 | "type": "string", 134 | }, 135 | "type": "array", 136 | }, 137 | "ID": Object { 138 | "type": "integer", 139 | }, 140 | "Image": Object { 141 | "items": Object { 142 | "properties": Object { 143 | "filename": Object { 144 | "type": "string", 145 | }, 146 | "height": Object { 147 | "type": "integer", 148 | }, 149 | "id": Object { 150 | "type": "string", 151 | }, 152 | "size": Object { 153 | "type": "integer", 154 | }, 155 | "thumbnails": Object { 156 | "properties": Object { 157 | "full": Object { 158 | "properties": Object { 159 | "height": Object { 160 | "type": "integer", 161 | }, 162 | "url": Object { 163 | "format": "uri", 164 | "type": "string", 165 | }, 166 | "width": Object { 167 | "type": "integer", 168 | }, 169 | }, 170 | "required": Array [ 171 | "url", 172 | "width", 173 | "height", 174 | ], 175 | "type": "object", 176 | }, 177 | "large": Object { 178 | "properties": Object { 179 | "height": Object { 180 | "type": "integer", 181 | }, 182 | "url": Object { 183 | "format": "uri", 184 | "type": "string", 185 | }, 186 | "width": Object { 187 | "type": "integer", 188 | }, 189 | }, 190 | "required": Array [ 191 | "url", 192 | "width", 193 | "height", 194 | ], 195 | "type": "object", 196 | }, 197 | "small": Object { 198 | "properties": Object { 199 | "height": Object { 200 | "type": "integer", 201 | }, 202 | "url": Object { 203 | "format": "uri", 204 | "type": "string", 205 | }, 206 | "width": Object { 207 | "type": "integer", 208 | }, 209 | }, 210 | "required": Array [ 211 | "url", 212 | "width", 213 | "height", 214 | ], 215 | "type": "object", 216 | }, 217 | }, 218 | "required": Array [ 219 | "small", 220 | "large", 221 | "full", 222 | ], 223 | "type": "object", 224 | }, 225 | "type": Object { 226 | "type": "string", 227 | }, 228 | "url": Object { 229 | "format": "uri", 230 | "type": "string", 231 | }, 232 | "width": Object { 233 | "type": "integer", 234 | }, 235 | }, 236 | "required": Array [ 237 | "id", 238 | "width", 239 | "height", 240 | "url", 241 | "filename", 242 | "size", 243 | "type", 244 | "thumbnails", 245 | ], 246 | "type": "object", 247 | }, 248 | "type": "array", 249 | }, 250 | "Notes": Object { 251 | "type": "string", 252 | }, 253 | "People": Object { 254 | "items": Object { 255 | "type": "string", 256 | }, 257 | "type": "array", 258 | }, 259 | "Status": Object { 260 | "type": "string", 261 | }, 262 | "Tags": Object { 263 | "items": Object { 264 | "type": "string", 265 | }, 266 | "type": "array", 267 | }, 268 | }, 269 | "required": Array [ 270 | "Tags", 271 | "ID", 272 | "Description", 273 | "Faces", 274 | "Image", 275 | "Status", 276 | "Notes", 277 | "People", 278 | "Face Images", 279 | ], 280 | "type": "object", 281 | }, 282 | "id": Object { 283 | "type": "string", 284 | }, 285 | }, 286 | "required": Array [ 287 | "id", 288 | "fields", 289 | "createdTime", 290 | ], 291 | "type": "object", 292 | }, 293 | "type": "array", 294 | }, 295 | }, 296 | "required": Array [ 297 | "records", 298 | "offset", 299 | ], 300 | "type": "object", 301 | } 302 | `; 303 | 304 | exports[`real world tests infers the mux-list-delivery-usage schema correctly 1`] = ` 305 | Object { 306 | "properties": Object { 307 | "data": Object { 308 | "items": Object { 309 | "properties": Object { 310 | "asset_duration": Object { 311 | "type": "number", 312 | }, 313 | "asset_id": Object { 314 | "type": "string", 315 | }, 316 | "asset_state": Object { 317 | "type": "string", 318 | }, 319 | "created_at": Object { 320 | "type": "string", 321 | }, 322 | "deleted_at": Object { 323 | "type": "string", 324 | }, 325 | "delivered_seconds": Object { 326 | "type": "number", 327 | }, 328 | "live_stream_id": Object { 329 | "type": "string", 330 | }, 331 | }, 332 | "required": Array [ 333 | "delivered_seconds", 334 | "deleted_at", 335 | "created_at", 336 | "asset_state", 337 | "asset_id", 338 | "asset_duration", 339 | ], 340 | "type": "object", 341 | }, 342 | "type": "array", 343 | }, 344 | "limit": Object { 345 | "type": "integer", 346 | }, 347 | "page": Object { 348 | "type": "integer", 349 | }, 350 | "timeframe": Object { 351 | "items": Object { 352 | "type": "integer", 353 | }, 354 | "type": "array", 355 | }, 356 | "total_row_count": Object { 357 | "type": "integer", 358 | }, 359 | }, 360 | "required": Array [ 361 | "total_row_count", 362 | "timeframe", 363 | "page", 364 | "limit", 365 | "data", 366 | ], 367 | "type": "object", 368 | } 369 | `; 370 | 371 | exports[`real world tests infers the tweets schema correctly 1`] = ` 372 | Object { 373 | "items": Object { 374 | "properties": Object { 375 | "data": Object { 376 | "items": Object { 377 | "properties": Object { 378 | "author_id": Object { 379 | "type": "string", 380 | }, 381 | "context_annotations": Object { 382 | "items": Object { 383 | "properties": Object { 384 | "domain": Object { 385 | "properties": Object { 386 | "description": Object { 387 | "type": "string", 388 | }, 389 | "id": Object { 390 | "type": "string", 391 | }, 392 | "name": Object { 393 | "type": "string", 394 | }, 395 | }, 396 | "required": Array [ 397 | "id", 398 | "name", 399 | "description", 400 | ], 401 | "type": "object", 402 | }, 403 | "entity": Object { 404 | "properties": Object { 405 | "id": Object { 406 | "type": "string", 407 | }, 408 | "name": Object { 409 | "type": "string", 410 | }, 411 | }, 412 | "required": Array [ 413 | "id", 414 | "name", 415 | ], 416 | "type": "object", 417 | }, 418 | }, 419 | "required": Array [ 420 | "domain", 421 | "entity", 422 | ], 423 | "type": "object", 424 | }, 425 | "type": "array", 426 | }, 427 | "conversation_id": Object { 428 | "type": "string", 429 | }, 430 | "created_at": Object { 431 | "format": "date-time", 432 | "type": "string", 433 | }, 434 | "entities": Object { 435 | "properties": Object { 436 | "annotations": Object { 437 | "items": Object { 438 | "properties": Object { 439 | "end": Object { 440 | "type": "integer", 441 | }, 442 | "normalized_text": Object { 443 | "type": "string", 444 | }, 445 | "probability": Object { 446 | "type": "number", 447 | }, 448 | "start": Object { 449 | "type": "integer", 450 | }, 451 | "type": Object { 452 | "type": "string", 453 | }, 454 | }, 455 | "required": Array [ 456 | "start", 457 | "end", 458 | "probability", 459 | "type", 460 | "normalized_text", 461 | ], 462 | "type": "object", 463 | }, 464 | "type": "array", 465 | }, 466 | "mentions": Object { 467 | "items": Object { 468 | "properties": Object { 469 | "end": Object { 470 | "type": "integer", 471 | }, 472 | "start": Object { 473 | "type": "integer", 474 | }, 475 | "username": Object { 476 | "type": "string", 477 | }, 478 | }, 479 | "required": Array [ 480 | "start", 481 | "end", 482 | "username", 483 | ], 484 | "type": "object", 485 | }, 486 | "type": "array", 487 | }, 488 | "urls": Object { 489 | "items": Object { 490 | "properties": Object { 491 | "description": Object { 492 | "type": "string", 493 | }, 494 | "display_url": Object { 495 | "type": "string", 496 | }, 497 | "end": Object { 498 | "type": "integer", 499 | }, 500 | "expanded_url": Object { 501 | "format": "uri", 502 | "type": "string", 503 | }, 504 | "images": Object { 505 | "items": Object { 506 | "properties": Object { 507 | "height": Object { 508 | "type": "integer", 509 | }, 510 | "url": Object { 511 | "format": "uri", 512 | "type": "string", 513 | }, 514 | "width": Object { 515 | "type": "integer", 516 | }, 517 | }, 518 | "required": Array [ 519 | "url", 520 | "width", 521 | "height", 522 | ], 523 | "type": "object", 524 | }, 525 | "type": "array", 526 | }, 527 | "start": Object { 528 | "type": "integer", 529 | }, 530 | "status": Object { 531 | "type": "integer", 532 | }, 533 | "title": Object { 534 | "type": "string", 535 | }, 536 | "unwound_url": Object { 537 | "format": "uri", 538 | "type": "string", 539 | }, 540 | "url": Object { 541 | "format": "uri", 542 | "type": "string", 543 | }, 544 | }, 545 | "required": Array [ 546 | "start", 547 | "end", 548 | "url", 549 | "expanded_url", 550 | "display_url", 551 | "status", 552 | "title", 553 | "description", 554 | "unwound_url", 555 | ], 556 | "type": "object", 557 | }, 558 | "type": "array", 559 | }, 560 | }, 561 | "required": Array [ 562 | "urls", 563 | ], 564 | "type": "object", 565 | }, 566 | "id": Object { 567 | "type": "string", 568 | }, 569 | "in_reply_to_user_id": Object { 570 | "type": "string", 571 | }, 572 | "lang": Object { 573 | "type": "string", 574 | }, 575 | "possibly_sensitive": Object { 576 | "type": "boolean", 577 | }, 578 | "public_metrics": Object { 579 | "properties": Object { 580 | "like_count": Object { 581 | "type": "integer", 582 | }, 583 | "quote_count": Object { 584 | "type": "integer", 585 | }, 586 | "reply_count": Object { 587 | "type": "integer", 588 | }, 589 | "retweet_count": Object { 590 | "type": "integer", 591 | }, 592 | }, 593 | "required": Array [ 594 | "retweet_count", 595 | "reply_count", 596 | "like_count", 597 | "quote_count", 598 | ], 599 | "type": "object", 600 | }, 601 | "referenced_tweets": Object { 602 | "items": Object { 603 | "properties": Object { 604 | "id": Object { 605 | "type": "string", 606 | }, 607 | "type": Object { 608 | "type": "string", 609 | }, 610 | }, 611 | "required": Array [ 612 | "type", 613 | "id", 614 | ], 615 | "type": "object", 616 | }, 617 | "type": "array", 618 | }, 619 | "source": Object { 620 | "type": "string", 621 | }, 622 | "text": Object { 623 | "type": "string", 624 | }, 625 | }, 626 | "required": Array [ 627 | "conversation_id", 628 | "id", 629 | "possibly_sensitive", 630 | "public_metrics", 631 | "entities", 632 | "text", 633 | "in_reply_to_user_id", 634 | "created_at", 635 | "author_id", 636 | "referenced_tweets", 637 | "lang", 638 | "source", 639 | ], 640 | "type": "object", 641 | }, 642 | "type": "array", 643 | }, 644 | "includes": Object { 645 | "properties": Object { 646 | "tweets": Object { 647 | "items": Object { 648 | "properties": Object { 649 | "attachments": Object { 650 | "properties": Object { 651 | "media_keys": Object { 652 | "items": Object { 653 | "type": "string", 654 | }, 655 | "type": "array", 656 | }, 657 | }, 658 | "required": Array [ 659 | "media_keys", 660 | ], 661 | "type": "object", 662 | }, 663 | "author_id": Object { 664 | "type": "string", 665 | }, 666 | "conversation_id": Object { 667 | "type": "string", 668 | }, 669 | "created_at": Object { 670 | "format": "date-time", 671 | "type": "string", 672 | }, 673 | "entities": Object { 674 | "properties": Object { 675 | "annotations": Object { 676 | "items": Object { 677 | "properties": Object { 678 | "end": Object { 679 | "type": "integer", 680 | }, 681 | "normalized_text": Object { 682 | "type": "string", 683 | }, 684 | "probability": Object { 685 | "type": "number", 686 | }, 687 | "start": Object { 688 | "type": "integer", 689 | }, 690 | "type": Object { 691 | "type": "string", 692 | }, 693 | }, 694 | "required": Array [ 695 | "start", 696 | "end", 697 | "probability", 698 | "type", 699 | "normalized_text", 700 | ], 701 | "type": "object", 702 | }, 703 | "type": "array", 704 | }, 705 | "hashtags": Object { 706 | "items": Object { 707 | "properties": Object { 708 | "end": Object { 709 | "type": "integer", 710 | }, 711 | "start": Object { 712 | "type": "integer", 713 | }, 714 | "tag": Object { 715 | "type": "string", 716 | }, 717 | }, 718 | "required": Array [ 719 | "start", 720 | "end", 721 | "tag", 722 | ], 723 | "type": "object", 724 | }, 725 | "type": "array", 726 | }, 727 | "mentions": Object { 728 | "items": Object { 729 | "properties": Object { 730 | "end": Object { 731 | "type": "integer", 732 | }, 733 | "start": Object { 734 | "type": "integer", 735 | }, 736 | "username": Object { 737 | "type": "string", 738 | }, 739 | }, 740 | "required": Array [ 741 | "start", 742 | "end", 743 | "username", 744 | ], 745 | "type": "object", 746 | }, 747 | "type": "array", 748 | }, 749 | "urls": Object { 750 | "items": Object { 751 | "properties": Object { 752 | "display_url": Object { 753 | "type": "string", 754 | }, 755 | "end": Object { 756 | "type": "integer", 757 | }, 758 | "expanded_url": Object { 759 | "format": "uri", 760 | "type": "string", 761 | }, 762 | "start": Object { 763 | "type": "integer", 764 | }, 765 | "url": Object { 766 | "format": "uri", 767 | "type": "string", 768 | }, 769 | }, 770 | "required": Array [ 771 | "start", 772 | "end", 773 | "url", 774 | "expanded_url", 775 | "display_url", 776 | ], 777 | "type": "object", 778 | }, 779 | "type": "array", 780 | }, 781 | }, 782 | "required": Array [ 783 | "mentions", 784 | ], 785 | "type": "object", 786 | }, 787 | "id": Object { 788 | "type": "string", 789 | }, 790 | "lang": Object { 791 | "type": "string", 792 | }, 793 | "possibly_sensitive": Object { 794 | "type": "boolean", 795 | }, 796 | "public_metrics": Object { 797 | "properties": Object { 798 | "like_count": Object { 799 | "type": "integer", 800 | }, 801 | "quote_count": Object { 802 | "type": "integer", 803 | }, 804 | "reply_count": Object { 805 | "type": "integer", 806 | }, 807 | "retweet_count": Object { 808 | "type": "integer", 809 | }, 810 | }, 811 | "required": Array [ 812 | "retweet_count", 813 | "reply_count", 814 | "like_count", 815 | "quote_count", 816 | ], 817 | "type": "object", 818 | }, 819 | "source": Object { 820 | "type": "string", 821 | }, 822 | "text": Object { 823 | "type": "string", 824 | }, 825 | }, 826 | "required": Array [ 827 | "conversation_id", 828 | "id", 829 | "possibly_sensitive", 830 | "public_metrics", 831 | "entities", 832 | "text", 833 | "created_at", 834 | "author_id", 835 | "lang", 836 | "source", 837 | ], 838 | "type": "object", 839 | }, 840 | "type": "array", 841 | }, 842 | "users": Object { 843 | "items": Object { 844 | "properties": Object { 845 | "created_at": Object { 846 | "format": "date-time", 847 | "type": "string", 848 | }, 849 | "description": Object { 850 | "type": "string", 851 | }, 852 | "entities": Object { 853 | "properties": Object { 854 | "description": Object { 855 | "properties": Object { 856 | "hashtags": Object { 857 | "items": Object { 858 | "properties": Object { 859 | "end": Object { 860 | "type": "integer", 861 | }, 862 | "start": Object { 863 | "type": "integer", 864 | }, 865 | "tag": Object { 866 | "type": "string", 867 | }, 868 | }, 869 | "required": Array [ 870 | "start", 871 | "end", 872 | "tag", 873 | ], 874 | "type": "object", 875 | }, 876 | "type": "array", 877 | }, 878 | "mentions": Object { 879 | "items": Object { 880 | "properties": Object { 881 | "end": Object { 882 | "type": "integer", 883 | }, 884 | "start": Object { 885 | "type": "integer", 886 | }, 887 | "username": Object { 888 | "type": "string", 889 | }, 890 | }, 891 | "required": Array [ 892 | "start", 893 | "end", 894 | "username", 895 | ], 896 | "type": "object", 897 | }, 898 | "type": "array", 899 | }, 900 | }, 901 | "type": "object", 902 | }, 903 | "url": Object { 904 | "properties": Object { 905 | "urls": Object { 906 | "items": Object { 907 | "properties": Object { 908 | "display_url": Object { 909 | "type": "string", 910 | }, 911 | "end": Object { 912 | "type": "integer", 913 | }, 914 | "expanded_url": Object { 915 | "format": "uri", 916 | "type": "string", 917 | }, 918 | "start": Object { 919 | "type": "integer", 920 | }, 921 | "url": Object { 922 | "format": "uri", 923 | "type": "string", 924 | }, 925 | }, 926 | "required": Array [ 927 | "start", 928 | "end", 929 | "url", 930 | "expanded_url", 931 | "display_url", 932 | ], 933 | "type": "object", 934 | }, 935 | "type": "array", 936 | }, 937 | }, 938 | "required": Array [ 939 | "urls", 940 | ], 941 | "type": "object", 942 | }, 943 | }, 944 | "required": Array [ 945 | "url", 946 | "description", 947 | ], 948 | "type": "object", 949 | }, 950 | "id": Object { 951 | "type": "string", 952 | }, 953 | "location": Object { 954 | "type": "string", 955 | }, 956 | "name": Object { 957 | "type": "string", 958 | }, 959 | "pinned_tweet_id": Object { 960 | "type": "string", 961 | }, 962 | "profile_image_url": Object { 963 | "format": "uri", 964 | "type": "string", 965 | }, 966 | "protected": Object { 967 | "type": "boolean", 968 | }, 969 | "public_metrics": Object { 970 | "properties": Object { 971 | "followers_count": Object { 972 | "type": "integer", 973 | }, 974 | "following_count": Object { 975 | "type": "integer", 976 | }, 977 | "listed_count": Object { 978 | "type": "integer", 979 | }, 980 | "tweet_count": Object { 981 | "type": "integer", 982 | }, 983 | }, 984 | "required": Array [ 985 | "followers_count", 986 | "following_count", 987 | "tweet_count", 988 | "listed_count", 989 | ], 990 | "type": "object", 991 | }, 992 | "url": Object { 993 | "format": "uri", 994 | "type": "string", 995 | }, 996 | "username": Object { 997 | "type": "string", 998 | }, 999 | "verified": Object { 1000 | "type": "boolean", 1001 | }, 1002 | }, 1003 | "required": Array [ 1004 | "created_at", 1005 | "profile_image_url", 1006 | "entities", 1007 | "id", 1008 | "verified", 1009 | "location", 1010 | "description", 1011 | "username", 1012 | "public_metrics", 1013 | "name", 1014 | "url", 1015 | "protected", 1016 | ], 1017 | "type": "object", 1018 | }, 1019 | "type": "array", 1020 | }, 1021 | }, 1022 | "required": Array [ 1023 | "users", 1024 | "tweets", 1025 | ], 1026 | "type": "object", 1027 | }, 1028 | }, 1029 | "required": Array [ 1030 | "data", 1031 | "includes", 1032 | ], 1033 | "type": "object", 1034 | }, 1035 | "type": "array", 1036 | } 1037 | `; 1038 | --------------------------------------------------------------------------------