Skip to content
Merged
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
84 changes: 84 additions & 0 deletions src/__tests__/__snapshots__/zodv3.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,90 @@ exports[`zod v3 > describeResponse 1`] = `
}
`;

exports[`zod v3 > operation id for path with dash 1`] = `
{
"components": {
"parameters": {
"Param": {
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "number",
},
},
},
},
"info": {
"description": "Development documentation",
"title": "Hono Documentation",
"version": "0.0.0",
},
"openapi": "3.1.0",
"paths": {
"/some-path": {
"get": {
"description": "This is a test route",
"operationId": "getSomePath",
"parameters": [
{
"$ref": "#/components/parameters/Param",
},
{
"in": "query",
"name": "limit",
"schema": {
"default": 10,
"type": "number",
},
},
{
"in": "query",
"name": "offset",
"schema": {
"default": 0,
"type": "number",
},
},
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"count": {
"type": "number",
},
"result": {
"items": {
"type": "string",
},
"type": "array",
},
},
"required": [
"result",
"count",
],
"type": "object",
},
},
},
"description": "Success",
},
},
"summary": "Test route",
"tags": [
"test",
],
},
},
},
"tags": undefined,
}
`;

exports[`zod v3 > with metadata 1`] = `
{
"components": {
Expand Down
112 changes: 112 additions & 0 deletions src/__tests__/zodv3.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,116 @@ describe("zod v3", () => {

expect(specs).toMatchSnapshot();
});

it("operation id for path with dash", async () => {
const query = z.object({
limit: z.coerce.number().default(10),
offset: z.coerce.number().default(0),
});

const param = z
.object({
id: z.coerce.number(),
})
.openapi({ ref: "Param" });

const app = new Hono().get(
"/some-path",
describeRoute({
tags: ["test"],
summary: "Test route",
description: "This is a test route",
}),
validator("param", param),
Comment on lines +186 to +199
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The test defines a path parameter validator but the route path '/some-path' doesn't contain any path parameters. The param validator at lines 186-190 expects an 'id' path parameter, but there's no ':id' in the route path. This appears to be copy-pasted from the 'describeResponse' test above. Either remove the param validator or change the path to include a path parameter like '/:id/some-path' to properly test the dash handling in both static and dynamic path segments.

Copilot uses AI. Check for mistakes.
validator("query", query),
Comment on lines +186 to +200
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The test defines a param validator for a path parameter, but the path /some-path contains no path parameters. This creates an inconsistent test case where the validator is declared but cannot be used. Either remove the param validator and the c.req.valid("param") call, or change the path to include a parameter like /some-path/:id to properly test the PascalCase conversion with both dashes and parameters.

Copilot uses AI. Check for mistakes.
describeResponse(
async (c) => {
c.req.valid("query");
c.req.valid("param");

return c.json({
result: [],
count: 0,
});
},
{
200: {
description: "Success",
content: {
"application/json": {
vSchema: z.object({
result: z.array(z.string()),
count: z.number(),
}),
},
},
},
},
),
);

const specs = await generateSpecs(app);

expect(specs).toMatchSnapshot();
});
Comment on lines +180 to +230
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The test only covers a single path segment with a dash. Consider adding test cases for additional scenarios to ensure the toPascalCase function works correctly in all cases, such as: multiple segments with dashes (e.g., '/hello-world/foo-bar'), path parameters with dashes (e.g., '/:user-id'), and combinations of static and dynamic segments with dashes (e.g., '/hello-world/:user-id/foo-bar').

Copilot uses AI. Check for mistakes.

it("operation id for path with underscore", async () => {
const app = new Hono().get(
"/api_v1/users",
describeRoute({
tags: ["test"],
summary: "Test route",
description: "This is a test route",
}),
async (c) => {
return c.json({ message: "Hello" });
},
);

const specs = await generateSpecs(app);

expect(specs.paths?.["/api_v1/users"]?.get?.operationId).toBe(
"getApiV1Users",
);
});

it("operation id for path param with dash", async () => {
const app = new Hono().get(
"/users/:user-id",
describeRoute({
tags: ["test"],
summary: "Test route",
description: "This is a test route",
}),
async (c) => {
return c.json({ message: "Hello" });
},
);

const specs = await generateSpecs(app);

expect(specs.paths?.["/users/{user-id}"]?.get?.operationId).toBe(
"getUsersByUserId",
);
});

it("operation id for multiple dashed segments", async () => {
const app = new Hono().get(
"/api-v1/user-profile",
describeRoute({
tags: ["test"],
summary: "Test route",
description: "This is a test route",
}),
async (c) => {
return c.json({ message: "Hello" });
},
);

const specs = await generateSpecs(app);

expect(specs.paths?.["/api-v1/user-profile"]?.get?.operationId).toBe(
"getApiV1UserProfile",
);
});
});
12 changes: 8 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ const toOpenAPIPathSegment = (segment: string) => {
const toOpenAPIPath = (path: string) =>
path.split("/").map(toOpenAPIPathSegment).join("/");

const capitalize = (word: string) =>
word.charAt(0).toUpperCase() + word.slice(1);
const toPascalCase = (text: string) =>
text
.split(/[\W_]+/)
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");

const generateOperationId = (route: RouterRoute) => {
Comment on lines +50 to 57
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The toPascalCase function preserves non-word characters at the beginning of the string due to the (?<!^) negative lookbehind in the regex. This means a path segment like "-test" would be converted to "-Test" instead of "Test", resulting in an invalid operation ID. Consider handling leading non-word characters explicitly by trimming them or including them in the removal pattern.

Copilot uses AI. Check for mistakes.
let operationId = route.method.toLowerCase();
Expand All @@ -58,9 +62,9 @@ const generateOperationId = (route: RouterRoute) => {
for (const segment of route.path.split("/")) {
const openApiPathSegment = toOpenAPIPathSegment(segment);
if (openApiPathSegment.charCodeAt(0) === 123) {
operationId += `By${capitalize(openApiPathSegment.slice(1, -1))}`;
operationId += `By${toPascalCase(openApiPathSegment.slice(1, -1))}`;
} else {
operationId += capitalize(openApiPathSegment);
operationId += toPascalCase(openApiPathSegment);
}
}

Expand Down
Loading