@furystack/rest 8.0.41 → 8.1.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 (54) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +37 -1
  3. package/esm/api-endpoint-schema.d.ts +47 -2
  4. package/esm/api-endpoint-schema.d.ts.map +1 -1
  5. package/esm/index.d.ts +4 -1
  6. package/esm/index.d.ts.map +1 -1
  7. package/esm/index.js +4 -1
  8. package/esm/index.js.map +1 -1
  9. package/esm/openapi-document.d.ts +303 -0
  10. package/esm/openapi-document.d.ts.map +1 -0
  11. package/esm/openapi-document.js +2 -0
  12. package/esm/openapi-document.js.map +1 -0
  13. package/esm/openapi-resolve-refs.d.ts +20 -0
  14. package/esm/openapi-resolve-refs.d.ts.map +1 -0
  15. package/esm/openapi-resolve-refs.js +68 -0
  16. package/esm/openapi-resolve-refs.js.map +1 -0
  17. package/esm/openapi-resolve-refs.spec.d.ts +2 -0
  18. package/esm/openapi-resolve-refs.spec.d.ts.map +1 -0
  19. package/esm/openapi-resolve-refs.spec.js +294 -0
  20. package/esm/openapi-resolve-refs.spec.js.map +1 -0
  21. package/esm/openapi-to-rest-api.d.ts +197 -0
  22. package/esm/openapi-to-rest-api.d.ts.map +1 -0
  23. package/esm/openapi-to-rest-api.js +2 -0
  24. package/esm/openapi-to-rest-api.js.map +1 -0
  25. package/esm/openapi-to-rest-api.spec.d.ts +2 -0
  26. package/esm/openapi-to-rest-api.spec.d.ts.map +1 -0
  27. package/esm/openapi-to-rest-api.spec.js +665 -0
  28. package/esm/openapi-to-rest-api.spec.js.map +1 -0
  29. package/esm/openapi-to-schema.d.ts +24 -0
  30. package/esm/openapi-to-schema.d.ts.map +1 -0
  31. package/esm/openapi-to-schema.js +145 -0
  32. package/esm/openapi-to-schema.js.map +1 -0
  33. package/esm/openapi-to-schema.spec.d.ts +2 -0
  34. package/esm/openapi-to-schema.spec.d.ts.map +1 -0
  35. package/esm/openapi-to-schema.spec.js +610 -0
  36. package/esm/openapi-to-schema.spec.js.map +1 -0
  37. package/esm/rest-api.d.ts +21 -4
  38. package/esm/rest-api.d.ts.map +1 -1
  39. package/esm/swagger-document.d.ts +2 -195
  40. package/esm/swagger-document.d.ts.map +1 -1
  41. package/esm/swagger-document.js +2 -1
  42. package/esm/swagger-document.js.map +1 -1
  43. package/package.json +4 -4
  44. package/src/api-endpoint-schema.ts +56 -3
  45. package/src/index.ts +4 -1
  46. package/src/openapi-document.ts +328 -0
  47. package/src/openapi-resolve-refs.spec.ts +324 -0
  48. package/src/openapi-resolve-refs.ts +71 -0
  49. package/src/openapi-to-rest-api.spec.ts +823 -0
  50. package/src/openapi-to-rest-api.ts +263 -0
  51. package/src/openapi-to-schema.spec.ts +707 -0
  52. package/src/openapi-to-schema.ts +163 -0
  53. package/src/rest-api.ts +26 -5
  54. package/src/swagger-document.ts +2 -220
@@ -0,0 +1,823 @@
1
+ import { describe, it, expectTypeOf } from 'vitest'
2
+ import type { OpenApiDocument } from './openapi-document.js'
3
+ import type { ConvertOpenApiPath, JsonSchemaToType, OpenApiToRestApi } from './openapi-to-rest-api.js'
4
+ import type { RestApi } from './rest-api.js'
5
+
6
+ describe('ConvertOpenApiPath', () => {
7
+ it('Should convert single {param} to :param', () => {
8
+ expectTypeOf<ConvertOpenApiPath<'/users/{id}'>>().toEqualTypeOf<'/users/:id'>()
9
+ })
10
+
11
+ it('Should convert multiple params', () => {
12
+ expectTypeOf<ConvertOpenApiPath<'/users/{userId}/posts/{postId}'>>().toEqualTypeOf<'/users/:userId/posts/:postId'>()
13
+ })
14
+
15
+ it('Should pass through paths without params', () => {
16
+ expectTypeOf<ConvertOpenApiPath<'/users'>>().toEqualTypeOf<'/users'>()
17
+ })
18
+
19
+ it('Should handle root path', () => {
20
+ expectTypeOf<ConvertOpenApiPath<'/'>>().toEqualTypeOf<'/'>()
21
+ })
22
+
23
+ it('Should handle param at the end', () => {
24
+ expectTypeOf<ConvertOpenApiPath<'/{version}'>>().toEqualTypeOf<'/:version'>()
25
+ })
26
+
27
+ it('Should handle adjacent segments with params', () => {
28
+ expectTypeOf<ConvertOpenApiPath<'/{a}/{b}'>>().toEqualTypeOf<'/:a/:b'>()
29
+ })
30
+ })
31
+
32
+ describe('JsonSchemaToType', () => {
33
+ describe('Primitive types', () => {
34
+ it('Should map string', () => {
35
+ expectTypeOf<JsonSchemaToType<{ type: 'string' }>>().toEqualTypeOf<string>()
36
+ })
37
+
38
+ it('Should map number', () => {
39
+ expectTypeOf<JsonSchemaToType<{ type: 'number' }>>().toEqualTypeOf<number>()
40
+ })
41
+
42
+ it('Should map integer to number', () => {
43
+ expectTypeOf<JsonSchemaToType<{ type: 'integer' }>>().toEqualTypeOf<number>()
44
+ })
45
+
46
+ it('Should map boolean', () => {
47
+ expectTypeOf<JsonSchemaToType<{ type: 'boolean' }>>().toEqualTypeOf<boolean>()
48
+ })
49
+
50
+ it('Should map null', () => {
51
+ expectTypeOf<JsonSchemaToType<{ type: 'null' }>>().toEqualTypeOf<null>()
52
+ })
53
+ })
54
+
55
+ describe('String enums', () => {
56
+ it('Should map string enum to union', () => {
57
+ type Schema = { type: 'string'; enum: readonly ['a', 'b', 'c'] }
58
+ expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<'a' | 'b' | 'c'>()
59
+ })
60
+
61
+ it('Should map single-value enum', () => {
62
+ type Schema = { type: 'string'; enum: readonly ['only'] }
63
+ expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<'only'>()
64
+ })
65
+ })
66
+
67
+ describe('Arrays', () => {
68
+ it('Should map string array', () => {
69
+ expectTypeOf<JsonSchemaToType<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>()
70
+ })
71
+
72
+ it('Should map number array', () => {
73
+ expectTypeOf<JsonSchemaToType<{ type: 'array'; items: { type: 'number' } }>>().toEqualTypeOf<number[]>()
74
+ })
75
+
76
+ it('Should map nested object array', () => {
77
+ type Schema = { type: 'array'; items: { type: 'object'; properties: { id: { type: 'string' } } } }
78
+ expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<Array<{ id?: string }>>()
79
+ })
80
+ })
81
+
82
+ describe('Objects', () => {
83
+ it('Should map object with all-optional properties', () => {
84
+ type Schema = { type: 'object'; properties: { name: { type: 'string' }; age: { type: 'number' } } }
85
+ expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<{ name?: string; age?: number }>()
86
+ })
87
+
88
+ it('Should map object with required properties', () => {
89
+ type Schema = {
90
+ type: 'object'
91
+ properties: { name: { type: 'string' }; age: { type: 'number' } }
92
+ required: readonly ['name']
93
+ }
94
+ expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<{ name: string } & { age?: number }>()
95
+ })
96
+
97
+ it('Should map object with all required properties', () => {
98
+ type Schema = {
99
+ type: 'object'
100
+ properties: { name: { type: 'string' }; age: { type: 'number' } }
101
+ required: readonly ['name', 'age']
102
+ }
103
+ type Result = JsonSchemaToType<Schema>
104
+ expectTypeOf<Result>().toHaveProperty('name')
105
+ expectTypeOf<Result['name']>().toEqualTypeOf<string>()
106
+ expectTypeOf<Result['age']>().toEqualTypeOf<number>()
107
+ })
108
+
109
+ it('Should map object without properties to Record<string, unknown>', () => {
110
+ expectTypeOf<JsonSchemaToType<{ type: 'object' }>>().toEqualTypeOf<Record<string, unknown>>()
111
+ })
112
+ })
113
+
114
+ describe('Fallback', () => {
115
+ it('Should return unknown for unrecognized schemas', () => {
116
+ expectTypeOf<JsonSchemaToType<{ description: 'something' }>>().toEqualTypeOf<unknown>()
117
+ })
118
+
119
+ it('Should return unknown for empty object', () => {
120
+ expectTypeOf<JsonSchemaToType<Record<string, never>>>().toEqualTypeOf<unknown>()
121
+ })
122
+ })
123
+ })
124
+
125
+ describe('OpenApiToRestApi', () => {
126
+ describe('HTTP methods', () => {
127
+ it('Should extract GET endpoints', () => {
128
+ const doc = {
129
+ openapi: '3.1.0',
130
+ info: { title: 'Test', version: '1.0.0' },
131
+ paths: {
132
+ '/items': { get: { responses: { '200': { description: 'OK' } } } },
133
+ },
134
+ } as const satisfies OpenApiDocument
135
+
136
+ type Api = OpenApiToRestApi<typeof doc>
137
+ expectTypeOf<Api>().toExtend<RestApi>()
138
+ expectTypeOf<Api>().toHaveProperty('GET')
139
+ })
140
+
141
+ it('Should extract POST endpoints', () => {
142
+ const doc = {
143
+ openapi: '3.1.0',
144
+ info: { title: 'Test', version: '1.0.0' },
145
+ paths: {
146
+ '/items': { post: { responses: { '201': { description: 'Created' } } } },
147
+ },
148
+ } as const satisfies OpenApiDocument
149
+
150
+ type Api = OpenApiToRestApi<typeof doc>
151
+ expectTypeOf<Api>().toHaveProperty('POST')
152
+ })
153
+
154
+ it('Should extract PUT endpoints', () => {
155
+ const doc = {
156
+ openapi: '3.1.0',
157
+ info: { title: 'Test', version: '1.0.0' },
158
+ paths: {
159
+ '/items/{id}': { put: { responses: { '200': { description: 'OK' } } } },
160
+ },
161
+ } as const satisfies OpenApiDocument
162
+
163
+ type Api = OpenApiToRestApi<typeof doc>
164
+ expectTypeOf<Api>().toHaveProperty('PUT')
165
+ })
166
+
167
+ it('Should extract DELETE endpoints', () => {
168
+ const doc = {
169
+ openapi: '3.1.0',
170
+ info: { title: 'Test', version: '1.0.0' },
171
+ paths: {
172
+ '/items/{id}': { delete: { responses: { '200': { description: 'OK' } } } },
173
+ },
174
+ } as const satisfies OpenApiDocument
175
+
176
+ type Api = OpenApiToRestApi<typeof doc>
177
+ expectTypeOf<Api>().toHaveProperty('DELETE')
178
+ })
179
+
180
+ it('Should extract PATCH endpoints', () => {
181
+ const doc = {
182
+ openapi: '3.1.0',
183
+ info: { title: 'Test', version: '1.0.0' },
184
+ paths: {
185
+ '/items/{id}': { patch: { responses: { '200': { description: 'OK' } } } },
186
+ },
187
+ } as const satisfies OpenApiDocument
188
+
189
+ type Api = OpenApiToRestApi<typeof doc>
190
+ expectTypeOf<Api>().toHaveProperty('PATCH')
191
+ })
192
+
193
+ it('Should extract HEAD endpoints', () => {
194
+ const doc = {
195
+ openapi: '3.1.0',
196
+ info: { title: 'Test', version: '1.0.0' },
197
+ paths: {
198
+ '/items': { head: { responses: { '200': { description: 'OK' } } } },
199
+ },
200
+ } as const satisfies OpenApiDocument
201
+
202
+ type Api = OpenApiToRestApi<typeof doc>
203
+ expectTypeOf<Api>().toHaveProperty('HEAD')
204
+ })
205
+
206
+ it('Should extract OPTIONS endpoints', () => {
207
+ const doc = {
208
+ openapi: '3.1.0',
209
+ info: { title: 'Test', version: '1.0.0' },
210
+ paths: {
211
+ '/items': { options: { responses: { '200': { description: 'OK' } } } },
212
+ },
213
+ } as const satisfies OpenApiDocument
214
+
215
+ type Api = OpenApiToRestApi<typeof doc>
216
+ expectTypeOf<Api>().toHaveProperty('OPTIONS')
217
+ })
218
+
219
+ it('Should extract TRACE endpoints', () => {
220
+ const doc = {
221
+ openapi: '3.1.0',
222
+ info: { title: 'Test', version: '1.0.0' },
223
+ paths: {
224
+ '/items': { trace: { responses: { '200': { description: 'OK' } } } },
225
+ },
226
+ } as const satisfies OpenApiDocument
227
+
228
+ type Api = OpenApiToRestApi<typeof doc>
229
+ expectTypeOf<Api>().toHaveProperty('TRACE')
230
+ })
231
+
232
+ it('Should handle multiple methods on the same path', () => {
233
+ const doc = {
234
+ openapi: '3.1.0',
235
+ info: { title: 'Test', version: '1.0.0' },
236
+ paths: {
237
+ '/items': {
238
+ get: { responses: { '200': { description: 'OK' } } },
239
+ post: { responses: { '201': { description: 'Created' } } },
240
+ },
241
+ },
242
+ } as const satisfies OpenApiDocument
243
+
244
+ type Api = OpenApiToRestApi<typeof doc>
245
+ expectTypeOf<Api>().toHaveProperty('GET')
246
+ expectTypeOf<Api>().toHaveProperty('POST')
247
+ })
248
+
249
+ it('Should only include methods that have endpoints', () => {
250
+ const doc = {
251
+ openapi: '3.1.0',
252
+ info: { title: 'Test', version: '1.0.0' },
253
+ paths: {
254
+ '/items': { get: { responses: { '200': { description: 'OK' } } } },
255
+ },
256
+ } as const satisfies OpenApiDocument
257
+
258
+ type Api = OpenApiToRestApi<typeof doc>
259
+ expectTypeOf<Api>().not.toHaveProperty('POST')
260
+ expectTypeOf<Api>().not.toHaveProperty('PUT')
261
+ expectTypeOf<Api>().not.toHaveProperty('DELETE')
262
+ expectTypeOf<Api>().not.toHaveProperty('PATCH')
263
+ })
264
+ })
265
+
266
+ describe('Response types', () => {
267
+ it('Should extract typed 200 response', () => {
268
+ const doc = {
269
+ openapi: '3.1.0',
270
+ info: { title: 'Test', version: '1.0.0' },
271
+ paths: {
272
+ '/users': {
273
+ get: {
274
+ responses: {
275
+ '200': {
276
+ description: 'OK',
277
+ content: { 'application/json': { schema: { type: 'array', items: { type: 'string' } } } },
278
+ },
279
+ },
280
+ },
281
+ },
282
+ },
283
+ } as const satisfies OpenApiDocument
284
+
285
+ type Api = OpenApiToRestApi<typeof doc>
286
+ expectTypeOf<Api['GET']['/users']['result']>().toExtend<string[]>()
287
+ })
288
+
289
+ it('Should extract typed 201 response', () => {
290
+ const doc = {
291
+ openapi: '3.1.0',
292
+ info: { title: 'Test', version: '1.0.0' },
293
+ paths: {
294
+ '/users': {
295
+ post: {
296
+ responses: {
297
+ '201': {
298
+ description: 'Created',
299
+ content: {
300
+ 'application/json': {
301
+ schema: { type: 'object', properties: { id: { type: 'string' } } },
302
+ },
303
+ },
304
+ },
305
+ },
306
+ },
307
+ },
308
+ },
309
+ } as const satisfies OpenApiDocument
310
+
311
+ type Api = OpenApiToRestApi<typeof doc>
312
+ expectTypeOf<Api['POST']['/users']['result']>().toEqualTypeOf<{ readonly id?: string }>()
313
+ })
314
+
315
+ it('Should return unknown for responses without schemas', () => {
316
+ const doc = {
317
+ openapi: '3.1.0',
318
+ info: { title: 'Test', version: '1.0.0' },
319
+ paths: {
320
+ '/health': { get: { responses: { '200': { description: 'OK' } } } },
321
+ },
322
+ } as const satisfies OpenApiDocument
323
+
324
+ type Api = OpenApiToRestApi<typeof doc>
325
+ expectTypeOf<Api['GET']['/health']['result']>().toEqualTypeOf<unknown>()
326
+ })
327
+
328
+ it('Should return unknown for non-JSON content types', () => {
329
+ const doc = {
330
+ openapi: '3.1.0',
331
+ info: { title: 'Test', version: '1.0.0' },
332
+ paths: {
333
+ '/file': {
334
+ get: {
335
+ responses: {
336
+ '200': {
337
+ description: 'OK',
338
+ content: { 'application/octet-stream': { schema: { type: 'string' } } },
339
+ },
340
+ },
341
+ },
342
+ },
343
+ },
344
+ } as const satisfies OpenApiDocument
345
+
346
+ type Api = OpenApiToRestApi<typeof doc>
347
+ expectTypeOf<Api['GET']['/file']['result']>().toEqualTypeOf<unknown>()
348
+ })
349
+ })
350
+
351
+ describe('Path parameters', () => {
352
+ it('Should extract single path parameter', () => {
353
+ const doc = {
354
+ openapi: '3.1.0',
355
+ info: { title: 'Test', version: '1.0.0' },
356
+ paths: {
357
+ '/users/{id}': {
358
+ get: { responses: { '200': { description: 'OK' } } },
359
+ },
360
+ },
361
+ } as const satisfies OpenApiDocument
362
+
363
+ type Api = OpenApiToRestApi<typeof doc>
364
+ type Url = Api['GET']['/users/:id']['url']
365
+ expectTypeOf<Url>().toHaveProperty('id')
366
+ expectTypeOf<Url['id']>().toBeString()
367
+ })
368
+
369
+ it('Should extract multiple path parameters', () => {
370
+ const doc = {
371
+ openapi: '3.1.0',
372
+ info: { title: 'Test', version: '1.0.0' },
373
+ paths: {
374
+ '/users/{userId}/posts/{postId}': {
375
+ get: { responses: { '200': { description: 'OK' } } },
376
+ },
377
+ },
378
+ } as const satisfies OpenApiDocument
379
+
380
+ type Api = OpenApiToRestApi<typeof doc>
381
+ type Url = Api['GET']['/users/:userId/posts/:postId']['url']
382
+ expectTypeOf<Url>().toHaveProperty('userId')
383
+ expectTypeOf<Url>().toHaveProperty('postId')
384
+ expectTypeOf<Url['userId']>().toBeString()
385
+ expectTypeOf<Url['postId']>().toBeString()
386
+ })
387
+
388
+ it('Should not have url property for paths without params', () => {
389
+ const doc = {
390
+ openapi: '3.1.0',
391
+ info: { title: 'Test', version: '1.0.0' },
392
+ paths: {
393
+ '/users': {
394
+ get: { responses: { '200': { description: 'OK' } } },
395
+ },
396
+ },
397
+ } as const satisfies OpenApiDocument
398
+
399
+ type Api = OpenApiToRestApi<typeof doc>
400
+ type Endpoint = Api['GET']['/users']
401
+ expectTypeOf<Endpoint>().not.toHaveProperty('url')
402
+ })
403
+ })
404
+
405
+ describe('Request body', () => {
406
+ it('Should extract JSON request body', () => {
407
+ const doc = {
408
+ openapi: '3.1.0',
409
+ info: { title: 'Test', version: '1.0.0' },
410
+ paths: {
411
+ '/users': {
412
+ post: {
413
+ requestBody: {
414
+ content: {
415
+ 'application/json': {
416
+ schema: {
417
+ type: 'object',
418
+ properties: { name: { type: 'string' }, email: { type: 'string' } },
419
+ required: ['name', 'email'] as const,
420
+ },
421
+ },
422
+ },
423
+ },
424
+ responses: { '201': { description: 'Created' } },
425
+ },
426
+ },
427
+ },
428
+ } as const satisfies OpenApiDocument
429
+
430
+ type Api = OpenApiToRestApi<typeof doc>
431
+ expectTypeOf<Api['POST']['/users']['body']>().toExtend<{
432
+ name: string
433
+ email: string
434
+ }>()
435
+ })
436
+
437
+ it('Should not have body property when no request body', () => {
438
+ const doc = {
439
+ openapi: '3.1.0',
440
+ info: { title: 'Test', version: '1.0.0' },
441
+ paths: {
442
+ '/items': {
443
+ get: { responses: { '200': { description: 'OK' } } },
444
+ },
445
+ },
446
+ } as const satisfies OpenApiDocument
447
+
448
+ type Api = OpenApiToRestApi<typeof doc>
449
+ type Endpoint = Api['GET']['/items']
450
+ expectTypeOf<Endpoint>().not.toHaveProperty('body')
451
+ })
452
+ })
453
+
454
+ describe('Query parameters', () => {
455
+ it('Should extract typed query parameters', () => {
456
+ const doc = {
457
+ openapi: '3.1.0',
458
+ info: { title: 'Test', version: '1.0.0' },
459
+ paths: {
460
+ '/search': {
461
+ get: {
462
+ parameters: [
463
+ { name: 'q', in: 'query', schema: { type: 'string' } },
464
+ { name: 'limit', in: 'query', schema: { type: 'integer' } },
465
+ ],
466
+ responses: { '200': { description: 'OK' } },
467
+ },
468
+ },
469
+ },
470
+ } as const satisfies OpenApiDocument
471
+
472
+ type Api = OpenApiToRestApi<typeof doc>
473
+ expectTypeOf<Api['GET']['/search']['query']>().toEqualTypeOf<{ q: string } & { limit: number }>()
474
+ })
475
+
476
+ it('Should default to string for query params without schema', () => {
477
+ const doc = {
478
+ openapi: '3.1.0',
479
+ info: { title: 'Test', version: '1.0.0' },
480
+ paths: {
481
+ '/search': {
482
+ get: {
483
+ parameters: [{ name: 'q', in: 'query' }],
484
+ responses: { '200': { description: 'OK' } },
485
+ },
486
+ },
487
+ },
488
+ } as const satisfies OpenApiDocument
489
+
490
+ type Api = OpenApiToRestApi<typeof doc>
491
+ expectTypeOf<Api['GET']['/search']['query']>().toEqualTypeOf<{ q: string }>()
492
+ })
493
+
494
+ it('Should not mix path params into query', () => {
495
+ const doc = {
496
+ openapi: '3.1.0',
497
+ info: { title: 'Test', version: '1.0.0' },
498
+ paths: {
499
+ '/users/{id}': {
500
+ get: {
501
+ parameters: [
502
+ { name: 'id', in: 'path', required: true, schema: { type: 'string' } },
503
+ { name: 'fields', in: 'query', schema: { type: 'string' } },
504
+ ],
505
+ responses: { '200': { description: 'OK' } },
506
+ },
507
+ },
508
+ },
509
+ } as const satisfies OpenApiDocument
510
+
511
+ type Api = OpenApiToRestApi<typeof doc>
512
+ type Query = Api['GET']['/users/:id']['query']
513
+ expectTypeOf<Query>().toHaveProperty('fields')
514
+ expectTypeOf<Query['fields']>().toBeString()
515
+ type Url = Api['GET']['/users/:id']['url']
516
+ expectTypeOf<Url>().toHaveProperty('id')
517
+ expectTypeOf<Url['id']>().toBeString()
518
+ })
519
+
520
+ it('Should not have query property when no query parameters', () => {
521
+ const doc = {
522
+ openapi: '3.1.0',
523
+ info: { title: 'Test', version: '1.0.0' },
524
+ paths: {
525
+ '/items': {
526
+ get: {
527
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
528
+ responses: { '200': { description: 'OK' } },
529
+ },
530
+ },
531
+ },
532
+ } as const satisfies OpenApiDocument
533
+
534
+ type Api = OpenApiToRestApi<typeof doc>
535
+ type Endpoint = Api['GET']['/items']
536
+ expectTypeOf<Endpoint>().not.toHaveProperty('query')
537
+ })
538
+ })
539
+
540
+ describe('Edge cases', () => {
541
+ it('Should handle document with no paths', () => {
542
+ const doc = {
543
+ openapi: '3.1.0',
544
+ info: { title: 'Test', version: '1.0.0' },
545
+ } as const satisfies OpenApiDocument
546
+
547
+ type Api = OpenApiToRestApi<typeof doc>
548
+ expectTypeOf<Api>().toExtend<RestApi>()
549
+ })
550
+
551
+ it('Should handle document with empty paths', () => {
552
+ const doc = {
553
+ openapi: '3.1.0',
554
+ info: { title: 'Test', version: '1.0.0' },
555
+ paths: {},
556
+ } as const satisfies OpenApiDocument
557
+
558
+ type Api = OpenApiToRestApi<typeof doc>
559
+ expectTypeOf<Api>().toExtend<RestApi>()
560
+ })
561
+
562
+ it('Should handle multiple paths', () => {
563
+ const doc = {
564
+ openapi: '3.1.0',
565
+ info: { title: 'Test', version: '1.0.0' },
566
+ paths: {
567
+ '/a': { get: { responses: { '200': { description: 'OK' } } } },
568
+ '/b': { get: { responses: { '200': { description: 'OK' } } } },
569
+ '/c': { post: { responses: { '201': { description: 'Created' } } } },
570
+ },
571
+ } as const satisfies OpenApiDocument
572
+
573
+ type Api = OpenApiToRestApi<typeof doc>
574
+ expectTypeOf<Api['GET']>().toHaveProperty('/a')
575
+ expectTypeOf<Api['GET']>().toHaveProperty('/b')
576
+ expectTypeOf<Api['POST']>().toHaveProperty('/c')
577
+ })
578
+ })
579
+
580
+ describe('$ref resolution', () => {
581
+ it('Should resolve $ref in response schema', () => {
582
+ const doc = {
583
+ openapi: '3.1.0',
584
+ info: { title: 'Test', version: '1.0.0' },
585
+ paths: {
586
+ '/users': {
587
+ get: {
588
+ responses: {
589
+ '200': {
590
+ description: 'OK',
591
+ content: {
592
+ 'application/json': { schema: { $ref: '#/components/schemas/User' } },
593
+ },
594
+ },
595
+ },
596
+ },
597
+ },
598
+ },
599
+ components: {
600
+ schemas: {
601
+ User: {
602
+ type: 'object',
603
+ properties: { id: { type: 'string' }, name: { type: 'string' } },
604
+ },
605
+ },
606
+ },
607
+ } as const satisfies OpenApiDocument
608
+
609
+ type Api = OpenApiToRestApi<typeof doc>
610
+ type Result = Api['GET']['/users']['result']
611
+ expectTypeOf<Result>().toHaveProperty('id')
612
+ expectTypeOf<Result>().toHaveProperty('name')
613
+ })
614
+
615
+ it('Should resolve $ref in request body schema', () => {
616
+ const doc = {
617
+ openapi: '3.1.0',
618
+ info: { title: 'Test', version: '1.0.0' },
619
+ paths: {
620
+ '/users': {
621
+ post: {
622
+ requestBody: {
623
+ content: {
624
+ 'application/json': { schema: { $ref: '#/components/schemas/CreateUser' } },
625
+ },
626
+ },
627
+ responses: { '201': { description: 'Created' } },
628
+ },
629
+ },
630
+ },
631
+ components: {
632
+ schemas: {
633
+ CreateUser: {
634
+ type: 'object',
635
+ properties: { name: { type: 'string' } },
636
+ required: ['name'],
637
+ },
638
+ },
639
+ },
640
+ } as const satisfies OpenApiDocument
641
+
642
+ type Api = OpenApiToRestApi<typeof doc>
643
+ type Body = Api['POST']['/users']['body']
644
+ expectTypeOf<Body>().toHaveProperty('name')
645
+ })
646
+ })
647
+
648
+ describe('Schema composition', () => {
649
+ it('Should handle oneOf as union type', () => {
650
+ const doc = {
651
+ openapi: '3.1.0',
652
+ info: { title: 'Test', version: '1.0.0' },
653
+ paths: {
654
+ '/shape': {
655
+ get: {
656
+ responses: {
657
+ '200': {
658
+ description: 'OK',
659
+ content: {
660
+ 'application/json': {
661
+ schema: {
662
+ oneOf: [
663
+ { type: 'object', properties: { radius: { type: 'number' } } },
664
+ { type: 'object', properties: { width: { type: 'number' } } },
665
+ ],
666
+ },
667
+ },
668
+ },
669
+ },
670
+ },
671
+ },
672
+ },
673
+ },
674
+ } as const satisfies OpenApiDocument
675
+
676
+ type Api = OpenApiToRestApi<typeof doc>
677
+ type Result = Api['GET']['/shape']['result']
678
+ expectTypeOf<{ radius?: number }>().toExtend<Result>()
679
+ expectTypeOf<{ width?: number }>().toExtend<Result>()
680
+ })
681
+
682
+ it('Should handle allOf as intersection type', () => {
683
+ const doc = {
684
+ openapi: '3.1.0',
685
+ info: { title: 'Test', version: '1.0.0' },
686
+ paths: {
687
+ '/item': {
688
+ get: {
689
+ responses: {
690
+ '200': {
691
+ description: 'OK',
692
+ content: {
693
+ 'application/json': {
694
+ schema: {
695
+ allOf: [
696
+ { type: 'object', properties: { id: { type: 'string' } } },
697
+ { type: 'object', properties: { name: { type: 'string' } } },
698
+ ],
699
+ },
700
+ },
701
+ },
702
+ },
703
+ },
704
+ },
705
+ },
706
+ },
707
+ } as const satisfies OpenApiDocument
708
+
709
+ type Api = OpenApiToRestApi<typeof doc>
710
+ type Result = Api['GET']['/item']['result']
711
+ expectTypeOf<Result>().toHaveProperty('id')
712
+ expectTypeOf<Result>().toHaveProperty('name')
713
+ })
714
+
715
+ it('Should handle const values', () => {
716
+ const doc = {
717
+ openapi: '3.1.0',
718
+ info: { title: 'Test', version: '1.0.0' },
719
+ paths: {
720
+ '/status': {
721
+ get: {
722
+ responses: {
723
+ '200': {
724
+ description: 'OK',
725
+ content: {
726
+ 'application/json': { schema: { const: 'active' } },
727
+ },
728
+ },
729
+ },
730
+ },
731
+ },
732
+ },
733
+ } as const satisfies OpenApiDocument
734
+
735
+ type Api = OpenApiToRestApi<typeof doc>
736
+ expectTypeOf<Api['GET']['/status']['result']>().toEqualTypeOf<'active'>()
737
+ })
738
+
739
+ it('Should handle nullable types (3.1 tuple style)', () => {
740
+ const doc = {
741
+ openapi: '3.1.0',
742
+ info: { title: 'Test', version: '1.0.0' },
743
+ paths: {
744
+ '/item': {
745
+ get: {
746
+ responses: {
747
+ '200': {
748
+ description: 'OK',
749
+ content: {
750
+ 'application/json': {
751
+ schema: { type: ['string', 'null'] },
752
+ },
753
+ },
754
+ },
755
+ },
756
+ },
757
+ },
758
+ },
759
+ } as const satisfies OpenApiDocument
760
+
761
+ type Api = OpenApiToRestApi<typeof doc>
762
+ expectTypeOf<Api['GET']['/item']['result']>().toEqualTypeOf<string | null>()
763
+ })
764
+ })
765
+
766
+ describe('Metadata extraction', () => {
767
+ it('Should extract tags at type level', () => {
768
+ const doc = {
769
+ openapi: '3.1.0',
770
+ info: { title: 'Test', version: '1.0.0' },
771
+ paths: {
772
+ '/items': {
773
+ get: {
774
+ tags: ['store'],
775
+ responses: { '200': { description: 'OK' } },
776
+ },
777
+ },
778
+ },
779
+ } as const satisfies OpenApiDocument
780
+
781
+ type Api = OpenApiToRestApi<typeof doc>
782
+ expectTypeOf<Api['GET']['/items']>().toHaveProperty('tags')
783
+ })
784
+
785
+ it('Should extract deprecated flag at type level', () => {
786
+ const doc = {
787
+ openapi: '3.1.0',
788
+ info: { title: 'Test', version: '1.0.0' },
789
+ paths: {
790
+ '/old': {
791
+ get: {
792
+ deprecated: true,
793
+ responses: { '200': { description: 'OK' } },
794
+ },
795
+ },
796
+ },
797
+ } as const satisfies OpenApiDocument
798
+
799
+ type Api = OpenApiToRestApi<typeof doc>
800
+ expectTypeOf<Api['GET']['/old']>().toHaveProperty('deprecated')
801
+ })
802
+
803
+ it('Should extract summary and description at type level', () => {
804
+ const doc = {
805
+ openapi: '3.1.0',
806
+ info: { title: 'Test', version: '1.0.0' },
807
+ paths: {
808
+ '/items': {
809
+ get: {
810
+ summary: 'List items',
811
+ description: 'Returns all items',
812
+ responses: { '200': { description: 'OK' } },
813
+ },
814
+ },
815
+ },
816
+ } as const satisfies OpenApiDocument
817
+
818
+ type Api = OpenApiToRestApi<typeof doc>
819
+ expectTypeOf<Api['GET']['/items']>().toHaveProperty('summary')
820
+ expectTypeOf<Api['GET']['/items']>().toHaveProperty('description')
821
+ })
822
+ })
823
+ })