@vertz/openapi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,55 @@
1
+ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
2
+ interface ParsedSpec {
3
+ version: "3.0" | "3.1";
4
+ info: {
5
+ title: string;
6
+ version: string;
7
+ };
8
+ resources: ParsedResource[];
9
+ schemas: ParsedSchema[];
10
+ }
11
+ interface ParsedResource {
12
+ name: string;
13
+ identifier: string;
14
+ operations: ParsedOperation[];
15
+ }
16
+ interface ParsedOperation {
17
+ operationId: string;
18
+ methodName: string;
19
+ method: HttpMethod;
20
+ path: string;
21
+ pathParams: ParsedParameter[];
22
+ queryParams: ParsedParameter[];
23
+ requestBody?: ParsedSchema;
24
+ response?: ParsedSchema;
25
+ responseStatus: number;
26
+ tags: string[];
27
+ }
28
+ interface ParsedParameter {
29
+ name: string;
30
+ required: boolean;
31
+ schema: Record<string, unknown>;
32
+ }
33
+ interface ParsedSchema {
34
+ name?: string;
35
+ jsonSchema: Record<string, unknown>;
36
+ }
37
+ type GroupByStrategy = "tag" | "path" | "none";
38
+ declare function groupOperations(operations: ParsedOperation[], strategy: GroupByStrategy): ParsedResource[];
39
+ declare function sanitizeIdentifier(name: string): string;
40
+ interface NormalizerConfig {
41
+ overrides?: Record<string, string>;
42
+ transform?: (cleaned: string, original: string) => string;
43
+ }
44
+ declare function normalizeOperationId(operationId: string, method: HttpMethod, path: string, config?: NormalizerConfig): string;
45
+ declare function parseOpenAPI(spec: Record<string, unknown>): {
46
+ operations: ParsedOperation[];
47
+ schemas: ParsedSchema[];
48
+ version: "3.0" | "3.1";
49
+ };
50
+ interface ResolveOptions {
51
+ specVersion: "3.0" | "3.1";
52
+ }
53
+ declare function resolveRef(ref: string, document: Record<string, unknown>, options: ResolveOptions): Record<string, unknown>;
54
+ declare function resolveSchema(schema: Record<string, unknown>, document: Record<string, unknown>, options: ResolveOptions, resolving?: Set<string>): Record<string, unknown>;
55
+ export { sanitizeIdentifier, resolveSchema, resolveRef, parseOpenAPI, normalizeOperationId, groupOperations, ResolveOptions, ParsedSpec, ParsedSchema, ParsedResource, ParsedParameter, ParsedOperation, NormalizerConfig, HttpMethod, GroupByStrategy };
package/dist/index.js ADDED
@@ -0,0 +1,420 @@
1
+ // src/adapter/identifier.ts
2
+ function splitWords(name) {
3
+ return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z0-9]+/g, " ").split(/\s+/).filter(Boolean);
4
+ }
5
+ function sanitizeIdentifier(name) {
6
+ const words = splitWords(name);
7
+ if (words.length === 0) {
8
+ return "_";
9
+ }
10
+ const [first, ...rest] = words.map((word) => word.toLowerCase());
11
+ const camelCase = first + rest.map((word) => word[0]?.toUpperCase() + word.slice(1)).join("");
12
+ return /^[0-9]/.test(camelCase) ? `_${camelCase}` : camelCase;
13
+ }
14
+
15
+ // src/adapter/resource-grouper.ts
16
+ function getPathGroupKey(path) {
17
+ const meaningfulSegments = path.split("/").filter(Boolean).filter((segment) => segment !== "api").filter((segment) => !/^v\d+$/i.test(segment)).filter((segment) => !(segment.startsWith("{") && segment.endsWith("}")));
18
+ return meaningfulSegments[meaningfulSegments.length - 1] ?? "_ungrouped";
19
+ }
20
+ function toResourceName(identifier) {
21
+ return identifier === "_ungrouped" ? "Ungrouped" : identifier.charAt(0).toUpperCase() + identifier.slice(1);
22
+ }
23
+ function groupOperations(operations, strategy) {
24
+ const resources = new Map;
25
+ for (const operation of operations) {
26
+ const groupKey = strategy === "tag" ? operation.tags[0] ?? "_ungrouped" : strategy === "none" ? operation.operationId : getPathGroupKey(operation.path);
27
+ const identifier = groupKey === "_ungrouped" ? "_ungrouped" : sanitizeIdentifier(groupKey);
28
+ const existing = resources.get(identifier) ?? [];
29
+ existing.push(operation);
30
+ resources.set(identifier, existing);
31
+ }
32
+ return [...resources.entries()].map(([identifier, resourceOperations]) => ({
33
+ name: toResourceName(identifier),
34
+ identifier,
35
+ operations: resourceOperations
36
+ }));
37
+ }
38
+ // src/parser/operation-id-normalizer.ts
39
+ var HTTP_METHOD_WORDS = new Set(["get", "post", "put", "delete", "patch"]);
40
+ function isPathParam(segment) {
41
+ return segment.startsWith("{") && segment.endsWith("}");
42
+ }
43
+ function getPathSegments(path) {
44
+ return path.split("/").filter(Boolean);
45
+ }
46
+ function splitWords2(input) {
47
+ return input.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[^A-Za-z0-9]+/g, " ").split(/\s+/).filter(Boolean);
48
+ }
49
+ function toCamelCase(words) {
50
+ if (words.length === 0) {
51
+ return "";
52
+ }
53
+ const [first = "", ...rest] = words.map((word) => word.toLowerCase());
54
+ return first + rest.map((word) => word[0]?.toUpperCase() + word.slice(1)).join("");
55
+ }
56
+ function singularize(word) {
57
+ if (word.endsWith("ies") && word.length > 3) {
58
+ return `${word.slice(0, -3)}y`;
59
+ }
60
+ if (word.endsWith("s") && word.length > 1) {
61
+ return word.slice(0, -1);
62
+ }
63
+ return word;
64
+ }
65
+ function getPrimaryResourceWords(path) {
66
+ const segments = getPathSegments(path).filter((segment) => !isPathParam(segment));
67
+ const firstSegment = segments[0];
68
+ if (!firstSegment) {
69
+ return [];
70
+ }
71
+ return splitWords2(firstSegment).map((word) => word.toLowerCase());
72
+ }
73
+ function autoCleanOperationId(operationId, path) {
74
+ const withoutControllerPrefix = operationId.replace(/^[A-Za-z0-9]+Controller[_.-]+/, "");
75
+ const words = splitWords2(withoutControllerPrefix).map((word) => word.toLowerCase());
76
+ const lastWord = words.at(-1);
77
+ if (words.length > 1 && lastWord && HTTP_METHOD_WORDS.has(lastWord)) {
78
+ words.pop();
79
+ }
80
+ const resourcePhrase = getPrimaryResourceWords(path);
81
+ const resourceWords = new Set([...resourcePhrase, ...resourcePhrase.map(singularize)]);
82
+ if (resourcePhrase.length === 1) {
83
+ while (words.length > 1 && words[0] && resourceWords.has(words[0])) {
84
+ words.shift();
85
+ }
86
+ while (words.length > 1 && words.at(-1) && resourceWords.has(words.at(-1))) {
87
+ words.pop();
88
+ }
89
+ }
90
+ return toCamelCase(words);
91
+ }
92
+ function detectCrudMethod(method, path) {
93
+ const segments = getPathSegments(path);
94
+ if (segments.length === 1 && method === "GET") {
95
+ return "list";
96
+ }
97
+ if (segments.length === 2 && segments[1] && isPathParam(segments[1])) {
98
+ if (method === "GET") {
99
+ return "get";
100
+ }
101
+ if (method === "PUT" || method === "PATCH") {
102
+ return "update";
103
+ }
104
+ if (method === "DELETE") {
105
+ return "delete";
106
+ }
107
+ }
108
+ if (segments.length === 1 && method === "POST") {
109
+ return "create";
110
+ }
111
+ return;
112
+ }
113
+ function normalizeOperationId(operationId, method, path, config) {
114
+ if (config?.overrides?.[operationId]) {
115
+ return config.overrides[operationId];
116
+ }
117
+ const cleaned = autoCleanOperationId(operationId, path);
118
+ if (config?.transform) {
119
+ return config.transform(cleaned, operationId);
120
+ }
121
+ return detectCrudMethod(method, path) ?? cleaned;
122
+ }
123
+ // src/parser/ref-resolver.ts
124
+ class OpenAPIParserError extends Error {
125
+ name = "OpenAPIParserError";
126
+ }
127
+ function isRecord(value) {
128
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
129
+ }
130
+ function getRefSegments(ref) {
131
+ if (!ref.startsWith("#/")) {
132
+ throw new OpenAPIParserError(`External $ref values are not supported: ${ref}`);
133
+ }
134
+ return ref.slice(2).split("/");
135
+ }
136
+ function getRefName(ref) {
137
+ const segments = getRefSegments(ref);
138
+ return segments[segments.length - 1] ?? ref;
139
+ }
140
+ function getRawRefTarget(ref, document) {
141
+ let current = document;
142
+ for (const segment of getRefSegments(ref)) {
143
+ if (!isRecord(current) || !(segment in current)) {
144
+ throw new OpenAPIParserError(`Could not resolve $ref: ${ref}`);
145
+ }
146
+ current = current[segment];
147
+ }
148
+ if (!isRecord(current)) {
149
+ throw new OpenAPIParserError(`Resolved $ref is not an object schema: ${ref}`);
150
+ }
151
+ return current;
152
+ }
153
+ function resolveNestedValue(value, document, options, resolving) {
154
+ if (Array.isArray(value)) {
155
+ return value.map((entry) => isRecord(entry) ? resolveSchema(entry, document, options, resolving) : entry);
156
+ }
157
+ if (isRecord(value)) {
158
+ return resolveSchema(value, document, options, resolving);
159
+ }
160
+ return value;
161
+ }
162
+ function mergeSchemas(left, right) {
163
+ const merged = { ...left };
164
+ for (const [key, value] of Object.entries(right)) {
165
+ if (key === "properties" && isRecord(merged.properties) && isRecord(value)) {
166
+ merged.properties = { ...merged.properties, ...value };
167
+ continue;
168
+ }
169
+ if (key === "required" && Array.isArray(merged.required) && Array.isArray(value)) {
170
+ merged.required = [...new Set([...merged.required, ...value])];
171
+ continue;
172
+ }
173
+ if (isRecord(merged[key]) && isRecord(value)) {
174
+ merged[key] = mergeSchemas(merged[key], value);
175
+ continue;
176
+ }
177
+ merged[key] = value;
178
+ }
179
+ return merged;
180
+ }
181
+ function resolveRef(ref, document, options) {
182
+ const target = getRawRefTarget(ref, document);
183
+ if (typeof target.$ref === "string") {
184
+ return resolveRef(target.$ref, document, options);
185
+ }
186
+ return resolveSchema(target, document, options);
187
+ }
188
+ function resolveSchema(schema, document, options, resolving = new Set) {
189
+ let workingSchema = { ...schema };
190
+ if (typeof workingSchema.$ref === "string") {
191
+ const ref = workingSchema.$ref;
192
+ if (resolving.has(ref)) {
193
+ return { $circular: getRefName(ref) };
194
+ }
195
+ const nextResolving = new Set(resolving);
196
+ nextResolving.add(ref);
197
+ const resolvedTarget = resolveSchema(getRawRefTarget(ref, document), document, options, nextResolving);
198
+ const siblingEntries = Object.entries(workingSchema).filter(([key]) => key !== "$ref");
199
+ if (options.specVersion === "3.0" || siblingEntries.length === 0) {
200
+ workingSchema = resolvedTarget;
201
+ } else {
202
+ const siblings = Object.fromEntries(siblingEntries);
203
+ workingSchema = mergeSchemas(resolvedTarget, resolveSchema(siblings, document, options, nextResolving));
204
+ }
205
+ }
206
+ if (Array.isArray(workingSchema.allOf)) {
207
+ const flattened = workingSchema.allOf.reduce((accumulator, member) => {
208
+ if (!isRecord(member)) {
209
+ return accumulator;
210
+ }
211
+ return mergeSchemas(accumulator, resolveSchema(member, document, options, new Set(resolving)));
212
+ }, {});
213
+ const { allOf: _allOf, ...rest } = workingSchema;
214
+ return mergeSchemas(flattened, resolveSchema(rest, document, options, resolving));
215
+ }
216
+ const resolved = {};
217
+ for (const [key, value] of Object.entries(workingSchema)) {
218
+ if (key === "properties" && isRecord(value)) {
219
+ resolved.properties = Object.fromEntries(Object.entries(value).map(([propertyName, propertySchema]) => [
220
+ propertyName,
221
+ isRecord(propertySchema) ? resolveSchema(propertySchema, document, options, new Set(resolving)) : propertySchema
222
+ ]));
223
+ continue;
224
+ }
225
+ resolved[key] = resolveNestedValue(value, document, options, new Set(resolving));
226
+ }
227
+ return resolved;
228
+ }
229
+
230
+ // src/parser/openapi-parser.ts
231
+ class OpenAPIParserError2 extends Error {
232
+ name = "OpenAPIParserError";
233
+ }
234
+ function isRecord2(value) {
235
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
236
+ }
237
+ function getVersion(spec) {
238
+ const version = spec.openapi;
239
+ if (typeof version !== "string") {
240
+ throw new OpenAPIParserError2("OpenAPI spec is missing required field: openapi");
241
+ }
242
+ if (version.startsWith("3.0")) {
243
+ return "3.0";
244
+ }
245
+ if (version.startsWith("3.1")) {
246
+ return "3.1";
247
+ }
248
+ throw new OpenAPIParserError2(`Unsupported OpenAPI version: ${version}`);
249
+ }
250
+ function getRefTarget(ref, spec) {
251
+ if (!ref.startsWith("#/")) {
252
+ throw new OpenAPIParserError2(`External $ref values are not supported: ${ref}`);
253
+ }
254
+ let current = spec;
255
+ for (const segment of ref.slice(2).split("/")) {
256
+ if (!isRecord2(current) || !(segment in current)) {
257
+ throw new OpenAPIParserError2(`Could not resolve $ref: ${ref}`);
258
+ }
259
+ current = current[segment];
260
+ }
261
+ if (!isRecord2(current)) {
262
+ throw new OpenAPIParserError2(`Resolved $ref is not an object: ${ref}`);
263
+ }
264
+ return current;
265
+ }
266
+ function resolveOpenAPIObject(value, spec) {
267
+ if (!isRecord2(value)) {
268
+ return;
269
+ }
270
+ if (typeof value.$ref === "string") {
271
+ return resolveOpenAPIObject(getRefTarget(value.$ref, spec), spec);
272
+ }
273
+ return value;
274
+ }
275
+ function normalizeNullableSchema(schema, version) {
276
+ if (Array.isArray(schema)) {
277
+ return schema.map((entry) => normalizeNullableSchema(entry, version));
278
+ }
279
+ if (!isRecord2(schema)) {
280
+ return schema;
281
+ }
282
+ const normalized = Object.fromEntries(Object.entries(schema).map(([key, value]) => [key, normalizeNullableSchema(value, version)]));
283
+ if (version !== "3.0" || normalized.nullable !== true) {
284
+ return normalized;
285
+ }
286
+ const type = normalized.type;
287
+ if (typeof type === "string") {
288
+ normalized.type = [type, "null"];
289
+ } else if (Array.isArray(type) && !type.includes("null")) {
290
+ normalized.type = [...type, "null"];
291
+ }
292
+ delete normalized.nullable;
293
+ return normalized;
294
+ }
295
+ function getJsonContentSchema(value) {
296
+ if (!isRecord2(value) || !isRecord2(value.content)) {
297
+ return;
298
+ }
299
+ const mediaType = value.content["application/json"];
300
+ if (!isRecord2(mediaType) || !isRecord2(mediaType.schema)) {
301
+ return;
302
+ }
303
+ return mediaType.schema;
304
+ }
305
+ function resolveSchemaForOutput(schema, spec, version) {
306
+ return {
307
+ jsonSchema: normalizeNullableSchema(resolveSchema(schema, spec, { specVersion: version }), version)
308
+ };
309
+ }
310
+ function getOperationResponses(operation) {
311
+ if (!isRecord2(operation.responses)) {
312
+ return {};
313
+ }
314
+ return operation.responses;
315
+ }
316
+ function pickSuccessResponse(operation, spec) {
317
+ const entries = Object.entries(getOperationResponses(operation)).map(([status, response]) => ({ status: Number(status), response })).filter(({ status }) => Number.isInteger(status) && status >= 200 && status < 300).sort((left, right) => left.status - right.status);
318
+ const first = entries[0];
319
+ if (!first) {
320
+ return { status: 200 };
321
+ }
322
+ const resolvedResponse = resolveOpenAPIObject(first.response, spec);
323
+ return {
324
+ status: first.status,
325
+ schema: getJsonContentSchema(resolvedResponse)
326
+ };
327
+ }
328
+ function getCombinedParameters(pathItem, operation, spec) {
329
+ const collect = (value) => Array.isArray(value) ? value.map((entry) => resolveOpenAPIObject(entry, spec)).filter((entry) => Boolean(entry)) : [];
330
+ return [...collect(pathItem.parameters), ...collect(operation.parameters)];
331
+ }
332
+ function getPathParameterNames(path) {
333
+ return [...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]).filter((name) => Boolean(name));
334
+ }
335
+ function toParsedParameter(parameter, name, spec, version, requiredFallback) {
336
+ const resolvedSchema = isRecord2(parameter?.schema) ? normalizeNullableSchema(resolveSchema(parameter.schema, spec, { specVersion: version }), version) : {};
337
+ return {
338
+ name,
339
+ required: typeof parameter?.required === "boolean" ? parameter.required : requiredFallback,
340
+ schema: resolvedSchema
341
+ };
342
+ }
343
+ function extractParameters(path, pathItem, operation, spec, version) {
344
+ const combined = getCombinedParameters(pathItem, operation, spec);
345
+ const pathParameters = new Map;
346
+ const queryParameters = [];
347
+ for (const parameter of combined) {
348
+ if (parameter.in === "path" && typeof parameter.name === "string") {
349
+ pathParameters.set(parameter.name, parameter);
350
+ }
351
+ if (parameter.in === "query" && typeof parameter.name === "string") {
352
+ queryParameters.push(toParsedParameter(parameter, parameter.name, spec, version, false));
353
+ }
354
+ }
355
+ const pathNames = [...new Set([...getPathParameterNames(path), ...pathParameters.keys()])];
356
+ return {
357
+ pathParams: pathNames.map((name) => toParsedParameter(pathParameters.get(name), name, spec, version, true)),
358
+ queryParams: queryParameters
359
+ };
360
+ }
361
+ function extractRequestBody(operation, spec, version) {
362
+ const requestBody = resolveOpenAPIObject(operation.requestBody, spec);
363
+ const schema = getJsonContentSchema(requestBody);
364
+ return schema ? resolveSchemaForOutput(schema, spec, version) : undefined;
365
+ }
366
+ function collectComponentSchemas(spec, version) {
367
+ const schemas = isRecord2(spec.components) && isRecord2(spec.components.schemas) ? spec.components.schemas : undefined;
368
+ if (!schemas) {
369
+ return [];
370
+ }
371
+ return Object.entries(schemas).filter((entry) => isRecord2(entry[1])).map(([name, schema]) => ({
372
+ name,
373
+ ...resolveSchemaForOutput(schema, spec, version)
374
+ }));
375
+ }
376
+ function parseOpenAPI(spec) {
377
+ const version = getVersion(spec);
378
+ if (!isRecord2(spec.info)) {
379
+ throw new OpenAPIParserError2("OpenAPI spec is missing required field: info");
380
+ }
381
+ if (!isRecord2(spec.paths)) {
382
+ throw new OpenAPIParserError2("OpenAPI spec is missing required field: paths");
383
+ }
384
+ const operations = [];
385
+ for (const [path, pathItem] of Object.entries(spec.paths)) {
386
+ if (!isRecord2(pathItem)) {
387
+ continue;
388
+ }
389
+ for (const method of ["get", "post", "put", "delete", "patch"]) {
390
+ const operation = pathItem[method];
391
+ if (!isRecord2(operation)) {
392
+ continue;
393
+ }
394
+ const operationId = typeof operation.operationId === "string" ? operation.operationId : `${method}_${path}`;
395
+ const { pathParams, queryParams } = extractParameters(path, pathItem, operation, spec, version);
396
+ const successResponse = pickSuccessResponse(operation, spec);
397
+ operations.push({
398
+ operationId,
399
+ methodName: normalizeOperationId(operationId, method.toUpperCase(), path),
400
+ method: method.toUpperCase(),
401
+ path,
402
+ pathParams,
403
+ queryParams,
404
+ requestBody: extractRequestBody(operation, spec, version),
405
+ response: successResponse.schema ? resolveSchemaForOutput(successResponse.schema, spec, version) : undefined,
406
+ responseStatus: successResponse.status,
407
+ tags: Array.isArray(operation.tags) ? operation.tags.filter((tag) => typeof tag === "string") : []
408
+ });
409
+ }
410
+ }
411
+ return { operations, schemas: collectComponentSchemas(spec, version), version };
412
+ }
413
+ export {
414
+ sanitizeIdentifier,
415
+ resolveSchema,
416
+ resolveRef,
417
+ parseOpenAPI,
418
+ normalizeOperationId,
419
+ groupOperations
420
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@vertz/openapi",
3
+ "version": "0.1.0",
4
+ "description": "OpenAPI 3.x parser and TypeScript SDK generator for Vertz",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/vertz-dev/vertz.git",
9
+ "directory": "packages/openapi"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": "./dist/index.js",
21
+ "types": "./dist/index.d.ts"
22
+ }
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "provenance": true
27
+ },
28
+ "scripts": {
29
+ "build": "bunup",
30
+ "test": "bun test",
31
+ "test:watch": "bun test --watch",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "devDependencies": {
35
+ "bun-types": "^1.3.10",
36
+ "bunup": "^0.16.31",
37
+ "typescript": "^5.7.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=22"
41
+ }
42
+ }