Skip to content

Commit f7c1113

Browse files
authored
fix(core): dotted path parameter names generate malformed requests (#3741)
1 parent a22a5ef commit f7c1113

15 files changed

Lines changed: 376 additions & 140 deletions

File tree

packages/core/src/getters/keys.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { getKey } from './keys';
3+
import { getKey, getPropertyAccessor } from './keys';
44

55
describe('getKey', () => {
6+
it('leaves a valid identifier name unchanged', () => {
7+
expect(getKey('scopeId')).toBe('scopeId');
8+
});
9+
10+
it('quotes a dotted key', () => {
11+
expect(getKey('scope.id')).toBe("'scope.id'");
12+
});
13+
14+
it('quotes a dashed key', () => {
15+
expect(getKey('user-id')).toBe("'user-id'");
16+
});
17+
618
it('escapes single quote in key', () => {
719
const result = getKey("x':[require('fs').execSync('id'),");
820
expect(result).toMatch(/^'(.*)'$/);
@@ -15,3 +27,17 @@ describe('getKey', () => {
1527
expect(result).toBe(String.raw`'a\\b'`);
1628
});
1729
});
30+
31+
describe('getPropertyAccessor', () => {
32+
it('uses dot access for a valid identifier name', () => {
33+
expect(getPropertyAccessor('scopeId')).toBe('.scopeId');
34+
});
35+
36+
it('uses quoted bracket access for a dotted name', () => {
37+
expect(getPropertyAccessor('scope.id')).toBe("['scope.id']");
38+
});
39+
40+
it('escapes quotes inside a bracket-access name', () => {
41+
expect(getPropertyAccessor("it's")).toBe(String.raw`['it\'s']`);
42+
});
43+
});

packages/core/src/getters/keys.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,12 @@ export function getKey(key: string) {
77
? key
88
: `'${jsStringLiteralEscape(key)}'`;
99
}
10+
11+
/**
12+
* Emits a property access for a possibly non-identifier name: dot access for
13+
* valid identifier names (`.petId`), quoted bracket access otherwise
14+
* (`['scope.id']`).
15+
*/
16+
export function getPropertyAccessor(name: string) {
17+
return keyword.isIdentifierNameES5(name) ? `.${name}` : `[${getKey(name)}]`;
18+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { createTestContextSpec } from '../test-utils/context';
4+
import type { GetterParameters } from '../types';
5+
import { getParams } from './params';
6+
7+
const context = createTestContextSpec();
8+
9+
const pathParam = (name: string): GetterParameters['path'][number] => ({
10+
parameter: { name, in: 'path', required: true, schema: { type: 'string' } },
11+
imports: [],
12+
});
13+
14+
describe('getParams getter', () => {
15+
it('matches a dotted spec name to its generated identifier in the route', () => {
16+
const params = getParams({
17+
route: '/api/${scopeId}/items',
18+
pathParams: [pathParam('scope.id')],
19+
operationId: 'getItems',
20+
context,
21+
output: context.output,
22+
});
23+
24+
expect(params).toHaveLength(1);
25+
expect(params[0].name).toBe('scopeId');
26+
expect(params[0].implementation).toBe('scopeId: string');
27+
});
28+
29+
it('throws when a route param has no matching spec parameter', () => {
30+
expect(() =>
31+
getParams({
32+
route: '/api/${scopeId}',
33+
pathParams: [pathParam('other')],
34+
operationId: 'getItems',
35+
context,
36+
output: context.output,
37+
}),
38+
).toThrow(
39+
"The path params scopeId can't be found in parameters (getItems)",
40+
);
41+
});
42+
43+
it('throws when two spec names collide on the same generated identifier', () => {
44+
expect(() =>
45+
getParams({
46+
route: '/api/${scopeId}',
47+
pathParams: [pathParam('scope.id'), pathParam('scope_id')],
48+
operationId: 'getItems',
49+
context,
50+
output: context.output,
51+
}),
52+
).toThrow(
53+
"Path parameters 'scope.id', 'scope_id' all map to the same generated identifier 'scopeId' (getItems). Rename them so they don't collide.",
54+
);
55+
});
56+
});

packages/core/src/getters/params.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import type {
55
GetterParams,
66
NormalizedOutputOptions,
77
} from '../types';
8-
import { camel, sanitize, stringify } from '../utils';
8+
import { stringify } from '../utils';
9+
import { camelPathParamName } from './route';
910

1011
/**
1112
* Return every params in a path
@@ -36,6 +37,41 @@ interface GetParamsOptions {
3637
output: NormalizedOutputOptions;
3738
}
3839

40+
/**
41+
* Resolves a route placeholder to its single matching spec path parameter.
42+
* `identifier` already is the generated JS identifier (it comes from the
43+
* processed route), so we re-derive the same identifier from each spec name via
44+
* `camelPathParamName` to match. Throws when two spec names collapse onto the
45+
* same identifier, or when none match.
46+
*/
47+
function resolvePathParam(
48+
identifier: string,
49+
pathParams: GetterParameters['query'],
50+
operationId: string,
51+
): GetterParameters['query'][number] {
52+
const matching = pathParams.filter(
53+
({ parameter }) => camelPathParamName(parameter.name ?? '') === identifier,
54+
);
55+
56+
if (matching.length > 1) {
57+
const names = matching
58+
.map(({ parameter }) => `'${parameter.name}'`)
59+
.join(', ');
60+
throw new Error(
61+
`Path parameters ${names} all map to the same generated identifier '${identifier}' (${operationId}). Rename them so they don't collide.`,
62+
);
63+
}
64+
65+
const pathParam = matching[0];
66+
if (!pathParam) {
67+
throw new Error(
68+
`The path params ${identifier} can't be found in parameters (${operationId})`,
69+
);
70+
}
71+
72+
return pathParam;
73+
}
74+
3975
export function getParams({
4076
route,
4177
pathParams = [],
@@ -45,28 +81,15 @@ export function getParams({
4581
}: GetParamsOptions): GetterParams {
4682
const params = getParamsInPath(route);
4783
return params.map((p) => {
48-
const pathParam = pathParams.find(
49-
({ parameter }) =>
50-
sanitize(camel(parameter.name), {
51-
es5keyword: true,
52-
underscore: true,
53-
dash: true,
54-
}) === p,
55-
);
56-
57-
if (!pathParam) {
58-
throw new Error(
59-
`The path params ${p} can't be found in parameters (${operationId})`,
60-
);
61-
}
84+
const pathParam = resolvePathParam(p, pathParams, operationId);
6285

6386
const {
6487
name: nameWithoutSanitize,
6588
required = false,
6689
schema,
6790
} = pathParam.parameter;
6891

69-
const name = sanitize(camel(nameWithoutSanitize), { es5keyword: true });
92+
const name = camelPathParamName(nameWithoutSanitize ?? '');
7093

7194
if (!schema) {
7295
return {

packages/core/src/getters/route.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,20 @@ import {
1818
describe('getRoute getter', () => {
1919
for (const [input, expected] of [
2020
['/api/test/{id}', '/api/test/${id}'],
21+
// A malformed spec path without a leading slash is normalized.
22+
['api/test/{id}', '/api/test/${id}'],
2123
['/api/test/{path*}', '/api/test/${path}'],
2224
['/api/test/{user_id}', '/api/test/${userId}'],
25+
// Matches the identifier generated for the function argument
26+
// (camelPathParamName), which strips the leading underscore.
27+
['/api/test/{_id}', '/api/test/${id}'],
28+
['/api/test/{scope.id}', '/api/test/${scopeId}'],
29+
// A malformed empty `{}` stays literal instead of emitting `${}`.
30+
['/api/test/{}/x', '/api/test/{}/x'],
31+
[
32+
'/api/v1/{scope.id}/items/{item.name}',
33+
'/api/v1/${scopeId}/items/${itemName}',
34+
],
2335
['/api/test/{locale}.js', '/api/test/${locale}.js'],
2436
['/api/test/i18n-{locale}.js', '/api/test/i18n-${locale}.js'],
2537
['/api/test/{param1}-{param2}.js', '/api/test/${param1}-${param2}.js'],

packages/core/src/getters/route.ts

Lines changed: 56 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -31,61 +31,70 @@ function runtimeExpressionToUrlPrefix(expression: string): string {
3131
return '${' + t + '}';
3232
}
3333

34-
const hasParam = (path: string): boolean => /[^{]*{[\w*_-]*}.*/.test(path);
34+
// Matches a `{name}` path-parameter template and captures the name
35+
// (`{petId}`, `{user_id}`, `{scope.id}`, `{kebab-case}`, `{path*}`). The
36+
// (?<!\$) guard is shared policy for every consumer (template-literal,
37+
// Hono and MSW routes): a `${...}` block in a spec path is never treated
38+
// as an OpenAPI param — it stays literal text in the emitted route. The name
39+
// must be non-empty so a malformed `{}` also stays literal instead of
40+
// emitting an invalid `${}` interpolation.
41+
const PATH_PARAM_REGEX = /(?<!\$)\{([\w.*-]+)\}/g;
42+
43+
// Spec paths are required to start with `/`, but malformed specs without it
44+
// are tolerated by normalizing here.
45+
const ensureLeadingSlash = (path: string): string =>
46+
path && !path.startsWith('/') ? `/${path}` : path;
3547

36-
const esc = (str: string) => jsesc(str, { quotes: 'backtick', wrap: false });
48+
/**
49+
* Sanitizes an OpenAPI path-parameter name while preserving the spec's
50+
* spelling: keeps word characters, underscores, dashes and dots, strips
51+
* everything else, and prefixes ES5 keywords with an underscore. Use this
52+
* when the emitted name must match the spec (e.g. Hono routes).
53+
*/
54+
export const sanitizePathParamName = (name: string): string =>
55+
sanitize(name, { es5keyword: true, underscore: true, dash: true, dot: true });
3756

38-
const getRoutePath = (path: string): string => {
39-
// Don't treat ${...} as an OpenAPI path param — the $ makes it literal text,
40-
// not a {param} template. Escape the ${...} block and continue processing
41-
// any legitimate {param} segments after it.
42-
const braceIdx = path.indexOf('{');
43-
if (braceIdx > 0 && path[braceIdx - 1] === '$') {
44-
const closeIdx = path.indexOf('}', braceIdx);
45-
if (closeIdx === -1) return esc(path);
46-
const before = esc(path.slice(0, closeIdx + 1));
47-
const rest = path.slice(closeIdx + 1);
48-
return hasParam(rest)
49-
? `${before}${getRoutePath(rest)}`
50-
: `${before}${esc(rest)}`;
51-
}
57+
/**
58+
* Derives the generated JS identifier for an OpenAPI path-parameter name
59+
* (`scope.id` → `scopeId`, `_id` → `id`, `class` → `_class`). This is the
60+
* single source of truth for param variable names: the emitted route
61+
* interpolations, the generated function arguments and the spec-parameter
62+
* matching must all agree on it.
63+
*/
64+
export const camelPathParamName = (name: string): string =>
65+
sanitize(camel(name), { es5keyword: true });
5266

53-
const matches = /([^{]*){?([\w*_-]*)}?(.*)/.exec(path);
54-
if (!matches?.length) return esc(path);
55-
56-
const prev = matches[1];
57-
const rawParam = matches[2];
58-
const rest = matches[3];
59-
const param = sanitize(camel(rawParam), {
60-
es5keyword: true,
61-
underscore: true,
62-
dash: true,
63-
dot: true,
64-
});
65-
const next = hasParam(rest) ? getRoutePath(rest) : esc(rest);
66-
67-
return hasParam(path)
68-
? `${esc(prev)}\${${param}}${next}`
69-
: `${esc(prev)}${param}${next}`;
70-
};
67+
/**
68+
* Converts every `{param}` in an OpenAPI path to `:param` (Hono/MSW style
69+
* routes). `formatParamName` maps the raw OpenAPI parameter name to the
70+
* emitted one (`sanitizePathParamName` or `camelPathParamName`).
71+
*/
72+
export const toColonRoutePath = (
73+
path: string,
74+
formatParamName: (rawName: string) => string,
75+
): string =>
76+
ensureLeadingSlash(path).replaceAll(
77+
PATH_PARAM_REGEX,
78+
(_, name: string) => `:${formatParamName(name)}`,
79+
);
80+
81+
const esc = (str: string) => jsesc(str, { quotes: 'backtick', wrap: false });
7182

7283
/**
7384
* Converts an OpenAPI path (`{param}`) to a template-literal route (`${param}`),
74-
* escaping static segments with jsesc for safe embedding in backtick strings.
75-
* The `route` arg must be a raw OpenAPI path.
85+
* escaping static text with jsesc for safe embedding in backtick strings.
86+
* The `route` arg must be a raw OpenAPI path; a non-empty route always emits
87+
* with a leading `/`.
7688
*/
7789
export function getRoute(route: string) {
78-
const splittedRoute = route.split('/');
79-
80-
let result = '';
81-
for (const [i, path] of splittedRoute.entries()) {
82-
if (!path && !i) {
83-
continue;
84-
}
85-
86-
result += path.includes('{') ? `/${getRoutePath(path)}` : `/${esc(path)}`;
87-
}
88-
return result;
90+
// Splitting on the capture group leaves param names at odd indices and
91+
// literal text at even indices. `${...}` blocks in the spec path fall into
92+
// the literal parts (via the lookbehind) so they are escaped, not
93+
// interpolated.
94+
return ensureLeadingSlash(route)
95+
.split(PATH_PARAM_REGEX)
96+
.map((part, i) => (i % 2 ? `\${${camelPathParamName(part)}}` : esc(part)))
97+
.join('');
8998
}
9099

91100
/**

packages/hono/src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import nodePath from 'node:path';
22

33
import {
4-
camel,
4+
camelPathParamName,
55
type ClientBuilder,
66
type ClientExtraFilesBuilder,
77
type ClientFooterBuilder,
@@ -24,8 +24,9 @@ import {
2424
type NormalizedMutator,
2525
type NormalizedOutputOptions,
2626
type OpenApiInfoObject,
27+
getKey,
2728
pascal,
28-
sanitize,
29+
sanitizePathParamName,
2930
getImportExtension,
3031
type Tsconfig,
3132
upath,
@@ -730,12 +731,14 @@ const getContext = (verbOption: GeneratorVerbOptions) => {
730731
if (verbOption.params.length > 0) {
731732
const params = getParamsInPath(verbOption.pathRoute).map((name) => {
732733
const param = verbOption.params.find(
733-
(p) => p.name === sanitize(camel(name), { es5keyword: true }),
734+
(p) => p.name === camelPathParamName(name),
734735
);
735736
const definition = param?.definition.split(':')[1];
736737
const required = param?.required ?? false;
738+
// The key must be the same name the emitted route uses (`:name`), which
739+
// is the sanitized spec name — that is the key Hono's runtime exposes.
737740
return {
738-
definition: `${name}${required ? '' : '?'}:${definition}`,
741+
definition: `${getKey(sanitizePathParamName(name))}${required ? '' : '?'}:${definition}`,
739742
};
740743
});
741744
paramType = `param: {\n ${params

0 commit comments

Comments
 (0)