@furystack/rest-service 12.2.1 → 12.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/esm/api-manager.d.ts +1 -1
  3. package/esm/api-manager.d.ts.map +1 -1
  4. package/esm/api-manager.js +3 -2
  5. package/esm/api-manager.js.map +1 -1
  6. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +29 -0
  7. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -0
  8. package/esm/endpoint-generators/create-get-openapi-document-action.js +61 -0
  9. package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -0
  10. package/esm/endpoint-generators/index.d.ts +2 -0
  11. package/esm/endpoint-generators/index.d.ts.map +1 -1
  12. package/esm/endpoint-generators/index.js +2 -0
  13. package/esm/endpoint-generators/index.js.map +1 -1
  14. package/esm/endpoint-generators/with-schema-and-openapi-action.d.ts +23 -0
  15. package/esm/endpoint-generators/with-schema-and-openapi-action.d.ts.map +1 -0
  16. package/esm/endpoint-generators/with-schema-and-openapi-action.js +2 -0
  17. package/esm/endpoint-generators/with-schema-and-openapi-action.js.map +1 -0
  18. package/esm/openapi/auth-provider-to-security-scheme.d.ts +14 -0
  19. package/esm/openapi/auth-provider-to-security-scheme.d.ts.map +1 -0
  20. package/esm/openapi/auth-provider-to-security-scheme.js +35 -0
  21. package/esm/openapi/auth-provider-to-security-scheme.js.map +1 -0
  22. package/esm/openapi/auth-provider-to-security-scheme.spec.d.ts +2 -0
  23. package/esm/openapi/auth-provider-to-security-scheme.spec.d.ts.map +1 -0
  24. package/esm/openapi/auth-provider-to-security-scheme.spec.js +42 -0
  25. package/esm/openapi/auth-provider-to-security-scheme.spec.js.map +1 -0
  26. package/esm/openapi/generate-openapi-document.d.ts +21 -0
  27. package/esm/openapi/generate-openapi-document.d.ts.map +1 -0
  28. package/esm/openapi/generate-openapi-document.js +144 -0
  29. package/esm/openapi/generate-openapi-document.js.map +1 -0
  30. package/esm/openapi/generate-openapi-document.spec.d.ts +2 -0
  31. package/esm/openapi/generate-openapi-document.spec.d.ts.map +1 -0
  32. package/esm/openapi/generate-openapi-document.spec.js +643 -0
  33. package/esm/openapi/generate-openapi-document.spec.js.map +1 -0
  34. package/esm/openapi/openapi-round-trip.advanced-api.json +363 -0
  35. package/esm/openapi/openapi-round-trip.crud-api.json +115 -0
  36. package/esm/openapi/openapi-round-trip.example-api.json +71 -0
  37. package/esm/openapi/openapi-round-trip.spec.d.ts +2 -0
  38. package/esm/openapi/openapi-round-trip.spec.d.ts.map +1 -0
  39. package/esm/openapi/openapi-round-trip.spec.js +525 -0
  40. package/esm/openapi/openapi-round-trip.spec.js.map +1 -0
  41. package/esm/swagger/generate-swagger-json.spec.js +1 -1
  42. package/esm/swagger/generate-swagger-json.spec.js.map +1 -1
  43. package/esm/validate.integration.spec.js +153 -32
  44. package/esm/validate.integration.spec.js.map +1 -1
  45. package/package.json +7 -7
  46. package/src/api-manager.ts +7 -3
  47. package/src/endpoint-generators/create-get-openapi-document-action.ts +96 -0
  48. package/src/endpoint-generators/index.ts +2 -0
  49. package/src/endpoint-generators/with-schema-and-openapi-action.ts +14 -0
  50. package/src/openapi/auth-provider-to-security-scheme.spec.ts +50 -0
  51. package/src/openapi/auth-provider-to-security-scheme.ts +41 -0
  52. package/src/openapi/generate-openapi-document.spec.ts +733 -0
  53. package/src/openapi/generate-openapi-document.ts +198 -0
  54. package/src/openapi/openapi-round-trip.advanced-api.json +363 -0
  55. package/src/openapi/openapi-round-trip.crud-api.json +115 -0
  56. package/src/openapi/openapi-round-trip.example-api.json +71 -0
  57. package/src/openapi/openapi-round-trip.spec.ts +621 -0
  58. package/src/swagger/generate-swagger-json.spec.ts +1 -1
  59. package/src/validate.integration.spec.ts +184 -33
  60. package/esm/endpoint-generators/create-get-swagger-json-action.d.ts +0 -14
  61. package/esm/endpoint-generators/create-get-swagger-json-action.d.ts.map +0 -1
  62. package/esm/endpoint-generators/create-get-swagger-json-action.js +0 -18
  63. package/esm/endpoint-generators/create-get-swagger-json-action.js.map +0 -1
  64. package/esm/endpoint-generators/with-schema-and-swagger-action.d.ts +0 -21
  65. package/esm/endpoint-generators/with-schema-and-swagger-action.d.ts.map +0 -1
  66. package/esm/endpoint-generators/with-schema-and-swagger-action.js +0 -2
  67. package/esm/endpoint-generators/with-schema-and-swagger-action.js.map +0 -1
  68. package/esm/swagger/generate-swagger-json.d.ts +0 -14
  69. package/esm/swagger/generate-swagger-json.d.ts.map +0 -1
  70. package/esm/swagger/generate-swagger-json.js +0 -108
  71. package/esm/swagger/generate-swagger-json.js.map +0 -1
  72. package/src/endpoint-generators/create-get-swagger-json-action.ts +0 -26
  73. package/src/endpoint-generators/with-schema-and-swagger-action.ts +0 -11
  74. package/src/swagger/generate-swagger-json.ts +0 -132
@@ -0,0 +1,71 @@
1
+ {
2
+ "openapi": "3.1.0",
3
+ "info": {
4
+ "title": "Simple API overview",
5
+ "version": "2.0.0"
6
+ },
7
+ "paths": {
8
+ "/": {
9
+ "get": {
10
+ "operationId": "listVersionsv2",
11
+ "summary": "List API versions",
12
+ "responses": {
13
+ "200": {
14
+ "description": "200 response",
15
+ "content": {
16
+ "application/json": {
17
+ "examples": {
18
+ "foo": {
19
+ "value": {
20
+ "versions": [
21
+ {
22
+ "status": "CURRENT",
23
+ "updated": "2011-01-21T11:33:21Z",
24
+ "id": "v2.0",
25
+ "links": [
26
+ {
27
+ "href": "http://127.0.0.1:8774/v2/",
28
+ "rel": "self"
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "status": "EXPERIMENTAL",
34
+ "updated": "2013-07-23T11:33:21Z",
35
+ "id": "v3.0",
36
+ "links": [
37
+ {
38
+ "href": "http://127.0.0.1:8774/v3/",
39
+ "rel": "self"
40
+ }
41
+ ]
42
+ }
43
+ ]
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ },
50
+ "300": {
51
+ "description": "300 response"
52
+ }
53
+ }
54
+ }
55
+ },
56
+ "/v2": {
57
+ "get": {
58
+ "operationId": "getVersionDetailsv2",
59
+ "summary": "Show API version details",
60
+ "responses": {
61
+ "200": {
62
+ "description": "200 response"
63
+ },
64
+ "203": {
65
+ "description": "203 response"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,621 @@
1
+ import type { OpenApiDocument, OpenApiToRestApi, ParameterObject, ResponseObject, RestApi } from '@furystack/rest'
2
+ import { openApiToSchema, resolveOpenApiRefs } from '@furystack/rest'
3
+ import { describe, expect, expectTypeOf, it } from 'vitest'
4
+ import { generateOpenApiDocument } from './generate-openapi-document.js'
5
+ import exampleApiDoc from './openapi-round-trip.example-api.json' with { type: 'json' }
6
+ import crudApiDoc from './openapi-round-trip.crud-api.json' with { type: 'json' }
7
+ import advancedApiDoc from './openapi-round-trip.advanced-api.json' with { type: 'json' }
8
+
9
+ const roundTrip = (doc: OpenApiDocument) => {
10
+ const schema = openApiToSchema(doc)
11
+ return generateOpenApiDocument({
12
+ api: schema.endpoints,
13
+ title: schema.name,
14
+ description: schema.description,
15
+ version: schema.version,
16
+ metadata: schema.metadata,
17
+ })
18
+ }
19
+
20
+ describe('OpenAPI round-trip: learn.openapis.org example (imported from JSON)', () => {
21
+ type ExampleApi = OpenApiToRestApi<typeof exampleApiDoc>
22
+
23
+ describe('Type-level extraction from JSON import', () => {
24
+ it('Should produce a valid RestApi type', () => {
25
+ expectTypeOf<ExampleApi>().toExtend<RestApi>()
26
+ })
27
+
28
+ it('Should have GET method with both paths', () => {
29
+ expectTypeOf<ExampleApi['GET']>().toHaveProperty('/')
30
+ expectTypeOf<ExampleApi['GET']>().toHaveProperty('/v2')
31
+ })
32
+
33
+ it('Should have unknown result (no schema defined, only examples)', () => {
34
+ expectTypeOf<ExampleApi['GET']['/']['result']>().toEqualTypeOf<unknown>()
35
+ expectTypeOf<ExampleApi['GET']['/v2']['result']>().toEqualTypeOf<unknown>()
36
+ })
37
+ })
38
+
39
+ describe('Runtime round-trip from JSON import', () => {
40
+ it('Should satisfy OpenApiDocument at runtime', () => {
41
+ const doc: OpenApiDocument = exampleApiDoc
42
+ expect(doc.openapi).toBe('3.1.0')
43
+ })
44
+
45
+ it('Should preserve document info through round-trip', () => {
46
+ const regenerated = roundTrip(exampleApiDoc)
47
+ expect(regenerated.info.title).toBe('Simple API overview')
48
+ expect(regenerated.info.version).toBe('2.0.0')
49
+ })
50
+
51
+ it('Should preserve paths through round-trip', () => {
52
+ const regenerated = roundTrip(exampleApiDoc)
53
+ expect(Object.keys(regenerated.paths ?? {}).sort()).toEqual(['/', '/v2'])
54
+ })
55
+
56
+ it('Should preserve HTTP methods through round-trip', () => {
57
+ const regenerated = roundTrip(exampleApiDoc)
58
+ expect(regenerated.paths!['/']?.get).toBeDefined()
59
+ expect(regenerated.paths!['/']?.post).toBeUndefined()
60
+ expect(regenerated.paths!['/v2']?.get).toBeDefined()
61
+ expect(regenerated.paths!['/v2']?.post).toBeUndefined()
62
+ })
63
+
64
+ it('Should produce valid OpenAPI 3.1 structure', () => {
65
+ const regenerated = roundTrip(exampleApiDoc)
66
+ expect(regenerated.openapi).toBe('3.1.0')
67
+ expect(regenerated.info).toBeDefined()
68
+ expect(regenerated.paths).toBeDefined()
69
+ expect(regenerated.components).toBeDefined()
70
+ })
71
+ })
72
+ })
73
+
74
+ describe('Round-trip: CRUD API (imported from JSON)', () => {
75
+ const typedCrudDoc = crudApiDoc as OpenApiDocument
76
+
77
+ describe('Runtime round-trip from JSON import', () => {
78
+ it('Should preserve document info', () => {
79
+ const regenerated = roundTrip(typedCrudDoc)
80
+ expect(regenerated.info.title).toBe('CRUD API')
81
+ expect(regenerated.info.version).toBe('1.0.0')
82
+ })
83
+
84
+ it('Should preserve all paths', () => {
85
+ const regenerated = roundTrip(typedCrudDoc)
86
+ expect(Object.keys(regenerated.paths ?? {}).sort()).toEqual(['/users', '/users/{id}'])
87
+ })
88
+
89
+ it('Should preserve all HTTP methods', () => {
90
+ const regenerated = roundTrip(typedCrudDoc)
91
+ expect(regenerated.paths!['/users']?.get).toBeDefined()
92
+ expect(regenerated.paths!['/users']?.post).toBeDefined()
93
+ expect(regenerated.paths!['/users/{id}']?.get).toBeDefined()
94
+ expect(regenerated.paths!['/users/{id}']?.delete).toBeDefined()
95
+ })
96
+
97
+ it('Should preserve path parameters', () => {
98
+ const regenerated = roundTrip(typedCrudDoc)
99
+ const getParams = regenerated.paths!['/users/{id}']?.get?.parameters as ParameterObject[]
100
+ expect(getParams.some((p) => p.name === 'id' && p.in === 'path')).toBe(true)
101
+ })
102
+
103
+ it('Should preserve response schemas', () => {
104
+ const regenerated = roundTrip(typedCrudDoc)
105
+ expect(regenerated.components?.schemas?.listUsers).toEqual(
106
+ crudApiDoc.paths['/users'].get.responses['200'].content['application/json'].schema,
107
+ )
108
+ expect(regenerated.components?.schemas?.createUser).toEqual(
109
+ crudApiDoc.paths['/users'].post.responses['201'].content['application/json'].schema,
110
+ )
111
+ })
112
+ })
113
+ })
114
+
115
+ describe('Round-trip: individual construct tests', () => {
116
+ describe('Response schema', () => {
117
+ const doc = {
118
+ openapi: '3.1.0',
119
+ info: { title: 'Test', version: '1.0.0' },
120
+ paths: {
121
+ '/users': {
122
+ get: {
123
+ operationId: 'listUsers',
124
+ responses: {
125
+ '200': {
126
+ description: 'OK',
127
+ content: {
128
+ 'application/json': {
129
+ schema: {
130
+ type: 'array',
131
+ items: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } } },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ },
138
+ },
139
+ },
140
+ } as const satisfies OpenApiDocument
141
+
142
+ type Api = OpenApiToRestApi<typeof doc>
143
+
144
+ it('Should extract typed response at the type level', () => {
145
+ expectTypeOf<Api['GET']['/users']['result']>().toExtend<Array<{ id?: string; name?: string }>>()
146
+ })
147
+
148
+ it('Should preserve response schema through round-trip', () => {
149
+ const regenerated = roundTrip(doc)
150
+ expect(regenerated.components?.schemas?.listUsers).toEqual(
151
+ doc.paths['/users'].get.responses['200'].content['application/json'].schema,
152
+ )
153
+ })
154
+ })
155
+
156
+ describe('Path parameters', () => {
157
+ const doc = {
158
+ openapi: '3.1.0',
159
+ info: { title: 'Test', version: '1.0.0' },
160
+ paths: {
161
+ '/users/{userId}/posts/{postId}': {
162
+ get: {
163
+ parameters: [
164
+ { name: 'userId', in: 'path' as const, required: true, schema: { type: 'string' } },
165
+ { name: 'postId', in: 'path' as const, required: true, schema: { type: 'string' } },
166
+ ],
167
+ responses: { '200': { description: 'OK' } },
168
+ },
169
+ },
170
+ },
171
+ } as const satisfies OpenApiDocument
172
+
173
+ type Api = OpenApiToRestApi<typeof doc>
174
+
175
+ it('Should extract path params at the type level', () => {
176
+ type Url = Api['GET']['/users/:userId/posts/:postId']['url']
177
+ expectTypeOf<Url>().toHaveProperty('userId')
178
+ expectTypeOf<Url>().toHaveProperty('postId')
179
+ })
180
+
181
+ it('Should convert {param} to :param and back through round-trip', () => {
182
+ const regenerated = roundTrip(doc)
183
+ expect(regenerated.paths?.['/users/{userId}/posts/{postId}']).toBeDefined()
184
+ const params = regenerated.paths?.['/users/{userId}/posts/{postId}']?.get?.parameters as ParameterObject[]
185
+ expect(params.some((p) => p.name === 'userId' && p.in === 'path')).toBe(true)
186
+ expect(params.some((p) => p.name === 'postId' && p.in === 'path')).toBe(true)
187
+ })
188
+ })
189
+
190
+ describe('Request body', () => {
191
+ const doc = {
192
+ openapi: '3.1.0',
193
+ info: { title: 'Test', version: '1.0.0' },
194
+ paths: {
195
+ '/users': {
196
+ post: {
197
+ operationId: 'createUser',
198
+ requestBody: {
199
+ required: true,
200
+ content: {
201
+ 'application/json': {
202
+ schema: {
203
+ type: 'object',
204
+ properties: { name: { type: 'string' }, email: { type: 'string' } },
205
+ required: ['name', 'email'],
206
+ },
207
+ },
208
+ },
209
+ },
210
+ responses: {
211
+ '201': {
212
+ description: 'Created',
213
+ content: {
214
+ 'application/json': {
215
+ schema: { type: 'object', properties: { id: { type: 'string' } } },
216
+ },
217
+ },
218
+ },
219
+ },
220
+ },
221
+ },
222
+ },
223
+ } as const satisfies OpenApiDocument
224
+
225
+ type Api = OpenApiToRestApi<typeof doc>
226
+
227
+ it('Should extract request body type', () => {
228
+ expectTypeOf<Api['POST']['/users']['body']>().toExtend<{ name: string; email: string }>()
229
+ })
230
+
231
+ it('Should extract response type from 201', () => {
232
+ expectTypeOf<Api['POST']['/users']['result']>().toExtend<{ id?: string }>()
233
+ })
234
+ })
235
+
236
+ describe('Query parameters', () => {
237
+ const doc = {
238
+ openapi: '3.1.0',
239
+ info: { title: 'Test', version: '1.0.0' },
240
+ paths: {
241
+ '/search': {
242
+ get: {
243
+ parameters: [
244
+ { name: 'q', in: 'query' as const, schema: { type: 'string' } },
245
+ { name: 'limit', in: 'query' as const, schema: { type: 'integer' } },
246
+ ],
247
+ responses: {
248
+ '200': {
249
+ description: 'OK',
250
+ content: { 'application/json': { schema: { type: 'array', items: { type: 'string' } } } },
251
+ },
252
+ },
253
+ },
254
+ },
255
+ },
256
+ } as const satisfies OpenApiDocument
257
+
258
+ type Api = OpenApiToRestApi<typeof doc>
259
+
260
+ it('Should extract query params at the type level', () => {
261
+ type Query = Api['GET']['/search']['query']
262
+ expectTypeOf<Query>().toHaveProperty('q')
263
+ expectTypeOf<Query>().toHaveProperty('limit')
264
+ })
265
+
266
+ it('Should preserve result type', () => {
267
+ expectTypeOf<Api['GET']['/search']['result']>().toExtend<string[]>()
268
+ })
269
+ })
270
+
271
+ describe('Authentication', () => {
272
+ const doc = {
273
+ openapi: '3.1.0',
274
+ info: { title: 'Test', version: '1.0.0' },
275
+ paths: {
276
+ '/public': {
277
+ get: { security: [], responses: { '200': { description: 'OK' } } },
278
+ },
279
+ '/private': {
280
+ get: { security: [{ bearerAuth: [] }], responses: { '200': { description: 'OK' } } },
281
+ },
282
+ },
283
+ } as const satisfies OpenApiDocument
284
+
285
+ it('Should preserve authentication through openApiToSchema', () => {
286
+ const schema = openApiToSchema(doc)
287
+ expect(schema.endpoints.GET!['/public'].isAuthenticated).toBe(false)
288
+ expect(schema.endpoints.GET!['/private'].isAuthenticated).toBe(true)
289
+ })
290
+
291
+ it('Should preserve security scheme names through round-trip', () => {
292
+ const regenerated = roundTrip(doc)
293
+ expect(regenerated.paths?.['/public']?.get?.security).toEqual([])
294
+ expect(regenerated.paths?.['/private']?.get?.security).toEqual([{ bearerAuth: [] }])
295
+ })
296
+ })
297
+
298
+ describe('HEAD, OPTIONS, TRACE methods', () => {
299
+ const doc = {
300
+ openapi: '3.1.0',
301
+ info: { title: 'Test', version: '1.0.0' },
302
+ paths: {
303
+ '/items': {
304
+ head: { responses: { '200': { description: 'OK' } } },
305
+ options: { responses: { '200': { description: 'OK' } } },
306
+ trace: { responses: { '200': { description: 'OK' } } },
307
+ },
308
+ },
309
+ } as const satisfies OpenApiDocument
310
+
311
+ it('Should preserve HEAD, OPTIONS, TRACE through round-trip', () => {
312
+ const regenerated = roundTrip(doc)
313
+ expect(regenerated.paths?.['/items']?.head).toBeDefined()
314
+ expect(regenerated.paths?.['/items']?.options).toBeDefined()
315
+ expect(regenerated.paths?.['/items']?.trace).toBeDefined()
316
+ })
317
+ })
318
+
319
+ describe('No response schema', () => {
320
+ const doc = {
321
+ openapi: '3.1.0',
322
+ info: { title: 'Test', version: '1.0.0' },
323
+ paths: {
324
+ '/health': {
325
+ get: { responses: { '200': { description: 'OK' } } },
326
+ },
327
+ },
328
+ } as const satisfies OpenApiDocument
329
+
330
+ type Api = OpenApiToRestApi<typeof doc>
331
+
332
+ it('Should have unknown result type', () => {
333
+ expectTypeOf<Api['GET']['/health']['result']>().toEqualTypeOf<unknown>()
334
+ })
335
+
336
+ it('Should still produce the endpoint in the round-trip', () => {
337
+ const regenerated = roundTrip(doc)
338
+ expect(regenerated.paths?.['/health']?.get).toBeDefined()
339
+ const response = regenerated.paths?.['/health']?.get?.responses?.['200'] as ResponseObject
340
+ expect(response).toBeDefined()
341
+ })
342
+ })
343
+ })
344
+
345
+ // ─── Advanced API round-trip ─────────────────────────────────────────────────
346
+
347
+ describe('Round-trip: advanced API (imported from JSON)', () => {
348
+ const resolvedDoc = resolveOpenApiRefs(advancedApiDoc as OpenApiDocument)
349
+
350
+ const advancedRoundTrip = (doc: OpenApiDocument) => {
351
+ const resolved = resolveOpenApiRefs(doc)
352
+ const schema = openApiToSchema(resolved)
353
+ return generateOpenApiDocument({
354
+ api: schema.endpoints,
355
+ title: schema.name,
356
+ description: schema.description,
357
+ version: schema.version,
358
+ metadata: schema.metadata,
359
+ })
360
+ }
361
+
362
+ describe('$ref resolution', () => {
363
+ it('Should resolve $ref in response schemas', () => {
364
+ const getPetResp = resolvedDoc.paths?.['/pets/{petId}']?.get?.responses?.['200'] as Record<string, unknown>
365
+ const content = getPetResp.content as Record<string, { schema: Record<string, unknown> }>
366
+ const { schema } = content['application/json']
367
+ expect(schema.allOf).toBeDefined()
368
+ })
369
+
370
+ it('Should resolve $ref parameters from components', () => {
371
+ const params = resolvedDoc.paths?.['/pets']?.get?.parameters as Array<Record<string, unknown>>
372
+ const limitParam = params.find((p) => p.name === 'limit')
373
+ expect(limitParam).toBeDefined()
374
+ expect(limitParam?.in).toBe('query')
375
+ })
376
+
377
+ it('Should resolve $ref in request body schemas', () => {
378
+ const body = resolvedDoc.paths?.['/pets']?.post?.requestBody as Record<string, unknown>
379
+ const content = body.content as Record<string, { schema: Record<string, unknown> }>
380
+ const { schema } = content['application/json']
381
+ expect(schema.type).toBe('object')
382
+ expect(schema.properties).toBeDefined()
383
+ })
384
+
385
+ it('Should resolve nested $ref within allOf', () => {
386
+ const petSchema = resolvedDoc.components?.schemas?.Pet as Record<string, unknown>
387
+ const allOfItems = petSchema.allOf as Array<Record<string, unknown>>
388
+ const firstItem = allOfItems[0]
389
+ expect(firstItem.type).toBe('object')
390
+ expect(firstItem.properties).toBeDefined()
391
+ })
392
+ })
393
+
394
+ describe('Document-level metadata round-trip', () => {
395
+ it('Should preserve info fields', () => {
396
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
397
+ expect(regenerated.info.title).toBe('Advanced Pet Store API')
398
+ expect(regenerated.info.version).toBe('2.1.0')
399
+ expect(regenerated.info.description).toBe('A feature-rich API exercising all OpenAPI 3.1 constructs')
400
+ expect(regenerated.info.summary).toBe('Pet store with advanced features')
401
+ expect(regenerated.info.termsOfService).toBe('https://example.com/terms')
402
+ })
403
+
404
+ it('Should preserve contact info', () => {
405
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
406
+ expect(regenerated.info.contact).toEqual({
407
+ name: 'API Support',
408
+ url: 'https://example.com/support',
409
+ email: 'support@example.com',
410
+ })
411
+ })
412
+
413
+ it('Should preserve license info', () => {
414
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
415
+ expect(regenerated.info.license).toEqual({ name: 'MIT', identifier: 'MIT' })
416
+ })
417
+
418
+ it('Should preserve servers with variables', () => {
419
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
420
+ expect(regenerated.servers).toHaveLength(1)
421
+ expect(regenerated.servers?.[0].url).toBe('https://{environment}.example.com/api/{version}')
422
+ expect(regenerated.servers?.[0].variables?.environment?.default).toBe('production')
423
+ })
424
+
425
+ it('Should preserve tags', () => {
426
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
427
+ expect(regenerated.tags).toHaveLength(2)
428
+ expect(regenerated.tags?.find((t) => t.name === 'pets')).toBeDefined()
429
+ expect(regenerated.tags?.find((t) => t.name === 'store')?.externalDocs?.url).toBe(
430
+ 'https://example.com/docs/store',
431
+ )
432
+ })
433
+
434
+ it('Should preserve externalDocs', () => {
435
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
436
+ expect(regenerated.externalDocs).toEqual({
437
+ description: 'Full documentation',
438
+ url: 'https://example.com/docs',
439
+ })
440
+ })
441
+
442
+ it('Should preserve security schemes', () => {
443
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
444
+ expect(regenerated.components?.securitySchemes?.bearerAuth).toEqual({
445
+ type: 'http',
446
+ scheme: 'bearer',
447
+ bearerFormat: 'JWT',
448
+ })
449
+ expect(regenerated.components?.securitySchemes?.apiKey).toBeDefined()
450
+ })
451
+ })
452
+
453
+ describe('Operation metadata round-trip', () => {
454
+ it('Should preserve operation summary and description', () => {
455
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
456
+ expect(regenerated.paths?.['/pets']?.get?.summary).toBe('List all pets')
457
+ expect(regenerated.paths?.['/pets']?.get?.description).toBe(
458
+ 'Returns a paginated list of pets with optional filtering',
459
+ )
460
+ })
461
+
462
+ it('Should preserve operation tags', () => {
463
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
464
+ expect(regenerated.paths?.['/pets']?.get?.tags).toEqual(['pets'])
465
+ expect(regenerated.paths?.['/store/inventory']?.get?.tags).toEqual(['store'])
466
+ })
467
+
468
+ it('Should preserve deprecated flag', () => {
469
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
470
+ expect(regenerated.paths?.['/pets/{petId}']?.delete?.deprecated).toBe(true)
471
+ })
472
+
473
+ it('Should preserve authentication: public override with empty security', () => {
474
+ const schema = openApiToSchema(resolvedDoc)
475
+ expect(schema.endpoints.GET?.['/pets']?.isAuthenticated).toBe(false)
476
+ })
477
+
478
+ it('Should preserve authentication: inherited from document-level security', () => {
479
+ const schema = openApiToSchema(resolvedDoc)
480
+ expect(schema.endpoints.GET?.['/pets/:petId']?.isAuthenticated).toBe(true)
481
+ })
482
+ })
483
+
484
+ describe('Structural round-trip', () => {
485
+ it('Should preserve all paths', () => {
486
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
487
+ const paths = Object.keys(regenerated.paths ?? {}).sort()
488
+ expect(paths).toEqual(['/pets', '/pets/{petId}', '/store/inventory', '/store/orders'])
489
+ })
490
+
491
+ it('Should preserve all HTTP methods per path', () => {
492
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
493
+ expect(regenerated.paths?.['/pets']?.get).toBeDefined()
494
+ expect(regenerated.paths?.['/pets']?.post).toBeDefined()
495
+ expect(regenerated.paths?.['/pets/{petId}']?.get).toBeDefined()
496
+ expect(regenerated.paths?.['/pets/{petId}']?.patch).toBeDefined()
497
+ expect(regenerated.paths?.['/pets/{petId}']?.delete).toBeDefined()
498
+ expect(regenerated.paths?.['/store/inventory']?.get).toBeDefined()
499
+ expect(regenerated.paths?.['/store/orders']?.post).toBeDefined()
500
+ })
501
+
502
+ it('Should preserve path parameters', () => {
503
+ const regenerated = advancedRoundTrip(advancedApiDoc as OpenApiDocument)
504
+ const params = regenerated.paths?.['/pets/{petId}']?.get?.parameters as ParameterObject[]
505
+ expect(params.some((p) => p.name === 'petId' && p.in === 'path')).toBe(true)
506
+ })
507
+ })
508
+
509
+ describe('Type-level extraction (as const inline subset)', () => {
510
+ const typedDoc = {
511
+ openapi: '3.1.0',
512
+ info: { title: 'Test', version: '1.0.0' },
513
+ paths: {
514
+ '/pets': {
515
+ get: {
516
+ tags: ['pets'],
517
+ summary: 'List all pets',
518
+ description: 'Returns a list of pets',
519
+ security: [],
520
+ responses: {
521
+ '200': {
522
+ description: 'OK',
523
+ content: {
524
+ 'application/json': {
525
+ schema: { type: 'object', properties: { name: { type: 'string' } } },
526
+ },
527
+ },
528
+ },
529
+ },
530
+ },
531
+ post: {
532
+ requestBody: {
533
+ content: {
534
+ 'application/json': {
535
+ schema: { $ref: '#/components/schemas/CreatePet' },
536
+ },
537
+ },
538
+ },
539
+ responses: { '201': { description: 'Created' } },
540
+ },
541
+ },
542
+ '/pets/{petId}': {
543
+ get: {
544
+ responses: {
545
+ '200': {
546
+ description: 'OK',
547
+ content: {
548
+ 'application/json': {
549
+ schema: { $ref: '#/components/schemas/Pet' },
550
+ },
551
+ },
552
+ },
553
+ },
554
+ },
555
+ delete: {
556
+ deprecated: true,
557
+ description: 'Use archive instead',
558
+ responses: { '204': { description: 'Deleted' } },
559
+ },
560
+ },
561
+ },
562
+ components: {
563
+ schemas: {
564
+ Pet: {
565
+ allOf: [
566
+ { $ref: '#/components/schemas/CreatePet' },
567
+ { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
568
+ ],
569
+ },
570
+ CreatePet: {
571
+ type: 'object',
572
+ properties: { name: { type: 'string' } },
573
+ required: ['name'],
574
+ },
575
+ },
576
+ },
577
+ } as const satisfies OpenApiDocument
578
+
579
+ type Api = OpenApiToRestApi<typeof typedDoc>
580
+
581
+ it('Should produce a valid RestApi type', () => {
582
+ expectTypeOf<Api>().toExtend<RestApi>()
583
+ })
584
+
585
+ it('Should have GET and POST on /pets, GET and DELETE on /pets/:petId', () => {
586
+ expectTypeOf<Api['GET']>().toHaveProperty('/pets')
587
+ expectTypeOf<Api['POST']>().toHaveProperty('/pets')
588
+ expectTypeOf<Api['GET']>().toHaveProperty('/pets/:petId')
589
+ expectTypeOf<Api['DELETE']>().toHaveProperty('/pets/:petId')
590
+ })
591
+
592
+ it('Should resolve $ref in GET /pets/:petId response via allOf', () => {
593
+ type Result = Api['GET']['/pets/:petId']['result']
594
+ expectTypeOf<Result>().toHaveProperty('id')
595
+ expectTypeOf<Result>().toHaveProperty('name')
596
+ })
597
+
598
+ it('Should resolve $ref in POST /pets request body', () => {
599
+ type Body = Api['POST']['/pets']['body']
600
+ expectTypeOf<Body>().toHaveProperty('name')
601
+ })
602
+
603
+ it('Should extract path params from /pets/{petId}', () => {
604
+ type Url = Api['GET']['/pets/:petId']['url']
605
+ expectTypeOf<Url>().toHaveProperty('petId')
606
+ })
607
+
608
+ it('Should extract tags metadata', () => {
609
+ type ListPets = Api['GET']['/pets']
610
+ expectTypeOf<ListPets>().toHaveProperty('tags')
611
+ expectTypeOf<ListPets>().toHaveProperty('summary')
612
+ expectTypeOf<ListPets>().toHaveProperty('description')
613
+ })
614
+
615
+ it('Should extract deprecated flag', () => {
616
+ type DeletePet = Api['DELETE']['/pets/:petId']
617
+ expectTypeOf<DeletePet>().toHaveProperty('deprecated')
618
+ expectTypeOf<DeletePet>().toHaveProperty('description')
619
+ })
620
+ })
621
+ })