diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 66b1c4104..7d3436fae 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -34,6 +34,7 @@ import { descriptionAttributeProducer } from "../attributes/Description.js"; import { enumValuesAttributeProducer } from "../attributes/EnumValues.js"; import { StringTypes } from "../attributes/StringTypes.js"; import { + TypeAttributeKind, type TypeAttributes, combineTypeAttributes, emptyTypeAttributes, @@ -668,6 +669,53 @@ export type JSONSchemaAttributeProducer = ( unionCases: JSONSchema[] | undefined, ) => JSONSchemaAttributes | undefined; +// `forType` attributes from custom producers belong to one schema occurrence. +// Keep primitive types carrying them out of the intern pool so the attributes +// cannot leak to other occurrences of the same primitive kind. +class ForTypeIdentityTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("forTypeIdentity"); + } + + public combine(_attributes: true[]): true { + return true; + } + + public makeInferred(_attribute: true): true { + return true; + } + + public requiresUniqueIdentity(_attribute: true): boolean { + return true; + } +} + +const forTypeIdentityTypeAttributeKind = new ForTypeIdentityTypeAttributeKind(); + +function makeForTypeAttributesUnique( + attributes: TypeAttributes, +): TypeAttributes { + if (attributes.size === 0) return attributes; + return combineTypeAttributes( + "union", + attributes, + forTypeIdentityTypeAttributeKind.makeAttributes(true), + ); +} + +function makeCustomAttributeProducer( + producer: JSONSchemaAttributeProducer, +): JSONSchemaAttributeProducer { + return (schema, canonicalRef, types, unionCases) => { + const attributes = producer(schema, canonicalRef, types, unionCases); + if (attributes?.forType === undefined) return attributes; + return { + ...attributes, + forType: makeForTypeAttributesUnique(attributes.forType), + }; + }; +} + function typeKindForJSONSchemaFormat( format: string, ): TransformedStringTypeKind | undefined { @@ -1576,7 +1624,7 @@ export class JSONSchemaInput implements Input { minMaxLengthAttributeProducer, minMaxItemsAttributeProducer, patternAttributeProducer, - ].concat(additionalAttributeProducers); + ].concat(additionalAttributeProducers.map(makeCustomAttributeProducer)); } public get needIR(): boolean { diff --git a/test/unit/json-schema-custom-attributes.test.ts b/test/unit/json-schema-custom-attributes.test.ts new file mode 100644 index 000000000..9006e7918 --- /dev/null +++ b/test/unit/json-schema-custom-attributes.test.ts @@ -0,0 +1,93 @@ +import { + FetchingJSONSchemaStore, + InputData, + type JSONSchema, + JSONSchemaInput, + type Type, + TypeAttributeKind, + quicktype, +} from "quicktype-core"; +import { expect, test } from "vitest"; + +class DeprecatedTypeAttributeKind extends TypeAttributeKind { + public constructor() { + super("deprecated"); + } + + public combine(attributes: boolean[]): boolean { + return attributes.some((deprecated) => deprecated); + } + + public makeInferred(): boolean { + return false; + } + + public addToSchema( + schema: { [name: string]: unknown }, + _type: Type, + deprecated: boolean, + ): void { + if (deprecated) schema.deprecated = true; + } +} + +const deprecatedTypeAttributeKind = new DeprecatedTypeAttributeKind(); + +function deprecatedAttributeProducer(schema: JSONSchema) { + if (typeof schema !== "object" || schema === null) return undefined; + + return { + forType: deprecatedTypeAttributeKind.makeAttributes( + schema.deprecated === true, + ), + }; +} + +test("a forType custom attribute does not leak between primitive schema nodes (issue #1268)", async () => { + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore(), [ + deprecatedAttributeProducer, + ]); + await schemaInput.addSource({ + name: "Player", + schema: JSON.stringify({ + type: "object", + properties: { + externalId: { type: "string" }, + participant: { $ref: "#/definitions/Participant" }, + }, + required: ["externalId", "participant"], + definitions: { + Participant: { + type: "object", + properties: { + emailAddress: { type: "string", deprecated: true }, + phoneNumber: { type: "string", deprecated: true }, + }, + required: ["emailAddress", "phoneNumber"], + }, + }, + }), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "schema" }); + const output = JSON.parse(result.lines.join("\n")) as { + definitions: Record< + string, + { properties: Record> } + >; + }; + const playerProperties = output.definitions.Player.properties; + const participantProperties = output.definitions.Participant.properties; + + expect(playerProperties.externalId).not.toHaveProperty("deprecated"); + expect(participantProperties.emailAddress).toHaveProperty( + "deprecated", + true, + ); + expect(participantProperties.phoneNumber).toHaveProperty( + "deprecated", + true, + ); +});