diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index 3f795c60c..18bfa3a99 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -1,5 +1,7 @@ import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js"; +import type { Referenced, OasRef } from "@redocly/openapi-core"; import ts, { type LiteralTypeNode, type TypeLiteralNode } from "typescript"; +import type { ParameterObject } from "../types.js"; export const JS_PROPERTY_INDEX_RE = /^[A-Za-z_$][A-Za-z_$0-9]*$/; export const JS_ENUM_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+(.)?/g; @@ -115,33 +117,77 @@ export function addJSDocComment(schemaObject: AnnotatedSchemaObject, node: ts.Pr } } -/** Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`) */ -export function oapiRef(path: string): ts.TypeNode { +function isOasRef(obj: Referenced): obj is OasRef { + return Boolean((obj as OasRef).$ref); +} +type OapiRefResolved = Referenced; + +function isParameterObject(obj: OapiRefResolved | undefined): obj is ParameterObject { + return Boolean(obj && !isOasRef(obj) && obj.in); +} + +function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode, ...segments: readonly string[]) { + return segments.reduce((acc, segment) => { + return ts.factory.createIndexedAccessTypeNode( + acc, + ts.factory.createLiteralTypeNode( + typeof segment === "number" + ? ts.factory.createNumericLiteral(segment) + : ts.factory.createStringLiteral(segment), + ), + ); + }, node); +} + +/** + * Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`) + * `path` is a JSON Pointer to a location within an OpenAPI document. + * Transform it into a TypeScript type reference into the generated types. + * + * In most cases the structures of the openapi-typescript generated types and the + * JSON Pointer paths into the OpenAPI document are the same. However, in some cases + * special transformations are necessary to account for the ways they differ. + * * Object schemas + * $refs into the `properties` of object schemas are valid, but openapi-typescript + * flattens these objects, so we omit so the index into the schema skips ["properties"] + * * Parameters + * $refs into the `parameters` of paths are valid, but openapi-ts represents + * them according to their type; path, query, header, etc… so in these cases we + * must check the parameter definition to determine the how to index into + * the openapi-typescript type. + **/ +export function oapiRef(path: string, resolved?: OapiRefResolved): ts.TypeNode { const { pointer } = parseRef(path); if (pointer.length === 0) { throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`); } - let t: ts.TypeReferenceNode | ts.IndexedAccessTypeNode = ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier(String(pointer[0])), + + const parametersObject = isParameterObject(resolved); + + // Initial segments are handled in a fixed , then remaining segments are treated + // according to heuristics based on the initial segments + const initialSegment = pointer[0]; + const leadingSegments = pointer.slice(1, 3); + const restSegments = pointer.slice(3); + + const leadingType = addIndexedAccess( + ts.factory.createTypeReferenceNode(ts.factory.createIdentifier(String(initialSegment))), + ...leadingSegments, ); - if (pointer.length > 1) { - for (let i = 1; i < pointer.length; i++) { - // Skip `properties` items when in the middle of the pointer - // See: https://github.com/openapi-ts/openapi-typescript/issues/1742 - if (i > 2 && i < pointer.length - 1 && pointer[i] === "properties") { - continue; - } - t = ts.factory.createIndexedAccessTypeNode( - t, - ts.factory.createLiteralTypeNode( - typeof pointer[i] === "number" - ? ts.factory.createNumericLiteral(pointer[i]) - : ts.factory.createStringLiteral(pointer[i] as string), - ), - ); + + return restSegments.reduce((acc, segment, index, original) => { + // Skip `properties` items when in the middle of the pointer + // See: https://github.com/openapi-ts/openapi-typescript/issues/1742 + if (segment === "properties") { + return acc; } - } - return t; + + if (parametersObject && index === original.length - 1) { + return addIndexedAccess(acc, resolved.in, resolved.name); + } + + return addIndexedAccess(acc, segment); + }, leadingType); } export interface AstToStringOptions { diff --git a/packages/openapi-typescript/src/transform/parameters-array.ts b/packages/openapi-typescript/src/transform/parameters-array.ts index be995b75a..c28aac0ea 100644 --- a/packages/openapi-typescript/src/transform/parameters-array.ts +++ b/packages/openapi-typescript/src/transform/parameters-array.ts @@ -92,7 +92,7 @@ export function transformParametersArray( } const subType = "$ref" in original - ? oapiRef(original.$ref) + ? oapiRef(original.$ref, resolved) : transformParameterObject(resolved as ParameterObject, { ...options, path: createRef([options.path, "parameters", resolved.in, resolved.name]), diff --git a/packages/openapi-typescript/test/fixtures/parameters-test.yaml b/packages/openapi-typescript/test/fixtures/parameters-test.yaml index 1807eeed6..c16167f07 100644 --- a/packages/openapi-typescript/test/fixtures/parameters-test.yaml +++ b/packages/openapi-typescript/test/fixtures/parameters-test.yaml @@ -35,6 +35,9 @@ paths: type: string - $ref: "#/components/parameters/local_ref_b" - $ref: "./_parameters-test-partial.yaml#/remote_ref_b" + /endpoint2: + parameters: + - $ref: "#/paths/~1endpoint/get/parameters/0" components: parameters: local_ref_a: diff --git a/packages/openapi-typescript/test/index.test.ts b/packages/openapi-typescript/test/index.test.ts index 8bd11b97f..f3fffc6e7 100644 --- a/packages/openapi-typescript/test/index.test.ts +++ b/packages/openapi-typescript/test/index.test.ts @@ -172,6 +172,25 @@ export type operations = Record;`, patch?: never; trace?: never; }; + "/endpoint2": { + parameters: { + query?: never; + header?: never; + path: { + /** @description This overrides parameters */ + local_param_a: paths["/endpoint"]["get"]["parameters"]["path"]["local_param_a"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index e7a1fd992..afeee038c 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -65,15 +65,27 @@ describe("oapiRef", () => { expect(astToString(oapiRef("#/components/schemas/User")).trim()).toBe(`components["schemas"]["User"]`); }); - test("removes inner `properties`", () => { + test("`properties` of component schema `properties`", () => { expect(astToString(oapiRef("#/components/schemas/User/properties/username")).trim()).toBe( `components["schemas"]["User"]["username"]`, ); }); - test("leaves final `properties` intact", () => { + test("component schema named `properties`", () => { expect(astToString(oapiRef("#/components/schemas/properties")).trim()).toBe(`components["schemas"]["properties"]`); }); + + test("reference into paths parameters", () => { + expect( + astToString( + oapiRef("#/paths/~1endpoint/get/parameters/0", { + in: "query", + name: "boop", + required: true, + }), + ).trim(), + ).toBe('paths["/endpoint"]["get"]["parameters"]["query"]["boop"]'); + }); }); describe("tsEnum", () => { diff --git a/packages/openapi-typescript/test/transform/webhooks-object.test.ts b/packages/openapi-typescript/test/transform/webhooks-object.test.ts index f310d14e4..8a9c2e725 100644 --- a/packages/openapi-typescript/test/transform/webhooks-object.test.ts +++ b/packages/openapi-typescript/test/transform/webhooks-object.test.ts @@ -105,10 +105,10 @@ describe("transformWebhooksObject", () => { schema: { type: "string" }, required: true, }, - { $ref: "#/components/parameters/query/utm_source" }, - { $ref: "#/components/parameters/query/utm_email" }, - { $ref: "#/components/parameters/query/utm_campaign" }, - { $ref: "#/components/parameters/path/version" }, + { $ref: "#/components/parameters/utm_source" }, + { $ref: "#/components/parameters/utm_email" }, + { $ref: "#/components/parameters/utm_campaign" }, + { $ref: "#/components/parameters/version" }, ], }, }, @@ -117,13 +117,13 @@ describe("transformWebhooksObject", () => { parameters: { query: { signature: string; - utm_source?: components["parameters"]["query"]["utm_source"]; - utm_email?: components["parameters"]["query"]["utm_email"]; - utm_campaign?: components["parameters"]["query"]["utm_campaign"]; + utm_source?: components["parameters"]["utm_source"]; + utm_email?: components["parameters"]["utm_email"]; + utm_campaign?: components["parameters"]["utm_campaign"]; }; header?: never; path: { - utm_campaign: components["parameters"]["path"]["version"]; + utm_campaign: components["parameters"]["version"]; }; cookie?: never; }; @@ -141,28 +141,28 @@ describe("transformWebhooksObject", () => { ...DEFAULT_OPTIONS, resolve($ref) { switch ($ref) { - case "#/components/parameters/query/utm_source": { + case "#/components/parameters/utm_source": { return { in: "query", name: "utm_source", schema: { type: "string" }, }; } - case "#/components/parameters/query/utm_email": { + case "#/components/parameters/utm_email": { return { in: "query", name: "utm_email", schema: { type: "string" }, }; } - case "#/components/parameters/query/utm_campaign": { + case "#/components/parameters/utm_campaign": { return { in: "query", name: "utm_campaign", schema: { type: "string" }, }; } - case "#/components/parameters/path/version": { + case "#/components/parameters/version": { return { in: "path", name: "utm_campaign",