From aa0de6ac7eac3580d9a17403a1dd4b986f66c84f Mon Sep 17 00:00:00 2001 From: Gediminas Date: Wed, 29 Jul 2026 14:56:02 +0000 Subject: [PATCH] Add opt-in JSON-LD schema support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schemas that declare the `x-jsonld` extension (or one of `x-jsonld-context`, `x-jsonld-type`, `x-jsonld-id`) are parsed as JSON-LD when the new `jsonLdOptions.enabled` option is set (CLI: `--jsonld`). Entities get typed `@context`, `@type` and `@id` members and extend a shared `JsonLdEntity` interface. A property-less `x-jsonld-type` schema becomes a string-literal type alias and stays in `data-contracts` alongside the other aliases. In modular output entities are emitted as `jsonld-entity` and the shared interfaces as `jsonld-utils`, both re-exported from `data-contracts` so route modules keep importing their models from a single place. Setting `jsonLdOptions.generateUtils` to `false` emits standalone entity interfaces instead. Detection is explicit — no auto-discovery from `@context`/`@type`/`@id` property names — and the input document is never mutated, so specs without the extension and runs without `--jsonld` produce unchanged output. Tests cover single-file and modular output, the disabled default, the `generateUtils` opt-out, mixed JSON-LD/plain specs, and the type-alias path. Generated output for all three modes type-checks under `--strict`. --- .changeset/jsonld-schema-support.md | 18 ++ index.ts | 7 + src/code-gen-process.ts | 66 +++++- src/configuration.ts | 17 ++ src/constants.ts | 2 + .../base-schema-parsers/jsonld-entity.ts | 200 ++++++++++++++++++ .../base-schema-parsers/jsonld-type.ts | 89 ++++++++ .../base-schema-parsers/jsonld-utils.ts | 21 ++ src/schema-parser/schema-formatters.ts | 16 ++ src/schema-parser/schema-parser.ts | 24 +++ src/schema-parser/schema-utils.ts | 39 ++++ templates/base/data-contracts.ejs | 20 ++ .../base/jsonld-entity-data-contract.ejs | 26 +++ templates/base/jsonld-utils.ejs | 80 +++++++ .../__snapshots__/basic.test.ts.snap | 177 ++++++++++++++++ tests/spec/jsonld-basic/basic.test.ts | 137 ++++++++++++ tests/spec/jsonld-basic/schema.json | 155 ++++++++++++++ types/index.ts | 21 ++ 18 files changed, 1114 insertions(+), 1 deletion(-) create mode 100644 .changeset/jsonld-schema-support.md create mode 100644 src/schema-parser/base-schema-parsers/jsonld-entity.ts create mode 100644 src/schema-parser/base-schema-parsers/jsonld-type.ts create mode 100644 src/schema-parser/base-schema-parsers/jsonld-utils.ts create mode 100644 templates/base/jsonld-entity-data-contract.ejs create mode 100644 templates/base/jsonld-utils.ejs create mode 100644 tests/spec/jsonld-basic/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/jsonld-basic/basic.test.ts create mode 100644 tests/spec/jsonld-basic/schema.json diff --git a/.changeset/jsonld-schema-support.md b/.changeset/jsonld-schema-support.md new file mode 100644 index 000000000..2160fb2a4 --- /dev/null +++ b/.changeset/jsonld-schema-support.md @@ -0,0 +1,18 @@ +--- +"swagger-typescript-api": minor +--- + +Add opt-in JSON-LD schema support. + +With `jsonLdOptions.enabled` (CLI: `--jsonld`), schemas that declare the +`x-jsonld` extension — or one of `x-jsonld-context`, `x-jsonld-type`, +`x-jsonld-id` — are parsed as JSON-LD entities. Entities gain typed +`@context`, `@type` and `@id` members and extend a shared `JsonLdEntity` +interface; a property-less `x-jsonld-type` schema becomes a string-literal +type alias. In modular output the entities are emitted as `jsonld-entity` +and the shared interfaces as `jsonld-utils`, both re-exported from +`data-contracts`. Set `jsonLdOptions.generateUtils` to `false` to emit +standalone entity interfaces without the shared module. + +The feature is fully opt-in — schemas without the extension and runs +without `--jsonld` produce byte-identical output to previous versions. diff --git a/index.ts b/index.ts index e4544a1db..749cbef6d 100644 --- a/index.ts +++ b/index.ts @@ -204,6 +204,12 @@ const generateCommand = defineCommand({ description: "generate js api module with declaration file", default: codeGenBaseConfig.toJS, }, + jsonld: { + type: "boolean", + description: + "enable JSON-LD support; schemas declaring the `x-jsonld` extension produce additional context/entity/utility types", + default: codeGenBaseConfig.jsonLdOptions.enabled, + }, modular: { type: "boolean", description: @@ -347,6 +353,7 @@ const generateCommand = defineCommand({ ? HTTP_CLIENT.AXIOS : HTTP_CLIENT.FETCH, input: path.resolve(process.cwd(), args.path as string), + jsonLdOptions: { enabled: args.jsonld }, modular: args.modular, moduleNameFirstTag: args["module-name-first-tag"], moduleNameIndex: +args["module-name-index"] || 0, diff --git a/src/code-gen-process.ts b/src/code-gen-process.ts index 49c2a193d..50679bcb9 100644 --- a/src/code-gen-process.ts +++ b/src/code-gen-process.ts @@ -9,6 +9,7 @@ import type { } from "../types/index.js"; import { CodeFormatter } from "./code-formatter.js"; import { CodeGenConfig } from "./configuration.js"; +import { SCHEMA_TYPES } from "./constants.js"; import { SchemaComponentsMap } from "./schema-components-map.js"; import { SchemaParserFabric } from "./schema-parser/schema-parser-fabric.js"; import { SchemaRoutes } from "./schema-routes/schema-routes.js"; @@ -477,6 +478,39 @@ export class CodeGenProcess { } } + const jsonldOutputFiles: TranslatorIO[] = []; + + const { jsonLdOptions } = configuration.config; + const hasJsonLdSchemas = this.hasJsonLdEntities(configuration); + + if (hasJsonLdSchemas) { + if (templatesToRender.jsonldEntityDataContract) { + jsonldOutputFiles.push( + ...(await this.createOutputFileInfo( + configuration, + fileNames.jsonldEntity, + this.templatesWorker.renderTemplate( + templatesToRender.jsonldEntityDataContract, + configuration, + ), + )), + ); + } + + if (jsonLdOptions.generateUtils && templatesToRender.jsonldUtils) { + jsonldOutputFiles.push( + ...(await this.createOutputFileInfo( + configuration, + fileNames.jsonldUtils, + this.templatesWorker.renderTemplate( + templatesToRender.jsonldUtils, + configuration, + ), + )), + ); + } + } + return [ ...(await this.createOutputFileInfo( configuration, @@ -496,6 +530,7 @@ export class CodeGenProcess { ), ) : []), + ...jsonldOutputFiles, ...modularApiFileInfos, ]; }; @@ -504,7 +539,10 @@ export class CodeGenProcess { templatesToRender, configuration, ): Promise => { - const { generateRouteTypes, generateClient } = configuration.config; + const { generateRouteTypes, generateClient, jsonLdOptions } = + configuration.config; + + const hasJsonLdSchemas = this.hasJsonLdEntities(configuration); return await this.createOutputFileInfo( configuration, @@ -514,6 +552,19 @@ export class CodeGenProcess { templatesToRender.dataContracts, configuration, ), + hasJsonLdSchemas && + templatesToRender.jsonldEntityDataContract && + this.templatesWorker.renderTemplate( + templatesToRender.jsonldEntityDataContract, + configuration, + ), + hasJsonLdSchemas && + jsonLdOptions?.generateUtils && + templatesToRender.jsonldUtils && + this.templatesWorker.renderTemplate( + templatesToRender.jsonldUtils, + configuration, + ), generateRouteTypes && this.templatesWorker.renderTemplate( templatesToRender.routeTypes, @@ -533,6 +584,19 @@ export class CodeGenProcess { ); }; + /** + * `jsonld-type` schemas stay in `data-contracts`; only entities are moved + * into their own module, and the utility types exist to support them. + */ + hasJsonLdEntities = (configuration): boolean => + Boolean( + configuration.config.jsonLdOptions?.enabled && + configuration.modelTypes?.some?.( + (modelType) => + modelType.typeData?.schemaType === SCHEMA_TYPES.JSONLD_ENTITY, + ), + ); + createOutputFileInfo = async ( configuration, fileNameFull, diff --git a/src/configuration.ts b/src/configuration.ts index 8cdcd9e2c..a99a997f7 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -92,6 +92,8 @@ export class CodeGenConfig { routeTypes: "route-types", httpClient: "http-client", outOfModuleApi: "Common", + jsonldEntity: "jsonld-entity", + jsonldUtils: "jsonld-utils", }; routeNameDuplicatesMap = new Map(); hooks: Hooks = { @@ -153,6 +155,8 @@ export class CodeGenConfig { httpClient: "", routeTypes: "", routeName: "", + jsonldEntityDataContract: "", + jsonldUtils: "", }; schemaParsers: Record MonoSchemaParser> = {}; toJS = false; @@ -194,6 +198,14 @@ export class CodeGenConfig { successResponseStatusRange = [200, 299]; + /** JSON-LD specific configuration options */ + jsonLdOptions = { + /** Enable JSON-LD support. Schemas are detected via the `x-jsonld` extension. */ + enabled: false, + /** Generate the shared `jsonld-utils` file with base interfaces (JsonLdEntity, JsonLdGraph, ...) */ + generateUtils: true, + }; + extractingOptions: Partial = { requestBodySuffix: ["Payload", "Body", "Input"], requestParamsSuffix: ["Params"], @@ -421,6 +433,11 @@ export class CodeGenConfig { { name: "httpClient", fileName: "http-client" }, { name: "routeTypes", fileName: "route-types" }, { name: "routeName", fileName: "route-name" }, + { + name: "jsonldEntityDataContract", + fileName: "jsonld-entity-data-contract", + }, + { name: "jsonldUtils", fileName: "jsonld-utils" }, ]; templateExtensions = [".eta", ".ejs"]; diff --git a/src/constants.ts b/src/constants.ts index a64a9da61..72db29239 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -51,4 +51,6 @@ export const SCHEMA_TYPES = { COMPLEX_ALL_OF: "allOf", COMPLEX_NOT: "not", COMPLEX_UNKNOWN: "__unknown", + JSONLD_ENTITY: "jsonld-entity", + JSONLD_TYPE: "jsonld-type", } as const; diff --git a/src/schema-parser/base-schema-parsers/jsonld-entity.ts b/src/schema-parser/base-schema-parsers/jsonld-entity.ts new file mode 100644 index 000000000..784815509 --- /dev/null +++ b/src/schema-parser/base-schema-parsers/jsonld-entity.ts @@ -0,0 +1,200 @@ +import { get } from "es-toolkit/compat"; +import { SCHEMA_TYPES } from "../../constants.js"; +import { MonoSchemaParser } from "../mono-schema-parser.js"; +import { + getEntityNameFromJsonLdType, + JSONLD_UTILS_TYPES, +} from "./jsonld-utils.js"; + +export class JsonLdEntitySchemaParser extends MonoSchemaParser { + override parse() { + const entitySchema = this.schema; + const entityProperties = this.getJsonLdEntityContent(entitySchema); + + let entityName = this.typeName; + if (!entityName && entitySchema["x-jsonld-type"]) { + const jsonldType = entitySchema["x-jsonld-type"]; + if (typeof jsonldType === "string") { + entityName = getEntityNameFromJsonLdType(jsonldType); + } else if (Array.isArray(jsonldType) && jsonldType.length > 0) { + entityName = getEntityNameFromJsonLdType(jsonldType[0]); + } + } + entityName = entityName || "JsonLdEntity"; + + return { + ...(typeof this.schema === "object" && !Array.isArray(this.schema) + ? this.schema + : {}), + $schemaPath: this.schemaPath.slice(), + $parsedSchema: true, + schemaType: SCHEMA_TYPES.JSONLD_ENTITY, + type: SCHEMA_TYPES.OBJECT, + typeIdentifier: this.config.Ts.Keyword.Interface, + name: entityName, + description: this.schemaFormatters.formatDescription( + entitySchema.description || `JSON-LD Entity: ${entityName}`, + ), + allFieldsAreOptional: !entityProperties.some((prop) => prop.isRequired), + content: entityProperties, + isJsonLdEntity: true, + }; + } + + getJsonLdEntityContent = (schema) => { + const properties: Record[] = []; + const { properties: schemaProperties = {} } = schema; + + if (schema["x-jsonld-context"] || schemaProperties["@context"]) { + const context = + schema["x-jsonld-context"] || schemaProperties["@context"]; + const contextValue = this.getContextFieldType(context); + properties.push({ + $$raw: { "@context": context }, + description: "JSON-LD context defining the meaning of terms", + isRequired: false, + isNullable: false, + name: '"@context"', + value: contextValue, + field: this.config.Ts.TypeField({ + readonly: false, + optional: true, + key: '"@context"', + value: contextValue, + }), + }); + } + + if (schema["x-jsonld-type"] || schemaProperties["@type"]) { + const jsonldType = schema["x-jsonld-type"] || schemaProperties["@type"]; + let typeValue: string; + + if (typeof jsonldType === "string") { + typeValue = this.config.Ts.StringValue(jsonldType); + } else if (Array.isArray(jsonldType)) { + typeValue = this.config.Ts.UnionType( + jsonldType.map((type) => this.config.Ts.StringValue(type)), + ); + } else { + typeValue = this.config.Ts.Keyword.String; + } + + properties.push({ + $$raw: { "@type": jsonldType }, + description: "JSON-LD type identifier", + isRequired: true, + isNullable: false, + name: '"@type"', + value: typeValue, + field: this.config.Ts.TypeField({ + readonly: false, + optional: false, + key: '"@type"', + value: typeValue, + }), + }); + } + + if (schema["x-jsonld-id"] || schemaProperties["@id"]) { + properties.push({ + $$raw: { "@id": schema["x-jsonld-id"] || schemaProperties["@id"] }, + description: "JSON-LD identifier (IRI)", + isRequired: false, + isNullable: false, + name: '"@id"', + value: this.config.Ts.Keyword.String, + field: this.config.Ts.TypeField({ + readonly: false, + optional: true, + key: '"@id"', + value: this.config.Ts.Keyword.String, + }), + }); + } + + Object.entries(schemaProperties).forEach(([name, property]) => { + if (name.startsWith("@")) return; + + const required = this.schemaUtils.isPropertyRequired( + name, + property, + schema, + ); + const rawTypeData = get( + this.schemaUtils.getSchemaRefType(property), + "rawTypeData", + {}, + ); + const propertyValue = property as Record; + const nullable = !!(rawTypeData.nullable || propertyValue.nullable); + const fieldName = this.typeNameFormatter.isValidName(name) + ? name + : this.config.Ts.StringValue(name); + + const fieldValue = this.schemaParserFabric + .createSchemaParser({ + schema: property, + schemaPath: [...this.schemaPath, name], + }) + .getInlineParseContent(); + const readOnly = propertyValue.readOnly; + + properties.push({ + ...propertyValue, + $$raw: propertyValue, + title: propertyValue.title, + description: propertyValue.description, + isRequired: required, + isNullable: nullable, + name: fieldName, + value: fieldValue, + field: this.config.Ts.TypeField({ + readonly: readOnly && this.config.addReadonly, + optional: !required, + key: fieldName, + value: fieldValue, + }), + }); + }); + + return properties; + }; + + private getContextFieldType(context: unknown): string { + if (typeof context === "string") { + return this.config.Ts.StringValue(context); + } + + // When the utils module is generated the entity extends `JsonLdEntity`, + // whose `@context` is a `JsonLdContext`. Anything structural we could + // derive here (`object`, `(string | object)[]`) is not assignable to it, + // so widen to the alias instead and keep the output type-checking. + if (this.config.jsonLdOptions.generateUtils) { + return JSONLD_UTILS_TYPES.context; + } + + if (Array.isArray(context)) { + return this.config.Ts.ArrayType( + this.config.Ts.UnionType([ + this.config.Ts.Keyword.String, + this.config.Ts.Keyword.Object, + ]), + ); + } + + if (typeof context === "object" && context !== null) { + return this.config.Ts.Keyword.Object; + } + + return this.config.Ts.UnionType([ + this.config.Ts.Keyword.String, + this.config.Ts.Keyword.Object, + this.config.Ts.ArrayType( + this.config.Ts.UnionType([ + this.config.Ts.Keyword.String, + this.config.Ts.Keyword.Object, + ]), + ), + ]); + } +} diff --git a/src/schema-parser/base-schema-parsers/jsonld-type.ts b/src/schema-parser/base-schema-parsers/jsonld-type.ts new file mode 100644 index 000000000..d2e8d5c7c --- /dev/null +++ b/src/schema-parser/base-schema-parsers/jsonld-type.ts @@ -0,0 +1,89 @@ +import { SCHEMA_TYPES } from "../../constants.js"; +import { MonoSchemaParser } from "../mono-schema-parser.js"; + +export class JsonLdTypeSchemaParser extends MonoSchemaParser { + override parse() { + const typeSchema = this.schema; + + // Handle single type + if (typeof typeSchema === "string") { + return { + ...(typeof this.schema === "object" && !Array.isArray(this.schema) + ? this.schema + : {}), + $schemaPath: this.schemaPath.slice(), + $parsedSchema: true, + schemaType: SCHEMA_TYPES.JSONLD_TYPE, + type: SCHEMA_TYPES.PRIMITIVE, + typeIdentifier: this.config.Ts.Keyword.String, + name: this.typeName || "JsonLdType", + description: `JSON-LD Type: ${typeSchema}`, + content: this.config.Ts.StringValue(typeSchema), + }; + } + + // Handle array of types + if (Array.isArray(typeSchema)) { + const typeUnion = this.config.Ts.UnionType( + typeSchema.map((type) => this.config.Ts.StringValue(type)), + ); + + return { + ...(typeof this.schema === "object" && !Array.isArray(this.schema) + ? this.schema + : {}), + $schemaPath: this.schemaPath.slice(), + $parsedSchema: true, + schemaType: SCHEMA_TYPES.JSONLD_TYPE, + type: SCHEMA_TYPES.PRIMITIVE, + typeIdentifier: this.config.Ts.Keyword.String, + name: this.typeName || "JsonLdType", + description: `JSON-LD Types: ${typeSchema.join(", ")}`, + content: typeUnion, + }; + } + + // Handle object with enum-like structure + if ( + typeof typeSchema === "object" && + typeSchema !== null && + typeSchema.enum + ) { + const enumValues = typeSchema.enum.map((value) => + this.config.Ts.StringValue(value), + ); + + return { + ...(typeof this.schema === "object" && !Array.isArray(this.schema) + ? this.schema + : {}), + $schemaPath: this.schemaPath.slice(), + $parsedSchema: true, + schemaType: SCHEMA_TYPES.JSONLD_TYPE, + // A JSON-LD type resolves to a string-literal union alias, not an + // enum declaration — claiming `ENUM` here would route the schema to + // the enum formatter, which expects `content` to be a list of members. + type: SCHEMA_TYPES.PRIMITIVE, + typeIdentifier: this.config.Ts.Keyword.Type, + name: this.typeName || "JsonLdType", + description: this.schemaFormatters.formatDescription( + typeSchema.description || `JSON-LD Type enumeration`, + ), + content: this.config.Ts.UnionType(enumValues), + enum: typeSchema.enum, + }; + } + + // Fallback for complex type definitions + return { + $schemaPath: this.schemaPath.slice(), + $parsedSchema: true, + schemaType: SCHEMA_TYPES.JSONLD_TYPE, + type: SCHEMA_TYPES.PRIMITIVE, + typeIdentifier: this.config.Ts.Keyword.String, + name: this.typeName || "JsonLdType", + description: "JSON-LD Type", + content: this.config.Ts.Keyword.String, + }; + } +} diff --git a/src/schema-parser/base-schema-parsers/jsonld-utils.ts b/src/schema-parser/base-schema-parsers/jsonld-utils.ts new file mode 100644 index 000000000..62ff3396a --- /dev/null +++ b/src/schema-parser/base-schema-parsers/jsonld-utils.ts @@ -0,0 +1,21 @@ +/** + * Names declared by `templates/base/jsonld-utils.ejs`. Generated entities + * reference them, so the two have to stay in sync. + */ +export const JSONLD_UTILS_TYPES = { + entity: "JsonLdEntity", + context: "JsonLdContext", +} as const; + +/** + * Derives a TypeScript-friendly entity name from a JSON-LD `@type` value. + * + * - Schema.org and other URI-style types are reduced to their last path segment. + * - Bare names are PascalCased on first character. + */ +export function getEntityNameFromJsonLdType(type: string): string { + if (type.includes("/")) { + return type.split("/").pop() || "Entity"; + } + return type.charAt(0).toUpperCase() + type.slice(1); +} diff --git a/src/schema-parser/schema-formatters.ts b/src/schema-parser/schema-formatters.ts index 576bb0dd6..31df0897a 100644 --- a/src/schema-parser/schema-formatters.ts +++ b/src/schema-parser/schema-formatters.ts @@ -68,6 +68,12 @@ export class SchemaFormatters { $content: parsedSchema.content, }; }, + [SCHEMA_TYPES.JSONLD_ENTITY]: (parsedSchema) => + this.base[SCHEMA_TYPES.OBJECT](parsedSchema), + [SCHEMA_TYPES.JSONLD_TYPE]: (parsedSchema) => ({ + ...parsedSchema, + $content: parsedSchema.content, + }), }; inline = { [SCHEMA_TYPES.ENUM]: (parsedSchema) => { @@ -107,6 +113,16 @@ export class SchemaFormatters { ), }; }, + [SCHEMA_TYPES.JSONLD_ENTITY]: (parsedSchema) => + this.inline[SCHEMA_TYPES.OBJECT](parsedSchema), + [SCHEMA_TYPES.JSONLD_TYPE]: (parsedSchema) => ({ + ...parsedSchema, + typeIdentifier: this.config.Ts.Keyword.Type, + content: this.schemaUtils.safeAddNullToType( + parsedSchema, + parsedSchema.content, + ), + }), }; formatSchema = ( diff --git a/src/schema-parser/schema-parser.ts b/src/schema-parser/schema-parser.ts index b6306b8a0..a7b5ce784 100644 --- a/src/schema-parser/schema-parser.ts +++ b/src/schema-parser/schema-parser.ts @@ -11,6 +11,8 @@ import { ArraySchemaParser } from "./base-schema-parsers/array.js"; import { ComplexSchemaParser } from "./base-schema-parsers/complex.js"; import { DiscriminatorSchemaParser } from "./base-schema-parsers/discriminator.js"; import { EnumSchemaParser } from "./base-schema-parsers/enum.js"; +import { JsonLdEntitySchemaParser } from "./base-schema-parsers/jsonld-entity.js"; +import { JsonLdTypeSchemaParser } from "./base-schema-parsers/jsonld-type.js"; import { ObjectSchemaParser } from "./base-schema-parsers/object.js"; import { PrimitiveSchemaParser } from "./base-schema-parsers/primitive.js"; import { AllOfSchemaParser } from "./complex-schema-parsers/all-of.js"; @@ -173,6 +175,28 @@ export class SchemaParser { ); return schemaParser.parse(); }, + [SCHEMA_TYPES.JSONLD_ENTITY]: (schema, typeName) => { + const SchemaParser = + this.config.schemaParsers.jsonldEntity || JsonLdEntitySchemaParser; + const schemaParser = new SchemaParser( + this, + schema, + typeName, + this.schemaPath, + ); + return schemaParser.parse(); + }, + [SCHEMA_TYPES.JSONLD_TYPE]: (schema, typeName) => { + const SchemaParser = + this.config.schemaParsers.jsonldType || JsonLdTypeSchemaParser; + const schemaParser = new SchemaParser( + this, + schema, + typeName, + this.schemaPath, + ); + return schemaParser.parse(); + }, }; parseSchema = () => { diff --git a/src/schema-parser/schema-utils.ts b/src/schema-parser/schema-utils.ts index 9dec02642..22aba91d7 100644 --- a/src/schema-parser/schema-utils.ts +++ b/src/schema-parser/schema-utils.ts @@ -305,6 +305,11 @@ export class SchemaUtils { }; getInternalSchemaType = (schema) => { + // Check for JSON-LD specific schemas first + if (this.isJsonLdSchema(schema)) { + return this.getJsonLdSchemaType(schema); + } + if ( (schema.enum && schema.enum.length > 0) || (this.getEnumNames(schema) && this.getEnumNames(schema).length > 0) @@ -420,4 +425,38 @@ export class SchemaUtils { } } }; + + /** + * Checks if a schema opts in to JSON-LD handling. Detection is explicit: + * the schema must declare `x-jsonld: true` (or one of the `x-jsonld-*` + * metadata extensions) and the user must enable `jsonLdOptions.enabled`. + */ + isJsonLdSchema = (schema) => { + if (!this.config.jsonLdOptions?.enabled) return false; + if (!schema || typeof schema !== "object") return false; + + return Boolean( + schema["x-jsonld"] || + schema["x-jsonld-context"] || + schema["x-jsonld-type"] || + schema["x-jsonld-id"], + ); + }; + + /** + * Determines the specific JSON-LD schema type. Only called after + * `isJsonLdSchema` returns true. + */ + getJsonLdSchemaType = (schema) => { + if ( + schema["x-jsonld-type"] && + !schema.properties && + (typeof schema["x-jsonld-type"] === "string" || + Array.isArray(schema["x-jsonld-type"])) + ) { + return SCHEMA_TYPES.JSONLD_TYPE; + } + + return SCHEMA_TYPES.JSONLD_ENTITY; + }; } diff --git a/templates/base/data-contracts.ejs b/templates/base/data-contracts.ejs index 2f6c67126..8be827d1b 100644 --- a/templates/base/data-contracts.ejs +++ b/templates/base/data-contracts.ejs @@ -45,7 +45,27 @@ const dataContractTemplates = { type <%~ config.Ts.CodeGenKeyword.UtilRequiredKeys %> = Omit & Required> <% } %> +<% +/* Only entities are relocated to their own module — `jsonld-type` aliases are + ordinary type aliases and stay here alongside the rest of the contracts. */ +const isJsonLdEnabled = config.jsonLdOptions && config.jsonLdOptions.enabled; +const isJsonLdEntity = (contract) => + (contract.typeData && contract.typeData.schemaType) === 'jsonld-entity'; +const hasJsonLdEntities = isJsonLdEnabled && modelTypes.some(isJsonLdEntity); +%> +<% +/* In modular output the JSON-LD types live in their own files, but route + modules only ever import from `data-contracts`. Re-export them so those + imports keep resolving. */ +%> +<% if (hasJsonLdEntities && config.modular) { %> +export * from "./<%~ config.fileNames.jsonldEntity %>"; +<% if (config.jsonLdOptions.generateUtils) { %> +export * from "./<%~ config.fileNames.jsonldUtils %>"; +<% } %> +<% } %> <% for (const contract of modelTypes) { %> +<% if (isJsonLdEnabled && isJsonLdEntity(contract)) { continue; } %> <%~ includeFile('@base/data-contract-jsdoc.ejs', { ...it, data: { ...contract, ...contract.typeData } }) %> <%~ contract.internal ? '' : 'export'%> <%~ (dataContractTemplates[contract.typeIdentifier] || dataContractTemplates.type)(contract) %> diff --git a/templates/base/jsonld-entity-data-contract.ejs b/templates/base/jsonld-entity-data-contract.ejs new file mode 100644 index 000000000..b228edc8b --- /dev/null +++ b/templates/base/jsonld-entity-data-contract.ejs @@ -0,0 +1,26 @@ +<% +const { modelTypes, utils, config } = it; +const { formatDescription } = utils; + +const jsonldEntities = modelTypes.filter(contract => + contract.typeData?.schemaType === "jsonld-entity" +); + +/* `JsonLdEntity` is declared by the jsonld-utils template. Without it there is + nothing to extend, so entities are emitted as standalone interfaces. */ +const withUtils = config.jsonLdOptions.generateUtils; +const extendsClause = withUtils ? ' extends JsonLdEntity' : ''; +%> +<% if (withUtils && config.modular) { %> +import type { JsonLdContext, JsonLdEntity } from "./<%~ config.fileNames.jsonldUtils %>"; +<% } %> +<% for (const contract of jsonldEntities) { %> +/** <%~ formatDescription(contract.description || `JSON-LD Entity: ${contract.name}`) %> */ +export interface <%~ contract.name %><%~ extendsClause %> { + <% for (const field of contract.$content) { %> + <%~ includeFile('@base/object-field-jsdoc.ejs', { ...it, field }) %> + <%~ field.name %><%~ field.isRequired ? '' : '?' %>: <%~ field.value %><%~ field.isNullable ? ' | null' : ''%>; + <% } %> +} + +<% } %> diff --git a/templates/base/jsonld-utils.ejs b/templates/base/jsonld-utils.ejs new file mode 100644 index 000000000..e6871e97d --- /dev/null +++ b/templates/base/jsonld-utils.ejs @@ -0,0 +1,80 @@ +/** + * JSON-LD Utility Types and Interfaces + * Generated by swagger-typescript-api + */ + +/** + * Base interface for JSON-LD entities + */ +export interface JsonLdEntity { + /** JSON-LD context defining the meaning of terms */ + "@context"?: JsonLdContext; + /** JSON-LD type identifier */ + "@type"?: string | string[]; + /** JSON-LD identifier (IRI) */ + "@id"?: string; +} + +/** + * JSON-LD Context type + * Can be a string (URI), object (term mappings), or array of contexts + */ +export type JsonLdContext = + | string + | JsonLdContextObject + | (string | JsonLdContextObject)[]; + +/** + * JSON-LD Context object with term mappings + */ +export interface JsonLdContextObject { + [term: string]: string | JsonLdTermDefinition; +} + +/** + * JSON-LD Term Definition + */ +export interface JsonLdTermDefinition { + /** IRI associated with the term */ + "@id": string; + /** Type coercion for the term */ + "@type"?: string; + /** Container specification */ + "@container"?: "@list" | "@set" | "@language" | "@index" | "@id" | "@type"; +} + +/** + * JSON-LD Graph structure + */ +export interface JsonLdGraph { + "@context"?: JsonLdContext; + "@graph": JsonLdEntity[]; +} + +/** + * JSON-LD Node reference + */ +export interface JsonLdNodeReference { + "@id": string; +} + +/** + * JSON-LD Value object + */ +export interface JsonLdValue { + "@value": any; + "@type"?: string; + "@language"?: string; +} + +/** + * Utility type to extract JSON-LD properties + */ +export type JsonLdProperties = { + [K in keyof T]: K extends "@context" | "@type" | "@id" ? never : T[K]; +}; + +/** + * Utility type to make a type compatible with JSON-LD + */ +export type WithJsonLd = T & JsonLdEntity; \ No newline at end of file diff --git a/tests/spec/jsonld-basic/__snapshots__/basic.test.ts.snap b/tests/spec/jsonld-basic/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..5d4db0786 --- /dev/null +++ b/tests/spec/jsonld-basic/__snapshots__/basic.test.ts.snap @@ -0,0 +1,177 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`jsonld-basic > a property-less x-jsonld-type schema becomes a JSON-LD type alias 1`] = ` +"/** JSON-LD Type enumeration */ +export type PersonType = "Person" | "Patient"; +" +`; + +exports[`jsonld-basic > output is unchanged when JSON-LD is disabled (default) 1`] = ` +[ + { + "fileContent": "export interface Person { + /** JSON-LD context */ + "@context"?: object; + /** JSON-LD type */ + "@type": "Person" | "https://schema.org/Person"; + /** + * Person identifier + * @format uri + */ + "@id"?: string; + /** Person's name */ + name: string; + /** + * Person's email + * @format email + */ + email?: string; + /** + * Person's birth date + * @format date + */ + birthDate?: string; +} + +export interface Organization { + "@context"?: "https://schema.org/"; + "@type": "Organization"; + /** @format uri */ + "@id"?: string; + name: string; + /** @format uri */ + url?: string; + employees?: Person[]; +} +", + "fileExtension": ".ts", + "fileName": "Api", + }, +] +`; + +exports[`jsonld-basic > single file output inlines entities and utility types 1`] = ` +[ + { + "fileContent": "/** JSON-LD Entity: Person */ +export interface Person extends JsonLdEntity { + /** JSON-LD context defining the meaning of terms */ + "@context"?: JsonLdContext; + /** JSON-LD type identifier */ + "@type": "https://schema.org/Person"; + /** JSON-LD identifier (IRI) */ + "@id"?: string; + /** Person's name */ + name: string; + /** + * Person's email + * @format email + */ + email?: string; + /** + * Person's birth date + * @format date + */ + birthDate?: string; +} + +/** JSON-LD Entity: Organization */ +export interface Organization extends JsonLdEntity { + /** JSON-LD context defining the meaning of terms */ + "@context"?: "https://schema.org/"; + /** JSON-LD type identifier */ + "@type": "Organization"; + /** JSON-LD identifier (IRI) */ + "@id"?: string; + name: string; + /** @format uri */ + url?: string; + employees?: Person[]; +} + +/** + * JSON-LD Utility Types and Interfaces + * Generated by swagger-typescript-api + */ + +/** + * Base interface for JSON-LD entities + */ +export interface JsonLdEntity { + /** JSON-LD context defining the meaning of terms */ + "@context"?: JsonLdContext; + /** JSON-LD type identifier */ + "@type"?: string | string[]; + /** JSON-LD identifier (IRI) */ + "@id"?: string; +} + +/** + * JSON-LD Context type + * Can be a string (URI), object (term mappings), or array of contexts + */ +export type JsonLdContext = + | string + | JsonLdContextObject + | (string | JsonLdContextObject)[]; + +/** + * JSON-LD Context object with term mappings + */ +export interface JsonLdContextObject { + [term: string]: string | JsonLdTermDefinition; +} + +/** + * JSON-LD Term Definition + */ +export interface JsonLdTermDefinition { + /** IRI associated with the term */ + "@id": string; + /** Type coercion for the term */ + "@type"?: string; + /** Container specification */ + "@container"?: "@list" | "@set" | "@language" | "@index" | "@id" | "@type"; +} + +/** + * JSON-LD Graph structure + */ +export interface JsonLdGraph { + "@context"?: JsonLdContext; + "@graph": JsonLdEntity[]; +} + +/** + * JSON-LD Node reference + */ +export interface JsonLdNodeReference { + "@id": string; +} + +/** + * JSON-LD Value object + */ +export interface JsonLdValue { + "@value": any; + "@type"?: string; + "@language"?: string; +} + +/** + * Utility type to extract JSON-LD properties + */ +export type JsonLdProperties = { + [K in keyof T]: K extends "@context" | "@type" | "@id" ? never : T[K]; +}; + +/** + * Utility type to make a type compatible with JSON-LD + */ +export type WithJsonLd = T & JsonLdEntity; +", + "fileExtension": ".ts", + "fileName": "Api", + }, +] +`; diff --git a/tests/spec/jsonld-basic/basic.test.ts b/tests/spec/jsonld-basic/basic.test.ts new file mode 100644 index 000000000..64719bc50 --- /dev/null +++ b/tests/spec/jsonld-basic/basic.test.ts @@ -0,0 +1,137 @@ +import * as path from "node:path"; +import { describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +const SCHEMA = path.resolve(import.meta.dirname, "schema.json"); + +const fileNames = (files: { fileName: string }[]) => + files.map((file) => file.fileName); + +const contentOf = ( + files: { fileName: string; fileContent: string }[], + fileName: string, +) => files.find((file) => file.fileName === fileName)?.fileContent; + +describe("jsonld-basic", () => { + test("single file output inlines entities and utility types", async () => { + const { files } = await generateApi({ + input: SCHEMA, + output: false, + generateClient: false, + jsonLdOptions: { enabled: true }, + }); + + expect(files).toMatchSnapshot(); + }); + + test("output is unchanged when JSON-LD is disabled (default)", async () => { + const { files } = await generateApi({ + input: SCHEMA, + output: false, + generateClient: false, + }); + + expect(files.some((file) => file.fileName.startsWith("jsonld-"))).toBe( + false, + ); + expect(files).toMatchSnapshot(); + }); + + test("modular output emits jsonld files and keeps data-contracts importable", async () => { + const { files } = await generateApi({ + input: SCHEMA, + output: false, + modular: true, + generateClient: true, + jsonLdOptions: { enabled: true }, + }); + + expect(fileNames(files)).toContain("data-contracts"); + expect(fileNames(files)).toContain("jsonld-entity"); + expect(fileNames(files)).toContain("jsonld-utils"); + + // Route modules import their models from `data-contracts` only. + expect(contentOf(files, "People")).toContain( + 'import { Person } from "./data-contracts"', + ); + expect(contentOf(files, "data-contracts")).toContain( + 'export * from "./jsonld-entity"', + ); + + // Entities extend `JsonLdEntity`, which lives in a different module. + expect(contentOf(files, "jsonld-entity")).toContain( + 'from "./jsonld-utils"', + ); + expect(contentOf(files, "jsonld-entity")).toContain( + "export interface Person extends JsonLdEntity {", + ); + }); + + test("generateUtils: false drops the utils module and the extends clause", async () => { + const { files } = await generateApi({ + input: SCHEMA, + output: false, + modular: true, + generateClient: false, + jsonLdOptions: { enabled: true, generateUtils: false }, + }); + + expect(fileNames(files)).not.toContain("jsonld-utils"); + + const entities = contentOf(files, "jsonld-entity"); + expect(entities).toContain("export interface Person {"); + expect(entities).not.toContain("JsonLdEntity"); + }); + + test("non-JSON-LD schemas stay in data-contracts", async () => { + const { files } = await generateApi({ + spec: { + openapi: "3.0.0", + info: { title: "mixed", version: "1.0.0" }, + paths: {}, + components: { + schemas: { + Plain: { type: "object", properties: { a: { type: "string" } } }, + Thing: { + type: "object", + "x-jsonld": true, + "x-jsonld-type": "https://schema.org/Thing", + properties: { b: { type: "string" } }, + }, + }, + }, + } as never, + output: false, + generateClient: false, + jsonLdOptions: { enabled: true }, + }); + + const content = contentOf(files, "Api"); + expect(content).toContain("export interface Plain {"); + expect(content).toContain("export interface Thing extends JsonLdEntity {"); + }); + + test("a property-less x-jsonld-type schema becomes a JSON-LD type alias", async () => { + const { files } = await generateApi({ + spec: { + openapi: "3.0.0", + info: { title: "type-only", version: "1.0.0" }, + paths: {}, + components: { + schemas: { + PersonType: { + type: "string", + "x-jsonld-type": "https://schema.org/Person", + enum: ["Person", "Patient"], + }, + }, + }, + } as never, + output: false, + generateClient: false, + jsonLdOptions: { enabled: true }, + }); + + expect(contentOf(files, "Api")).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/jsonld-basic/schema.json b/tests/spec/jsonld-basic/schema.json new file mode 100644 index 000000000..7b1bfdca1 --- /dev/null +++ b/tests/spec/jsonld-basic/schema.json @@ -0,0 +1,155 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "JSON-LD Test API", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Person": { + "type": "object", + "x-jsonld": true, + "x-jsonld-context": { + "name": "https://schema.org/name", + "email": "https://schema.org/email", + "birthDate": { + "@id": "https://schema.org/birthDate", + "@type": "xsd:date" + } + }, + "x-jsonld-type": "https://schema.org/Person", + "properties": { + "@context": { + "type": "object", + "description": "JSON-LD context" + }, + "@type": { + "type": "string", + "enum": ["Person", "https://schema.org/Person"], + "description": "JSON-LD type" + }, + "@id": { + "type": "string", + "format": "uri", + "description": "Person identifier" + }, + "name": { + "type": "string", + "description": "Person's name" + }, + "email": { + "type": "string", + "format": "email", + "description": "Person's email" + }, + "birthDate": { + "type": "string", + "format": "date", + "description": "Person's birth date" + } + }, + "required": ["@type", "name"] + }, + "Organization": { + "type": "object", + "x-jsonld": true, + "x-jsonld-context": "https://schema.org/", + "x-jsonld-type": "Organization", + "properties": { + "@context": { + "type": "string", + "const": "https://schema.org/" + }, + "@type": { + "type": "string", + "const": "Organization" + }, + "@id": { + "type": "string", + "format": "uri" + }, + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "employees": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + }, + "required": ["@type", "name"] + } + } + }, + "paths": { + "/people": { + "get": { + "summary": "Get people", + "responses": { + "200": { + "description": "List of people", + "content": { + "application/ld+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + }, + "post": { + "summary": "Create person", + "requestBody": { + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + }, + "responses": { + "201": { + "description": "Person created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Person" + } + } + } + } + } + } + }, + "/organizations": { + "get": { + "summary": "Get organizations", + "responses": { + "200": { + "description": "List of organizations", + "content": { + "application/ld+json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Organization" + } + } + } + } + } + } + } + } + } +} diff --git a/types/index.ts b/types/index.ts index 18ad859ea..820c72ba9 100644 --- a/types/index.ts +++ b/types/index.ts @@ -695,6 +695,23 @@ export interface GenerateApiConfiguration { primitive?: MonoSchemaParser; discriminator?: MonoSchemaParser; array?: MonoSchemaParser; + jsonldEntity?: MonoSchemaParser; + jsonldType?: MonoSchemaParser; + }; + /** + * JSON-LD specific configuration options. + * + * Disabled by default. When enabled, schemas declaring the `x-jsonld` + * extension (or one of `x-jsonld-context` / `x-jsonld-type` / + * `x-jsonld-id`) are emitted as JSON-LD entities/contexts in addition to + * the regular data contracts. Schemas without the extension are + * unaffected. + */ + jsonLdOptions: { + /** Enable JSON-LD support (default: `false`). */ + enabled?: boolean; + /** Emit shared utility types (`JsonLdEntity`, `JsonLdGraph`, ...) (default: `true`). */ + generateUtils?: boolean; }; /** internal options for templates */ internalTemplateOptions: { @@ -708,6 +725,8 @@ export interface GenerateApiConfiguration { routeTypes: string; httpClient: string; outOfModuleApi: string; + jsonldEntity: string; + jsonldUtils: string; }; /** Record */ templatesToRender: { @@ -721,6 +740,8 @@ export interface GenerateApiConfiguration { typeDataContract: string; enumDataContract: string; objectFieldJsDoc: string; + jsonldEntityDataContract: string; + jsonldUtils: string; }; /** map of duplicate route names */ routeNameDuplicatesMap: Map;