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 +213 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +449 -0
- package/dist/cli.mjs +426 -0
- package/dist/index.d.mts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.js +3 -29
- package/dist/index.mjs +3 -32
- package/package.json +2 -2
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
package/dist/index.d.ts
CHANGED
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
|
|
227
|
-
const checker =
|
|
228
|
-
const sourceFile =
|
|
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
|
|
193
|
-
const checker =
|
|
194
|
-
const sourceFile =
|
|
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
|