enlace-openapi 0.0.1-beta.2 → 0.0.1-beta.4
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/LICENSE +21 -0
- package/README.md +129 -13
- package/dist/cli.js +92 -18
- package/dist/cli.mjs +92 -18
- package/dist/index.d.mts +15 -9
- package/dist/index.d.ts +15 -9
- package/dist/index.js +92 -18
- package/dist/index.mjs +92 -18
- package/package.json +8 -9
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Enlace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -135,13 +135,11 @@ This generates:
|
|
|
135
135
|
}
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
## Endpoint
|
|
138
|
+
## Endpoint Types
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
### `Endpoint<TData, TBody?, TError?>`
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
Endpoint<TData, TBody?, TError?>
|
|
144
|
-
```
|
|
142
|
+
For endpoints with JSON body:
|
|
145
143
|
|
|
146
144
|
| Parameter | Description |
|
|
147
145
|
|-----------|-------------|
|
|
@@ -149,6 +147,112 @@ Endpoint<TData, TBody?, TError?>
|
|
|
149
147
|
| `TBody` | Request body type (optional) |
|
|
150
148
|
| `TError` | Error response type (optional) |
|
|
151
149
|
|
|
150
|
+
### `EndpointWithQuery<TData, TQuery, TError?>`
|
|
151
|
+
|
|
152
|
+
For endpoints with typed query parameters:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { EndpointWithQuery } from "enlace-core";
|
|
156
|
+
|
|
157
|
+
type ApiSchema = {
|
|
158
|
+
users: {
|
|
159
|
+
$get: EndpointWithQuery<User[], { page: number; limit: number; search?: string }>;
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Generated OpenAPI:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"/users": {
|
|
169
|
+
"get": {
|
|
170
|
+
"parameters": [
|
|
171
|
+
{ "name": "page", "in": "query", "required": true, "schema": { "type": "number" } },
|
|
172
|
+
{ "name": "limit", "in": "query", "required": true, "schema": { "type": "number" } },
|
|
173
|
+
{ "name": "search", "in": "query", "required": false, "schema": { "type": "string" } }
|
|
174
|
+
],
|
|
175
|
+
"responses": { "200": { "..." } }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### `EndpointWithFormData<TData, TFormData, TError?>`
|
|
182
|
+
|
|
183
|
+
For file upload endpoints (multipart/form-data):
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { EndpointWithFormData } from "enlace-core";
|
|
187
|
+
|
|
188
|
+
type ApiSchema = {
|
|
189
|
+
uploads: {
|
|
190
|
+
$post: EndpointWithFormData<Upload, { file: Blob | File; name: string }>;
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Generated OpenAPI:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"/uploads": {
|
|
200
|
+
"post": {
|
|
201
|
+
"requestBody": {
|
|
202
|
+
"required": true,
|
|
203
|
+
"content": {
|
|
204
|
+
"multipart/form-data": {
|
|
205
|
+
"schema": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"properties": {
|
|
208
|
+
"file": { "type": "string", "format": "binary" },
|
|
209
|
+
"name": { "type": "string" }
|
|
210
|
+
},
|
|
211
|
+
"required": ["file", "name"]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
"responses": { "200": { "..." } }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### `EndpointFull<T>`
|
|
223
|
+
|
|
224
|
+
Object-style for complex endpoints with multiple options:
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { EndpointFull } from "enlace-core";
|
|
228
|
+
|
|
229
|
+
type ApiSchema = {
|
|
230
|
+
products: {
|
|
231
|
+
$post: EndpointFull<{
|
|
232
|
+
data: Product;
|
|
233
|
+
body: CreateProduct;
|
|
234
|
+
query: { categoryId: string };
|
|
235
|
+
error: ValidationError;
|
|
236
|
+
}>;
|
|
237
|
+
};
|
|
238
|
+
files: {
|
|
239
|
+
$post: EndpointFull<{
|
|
240
|
+
data: FileUpload;
|
|
241
|
+
formData: { file: File; description: string };
|
|
242
|
+
query: { folder: string };
|
|
243
|
+
}>;
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
| Property | Description | OpenAPI Mapping |
|
|
249
|
+
|----------|-------------|-----------------|
|
|
250
|
+
| `data` | Response data type | `responses.200.content` |
|
|
251
|
+
| `body` | JSON request body | `requestBody` with `application/json` |
|
|
252
|
+
| `query` | Query parameters | `parameters` with `in: "query"` |
|
|
253
|
+
| `formData` | FormData fields | `requestBody` with `multipart/form-data` |
|
|
254
|
+
| `error` | Error response type | `responses.400.content` |
|
|
255
|
+
|
|
152
256
|
## Path Parameters
|
|
153
257
|
|
|
154
258
|
Use `_` to define dynamic path segments:
|
|
@@ -186,18 +290,30 @@ Parameter names are auto-generated from the parent segment (e.g., `users` → `u
|
|
|
186
290
|
|
|
187
291
|
## Programmatic API
|
|
188
292
|
|
|
293
|
+
### Next.js + Swagger UI Example
|
|
294
|
+
|
|
189
295
|
```typescript
|
|
296
|
+
import SwaggerUI from "swagger-ui-react";
|
|
297
|
+
import "swagger-ui-react/swagger-ui.css";
|
|
190
298
|
import { parseSchema, generateOpenAPISpec } from "enlace-openapi";
|
|
191
299
|
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
300
|
+
const spec = (() => {
|
|
301
|
+
const { endpoints, schemas } = parseSchema(
|
|
302
|
+
"./APISchema.ts",
|
|
303
|
+
"ApiSchema"
|
|
304
|
+
);
|
|
305
|
+
return generateOpenAPISpec(endpoints, schemas, {
|
|
306
|
+
title: "My API",
|
|
307
|
+
version: "1.0.0",
|
|
308
|
+
baseUrl: "https://api.example.com",
|
|
309
|
+
});
|
|
310
|
+
})();
|
|
311
|
+
|
|
312
|
+
const DocsPage = () => {
|
|
313
|
+
return <SwaggerUI spec={spec} />;
|
|
314
|
+
};
|
|
199
315
|
|
|
200
|
-
|
|
316
|
+
export default DocsPage;
|
|
201
317
|
```
|
|
202
318
|
|
|
203
319
|
## Viewing the OpenAPI Spec
|
package/dist/cli.js
CHANGED
|
@@ -33,14 +33,19 @@ var import_path = __toESM(require("path"));
|
|
|
33
33
|
|
|
34
34
|
// src/type-to-schema.ts
|
|
35
35
|
var import_typescript = __toESM(require("typescript"));
|
|
36
|
+
var MAX_DEPTH = 50;
|
|
36
37
|
function createSchemaContext(checker) {
|
|
37
38
|
return {
|
|
38
39
|
checker,
|
|
39
40
|
schemas: /* @__PURE__ */ new Map(),
|
|
40
|
-
visitedTypes: /* @__PURE__ */ new Set()
|
|
41
|
+
visitedTypes: /* @__PURE__ */ new Set(),
|
|
42
|
+
depth: 0
|
|
41
43
|
};
|
|
42
44
|
}
|
|
43
45
|
function typeToSchema(type, ctx) {
|
|
46
|
+
if (ctx.depth > MAX_DEPTH) {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
44
49
|
const { checker } = ctx;
|
|
45
50
|
if (type.flags & import_typescript.default.TypeFlags.String) {
|
|
46
51
|
return { type: "string" };
|
|
@@ -118,18 +123,18 @@ function typeToSchema(type, ctx) {
|
|
|
118
123
|
return { type: "string", format: "date-time" };
|
|
119
124
|
}
|
|
120
125
|
if (typeName && typeName !== "__type" && typeName !== "Array" && !typeName.startsWith("__")) {
|
|
121
|
-
if (ctx.visitedTypes.has(
|
|
126
|
+
if (ctx.visitedTypes.has(typeName)) {
|
|
122
127
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
123
128
|
}
|
|
124
129
|
if (!ctx.schemas.has(typeName)) {
|
|
125
|
-
ctx.visitedTypes.add(
|
|
126
|
-
const schema = objectTypeToSchema(type, ctx);
|
|
130
|
+
ctx.visitedTypes.add(typeName);
|
|
131
|
+
const schema = objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
127
132
|
ctx.schemas.set(typeName, schema);
|
|
128
|
-
ctx.visitedTypes.delete(
|
|
133
|
+
ctx.visitedTypes.delete(typeName);
|
|
129
134
|
}
|
|
130
135
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
131
136
|
}
|
|
132
|
-
return objectTypeToSchema(type, ctx);
|
|
137
|
+
return objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
133
138
|
}
|
|
134
139
|
return {};
|
|
135
140
|
}
|
|
@@ -142,7 +147,7 @@ function objectTypeToSchema(type, ctx) {
|
|
|
142
147
|
const propName = prop.getName();
|
|
143
148
|
const propType = checker.getTypeOfSymbol(prop);
|
|
144
149
|
const isOptional = prop.flags & import_typescript.default.SymbolFlags.Optional;
|
|
145
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
150
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
146
151
|
if (!isOptional) {
|
|
147
152
|
required.push(propName);
|
|
148
153
|
}
|
|
@@ -167,7 +172,7 @@ function intersectionTypeToSchema(type, ctx) {
|
|
|
167
172
|
if (propName.startsWith("__")) continue;
|
|
168
173
|
const propType = checker.getTypeOfSymbol(prop);
|
|
169
174
|
const isOptional = prop.flags & import_typescript.default.SymbolFlags.Optional;
|
|
170
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
175
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
171
176
|
if (!isOptional && !required.includes(propName)) {
|
|
172
177
|
required.push(propName);
|
|
173
178
|
}
|
|
@@ -299,16 +304,20 @@ function isEndpointStructure(type) {
|
|
|
299
304
|
propNames.delete("data");
|
|
300
305
|
propNames.delete("body");
|
|
301
306
|
propNames.delete("error");
|
|
307
|
+
propNames.delete("query");
|
|
308
|
+
propNames.delete("formData");
|
|
302
309
|
const remainingProps = [...propNames].filter(
|
|
303
310
|
(name) => !name.startsWith("__@") && !name.includes("Brand")
|
|
304
311
|
);
|
|
305
312
|
return remainingProps.length === 0;
|
|
306
313
|
}
|
|
307
|
-
function parseEndpointType(type,
|
|
314
|
+
function parseEndpointType(type, pathStr, method, pathParams, ctx) {
|
|
308
315
|
const { checker } = ctx;
|
|
309
316
|
let dataType;
|
|
310
317
|
let bodyType;
|
|
311
318
|
let errorType;
|
|
319
|
+
let queryType;
|
|
320
|
+
let formDataType;
|
|
312
321
|
const typesToCheck = type.isIntersection() ? type.types : [type];
|
|
313
322
|
for (const t of typesToCheck) {
|
|
314
323
|
const props = t.getProperties();
|
|
@@ -320,32 +329,93 @@ function parseEndpointType(type, path2, method, pathParams, ctx) {
|
|
|
320
329
|
bodyType = checker.getTypeOfSymbol(prop);
|
|
321
330
|
} else if (name === "error") {
|
|
322
331
|
errorType = checker.getTypeOfSymbol(prop);
|
|
332
|
+
} else if (name === "query") {
|
|
333
|
+
queryType = checker.getTypeOfSymbol(prop);
|
|
334
|
+
} else if (name === "formData") {
|
|
335
|
+
formDataType = checker.getTypeOfSymbol(prop);
|
|
323
336
|
}
|
|
324
337
|
}
|
|
325
338
|
}
|
|
326
339
|
const endpoint = {
|
|
327
|
-
path:
|
|
340
|
+
path: pathStr,
|
|
328
341
|
method,
|
|
329
342
|
responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
|
|
330
343
|
pathParams
|
|
331
344
|
};
|
|
332
|
-
if (
|
|
345
|
+
if (formDataType && !(formDataType.flags & import_typescript2.default.TypeFlags.Never)) {
|
|
346
|
+
endpoint.requestBodySchema = formDataTypeToSchema(formDataType, ctx);
|
|
347
|
+
endpoint.requestBodyContentType = "multipart/form-data";
|
|
348
|
+
} else if (bodyType && !(bodyType.flags & import_typescript2.default.TypeFlags.Never)) {
|
|
333
349
|
endpoint.requestBodySchema = typeToSchema(bodyType, ctx);
|
|
350
|
+
endpoint.requestBodyContentType = "application/json";
|
|
351
|
+
}
|
|
352
|
+
if (queryType && !(queryType.flags & import_typescript2.default.TypeFlags.Never)) {
|
|
353
|
+
endpoint.queryParams = queryTypeToParams(queryType, ctx);
|
|
334
354
|
}
|
|
335
355
|
if (errorType && !(errorType.flags & import_typescript2.default.TypeFlags.Never) && !(errorType.flags & import_typescript2.default.TypeFlags.Unknown)) {
|
|
336
356
|
endpoint.errorSchema = typeToSchema(errorType, ctx);
|
|
337
357
|
}
|
|
338
358
|
return endpoint;
|
|
339
359
|
}
|
|
360
|
+
function queryTypeToParams(queryType, ctx) {
|
|
361
|
+
const { checker } = ctx;
|
|
362
|
+
const params = [];
|
|
363
|
+
const props = queryType.getProperties();
|
|
364
|
+
for (const prop of props) {
|
|
365
|
+
const propName = prop.getName();
|
|
366
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
367
|
+
const isOptional = !!(prop.flags & import_typescript2.default.SymbolFlags.Optional);
|
|
368
|
+
params.push({
|
|
369
|
+
name: propName,
|
|
370
|
+
in: "query",
|
|
371
|
+
required: !isOptional,
|
|
372
|
+
schema: typeToSchema(propType, ctx)
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
return params;
|
|
376
|
+
}
|
|
377
|
+
function formDataTypeToSchema(formDataType, ctx) {
|
|
378
|
+
const { checker } = ctx;
|
|
379
|
+
const properties = {};
|
|
380
|
+
const required = [];
|
|
381
|
+
const props = formDataType.getProperties();
|
|
382
|
+
for (const prop of props) {
|
|
383
|
+
const propName = prop.getName();
|
|
384
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
385
|
+
const isOptional = !!(prop.flags & import_typescript2.default.SymbolFlags.Optional);
|
|
386
|
+
const typeName = checker.typeToString(propType);
|
|
387
|
+
if (typeName.includes("File") || typeName.includes("Blob")) {
|
|
388
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
389
|
+
} else if (propType.isUnion()) {
|
|
390
|
+
const hasFile = propType.types.some((t) => {
|
|
391
|
+
const name = checker.typeToString(t);
|
|
392
|
+
return name.includes("File") || name.includes("Blob");
|
|
393
|
+
});
|
|
394
|
+
if (hasFile) {
|
|
395
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
396
|
+
} else {
|
|
397
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
401
|
+
}
|
|
402
|
+
if (!isOptional) {
|
|
403
|
+
required.push(propName);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const schema = {
|
|
407
|
+
type: "object",
|
|
408
|
+
properties
|
|
409
|
+
};
|
|
410
|
+
if (required.length > 0) {
|
|
411
|
+
schema.required = required;
|
|
412
|
+
}
|
|
413
|
+
return schema;
|
|
414
|
+
}
|
|
340
415
|
|
|
341
416
|
// src/generator.ts
|
|
342
417
|
function generateOpenAPISpec(endpoints, schemas, options = {}) {
|
|
343
|
-
const {
|
|
344
|
-
title = "API",
|
|
345
|
-
version = "1.0.0",
|
|
346
|
-
description,
|
|
347
|
-
baseUrl
|
|
348
|
-
} = options;
|
|
418
|
+
const { title = "API", version = "1.0.0", description, baseUrl } = options;
|
|
349
419
|
const paths = {};
|
|
350
420
|
for (const endpoint of endpoints) {
|
|
351
421
|
if (!paths[endpoint.path]) {
|
|
@@ -399,11 +469,15 @@ function createOperation(endpoint) {
|
|
|
399
469
|
}
|
|
400
470
|
};
|
|
401
471
|
}
|
|
472
|
+
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
|
|
473
|
+
operation.parameters = endpoint.queryParams;
|
|
474
|
+
}
|
|
402
475
|
if (endpoint.requestBodySchema && hasContent(endpoint.requestBodySchema)) {
|
|
476
|
+
const contentType = endpoint.requestBodyContentType || "application/json";
|
|
403
477
|
operation.requestBody = {
|
|
404
478
|
required: true,
|
|
405
479
|
content: {
|
|
406
|
-
|
|
480
|
+
[contentType]: {
|
|
407
481
|
schema: endpoint.requestBodySchema
|
|
408
482
|
}
|
|
409
483
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -10,14 +10,19 @@ import path from "path";
|
|
|
10
10
|
|
|
11
11
|
// src/type-to-schema.ts
|
|
12
12
|
import ts from "typescript";
|
|
13
|
+
var MAX_DEPTH = 50;
|
|
13
14
|
function createSchemaContext(checker) {
|
|
14
15
|
return {
|
|
15
16
|
checker,
|
|
16
17
|
schemas: /* @__PURE__ */ new Map(),
|
|
17
|
-
visitedTypes: /* @__PURE__ */ new Set()
|
|
18
|
+
visitedTypes: /* @__PURE__ */ new Set(),
|
|
19
|
+
depth: 0
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
function typeToSchema(type, ctx) {
|
|
23
|
+
if (ctx.depth > MAX_DEPTH) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
21
26
|
const { checker } = ctx;
|
|
22
27
|
if (type.flags & ts.TypeFlags.String) {
|
|
23
28
|
return { type: "string" };
|
|
@@ -95,18 +100,18 @@ function typeToSchema(type, ctx) {
|
|
|
95
100
|
return { type: "string", format: "date-time" };
|
|
96
101
|
}
|
|
97
102
|
if (typeName && typeName !== "__type" && typeName !== "Array" && !typeName.startsWith("__")) {
|
|
98
|
-
if (ctx.visitedTypes.has(
|
|
103
|
+
if (ctx.visitedTypes.has(typeName)) {
|
|
99
104
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
100
105
|
}
|
|
101
106
|
if (!ctx.schemas.has(typeName)) {
|
|
102
|
-
ctx.visitedTypes.add(
|
|
103
|
-
const schema = objectTypeToSchema(type, ctx);
|
|
107
|
+
ctx.visitedTypes.add(typeName);
|
|
108
|
+
const schema = objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
104
109
|
ctx.schemas.set(typeName, schema);
|
|
105
|
-
ctx.visitedTypes.delete(
|
|
110
|
+
ctx.visitedTypes.delete(typeName);
|
|
106
111
|
}
|
|
107
112
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
108
113
|
}
|
|
109
|
-
return objectTypeToSchema(type, ctx);
|
|
114
|
+
return objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
110
115
|
}
|
|
111
116
|
return {};
|
|
112
117
|
}
|
|
@@ -119,7 +124,7 @@ function objectTypeToSchema(type, ctx) {
|
|
|
119
124
|
const propName = prop.getName();
|
|
120
125
|
const propType = checker.getTypeOfSymbol(prop);
|
|
121
126
|
const isOptional = prop.flags & ts.SymbolFlags.Optional;
|
|
122
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
127
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
123
128
|
if (!isOptional) {
|
|
124
129
|
required.push(propName);
|
|
125
130
|
}
|
|
@@ -144,7 +149,7 @@ function intersectionTypeToSchema(type, ctx) {
|
|
|
144
149
|
if (propName.startsWith("__")) continue;
|
|
145
150
|
const propType = checker.getTypeOfSymbol(prop);
|
|
146
151
|
const isOptional = prop.flags & ts.SymbolFlags.Optional;
|
|
147
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
152
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
148
153
|
if (!isOptional && !required.includes(propName)) {
|
|
149
154
|
required.push(propName);
|
|
150
155
|
}
|
|
@@ -276,16 +281,20 @@ function isEndpointStructure(type) {
|
|
|
276
281
|
propNames.delete("data");
|
|
277
282
|
propNames.delete("body");
|
|
278
283
|
propNames.delete("error");
|
|
284
|
+
propNames.delete("query");
|
|
285
|
+
propNames.delete("formData");
|
|
279
286
|
const remainingProps = [...propNames].filter(
|
|
280
287
|
(name) => !name.startsWith("__@") && !name.includes("Brand")
|
|
281
288
|
);
|
|
282
289
|
return remainingProps.length === 0;
|
|
283
290
|
}
|
|
284
|
-
function parseEndpointType(type,
|
|
291
|
+
function parseEndpointType(type, pathStr, method, pathParams, ctx) {
|
|
285
292
|
const { checker } = ctx;
|
|
286
293
|
let dataType;
|
|
287
294
|
let bodyType;
|
|
288
295
|
let errorType;
|
|
296
|
+
let queryType;
|
|
297
|
+
let formDataType;
|
|
289
298
|
const typesToCheck = type.isIntersection() ? type.types : [type];
|
|
290
299
|
for (const t of typesToCheck) {
|
|
291
300
|
const props = t.getProperties();
|
|
@@ -297,32 +306,93 @@ function parseEndpointType(type, path2, method, pathParams, ctx) {
|
|
|
297
306
|
bodyType = checker.getTypeOfSymbol(prop);
|
|
298
307
|
} else if (name === "error") {
|
|
299
308
|
errorType = checker.getTypeOfSymbol(prop);
|
|
309
|
+
} else if (name === "query") {
|
|
310
|
+
queryType = checker.getTypeOfSymbol(prop);
|
|
311
|
+
} else if (name === "formData") {
|
|
312
|
+
formDataType = checker.getTypeOfSymbol(prop);
|
|
300
313
|
}
|
|
301
314
|
}
|
|
302
315
|
}
|
|
303
316
|
const endpoint = {
|
|
304
|
-
path:
|
|
317
|
+
path: pathStr,
|
|
305
318
|
method,
|
|
306
319
|
responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
|
|
307
320
|
pathParams
|
|
308
321
|
};
|
|
309
|
-
if (
|
|
322
|
+
if (formDataType && !(formDataType.flags & ts2.TypeFlags.Never)) {
|
|
323
|
+
endpoint.requestBodySchema = formDataTypeToSchema(formDataType, ctx);
|
|
324
|
+
endpoint.requestBodyContentType = "multipart/form-data";
|
|
325
|
+
} else if (bodyType && !(bodyType.flags & ts2.TypeFlags.Never)) {
|
|
310
326
|
endpoint.requestBodySchema = typeToSchema(bodyType, ctx);
|
|
327
|
+
endpoint.requestBodyContentType = "application/json";
|
|
328
|
+
}
|
|
329
|
+
if (queryType && !(queryType.flags & ts2.TypeFlags.Never)) {
|
|
330
|
+
endpoint.queryParams = queryTypeToParams(queryType, ctx);
|
|
311
331
|
}
|
|
312
332
|
if (errorType && !(errorType.flags & ts2.TypeFlags.Never) && !(errorType.flags & ts2.TypeFlags.Unknown)) {
|
|
313
333
|
endpoint.errorSchema = typeToSchema(errorType, ctx);
|
|
314
334
|
}
|
|
315
335
|
return endpoint;
|
|
316
336
|
}
|
|
337
|
+
function queryTypeToParams(queryType, ctx) {
|
|
338
|
+
const { checker } = ctx;
|
|
339
|
+
const params = [];
|
|
340
|
+
const props = queryType.getProperties();
|
|
341
|
+
for (const prop of props) {
|
|
342
|
+
const propName = prop.getName();
|
|
343
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
344
|
+
const isOptional = !!(prop.flags & ts2.SymbolFlags.Optional);
|
|
345
|
+
params.push({
|
|
346
|
+
name: propName,
|
|
347
|
+
in: "query",
|
|
348
|
+
required: !isOptional,
|
|
349
|
+
schema: typeToSchema(propType, ctx)
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
return params;
|
|
353
|
+
}
|
|
354
|
+
function formDataTypeToSchema(formDataType, ctx) {
|
|
355
|
+
const { checker } = ctx;
|
|
356
|
+
const properties = {};
|
|
357
|
+
const required = [];
|
|
358
|
+
const props = formDataType.getProperties();
|
|
359
|
+
for (const prop of props) {
|
|
360
|
+
const propName = prop.getName();
|
|
361
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
362
|
+
const isOptional = !!(prop.flags & ts2.SymbolFlags.Optional);
|
|
363
|
+
const typeName = checker.typeToString(propType);
|
|
364
|
+
if (typeName.includes("File") || typeName.includes("Blob")) {
|
|
365
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
366
|
+
} else if (propType.isUnion()) {
|
|
367
|
+
const hasFile = propType.types.some((t) => {
|
|
368
|
+
const name = checker.typeToString(t);
|
|
369
|
+
return name.includes("File") || name.includes("Blob");
|
|
370
|
+
});
|
|
371
|
+
if (hasFile) {
|
|
372
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
373
|
+
} else {
|
|
374
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
378
|
+
}
|
|
379
|
+
if (!isOptional) {
|
|
380
|
+
required.push(propName);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const schema = {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties
|
|
386
|
+
};
|
|
387
|
+
if (required.length > 0) {
|
|
388
|
+
schema.required = required;
|
|
389
|
+
}
|
|
390
|
+
return schema;
|
|
391
|
+
}
|
|
317
392
|
|
|
318
393
|
// src/generator.ts
|
|
319
394
|
function generateOpenAPISpec(endpoints, schemas, options = {}) {
|
|
320
|
-
const {
|
|
321
|
-
title = "API",
|
|
322
|
-
version = "1.0.0",
|
|
323
|
-
description,
|
|
324
|
-
baseUrl
|
|
325
|
-
} = options;
|
|
395
|
+
const { title = "API", version = "1.0.0", description, baseUrl } = options;
|
|
326
396
|
const paths = {};
|
|
327
397
|
for (const endpoint of endpoints) {
|
|
328
398
|
if (!paths[endpoint.path]) {
|
|
@@ -376,11 +446,15 @@ function createOperation(endpoint) {
|
|
|
376
446
|
}
|
|
377
447
|
};
|
|
378
448
|
}
|
|
449
|
+
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
|
|
450
|
+
operation.parameters = endpoint.queryParams;
|
|
451
|
+
}
|
|
379
452
|
if (endpoint.requestBodySchema && hasContent(endpoint.requestBodySchema)) {
|
|
453
|
+
const contentType = endpoint.requestBodyContentType || "application/json";
|
|
380
454
|
operation.requestBody = {
|
|
381
455
|
required: true,
|
|
382
456
|
content: {
|
|
383
|
-
|
|
457
|
+
[contentType]: {
|
|
384
458
|
schema: endpoint.requestBodySchema
|
|
385
459
|
}
|
|
386
460
|
}
|
package/dist/index.d.mts
CHANGED
|
@@ -14,20 +14,24 @@ type JSONSchema = {
|
|
|
14
14
|
format?: string;
|
|
15
15
|
description?: string;
|
|
16
16
|
};
|
|
17
|
+
type OpenAPIRequestBody = {
|
|
18
|
+
required?: boolean;
|
|
19
|
+
content: {
|
|
20
|
+
"application/json"?: {
|
|
21
|
+
schema: JSONSchema;
|
|
22
|
+
};
|
|
23
|
+
"multipart/form-data"?: {
|
|
24
|
+
schema: JSONSchema;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
17
28
|
type OpenAPIOperation = {
|
|
18
29
|
operationId?: string;
|
|
19
30
|
summary?: string;
|
|
20
31
|
description?: string;
|
|
21
32
|
tags?: string[];
|
|
22
33
|
parameters?: OpenAPIParameter[];
|
|
23
|
-
requestBody?:
|
|
24
|
-
required?: boolean;
|
|
25
|
-
content: {
|
|
26
|
-
"application/json": {
|
|
27
|
-
schema: JSONSchema;
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
};
|
|
34
|
+
requestBody?: OpenAPIRequestBody;
|
|
31
35
|
responses: Record<string, {
|
|
32
36
|
description: string;
|
|
33
37
|
content?: {
|
|
@@ -73,6 +77,8 @@ type ParsedEndpoint = {
|
|
|
73
77
|
method: "get" | "post" | "put" | "patch" | "delete";
|
|
74
78
|
responseSchema: JSONSchema;
|
|
75
79
|
requestBodySchema?: JSONSchema;
|
|
80
|
+
requestBodyContentType?: "application/json" | "multipart/form-data";
|
|
81
|
+
queryParams?: OpenAPIParameter[];
|
|
76
82
|
errorSchema?: JSONSchema;
|
|
77
83
|
pathParams: string[];
|
|
78
84
|
};
|
|
@@ -99,4 +105,4 @@ type GeneratorOptions = {
|
|
|
99
105
|
};
|
|
100
106
|
declare function generateOpenAPISpec(endpoints: ParsedEndpoint[], schemas: Map<string, JSONSchema>, options?: GeneratorOptions): OpenAPISpec;
|
|
101
107
|
|
|
102
|
-
export { type CLIOptions, type JSONSchema, type OpenAPIOperation, type OpenAPIParameter, type OpenAPIPathItem, type OpenAPISpec, type ParsedEndpoint, generateOpenAPISpec, parseSchema };
|
|
108
|
+
export { type CLIOptions, type JSONSchema, type OpenAPIOperation, type OpenAPIParameter, type OpenAPIPathItem, type OpenAPIRequestBody, type OpenAPISpec, type ParsedEndpoint, generateOpenAPISpec, parseSchema };
|
package/dist/index.d.ts
CHANGED
|
@@ -14,20 +14,24 @@ type JSONSchema = {
|
|
|
14
14
|
format?: string;
|
|
15
15
|
description?: string;
|
|
16
16
|
};
|
|
17
|
+
type OpenAPIRequestBody = {
|
|
18
|
+
required?: boolean;
|
|
19
|
+
content: {
|
|
20
|
+
"application/json"?: {
|
|
21
|
+
schema: JSONSchema;
|
|
22
|
+
};
|
|
23
|
+
"multipart/form-data"?: {
|
|
24
|
+
schema: JSONSchema;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
17
28
|
type OpenAPIOperation = {
|
|
18
29
|
operationId?: string;
|
|
19
30
|
summary?: string;
|
|
20
31
|
description?: string;
|
|
21
32
|
tags?: string[];
|
|
22
33
|
parameters?: OpenAPIParameter[];
|
|
23
|
-
requestBody?:
|
|
24
|
-
required?: boolean;
|
|
25
|
-
content: {
|
|
26
|
-
"application/json": {
|
|
27
|
-
schema: JSONSchema;
|
|
28
|
-
};
|
|
29
|
-
};
|
|
30
|
-
};
|
|
34
|
+
requestBody?: OpenAPIRequestBody;
|
|
31
35
|
responses: Record<string, {
|
|
32
36
|
description: string;
|
|
33
37
|
content?: {
|
|
@@ -73,6 +77,8 @@ type ParsedEndpoint = {
|
|
|
73
77
|
method: "get" | "post" | "put" | "patch" | "delete";
|
|
74
78
|
responseSchema: JSONSchema;
|
|
75
79
|
requestBodySchema?: JSONSchema;
|
|
80
|
+
requestBodyContentType?: "application/json" | "multipart/form-data";
|
|
81
|
+
queryParams?: OpenAPIParameter[];
|
|
76
82
|
errorSchema?: JSONSchema;
|
|
77
83
|
pathParams: string[];
|
|
78
84
|
};
|
|
@@ -99,4 +105,4 @@ type GeneratorOptions = {
|
|
|
99
105
|
};
|
|
100
106
|
declare function generateOpenAPISpec(endpoints: ParsedEndpoint[], schemas: Map<string, JSONSchema>, options?: GeneratorOptions): OpenAPISpec;
|
|
101
107
|
|
|
102
|
-
export { type CLIOptions, type JSONSchema, type OpenAPIOperation, type OpenAPIParameter, type OpenAPIPathItem, type OpenAPISpec, type ParsedEndpoint, generateOpenAPISpec, parseSchema };
|
|
108
|
+
export { type CLIOptions, type JSONSchema, type OpenAPIOperation, type OpenAPIParameter, type OpenAPIPathItem, type OpenAPIRequestBody, type OpenAPISpec, type ParsedEndpoint, generateOpenAPISpec, parseSchema };
|
package/dist/index.js
CHANGED
|
@@ -41,14 +41,19 @@ var import_path = __toESM(require("path"));
|
|
|
41
41
|
|
|
42
42
|
// src/type-to-schema.ts
|
|
43
43
|
var import_typescript = __toESM(require("typescript"));
|
|
44
|
+
var MAX_DEPTH = 50;
|
|
44
45
|
function createSchemaContext(checker) {
|
|
45
46
|
return {
|
|
46
47
|
checker,
|
|
47
48
|
schemas: /* @__PURE__ */ new Map(),
|
|
48
|
-
visitedTypes: /* @__PURE__ */ new Set()
|
|
49
|
+
visitedTypes: /* @__PURE__ */ new Set(),
|
|
50
|
+
depth: 0
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
function typeToSchema(type, ctx) {
|
|
54
|
+
if (ctx.depth > MAX_DEPTH) {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
52
57
|
const { checker } = ctx;
|
|
53
58
|
if (type.flags & import_typescript.default.TypeFlags.String) {
|
|
54
59
|
return { type: "string" };
|
|
@@ -126,18 +131,18 @@ function typeToSchema(type, ctx) {
|
|
|
126
131
|
return { type: "string", format: "date-time" };
|
|
127
132
|
}
|
|
128
133
|
if (typeName && typeName !== "__type" && typeName !== "Array" && !typeName.startsWith("__")) {
|
|
129
|
-
if (ctx.visitedTypes.has(
|
|
134
|
+
if (ctx.visitedTypes.has(typeName)) {
|
|
130
135
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
131
136
|
}
|
|
132
137
|
if (!ctx.schemas.has(typeName)) {
|
|
133
|
-
ctx.visitedTypes.add(
|
|
134
|
-
const schema = objectTypeToSchema(type, ctx);
|
|
138
|
+
ctx.visitedTypes.add(typeName);
|
|
139
|
+
const schema = objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
135
140
|
ctx.schemas.set(typeName, schema);
|
|
136
|
-
ctx.visitedTypes.delete(
|
|
141
|
+
ctx.visitedTypes.delete(typeName);
|
|
137
142
|
}
|
|
138
143
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
139
144
|
}
|
|
140
|
-
return objectTypeToSchema(type, ctx);
|
|
145
|
+
return objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
141
146
|
}
|
|
142
147
|
return {};
|
|
143
148
|
}
|
|
@@ -150,7 +155,7 @@ function objectTypeToSchema(type, ctx) {
|
|
|
150
155
|
const propName = prop.getName();
|
|
151
156
|
const propType = checker.getTypeOfSymbol(prop);
|
|
152
157
|
const isOptional = prop.flags & import_typescript.default.SymbolFlags.Optional;
|
|
153
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
158
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
154
159
|
if (!isOptional) {
|
|
155
160
|
required.push(propName);
|
|
156
161
|
}
|
|
@@ -175,7 +180,7 @@ function intersectionTypeToSchema(type, ctx) {
|
|
|
175
180
|
if (propName.startsWith("__")) continue;
|
|
176
181
|
const propType = checker.getTypeOfSymbol(prop);
|
|
177
182
|
const isOptional = prop.flags & import_typescript.default.SymbolFlags.Optional;
|
|
178
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
183
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
179
184
|
if (!isOptional && !required.includes(propName)) {
|
|
180
185
|
required.push(propName);
|
|
181
186
|
}
|
|
@@ -307,16 +312,20 @@ function isEndpointStructure(type) {
|
|
|
307
312
|
propNames.delete("data");
|
|
308
313
|
propNames.delete("body");
|
|
309
314
|
propNames.delete("error");
|
|
315
|
+
propNames.delete("query");
|
|
316
|
+
propNames.delete("formData");
|
|
310
317
|
const remainingProps = [...propNames].filter(
|
|
311
318
|
(name) => !name.startsWith("__@") && !name.includes("Brand")
|
|
312
319
|
);
|
|
313
320
|
return remainingProps.length === 0;
|
|
314
321
|
}
|
|
315
|
-
function parseEndpointType(type,
|
|
322
|
+
function parseEndpointType(type, pathStr, method, pathParams, ctx) {
|
|
316
323
|
const { checker } = ctx;
|
|
317
324
|
let dataType;
|
|
318
325
|
let bodyType;
|
|
319
326
|
let errorType;
|
|
327
|
+
let queryType;
|
|
328
|
+
let formDataType;
|
|
320
329
|
const typesToCheck = type.isIntersection() ? type.types : [type];
|
|
321
330
|
for (const t of typesToCheck) {
|
|
322
331
|
const props = t.getProperties();
|
|
@@ -328,32 +337,93 @@ function parseEndpointType(type, path2, method, pathParams, ctx) {
|
|
|
328
337
|
bodyType = checker.getTypeOfSymbol(prop);
|
|
329
338
|
} else if (name === "error") {
|
|
330
339
|
errorType = checker.getTypeOfSymbol(prop);
|
|
340
|
+
} else if (name === "query") {
|
|
341
|
+
queryType = checker.getTypeOfSymbol(prop);
|
|
342
|
+
} else if (name === "formData") {
|
|
343
|
+
formDataType = checker.getTypeOfSymbol(prop);
|
|
331
344
|
}
|
|
332
345
|
}
|
|
333
346
|
}
|
|
334
347
|
const endpoint = {
|
|
335
|
-
path:
|
|
348
|
+
path: pathStr,
|
|
336
349
|
method,
|
|
337
350
|
responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
|
|
338
351
|
pathParams
|
|
339
352
|
};
|
|
340
|
-
if (
|
|
353
|
+
if (formDataType && !(formDataType.flags & import_typescript2.default.TypeFlags.Never)) {
|
|
354
|
+
endpoint.requestBodySchema = formDataTypeToSchema(formDataType, ctx);
|
|
355
|
+
endpoint.requestBodyContentType = "multipart/form-data";
|
|
356
|
+
} else if (bodyType && !(bodyType.flags & import_typescript2.default.TypeFlags.Never)) {
|
|
341
357
|
endpoint.requestBodySchema = typeToSchema(bodyType, ctx);
|
|
358
|
+
endpoint.requestBodyContentType = "application/json";
|
|
359
|
+
}
|
|
360
|
+
if (queryType && !(queryType.flags & import_typescript2.default.TypeFlags.Never)) {
|
|
361
|
+
endpoint.queryParams = queryTypeToParams(queryType, ctx);
|
|
342
362
|
}
|
|
343
363
|
if (errorType && !(errorType.flags & import_typescript2.default.TypeFlags.Never) && !(errorType.flags & import_typescript2.default.TypeFlags.Unknown)) {
|
|
344
364
|
endpoint.errorSchema = typeToSchema(errorType, ctx);
|
|
345
365
|
}
|
|
346
366
|
return endpoint;
|
|
347
367
|
}
|
|
368
|
+
function queryTypeToParams(queryType, ctx) {
|
|
369
|
+
const { checker } = ctx;
|
|
370
|
+
const params = [];
|
|
371
|
+
const props = queryType.getProperties();
|
|
372
|
+
for (const prop of props) {
|
|
373
|
+
const propName = prop.getName();
|
|
374
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
375
|
+
const isOptional = !!(prop.flags & import_typescript2.default.SymbolFlags.Optional);
|
|
376
|
+
params.push({
|
|
377
|
+
name: propName,
|
|
378
|
+
in: "query",
|
|
379
|
+
required: !isOptional,
|
|
380
|
+
schema: typeToSchema(propType, ctx)
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return params;
|
|
384
|
+
}
|
|
385
|
+
function formDataTypeToSchema(formDataType, ctx) {
|
|
386
|
+
const { checker } = ctx;
|
|
387
|
+
const properties = {};
|
|
388
|
+
const required = [];
|
|
389
|
+
const props = formDataType.getProperties();
|
|
390
|
+
for (const prop of props) {
|
|
391
|
+
const propName = prop.getName();
|
|
392
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
393
|
+
const isOptional = !!(prop.flags & import_typescript2.default.SymbolFlags.Optional);
|
|
394
|
+
const typeName = checker.typeToString(propType);
|
|
395
|
+
if (typeName.includes("File") || typeName.includes("Blob")) {
|
|
396
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
397
|
+
} else if (propType.isUnion()) {
|
|
398
|
+
const hasFile = propType.types.some((t) => {
|
|
399
|
+
const name = checker.typeToString(t);
|
|
400
|
+
return name.includes("File") || name.includes("Blob");
|
|
401
|
+
});
|
|
402
|
+
if (hasFile) {
|
|
403
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
404
|
+
} else {
|
|
405
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
409
|
+
}
|
|
410
|
+
if (!isOptional) {
|
|
411
|
+
required.push(propName);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const schema = {
|
|
415
|
+
type: "object",
|
|
416
|
+
properties
|
|
417
|
+
};
|
|
418
|
+
if (required.length > 0) {
|
|
419
|
+
schema.required = required;
|
|
420
|
+
}
|
|
421
|
+
return schema;
|
|
422
|
+
}
|
|
348
423
|
|
|
349
424
|
// src/generator.ts
|
|
350
425
|
function generateOpenAPISpec(endpoints, schemas, options = {}) {
|
|
351
|
-
const {
|
|
352
|
-
title = "API",
|
|
353
|
-
version = "1.0.0",
|
|
354
|
-
description,
|
|
355
|
-
baseUrl
|
|
356
|
-
} = options;
|
|
426
|
+
const { title = "API", version = "1.0.0", description, baseUrl } = options;
|
|
357
427
|
const paths = {};
|
|
358
428
|
for (const endpoint of endpoints) {
|
|
359
429
|
if (!paths[endpoint.path]) {
|
|
@@ -407,11 +477,15 @@ function createOperation(endpoint) {
|
|
|
407
477
|
}
|
|
408
478
|
};
|
|
409
479
|
}
|
|
480
|
+
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
|
|
481
|
+
operation.parameters = endpoint.queryParams;
|
|
482
|
+
}
|
|
410
483
|
if (endpoint.requestBodySchema && hasContent(endpoint.requestBodySchema)) {
|
|
484
|
+
const contentType = endpoint.requestBodyContentType || "application/json";
|
|
411
485
|
operation.requestBody = {
|
|
412
486
|
required: true,
|
|
413
487
|
content: {
|
|
414
|
-
|
|
488
|
+
[contentType]: {
|
|
415
489
|
schema: endpoint.requestBodySchema
|
|
416
490
|
}
|
|
417
491
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -4,14 +4,19 @@ import path from "path";
|
|
|
4
4
|
|
|
5
5
|
// src/type-to-schema.ts
|
|
6
6
|
import ts from "typescript";
|
|
7
|
+
var MAX_DEPTH = 50;
|
|
7
8
|
function createSchemaContext(checker) {
|
|
8
9
|
return {
|
|
9
10
|
checker,
|
|
10
11
|
schemas: /* @__PURE__ */ new Map(),
|
|
11
|
-
visitedTypes: /* @__PURE__ */ new Set()
|
|
12
|
+
visitedTypes: /* @__PURE__ */ new Set(),
|
|
13
|
+
depth: 0
|
|
12
14
|
};
|
|
13
15
|
}
|
|
14
16
|
function typeToSchema(type, ctx) {
|
|
17
|
+
if (ctx.depth > MAX_DEPTH) {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
15
20
|
const { checker } = ctx;
|
|
16
21
|
if (type.flags & ts.TypeFlags.String) {
|
|
17
22
|
return { type: "string" };
|
|
@@ -89,18 +94,18 @@ function typeToSchema(type, ctx) {
|
|
|
89
94
|
return { type: "string", format: "date-time" };
|
|
90
95
|
}
|
|
91
96
|
if (typeName && typeName !== "__type" && typeName !== "Array" && !typeName.startsWith("__")) {
|
|
92
|
-
if (ctx.visitedTypes.has(
|
|
97
|
+
if (ctx.visitedTypes.has(typeName)) {
|
|
93
98
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
94
99
|
}
|
|
95
100
|
if (!ctx.schemas.has(typeName)) {
|
|
96
|
-
ctx.visitedTypes.add(
|
|
97
|
-
const schema = objectTypeToSchema(type, ctx);
|
|
101
|
+
ctx.visitedTypes.add(typeName);
|
|
102
|
+
const schema = objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
98
103
|
ctx.schemas.set(typeName, schema);
|
|
99
|
-
ctx.visitedTypes.delete(
|
|
104
|
+
ctx.visitedTypes.delete(typeName);
|
|
100
105
|
}
|
|
101
106
|
return { $ref: `#/components/schemas/${typeName}` };
|
|
102
107
|
}
|
|
103
|
-
return objectTypeToSchema(type, ctx);
|
|
108
|
+
return objectTypeToSchema(type, { ...ctx, depth: ctx.depth + 1 });
|
|
104
109
|
}
|
|
105
110
|
return {};
|
|
106
111
|
}
|
|
@@ -113,7 +118,7 @@ function objectTypeToSchema(type, ctx) {
|
|
|
113
118
|
const propName = prop.getName();
|
|
114
119
|
const propType = checker.getTypeOfSymbol(prop);
|
|
115
120
|
const isOptional = prop.flags & ts.SymbolFlags.Optional;
|
|
116
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
121
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
117
122
|
if (!isOptional) {
|
|
118
123
|
required.push(propName);
|
|
119
124
|
}
|
|
@@ -138,7 +143,7 @@ function intersectionTypeToSchema(type, ctx) {
|
|
|
138
143
|
if (propName.startsWith("__")) continue;
|
|
139
144
|
const propType = checker.getTypeOfSymbol(prop);
|
|
140
145
|
const isOptional = prop.flags & ts.SymbolFlags.Optional;
|
|
141
|
-
properties[propName] = typeToSchema(propType, ctx);
|
|
146
|
+
properties[propName] = typeToSchema(propType, { ...ctx, depth: ctx.depth + 1 });
|
|
142
147
|
if (!isOptional && !required.includes(propName)) {
|
|
143
148
|
required.push(propName);
|
|
144
149
|
}
|
|
@@ -270,16 +275,20 @@ function isEndpointStructure(type) {
|
|
|
270
275
|
propNames.delete("data");
|
|
271
276
|
propNames.delete("body");
|
|
272
277
|
propNames.delete("error");
|
|
278
|
+
propNames.delete("query");
|
|
279
|
+
propNames.delete("formData");
|
|
273
280
|
const remainingProps = [...propNames].filter(
|
|
274
281
|
(name) => !name.startsWith("__@") && !name.includes("Brand")
|
|
275
282
|
);
|
|
276
283
|
return remainingProps.length === 0;
|
|
277
284
|
}
|
|
278
|
-
function parseEndpointType(type,
|
|
285
|
+
function parseEndpointType(type, pathStr, method, pathParams, ctx) {
|
|
279
286
|
const { checker } = ctx;
|
|
280
287
|
let dataType;
|
|
281
288
|
let bodyType;
|
|
282
289
|
let errorType;
|
|
290
|
+
let queryType;
|
|
291
|
+
let formDataType;
|
|
283
292
|
const typesToCheck = type.isIntersection() ? type.types : [type];
|
|
284
293
|
for (const t of typesToCheck) {
|
|
285
294
|
const props = t.getProperties();
|
|
@@ -291,32 +300,93 @@ function parseEndpointType(type, path2, method, pathParams, ctx) {
|
|
|
291
300
|
bodyType = checker.getTypeOfSymbol(prop);
|
|
292
301
|
} else if (name === "error") {
|
|
293
302
|
errorType = checker.getTypeOfSymbol(prop);
|
|
303
|
+
} else if (name === "query") {
|
|
304
|
+
queryType = checker.getTypeOfSymbol(prop);
|
|
305
|
+
} else if (name === "formData") {
|
|
306
|
+
formDataType = checker.getTypeOfSymbol(prop);
|
|
294
307
|
}
|
|
295
308
|
}
|
|
296
309
|
}
|
|
297
310
|
const endpoint = {
|
|
298
|
-
path:
|
|
311
|
+
path: pathStr,
|
|
299
312
|
method,
|
|
300
313
|
responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
|
|
301
314
|
pathParams
|
|
302
315
|
};
|
|
303
|
-
if (
|
|
316
|
+
if (formDataType && !(formDataType.flags & ts2.TypeFlags.Never)) {
|
|
317
|
+
endpoint.requestBodySchema = formDataTypeToSchema(formDataType, ctx);
|
|
318
|
+
endpoint.requestBodyContentType = "multipart/form-data";
|
|
319
|
+
} else if (bodyType && !(bodyType.flags & ts2.TypeFlags.Never)) {
|
|
304
320
|
endpoint.requestBodySchema = typeToSchema(bodyType, ctx);
|
|
321
|
+
endpoint.requestBodyContentType = "application/json";
|
|
322
|
+
}
|
|
323
|
+
if (queryType && !(queryType.flags & ts2.TypeFlags.Never)) {
|
|
324
|
+
endpoint.queryParams = queryTypeToParams(queryType, ctx);
|
|
305
325
|
}
|
|
306
326
|
if (errorType && !(errorType.flags & ts2.TypeFlags.Never) && !(errorType.flags & ts2.TypeFlags.Unknown)) {
|
|
307
327
|
endpoint.errorSchema = typeToSchema(errorType, ctx);
|
|
308
328
|
}
|
|
309
329
|
return endpoint;
|
|
310
330
|
}
|
|
331
|
+
function queryTypeToParams(queryType, ctx) {
|
|
332
|
+
const { checker } = ctx;
|
|
333
|
+
const params = [];
|
|
334
|
+
const props = queryType.getProperties();
|
|
335
|
+
for (const prop of props) {
|
|
336
|
+
const propName = prop.getName();
|
|
337
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
338
|
+
const isOptional = !!(prop.flags & ts2.SymbolFlags.Optional);
|
|
339
|
+
params.push({
|
|
340
|
+
name: propName,
|
|
341
|
+
in: "query",
|
|
342
|
+
required: !isOptional,
|
|
343
|
+
schema: typeToSchema(propType, ctx)
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return params;
|
|
347
|
+
}
|
|
348
|
+
function formDataTypeToSchema(formDataType, ctx) {
|
|
349
|
+
const { checker } = ctx;
|
|
350
|
+
const properties = {};
|
|
351
|
+
const required = [];
|
|
352
|
+
const props = formDataType.getProperties();
|
|
353
|
+
for (const prop of props) {
|
|
354
|
+
const propName = prop.getName();
|
|
355
|
+
const propType = checker.getTypeOfSymbol(prop);
|
|
356
|
+
const isOptional = !!(prop.flags & ts2.SymbolFlags.Optional);
|
|
357
|
+
const typeName = checker.typeToString(propType);
|
|
358
|
+
if (typeName.includes("File") || typeName.includes("Blob")) {
|
|
359
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
360
|
+
} else if (propType.isUnion()) {
|
|
361
|
+
const hasFile = propType.types.some((t) => {
|
|
362
|
+
const name = checker.typeToString(t);
|
|
363
|
+
return name.includes("File") || name.includes("Blob");
|
|
364
|
+
});
|
|
365
|
+
if (hasFile) {
|
|
366
|
+
properties[propName] = { type: "string", format: "binary" };
|
|
367
|
+
} else {
|
|
368
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
properties[propName] = typeToSchema(propType, ctx);
|
|
372
|
+
}
|
|
373
|
+
if (!isOptional) {
|
|
374
|
+
required.push(propName);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const schema = {
|
|
378
|
+
type: "object",
|
|
379
|
+
properties
|
|
380
|
+
};
|
|
381
|
+
if (required.length > 0) {
|
|
382
|
+
schema.required = required;
|
|
383
|
+
}
|
|
384
|
+
return schema;
|
|
385
|
+
}
|
|
311
386
|
|
|
312
387
|
// src/generator.ts
|
|
313
388
|
function generateOpenAPISpec(endpoints, schemas, options = {}) {
|
|
314
|
-
const {
|
|
315
|
-
title = "API",
|
|
316
|
-
version = "1.0.0",
|
|
317
|
-
description,
|
|
318
|
-
baseUrl
|
|
319
|
-
} = options;
|
|
389
|
+
const { title = "API", version = "1.0.0", description, baseUrl } = options;
|
|
320
390
|
const paths = {};
|
|
321
391
|
for (const endpoint of endpoints) {
|
|
322
392
|
if (!paths[endpoint.path]) {
|
|
@@ -370,11 +440,15 @@ function createOperation(endpoint) {
|
|
|
370
440
|
}
|
|
371
441
|
};
|
|
372
442
|
}
|
|
443
|
+
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
|
|
444
|
+
operation.parameters = endpoint.queryParams;
|
|
445
|
+
}
|
|
373
446
|
if (endpoint.requestBodySchema && hasContent(endpoint.requestBodySchema)) {
|
|
447
|
+
const contentType = endpoint.requestBodyContentType || "application/json";
|
|
374
448
|
operation.requestBody = {
|
|
375
449
|
required: true,
|
|
376
450
|
content: {
|
|
377
|
-
|
|
451
|
+
[contentType]: {
|
|
378
452
|
schema: endpoint.requestBodySchema
|
|
379
453
|
}
|
|
380
454
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enlace-openapi",
|
|
3
|
-
"version": "0.0.1-beta.
|
|
3
|
+
"version": "0.0.1-beta.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"bin": {
|
|
6
6
|
"enlace-openapi": "./dist/cli.mjs"
|
|
@@ -15,18 +15,17 @@
|
|
|
15
15
|
"require": "./dist/index.js"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
-
"scripts": {
|
|
19
|
-
"dev": "tsup --watch",
|
|
20
|
-
"build": "tsup",
|
|
21
|
-
"typecheck": "tsc --noEmit",
|
|
22
|
-
"lint": "eslint src --max-warnings 0",
|
|
23
|
-
"prepublishOnly": "npm run build && npm run typecheck && npm run lint"
|
|
24
|
-
},
|
|
25
18
|
"dependencies": {
|
|
26
19
|
"commander": "^12.1.0",
|
|
27
20
|
"typescript": "^5.9.2"
|
|
28
21
|
},
|
|
29
22
|
"devDependencies": {
|
|
30
23
|
"@types/node": "^22.19.2"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"build": "tsup",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"lint": "eslint src --max-warnings 0"
|
|
31
30
|
}
|
|
32
|
-
}
|
|
31
|
+
}
|