Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix invalid $ref index into path #2185

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 67 additions & 21 deletions packages/openapi-typescript/src/lib/ts.ts
Original file line number Diff line number Diff line change
@@ -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<T>(obj: Referenced<T>): obj is OasRef {
return Boolean((obj as OasRef).$ref);
}
type OapiRefResolved = Referenced<ParameterObject>;

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<ts.TypeReferenceNode | ts.IndexedAccessTypeNode>((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 {
Original file line number Diff line number Diff line change
@@ -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]),
Original file line number Diff line number Diff line change
@@ -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:
19 changes: 19 additions & 0 deletions packages/openapi-typescript/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -172,6 +172,25 @@ export type operations = Record<string, never>;`,
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<string, never>;
export interface components {
16 changes: 14 additions & 2 deletions packages/openapi-typescript/test/lib/ts.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Original file line number Diff line number Diff line change
@@ -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" },
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like these refs were artificially-constructed to match the expected ts output, rather than extracted from a real OpenAPI ref.

Therefore, now that the parameter in is respected, I made them more realistic, and adjusted the custom resolver and expected test output.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup that seems right. I‘ve operated off the assumption that parameters in, say, query vs path are allowed to share names. But here in #/components/parameters you’re right there’s no such thing as “query” or “path”

{ $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",