From 105c5e4e4694f0042297d85967847a0794dd4685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Sol=C3=A1r?= Date: Mon, 10 Aug 2026 13:15:00 +0200 Subject: [PATCH] perf: replace json-schema-to-typescript with a built-in emitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `json-schema-to-typescript` served one command, `actor generate-schema-types`, and dragged prettier, lodash and js-yaml into the install. Dropping just the formatting is not possible — v15 imports prettier at module top level — so it is replaced by a built-in emitter in src/lib/schema-to-typescript.ts. Output is byte-identical to the old library on every repo schema fixture. Full install drops from 55.61 MB to 43.55 MB (-21.7%). `stripTitles` is deleted: it existed only to stop the library hoisting named interfaces, and the new emitter ignores `title` entirely. Closes #1310 Co-Authored-By: Claude Opus 5 --- package.json | 1 - pnpm-lock.yaml | 62 +--- src/commands/actor/generate-schema-types.ts | 40 +-- src/lib/schema-to-typescript.ts | 276 ++++++++++++++++++ src/lib/schema-transforms.ts | 75 ----- .../actor/generate-schema-types.test.ts | 125 ++------ test/local/lib/schema-to-typescript.test.ts | 252 ++++++++++++++++ 7 files changed, 587 insertions(+), 244 deletions(-) create mode 100644 src/lib/schema-to-typescript.ts create mode 100644 test/local/lib/schema-to-typescript.test.ts diff --git a/package.json b/package.json index 719230ccc..5bf493dbd 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,6 @@ "istextorbinary": "~9.5.0", "jju": "~1.4.0", "js-levenshtein": "^1.1.6", - "json-schema-to-typescript": "^15.0.4", "mime": "~4.1.0", "open": "~11.0.0", "semver": "~7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d37777901..c3dbafa5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -321,9 +321,6 @@ importers: js-levenshtein: specifier: ^1.1.6 version: 1.1.6 - json-schema-to-typescript: - specifier: ^15.0.4 - version: 15.0.4 mime: specifier: ~4.1.0 version: 4.1.0 @@ -642,10 +639,6 @@ packages: '@algolia/transporter@4.27.0': resolution: {integrity: sha512-PvSbELU4VjN3xSX79ki+zIdOGhTxyJXWvRDzkUjfTx2iNfPWDdTjzKbP1o+268coJztxrkuBwJz90Urek7o1Kw==} - '@apidevtools/json-schema-ref-parser@11.9.3': - resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} - engines: {node: '>= 16'} - '@apify/actor-memory-expression@0.2.9': resolution: {integrity: sha512-XXyExRSQQmZ1VoU+6vXKURTmBHA0Hkz6r/ECqgGr5zeV6IfkR9WidmoheVmdxE14wn/Qlpaa37FR1WpjdILDQw==} @@ -2504,9 +2497,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@jsdevtools/ono@7.1.3': - resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -4126,9 +4116,6 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/lodash@4.17.24': - resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -6477,11 +6464,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-to-typescript@15.0.4: - resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} - engines: {node: '>=16.0.0'} - hasBin: true - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -9668,12 +9650,6 @@ snapshots: '@algolia/logger-common': 4.27.0 '@algolia/requester-common': 4.27.0 - '@apidevtools/json-schema-ref-parser@11.9.3': - dependencies: - '@jsdevtools/ono': 7.1.3 - '@types/json-schema': 7.0.15 - js-yaml: 4.3.0 - '@apify/actor-memory-expression@0.2.9': dependencies: '@apify/consts': 2.56.1 @@ -12709,8 +12685,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@jsdevtools/ono@7.1.3': {} - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -13980,7 +13954,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -14092,8 +14066,6 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/lodash@4.17.24': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -14422,7 +14394,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -15346,6 +15318,10 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -15975,7 +15951,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16423,7 +16399,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16455,14 +16431,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -16681,18 +16657,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-to-typescript@15.0.4: - dependencies: - '@apidevtools/json-schema-ref-parser': 11.9.3 - '@types/json-schema': 7.0.15 - '@types/lodash': 4.17.24 - is-glob: 4.0.3 - js-yaml: 4.3.0 - lodash: 4.18.1 - minimist: 1.2.8 - prettier: 3.9.6 - tinyglobby: 0.2.17 - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -17713,7 +17677,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -18538,7 +18502,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -19274,7 +19238,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 socks: 2.8.9 transitivePeerDependencies: - supports-color diff --git a/src/commands/actor/generate-schema-types.ts b/src/commands/actor/generate-schema-types.ts index c4f127a7b..f1a22615c 100644 --- a/src/commands/actor/generate-schema-types.ts +++ b/src/commands/actor/generate-schema-types.ts @@ -2,9 +2,6 @@ import { mkdir, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import type { JSONSchema4 } from 'json-schema'; -import { compile, type Options } from 'json-schema-to-typescript'; - import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags } from '../../lib/command-framework/flags.js'; @@ -16,13 +13,13 @@ import { readStorageSchema, } from '../../lib/input_schema.js'; import { error, info, success, warning } from '../../lib/outputs.js'; +import { compileSchema, type CompileSchemaOptions, declarationName } from '../../lib/schema-to-typescript.js'; import { clearAllRequired, makePropertiesRequired, prepareFieldsSchemaForCompilation, prepareKvsCollectionsForCompilation, prepareOutputSchemaForCompilation, - stripTitles, } from '../../lib/schema-transforms.js'; export const BANNER_COMMENT = ` @@ -31,7 +28,7 @@ export const BANNER_COMMENT = ` /* eslint-disable */ /* prettier-ignore-start */ /* - * This file was automatically generated by json-schema-to-typescript. + * This file was automatically generated from an Actor schema. * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, * and run apify actor generate-schema-types to regenerate this file. */ @@ -140,16 +137,12 @@ just as if the command were run from that directory with no argument.`; ? clearAllRequired(inputSchema) : makePropertiesRequired(inputSchema); - const compileOptions: Partial = { + const compileOptions: CompileSchemaOptions = { bannerComment: BANNER_COMMENT, - maxItems: -1, - unknownAny: true, - format: true, additionalProperties: !this.flags.strict, - $refOptions: { resolve: { external: false, file: false, http: false } }, }; - const result = await compile(stripTitles(schemaToCompile) as JSONSchema4, name, compileOptions); + const result = compileSchema(schemaToCompile, name, compileOptions); const outputDir = path.resolve(effectiveCwd, this.flags.output); await mkdir(outputDir, { recursive: true }); @@ -193,7 +186,7 @@ just as if the command were run from that directory with no argument.`; }: { cwd: string; outputDir: string; - compileOptions: Partial; + compileOptions: CompileSchemaOptions; }) { const datasetResult = readDatasetSchema({ cwd }); @@ -220,7 +213,7 @@ just as if the command were run from that directory with no argument.`; const schemaToCompile = this.flags.allOptional ? clearAllRequired(prepared) : prepared; - const result = await compile(stripTitles(schemaToCompile) as JSONSchema4, datasetName, compileOptions); + const result = compileSchema(schemaToCompile, datasetName, compileOptions); const outputFile = path.join(outputDir, `${datasetName}.ts`); await writeFile(outputFile, result, 'utf-8'); @@ -235,7 +228,7 @@ just as if the command were run from that directory with no argument.`; }: { cwd: string; outputDir: string; - compileOptions: Partial; + compileOptions: CompileSchemaOptions; }) { const outputResult = readOutputSchema({ cwd }); @@ -262,7 +255,7 @@ just as if the command were run from that directory with no argument.`; const schemaToCompile = this.flags.allOptional ? clearAllRequired(prepared) : prepared; - const result = await compile(stripTitles(schemaToCompile) as JSONSchema4, outputName, compileOptions); + const result = compileSchema(schemaToCompile, outputName, compileOptions); const outputFile = path.join(outputDir, `${outputName}.ts`); await writeFile(outputFile, result, 'utf-8'); @@ -277,7 +270,7 @@ just as if the command were run from that directory with no argument.`; }: { cwd: string; outputDir: string; - compileOptions: Partial; + compileOptions: CompileSchemaOptions; }) { const kvsResult = readStorageSchema({ cwd, key: 'keyValueStore', label: 'Key-Value Store' }); @@ -305,14 +298,25 @@ just as if the command were run from that directory with no argument.`; } const parts: string[] = []; + const takenNames = new Set(); for (const { name, schema } of collections) { const schemaToCompile = this.flags.allOptional ? clearAllRequired(schema) : schema; - const compiled = await compile(stripTitles(schemaToCompile) as JSONSchema4, name, { + // Collection names that differ only in separators or non-ASCII characters would otherwise + // compile to the same identifier, producing a file that does not type-check. + let uniqueName = name; + + for (let suffix = 2; takenNames.has(declarationName(uniqueName)); suffix++) { + uniqueName = `${name} ${suffix}`; + } + + takenNames.add(declarationName(uniqueName)); + + const compiled = compileSchema(schemaToCompile, uniqueName, { ...compileOptions, // Only the first collection gets the banner comment - bannerComment: parts.length === 0 ? (compileOptions.bannerComment as string) : '', + bannerComment: parts.length === 0 ? compileOptions.bannerComment : '', }); parts.push(compiled); diff --git a/src/lib/schema-to-typescript.ts b/src/lib/schema-to-typescript.ts new file mode 100644 index 000000000..cbf8ba393 --- /dev/null +++ b/src/lib/schema-to-typescript.ts @@ -0,0 +1,276 @@ +type Schema = Record; + +export interface CompileSchemaOptions { + /** Comment prepended to the generated source. */ + bannerComment?: string; + /** Whether object types get an `[k: string]: unknown` index signature. */ + additionalProperties?: boolean; +} + +/** + * A rendered type plus the shape information the caller needs: + * `object` may become an `interface`, `composite` needs parentheses to compose. + */ +interface Rendered { + text: string; + kind: 'object' | 'composite' | 'other'; +} + +const INDENT = ' '; +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** The identifier `compileSchema` declares for `name`. */ +export function declarationName(name: string) { + const pascal = name + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(''); + + return IDENTIFIER.test(pascal) ? pascal : `_${pascal}`; +} + +function docComment(description: unknown, indent: string) { + if (typeof description !== 'string' || description.length === 0) { + return ''; + } + + // `*/` inside a description would terminate the comment early. + const lines = description.split('\n').map((line) => `${indent} * ${line.replaceAll('*/', '*\\/')}`.trimEnd()); + + return `${indent}/**\n${lines.join('\n')}\n${indent} */\n`; +} + +function literal(value: unknown) { + return value === undefined ? 'undefined' : JSON.stringify(value); +} + +const plain = (text: string): Rendered => ({ text, kind: 'other' }); +const objectType = (text: string): Rendered => ({ text, kind: 'object' }); + +/** Wraps unions and intersections so they compose correctly with `[]`, `|` and `&`. */ +function wrap(rendered: Rendered) { + return rendered.kind === 'composite' ? `(${rendered.text})` : rendered.text; +} + +function join(parts: Rendered[], separator: ' | ' | ' & '): Rendered { + return parts.length === 1 ? parts[0] : { text: parts.map(wrap).join(separator), kind: 'composite' }; +} + +function tryDecodeURIComponent(value: string) { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +function isSchemaObject(value: unknown): value is Schema { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasObjectKeywords(node: Schema) { + return Boolean(node.properties || node.patternProperties) || 'additionalProperties' in node; +} + +/** + * Generates a TypeScript declaration from a JSON schema. + * + * Everything is inlined into a single exported declaration named after `name`. + * Local `$ref`s are resolved against `schema`; external ones throw, since the + * generator never reads from the network or the file system. + */ +export function compileSchema(schema: Schema, name: string, options: CompileSchemaOptions = {}) { + const allowAdditionalProperties = options.additionalProperties ?? false; + + function resolveRef(ref: string): unknown { + if (!ref.startsWith('#')) { + throw new Error(`Cannot resolve $ref "${ref}": only local references starting with "#" are supported.`); + } + + let current: unknown = schema; + + for (const segment of ref.slice(1).split('/').filter(Boolean)) { + // A malformed percent-escape makes decodeURIComponent throw a bare URIError. + const key = tryDecodeURIComponent(segment)?.replaceAll('~1', '/').replaceAll('~0', '~'); + const container = Array.isArray(current) || isSchemaObject(current) ? current : undefined; + + if (key === undefined || !container || !Object.hasOwn(container, key)) { + throw new Error(`Cannot resolve $ref "${ref}".`); + } + + current = (container as Record)[key]; + } + + return current; + } + + function renderObject(node: Schema, indent: string, seenRefs: Set) { + const inner = indent + INDENT; + const properties = isSchemaObject(node.properties) ? node.properties : {}; + const required = new Set(Array.isArray(node.required) ? node.required.map(String) : []); + + const members = Object.entries(properties).map(([key, property]) => { + const propertyName = IDENTIFIER.test(key) ? key : JSON.stringify(key); + const optional = required.has(key) ? '' : '?'; + const description = isSchemaObject(property) ? property.description : undefined; + const type = renderType(property, inner, seenRefs).text; + + return `${docComment(description, inner)}${inner}${propertyName}${optional}: ${type};`; + }); + + const indexType = renderIndexSignature(node, inner, seenRefs, members.length > 0); + + if (indexType) { + members.push(`${inner}[k: string]: ${indexType};`); + } + + return members.length === 0 ? '{}' : `{\n${members.join('\n')}\n${indent}}`; + } + + function renderIndexSignature(node: Schema, indent: string, seenRefs: Set, hasMembers: boolean) { + const { additionalProperties, patternProperties } = node; + + let indexType: string | null = null; + + if (isSchemaObject(additionalProperties)) { + indexType = renderType(additionalProperties, indent, seenRefs).text; + } else if (additionalProperties === true) { + indexType = 'unknown'; + } else if (isSchemaObject(patternProperties) && Object.keys(patternProperties).length > 0) { + // `additionalProperties: false` still permits keys matched by `patternProperties`. + indexType = Object.values(patternProperties) + .map((pattern) => wrap(renderType(pattern, indent, seenRefs))) + .join(' | '); + } else if (additionalProperties !== false && allowAdditionalProperties) { + indexType = 'unknown'; + } + + // Optional members are not assignable to a narrower index type, so widen instead. + return indexType && hasMembers ? 'unknown' : indexType; + } + + function renderArray(node: Schema, indent: string, seenRefs: Set) { + const { items } = node; + + if (Array.isArray(items)) { + return `[${items.map((item) => renderType(item, indent, seenRefs).text).join(', ')}]`; + } + + return items === undefined ? 'unknown[]' : `${wrap(renderType(items, indent, seenRefs))}[]`; + } + + function renderNamedType(type: string, node: Schema, indent: string, seenRefs: Set): Rendered { + switch (type) { + case 'object': + return objectType(renderObject(node, indent, seenRefs)); + case 'array': + return plain(renderArray(node, indent, seenRefs)); + case 'string': + return plain('string'); + case 'integer': + case 'number': + return plain('number'); + case 'boolean': + return plain('boolean'); + case 'null': + return plain('null'); + default: + return plain('unknown'); + } + } + + function renderType(node: unknown, indent: string, seenRefs: Set): Rendered { + if (node === false) { + return plain('never'); + } + + if (!isSchemaObject(node)) { + return plain('unknown'); + } + + if (typeof node.$ref === 'string') { + // Recursive $refs collapse to `unknown`, since everything is inlined. + if (seenRefs.has(node.$ref)) { + return plain('unknown'); + } + + const nested = new Set(seenRefs).add(node.$ref); + + return renderType(resolveRef(node.$ref), indent, nested); + } + + if (Array.isArray(node.enum)) { + return node.enum.length === 0 + ? plain('never') + : join( + node.enum.map((value) => plain(literal(value))), + ' | ', + ); + } + + if ('const' in node) { + return plain(literal(node.const)); + } + + const compositions: Rendered[] = []; + + for (const keyword of ['allOf', 'anyOf', 'oneOf'] as const) { + const variants = node[keyword]; + + if (Array.isArray(variants) && variants.length > 0) { + compositions.push( + join( + variants.map((variant) => renderType(variant, indent, seenRefs)), + keyword === 'allOf' ? ' & ' : ' | ', + ), + ); + } + } + + if (compositions.length > 0) { + // Sibling object keywords constrain the same value, so they are intersected in rather than dropped. + const siblings = hasObjectKeywords(node) ? renderObject(node, indent, seenRefs) : '{}'; + + if (siblings !== '{}') { + compositions.unshift(objectType(siblings)); + } + + return join(compositions, ' & '); + } + + const { type } = node; + + if (Array.isArray(type)) { + return type.length === 0 + ? plain('unknown') + : join( + type.map((entry) => renderNamedType(String(entry), node, indent, seenRefs)), + ' | ', + ); + } + + if (typeof type === 'string') { + return renderNamedType(type, node, indent, seenRefs); + } + + if (hasObjectKeywords(node)) { + return objectType(renderObject(node, indent, seenRefs)); + } + + if (node.items) { + return plain(renderArray(node, indent, seenRefs)); + } + + return plain('unknown'); + } + + const banner = options.bannerComment?.trim(); + const body = renderType(schema, '', new Set()); + const declaration = + body.kind === 'object' + ? `export interface ${declarationName(name)} ${body.text}\n` + : `export type ${declarationName(name)} = ${body.text};\n`; + + return `${banner ? `${banner}\n\n` : ''}${docComment(schema.description, '')}${declaration}`; +} diff --git a/src/lib/schema-transforms.ts b/src/lib/schema-transforms.ts index 6fec21208..abc37d495 100644 --- a/src/lib/schema-transforms.ts +++ b/src/lib/schema-transforms.ts @@ -49,81 +49,6 @@ export function clearAllRequired(schema: Record): Record): Record { - const clone = structuredClone(schema); - - delete clone.title; - - if (clone.properties && typeof clone.properties === 'object') { - const properties = clone.properties as Record>; - for (const [key, prop] of Object.entries(properties)) { - if (prop && typeof prop === 'object') { - properties[key] = stripTitles(prop) as Record; - } - } - } - - if (clone.items && typeof clone.items === 'object') { - clone.items = stripTitles(clone.items as Record); - } - - // Recurse into composition keywords (arrays of sub-schemas) - for (const keyword of ['allOf', 'anyOf', 'oneOf'] as const) { - if (Array.isArray(clone[keyword])) { - clone[keyword] = (clone[keyword] as Record[]).map((subSchema) => - subSchema && typeof subSchema === 'object' ? stripTitles(subSchema) : subSchema, - ); - } - } - - // Recurse into definitions / $defs (objects mapping names to sub-schemas) - for (const keyword of ['definitions', '$defs'] as const) { - if (clone[keyword] && typeof clone[keyword] === 'object' && !Array.isArray(clone[keyword])) { - const defs = clone[keyword] as Record>; - for (const [key, def] of Object.entries(defs)) { - if (def && typeof def === 'object') { - defs[key] = stripTitles(def) as Record; - } - } - } - } - - // Recurse into additionalProperties when it is a schema object - if (clone.additionalProperties && typeof clone.additionalProperties === 'object') { - clone.additionalProperties = stripTitles(clone.additionalProperties as Record); - } - - // Recurse into single sub-schema keywords - for (const keyword of ['if', 'then', 'else', 'not'] as const) { - if (clone[keyword] && typeof clone[keyword] === 'object') { - clone[keyword] = stripTitles(clone[keyword] as Record); - } - } - - // Recurse into patternProperties (object mapping patterns to sub-schemas) - if ( - clone.patternProperties && - typeof clone.patternProperties === 'object' && - !Array.isArray(clone.patternProperties) - ) { - const patternProperties = clone.patternProperties as Record>; - for (const [key, prop] of Object.entries(patternProperties)) { - if (prop && typeof prop === 'object') { - patternProperties[key] = stripTitles(prop) as Record; - } - } - } - - return clone; -} - /** * Extracts and prepares the `fields` sub-schema from a Dataset or KVS schema for compilation. * Returns `null` if the schema has no compilable fields (empty or missing). diff --git a/test/local/commands/actor/generate-schema-types.test.ts b/test/local/commands/actor/generate-schema-types.test.ts index a065cb7a8..558b73dec 100644 --- a/test/local/commands/actor/generate-schema-types.test.ts +++ b/test/local/commands/actor/generate-schema-types.test.ts @@ -9,7 +9,6 @@ import { prepareFieldsSchemaForCompilation, prepareKvsCollectionsForCompilation, prepareOutputSchemaForCompilation, - stripTitles, } from '../../../../src/lib/schema-transforms.js'; import { validDatasetSchemaPath } from '../../../__setup__/dataset-schemas/paths.js'; import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; @@ -450,6 +449,30 @@ describe('apify actor generate-schema-types', () => { expect(generatedFile).toContain('avgDuration'); }); + it('should not emit duplicate interfaces for collections with colliding names', async () => { + const outputDir = joinPath('kvs-output-collisions'); + const jsonSchema = { type: 'object', properties: { a: { type: 'string' } } }; + + await setupActorConfig(joinPath(), { + kvsSchemaRef: { + actorKeyValueStoreSchemaVersion: 1, + title: 'Colliding KVS', + collections: { + 'my-store': { contentTypes: ['application/json'], key: 'A', jsonSchema }, + my_store: { contentTypes: ['application/json'], key: 'B', jsonSchema }, + }, + }, + }); + + await testRunCommand(ActorGenerateSchemaTypesCommand, { + flags_output: outputDir, + }); + + const generatedFile = await readFile(joinPath('kvs-output-collisions', 'key-value-store.ts'), 'utf-8'); + expect(generatedFile).toContain('export interface MyStore '); + expect(generatedFile).toContain('export interface MyStore2 '); + }); + it('should skip when no collections have jsonSchema', async () => { const outputDir = joinPath('kvs-output-no-json'); await setupActorConfig(joinPath(), { @@ -988,103 +1011,3 @@ describe('clearAllRequired', () => { expect(result.required).toBeUndefined(); }); }); - -describe('stripTitles', () => { - it('should remove top-level title', () => { - const schema = { title: 'MySchema', type: 'object', properties: {} }; - const result = stripTitles(schema); - expect(result.title).toBeUndefined(); - }); - - it('should strip titles from nested properties', () => { - const schema = { - type: 'object', - properties: { - name: { title: 'Name', type: 'string' }, - age: { title: 'Age', type: 'integer' }, - }, - }; - const result = stripTitles(schema); - const props = result.properties as any; - expect(props.name.title).toBeUndefined(); - expect(props.age.title).toBeUndefined(); - }); - - it('should strip title from items', () => { - const schema = { - type: 'array', - items: { title: 'Item', type: 'string' }, - }; - const result = stripTitles(schema); - expect((result.items as any).title).toBeUndefined(); - }); - - it('should strip titles from allOf / anyOf / oneOf sub-schemas', () => { - const schema = { - allOf: [{ title: 'A', type: 'string' }], - anyOf: [{ title: 'B', type: 'number' }], - oneOf: [{ title: 'C', type: 'boolean' }], - }; - const result = stripTitles(schema); - expect((result.allOf as any[])[0].title).toBeUndefined(); - expect((result.anyOf as any[])[0].title).toBeUndefined(); - expect((result.oneOf as any[])[0].title).toBeUndefined(); - }); - - it('should strip titles from definitions and $defs', () => { - const schema = { - definitions: { Foo: { title: 'Foo', type: 'string' } }, - $defs: { Bar: { title: 'Bar', type: 'number' } }, - }; - const result = stripTitles(schema); - expect((result.definitions as any).Foo.title).toBeUndefined(); - expect((result.$defs as any).Bar.title).toBeUndefined(); - }); - - it('should strip title from additionalProperties when it is a schema object', () => { - const schema = { - type: 'object', - additionalProperties: { title: 'Extra', type: 'string' }, - }; - const result = stripTitles(schema); - expect((result.additionalProperties as any).title).toBeUndefined(); - }); - - it('should strip titles from if / then / else / not', () => { - const schema = { - if: { title: 'If', type: 'string' }, - then: { title: 'Then', type: 'number' }, - else: { title: 'Else', type: 'boolean' }, - not: { title: 'Not', type: 'null' }, - }; - const result = stripTitles(schema); - expect((result.if as any).title).toBeUndefined(); - expect((result.then as any).title).toBeUndefined(); - expect((result.else as any).title).toBeUndefined(); - expect((result.not as any).title).toBeUndefined(); - }); - - it('should strip titles from patternProperties', () => { - const schema = { - type: 'object', - patternProperties: { - '^S_': { title: 'StringProp', type: 'string' }, - }, - }; - const result = stripTitles(schema); - expect((result.patternProperties as any)['^S_'].title).toBeUndefined(); - }); - - it('should not mutate the original schema', () => { - const schema = { - title: 'Root', - type: 'object', - properties: { - name: { title: 'Name', type: 'string' }, - }, - }; - stripTitles(schema); - expect(schema.title).toBe('Root'); - expect((schema.properties as any).name.title).toBe('Name'); - }); -}); diff --git a/test/local/lib/schema-to-typescript.test.ts b/test/local/lib/schema-to-typescript.test.ts new file mode 100644 index 000000000..07971280a --- /dev/null +++ b/test/local/lib/schema-to-typescript.test.ts @@ -0,0 +1,252 @@ +import { compileSchema } from '../../../src/lib/schema-to-typescript.js'; + +describe('compileSchema', () => { + it('should emit an interface with required and optional properties', () => { + const result = compileSchema( + { + type: 'object', + description: 'Root schema', + properties: { + name: { type: 'string', description: 'The name' }, + age: { type: 'integer' }, + }, + required: ['name'], + }, + 'input', + ); + + expect(result).toBe( + [ + '/**', + ' * Root schema', + ' */', + 'export interface Input {', + ' /**', + ' * The name', + ' */', + ' name: string;', + ' age?: number;', + '}', + '', + ].join('\n'), + ); + }); + + it('should prepend the banner comment', () => { + const result = compileSchema({ type: 'object' }, 'input', { bannerComment: '\n/* generated */\n' }); + + expect(result).toBe('/* generated */\n\nexport interface Input {}\n'); + }); + + it('should pascal-case the declaration name', () => { + expect(compileSchema({ type: 'object' }, 'my-collection weird_name')).toContain( + 'export interface MyCollectionWeirdName', + ); + expect(compileSchema({ type: 'object' }, '1st')).toContain('export interface _1st'); + }); + + it('should add an index signature only when additionalProperties is enabled', () => { + const schema = { type: 'object', properties: { a: { type: 'string' } } }; + + expect(compileSchema(schema, 'x')).not.toContain('[k: string]'); + expect(compileSchema(schema, 'x', { additionalProperties: true })).toContain('[k: string]: unknown;'); + }); + + it('should honour additionalProperties declared in the schema itself', () => { + expect(compileSchema({ type: 'object', additionalProperties: { type: 'number' } }, 'x')).toContain( + '[k: string]: number;', + ); + + expect( + compileSchema({ type: 'object', additionalProperties: false, properties: { a: { type: 'string' } } }, 'x', { + additionalProperties: true, + }), + ).not.toContain('[k: string]'); + + expect(compileSchema({ type: 'object', patternProperties: { '^S_': { type: 'string' } } }, 'x')).toContain( + '[k: string]: string;', + ); + }); + + it('should render enums, consts and type unions as literal unions', () => { + const result = compileSchema( + { + type: 'object', + properties: { + choice: { type: 'string', enum: ['a', 'b'] }, + fixed: { const: 42 }, + mixed: { type: ['string', 'null'] }, + nothing: { enum: [] }, + }, + }, + 'x', + ); + + expect(result).toContain('choice?: "a" | "b";'); + expect(result).toContain('fixed?: 42;'); + expect(result).toContain('mixed?: string | null;'); + expect(result).toContain('nothing?: never;'); + }); + + it('should render anyOf/oneOf as unions and allOf as an intersection', () => { + const result = compileSchema( + { + type: 'object', + properties: { + a: { anyOf: [{ type: 'string' }, { type: 'number' }] }, + b: { oneOf: [{ type: 'boolean' }, { type: 'null' }] }, + c: { allOf: [{ type: 'object', properties: { x: { type: 'string' } } }, { type: 'object' }] }, + }, + }, + 'x', + ); + + expect(result).toContain('a?: string | number;'); + expect(result).toContain('b?: boolean | null;'); + expect(result).toContain('c?: {\n x?: string;\n } & {};'); + }); + + it('should render arrays, including tuples and unions of items', () => { + const result = compileSchema( + { + type: 'object', + properties: { + untyped: { type: 'array' }, + strings: { type: 'array', items: { type: 'string' } }, + unions: { type: 'array', items: { type: ['string', 'number'] } }, + tuple: { type: 'array', items: [{ type: 'string' }, { type: 'number' }] }, + }, + }, + 'x', + ); + + expect(result).toContain('untyped?: unknown[];'); + expect(result).toContain('strings?: string[];'); + expect(result).toContain('unions?: (string | number)[];'); + expect(result).toContain('tuple?: [string, number];'); + }); + + it('should quote property names that are not valid identifiers', () => { + expect(compileSchema({ type: 'object', properties: { 'weird-key': { type: 'string' } } }, 'x')).toContain( + '"weird-key"?: string;', + ); + }); + + it('should inline local $refs and collapse recursive ones', () => { + const result = compileSchema( + { + type: 'object', + definitions: { + node: { type: 'object', properties: { child: { $ref: '#/definitions/node' } } }, + }, + properties: { root: { $ref: '#/definitions/node' } }, + }, + 'x', + ); + + expect(result).toContain('root?: {\n child?: unknown;\n };'); + }); + + it('should resolve $refs that point through arrays', () => { + const result = compileSchema( + { + type: 'object', + definitions: { x: { anyOf: [{ type: 'string' }, { type: 'number' }] } }, + properties: { a: { $ref: '#/definitions/x/anyOf/0' } }, + }, + 'x', + ); + + expect(result).toContain('a?: string;'); + }); + + it('should not resolve $refs against Object.prototype', () => { + expect(() => compileSchema({ definitions: {}, $ref: '#/definitions/hasOwnProperty' }, 'x')).toThrow( + 'Cannot resolve $ref', + ); + }); + + it('should throw on external and unresolvable $refs', () => { + expect(() => compileSchema({ $ref: './other.json' }, 'x')).toThrow('only local references'); + expect(() => compileSchema({ $ref: '#/definitions/missing' }, 'x')).toThrow('Cannot resolve $ref'); + expect(() => compileSchema({ $ref: '#/%' }, 'x')).toThrow('Cannot resolve $ref'); + }); + + it('should emit a type alias when the root is not an object', () => { + expect(compileSchema({ type: 'array', items: { type: 'string' } }, 'x')).toBe('export type X = string[];\n'); + }); + + it('should emit a type alias when the root renders as a union, intersection or array of objects', () => { + const object = { type: 'object', properties: { a: { type: 'string' } } }; + + expect(compileSchema({ ...object, type: ['object', 'null'] }, 'x')).toBe( + 'export type X = {\n a?: string;\n} | null;\n', + ); + expect(compileSchema({ allOf: [object, { type: 'object' }] }, 'x')).toBe( + 'export type X = {\n a?: string;\n} & {};\n', + ); + expect(compileSchema({ type: 'array', items: object }, 'x')).toBe('export type X = {\n a?: string;\n}[];\n'); + }); + + it('should escape comment terminators in descriptions', () => { + const result = compileSchema( + { type: 'object', properties: { globs: { type: 'string', description: 'e.g. https://x.com/**/*' } } }, + 'x', + ); + + expect(result).not.toContain('/**/*'); + expect(result).toContain('e.g. https://x.com/**\\/*'); + }); + + it('should intersect sibling object keywords with allOf/anyOf/oneOf instead of dropping them', () => { + expect( + compileSchema( + { + type: 'object', + properties: { a: { type: 'string' } }, + allOf: [{ type: 'object', properties: { b: { type: 'number' } } }], + }, + 'x', + ), + ).toBe('export type X = {\n a?: string;\n} & {\n b?: number;\n};\n'); + }); + + it('should intersect every composition keyword, not just the first', () => { + expect( + compileSchema( + { + allOf: [{ type: 'object', properties: { a: { type: 'string' } } }], + anyOf: [{ type: 'object', properties: { b: { type: 'number' } } }], + }, + 'x', + ), + ).toBe('export type X = {\n a?: string;\n} & {\n b?: number;\n};\n'); + }); + + it('should keep patternProperties when additionalProperties is false', () => { + expect( + compileSchema( + { type: 'object', patternProperties: { '^S_': { type: 'string' } }, additionalProperties: false }, + 'x', + ), + ).toContain('[k: string]: string;'); + + expect(compileSchema({ type: 'object', additionalProperties: false }, 'x')).toBe('export interface X {}\n'); + }); + + it('should widen the index signature so declared properties stay assignable', () => { + expect( + compileSchema( + { type: 'object', properties: { a: { type: 'number' } }, patternProperties: { '^s_': { type: 'string' } } }, + 'x', + ), + ).toContain('[k: string]: unknown;'); + + expect( + compileSchema( + { type: 'object', additionalProperties: true, patternProperties: { '^s_': { type: 'string' } } }, + 'x', + ), + ).toContain('[k: string]: unknown;'); + }); +});