From bfbcfd5623d5bb269a9d42858edf69973f574769 Mon Sep 17 00:00:00 2001 From: Alexander Mattoni <5110855+mattoni@users.noreply.github.com> Date: Fri, 10 Jul 2026 07:50:06 +0000 Subject: [PATCH] improve downconvert util --- util/downconvert.ts | 831 ++++++++++++++++++++++++++++++++------------ 1 file changed, 616 insertions(+), 215 deletions(-) diff --git a/util/downconvert.ts b/util/downconvert.ts index c04d21b0..461d8ef3 100644 --- a/util/downconvert.ts +++ b/util/downconvert.ts @@ -2,7 +2,7 @@ type OpenAPISpec31 = { openapi: "3.1.0"; components?: ComponentsObject; paths?: Record; - webhooks?: Record; + webhooks?: Record; [key: string]: any; }; @@ -15,30 +15,24 @@ type OpenAPISpec30 = { type ComponentsObject = { schemas?: Record; + parameters?: Record; + headers?: Record; + requestBodies?: Record; + responses?: Record; + callbacks?: Record; + pathItems?: Record; [key: string]: any; }; type PathItemObject = { - [method: string]: OperationObject; -}; - -type OperationObject = { - requestBody?: { - content?: ContentObject; - [key: string]: any; - }; - responses?: Record; - parameters?: Array<{ - schema?: SchemaObject; - [key: string]: any; - }>; - [key: string]: any; + [method: string]: any; }; type ContentObject = Record; type MediaTypeObject = { schema?: SchemaObject; + encoding?: Record; [key: string]: any; }; @@ -48,6 +42,7 @@ type SchemaObject = { oneOf?: SchemaObject[]; anyOf?: SchemaObject[]; allOf?: SchemaObject[]; + not?: SchemaObject; $ref?: string; properties?: Record; items?: SchemaObject; @@ -61,6 +56,7 @@ class Schema { oneOf?: Schema[]; anyOf?: Schema[]; allOf?: Schema[]; + not?: Schema; $ref?: string; properties?: Record; items?: Schema; @@ -88,16 +84,24 @@ class Schema { if (schema.allOf) { this.allOf = schema.allOf.map((entry) => new Schema(entry)); } + if (schema.not) { + this.not = new Schema(schema.not); + } if (schema.properties) { this.properties = {}; for (const key in schema.properties) { this.properties[key] = new Schema(schema.properties[key]); } } - if (schema.items) { + // `items` is a single Schema Object in 3.0/3.1. Guard against the legacy + // tuple form (an array) so we never call `new Schema([...])`. + if (schema.items && !Array.isArray(schema.items)) { this.items = new Schema(schema.items); } - if (schema.additionalProperties) { + if ( + schema.additionalProperties && + typeof schema.additionalProperties === "object" + ) { this.additionalProperties = new Schema(schema.additionalProperties); } } @@ -106,36 +110,79 @@ class Schema { handleTypeArray(types: string[]) { if (types.includes("null")) { this.nullable = true; - this.type = types.filter((type) => type !== "null")[0] || "object"; + + const nonNull = types.filter((type) => type !== "null"); + + // A single non-null type downconverts cleanly. More than one non-null + // type has no faithful OpenAPI 3.0 scalar `type`, so keep the first and + // warn rather than silently dropping the rest. + if (nonNull.length > 1) { + console.warn( + `[downconvert] multi-type array ${JSON.stringify( + types + )} cannot be represented in 3.0; keeping "${nonNull[0]}" only` + ); + } + + this.type = nonNull[0] || "object"; } else { + if (types.length > 1) { + console.warn( + `[downconvert] multi-type array ${JSON.stringify( + types + )} cannot be represented in 3.0; keeping "${types[0]}" only` + ); + } + this.type = types[0]; } } - // Method to adjust the schema for OpenAPI 3.0.3 compatibility + // Adjust the schema (and everything nested in it) for OpenAPI 3.0.1. adjust(schemaMap: Record) { this.removeUnsupportedProperties(); + this.deduplicateRequired(); this.handleConstAsEnum(); this.handleNullType(); - this.handleAnyOfWithNull(); - this.handleOneOfWithNull(); + this.collapseNullUnion("anyOf"); + this.collapseNullUnion("oneOf"); this.flattenIdenticalReferences(schemaMap); + this.normalizeRefSiblings(); this.adjustNestedSchemas(schemaMap); } - // Remove unsupported properties in OpenAPI 3.0.3 + // Remove keywords that are not valid in OpenAPI 3.0.1 removeUnsupportedProperties() { + // `definition` is not an OpenAPI keyword. The source spec uses it in a few + // places as a stray doc string (a typo for `description`). Promote it so the + // text is preserved and the node stays valid 3.0, rather than dropping it. + if (typeof this.definition === "string") { + if (this.description === undefined) { + this.description = this.definition; + } + delete this.definition; + } + delete this.$schema; delete this.$id; delete this.$comment; delete this.unevaluatedProperties; + delete this.unevaluatedItems; delete this.patternProperties; delete this.contentMediaType; delete this.contentEncoding; - delete this.examples; // Remove the `examples` property + delete this.examples; // 3.0 uses singular `example` } - // Handle `const` by converting it to `enum` + // A `required` array must not contain duplicates in strict 3.0. Requiring a + // property twice is equivalent to requiring it once, so de-duping is lossless. + deduplicateRequired() { + if (Array.isArray(this.required)) { + this.required = Array.from(new Set(this.required)); + } + } + + // Handle `const` by converting it to a single-value `enum` handleConstAsEnum() { if (this.const !== undefined) { this.enum = [this.const]; @@ -143,7 +190,7 @@ class Schema { } } - // 🔹 Convert `type: "null"` to `type: "object", nullable: true` + // Convert a standalone `type: "null"` to `type: "object", nullable: true` handleNullType() { if (this.type === "null") { this.type = "object"; @@ -151,137 +198,156 @@ class Schema { } } - // Handle `anyOf` containing `null` and a reference or another type - handleAnyOfWithNull() { - if (this.anyOf) { - let hasNullType = false; - let nonNullEntry: Schema | null = null; + // Collapse an `oneOf`/`anyOf` union that contains a `type: "null"` member + // into an OpenAPI 3.0 nullable form. + // + // - no null member -> leave untouched (flatten may handle it) + // - discriminated union + null -> keep the branch list, only drop null + // - >= 2 real branches + null -> preserve the union, only drop null + // - exactly 1 real branch ($ref) -> allOf-wrap the ref + nullable + // (a $ref cannot carry a nullable sibling in 3.0) + // - exactly 1 real branch (inline) -> hoist the branch onto this schema + nullable + // - 0 real branches -> just a nullable, untyped schema + collapseNullUnion(keyword: "oneOf" | "anyOf") { + const members = this[keyword] as Schema[] | undefined; + if (!members) { + return; + } - // Check for `null` type in `anyOf` - this.anyOf.forEach((entry) => { - if (entry.type === "null") { - hasNullType = true; - } else { - nonNullEntry = entry; - } - }); + const hasNull = members.some((member) => member.type === "null"); + if (!hasNull) { + return; + } - // If both `null` and another type are present, make the schema nullable and use `allOf` for reference - if (hasNullType && nonNullEntry) { - this.nullable = true; + const nonNull = members.filter((member) => member.type !== "null"); + this.nullable = true; - if ((nonNullEntry as Schema).$ref) { - // Convert `anyOf` to `allOf` with the reference - this.allOf = [{ $ref: (nonNullEntry as Schema).$ref } as Schema]; - } else { - // Otherwise, retain all properties of the non-null entry - Object.assign(this, nonNullEntry); + // Discriminated unions must keep their full branch list for the + // discriminator to stay valid; only strip the null member. + if (this.discriminator) { + this[keyword] = nonNull; + return; + } + + // Two or more real branches: preserve the union, drop only the null member. + if (nonNull.length >= 2) { + this[keyword] = nonNull; + return; + } + + // Exactly one (or zero) real branch: collapse it into this schema. + const only = nonNull[0]; + delete this[keyword]; + + if (only && only.$ref) { + // A `$ref` alongside `nullable` is ignored by 3.0 tooling, so express the + // reference through `allOf`, where the sibling `nullable` is honored. + this.allOf = [{ $ref: only.$ref } as Schema]; + return; + } + + if (only) { + // Retain the non-null branch's members (properties, required, enum, + // description, etc.), then set the type explicitly. + for (const key in only) { + if (key !== "type" && key !== "nullable") { + (this as any)[key] = only[key]; } + } - delete this.anyOf; + this.type = only.type; + + if (only.enum) { + this.enum = only.enum; } } } - // Handle `oneOf` containing `null` and another type - handleOneOfWithNull() { - // Do not modify `oneOf` if a discriminator is present + // Flatten `oneOf`/`anyOf` only when every branch resolves to the *same + // scalar* type. Object/array branches (or refs to them) are real, distinct + // schemas and must never be collapsed to a bare `type`, or the union is lost. + flattenIdenticalReferences(schemaMap: Record) { if (this.discriminator) { return; } - if (this.oneOf) { + const SCALAR_TYPES = new Set(["string", "number", "integer", "boolean"]); + const keywords = ["oneOf", "anyOf"] as const; + + keywords.forEach((keyword) => { + if (!this[keyword]) { + return; + } + + const entries = this[keyword]!; + let sharedType: string | null = null; let hasNullType = false; - let nonNullEntry: Schema | null = null; + let canFlatten = true; - // Iterate through the `oneOf` entries - this.oneOf.forEach((entry) => { + entries.forEach((entry) => { if (entry.type === "null") { hasNullType = true; - } else { - nonNullEntry = entry; + return; } - }); - - // If both `null` and another type are present - if (hasNullType && nonNullEntry) { - // Set nullable to true - this.nullable = true; - // Retain all properties of the non-null entry (type, enum, description, etc.) - for (const key in nonNullEntry as Schema) { - if (key !== "type" && key !== "nullable") { - (this as any)[key] = nonNullEntry[key]; + let entryType: string | undefined; + if (entry.$ref) { + const refSchemaName = entry.$ref.split("/").pop()!; + const refSchema = schemaMap[refSchemaName]; + if (refSchema && typeof refSchema.type === "string") { + entryType = refSchema.type; } + } else if (typeof entry.type === "string") { + entryType = entry.type; } - // Assign the type of the non-null entry - this.type = (nonNullEntry as Schema).type; + // Non-scalar, unresolved, or typeless branch -> keep the union intact. + if (!entryType || !SCALAR_TYPES.has(entryType)) { + canFlatten = false; + return; + } - // If there's an enum, retain it - if ((nonNullEntry as Schema).enum) { - this.enum = (nonNullEntry as Schema).enum; + if (sharedType === null) { + sharedType = entryType; + } else if (sharedType !== entryType) { + canFlatten = false; } + }); - delete this.oneOf; + if (canFlatten && sharedType) { + this.type = sharedType; + if (hasNullType) { + this.nullable = true; + } + delete this[keyword]; } - } + }); } - // Flatten `oneOf` or `anyOf` if all `$ref` point to the same primitive type - flattenIdenticalReferences(schemaMap: Record) { - // Do not modify `oneOf` or `anyOf` if a discriminator is present - if (this.discriminator) { + // In OpenAPI 3.0 a `$ref` may not carry sibling keywords (unlike 3.1). When a + // node has both a `$ref` and siblings (description, minLength, etc.), move the + // reference into an `allOf` so the siblings are retained and the node is valid + // 3.0. A bare `{ $ref }` is left untouched. + normalizeRefSiblings() { + if (!this.$ref) { return; } - const keywords = ["oneOf", "anyOf"] as const; - - keywords.forEach((keyword) => { - if (this[keyword]) { - const entries = this[keyword]!; - let primitiveType: string | null = null; - let hasNullType = false; - - // Check each entry in `oneOf` or `anyOf` - entries.forEach((entry) => { - if (entry.$ref) { - const refSchemaName = entry.$ref.split("/").pop()!; - const refSchema = schemaMap[refSchemaName]; - - if (refSchema) { - if (typeof refSchema.type === "string") { - if (!primitiveType) { - primitiveType = refSchema.type; - } else if (primitiveType !== refSchema.type) { - primitiveType = null; // Different types found, cannot flatten - } - } - } - } else if (entry.type === "null") { - hasNullType = true; - } else if (typeof entry.type === "string") { - if (!primitiveType) { - primitiveType = entry.type; - } else if (primitiveType !== entry.type) { - primitiveType = null; // Different types found, cannot flatten - } - } - }); + const siblingKeys = Object.keys(this).filter( + (key) => key !== "$ref" && this[key] !== undefined + ); + if (siblingKeys.length === 0) { + return; + } - // If all references point to the same primitive type - if (primitiveType) { - this.type = primitiveType; - if (hasNullType) { - this.nullable = true; - } - delete this[keyword]; - } - } - }); + const ref = this.$ref; + const existingAllOf = Array.isArray(this.allOf) ? this.allOf : []; + delete this.$ref; + delete this.allOf; + this.allOf = [{ $ref: ref } as Schema, ...existingAllOf]; } - // Adjust nested schemas recursively + // Recurse into every sub-schema position adjustNestedSchemas(schemaMap: Record) { if (this.properties) { Object.values(this.properties).forEach((property) => { @@ -293,18 +359,20 @@ class Schema { this.items.adjust(schemaMap); } - if (this.additionalProperties) { + if (this.additionalProperties instanceof Schema) { this.additionalProperties.adjust(schemaMap); } - // Recursively adjust oneOf, anyOf, allOf entries + if (this.not) { + this.not.adjust(schemaMap); + } + const keywords = ["oneOf", "anyOf", "allOf"] as const; keywords.forEach((keyword) => { if (this[keyword]) { this[keyword] = this[keyword]!.map((entry) => { - // Ensure entry is converted to a Schema instance before adjustment const schemaEntry = - entry instanceof Schema ? entry : new Schema(entry); + entry instanceof Schema ? entry : new Schema(entry); schemaEntry.adjust(schemaMap); return schemaEntry; }); @@ -313,78 +381,415 @@ class Schema { } } -// Function to downconvert OpenAPI 3.1 to 3.0.3 +// --------------------------------------------------------------------------- +// Position-aware walk: normalize a Schema Object everywhere one can appear. +// --------------------------------------------------------------------------- + +const HTTP_METHODS = [ + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", +] as const; + +function isObject(value: any): boolean { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function normalizeSchemaAt( + container: any, + key: string, + schemaMap: Record +) { + const raw = container[key]; + if (isObject(raw)) { + const schema = raw instanceof Schema ? raw : new Schema(raw); + schema.adjust(schemaMap); + container[key] = schema; + } +} + +function walkContent(content: any, schemaMap: Record) { + if (!isObject(content)) { + return; + } + + for (const mediaType of Object.values(content)) { + if (!isObject(mediaType)) { + continue; + } + + if (mediaType.schema) { + normalizeSchemaAt(mediaType, "schema", schemaMap); + } + + if (isObject(mediaType.encoding)) { + for (const encoding of Object.values(mediaType.encoding)) { + if (isObject(encoding) && encoding.headers) { + walkHeaders(encoding.headers, schemaMap); + } + } + } + } +} + +function walkHeaders(headers: any, schemaMap: Record) { + if (!isObject(headers)) { + return; + } + + for (const header of Object.values(headers)) { + if (!isObject(header)) { + continue; + } + + if (header.schema) { + normalizeSchemaAt(header, "schema", schemaMap); + } + + if (header.content) { + walkContent(header.content, schemaMap); + } + } +} + +function walkParameters(parameters: any, schemaMap: Record) { + if (!parameters) { + return; + } + + const list = Array.isArray(parameters) + ? parameters + : Object.values(parameters); + + for (const parameter of list) { + if (!isObject(parameter)) { + continue; + } + + if (parameter.schema) { + normalizeSchemaAt(parameter, "schema", schemaMap); + } + + if (parameter.content) { + walkContent(parameter.content, schemaMap); + } + } +} + +function walkResponses(responses: any, schemaMap: Record) { + if (!isObject(responses)) { + return; + } + + for (const response of Object.values(responses)) { + if (!isObject(response)) { + continue; + } + + if (response.content) { + walkContent(response.content, schemaMap); + } + + if (response.headers) { + walkHeaders(response.headers, schemaMap); + } + } +} + +function walkRequestBody(requestBody: any, schemaMap: Record) { + if (isObject(requestBody) && requestBody.content) { + walkContent(requestBody.content, schemaMap); + } +} + +function walkCallbacks(callbacks: any, schemaMap: Record) { + if (!isObject(callbacks)) { + return; + } + + for (const callback of Object.values(callbacks)) { + if (!isObject(callback)) { + continue; + } + + for (const pathItem of Object.values(callback)) { + walkPathItem(pathItem, schemaMap); + } + } +} + +function walkOperation(operation: any, schemaMap: Record) { + if (!isObject(operation)) { + return; + } + + if (operation.parameters) { + walkParameters(operation.parameters, schemaMap); + } + + if (operation.requestBody) { + walkRequestBody(operation.requestBody, schemaMap); + } + + if (operation.responses) { + walkResponses(operation.responses, schemaMap); + } + + if (operation.callbacks) { + walkCallbacks(operation.callbacks, schemaMap); + } +} + +function walkPathItem(pathItem: any, schemaMap: Record) { + if (!isObject(pathItem)) { + return; + } + + if (pathItem.parameters) { + walkParameters(pathItem.parameters, schemaMap); + } + + for (const method of HTTP_METHODS) { + if (pathItem[method]) { + walkOperation(pathItem[method], schemaMap); + } + } +} + +// --------------------------------------------------------------------------- +// Hoist complex inline oneOf/anyOf members into named components. +// +// oapi-codegen auto-names an *inline* union member `` (e.g. +// `StackSpecContainerConfigRuntimeCapabilities0`). With the pervasive +// `oneOf: [, { $ref: StackVariable }]` idiom, two different inline +// members can reduce to the same generated name with different definitions, +// and codegen aborts: "duplicate typename ... can't auto-rename". A `$ref` +// member is instead named after its component, which we control and keep +// globally unique. Moving each complex inline member into its own component +// removes the entire collision class while preserving the union (unlike +// collapsing it to a bare `type`, which silently discards the branches). +// --------------------------------------------------------------------------- + +const UNION_SEPARATORS = new Set("-#@!$&=.+:;_~ (){}[]".split("")); + +// Mirrors oapi-codegen's ToCamelCase over an underscore-joined path, so the +// hoisted component name matches the name codegen would otherwise have derived +// (just promoted to a real, unique component). +function pascalConcat(parts: string[]): string { + const joined = parts.join("_"); + let result = ""; + let capitalizeNext = true; + for (const ch of joined) { + if (ch >= "A" && ch <= "Z") { + result += ch; + } else if (ch >= "0" && ch <= "9") { + result += ch; + } else if (ch >= "a" && ch <= "z") { + result += capitalizeNext ? ch.toUpperCase() : ch; + } + capitalizeNext = UNION_SEPARATORS.has(ch); + } + return result; +} + +// A member gets its own generated Go type only when it is a non-$ref, non-null +// schema that is an object, array, enum, or nested union. Scalars are rendered +// inline by oapi-codegen and never collide, so they are left alone. +function memberGeneratesNamedType(member: any): boolean { + if (!isObject(member) || member.$ref) { + return false; + } + if (member.type === "null") { + return false; + } + if (member.oneOf || member.anyOf) { + return true; + } + if (member.enum) { + return true; + } + if (member.type === "array") { + return true; + } + if (member.type === "object" && (member.properties || member.additionalProperties)) { + return true; + } + return false; +} + +function makeUniqueComponentName(base: string, used: Set): string { + const safeBase = base.length > 0 ? base : "InlineType"; + let candidate = safeBase; + let counter = 2; + while (used.has(candidate)) { + candidate = `${safeBase}_${counter}`; + counter += 1; + } + used.add(candidate); + return candidate; +} + +function hoistInSchema( + node: any, + path: string[], + schemas: Record, + used: Set +) { + if (!isObject(node)) { + return; + } + + const unionKeywords = ["oneOf", "anyOf"] as const; + for (const keyword of unionKeywords) { + const members = node[keyword]; + if (Array.isArray(members)) { + for (let index = 0; index < members.length; index += 1) { + const member = members[index]; + const memberPath = path.concat(String(index)); + + // Recurse first so nested inline members inside this member are hoisted + // (and turned into $refs) before the member itself is moved. + hoistInSchema(member, memberPath, schemas, used); + + if (memberGeneratesNamedType(member)) { + const name = makeUniqueComponentName(pascalConcat(memberPath), used); + schemas[name] = member; + members[index] = { $ref: `#/components/schemas/${name}` }; + } + } + } + } + + if (isObject(node.properties)) { + for (const propertyName in node.properties) { + hoistInSchema( + node.properties[propertyName], + path.concat(propertyName), + schemas, + used + ); + } + } + + if (isObject(node.items)) { + hoistInSchema(node.items, path.concat("Item"), schemas, used); + } + + if (node.additionalProperties instanceof Schema) { + hoistInSchema( + node.additionalProperties, + path.concat("AdditionalProperties"), + schemas, + used + ); + } + + if (isObject(node.not)) { + hoistInSchema(node.not, path.concat("Not"), schemas, used); + } + + if (Array.isArray(node.allOf)) { + for (let index = 0; index < node.allOf.length; index += 1) { + // allOf members share the owner's naming path in oapi-codegen. + hoistInSchema(node.allOf[index], path, schemas, used); + } + } +} + +// Walk every component schema and hoist its complex inline union members. +function hoistInlineUnionMembers(spec: OpenAPISpec30) { + const components = spec.components; + if (!components || !isObject(components.schemas)) { + return; + } + + const schemas = components.schemas as Record; + const used = new Set(Object.keys(schemas)); + + // Snapshot names first: hoisting adds new components during iteration, and + // those already contain only $ref members, so they need no further walk. + const originalNames = Object.keys(schemas); + for (const name of originalNames) { + hoistInSchema(schemas[name], [name], schemas, used); + } +} + +// Downconvert an OpenAPI 3.1 document to 3.0.1 export function downconvertOpenAPI31To30(spec: OpenAPISpec31): OpenAPISpec30 { const convertedSpec: OpenAPISpec30 = { ...spec, openapi: "3.0.1" }; - // Map to store schemas for resolving $ref types + // Build a name->Schema map so `$ref`s can be resolved during flattening. const schemaMap: Record = convertedSpec.components?.schemas - ? Object.fromEntries( - Object.entries(convertedSpec.components.schemas).map(([key, value]) => [ - key, - new Schema(value), - ]) + ? Object.fromEntries( + Object.entries(convertedSpec.components.schemas).map(([key, value]) => [ + key, + new Schema(value), + ]) ) - : {}; + : {}; + + const components = convertedSpec.components; + if (components) { + // components.schemas + if (components.schemas) { + for (const schemaName in components.schemas) { + const schema = schemaMap[schemaName]; + schema.adjust(schemaMap); + components.schemas[schemaName] = schema; + } + } - // Adjust schema properties in components - if (convertedSpec.components?.schemas) { - for (const schemaName in convertedSpec.components.schemas) { - const schema = schemaMap[schemaName]; - schema.adjust(schemaMap); - convertedSpec.components.schemas[schemaName] = schema; + // Reusable components that can carry Schema Objects. + if (components.parameters) { + walkParameters(components.parameters, schemaMap); + } + if (components.headers) { + walkHeaders(components.headers, schemaMap); + } + if (components.requestBodies) { + for (const requestBody of Object.values(components.requestBodies)) { + walkRequestBody(requestBody, schemaMap); + } + } + if (components.responses) { + walkResponses(components.responses, schemaMap); + } + if (components.callbacks) { + walkCallbacks(components.callbacks, schemaMap); + } + if (components.pathItems) { + for (const pathItem of Object.values(components.pathItems)) { + walkPathItem(pathItem, schemaMap); + } } } - // Adjust paths + // paths if (convertedSpec.paths) { for (const path in convertedSpec.paths) { - const pathItem = convertedSpec.paths[path]; - for (const method in pathItem) { - const operation = pathItem[method]; - - // Handle requestBody content - if (operation.requestBody?.content) { - Object.values(operation.requestBody.content).forEach((mediaType) => { - if (mediaType.schema) { - const schema = new Schema(mediaType.schema); - schema.adjust(schemaMap); - mediaType.schema = schema; - } - }); - } - - // Handle response content - if (operation.responses) { - for (const response in operation.responses) { - if (operation.responses[response]?.content) { - Object.values(operation.responses[response].content).forEach( - (mediaType) => { - if (mediaType.schema) { - const schema = new Schema(mediaType.schema); - schema.adjust(schemaMap); - mediaType.schema = schema; - } - } - ); - } - } - } + walkPathItem(convertedSpec.paths[path], schemaMap); + } + } - // Handle parameters - if (operation.parameters) { - operation.parameters.forEach((parameter) => { - if (parameter.schema) { - const schema = new Schema(parameter.schema); - schema.adjust(schemaMap); - parameter.schema = schema; - } - }); - } - } + // webhooks are a 3.1-only root construct. Normalize any schemas they carry + // (in case they are consumed elsewhere), then drop the key so the emitted + // document is valid 3.0.1. + if (isObject((convertedSpec as any).webhooks)) { + for (const pathItem of Object.values((convertedSpec as any).webhooks)) { + walkPathItem(pathItem, schemaMap); } + delete (convertedSpec as any).webhooks; } + // Final pass: hoist complex inline oneOf/anyOf members into named components + // so oapi-codegen never auto-generates colliding `` type names. + hoistInlineUnionMembers(convertedSpec); + return convertedSpec; } @@ -393,44 +798,40 @@ import path from "path"; import YAML from "yamljs"; async function readYamlFile(filePath: string): Promise { - try { - // Resolve the full path to ensure it works with relative paths - const fullPath = path.resolve(filePath); - - // Read the YAML file asynchronously - const fileContents = await fs.readFile(fullPath, "utf8"); - - // Parse YAML into JSON - const parsedData = YAML.parse(fileContents); - - return parsedData; - } catch (err) { - if (err && typeof err === "object" && "message" in err) { - throw new Error(`Error reading or parsing YAML file: ${err.message}`); - } - throw err; - } + const fullPath = path.resolve(filePath); + const fileContents = await fs.readFile(fullPath, "utf8"); + return YAML.parse(fileContents); } async function writeJsonToFile(filePath: string, data: object): Promise { - try { - // Resolve the full path to ensure it works with relative paths - const fullPath = path.resolve(filePath); + const fullPath = path.resolve(filePath); + const jsonString = JSON.stringify(data, null, 2); + await fs.writeFile(fullPath, jsonString, "utf8"); + console.log(`Successfully wrote to ${fullPath}`); +} + +// Convert every bundled API that exists. Each `dist/.yml` (produced by the +// `build:*` scripts) becomes `dist/-3.0.3.json`. +const APIS = ["platform", "internal", "scheduler", "ial"]; - // Convert the data to a JSON string - const jsonString = JSON.stringify(data, null, 2); // Pretty print with 2-space indentation +async function main() { + for (const api of APIS) { + const input = `./dist/${api}.yml`; - // Write the JSON string to the specified file asynchronously - await fs.writeFile(fullPath, jsonString, "utf8"); - console.log(`Successfully wrote to ${fullPath}`); - } catch (err) { - if (err && typeof err === "object" && "message" in err) { - throw new Error(`Error writing JSON to file: ${err.message}`); + try { + await fs.access(input); + } catch { + console.warn(`[downconvert] skipping ${api}: ${input} not found`); + continue; } - throw err; + + const spec = await readYamlFile(input); + const converted = downconvertOpenAPI31To30(spec as any); + await writeJsonToFile(`./dist/${api}-3.0.3.json`, converted); } } -readYamlFile("./dist/platform.yml") - .then((r) => downconvertOpenAPI31To30(r as any)) - .then((spec) => writeJsonToFile("./dist/platform-3.0.3.json", spec)); +main().catch((err) => { + console.error(err); + process.exit(1); +}); \ No newline at end of file