Skip to content
Open
Show file tree
Hide file tree
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
5 changes: 5 additions & 0 deletions .changeset/percent-sign-property-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-typescript": patch
---

Fix crash (`URIError: URI malformed`) when a schema has a property name containing a literal percent sign (e.g. `"25%"`) that resolves to an object. JSON Pointer segments are now parsed resiliently, falling back to the raw segment when `decodeURIComponent` fails instead of throwing.
32 changes: 31 additions & 1 deletion packages/openapi-typescript/src/lib/ts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import type { OasRef, Referenced } from "@redocly/openapi-core";
import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import { parseRef as redoclyParseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import ts, { type LiteralTypeNode, type TypeLiteralNode } from "typescript";
import type { ParameterObject } from "../types.js";

/**
* Parse a $ref / JSON Pointer into its URI and pointer segments.
*
* This is a resilient wrapper around Redocly’s `parseRef`. Redocly unescapes each
* pointer segment with `decodeURIComponent`, which throws a `URIError` on a literal,
* un-encoded `%` (e.g. a schema property named `"25%"`). Property names are valid
* pointer segments and shouldn’t crash codegen, so when decoding fails we fall back
* to the raw, un-decoded segment (matching the asymmetry of `escapePointer`, which
* does not percent-encode).
*/
export function parseRef(ref: string): ReturnType<typeof redoclyParseRef> {
try {
return redoclyParseRef(ref);
} catch {
const [uri, rawPointer = ""] = ref.split("#/");
const pointer = rawPointer
.split("/")
.map((fragment) => {
const unescaped = fragment.replace(/~1/g, "/").replace(/~0/g, "~");
try {
return decodeURIComponent(unescaped);
} catch {
return unescaped;
}
})
.filter(Boolean);
return { uri: (uri.endsWith("#") ? uri.slice(0, -1) : uri) || null, pointer };
}
}

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;
export const JS_PROPERTY_INDEX_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+/g;
Expand Down
4 changes: 2 additions & 2 deletions packages/openapi-typescript/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { escapePointer, parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import { escapePointer } from "@redocly/openapi-core/lib/ref-utils.js";
import c from "ansi-colors";
import supportsColor from "supports-color";
import ts from "typescript";
import type { DiscriminatorObject, OpenAPI3, OpenAPITSOptions, ReferenceObject, SchemaObject } from "../types.js";
import { tsLiteral, tsModifiers, tsPropertyIndex } from "./ts.js";
import { parseRef, tsLiteral, tsModifiers, tsPropertyIndex } from "./ts.js";

if (!supportsColor.stdout || supportsColor.stdout.hasBasic === false) {
c.enabled = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import ts from "typescript";
import {
addJSDocComment,
Expand All @@ -7,6 +6,7 @@ import {
NULL,
NUMBER,
oapiRef,
parseRef,
QUESTION_TOKEN,
STRING,
tsArrayLiteralExpression,
Expand Down
16 changes: 16 additions & 0 deletions packages/openapi-typescript/test/lib/ts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
NULL,
NUMBER,
oapiRef,
parseRef,
STRING,
tsArrayLiteralExpression,
tsEnum,
Expand All @@ -15,6 +16,21 @@ import {
tsUnion,
} from "../../src/lib/ts.js";

describe("parseRef", () => {
test("parses pointer segments", () => {
expect(parseRef("#/components/schemas/Foo").pointer).toEqual(["components", "schemas", "Foo"]);
});

test("decodes percent-encoded segments", () => {
expect(parseRef("#/paths/~1users~1%7Bid%7D/get").pointer).toEqual(["paths", "/users/{id}", "get"]);
});

test("does not throw on a literal percent sign (#2251)", () => {
expect(() => parseRef("#/components/schemas/response/25%")).not.toThrow();
expect(parseRef("#/components/schemas/response/25%").pointer).toEqual(["components", "schemas", "response", "25%"]);
});
});

describe("addJSDocComment", () => {
test("single-line comment", () => {
const property = ts.factory.createPropertySignature(undefined, "comment", undefined, BOOLEAN);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,33 @@ describe("transformSchemaObject > object", () => {
},
},
],
[
"property name with percent sign (nested object)",
{
// a property key containing a literal "%" must not crash codegen when it
// holds a nested object — the path is fed back through parseRef, which
// would otherwise throw a URIError via decodeURIComponent. See #2251.
given: {
type: "object",
properties: {
id: { type: "string" },
"25%": {
type: "object",
properties: {
color: { type: "string" },
},
},
},
},
want: `{
id?: string;
"25%"?: {
color?: string;
};
}`,
// options: DEFAULT_OPTIONS,
},
],
];

for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) {
Expand Down
Loading