Skip to content

Commit 5faacef

Browse files
authored
feat: add support for custom @operationid JSDoc tag (#60)
1 parent 2f396c9 commit 5faacef

File tree

7 files changed

+155
-3
lines changed

7 files changed

+155
-3
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ export async function POST(request: NextRequest) {
201201
| Tag | Description |
202202
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
203203
| `@description` | Endpoint description |
204+
| `@operationId` | Custom operation ID (overrides auto-generated ID) |
204205
| `@pathParams` | Path parameters type/schema |
205206
| `@params` | Query parameters type/schema |
206207
| `@body` | Request body type/schema |
@@ -424,6 +425,23 @@ export async function GET() {
424425
}
425426
```
426427

428+
### Custom Operation ID
429+
430+
```typescript
431+
// src/app/api/users/[id]/route.ts
432+
433+
/**
434+
* Get user by ID
435+
* @operationId getUserById
436+
* @pathParams UserParams
437+
* @response UserResponse
438+
*/
439+
export async function GET() {
440+
// ...
441+
}
442+
// Generates: operationId: "getUserById" instead of auto-generated "get-users-{id}"
443+
```
444+
427445
### File Uploads / Multipart Form Data
428446

429447
```typescript

examples/next15-app-zod/public/openapi.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,7 +1666,7 @@
16661666
"paths": {
16671667
"/orders": {
16681668
"get": {
1669-
"operationId": "get-orders",
1669+
"operationId": "getOrdersList",
16701670
"summary": "Get orders list\r",
16711671
"description": "Retrieves a paginated list of orders with filtering and sorting options",
16721672
"tags": [
@@ -1764,7 +1764,7 @@
17641764
}
17651765
},
17661766
"post": {
1767-
"operationId": "post-orders",
1767+
"operationId": "createOrder",
17681768
"summary": "Create order\r",
17691769
"description": "Creates a new order from cart",
17701770
"tags": [

examples/next15-app-zod/src/app/api/orders/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
33
/**
44
* Get orders list
55
* @description Retrieves a paginated list of orders with filtering and sorting options
6+
* @operationId getOrdersList
67
* @params OrdersQueryParams
78
* @response OrdersResponse
89
* @auth bearer
@@ -17,6 +18,7 @@ export async function GET(request: NextRequest) {
1718
/**
1819
* Create order
1920
* @description Creates a new order from cart
21+
* @operationId createOrder
2022
* @body CreateOrderBody
2123
* @response OrderSchema
2224
* @auth bearer

src/lib/route-processor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ export class RouteProcessor {
323323
const method = varName.toLowerCase();
324324
const routePath = this.getRoutePath(filePath);
325325
const rootPath = capitalize(routePath.split("/")[1]);
326-
const operationId = getOperationId(routePath, method);
326+
const operationId = dataTypes.operationId || getOperationId(routePath, method);
327327
const {
328328
tag,
329329
summary,

src/lib/utils.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function extractJSDocComments(path: NodePath): DataTypes {
4343
let responseSet = "";
4444
let addResponses = "";
4545
let successCode = "";
46+
let operationId = "";
4647

4748
if (comments) {
4849
comments.forEach((comment) => {
@@ -152,6 +153,14 @@ export function extractJSDocComments(path: NodePath): DataTypes {
152153
}
153154
}
154155

156+
if (commentValue.includes("@operationId")) {
157+
const regex = /@operationId\s+(\S+)/;
158+
const match = commentValue.match(regex);
159+
if (match && match[1]) {
160+
operationId = match[1].trim();
161+
}
162+
}
163+
155164
if (commentValue.includes("@response")) {
156165
// Updated regex to support generic types
157166
const responseMatch = commentValue.match(
@@ -186,6 +195,7 @@ export function extractJSDocComments(path: NodePath): DataTypes {
186195
responseSet,
187196
addResponses,
188197
successCode,
198+
operationId,
189199
};
190200
}
191201

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export type DataTypes = {
163163
responseSet?: string; // e.g. "authErrors" or "publicErrors,crudErrors"
164164
addResponses?: string; // e.g. "409:ConflictResponse,429:RateLimitResponse"
165165
successCode?: string; // e.g "201" for POST
166+
operationId?: string; // Custom operation ID (overrides auto-generated)
166167
};
167168

168169
export type RouteConfig = {

tests/utils.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,124 @@ describe('extractJSDocComments - @ignore tag', () => {
204204
expect(dataTypes?.deprecated).toBe(true);
205205
});
206206
});
207+
208+
describe('extractJSDocComments - @operationId tag', () => {
209+
it('should extract @operationId from JSDoc comment', () => {
210+
const code = `
211+
/**
212+
* Get user by ID
213+
* @operationId getUserById
214+
* @response UserResponse
215+
*/
216+
export async function GET() {}
217+
`;
218+
219+
const ast = parseTypeScriptFile(code);
220+
let dataTypes;
221+
222+
traverse(ast, {
223+
ExportNamedDeclaration: (path) => {
224+
dataTypes = extractJSDocComments(path);
225+
},
226+
});
227+
228+
expect(dataTypes).toBeDefined();
229+
expect(dataTypes?.operationId).toBe('getUserById');
230+
expect(dataTypes?.responseType).toBe('UserResponse');
231+
});
232+
233+
it('should return empty operationId when not specified', () => {
234+
const code = `
235+
/**
236+
* Get user by ID
237+
* @response UserResponse
238+
*/
239+
export async function GET() {}
240+
`;
241+
242+
const ast = parseTypeScriptFile(code);
243+
let dataTypes;
244+
245+
traverse(ast, {
246+
ExportNamedDeclaration: (path) => {
247+
dataTypes = extractJSDocComments(path);
248+
},
249+
});
250+
251+
expect(dataTypes).toBeDefined();
252+
expect(dataTypes?.operationId).toBe('');
253+
expect(dataTypes?.responseType).toBe('UserResponse');
254+
});
255+
256+
it('should handle @operationId with underscores and numbers', () => {
257+
const code = `
258+
/**
259+
* @operationId create_user_v2
260+
*/
261+
export async function POST() {}
262+
`;
263+
264+
const ast = parseTypeScriptFile(code);
265+
let dataTypes;
266+
267+
traverse(ast, {
268+
ExportNamedDeclaration: (path) => {
269+
dataTypes = extractJSDocComments(path);
270+
},
271+
});
272+
273+
expect(dataTypes?.operationId).toBe('create_user_v2');
274+
});
275+
276+
it('should extract @operationId alongside other tags', () => {
277+
const code = `
278+
/**
279+
* Create new user
280+
* @description Creates a new user in the system
281+
* @operationId createNewUser
282+
* @body CreateUserBody
283+
* @response 201:UserResponse
284+
* @auth bearer
285+
* @tag Users
286+
*/
287+
export async function POST() {}
288+
`;
289+
290+
const ast = parseTypeScriptFile(code);
291+
let dataTypes;
292+
293+
traverse(ast, {
294+
ExportNamedDeclaration: (path) => {
295+
dataTypes = extractJSDocComments(path);
296+
},
297+
});
298+
299+
expect(dataTypes?.operationId).toBe('createNewUser');
300+
expect(dataTypes?.description).toBe('Creates a new user in the system');
301+
expect(dataTypes?.bodyType).toBe('CreateUserBody');
302+
expect(dataTypes?.successCode).toBe('201');
303+
expect(dataTypes?.responseType).toBe('UserResponse');
304+
expect(dataTypes?.auth).toBe('BearerAuth');
305+
expect(dataTypes?.tag).toBe('Users');
306+
});
307+
308+
it('should handle camelCase operationId', () => {
309+
const code = `
310+
/**
311+
* @operationId listAllUsersWithFilters
312+
*/
313+
export async function GET() {}
314+
`;
315+
316+
const ast = parseTypeScriptFile(code);
317+
let dataTypes;
318+
319+
traverse(ast, {
320+
ExportNamedDeclaration: (path) => {
321+
dataTypes = extractJSDocComments(path);
322+
},
323+
});
324+
325+
expect(dataTypes?.operationId).toBe('listAllUsersWithFilters');
326+
});
327+
});

0 commit comments

Comments
 (0)