@furystack/rest-service 12.2.0 → 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 +44 -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 +9 -9
  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,733 @@
1
+ import type {
2
+ ApiEndpointSchema,
3
+ ParameterObject,
4
+ ReferenceObject,
5
+ RequestBodyObject,
6
+ ResponseObject,
7
+ } from '@furystack/rest'
8
+ import { describe, expect, it } from 'vitest'
9
+ import { generateOpenApiDocument } from './generate-openapi-document.js'
10
+
11
+ describe('generateOpenApiDocument', () => {
12
+ describe('Document structure', () => {
13
+ it('Should generate a valid OpenAPI 3.1 document', () => {
14
+ const api: ApiEndpointSchema['endpoints'] = {
15
+ GET: {
16
+ '/test': { path: '/test', isAuthenticated: false, schemaName: 'Test', schema: { type: 'object' } },
17
+ },
18
+ }
19
+
20
+ const result = generateOpenApiDocument({ api })
21
+
22
+ expect(result.openapi).toBe('3.1.0')
23
+ expect(result.info.title).toBe('FuryStack API')
24
+ expect(result.info.version).toBe('1.0.0')
25
+ expect(result.info.description).toBe('API documentation generated from FuryStack API schema')
26
+ expect(result.jsonSchemaDialect).toBe('https://spec.openapis.org/oas/3.1/dialect/base')
27
+ expect(result.servers).toEqual([{ url: '/' }])
28
+ expect(result.tags).toEqual([])
29
+ expect(result.paths).toBeDefined()
30
+ expect(result.components).toBeDefined()
31
+ })
32
+
33
+ it('Should use custom title, description, and version', () => {
34
+ const result = generateOpenApiDocument({
35
+ api: {},
36
+ title: 'Custom API',
37
+ description: 'Custom description',
38
+ version: '3.0.0',
39
+ })
40
+
41
+ expect(result.info.title).toBe('Custom API')
42
+ expect(result.info.description).toBe('Custom description')
43
+ expect(result.info.version).toBe('3.0.0')
44
+ })
45
+
46
+ it('Should include cookieAuth security scheme', () => {
47
+ const result = generateOpenApiDocument({ api: {} })
48
+
49
+ expect(result.components?.securitySchemes?.cookieAuth).toEqual({
50
+ type: 'apiKey',
51
+ in: 'cookie',
52
+ name: 'session',
53
+ })
54
+ })
55
+ })
56
+
57
+ describe('HTTP methods', () => {
58
+ it('Should generate GET operations', () => {
59
+ const api: ApiEndpointSchema['endpoints'] = {
60
+ GET: { '/items': { path: '/items', isAuthenticated: false, schemaName: 'Items', schema: { type: 'array' } } },
61
+ }
62
+
63
+ const result = generateOpenApiDocument({ api })
64
+ expect(result.paths?.['/items']?.get).toBeDefined()
65
+ })
66
+
67
+ it('Should generate POST operations', () => {
68
+ const api: ApiEndpointSchema['endpoints'] = {
69
+ POST: { '/items': { path: '/items', isAuthenticated: false, schemaName: 'Item', schema: { type: 'object' } } },
70
+ }
71
+
72
+ const result = generateOpenApiDocument({ api })
73
+ expect(result.paths?.['/items']?.post).toBeDefined()
74
+ })
75
+
76
+ it('Should generate PUT operations', () => {
77
+ const api: ApiEndpointSchema['endpoints'] = {
78
+ PUT: {
79
+ '/items/:id': {
80
+ path: '/items/:id',
81
+ isAuthenticated: false,
82
+ schemaName: 'Item',
83
+ schema: { type: 'object' },
84
+ },
85
+ },
86
+ }
87
+
88
+ const result = generateOpenApiDocument({ api })
89
+ expect(result.paths?.['/items/{id}']?.put).toBeDefined()
90
+ })
91
+
92
+ it('Should generate DELETE operations', () => {
93
+ const api: ApiEndpointSchema['endpoints'] = {
94
+ DELETE: {
95
+ '/items/:id': {
96
+ path: '/items/:id',
97
+ isAuthenticated: false,
98
+ schemaName: 'Item',
99
+ schema: { type: 'object' },
100
+ },
101
+ },
102
+ }
103
+
104
+ const result = generateOpenApiDocument({ api })
105
+ expect(result.paths?.['/items/{id}']?.delete).toBeDefined()
106
+ })
107
+
108
+ it('Should generate PATCH operations', () => {
109
+ const api: ApiEndpointSchema['endpoints'] = {
110
+ PATCH: {
111
+ '/items/:id': {
112
+ path: '/items/:id',
113
+ isAuthenticated: false,
114
+ schemaName: 'Item',
115
+ schema: { type: 'object' },
116
+ },
117
+ },
118
+ }
119
+
120
+ const result = generateOpenApiDocument({ api })
121
+ expect(result.paths?.['/items/{id}']?.patch).toBeDefined()
122
+ })
123
+
124
+ it('Should generate HEAD operations', () => {
125
+ const api: ApiEndpointSchema['endpoints'] = {
126
+ HEAD: { '/items': { path: '/items', isAuthenticated: false, schemaName: 'Items', schema: { type: 'object' } } },
127
+ }
128
+
129
+ const result = generateOpenApiDocument({ api })
130
+ expect(result.paths?.['/items']?.head).toBeDefined()
131
+ })
132
+
133
+ it('Should generate OPTIONS operations', () => {
134
+ const api: ApiEndpointSchema['endpoints'] = {
135
+ OPTIONS: {
136
+ '/items': { path: '/items', isAuthenticated: false, schemaName: 'Items', schema: { type: 'object' } },
137
+ },
138
+ }
139
+
140
+ const result = generateOpenApiDocument({ api })
141
+ expect(result.paths?.['/items']?.options).toBeDefined()
142
+ })
143
+
144
+ it('Should generate TRACE operations', () => {
145
+ const api: ApiEndpointSchema['endpoints'] = {
146
+ TRACE: {
147
+ '/items': { path: '/items', isAuthenticated: false, schemaName: 'Items', schema: { type: 'object' } },
148
+ },
149
+ }
150
+
151
+ const result = generateOpenApiDocument({ api })
152
+ expect(result.paths?.['/items']?.trace).toBeDefined()
153
+ })
154
+
155
+ it('Should handle multiple methods on the same path', () => {
156
+ const api: ApiEndpointSchema['endpoints'] = {
157
+ GET: {
158
+ '/resource': {
159
+ path: '/resource',
160
+ isAuthenticated: false,
161
+ schemaName: 'ResourceGet',
162
+ schema: { type: 'object' },
163
+ },
164
+ },
165
+ POST: {
166
+ '/resource': {
167
+ path: '/resource',
168
+ isAuthenticated: true,
169
+ schemaName: 'ResourcePost',
170
+ schema: { type: 'object' },
171
+ },
172
+ },
173
+ }
174
+
175
+ const result = generateOpenApiDocument({ api })
176
+ expect(result.paths?.['/resource']?.get).toBeDefined()
177
+ expect(result.paths?.['/resource']?.post).toBeDefined()
178
+ })
179
+ })
180
+
181
+ describe('Path parameters', () => {
182
+ it('Should convert :param to {param} in paths', () => {
183
+ const api: ApiEndpointSchema['endpoints'] = {
184
+ GET: {
185
+ '/users/:id': {
186
+ path: '/users/:id',
187
+ isAuthenticated: false,
188
+ schemaName: 'User',
189
+ schema: { type: 'object' },
190
+ },
191
+ },
192
+ }
193
+
194
+ const result = generateOpenApiDocument({ api })
195
+ expect(result.paths?.['/users/{id}']).toBeDefined()
196
+ expect(result.paths?.['/users/:id']).toBeUndefined()
197
+ })
198
+
199
+ it('Should generate path parameter objects', () => {
200
+ const api: ApiEndpointSchema['endpoints'] = {
201
+ GET: {
202
+ '/users/:id': {
203
+ path: '/users/:id',
204
+ isAuthenticated: false,
205
+ schemaName: 'User',
206
+ schema: { type: 'object' },
207
+ },
208
+ },
209
+ }
210
+
211
+ const result = generateOpenApiDocument({ api })
212
+ const params = result.paths?.['/users/{id}']?.get?.parameters as ParameterObject[]
213
+ expect(params).toHaveLength(1)
214
+ expect(params[0]).toEqual({
215
+ name: 'id',
216
+ in: 'path',
217
+ required: true,
218
+ description: 'Path parameter: id',
219
+ schema: { type: 'string' },
220
+ })
221
+ })
222
+
223
+ it('Should generate multiple path parameters', () => {
224
+ const api: ApiEndpointSchema['endpoints'] = {
225
+ GET: {
226
+ '/users/:userId/posts/:postId': {
227
+ path: '/users/:userId/posts/:postId',
228
+ isAuthenticated: false,
229
+ schemaName: 'Post',
230
+ schema: { type: 'object' },
231
+ },
232
+ },
233
+ }
234
+
235
+ const result = generateOpenApiDocument({ api })
236
+ const params = result.paths?.['/users/{userId}/posts/{postId}']?.get?.parameters as ParameterObject[]
237
+ expect(params).toHaveLength(2)
238
+ expect(params[0].name).toBe('userId')
239
+ expect(params[1].name).toBe('postId')
240
+ })
241
+ })
242
+
243
+ describe('Response schemas', () => {
244
+ it('Should reference schema via $ref when schemaName is set', () => {
245
+ const api: ApiEndpointSchema['endpoints'] = {
246
+ GET: {
247
+ '/test': {
248
+ path: '/test',
249
+ isAuthenticated: false,
250
+ schemaName: 'TestModel',
251
+ schema: { type: 'object', properties: { id: { type: 'string' } } },
252
+ },
253
+ },
254
+ }
255
+
256
+ const result = generateOpenApiDocument({ api })
257
+ const response = result.paths?.['/test']?.get?.responses?.['200'] as ResponseObject
258
+ expect((response.content?.['application/json']?.schema as ReferenceObject).$ref).toBe(
259
+ '#/components/schemas/TestModel',
260
+ )
261
+ })
262
+
263
+ it('Should include schemas in components', () => {
264
+ const testSchema = { type: 'object', properties: { id: { type: 'string' } } }
265
+ const api: ApiEndpointSchema['endpoints'] = {
266
+ GET: {
267
+ '/test': { path: '/test', isAuthenticated: false, schemaName: 'TestModel', schema: testSchema },
268
+ },
269
+ }
270
+
271
+ const result = generateOpenApiDocument({ api })
272
+ expect(result.components?.schemas?.TestModel).toEqual(testSchema)
273
+ })
274
+
275
+ it('Should use generic object schema when no schemaName', () => {
276
+ const api: ApiEndpointSchema['endpoints'] = {
277
+ GET: {
278
+ '/test': { path: '/test', isAuthenticated: false, schemaName: '', schema: null as unknown },
279
+ },
280
+ }
281
+
282
+ const result = generateOpenApiDocument({ api })
283
+ const response = result.paths?.['/test']?.get?.responses?.['200'] as ResponseObject
284
+ expect(response.content?.['application/json']?.schema).toEqual({ type: 'object' })
285
+ })
286
+
287
+ it('Should include 401 and 500 error responses', () => {
288
+ const api: ApiEndpointSchema['endpoints'] = {
289
+ GET: {
290
+ '/test': { path: '/test', isAuthenticated: false, schemaName: 'Test', schema: { type: 'object' } },
291
+ },
292
+ }
293
+
294
+ const result = generateOpenApiDocument({ api })
295
+ const responses = result.paths?.['/test']?.get?.responses as Record<string, ResponseObject>
296
+ expect(responses['401'].description).toBe('Unauthorized')
297
+ expect(responses['500'].description).toBe('Internal server error')
298
+ })
299
+
300
+ it('Should not add schema to components when schema is falsy', () => {
301
+ const api: ApiEndpointSchema['endpoints'] = {
302
+ GET: {
303
+ '/test': { path: '/test', isAuthenticated: false, schemaName: 'Empty', schema: null as unknown },
304
+ },
305
+ }
306
+
307
+ const result = generateOpenApiDocument({ api })
308
+ expect(result.components?.schemas?.Empty).toBeUndefined()
309
+ })
310
+ })
311
+
312
+ describe('Request body extraction', () => {
313
+ it('Should include request body from schema definitions', () => {
314
+ const bodySchema = { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
315
+
316
+ const api: ApiEndpointSchema['endpoints'] = {
317
+ POST: {
318
+ '/users': {
319
+ path: '/users',
320
+ isAuthenticated: false,
321
+ schemaName: 'CreateUser',
322
+ schema: {
323
+ definitions: {
324
+ CreateUser: {
325
+ type: 'object',
326
+ properties: { body: bodySchema, result: { type: 'object' } },
327
+ required: ['body', 'result'],
328
+ },
329
+ },
330
+ },
331
+ },
332
+ },
333
+ }
334
+
335
+ const result = generateOpenApiDocument({ api })
336
+ const requestBody = result.paths?.['/users']?.post?.requestBody as RequestBodyObject
337
+ expect(requestBody).toBeDefined()
338
+ expect(requestBody.required).toBe(true)
339
+ expect(requestBody.content['application/json'].schema).toEqual(bodySchema)
340
+ })
341
+
342
+ it('Should mark request body as not required when body is optional in schema', () => {
343
+ const api: ApiEndpointSchema['endpoints'] = {
344
+ POST: {
345
+ '/items': {
346
+ path: '/items',
347
+ isAuthenticated: false,
348
+ schemaName: 'CreateItem',
349
+ schema: {
350
+ definitions: {
351
+ CreateItem: {
352
+ type: 'object',
353
+ properties: { body: { type: 'object' }, result: { type: 'object' } },
354
+ required: ['result'],
355
+ },
356
+ },
357
+ },
358
+ },
359
+ },
360
+ }
361
+
362
+ const result = generateOpenApiDocument({ api })
363
+ const requestBody = result.paths?.['/items']?.post?.requestBody as RequestBodyObject
364
+ expect(requestBody.required).toBe(false)
365
+ })
366
+
367
+ it('Should not add request body when schema has no body property', () => {
368
+ const api: ApiEndpointSchema['endpoints'] = {
369
+ GET: {
370
+ '/items': {
371
+ path: '/items',
372
+ isAuthenticated: false,
373
+ schemaName: 'ListItems',
374
+ schema: {
375
+ definitions: {
376
+ ListItems: {
377
+ type: 'object',
378
+ properties: { result: { type: 'array' } },
379
+ required: ['result'],
380
+ },
381
+ },
382
+ },
383
+ },
384
+ },
385
+ }
386
+
387
+ const result = generateOpenApiDocument({ api })
388
+ expect(result.paths?.['/items']?.get?.requestBody).toBeUndefined()
389
+ })
390
+
391
+ it('Should not add request body when schema has no definitions', () => {
392
+ const api: ApiEndpointSchema['endpoints'] = {
393
+ POST: {
394
+ '/items': { path: '/items', isAuthenticated: false, schemaName: 'Item', schema: { type: 'object' } },
395
+ },
396
+ }
397
+
398
+ const result = generateOpenApiDocument({ api })
399
+ expect(result.paths?.['/items']?.post?.requestBody).toBeUndefined()
400
+ })
401
+ })
402
+
403
+ describe('Query parameter extraction', () => {
404
+ it('Should include query parameters from schema definitions', () => {
405
+ const api: ApiEndpointSchema['endpoints'] = {
406
+ GET: {
407
+ '/search': {
408
+ path: '/search',
409
+ isAuthenticated: false,
410
+ schemaName: 'Search',
411
+ schema: {
412
+ definitions: {
413
+ Search: {
414
+ type: 'object',
415
+ properties: {
416
+ query: {
417
+ type: 'object',
418
+ properties: { q: { type: 'string' }, limit: { type: 'number' } },
419
+ required: ['q'],
420
+ },
421
+ result: { type: 'array' },
422
+ },
423
+ },
424
+ },
425
+ },
426
+ },
427
+ },
428
+ }
429
+
430
+ const result = generateOpenApiDocument({ api })
431
+ const params = result.paths?.['/search']?.get?.parameters as ParameterObject[]
432
+ const queryParams = params.filter((p) => p.in === 'query')
433
+
434
+ expect(queryParams).toHaveLength(2)
435
+ expect(queryParams.find((p) => p.name === 'q')?.required).toBe(true)
436
+ expect(queryParams.find((p) => p.name === 'q')?.schema).toEqual({ type: 'string' })
437
+ expect(queryParams.find((p) => p.name === 'limit')?.required).toBe(false)
438
+ expect(queryParams.find((p) => p.name === 'limit')?.schema).toEqual({ type: 'number' })
439
+ })
440
+
441
+ it('Should not add query parameters when query has no properties', () => {
442
+ const api: ApiEndpointSchema['endpoints'] = {
443
+ GET: {
444
+ '/items': {
445
+ path: '/items',
446
+ isAuthenticated: false,
447
+ schemaName: 'Items',
448
+ schema: {
449
+ definitions: {
450
+ Items: {
451
+ type: 'object',
452
+ properties: { query: { type: 'object' }, result: { type: 'array' } },
453
+ },
454
+ },
455
+ },
456
+ },
457
+ },
458
+ }
459
+
460
+ const result = generateOpenApiDocument({ api })
461
+ const params = result.paths?.['/items']?.get?.parameters as ParameterObject[]
462
+ expect(params.filter((p) => p.in === 'query')).toHaveLength(0)
463
+ })
464
+
465
+ it('Should mark query params as not required when required array is missing', () => {
466
+ const api: ApiEndpointSchema['endpoints'] = {
467
+ GET: {
468
+ '/search': {
469
+ path: '/search',
470
+ isAuthenticated: false,
471
+ schemaName: 'Search',
472
+ schema: {
473
+ definitions: {
474
+ Search: {
475
+ type: 'object',
476
+ properties: {
477
+ query: {
478
+ type: 'object',
479
+ properties: { q: { type: 'string' } },
480
+ },
481
+ result: { type: 'array' },
482
+ },
483
+ },
484
+ },
485
+ },
486
+ },
487
+ },
488
+ }
489
+
490
+ const result = generateOpenApiDocument({ api })
491
+ const params = result.paths?.['/search']?.get?.parameters as ParameterObject[]
492
+ const qParam = params.find((p) => p.in === 'query' && p.name === 'q')
493
+ expect(qParam?.required).toBe(false)
494
+ })
495
+ })
496
+
497
+ describe('Header parameter extraction', () => {
498
+ it('Should include header parameters from schema definitions', () => {
499
+ const api: ApiEndpointSchema['endpoints'] = {
500
+ GET: {
501
+ '/data': {
502
+ path: '/data',
503
+ isAuthenticated: false,
504
+ schemaName: 'GetData',
505
+ schema: {
506
+ definitions: {
507
+ GetData: {
508
+ type: 'object',
509
+ properties: {
510
+ headers: {
511
+ type: 'object',
512
+ properties: { 'x-api-key': { type: 'string' } },
513
+ required: ['x-api-key'],
514
+ },
515
+ result: { type: 'object' },
516
+ },
517
+ },
518
+ },
519
+ },
520
+ },
521
+ },
522
+ }
523
+
524
+ const result = generateOpenApiDocument({ api })
525
+ const params = result.paths?.['/data']?.get?.parameters as ParameterObject[]
526
+ const headerParams = params.filter((p) => p.in === 'header')
527
+
528
+ expect(headerParams).toHaveLength(1)
529
+ expect(headerParams[0].name).toBe('x-api-key')
530
+ expect(headerParams[0].required).toBe(true)
531
+ expect(headerParams[0].schema).toEqual({ type: 'string' })
532
+ })
533
+
534
+ it('Should not add header parameters when headers has no properties', () => {
535
+ const api: ApiEndpointSchema['endpoints'] = {
536
+ GET: {
537
+ '/data': {
538
+ path: '/data',
539
+ isAuthenticated: false,
540
+ schemaName: 'GetData',
541
+ schema: {
542
+ definitions: {
543
+ GetData: {
544
+ type: 'object',
545
+ properties: { headers: { type: 'object' }, result: { type: 'object' } },
546
+ },
547
+ },
548
+ },
549
+ },
550
+ },
551
+ }
552
+
553
+ const result = generateOpenApiDocument({ api })
554
+ const params = result.paths?.['/data']?.get?.parameters as ParameterObject[]
555
+ expect(params.filter((p) => p.in === 'header')).toHaveLength(0)
556
+ })
557
+
558
+ it('Should mark header params as not required when required array is missing', () => {
559
+ const api: ApiEndpointSchema['endpoints'] = {
560
+ GET: {
561
+ '/data': {
562
+ path: '/data',
563
+ isAuthenticated: false,
564
+ schemaName: 'GetData',
565
+ schema: {
566
+ definitions: {
567
+ GetData: {
568
+ type: 'object',
569
+ properties: {
570
+ headers: {
571
+ type: 'object',
572
+ properties: { 'x-token': { type: 'string' } },
573
+ },
574
+ result: { type: 'object' },
575
+ },
576
+ },
577
+ },
578
+ },
579
+ },
580
+ },
581
+ }
582
+
583
+ const result = generateOpenApiDocument({ api })
584
+ const params = result.paths?.['/data']?.get?.parameters as ParameterObject[]
585
+ const headerParam = params.find((p) => p.in === 'header' && p.name === 'x-token')
586
+ expect(headerParam?.required).toBe(false)
587
+ })
588
+ })
589
+
590
+ describe('Security', () => {
591
+ it('Should add cookieAuth for authenticated endpoints', () => {
592
+ const api: ApiEndpointSchema['endpoints'] = {
593
+ GET: {
594
+ '/private': { path: '/private', isAuthenticated: true, schemaName: 'P', schema: { type: 'object' } },
595
+ },
596
+ }
597
+
598
+ const result = generateOpenApiDocument({ api })
599
+ expect(result.paths?.['/private']?.get?.security).toEqual([{ cookieAuth: [] }])
600
+ })
601
+
602
+ it('Should use empty security for non-authenticated endpoints', () => {
603
+ const api: ApiEndpointSchema['endpoints'] = {
604
+ GET: {
605
+ '/public': { path: '/public', isAuthenticated: false, schemaName: 'P', schema: { type: 'object' } },
606
+ },
607
+ }
608
+
609
+ const result = generateOpenApiDocument({ api })
610
+ expect(result.paths?.['/public']?.get?.security).toEqual([])
611
+ })
612
+
613
+ it('Should use stored securitySchemes names when available', () => {
614
+ const api: ApiEndpointSchema['endpoints'] = {
615
+ GET: {
616
+ '/private': {
617
+ path: '/private',
618
+ isAuthenticated: true,
619
+ schemaName: 'P',
620
+ schema: { type: 'object' },
621
+ securitySchemes: ['bearerAuth'],
622
+ },
623
+ },
624
+ }
625
+
626
+ const result = generateOpenApiDocument({ api })
627
+ expect(result.paths?.['/private']?.get?.security).toEqual([{ bearerAuth: [] }])
628
+ })
629
+
630
+ it('Should use multiple stored securitySchemes names', () => {
631
+ const api: ApiEndpointSchema['endpoints'] = {
632
+ GET: {
633
+ '/private': {
634
+ path: '/private',
635
+ isAuthenticated: true,
636
+ schemaName: 'P',
637
+ schema: { type: 'object' },
638
+ securitySchemes: ['bearerAuth', 'apiKey'],
639
+ },
640
+ },
641
+ }
642
+
643
+ const result = generateOpenApiDocument({ api })
644
+ expect(result.paths?.['/private']?.get?.security).toEqual([{ bearerAuth: [] }, { apiKey: [] }])
645
+ })
646
+
647
+ it('Should use metadata securitySchemes keys as default when isAuthenticated is true', () => {
648
+ const api: ApiEndpointSchema['endpoints'] = {
649
+ GET: {
650
+ '/private': { path: '/private', isAuthenticated: true, schemaName: 'P', schema: { type: 'object' } },
651
+ },
652
+ }
653
+
654
+ const result = generateOpenApiDocument({
655
+ api,
656
+ metadata: {
657
+ securitySchemes: {
658
+ bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
659
+ apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
660
+ },
661
+ },
662
+ })
663
+ expect(result.paths?.['/private']?.get?.security).toEqual([{ bearerAuth: [] }, { apiKey: [] }])
664
+ })
665
+
666
+ it('Should prefer stored securitySchemes over metadata-derived defaults', () => {
667
+ const api: ApiEndpointSchema['endpoints'] = {
668
+ GET: {
669
+ '/private': {
670
+ path: '/private',
671
+ isAuthenticated: true,
672
+ schemaName: 'P',
673
+ schema: { type: 'object' },
674
+ securitySchemes: ['customScheme'],
675
+ },
676
+ },
677
+ }
678
+
679
+ const result = generateOpenApiDocument({
680
+ api,
681
+ metadata: {
682
+ securitySchemes: {
683
+ bearerAuth: { type: 'http', scheme: 'bearer' },
684
+ },
685
+ },
686
+ })
687
+ expect(result.paths?.['/private']?.get?.security).toEqual([{ customScheme: [] }])
688
+ })
689
+ })
690
+
691
+ describe('Operation metadata', () => {
692
+ it('Should generate operationId from method and path', () => {
693
+ const api: ApiEndpointSchema['endpoints'] = {
694
+ GET: {
695
+ '/api/users/:id/profile': {
696
+ path: '/api/users/:id/profile',
697
+ isAuthenticated: false,
698
+ schemaName: 'P',
699
+ schema: { type: 'object' },
700
+ },
701
+ },
702
+ }
703
+
704
+ const result = generateOpenApiDocument({ api })
705
+ expect(result.paths?.['/api/users/{id}/profile']?.get?.operationId).toBe('get_api_users_id_profile')
706
+ })
707
+
708
+ it('Should gracefully ignore unsupported HTTP methods like CONNECT', () => {
709
+ const api: ApiEndpointSchema['endpoints'] = {
710
+ CONNECT: {
711
+ '/tunnel': { path: '/tunnel', isAuthenticated: false, schemaName: 'Tunnel', schema: { type: 'object' } },
712
+ },
713
+ }
714
+
715
+ const result = generateOpenApiDocument({ api })
716
+ expect(result.paths?.['/tunnel']).toBeDefined()
717
+ expect(result.paths?.['/tunnel']?.get).toBeUndefined()
718
+ expect(result.paths?.['/tunnel']?.post).toBeUndefined()
719
+ })
720
+
721
+ it('Should generate summary and description', () => {
722
+ const api: ApiEndpointSchema['endpoints'] = {
723
+ POST: {
724
+ '/users': { path: '/users', isAuthenticated: false, schemaName: 'U', schema: { type: 'object' } },
725
+ },
726
+ }
727
+
728
+ const result = generateOpenApiDocument({ api })
729
+ expect(result.paths?.['/users']?.post?.summary).toBe('POST /users')
730
+ expect(result.paths?.['/users']?.post?.description).toBe('Endpoint for /users')
731
+ })
732
+ })
733
+ })