Skip to content

Commit f518304

Browse files
committed
fix(core): dotted
1 parent f3b0b5a commit f518304

10 files changed

Lines changed: 125 additions & 97 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+
}

packages/core/src/getters/route.ts

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

34-
const hasParam = (path: string): boolean => /[^{]*{[\w.*_-]*}.*/.test(path);
34+
// Characters allowed in an OpenAPI path-parameter name inside `{...}`
35+
// (`{petId}`, `{user_id}`, `{scope.id}`, `{kebab-case}`, `{path*}`).
36+
const PATH_PARAM_NAME_CHARS = String.raw`[\w.*_-]`;
37+
const HAS_PATH_PARAM_REGEX = new RegExp(`[^{]*{${PATH_PARAM_NAME_CHARS}*}.*`);
38+
const PATH_PARAM_SEGMENT_REGEX = new RegExp(
39+
`([^{]*){?(${PATH_PARAM_NAME_CHARS}*)}?(.*)`,
40+
);
41+
42+
export const hasPathParam = (path: string): boolean =>
43+
HAS_PATH_PARAM_REGEX.test(path);
44+
45+
/**
46+
* Sanitizes an OpenAPI path-parameter name for emission in generated code:
47+
* keeps word characters, underscores, dashes and dots, strips everything
48+
* else, and prefixes ES5 keywords with an underscore.
49+
*/
50+
export const sanitizePathParamName = (name: string): string =>
51+
sanitize(name, { es5keyword: true, underscore: true, dash: true, dot: true });
52+
53+
/**
54+
* Converts every `{param}` in a path segment to `:param` (Hono/MSW style
55+
* routes). `formatParamName` maps the raw OpenAPI parameter name to the
56+
* emitted one (e.g. `sanitizePathParamName`, composed with `camel` or not).
57+
*/
58+
export function toColonRoutePath(
59+
path: string,
60+
formatParamName: (rawName: string) => string,
61+
): string {
62+
const matches = PATH_PARAM_SEGMENT_REGEX.exec(path);
63+
if (!matches?.length) return path;
64+
65+
const [, prev, rawName, rest] = matches;
66+
const param = formatParamName(rawName);
67+
const next = hasPathParam(rest)
68+
? toColonRoutePath(rest, formatParamName)
69+
: rest;
70+
71+
return hasPathParam(path)
72+
? `${prev}:${param}${next}`
73+
: `${prev}${param}${next}`;
74+
}
3575

3676
const esc = (str: string) => jsesc(str, { quotes: 'backtick', wrap: false });
3777

@@ -45,26 +85,21 @@ const getRoutePath = (path: string): string => {
4585
if (closeIdx === -1) return esc(path);
4686
const before = esc(path.slice(0, closeIdx + 1));
4787
const rest = path.slice(closeIdx + 1);
48-
return hasParam(rest)
88+
return hasPathParam(rest)
4989
? `${before}${getRoutePath(rest)}`
5090
: `${before}${esc(rest)}`;
5191
}
5292

53-
const matches = /([^{]*){?([\w.*_-]*)}?(.*)/.exec(path);
93+
const matches = PATH_PARAM_SEGMENT_REGEX.exec(path);
5494
if (!matches?.length) return esc(path);
5595

5696
const prev = matches[1];
5797
const rawParam = matches[2];
5898
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)
99+
const param = sanitizePathParamName(camel(rawParam));
100+
const next = hasPathParam(rest) ? getRoutePath(rest) : esc(rest);
101+
102+
return hasPathParam(path)
68103
? `${esc(prev)}\${${param}}${next}`
69104
: `${esc(prev)}${param}${next}`;
70105
};

packages/core/src/utils/string.test.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,8 @@ import {
77
jsStringEscape,
88
jsStringLiteralEscape,
99
stringify,
10-
toObjectKey,
1110
} from './string';
1211

13-
describe('toObjectKey', () => {
14-
it('leaves a valid identifier name unchanged', () => {
15-
expect(toObjectKey('scopeId')).toBe('scopeId');
16-
});
17-
18-
it('quotes a dotted name', () => {
19-
expect(toObjectKey('scope.id')).toBe("'scope.id'");
20-
});
21-
22-
it('quotes a dashed name', () => {
23-
expect(toObjectKey('user-id')).toBe("'user-id'");
24-
});
25-
26-
it('escapes quotes inside the name', () => {
27-
expect(toObjectKey("it's")).toBe(String.raw`'it\'s'`);
28-
});
29-
});
30-
3112
describe('dedupeUnionType', () => {
3213
describe('edge cases', () => {
3314
it('should handle empty string', () => {

packages/core/src/utils/string.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -321,17 +321,6 @@ export function jsStringLiteralEscape(input: string) {
321321
});
322322
}
323323

324-
/**
325-
* Formats a name for use as an object-literal or type-literal key: returned
326-
* as-is when it is a valid identifier name, single-quoted (and escaped)
327-
* otherwise (e.g. `scope.id` → `'scope.id'`).
328-
*/
329-
export function toObjectKey(name: string): string {
330-
return keyword.isIdentifierNameES5(name)
331-
? name
332-
: `'${jsStringLiteralEscape(name)}'`;
333-
}
334-
335324
/**
336325
* Deduplicates a TypeScript union type string.
337326
* Handles types like "A | B | B" → "A | B" and "null | null" → "null".

packages/hono/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ import {
2424
type NormalizedMutator,
2525
type NormalizedOutputOptions,
2626
type OpenApiInfoObject,
27+
getKey,
2728
pascal,
2829
sanitize,
29-
toObjectKey,
3030
getImportExtension,
3131
type Tsconfig,
3232
upath,
@@ -736,7 +736,7 @@ const getContext = (verbOption: GeneratorVerbOptions) => {
736736
const definition = param?.definition.split(':')[1];
737737
const required = param?.required ?? false;
738738
return {
739-
definition: `${toObjectKey(name)}${required ? '' : '?'}:${definition}`,
739+
definition: `${getKey(name)}${required ? '' : '?'}:${definition}`,
740740
};
741741
});
742742
paramType = `param: {\n ${params

packages/hono/src/route.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getRoute } from './route';
4+
5+
describe('getRoute getter', () => {
6+
// Hono keeps original parameter names (no camelization) so route params
7+
// line up with the spec names used by validators and `c.req.param()`.
8+
it.each([
9+
['/api/test', '/api/test'],
10+
['/api/test/{id}', '/api/test/:id'],
11+
['/api/test/{path*}', '/api/test/:path'],
12+
['/api/test/{user_id}', '/api/test/:user_id'],
13+
['/api/test/{scope.id}', '/api/test/:scope.id'],
14+
[
15+
'/api/v1/{scope.id}/items/{item.name}',
16+
'/api/v1/:scope.id/items/:item.name',
17+
],
18+
['/api/test/{locale}.js', '/api/test/:locale.js'],
19+
['/api/test/i18n-{locale}.js', '/api/test/i18n-:locale.js'],
20+
['/api/test/{param1}-{param2}.js', '/api/test/:param1-:param2.js'],
21+
])('should process the route %s => %s', (input, expected) => {
22+
expect(getRoute(input)).toBe(expected);
23+
});
24+
});

packages/hono/src/route.ts

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,18 @@
1-
import { sanitize } from '@orval/core';
2-
3-
const hasParam = (path: string): boolean => /[^{]*{[\w.*_-]*}.*/.test(path);
4-
5-
const getRoutePath = (path: string): string => {
6-
const matches = /([^{]*){?([\w.*_-]*)}?(.*)/.exec(path);
7-
if (!matches?.length) return path; // impossible due to regexp grouping here, but for TS
8-
9-
const prev = matches[1];
10-
const param = sanitize(matches[2], {
11-
es5keyword: true,
12-
underscore: true,
13-
dash: true,
14-
dot: true,
15-
});
16-
const next = hasParam(matches[3]) ? getRoutePath(matches[3]) : matches[3];
17-
18-
return hasParam(path) ? `${prev}:${param}${next}` : `${prev}${param}${next}`;
19-
};
1+
import { sanitizePathParamName, toColonRoutePath } from '@orval/core';
202

3+
// Hono keeps the original parameter name (no camelization): the `:name` in
4+
// the route is the key Hono's runtime and validators expose, so it must match
5+
// the spec's parameter name (e.g. `{scope.id}` → `:scope.id`).
216
export const getRoute = (route: string) => {
227
const splittedRoute = route.split('/');
238

249
let acc = '';
2510
for (const [i, path] of splittedRoute.entries()) {
2611
if (!path && i === 0) continue;
2712

28-
acc += path.includes('{') ? `/${getRoutePath(path)}` : `/${path}`;
13+
acc += path.includes('{')
14+
? `/${toColonRoutePath(path, sanitizePathParamName)}`
15+
: `/${path}`;
2916
}
3017

3118
return acc;

packages/mcp/src/index.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ import {
1919
jsStringEscape,
2020
type NormalizedOutputOptions,
2121
type OpenApiInfoObject,
22+
getKey,
23+
getPropertyAccessor,
2224
pascal,
23-
toObjectKey,
2425
upath,
2526
type Verbs,
2627
} from '@orval/core';
@@ -150,7 +151,7 @@ export const generateMcp: ClientBuilder = (verbOptions) => {
150151
.map((param, index) => {
151152
const paramName = originalParamNames[index];
152153
const paramType = param.implementation.split(': ')[1];
153-
return ` ${toObjectKey(paramName)}: ${paramType}`;
154+
return ` ${getKey(paramName)}: ${paramType}`;
154155
})
155156
.join(',\n');
156157
if (pathParamsType) {
@@ -180,12 +181,7 @@ ${handlerArgsTypes.join('\n')}
180181
const fetchParams = [];
181182
if (verbOptions.params.length > 0) {
182183
const pathParamsArgs = originalParamNames
183-
.map((paramName) => {
184-
const key = toObjectKey(paramName);
185-
return key === paramName
186-
? `args.pathParams.${paramName}`
187-
: `args.pathParams[${key}]`;
188-
})
184+
.map((paramName) => `args.pathParams${getPropertyAccessor(paramName)}`)
189185
.join(', ');
190186

191187
fetchParams.push(pathParamsArgs);

packages/mock/src/faker/getters/route.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,6 @@
1-
import { camel, sanitize } from '@orval/core';
1+
import { camel, sanitizePathParamName, toColonRoutePath } from '@orval/core';
22

3-
const hasParam = (path: string): boolean => /[^{]*{[\w.*_-]*}.*/.test(path);
4-
5-
const getRoutePath = (path: string): string => {
6-
const matches = /([^{]*){?([\w.*_-]*)}?(.*)/.exec(path);
7-
if (!matches?.length) return path; // impossible due to regexp grouping here, but for TS
8-
9-
const prev = matches[1];
10-
const param = sanitize(camel(matches[2]), {
11-
es5keyword: true,
12-
underscore: true,
13-
dash: true,
14-
dot: true,
15-
});
16-
const next = hasParam(matches[3]) ? getRoutePath(matches[3]) : matches[3];
17-
18-
return hasParam(path) ? `${prev}:${param}${next}` : `${prev}${param}${next}`;
19-
};
3+
const formatParamName = (name: string) => sanitizePathParamName(camel(name));
204

215
export const getRouteMSW = (route: string, baseUrl = '*') => {
226
route = route.replaceAll(':', String.raw`\\:`);
@@ -28,12 +12,9 @@ export const getRouteMSW = (route: string, baseUrl = '*') => {
2812
continue;
2913
}
3014

31-
if (!path.includes('{')) {
32-
resolvedRoute = `${resolvedRoute}/${path}`;
33-
continue;
34-
}
35-
36-
resolvedRoute = `${resolvedRoute}/${getRoutePath(path)}`;
15+
resolvedRoute = path.includes('{')
16+
? `${resolvedRoute}/${toColonRoutePath(path, formatParamName)}`
17+
: `${resolvedRoute}/${path}`;
3718
}
3819

3920
return resolvedRoute;

0 commit comments

Comments
 (0)