From 63b2f981ab462d2d20d094149c95b86f5bf8bf06 Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Sat, 13 Jun 2026 22:33:30 +0700 Subject: [PATCH] fix: handle property names with percent signs in JSON Pointers A schema property name containing a literal percent sign (e.g. "25%") crashed codegen with "URIError: URI malformed" whenever the property resolved to an object, because the accumulated path was re-parsed through Redocly's parseRef -> decodeURIComponent. Wrap parseRef so a failed decodeURIComponent falls back to the raw, un-decoded pointer segment (mirroring escapePointer, which does not percent-encode). Valid percent-encoded segments still decode correctly. Closes #2251 --- .changeset/percent-sign-property-name.md | 5 +++ packages/openapi-typescript/src/lib/ts.ts | 32 ++++++++++++++++++- packages/openapi-typescript/src/lib/utils.ts | 4 +-- .../src/transform/schema-object.ts | 2 +- .../openapi-typescript/test/lib/ts.test.ts | 16 ++++++++++ .../transform/schema-object/object.test.ts | 27 ++++++++++++++++ 6 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 .changeset/percent-sign-property-name.md diff --git a/.changeset/percent-sign-property-name.md b/.changeset/percent-sign-property-name.md new file mode 100644 index 000000000..748531319 --- /dev/null +++ b/.changeset/percent-sign-property-name.md @@ -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. diff --git a/packages/openapi-typescript/src/lib/ts.ts b/packages/openapi-typescript/src/lib/ts.ts index d1f41eb88..9374338a1 100644 --- a/packages/openapi-typescript/src/lib/ts.ts +++ b/packages/openapi-typescript/src/lib/ts.ts @@ -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 { + 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; diff --git a/packages/openapi-typescript/src/lib/utils.ts b/packages/openapi-typescript/src/lib/utils.ts index 49c192422..627519231 100644 --- a/packages/openapi-typescript/src/lib/utils.ts +++ b/packages/openapi-typescript/src/lib/utils.ts @@ -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; diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index caab5e10f..8886daf8a 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -1,4 +1,3 @@ -import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js"; import ts from "typescript"; import { addJSDocComment, @@ -7,6 +6,7 @@ import { NULL, NUMBER, oapiRef, + parseRef, QUESTION_TOKEN, STRING, tsArrayLiteralExpression, diff --git a/packages/openapi-typescript/test/lib/ts.test.ts b/packages/openapi-typescript/test/lib/ts.test.ts index 79e5b4571..af29f69ae 100644 --- a/packages/openapi-typescript/test/lib/ts.test.ts +++ b/packages/openapi-typescript/test/lib/ts.test.ts @@ -6,6 +6,7 @@ import { NULL, NUMBER, oapiRef, + parseRef, STRING, tsArrayLiteralExpression, tsEnum, @@ -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); diff --git a/packages/openapi-typescript/test/transform/schema-object/object.test.ts b/packages/openapi-typescript/test/transform/schema-object/object.test.ts index 0ef611a95..0ecc08b85 100644 --- a/packages/openapi-typescript/test/transform/schema-object/object.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/object.test.ts @@ -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) {