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 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 Type
138
+ ## Endpoint Types
139
139
 
140
- The `Endpoint` type accepts three generic parameters:
140
+ ### `Endpoint<TData, TBody?, TError?>`
141
141
 
142
- ```typescript
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 { endpoints, schemas } = parseSchema("./types/APISchema.ts", "ApiSchema");
193
-
194
- const spec = generateOpenAPISpec(endpoints, schemas, {
195
- title: "My API",
196
- version: "1.0.0",
197
- baseUrl: "https://api.example.com",
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
- console.log(JSON.stringify(spec, null, 2));
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(type)) {
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(type);
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(type);
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, path2, method, pathParams, ctx) {
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: path2,
340
+ path: pathStr,
328
341
  method,
329
342
  responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
330
343
  pathParams
331
344
  };
332
- if (bodyType && !(bodyType.flags & import_typescript2.default.TypeFlags.Never)) {
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
- "application/json": {
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(type)) {
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(type);
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(type);
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, path2, method, pathParams, ctx) {
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: path2,
317
+ path: pathStr,
305
318
  method,
306
319
  responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
307
320
  pathParams
308
321
  };
309
- if (bodyType && !(bodyType.flags & ts2.TypeFlags.Never)) {
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
- "application/json": {
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(type)) {
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(type);
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(type);
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, path2, method, pathParams, ctx) {
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: path2,
348
+ path: pathStr,
336
349
  method,
337
350
  responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
338
351
  pathParams
339
352
  };
340
- if (bodyType && !(bodyType.flags & import_typescript2.default.TypeFlags.Never)) {
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
- "application/json": {
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(type)) {
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(type);
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(type);
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, path2, method, pathParams, ctx) {
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: path2,
311
+ path: pathStr,
299
312
  method,
300
313
  responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
301
314
  pathParams
302
315
  };
303
- if (bodyType && !(bodyType.flags & ts2.TypeFlags.Never)) {
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
- "application/json": {
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.2",
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
+ }