enlace-openapi 0.0.1-beta.1 → 0.0.1-beta.2

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/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # enlace-openapi
2
+
3
+ Generate OpenAPI 3.0 specifications from TypeScript API schema types.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add enlace-openapi
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### CLI
14
+
15
+ ```bash
16
+ enlace-openapi --schema ./types/APISchema.ts --output ./openapi.json
17
+ ```
18
+
19
+ ### Options
20
+
21
+ | Option | Description | Default |
22
+ |--------|-------------|---------|
23
+ | `-s, --schema <path>` | Path to TypeScript schema file | (required) |
24
+ | `-t, --type <name>` | Schema type name to export | `ApiSchema` |
25
+ | `-o, --output <path>` | Output file path | stdout |
26
+ | `--title <title>` | API title | `API` |
27
+ | `--version <version>` | API version | `1.0.0` |
28
+ | `--base-url <url>` | Base URL for servers array | - |
29
+
30
+ ### Example
31
+
32
+ ```bash
33
+ enlace-openapi \
34
+ --schema ./types/APISchema.ts \
35
+ --type ApiSchema \
36
+ --title "My API" \
37
+ --version "2.0.0" \
38
+ --base-url "https://api.example.com" \
39
+ --output ./openapi.json
40
+ ```
41
+
42
+ ## Schema Format
43
+
44
+ Define your API schema using the `Endpoint` type from `enlace-core`:
45
+
46
+ ```typescript
47
+ import { Endpoint } from "enlace-core";
48
+
49
+ type User = {
50
+ id: string;
51
+ name: string;
52
+ email: string;
53
+ };
54
+
55
+ type CreateUserBody = {
56
+ name: string;
57
+ email: string;
58
+ };
59
+
60
+ type ValidationError = {
61
+ field: string;
62
+ message: string;
63
+ };
64
+
65
+ export type ApiSchema = {
66
+ users: {
67
+ $get: Endpoint<User[]>;
68
+ $post: Endpoint<User, CreateUserBody, ValidationError>;
69
+ _: {
70
+ $get: Endpoint<User>;
71
+ $put: Endpoint<User, Partial<CreateUserBody>>;
72
+ $delete: Endpoint<{ success: boolean }>;
73
+ };
74
+ };
75
+ };
76
+ ```
77
+
78
+ This generates:
79
+
80
+ ```json
81
+ {
82
+ "openapi": "3.0.0",
83
+ "info": { "title": "My API", "version": "2.0.0" },
84
+ "servers": [{ "url": "https://api.example.com" }],
85
+ "paths": {
86
+ "/users": {
87
+ "get": {
88
+ "responses": {
89
+ "200": {
90
+ "description": "Successful response",
91
+ "content": {
92
+ "application/json": {
93
+ "schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }
94
+ }
95
+ }
96
+ }
97
+ }
98
+ },
99
+ "post": {
100
+ "requestBody": {
101
+ "required": true,
102
+ "content": {
103
+ "application/json": {
104
+ "schema": { "$ref": "#/components/schemas/CreateUserBody" }
105
+ }
106
+ }
107
+ },
108
+ "responses": {
109
+ "200": { "..." },
110
+ "400": {
111
+ "description": "Error response",
112
+ "content": {
113
+ "application/json": {
114
+ "schema": { "$ref": "#/components/schemas/ValidationError" }
115
+ }
116
+ }
117
+ }
118
+ }
119
+ }
120
+ },
121
+ "/users/{userId}": {
122
+ "parameters": [{ "name": "userId", "in": "path", "required": true, "schema": { "type": "string" } }],
123
+ "get": { "..." },
124
+ "put": { "..." },
125
+ "delete": { "..." }
126
+ }
127
+ },
128
+ "components": {
129
+ "schemas": {
130
+ "User": { "..." },
131
+ "CreateUserBody": { "..." },
132
+ "ValidationError": { "..." }
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ ## Endpoint Type
139
+
140
+ The `Endpoint` type accepts three generic parameters:
141
+
142
+ ```typescript
143
+ Endpoint<TData, TBody?, TError?>
144
+ ```
145
+
146
+ | Parameter | Description |
147
+ |-----------|-------------|
148
+ | `TData` | Response data type (required) |
149
+ | `TBody` | Request body type (optional) |
150
+ | `TError` | Error response type (optional) |
151
+
152
+ ## Path Parameters
153
+
154
+ Use `_` to define dynamic path segments:
155
+
156
+ ```typescript
157
+ type ApiSchema = {
158
+ users: {
159
+ _: {
160
+ // /users/{userId}
161
+ posts: {
162
+ _: {
163
+ // /users/{userId}/posts/{postId}
164
+ $get: Endpoint<Post>;
165
+ };
166
+ };
167
+ };
168
+ };
169
+ };
170
+ ```
171
+
172
+ Parameter names are auto-generated from the parent segment (e.g., `users` → `userId`, `posts` → `postId`).
173
+
174
+ ## Supported Types
175
+
176
+ - Primitives: `string`, `number`, `boolean`, `null`
177
+ - Literals: `"active"`, `42`, `true`
178
+ - Arrays: `User[]`, `Array<User>`
179
+ - Objects: `{ name: string; age: number }`
180
+ - Optional properties: `{ name?: string }`
181
+ - Nullable: `string | null`
182
+ - Unions: `"pending" | "active" | "inactive"`
183
+ - Intersections: `BaseUser & { role: string }`
184
+ - Date: converted to `{ type: "string", format: "date-time" }`
185
+ - Named types: extracted to `#/components/schemas`
186
+
187
+ ## Programmatic API
188
+
189
+ ```typescript
190
+ import { parseSchema, generateOpenAPISpec } from "enlace-openapi";
191
+
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
+ });
199
+
200
+ console.log(JSON.stringify(spec, null, 2));
201
+ ```
202
+
203
+ ## Viewing the OpenAPI Spec
204
+
205
+ Use [Swagger UI](https://swagger.io/tools/swagger-ui/) or [Swagger Editor](https://editor.swagger.io/) to visualize the generated spec.
206
+
207
+ Quick local preview with Docker:
208
+
209
+ ```bash
210
+ docker run -p 8080:8080 -e SWAGGER_JSON=/openapi.json -v $(pwd)/openapi.json:/openapi.json swaggerapi/swagger-ui
211
+ ```
212
+
213
+ Then open http://localhost:8080
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_commander = require("commander");
28
+ var import_fs = __toESM(require("fs"));
29
+
30
+ // src/parser.ts
31
+ var import_typescript2 = __toESM(require("typescript"));
32
+ var import_path = __toESM(require("path"));
33
+
34
+ // src/type-to-schema.ts
35
+ var import_typescript = __toESM(require("typescript"));
36
+ function createSchemaContext(checker) {
37
+ return {
38
+ checker,
39
+ schemas: /* @__PURE__ */ new Map(),
40
+ visitedTypes: /* @__PURE__ */ new Set()
41
+ };
42
+ }
43
+ function typeToSchema(type, ctx) {
44
+ const { checker } = ctx;
45
+ if (type.flags & import_typescript.default.TypeFlags.String) {
46
+ return { type: "string" };
47
+ }
48
+ if (type.flags & import_typescript.default.TypeFlags.Number) {
49
+ return { type: "number" };
50
+ }
51
+ if (type.flags & import_typescript.default.TypeFlags.Boolean) {
52
+ return { type: "boolean" };
53
+ }
54
+ if (type.flags & import_typescript.default.TypeFlags.Null) {
55
+ return { type: "null" };
56
+ }
57
+ if (type.flags & import_typescript.default.TypeFlags.Undefined || type.flags & import_typescript.default.TypeFlags.Void) {
58
+ return {};
59
+ }
60
+ if (type.flags & import_typescript.default.TypeFlags.Any || type.flags & import_typescript.default.TypeFlags.Unknown) {
61
+ return {};
62
+ }
63
+ if (type.flags & import_typescript.default.TypeFlags.Never) {
64
+ return {};
65
+ }
66
+ if (type.isStringLiteral()) {
67
+ return { type: "string", const: type.value };
68
+ }
69
+ if (type.isNumberLiteral()) {
70
+ return { type: "number", const: type.value };
71
+ }
72
+ if (type.flags & import_typescript.default.TypeFlags.BooleanLiteral) {
73
+ const intrinsicName = type.intrinsicName;
74
+ return { type: "boolean", const: intrinsicName === "true" };
75
+ }
76
+ if (type.isUnion()) {
77
+ const nonNullTypes = type.types.filter(
78
+ (t) => !(t.flags & import_typescript.default.TypeFlags.Null) && !(t.flags & import_typescript.default.TypeFlags.Undefined)
79
+ );
80
+ const hasNull = type.types.some((t) => t.flags & import_typescript.default.TypeFlags.Null);
81
+ if (nonNullTypes.length === 1 && hasNull) {
82
+ const schema = typeToSchema(nonNullTypes[0], ctx);
83
+ return { ...schema, nullable: true };
84
+ }
85
+ if (nonNullTypes.every((t) => t.isStringLiteral())) {
86
+ return {
87
+ type: "string",
88
+ enum: nonNullTypes.map((t) => t.value)
89
+ };
90
+ }
91
+ if (nonNullTypes.every((t) => t.isNumberLiteral())) {
92
+ return {
93
+ type: "number",
94
+ enum: nonNullTypes.map((t) => t.value)
95
+ };
96
+ }
97
+ return {
98
+ oneOf: nonNullTypes.map((t) => typeToSchema(t, ctx))
99
+ };
100
+ }
101
+ if (type.isIntersection()) {
102
+ return intersectionTypeToSchema(type, ctx);
103
+ }
104
+ if (checker.isArrayType(type)) {
105
+ const typeArgs = type.typeArguments;
106
+ if (typeArgs?.[0]) {
107
+ return {
108
+ type: "array",
109
+ items: typeToSchema(typeArgs[0], ctx)
110
+ };
111
+ }
112
+ return { type: "array" };
113
+ }
114
+ if (type.flags & import_typescript.default.TypeFlags.Object) {
115
+ const symbol = type.getSymbol() ?? type.aliasSymbol;
116
+ const typeName = symbol?.getName();
117
+ if (typeName === "Date") {
118
+ return { type: "string", format: "date-time" };
119
+ }
120
+ if (typeName && typeName !== "__type" && typeName !== "Array" && !typeName.startsWith("__")) {
121
+ if (ctx.visitedTypes.has(type)) {
122
+ return { $ref: `#/components/schemas/${typeName}` };
123
+ }
124
+ if (!ctx.schemas.has(typeName)) {
125
+ ctx.visitedTypes.add(type);
126
+ const schema = objectTypeToSchema(type, ctx);
127
+ ctx.schemas.set(typeName, schema);
128
+ ctx.visitedTypes.delete(type);
129
+ }
130
+ return { $ref: `#/components/schemas/${typeName}` };
131
+ }
132
+ return objectTypeToSchema(type, ctx);
133
+ }
134
+ return {};
135
+ }
136
+ function objectTypeToSchema(type, ctx) {
137
+ const { checker } = ctx;
138
+ const properties = {};
139
+ const required = [];
140
+ const props = type.getProperties();
141
+ for (const prop of props) {
142
+ const propName = prop.getName();
143
+ const propType = checker.getTypeOfSymbol(prop);
144
+ const isOptional = prop.flags & import_typescript.default.SymbolFlags.Optional;
145
+ properties[propName] = typeToSchema(propType, ctx);
146
+ if (!isOptional) {
147
+ required.push(propName);
148
+ }
149
+ }
150
+ const schema = {
151
+ type: "object",
152
+ properties
153
+ };
154
+ if (required.length > 0) {
155
+ schema.required = required;
156
+ }
157
+ return schema;
158
+ }
159
+ function intersectionTypeToSchema(type, ctx) {
160
+ const { checker } = ctx;
161
+ const properties = {};
162
+ const required = [];
163
+ for (const t of type.types) {
164
+ const props = t.getProperties();
165
+ for (const prop of props) {
166
+ const propName = prop.getName();
167
+ if (propName.startsWith("__")) continue;
168
+ const propType = checker.getTypeOfSymbol(prop);
169
+ const isOptional = prop.flags & import_typescript.default.SymbolFlags.Optional;
170
+ properties[propName] = typeToSchema(propType, ctx);
171
+ if (!isOptional && !required.includes(propName)) {
172
+ required.push(propName);
173
+ }
174
+ }
175
+ }
176
+ const schema = {
177
+ type: "object",
178
+ properties
179
+ };
180
+ if (required.length > 0) {
181
+ schema.required = required;
182
+ }
183
+ return schema;
184
+ }
185
+
186
+ // src/parser.ts
187
+ var HTTP_METHODS = ["$get", "$post", "$put", "$patch", "$delete"];
188
+ function isHttpMethod(key) {
189
+ return HTTP_METHODS.includes(key);
190
+ }
191
+ function methodKeyToHttp(key) {
192
+ return key.slice(1);
193
+ }
194
+ function parseSchema(schemaFilePath, typeName) {
195
+ const absolutePath = import_path.default.resolve(schemaFilePath);
196
+ const schemaDir = import_path.default.dirname(absolutePath);
197
+ const configPath = import_typescript2.default.findConfigFile(schemaDir, import_typescript2.default.sys.fileExists, "tsconfig.json");
198
+ let compilerOptions = {
199
+ target: import_typescript2.default.ScriptTarget.ESNext,
200
+ module: import_typescript2.default.ModuleKind.NodeNext,
201
+ moduleResolution: import_typescript2.default.ModuleResolutionKind.NodeNext,
202
+ strict: true
203
+ };
204
+ if (configPath) {
205
+ const configFile = import_typescript2.default.readConfigFile(configPath, import_typescript2.default.sys.readFile);
206
+ if (!configFile.error) {
207
+ const parsed = import_typescript2.default.parseJsonConfigFileContent(
208
+ configFile.config,
209
+ import_typescript2.default.sys,
210
+ import_path.default.dirname(configPath)
211
+ );
212
+ compilerOptions = { ...compilerOptions, ...parsed.options };
213
+ }
214
+ }
215
+ const program2 = import_typescript2.default.createProgram([absolutePath], compilerOptions);
216
+ const checker = program2.getTypeChecker();
217
+ const sourceFile = program2.getSourceFile(absolutePath);
218
+ if (!sourceFile) {
219
+ throw new Error(`Could not find source file: ${absolutePath}`);
220
+ }
221
+ const schemaType = findExportedType(sourceFile, typeName, checker);
222
+ if (!schemaType) {
223
+ throw new Error(`Could not find exported type '${typeName}' in ${schemaFilePath}`);
224
+ }
225
+ const ctx = createSchemaContext(checker);
226
+ const endpoints = [];
227
+ walkSchemaType(schemaType, "", [], ctx, endpoints, checker);
228
+ return {
229
+ endpoints,
230
+ schemas: ctx.schemas
231
+ };
232
+ }
233
+ function findExportedType(sourceFile, typeName, checker) {
234
+ const symbol = checker.getSymbolAtLocation(sourceFile);
235
+ if (!symbol) return void 0;
236
+ const exports2 = checker.getExportsOfModule(symbol);
237
+ const typeSymbol = exports2.find((exp) => exp.getName() === typeName);
238
+ if (!typeSymbol) return void 0;
239
+ const declaredType = checker.getDeclaredTypeOfSymbol(typeSymbol);
240
+ return declaredType;
241
+ }
242
+ function walkSchemaType(type, currentPath, pathParams, ctx, endpoints, checker) {
243
+ const properties = type.getProperties();
244
+ for (const prop of properties) {
245
+ const propName = prop.getName();
246
+ const propType = checker.getTypeOfSymbol(prop);
247
+ if (isHttpMethod(propName)) {
248
+ const endpoint = parseEndpoint(
249
+ propType,
250
+ currentPath || "/",
251
+ methodKeyToHttp(propName),
252
+ pathParams,
253
+ ctx
254
+ );
255
+ endpoints.push(endpoint);
256
+ } else if (propName === "_") {
257
+ const paramName = getParamNameFromPath(currentPath);
258
+ const newPath = `${currentPath}/{${paramName}}`;
259
+ walkSchemaType(propType, newPath, [...pathParams, paramName], ctx, endpoints, checker);
260
+ } else {
261
+ const newPath = `${currentPath}/${propName}`;
262
+ walkSchemaType(propType, newPath, pathParams, ctx, endpoints, checker);
263
+ }
264
+ }
265
+ }
266
+ function getParamNameFromPath(currentPath) {
267
+ const segments = currentPath.split("/").filter(Boolean);
268
+ const lastSegment = segments[segments.length - 1];
269
+ if (lastSegment) {
270
+ const singular = lastSegment.endsWith("s") ? lastSegment.slice(0, -1) : lastSegment;
271
+ return `${singular}Id`;
272
+ }
273
+ return "id";
274
+ }
275
+ function parseEndpoint(type, path2, method, pathParams, ctx) {
276
+ if (type.isIntersection()) {
277
+ for (const t of type.types) {
278
+ if (isEndpointStructure(t)) {
279
+ return parseEndpointType(type, path2, method, pathParams, ctx);
280
+ }
281
+ }
282
+ }
283
+ if (isEndpointStructure(type)) {
284
+ return parseEndpointType(type, path2, method, pathParams, ctx);
285
+ }
286
+ return {
287
+ path: path2,
288
+ method,
289
+ responseSchema: typeToSchema(type, ctx),
290
+ pathParams
291
+ };
292
+ }
293
+ function isEndpointStructure(type) {
294
+ const props = type.getProperties();
295
+ const propNames = new Set(props.map((p) => p.getName()));
296
+ if (!propNames.has("data")) {
297
+ return false;
298
+ }
299
+ propNames.delete("data");
300
+ propNames.delete("body");
301
+ propNames.delete("error");
302
+ const remainingProps = [...propNames].filter(
303
+ (name) => !name.startsWith("__@") && !name.includes("Brand")
304
+ );
305
+ return remainingProps.length === 0;
306
+ }
307
+ function parseEndpointType(type, path2, method, pathParams, ctx) {
308
+ const { checker } = ctx;
309
+ let dataType;
310
+ let bodyType;
311
+ let errorType;
312
+ const typesToCheck = type.isIntersection() ? type.types : [type];
313
+ for (const t of typesToCheck) {
314
+ const props = t.getProperties();
315
+ for (const prop of props) {
316
+ const name = prop.getName();
317
+ if (name === "data") {
318
+ dataType = checker.getTypeOfSymbol(prop);
319
+ } else if (name === "body") {
320
+ bodyType = checker.getTypeOfSymbol(prop);
321
+ } else if (name === "error") {
322
+ errorType = checker.getTypeOfSymbol(prop);
323
+ }
324
+ }
325
+ }
326
+ const endpoint = {
327
+ path: path2,
328
+ method,
329
+ responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
330
+ pathParams
331
+ };
332
+ if (bodyType && !(bodyType.flags & import_typescript2.default.TypeFlags.Never)) {
333
+ endpoint.requestBodySchema = typeToSchema(bodyType, ctx);
334
+ }
335
+ if (errorType && !(errorType.flags & import_typescript2.default.TypeFlags.Never) && !(errorType.flags & import_typescript2.default.TypeFlags.Unknown)) {
336
+ endpoint.errorSchema = typeToSchema(errorType, ctx);
337
+ }
338
+ return endpoint;
339
+ }
340
+
341
+ // src/generator.ts
342
+ function generateOpenAPISpec(endpoints, schemas, options = {}) {
343
+ const {
344
+ title = "API",
345
+ version = "1.0.0",
346
+ description,
347
+ baseUrl
348
+ } = options;
349
+ const paths = {};
350
+ for (const endpoint of endpoints) {
351
+ if (!paths[endpoint.path]) {
352
+ paths[endpoint.path] = {};
353
+ }
354
+ const pathItem = paths[endpoint.path];
355
+ const operation = createOperation(endpoint);
356
+ pathItem[endpoint.method] = operation;
357
+ if (endpoint.pathParams.length > 0 && !pathItem.parameters) {
358
+ pathItem.parameters = endpoint.pathParams.map((param) => ({
359
+ name: param,
360
+ in: "path",
361
+ required: true,
362
+ schema: { type: "string" }
363
+ }));
364
+ }
365
+ }
366
+ const spec = {
367
+ openapi: "3.0.0",
368
+ info: {
369
+ title,
370
+ version
371
+ },
372
+ paths
373
+ };
374
+ if (description) {
375
+ spec.info.description = description;
376
+ }
377
+ if (baseUrl) {
378
+ spec.servers = [{ url: baseUrl }];
379
+ }
380
+ if (schemas.size > 0) {
381
+ spec.components = {
382
+ schemas: Object.fromEntries(schemas)
383
+ };
384
+ }
385
+ return spec;
386
+ }
387
+ function createOperation(endpoint) {
388
+ const operation = {
389
+ responses: {
390
+ "200": {
391
+ description: "Successful response"
392
+ }
393
+ }
394
+ };
395
+ if (hasContent(endpoint.responseSchema)) {
396
+ operation.responses["200"].content = {
397
+ "application/json": {
398
+ schema: endpoint.responseSchema
399
+ }
400
+ };
401
+ }
402
+ if (endpoint.requestBodySchema && hasContent(endpoint.requestBodySchema)) {
403
+ operation.requestBody = {
404
+ required: true,
405
+ content: {
406
+ "application/json": {
407
+ schema: endpoint.requestBodySchema
408
+ }
409
+ }
410
+ };
411
+ }
412
+ if (endpoint.errorSchema && hasContent(endpoint.errorSchema)) {
413
+ operation.responses["400"] = {
414
+ description: "Error response",
415
+ content: {
416
+ "application/json": {
417
+ schema: endpoint.errorSchema
418
+ }
419
+ }
420
+ };
421
+ }
422
+ return operation;
423
+ }
424
+ function hasContent(schema) {
425
+ return Object.keys(schema).length > 0;
426
+ }
427
+
428
+ // src/cli.ts
429
+ import_commander.program.name("enlace-openapi").description("Generate OpenAPI spec from TypeScript API schema").requiredOption("-s, --schema <path>", "Path to TypeScript file containing the schema type").option("-t, --type <name>", "Name of the schema type to use", "ApiSchema").option("-o, --output <path>", "Output file path (default: stdout)").option("--title <title>", "API title for OpenAPI info").option("--version <version>", "API version for OpenAPI info", "1.0.0").option("--base-url <url>", "Base URL for servers array").action((options) => {
430
+ try {
431
+ const { endpoints, schemas } = parseSchema(options.schema, options.type);
432
+ const spec = generateOpenAPISpec(endpoints, schemas, {
433
+ title: options.title,
434
+ version: options.version,
435
+ baseUrl: options.baseUrl
436
+ });
437
+ const output = JSON.stringify(spec, null, 2);
438
+ if (options.output) {
439
+ import_fs.default.writeFileSync(options.output, output);
440
+ console.log(`OpenAPI spec written to ${options.output}`);
441
+ } else {
442
+ console.log(output);
443
+ }
444
+ } catch (error) {
445
+ console.error("Error:", error instanceof Error ? error.message : error);
446
+ process.exit(1);
447
+ }
448
+ });
449
+ import_commander.program.parse();
package/dist/cli.mjs ADDED
@@ -0,0 +1,426 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { program } from "commander";
5
+ import fs from "fs";
6
+
7
+ // src/parser.ts
8
+ import ts2 from "typescript";
9
+ import path from "path";
10
+
11
+ // src/type-to-schema.ts
12
+ import ts from "typescript";
13
+ function createSchemaContext(checker) {
14
+ return {
15
+ checker,
16
+ schemas: /* @__PURE__ */ new Map(),
17
+ visitedTypes: /* @__PURE__ */ new Set()
18
+ };
19
+ }
20
+ function typeToSchema(type, ctx) {
21
+ const { checker } = ctx;
22
+ if (type.flags & ts.TypeFlags.String) {
23
+ return { type: "string" };
24
+ }
25
+ if (type.flags & ts.TypeFlags.Number) {
26
+ return { type: "number" };
27
+ }
28
+ if (type.flags & ts.TypeFlags.Boolean) {
29
+ return { type: "boolean" };
30
+ }
31
+ if (type.flags & ts.TypeFlags.Null) {
32
+ return { type: "null" };
33
+ }
34
+ if (type.flags & ts.TypeFlags.Undefined || type.flags & ts.TypeFlags.Void) {
35
+ return {};
36
+ }
37
+ if (type.flags & ts.TypeFlags.Any || type.flags & ts.TypeFlags.Unknown) {
38
+ return {};
39
+ }
40
+ if (type.flags & ts.TypeFlags.Never) {
41
+ return {};
42
+ }
43
+ if (type.isStringLiteral()) {
44
+ return { type: "string", const: type.value };
45
+ }
46
+ if (type.isNumberLiteral()) {
47
+ return { type: "number", const: type.value };
48
+ }
49
+ if (type.flags & ts.TypeFlags.BooleanLiteral) {
50
+ const intrinsicName = type.intrinsicName;
51
+ return { type: "boolean", const: intrinsicName === "true" };
52
+ }
53
+ if (type.isUnion()) {
54
+ const nonNullTypes = type.types.filter(
55
+ (t) => !(t.flags & ts.TypeFlags.Null) && !(t.flags & ts.TypeFlags.Undefined)
56
+ );
57
+ const hasNull = type.types.some((t) => t.flags & ts.TypeFlags.Null);
58
+ if (nonNullTypes.length === 1 && hasNull) {
59
+ const schema = typeToSchema(nonNullTypes[0], ctx);
60
+ return { ...schema, nullable: true };
61
+ }
62
+ if (nonNullTypes.every((t) => t.isStringLiteral())) {
63
+ return {
64
+ type: "string",
65
+ enum: nonNullTypes.map((t) => t.value)
66
+ };
67
+ }
68
+ if (nonNullTypes.every((t) => t.isNumberLiteral())) {
69
+ return {
70
+ type: "number",
71
+ enum: nonNullTypes.map((t) => t.value)
72
+ };
73
+ }
74
+ return {
75
+ oneOf: nonNullTypes.map((t) => typeToSchema(t, ctx))
76
+ };
77
+ }
78
+ if (type.isIntersection()) {
79
+ return intersectionTypeToSchema(type, ctx);
80
+ }
81
+ if (checker.isArrayType(type)) {
82
+ const typeArgs = type.typeArguments;
83
+ if (typeArgs?.[0]) {
84
+ return {
85
+ type: "array",
86
+ items: typeToSchema(typeArgs[0], ctx)
87
+ };
88
+ }
89
+ return { type: "array" };
90
+ }
91
+ if (type.flags & ts.TypeFlags.Object) {
92
+ const symbol = type.getSymbol() ?? type.aliasSymbol;
93
+ const typeName = symbol?.getName();
94
+ if (typeName === "Date") {
95
+ return { type: "string", format: "date-time" };
96
+ }
97
+ if (typeName && typeName !== "__type" && typeName !== "Array" && !typeName.startsWith("__")) {
98
+ if (ctx.visitedTypes.has(type)) {
99
+ return { $ref: `#/components/schemas/${typeName}` };
100
+ }
101
+ if (!ctx.schemas.has(typeName)) {
102
+ ctx.visitedTypes.add(type);
103
+ const schema = objectTypeToSchema(type, ctx);
104
+ ctx.schemas.set(typeName, schema);
105
+ ctx.visitedTypes.delete(type);
106
+ }
107
+ return { $ref: `#/components/schemas/${typeName}` };
108
+ }
109
+ return objectTypeToSchema(type, ctx);
110
+ }
111
+ return {};
112
+ }
113
+ function objectTypeToSchema(type, ctx) {
114
+ const { checker } = ctx;
115
+ const properties = {};
116
+ const required = [];
117
+ const props = type.getProperties();
118
+ for (const prop of props) {
119
+ const propName = prop.getName();
120
+ const propType = checker.getTypeOfSymbol(prop);
121
+ const isOptional = prop.flags & ts.SymbolFlags.Optional;
122
+ properties[propName] = typeToSchema(propType, ctx);
123
+ if (!isOptional) {
124
+ required.push(propName);
125
+ }
126
+ }
127
+ const schema = {
128
+ type: "object",
129
+ properties
130
+ };
131
+ if (required.length > 0) {
132
+ schema.required = required;
133
+ }
134
+ return schema;
135
+ }
136
+ function intersectionTypeToSchema(type, ctx) {
137
+ const { checker } = ctx;
138
+ const properties = {};
139
+ const required = [];
140
+ for (const t of type.types) {
141
+ const props = t.getProperties();
142
+ for (const prop of props) {
143
+ const propName = prop.getName();
144
+ if (propName.startsWith("__")) continue;
145
+ const propType = checker.getTypeOfSymbol(prop);
146
+ const isOptional = prop.flags & ts.SymbolFlags.Optional;
147
+ properties[propName] = typeToSchema(propType, ctx);
148
+ if (!isOptional && !required.includes(propName)) {
149
+ required.push(propName);
150
+ }
151
+ }
152
+ }
153
+ const schema = {
154
+ type: "object",
155
+ properties
156
+ };
157
+ if (required.length > 0) {
158
+ schema.required = required;
159
+ }
160
+ return schema;
161
+ }
162
+
163
+ // src/parser.ts
164
+ var HTTP_METHODS = ["$get", "$post", "$put", "$patch", "$delete"];
165
+ function isHttpMethod(key) {
166
+ return HTTP_METHODS.includes(key);
167
+ }
168
+ function methodKeyToHttp(key) {
169
+ return key.slice(1);
170
+ }
171
+ function parseSchema(schemaFilePath, typeName) {
172
+ const absolutePath = path.resolve(schemaFilePath);
173
+ const schemaDir = path.dirname(absolutePath);
174
+ const configPath = ts2.findConfigFile(schemaDir, ts2.sys.fileExists, "tsconfig.json");
175
+ let compilerOptions = {
176
+ target: ts2.ScriptTarget.ESNext,
177
+ module: ts2.ModuleKind.NodeNext,
178
+ moduleResolution: ts2.ModuleResolutionKind.NodeNext,
179
+ strict: true
180
+ };
181
+ if (configPath) {
182
+ const configFile = ts2.readConfigFile(configPath, ts2.sys.readFile);
183
+ if (!configFile.error) {
184
+ const parsed = ts2.parseJsonConfigFileContent(
185
+ configFile.config,
186
+ ts2.sys,
187
+ path.dirname(configPath)
188
+ );
189
+ compilerOptions = { ...compilerOptions, ...parsed.options };
190
+ }
191
+ }
192
+ const program2 = ts2.createProgram([absolutePath], compilerOptions);
193
+ const checker = program2.getTypeChecker();
194
+ const sourceFile = program2.getSourceFile(absolutePath);
195
+ if (!sourceFile) {
196
+ throw new Error(`Could not find source file: ${absolutePath}`);
197
+ }
198
+ const schemaType = findExportedType(sourceFile, typeName, checker);
199
+ if (!schemaType) {
200
+ throw new Error(`Could not find exported type '${typeName}' in ${schemaFilePath}`);
201
+ }
202
+ const ctx = createSchemaContext(checker);
203
+ const endpoints = [];
204
+ walkSchemaType(schemaType, "", [], ctx, endpoints, checker);
205
+ return {
206
+ endpoints,
207
+ schemas: ctx.schemas
208
+ };
209
+ }
210
+ function findExportedType(sourceFile, typeName, checker) {
211
+ const symbol = checker.getSymbolAtLocation(sourceFile);
212
+ if (!symbol) return void 0;
213
+ const exports = checker.getExportsOfModule(symbol);
214
+ const typeSymbol = exports.find((exp) => exp.getName() === typeName);
215
+ if (!typeSymbol) return void 0;
216
+ const declaredType = checker.getDeclaredTypeOfSymbol(typeSymbol);
217
+ return declaredType;
218
+ }
219
+ function walkSchemaType(type, currentPath, pathParams, ctx, endpoints, checker) {
220
+ const properties = type.getProperties();
221
+ for (const prop of properties) {
222
+ const propName = prop.getName();
223
+ const propType = checker.getTypeOfSymbol(prop);
224
+ if (isHttpMethod(propName)) {
225
+ const endpoint = parseEndpoint(
226
+ propType,
227
+ currentPath || "/",
228
+ methodKeyToHttp(propName),
229
+ pathParams,
230
+ ctx
231
+ );
232
+ endpoints.push(endpoint);
233
+ } else if (propName === "_") {
234
+ const paramName = getParamNameFromPath(currentPath);
235
+ const newPath = `${currentPath}/{${paramName}}`;
236
+ walkSchemaType(propType, newPath, [...pathParams, paramName], ctx, endpoints, checker);
237
+ } else {
238
+ const newPath = `${currentPath}/${propName}`;
239
+ walkSchemaType(propType, newPath, pathParams, ctx, endpoints, checker);
240
+ }
241
+ }
242
+ }
243
+ function getParamNameFromPath(currentPath) {
244
+ const segments = currentPath.split("/").filter(Boolean);
245
+ const lastSegment = segments[segments.length - 1];
246
+ if (lastSegment) {
247
+ const singular = lastSegment.endsWith("s") ? lastSegment.slice(0, -1) : lastSegment;
248
+ return `${singular}Id`;
249
+ }
250
+ return "id";
251
+ }
252
+ function parseEndpoint(type, path2, method, pathParams, ctx) {
253
+ if (type.isIntersection()) {
254
+ for (const t of type.types) {
255
+ if (isEndpointStructure(t)) {
256
+ return parseEndpointType(type, path2, method, pathParams, ctx);
257
+ }
258
+ }
259
+ }
260
+ if (isEndpointStructure(type)) {
261
+ return parseEndpointType(type, path2, method, pathParams, ctx);
262
+ }
263
+ return {
264
+ path: path2,
265
+ method,
266
+ responseSchema: typeToSchema(type, ctx),
267
+ pathParams
268
+ };
269
+ }
270
+ function isEndpointStructure(type) {
271
+ const props = type.getProperties();
272
+ const propNames = new Set(props.map((p) => p.getName()));
273
+ if (!propNames.has("data")) {
274
+ return false;
275
+ }
276
+ propNames.delete("data");
277
+ propNames.delete("body");
278
+ propNames.delete("error");
279
+ const remainingProps = [...propNames].filter(
280
+ (name) => !name.startsWith("__@") && !name.includes("Brand")
281
+ );
282
+ return remainingProps.length === 0;
283
+ }
284
+ function parseEndpointType(type, path2, method, pathParams, ctx) {
285
+ const { checker } = ctx;
286
+ let dataType;
287
+ let bodyType;
288
+ let errorType;
289
+ const typesToCheck = type.isIntersection() ? type.types : [type];
290
+ for (const t of typesToCheck) {
291
+ const props = t.getProperties();
292
+ for (const prop of props) {
293
+ const name = prop.getName();
294
+ if (name === "data") {
295
+ dataType = checker.getTypeOfSymbol(prop);
296
+ } else if (name === "body") {
297
+ bodyType = checker.getTypeOfSymbol(prop);
298
+ } else if (name === "error") {
299
+ errorType = checker.getTypeOfSymbol(prop);
300
+ }
301
+ }
302
+ }
303
+ const endpoint = {
304
+ path: path2,
305
+ method,
306
+ responseSchema: dataType ? typeToSchema(dataType, ctx) : {},
307
+ pathParams
308
+ };
309
+ if (bodyType && !(bodyType.flags & ts2.TypeFlags.Never)) {
310
+ endpoint.requestBodySchema = typeToSchema(bodyType, ctx);
311
+ }
312
+ if (errorType && !(errorType.flags & ts2.TypeFlags.Never) && !(errorType.flags & ts2.TypeFlags.Unknown)) {
313
+ endpoint.errorSchema = typeToSchema(errorType, ctx);
314
+ }
315
+ return endpoint;
316
+ }
317
+
318
+ // src/generator.ts
319
+ function generateOpenAPISpec(endpoints, schemas, options = {}) {
320
+ const {
321
+ title = "API",
322
+ version = "1.0.0",
323
+ description,
324
+ baseUrl
325
+ } = options;
326
+ const paths = {};
327
+ for (const endpoint of endpoints) {
328
+ if (!paths[endpoint.path]) {
329
+ paths[endpoint.path] = {};
330
+ }
331
+ const pathItem = paths[endpoint.path];
332
+ const operation = createOperation(endpoint);
333
+ pathItem[endpoint.method] = operation;
334
+ if (endpoint.pathParams.length > 0 && !pathItem.parameters) {
335
+ pathItem.parameters = endpoint.pathParams.map((param) => ({
336
+ name: param,
337
+ in: "path",
338
+ required: true,
339
+ schema: { type: "string" }
340
+ }));
341
+ }
342
+ }
343
+ const spec = {
344
+ openapi: "3.0.0",
345
+ info: {
346
+ title,
347
+ version
348
+ },
349
+ paths
350
+ };
351
+ if (description) {
352
+ spec.info.description = description;
353
+ }
354
+ if (baseUrl) {
355
+ spec.servers = [{ url: baseUrl }];
356
+ }
357
+ if (schemas.size > 0) {
358
+ spec.components = {
359
+ schemas: Object.fromEntries(schemas)
360
+ };
361
+ }
362
+ return spec;
363
+ }
364
+ function createOperation(endpoint) {
365
+ const operation = {
366
+ responses: {
367
+ "200": {
368
+ description: "Successful response"
369
+ }
370
+ }
371
+ };
372
+ if (hasContent(endpoint.responseSchema)) {
373
+ operation.responses["200"].content = {
374
+ "application/json": {
375
+ schema: endpoint.responseSchema
376
+ }
377
+ };
378
+ }
379
+ if (endpoint.requestBodySchema && hasContent(endpoint.requestBodySchema)) {
380
+ operation.requestBody = {
381
+ required: true,
382
+ content: {
383
+ "application/json": {
384
+ schema: endpoint.requestBodySchema
385
+ }
386
+ }
387
+ };
388
+ }
389
+ if (endpoint.errorSchema && hasContent(endpoint.errorSchema)) {
390
+ operation.responses["400"] = {
391
+ description: "Error response",
392
+ content: {
393
+ "application/json": {
394
+ schema: endpoint.errorSchema
395
+ }
396
+ }
397
+ };
398
+ }
399
+ return operation;
400
+ }
401
+ function hasContent(schema) {
402
+ return Object.keys(schema).length > 0;
403
+ }
404
+
405
+ // src/cli.ts
406
+ program.name("enlace-openapi").description("Generate OpenAPI spec from TypeScript API schema").requiredOption("-s, --schema <path>", "Path to TypeScript file containing the schema type").option("-t, --type <name>", "Name of the schema type to use", "ApiSchema").option("-o, --output <path>", "Output file path (default: stdout)").option("--title <title>", "API title for OpenAPI info").option("--version <version>", "API version for OpenAPI info", "1.0.0").option("--base-url <url>", "Base URL for servers array").action((options) => {
407
+ try {
408
+ const { endpoints, schemas } = parseSchema(options.schema, options.type);
409
+ const spec = generateOpenAPISpec(endpoints, schemas, {
410
+ title: options.title,
411
+ version: options.version,
412
+ baseUrl: options.baseUrl
413
+ });
414
+ const output = JSON.stringify(spec, null, 2);
415
+ if (options.output) {
416
+ fs.writeFileSync(options.output, output);
417
+ console.log(`OpenAPI spec written to ${options.output}`);
418
+ } else {
419
+ console.log(output);
420
+ }
421
+ } catch (error) {
422
+ console.error("Error:", error instanceof Error ? error.message : error);
423
+ process.exit(1);
424
+ }
425
+ });
426
+ program.parse();
package/dist/index.d.mts CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  type JSONSchema = {
3
2
  type?: string;
4
3
  items?: JSONSchema;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  type JSONSchema = {
3
2
  type?: string;
4
3
  items?: JSONSchema;
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  "use strict";
3
2
  var __create = Object.create;
4
3
  var __defProp = Object.defineProperty;
@@ -35,8 +34,6 @@ __export(src_exports, {
35
34
  parseSchema: () => parseSchema
36
35
  });
37
36
  module.exports = __toCommonJS(src_exports);
38
- var import_commander = require("commander");
39
- var import_fs = __toESM(require("fs"));
40
37
 
41
38
  // src/parser.ts
42
39
  var import_typescript2 = __toESM(require("typescript"));
@@ -223,9 +220,9 @@ function parseSchema(schemaFilePath, typeName) {
223
220
  compilerOptions = { ...compilerOptions, ...parsed.options };
224
221
  }
225
222
  }
226
- const program2 = import_typescript2.default.createProgram([absolutePath], compilerOptions);
227
- const checker = program2.getTypeChecker();
228
- const sourceFile = program2.getSourceFile(absolutePath);
223
+ const program = import_typescript2.default.createProgram([absolutePath], compilerOptions);
224
+ const checker = program.getTypeChecker();
225
+ const sourceFile = program.getSourceFile(absolutePath);
229
226
  if (!sourceFile) {
230
227
  throw new Error(`Could not find source file: ${absolutePath}`);
231
228
  }
@@ -435,29 +432,6 @@ function createOperation(endpoint) {
435
432
  function hasContent(schema) {
436
433
  return Object.keys(schema).length > 0;
437
434
  }
438
-
439
- // src/index.ts
440
- import_commander.program.name("enlace-openapi").description("Generate OpenAPI spec from TypeScript API schema").requiredOption("-s, --schema <path>", "Path to TypeScript file containing the schema type").option("-t, --type <name>", "Name of the schema type to use", "ApiSchema").option("-o, --output <path>", "Output file path (default: stdout)").option("--title <title>", "API title for OpenAPI info").option("--version <version>", "API version for OpenAPI info", "1.0.0").option("--base-url <url>", "Base URL for servers array").action((options) => {
441
- try {
442
- const { endpoints, schemas } = parseSchema(options.schema, options.type);
443
- const spec = generateOpenAPISpec(endpoints, schemas, {
444
- title: options.title,
445
- version: options.version,
446
- baseUrl: options.baseUrl
447
- });
448
- const output = JSON.stringify(spec, null, 2);
449
- if (options.output) {
450
- import_fs.default.writeFileSync(options.output, output);
451
- console.log(`OpenAPI spec written to ${options.output}`);
452
- } else {
453
- console.log(output);
454
- }
455
- } catch (error) {
456
- console.error("Error:", error instanceof Error ? error.message : error);
457
- process.exit(1);
458
- }
459
- });
460
- import_commander.program.parse();
461
435
  // Annotate the CommonJS export names for ESM import in node:
462
436
  0 && (module.exports = {
463
437
  generateOpenAPISpec,
package/dist/index.mjs CHANGED
@@ -1,9 +1,3 @@
1
- #!/usr/bin/env node
2
-
3
- // src/index.ts
4
- import { program } from "commander";
5
- import fs from "fs";
6
-
7
1
  // src/parser.ts
8
2
  import ts2 from "typescript";
9
3
  import path from "path";
@@ -189,9 +183,9 @@ function parseSchema(schemaFilePath, typeName) {
189
183
  compilerOptions = { ...compilerOptions, ...parsed.options };
190
184
  }
191
185
  }
192
- const program2 = ts2.createProgram([absolutePath], compilerOptions);
193
- const checker = program2.getTypeChecker();
194
- const sourceFile = program2.getSourceFile(absolutePath);
186
+ const program = ts2.createProgram([absolutePath], compilerOptions);
187
+ const checker = program.getTypeChecker();
188
+ const sourceFile = program.getSourceFile(absolutePath);
195
189
  if (!sourceFile) {
196
190
  throw new Error(`Could not find source file: ${absolutePath}`);
197
191
  }
@@ -401,29 +395,6 @@ function createOperation(endpoint) {
401
395
  function hasContent(schema) {
402
396
  return Object.keys(schema).length > 0;
403
397
  }
404
-
405
- // src/index.ts
406
- program.name("enlace-openapi").description("Generate OpenAPI spec from TypeScript API schema").requiredOption("-s, --schema <path>", "Path to TypeScript file containing the schema type").option("-t, --type <name>", "Name of the schema type to use", "ApiSchema").option("-o, --output <path>", "Output file path (default: stdout)").option("--title <title>", "API title for OpenAPI info").option("--version <version>", "API version for OpenAPI info", "1.0.0").option("--base-url <url>", "Base URL for servers array").action((options) => {
407
- try {
408
- const { endpoints, schemas } = parseSchema(options.schema, options.type);
409
- const spec = generateOpenAPISpec(endpoints, schemas, {
410
- title: options.title,
411
- version: options.version,
412
- baseUrl: options.baseUrl
413
- });
414
- const output = JSON.stringify(spec, null, 2);
415
- if (options.output) {
416
- fs.writeFileSync(options.output, output);
417
- console.log(`OpenAPI spec written to ${options.output}`);
418
- } else {
419
- console.log(output);
420
- }
421
- } catch (error) {
422
- console.error("Error:", error instanceof Error ? error.message : error);
423
- process.exit(1);
424
- }
425
- });
426
- program.parse();
427
398
  export {
428
399
  generateOpenAPISpec,
429
400
  parseSchema
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "enlace-openapi",
3
- "version": "0.0.1-beta.1",
3
+ "version": "0.0.1-beta.2",
4
4
  "license": "MIT",
5
5
  "bin": {
6
- "enlace-openapi": "./dist/index.mjs"
6
+ "enlace-openapi": "./dist/cli.mjs"
7
7
  },
8
8
  "files": [
9
9
  "dist"