@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.
- package/CHANGELOG.md +77 -0
- package/README.md +37 -1
- package/esm/api-endpoint-schema.d.ts +47 -2
- package/esm/api-endpoint-schema.d.ts.map +1 -1
- package/esm/index.d.ts +4 -1
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +4 -1
- package/esm/index.js.map +1 -1
- package/esm/openapi-document.d.ts +303 -0
- package/esm/openapi-document.d.ts.map +1 -0
- package/esm/openapi-document.js +2 -0
- package/esm/openapi-document.js.map +1 -0
- package/esm/openapi-resolve-refs.d.ts +20 -0
- package/esm/openapi-resolve-refs.d.ts.map +1 -0
- package/esm/openapi-resolve-refs.js +68 -0
- package/esm/openapi-resolve-refs.js.map +1 -0
- package/esm/openapi-resolve-refs.spec.d.ts +2 -0
- package/esm/openapi-resolve-refs.spec.d.ts.map +1 -0
- package/esm/openapi-resolve-refs.spec.js +294 -0
- package/esm/openapi-resolve-refs.spec.js.map +1 -0
- package/esm/openapi-to-rest-api.d.ts +197 -0
- package/esm/openapi-to-rest-api.d.ts.map +1 -0
- package/esm/openapi-to-rest-api.js +2 -0
- package/esm/openapi-to-rest-api.js.map +1 -0
- package/esm/openapi-to-rest-api.spec.d.ts +2 -0
- package/esm/openapi-to-rest-api.spec.d.ts.map +1 -0
- package/esm/openapi-to-rest-api.spec.js +665 -0
- package/esm/openapi-to-rest-api.spec.js.map +1 -0
- package/esm/openapi-to-schema.d.ts +24 -0
- package/esm/openapi-to-schema.d.ts.map +1 -0
- package/esm/openapi-to-schema.js +145 -0
- package/esm/openapi-to-schema.js.map +1 -0
- package/esm/openapi-to-schema.spec.d.ts +2 -0
- package/esm/openapi-to-schema.spec.d.ts.map +1 -0
- package/esm/openapi-to-schema.spec.js +610 -0
- package/esm/openapi-to-schema.spec.js.map +1 -0
- package/esm/rest-api.d.ts +21 -4
- package/esm/rest-api.d.ts.map +1 -1
- package/esm/swagger-document.d.ts +2 -195
- package/esm/swagger-document.d.ts.map +1 -1
- package/esm/swagger-document.js +2 -1
- package/esm/swagger-document.js.map +1 -1
- package/package.json +4 -4
- package/src/api-endpoint-schema.ts +56 -3
- package/src/index.ts +4 -1
- package/src/openapi-document.ts +328 -0
- package/src/openapi-resolve-refs.spec.ts +324 -0
- package/src/openapi-resolve-refs.ts +71 -0
- package/src/openapi-to-rest-api.spec.ts +823 -0
- package/src/openapi-to-rest-api.ts +263 -0
- package/src/openapi-to-schema.spec.ts +707 -0
- package/src/openapi-to-schema.ts +163 -0
- package/src/rest-api.ts +26 -5
- package/src/swagger-document.ts +2 -220
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { OpenApiDocument } from './openapi-document.js'
|
|
3
|
+
import { convertOpenApiPathToFuryStack, openApiToSchema } from './openapi-to-schema.js'
|
|
4
|
+
|
|
5
|
+
describe('convertOpenApiPathToFuryStack', () => {
|
|
6
|
+
it('Should convert single {param} to :param', () => {
|
|
7
|
+
expect(convertOpenApiPathToFuryStack('/users/{id}')).toBe('/users/:id')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('Should convert multiple params', () => {
|
|
11
|
+
expect(convertOpenApiPathToFuryStack('/users/{userId}/posts/{postId}')).toBe('/users/:userId/posts/:postId')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('Should pass through paths without params', () => {
|
|
15
|
+
expect(convertOpenApiPathToFuryStack('/users')).toBe('/users')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('Should handle root path', () => {
|
|
19
|
+
expect(convertOpenApiPathToFuryStack('/')).toBe('/')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('Should handle param at the start', () => {
|
|
23
|
+
expect(convertOpenApiPathToFuryStack('/{version}')).toBe('/:version')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('openApiToSchema', () => {
|
|
28
|
+
describe('Document info', () => {
|
|
29
|
+
it('Should extract title, version, and description', () => {
|
|
30
|
+
const doc: OpenApiDocument = {
|
|
31
|
+
openapi: '3.1.0',
|
|
32
|
+
info: { title: 'My API', version: '2.0.0', description: 'A test API' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const schema = openApiToSchema(doc)
|
|
36
|
+
expect(schema.name).toBe('My API')
|
|
37
|
+
expect(schema.version).toBe('2.0.0')
|
|
38
|
+
expect(schema.description).toBe('A test API')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('Should default description to empty string when absent', () => {
|
|
42
|
+
const doc: OpenApiDocument = {
|
|
43
|
+
openapi: '3.1.0',
|
|
44
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const schema = openApiToSchema(doc)
|
|
48
|
+
expect(schema.description).toBe('')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('HTTP methods', () => {
|
|
53
|
+
it('Should extract GET endpoints', () => {
|
|
54
|
+
const doc: OpenApiDocument = {
|
|
55
|
+
openapi: '3.1.0',
|
|
56
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
57
|
+
paths: { '/items': { get: { responses: { '200': { description: 'OK' } } } } },
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const schema = openApiToSchema(doc)
|
|
61
|
+
expect(schema.endpoints.GET).toBeDefined()
|
|
62
|
+
expect(schema.endpoints.GET!['/items']).toBeDefined()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('Should extract POST endpoints', () => {
|
|
66
|
+
const doc: OpenApiDocument = {
|
|
67
|
+
openapi: '3.1.0',
|
|
68
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
69
|
+
paths: { '/items': { post: { responses: { '201': { description: 'Created' } } } } },
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const schema = openApiToSchema(doc)
|
|
73
|
+
expect(schema.endpoints.POST).toBeDefined()
|
|
74
|
+
expect(schema.endpoints.POST!['/items']).toBeDefined()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('Should extract PUT endpoints', () => {
|
|
78
|
+
const doc: OpenApiDocument = {
|
|
79
|
+
openapi: '3.1.0',
|
|
80
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
81
|
+
paths: { '/items/{id}': { put: { responses: { '200': { description: 'OK' } } } } },
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const schema = openApiToSchema(doc)
|
|
85
|
+
expect(schema.endpoints.PUT).toBeDefined()
|
|
86
|
+
expect(schema.endpoints.PUT!['/items/:id']).toBeDefined()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('Should extract DELETE endpoints', () => {
|
|
90
|
+
const doc: OpenApiDocument = {
|
|
91
|
+
openapi: '3.1.0',
|
|
92
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
93
|
+
paths: { '/items/{id}': { delete: { responses: { '200': { description: 'OK' } } } } },
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const schema = openApiToSchema(doc)
|
|
97
|
+
expect(schema.endpoints.DELETE).toBeDefined()
|
|
98
|
+
expect(schema.endpoints.DELETE!['/items/:id']).toBeDefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('Should extract PATCH endpoints', () => {
|
|
102
|
+
const doc: OpenApiDocument = {
|
|
103
|
+
openapi: '3.1.0',
|
|
104
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
105
|
+
paths: { '/items/{id}': { patch: { responses: { '200': { description: 'OK' } } } } },
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const schema = openApiToSchema(doc)
|
|
109
|
+
expect(schema.endpoints.PATCH).toBeDefined()
|
|
110
|
+
expect(schema.endpoints.PATCH!['/items/:id']).toBeDefined()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('Should extract HEAD endpoints', () => {
|
|
114
|
+
const doc: OpenApiDocument = {
|
|
115
|
+
openapi: '3.1.0',
|
|
116
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
117
|
+
paths: { '/items': { head: { responses: { '200': { description: 'OK' } } } } },
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const schema = openApiToSchema(doc)
|
|
121
|
+
expect(schema.endpoints.HEAD).toBeDefined()
|
|
122
|
+
expect(schema.endpoints.HEAD!['/items']).toBeDefined()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('Should extract OPTIONS endpoints', () => {
|
|
126
|
+
const doc: OpenApiDocument = {
|
|
127
|
+
openapi: '3.1.0',
|
|
128
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
129
|
+
paths: { '/items': { options: { responses: { '200': { description: 'OK' } } } } },
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const schema = openApiToSchema(doc)
|
|
133
|
+
expect(schema.endpoints.OPTIONS).toBeDefined()
|
|
134
|
+
expect(schema.endpoints.OPTIONS!['/items']).toBeDefined()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('Should extract TRACE endpoints', () => {
|
|
138
|
+
const doc: OpenApiDocument = {
|
|
139
|
+
openapi: '3.1.0',
|
|
140
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
141
|
+
paths: { '/items': { trace: { responses: { '200': { description: 'OK' } } } } },
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const schema = openApiToSchema(doc)
|
|
145
|
+
expect(schema.endpoints.TRACE).toBeDefined()
|
|
146
|
+
expect(schema.endpoints.TRACE!['/items']).toBeDefined()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('Should handle multiple HTTP methods on the same path', () => {
|
|
150
|
+
const doc: OpenApiDocument = {
|
|
151
|
+
openapi: '3.1.0',
|
|
152
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
153
|
+
paths: {
|
|
154
|
+
'/items': {
|
|
155
|
+
get: { responses: { '200': { description: 'OK' } } },
|
|
156
|
+
post: { responses: { '201': { description: 'Created' } } },
|
|
157
|
+
delete: { responses: { '200': { description: 'Deleted' } } },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const schema = openApiToSchema(doc)
|
|
163
|
+
expect(schema.endpoints.GET!['/items']).toBeDefined()
|
|
164
|
+
expect(schema.endpoints.POST!['/items']).toBeDefined()
|
|
165
|
+
expect(schema.endpoints.DELETE!['/items']).toBeDefined()
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('Path parameters', () => {
|
|
170
|
+
it('Should convert single {param} to :param', () => {
|
|
171
|
+
const doc: OpenApiDocument = {
|
|
172
|
+
openapi: '3.1.0',
|
|
173
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
174
|
+
paths: {
|
|
175
|
+
'/users/{id}': { get: { responses: { '200': { description: 'OK' } } } },
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const schema = openApiToSchema(doc)
|
|
180
|
+
expect(schema.endpoints.GET!['/users/:id']).toBeDefined()
|
|
181
|
+
expect(schema.endpoints.GET!['/users/:id'].path).toBe('/users/:id')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('Should convert multiple {params} to :params', () => {
|
|
185
|
+
const doc: OpenApiDocument = {
|
|
186
|
+
openapi: '3.1.0',
|
|
187
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
188
|
+
paths: {
|
|
189
|
+
'/users/{userId}/posts/{postId}': { get: { responses: { '200': { description: 'OK' } } } },
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const schema = openApiToSchema(doc)
|
|
194
|
+
expect(schema.endpoints.GET!['/users/:userId/posts/:postId']).toBeDefined()
|
|
195
|
+
expect(schema.endpoints.GET!['/users/:userId/posts/:postId'].path).toBe('/users/:userId/posts/:postId')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('Response schema extraction', () => {
|
|
200
|
+
it('Should extract schema from 200 response', () => {
|
|
201
|
+
const responseSchema = { type: 'object', properties: { id: { type: 'string' } } }
|
|
202
|
+
const doc: OpenApiDocument = {
|
|
203
|
+
openapi: '3.1.0',
|
|
204
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
205
|
+
paths: {
|
|
206
|
+
'/items': {
|
|
207
|
+
get: {
|
|
208
|
+
responses: {
|
|
209
|
+
'200': { description: 'OK', content: { 'application/json': { schema: responseSchema } } },
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const schema = openApiToSchema(doc)
|
|
217
|
+
expect(schema.endpoints.GET!['/items'].schema).toEqual(responseSchema)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('Should fall back to 201 response schema', () => {
|
|
221
|
+
const responseSchema = { type: 'object', properties: { id: { type: 'string' } } }
|
|
222
|
+
const doc: OpenApiDocument = {
|
|
223
|
+
openapi: '3.1.0',
|
|
224
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
225
|
+
paths: {
|
|
226
|
+
'/items': {
|
|
227
|
+
post: {
|
|
228
|
+
responses: {
|
|
229
|
+
'201': { description: 'Created', content: { 'application/json': { schema: responseSchema } } },
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const schema = openApiToSchema(doc)
|
|
237
|
+
expect(schema.endpoints.POST!['/items'].schema).toEqual(responseSchema)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('Should return empty object for responses without content', () => {
|
|
241
|
+
const doc: OpenApiDocument = {
|
|
242
|
+
openapi: '3.1.0',
|
|
243
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
244
|
+
paths: {
|
|
245
|
+
'/health': { get: { responses: { '200': { description: 'OK' } } } },
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const schema = openApiToSchema(doc)
|
|
250
|
+
expect(schema.endpoints.GET!['/health'].schema).toEqual({})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('Should skip $ref response objects', () => {
|
|
254
|
+
const doc: OpenApiDocument = {
|
|
255
|
+
openapi: '3.1.0',
|
|
256
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
257
|
+
paths: {
|
|
258
|
+
'/items': {
|
|
259
|
+
get: {
|
|
260
|
+
responses: {
|
|
261
|
+
'200': { $ref: '#/components/responses/Success' },
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const schema = openApiToSchema(doc)
|
|
269
|
+
expect(schema.endpoints.GET!['/items'].schema).toEqual({})
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('Schema naming', () => {
|
|
274
|
+
it('Should use operationId as schemaName when available', () => {
|
|
275
|
+
const doc: OpenApiDocument = {
|
|
276
|
+
openapi: '3.1.0',
|
|
277
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
278
|
+
paths: {
|
|
279
|
+
'/users': { get: { operationId: 'listUsers', responses: { '200': { description: 'OK' } } } },
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const schema = openApiToSchema(doc)
|
|
284
|
+
expect(schema.endpoints.GET!['/users'].schemaName).toBe('listUsers')
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('Should generate schemaName from method and path when no operationId', () => {
|
|
288
|
+
const doc: OpenApiDocument = {
|
|
289
|
+
openapi: '3.1.0',
|
|
290
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
291
|
+
paths: {
|
|
292
|
+
'/users/{id}': { get: { responses: { '200': { description: 'OK' } } } },
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const schema = openApiToSchema(doc)
|
|
297
|
+
expect(schema.endpoints.GET!['/users/:id'].schemaName).toBe('get_users_id')
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('Authentication detection', () => {
|
|
302
|
+
it('Should mark as not authenticated when no security defined', () => {
|
|
303
|
+
const doc: OpenApiDocument = {
|
|
304
|
+
openapi: '3.1.0',
|
|
305
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
306
|
+
paths: {
|
|
307
|
+
'/public': { get: { responses: { '200': { description: 'OK' } } } },
|
|
308
|
+
},
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const schema = openApiToSchema(doc)
|
|
312
|
+
expect(schema.endpoints.GET!['/public'].isAuthenticated).toBe(false)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('Should mark as not authenticated with empty operation security', () => {
|
|
316
|
+
const doc: OpenApiDocument = {
|
|
317
|
+
openapi: '3.1.0',
|
|
318
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
319
|
+
paths: {
|
|
320
|
+
'/public': { get: { security: [], responses: { '200': { description: 'OK' } } } },
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const schema = openApiToSchema(doc)
|
|
325
|
+
expect(schema.endpoints.GET!['/public'].isAuthenticated).toBe(false)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('Should mark as authenticated from operation-level security', () => {
|
|
329
|
+
const doc: OpenApiDocument = {
|
|
330
|
+
openapi: '3.1.0',
|
|
331
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
332
|
+
paths: {
|
|
333
|
+
'/private': {
|
|
334
|
+
get: { security: [{ bearerAuth: [] }], responses: { '200': { description: 'OK' } } },
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const schema = openApiToSchema(doc)
|
|
340
|
+
expect(schema.endpoints.GET!['/private'].isAuthenticated).toBe(true)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('Should mark as authenticated from document-level security', () => {
|
|
344
|
+
const doc: OpenApiDocument = {
|
|
345
|
+
openapi: '3.1.0',
|
|
346
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
347
|
+
security: [{ apiKey: [] }],
|
|
348
|
+
paths: {
|
|
349
|
+
'/items': { get: { responses: { '200': { description: 'OK' } } } },
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const schema = openApiToSchema(doc)
|
|
354
|
+
expect(schema.endpoints.GET!['/items'].isAuthenticated).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('Should prefer operation-level security over document-level', () => {
|
|
358
|
+
const doc: OpenApiDocument = {
|
|
359
|
+
openapi: '3.1.0',
|
|
360
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
361
|
+
security: [{ apiKey: [] }],
|
|
362
|
+
paths: {
|
|
363
|
+
'/public': {
|
|
364
|
+
get: { security: [], responses: { '200': { description: 'OK' } } },
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const schema = openApiToSchema(doc)
|
|
370
|
+
expect(schema.endpoints.GET!['/public'].isAuthenticated).toBe(false)
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
describe('Security scheme name extraction', () => {
|
|
375
|
+
it('Should extract operation-level security scheme names', () => {
|
|
376
|
+
const doc: OpenApiDocument = {
|
|
377
|
+
openapi: '3.1.0',
|
|
378
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
379
|
+
paths: {
|
|
380
|
+
'/private': {
|
|
381
|
+
get: { security: [{ bearerAuth: [] }], responses: { '200': { description: 'OK' } } },
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const schema = openApiToSchema(doc)
|
|
387
|
+
expect(schema.endpoints.GET!['/private'].securitySchemes).toEqual(['bearerAuth'])
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('Should extract multiple security scheme names', () => {
|
|
391
|
+
const doc: OpenApiDocument = {
|
|
392
|
+
openapi: '3.1.0',
|
|
393
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
394
|
+
paths: {
|
|
395
|
+
'/private': {
|
|
396
|
+
get: {
|
|
397
|
+
security: [{ bearerAuth: [] }, { apiKey: [] }],
|
|
398
|
+
responses: { '200': { description: 'OK' } },
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const schema = openApiToSchema(doc)
|
|
405
|
+
expect(schema.endpoints.GET!['/private'].securitySchemes).toEqual(['bearerAuth', 'apiKey'])
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('Should inherit document-level security scheme names', () => {
|
|
409
|
+
const doc: OpenApiDocument = {
|
|
410
|
+
openapi: '3.1.0',
|
|
411
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
412
|
+
security: [{ apiKey: [] }],
|
|
413
|
+
paths: {
|
|
414
|
+
'/items': { get: { responses: { '200': { description: 'OK' } } } },
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const schema = openApiToSchema(doc)
|
|
419
|
+
expect(schema.endpoints.GET!['/items'].securitySchemes).toEqual(['apiKey'])
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('Should not set securitySchemes when security is empty', () => {
|
|
423
|
+
const doc: OpenApiDocument = {
|
|
424
|
+
openapi: '3.1.0',
|
|
425
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
426
|
+
paths: {
|
|
427
|
+
'/public': { get: { security: [], responses: { '200': { description: 'OK' } } } },
|
|
428
|
+
},
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const schema = openApiToSchema(doc)
|
|
432
|
+
expect(schema.endpoints.GET!['/public'].securitySchemes).toBeUndefined()
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('Should not set securitySchemes when no security defined', () => {
|
|
436
|
+
const doc: OpenApiDocument = {
|
|
437
|
+
openapi: '3.1.0',
|
|
438
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
439
|
+
paths: {
|
|
440
|
+
'/public': { get: { responses: { '200': { description: 'OK' } } } },
|
|
441
|
+
},
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const schema = openApiToSchema(doc)
|
|
445
|
+
expect(schema.endpoints.GET!['/public'].securitySchemes).toBeUndefined()
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('Should prefer operation-level scheme names over document-level', () => {
|
|
449
|
+
const doc: OpenApiDocument = {
|
|
450
|
+
openapi: '3.1.0',
|
|
451
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
452
|
+
security: [{ apiKey: [] }],
|
|
453
|
+
paths: {
|
|
454
|
+
'/custom': {
|
|
455
|
+
get: { security: [{ bearerAuth: [] }], responses: { '200': { description: 'OK' } } },
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const schema = openApiToSchema(doc)
|
|
461
|
+
expect(schema.endpoints.GET!['/custom'].securitySchemes).toEqual(['bearerAuth'])
|
|
462
|
+
})
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
describe('Edge cases', () => {
|
|
466
|
+
it('Should handle documents with no paths', () => {
|
|
467
|
+
const doc: OpenApiDocument = {
|
|
468
|
+
openapi: '3.1.0',
|
|
469
|
+
info: { title: 'Empty', version: '0.0.1' },
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const schema = openApiToSchema(doc)
|
|
473
|
+
expect(schema.endpoints).toEqual({})
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('Should skip $ref path items', () => {
|
|
477
|
+
const paths = {
|
|
478
|
+
'/ref-path': { $ref: '#/components/pathItems/SomePath' },
|
|
479
|
+
'/real-path': { get: { responses: { '200': { description: 'OK' } } } },
|
|
480
|
+
} as Record<string, unknown>
|
|
481
|
+
const doc: OpenApiDocument = {
|
|
482
|
+
openapi: '3.1.0',
|
|
483
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
484
|
+
paths: paths as OpenApiDocument['paths'],
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const schema = openApiToSchema(doc)
|
|
488
|
+
expect(schema.endpoints.GET!['/real-path']).toBeDefined()
|
|
489
|
+
expect(schema.endpoints.GET!['/ref-path']).toBeUndefined()
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('Should handle path items with summary/description but no operations', () => {
|
|
493
|
+
const doc: OpenApiDocument = {
|
|
494
|
+
openapi: '3.1.0',
|
|
495
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
496
|
+
paths: {
|
|
497
|
+
'/no-ops': { summary: 'A path with no operations' },
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const schema = openApiToSchema(doc)
|
|
502
|
+
expect(Object.keys(schema.endpoints)).toEqual([])
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
describe('Document-level metadata extraction', () => {
|
|
507
|
+
it('Should extract all info metadata fields', () => {
|
|
508
|
+
const doc: OpenApiDocument = {
|
|
509
|
+
openapi: '3.1.0',
|
|
510
|
+
info: {
|
|
511
|
+
title: 'Test',
|
|
512
|
+
version: '1.0.0',
|
|
513
|
+
summary: 'A test API',
|
|
514
|
+
termsOfService: 'https://example.com/terms',
|
|
515
|
+
contact: { name: 'Support', email: 'support@example.com' },
|
|
516
|
+
license: { name: 'MIT' },
|
|
517
|
+
},
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const schema = openApiToSchema(doc)
|
|
521
|
+
expect(schema.metadata?.summary).toBe('A test API')
|
|
522
|
+
expect(schema.metadata?.termsOfService).toBe('https://example.com/terms')
|
|
523
|
+
expect(schema.metadata?.contact).toEqual({ name: 'Support', email: 'support@example.com' })
|
|
524
|
+
expect(schema.metadata?.license).toEqual({ name: 'MIT' })
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('Should extract servers with variables', () => {
|
|
528
|
+
const doc: OpenApiDocument = {
|
|
529
|
+
openapi: '3.1.0',
|
|
530
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
531
|
+
servers: [
|
|
532
|
+
{
|
|
533
|
+
url: 'https://{env}.example.com',
|
|
534
|
+
variables: { env: { default: 'prod', enum: ['prod', 'staging'] } },
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const schema = openApiToSchema(doc)
|
|
540
|
+
expect(schema.metadata?.servers).toHaveLength(1)
|
|
541
|
+
expect(schema.metadata?.servers?.[0].url).toBe('https://{env}.example.com')
|
|
542
|
+
expect(schema.metadata?.servers?.[0].variables?.env.default).toBe('prod')
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
it('Should extract tags', () => {
|
|
546
|
+
const doc: OpenApiDocument = {
|
|
547
|
+
openapi: '3.1.0',
|
|
548
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
549
|
+
tags: [
|
|
550
|
+
{ name: 'pets', description: 'Pet operations' },
|
|
551
|
+
{ name: 'store', externalDocs: { url: 'https://example.com/docs' } },
|
|
552
|
+
],
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const schema = openApiToSchema(doc)
|
|
556
|
+
expect(schema.metadata?.tags).toHaveLength(2)
|
|
557
|
+
expect(schema.metadata?.tags?.[0].name).toBe('pets')
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('Should extract externalDocs', () => {
|
|
561
|
+
const doc: OpenApiDocument = {
|
|
562
|
+
openapi: '3.1.0',
|
|
563
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
564
|
+
externalDocs: { url: 'https://example.com/docs', description: 'Full docs' },
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const schema = openApiToSchema(doc)
|
|
568
|
+
expect(schema.metadata?.externalDocs).toEqual({ url: 'https://example.com/docs', description: 'Full docs' })
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('Should extract security schemes', () => {
|
|
572
|
+
const doc: OpenApiDocument = {
|
|
573
|
+
openapi: '3.1.0',
|
|
574
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
575
|
+
components: {
|
|
576
|
+
securitySchemes: {
|
|
577
|
+
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
|
578
|
+
apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const schema = openApiToSchema(doc)
|
|
584
|
+
expect(schema.metadata?.securitySchemes?.bearerAuth).toEqual({
|
|
585
|
+
type: 'http',
|
|
586
|
+
scheme: 'bearer',
|
|
587
|
+
bearerFormat: 'JWT',
|
|
588
|
+
})
|
|
589
|
+
expect(schema.metadata?.securitySchemes?.apiKey).toBeDefined()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('Should skip $ref security schemes', () => {
|
|
593
|
+
const doc: OpenApiDocument = {
|
|
594
|
+
openapi: '3.1.0',
|
|
595
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
596
|
+
components: {
|
|
597
|
+
securitySchemes: {
|
|
598
|
+
external: { $ref: '#/components/securitySchemes/Other' } as unknown as OpenApiDocument extends never
|
|
599
|
+
? never
|
|
600
|
+
: { $ref: string; type: 'apiKey'; in: 'header'; name: 'x' },
|
|
601
|
+
local: { type: 'apiKey', in: 'header', name: 'X-Key' },
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const schema = openApiToSchema(doc)
|
|
607
|
+
expect(schema.metadata?.securitySchemes?.local).toBeDefined()
|
|
608
|
+
expect(schema.metadata?.securitySchemes?.external).toBeUndefined()
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('Should not set securitySchemes when all are $ref', () => {
|
|
612
|
+
const schemes = {
|
|
613
|
+
external: { $ref: '#/other' },
|
|
614
|
+
} as Record<string, unknown>
|
|
615
|
+
const doc: OpenApiDocument = {
|
|
616
|
+
openapi: '3.1.0',
|
|
617
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
618
|
+
components: {
|
|
619
|
+
securitySchemes: schemes as OpenApiDocument['components'] extends { securitySchemes?: infer S } ? S : never,
|
|
620
|
+
},
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const schema = openApiToSchema(doc)
|
|
624
|
+
expect(schema.metadata?.securitySchemes).toBeUndefined()
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('Should return undefined metadata when no metadata present', () => {
|
|
628
|
+
const doc: OpenApiDocument = {
|
|
629
|
+
openapi: '3.1.0',
|
|
630
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const schema = openApiToSchema(doc)
|
|
634
|
+
expect(schema.metadata).toBeUndefined()
|
|
635
|
+
})
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
describe('Operation-level metadata extraction', () => {
|
|
639
|
+
it('Should extract tags from operations', () => {
|
|
640
|
+
const doc: OpenApiDocument = {
|
|
641
|
+
openapi: '3.1.0',
|
|
642
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
643
|
+
paths: {
|
|
644
|
+
'/pets': {
|
|
645
|
+
get: { tags: ['pets'], responses: { '200': { description: 'OK' } } },
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const schema = openApiToSchema(doc)
|
|
651
|
+
expect(schema.endpoints.GET!['/pets'].tags).toEqual(['pets'])
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
it('Should extract deprecated flag from operations', () => {
|
|
655
|
+
const doc: OpenApiDocument = {
|
|
656
|
+
openapi: '3.1.0',
|
|
657
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
658
|
+
paths: {
|
|
659
|
+
'/old': {
|
|
660
|
+
get: { deprecated: true, responses: { '200': { description: 'OK' } } },
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const schema = openApiToSchema(doc)
|
|
666
|
+
expect(schema.endpoints.GET!['/old'].deprecated).toBe(true)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('Should extract summary and description from operations', () => {
|
|
670
|
+
const doc: OpenApiDocument = {
|
|
671
|
+
openapi: '3.1.0',
|
|
672
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
673
|
+
paths: {
|
|
674
|
+
'/pets': {
|
|
675
|
+
get: {
|
|
676
|
+
summary: 'List pets',
|
|
677
|
+
description: 'Returns all pets',
|
|
678
|
+
responses: { '200': { description: 'OK' } },
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const schema = openApiToSchema(doc)
|
|
685
|
+
expect(schema.endpoints.GET!['/pets'].summary).toBe('List pets')
|
|
686
|
+
expect(schema.endpoints.GET!['/pets'].description).toBe('Returns all pets')
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('Should not set metadata fields when absent', () => {
|
|
690
|
+
const doc: OpenApiDocument = {
|
|
691
|
+
openapi: '3.1.0',
|
|
692
|
+
info: { title: 'Test', version: '1.0.0' },
|
|
693
|
+
paths: {
|
|
694
|
+
'/items': {
|
|
695
|
+
get: { responses: { '200': { description: 'OK' } } },
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const schema = openApiToSchema(doc)
|
|
701
|
+
expect(schema.endpoints.GET!['/items'].tags).toBeUndefined()
|
|
702
|
+
expect(schema.endpoints.GET!['/items'].deprecated).toBeUndefined()
|
|
703
|
+
expect(schema.endpoints.GET!['/items'].summary).toBeUndefined()
|
|
704
|
+
expect(schema.endpoints.GET!['/items'].description).toBeUndefined()
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
})
|