Skip to content

Commit 3dc6b1c

Browse files
committed
feat: Set operationId in open-api docs
1 parent b870904 commit 3dc6b1c

3 files changed

Lines changed: 88 additions & 6 deletions

File tree

.changeset/red-seals-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/open-api': minor
3+
---
4+
5+
Allow setting operationId in open-api docs

libs/ts-rest/open-api/src/lib/ts-rest-open-api.spec.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,12 +125,69 @@ const expectedApiDoc = {
125125

126126
describe('ts-rest-open-api', () => {
127127
describe('generateOpenApi', () => {
128-
it('w/ no parameters', async () => {
128+
it('should generate doc with defaults', async () => {
129129
const apiDoc = generateOpenApi(router, {
130130
info: { title: 'Blog API', version: '0.1' },
131131
});
132132

133-
expect(apiDoc).toEqual(expectedApiDoc);
133+
expect(apiDoc).toStrictEqual(expectedApiDoc);
134+
});
135+
136+
it('should generate doc with operation ids', async () => {
137+
const apiDoc = generateOpenApi(
138+
router,
139+
{
140+
info: { title: 'Blog API', version: '0.1' },
141+
},
142+
{ setOperationId: true }
143+
);
144+
145+
expect(apiDoc).toEqual({
146+
...expectedApiDoc,
147+
paths: {
148+
'/health': {
149+
get: {
150+
...expectedApiDoc.paths['/health'].get,
151+
operationId: 'health',
152+
},
153+
},
154+
'/posts': {
155+
post: {
156+
...expectedApiDoc.paths['/posts'].post,
157+
operationId: 'createPost',
158+
},
159+
},
160+
'/posts/{id}': {
161+
get: {
162+
...expectedApiDoc.paths['/posts/{id}'].get,
163+
operationId: 'getPost',
164+
},
165+
},
166+
},
167+
});
168+
});
169+
170+
it('should throw when duplicate operationIds', async () => {
171+
const router = c.router({
172+
posts: postsRouter,
173+
getPost: {
174+
method: 'GET',
175+
path: `/posts/:id`,
176+
responses: {
177+
200: c.response<Post | null>(),
178+
},
179+
},
180+
});
181+
182+
expect(() =>
183+
generateOpenApi(
184+
router,
185+
{
186+
info: { title: 'Blog API', version: '0.1' },
187+
},
188+
{ setOperationId: true }
189+
)
190+
).toThrowError(/getPost/);
134191
});
135192
});
136193
});

libs/ts-rest/open-api/src/lib/ts-rest-open-api.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import zodToJsonSchema from 'zod-to-json-schema';
1111
const getPathsFromRouter = (
1212
router: AppRouter,
1313
pathHistory?: string[]
14-
): { path: string; route: AppRoute; paths: string[] }[] => {
15-
const paths: { path: string; route: AppRoute; paths: string[] }[] = [];
14+
): { id: string; path: string; route: AppRoute; paths: string[] }[] => {
15+
const paths: {
16+
id: string;
17+
path: string;
18+
route: AppRoute;
19+
paths: string[];
20+
}[] = [];
1621

1722
Object.keys(router).forEach((key) => {
1823
const value = router[key];
@@ -22,6 +27,7 @@ const getPathsFromRouter = (
2227
const pathWithPathParams = value.path.replace(/:(\w+)/g, '{$1}');
2328

2429
paths.push({
30+
id: key,
2531
path: pathWithPathParams,
2632
route: value,
2733
paths: pathHistory ?? [],
@@ -54,7 +60,8 @@ const getResponseSchemaFromZod = (response: unknown) => {
5460

5561
export const generateOpenApi = (
5662
router: AppRouter,
57-
options: Omit<OpenAPIObject, 'paths' | 'openapi'> & { info: InfoObject }
63+
apiDoc: Omit<OpenAPIObject, 'paths' | 'openapi'> & { info: InfoObject },
64+
options: { setOperationId?: boolean } = {}
5865
): OpenAPIObject => {
5966
const paths = getPathsFromRouter(router);
6067

@@ -66,7 +73,19 @@ export const generateOpenApi = (
6673
PATCH: 'patch',
6774
};
6875

76+
const operationIds = new Map<string, string[]>();
77+
6978
const pathObject = paths.reduce((acc, path) => {
79+
if (options.setOperationId) {
80+
const existingOp = operationIds.get(path.id);
81+
if (existingOp) {
82+
throw new Error(
83+
`Route '${path.id}' already defined under ${existingOp.join('.')}`
84+
);
85+
}
86+
operationIds.set(path.id, path.paths);
87+
}
88+
7089
const paramsFromPath = path.path
7190
.match(/{[^}]+}/g)
7291
?.map((param) => param.slice(1, -1));
@@ -110,6 +129,7 @@ export const generateOpenApi = (
110129
in: 'path',
111130
required: true,
112131
})),
132+
...(options.setOperationId ? { operationId: path.id } : {}),
113133
...(bodySchema
114134
? {
115135
requestBody: {
@@ -136,7 +156,7 @@ export const generateOpenApi = (
136156
const document: OpenAPIObject = {
137157
openapi: '3.0.0',
138158
paths: pathObject,
139-
...options,
159+
...apiDoc,
140160
};
141161

142162
return document;

0 commit comments

Comments
 (0)