@velajs/vela 0.7.0 → 0.8.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.
package/dist/index.d.ts CHANGED
@@ -2,6 +2,8 @@ import './metadata';
2
2
  export { defineMetadata, getMetadata } from './metadata';
3
3
  export { VelaFactory, createApplication } from './factory';
4
4
  export { VelaApplication } from './application';
5
+ export { createOpenApiDocument, ApiDoc, ApiTags, zodToJsonSchema } from './openapi/index';
6
+ export type { OpenApiDocument, OpenApiInfo, OpenApiOperation, OpenApiParameter, OpenApiPathItem, OpenApiRequestBody, OpenApiResponse, ApiDocMetadata, CreateOpenApiDocumentOptions, HttpVerb, JsonSchema, } from './openapi/index';
5
7
  export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, mixin } from './container/index';
6
8
  export type { Type, Token, InjectableOptions, ProviderOptions, } from './container/index';
7
9
  export { METADATA_KEYS, HttpMethod, ParamType, Scope } from './constants';
package/dist/index.js CHANGED
@@ -3,6 +3,8 @@ export { defineMetadata, getMetadata } from "./metadata.js";
3
3
  // Factory & Application
4
4
  export { VelaFactory, createApplication } from "./factory.js";
5
5
  export { VelaApplication } from "./application.js";
6
+ // OpenAPI
7
+ export { createOpenApiDocument, ApiDoc, ApiTags, zodToJsonSchema } from "./openapi/index.js";
6
8
  // DI Container
7
9
  export { Container, Injectable, Inject, Optional, InjectionToken, ForwardRef, forwardRef, ModuleRef, mixin } from "./container/index.js";
8
10
  // Constants
@@ -0,0 +1,20 @@
1
+ import type { ApiDocMetadata } from './types';
2
+ export declare const API_DOC_METADATA = "vela:openapi:doc";
3
+ export declare const API_TAGS_METADATA = "vela:openapi:tags";
4
+ /**
5
+ * Attach OpenAPI documentation to a route handler (or controller).
6
+ *
7
+ * ```ts
8
+ * @Get('/:id')
9
+ * @ApiDoc({ summary: 'Get a user', description: '...', operationId: 'getUser' })
10
+ * findOne(@Param('id') id: string) { ... }
11
+ * ```
12
+ */
13
+ export declare function ApiDoc(metadata: ApiDocMetadata): MethodDecorator & ClassDecorator;
14
+ /**
15
+ * Attach OpenAPI tags to a controller or handler. Handler-level tags merge
16
+ * with controller-level tags; duplicates are de-duplicated in the output.
17
+ */
18
+ export declare function ApiTags(...tags: string[]): MethodDecorator & ClassDecorator;
19
+ export declare function getApiDoc(target: object, propertyKey?: string | symbol): ApiDocMetadata | undefined;
20
+ export declare function getApiTags(target: object, propertyKey?: string | symbol): string[] | undefined;
@@ -0,0 +1,37 @@
1
+ export const API_DOC_METADATA = 'vela:openapi:doc';
2
+ export const API_TAGS_METADATA = 'vela:openapi:tags';
3
+ /**
4
+ * Attach OpenAPI documentation to a route handler (or controller).
5
+ *
6
+ * ```ts
7
+ * @Get('/:id')
8
+ * @ApiDoc({ summary: 'Get a user', description: '...', operationId: 'getUser' })
9
+ * findOne(@Param('id') id: string) { ... }
10
+ * ```
11
+ */ export function ApiDoc(metadata) {
12
+ return (target, propertyKey)=>{
13
+ if (propertyKey !== undefined) {
14
+ Reflect.defineMetadata(API_DOC_METADATA, metadata, target, propertyKey);
15
+ } else {
16
+ Reflect.defineMetadata(API_DOC_METADATA, metadata, target);
17
+ }
18
+ };
19
+ }
20
+ /**
21
+ * Attach OpenAPI tags to a controller or handler. Handler-level tags merge
22
+ * with controller-level tags; duplicates are de-duplicated in the output.
23
+ */ export function ApiTags(...tags) {
24
+ return (target, propertyKey)=>{
25
+ if (propertyKey !== undefined) {
26
+ Reflect.defineMetadata(API_TAGS_METADATA, tags, target, propertyKey);
27
+ } else {
28
+ Reflect.defineMetadata(API_TAGS_METADATA, tags, target);
29
+ }
30
+ };
31
+ }
32
+ export function getApiDoc(target, propertyKey) {
33
+ return propertyKey !== undefined ? Reflect.getMetadata(API_DOC_METADATA, target, propertyKey) : Reflect.getMetadata(API_DOC_METADATA, target);
34
+ }
35
+ export function getApiTags(target, propertyKey) {
36
+ return propertyKey !== undefined ? Reflect.getMetadata(API_TAGS_METADATA, target, propertyKey) : Reflect.getMetadata(API_TAGS_METADATA, target);
37
+ }
@@ -0,0 +1,3 @@
1
+ import type { Type } from '../container/types';
2
+ import type { CreateOpenApiDocumentOptions, OpenApiDocument } from './types';
3
+ export declare function createOpenApiDocument(rootModule: Type, options?: CreateOpenApiDocumentOptions): OpenApiDocument;
@@ -0,0 +1,187 @@
1
+ import { getModuleMetadata } from "../module/decorators.js";
2
+ import { ForwardRef } from "../container/types.js";
3
+ import { MetadataRegistry } from "../registry/metadata.registry.js";
4
+ import { ParamType } from "../constants.js";
5
+ import { getApiDoc, getApiTags } from "./decorators.js";
6
+ import { isOptional, zodToJsonSchema } from "./zod-to-json-schema.js";
7
+ function isDynamicModuleLike(v) {
8
+ return !!v && typeof v === 'object' && 'module' in v && typeof v.module === 'function';
9
+ }
10
+ function collectControllers(rootModule) {
11
+ const visited = new Set();
12
+ const controllers = new Set();
13
+ function visit(entry) {
14
+ const unwrapped = entry instanceof ForwardRef ? entry.factory() : entry;
15
+ const moduleClass = isDynamicModuleLike(unwrapped) ? unwrapped.module : unwrapped;
16
+ const extraControllers = isDynamicModuleLike(unwrapped) ? unwrapped.controllers ?? [] : [];
17
+ const extraImports = isDynamicModuleLike(unwrapped) ? unwrapped.imports ?? [] : [];
18
+ if (typeof moduleClass !== 'function' || visited.has(moduleClass)) return;
19
+ visited.add(moduleClass);
20
+ const metadata = getModuleMetadata(moduleClass);
21
+ if (metadata) {
22
+ for (const controller of metadata.controllers)controllers.add(controller);
23
+ for (const imp of metadata.imports)visit(imp);
24
+ }
25
+ for (const controller of extraControllers)controllers.add(controller);
26
+ for (const imp of extraImports)visit(imp);
27
+ }
28
+ visit(rootModule);
29
+ return [
30
+ ...controllers
31
+ ];
32
+ }
33
+ function normalizePath(path) {
34
+ // Hono/Nest style `:id` → OpenAPI `{id}`
35
+ return path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, '{$1}');
36
+ }
37
+ function joinPath(a, b) {
38
+ const left = a.endsWith('/') ? a.slice(0, -1) : a;
39
+ const right = b && !b.startsWith('/') ? `/${b}` : b;
40
+ const joined = `${left}${right}`;
41
+ return joined || '/';
42
+ }
43
+ function getParamSchema(param, paramtypes) {
44
+ const metatype = paramtypes?.[param.index];
45
+ if (metatype?.schema) {
46
+ return zodToJsonSchema(metatype.schema);
47
+ }
48
+ return undefined;
49
+ }
50
+ function isParamOptional(param, paramtypes) {
51
+ const metatype = paramtypes?.[param.index];
52
+ if (metatype?.schema) {
53
+ return isOptional(metatype.schema);
54
+ }
55
+ return true;
56
+ }
57
+ function buildOperation(controller, route, pathString) {
58
+ const handlerName = route.handlerName;
59
+ const paramMetadata = MetadataRegistry.getParameters(controller).get(handlerName) ?? [];
60
+ const paramtypes = Reflect.getMetadata('design:paramtypes', controller.prototype, handlerName);
61
+ const parameters = [];
62
+ let requestBody;
63
+ // Path params declared in the URL but not listed as @Param() get a string default.
64
+ const declaredPathParams = new Set();
65
+ const pathParamNames = [
66
+ ...pathString.matchAll(/\{([^}]+)\}/g)
67
+ ].map((m)=>m[1]);
68
+ for (const param of paramMetadata){
69
+ if (param.type === ParamType.PARAM && param.name) {
70
+ declaredPathParams.add(param.name);
71
+ parameters.push({
72
+ name: param.name,
73
+ in: 'path',
74
+ required: true,
75
+ schema: getParamSchema(param, paramtypes) ?? {
76
+ type: 'string'
77
+ }
78
+ });
79
+ } else if (param.type === ParamType.QUERY && param.name) {
80
+ parameters.push({
81
+ name: param.name,
82
+ in: 'query',
83
+ required: !isParamOptional(param, paramtypes),
84
+ schema: getParamSchema(param, paramtypes) ?? {
85
+ type: 'string'
86
+ }
87
+ });
88
+ } else if (param.type === ParamType.HEADERS && param.name) {
89
+ parameters.push({
90
+ name: param.name,
91
+ in: 'header',
92
+ required: !isParamOptional(param, paramtypes),
93
+ schema: getParamSchema(param, paramtypes) ?? {
94
+ type: 'string'
95
+ }
96
+ });
97
+ } else if (param.type === ParamType.BODY) {
98
+ const schema = getParamSchema(param, paramtypes);
99
+ if (schema) {
100
+ requestBody = {
101
+ required: !isParamOptional(param, paramtypes),
102
+ content: {
103
+ 'application/json': {
104
+ schema
105
+ }
106
+ }
107
+ };
108
+ }
109
+ }
110
+ }
111
+ for (const name of pathParamNames){
112
+ if (!declaredPathParams.has(name)) {
113
+ parameters.push({
114
+ name,
115
+ in: 'path',
116
+ required: true,
117
+ schema: {
118
+ type: 'string'
119
+ }
120
+ });
121
+ }
122
+ }
123
+ const docMeta = getApiDoc(controller.prototype, handlerName);
124
+ const controllerTags = getApiTags(controller) ?? [];
125
+ const handlerTags = getApiTags(controller.prototype, handlerName) ?? [];
126
+ const docTags = docMeta?.tags ?? [];
127
+ const mergedTags = [
128
+ ...new Set([
129
+ ...controllerTags,
130
+ ...handlerTags,
131
+ ...docTags
132
+ ])
133
+ ];
134
+ const operation = {
135
+ responses: {
136
+ '200': {
137
+ description: 'OK'
138
+ }
139
+ }
140
+ };
141
+ if (parameters.length > 0) operation.parameters = parameters;
142
+ if (requestBody) operation.requestBody = requestBody;
143
+ if (docMeta?.summary) operation.summary = docMeta.summary;
144
+ if (docMeta?.description) operation.description = docMeta.description;
145
+ if (docMeta?.operationId) operation.operationId = docMeta.operationId;
146
+ if (docMeta?.deprecated) operation.deprecated = docMeta.deprecated;
147
+ if (mergedTags.length > 0) operation.tags = mergedTags;
148
+ return operation;
149
+ }
150
+ const VERB_WHITELIST = new Set([
151
+ 'get',
152
+ 'post',
153
+ 'put',
154
+ 'patch',
155
+ 'delete',
156
+ 'options',
157
+ 'head'
158
+ ]);
159
+ export function createOpenApiDocument(rootModule, options = {}) {
160
+ const paths = {};
161
+ const globalPrefix = options.globalPrefix ?? '';
162
+ for (const controller of collectControllers(rootModule)){
163
+ const controllerPath = MetadataRegistry.getControllerPath(controller);
164
+ const routes = MetadataRegistry.getRoutes(controller);
165
+ for (const route of routes){
166
+ const method = route.method.toLowerCase();
167
+ if (!VERB_WHITELIST.has(method)) continue;
168
+ const rawPath = joinPath(joinPath(globalPrefix, controllerPath), route.path);
169
+ const pathString = normalizePath(rawPath);
170
+ const operation = buildOperation(controller, route, pathString);
171
+ const pathItem = paths[pathString] ?? {};
172
+ pathItem[method] = operation;
173
+ paths[pathString] = pathItem;
174
+ }
175
+ }
176
+ return {
177
+ openapi: '3.1.0',
178
+ info: {
179
+ title: options.info?.title ?? 'Vela API',
180
+ version: options.info?.version ?? '1.0.0',
181
+ ...options.info?.description ? {
182
+ description: options.info.description
183
+ } : {}
184
+ },
185
+ paths
186
+ };
187
+ }
@@ -0,0 +1,4 @@
1
+ export { createOpenApiDocument } from './document';
2
+ export { ApiDoc, ApiTags, API_DOC_METADATA, API_TAGS_METADATA } from './decorators';
3
+ export { zodToJsonSchema } from './zod-to-json-schema';
4
+ export type { ApiDocMetadata, CreateOpenApiDocumentOptions, HttpVerb, JsonSchema, OpenApiDocument, OpenApiInfo, OpenApiOperation, OpenApiParameter, OpenApiPathItem, OpenApiRequestBody, OpenApiResponse, } from './types';
@@ -0,0 +1,3 @@
1
+ export { createOpenApiDocument } from "./document.js";
2
+ export { ApiDoc, ApiTags, API_DOC_METADATA, API_TAGS_METADATA } from "./decorators.js";
3
+ export { zodToJsonSchema } from "./zod-to-json-schema.js";
@@ -0,0 +1,85 @@
1
+ export interface OpenApiInfo {
2
+ title: string;
3
+ version: string;
4
+ description?: string;
5
+ }
6
+ export interface JsonSchema {
7
+ type?: string | string[];
8
+ format?: string;
9
+ enum?: Array<string | number | boolean | null>;
10
+ const?: unknown;
11
+ description?: string;
12
+ nullable?: boolean;
13
+ default?: unknown;
14
+ items?: JsonSchema;
15
+ properties?: Record<string, JsonSchema>;
16
+ required?: string[];
17
+ additionalProperties?: boolean | JsonSchema;
18
+ oneOf?: JsonSchema[];
19
+ anyOf?: JsonSchema[];
20
+ allOf?: JsonSchema[];
21
+ minimum?: number;
22
+ maximum?: number;
23
+ minLength?: number;
24
+ maxLength?: number;
25
+ pattern?: string;
26
+ $ref?: string;
27
+ [key: string]: unknown;
28
+ }
29
+ export interface OpenApiParameter {
30
+ name: string;
31
+ in: 'path' | 'query' | 'header' | 'cookie';
32
+ required?: boolean;
33
+ description?: string;
34
+ schema?: JsonSchema;
35
+ }
36
+ export interface OpenApiRequestBody {
37
+ description?: string;
38
+ required?: boolean;
39
+ content?: Record<string, {
40
+ schema: JsonSchema;
41
+ }>;
42
+ }
43
+ export interface OpenApiResponse {
44
+ description: string;
45
+ content?: Record<string, {
46
+ schema: JsonSchema;
47
+ }>;
48
+ }
49
+ export interface OpenApiOperation {
50
+ summary?: string;
51
+ description?: string;
52
+ operationId?: string;
53
+ deprecated?: boolean;
54
+ tags?: string[];
55
+ parameters?: OpenApiParameter[];
56
+ requestBody?: OpenApiRequestBody;
57
+ responses: Record<string, OpenApiResponse>;
58
+ }
59
+ export type HttpVerb = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head';
60
+ export type OpenApiPathItem = {
61
+ [verb in HttpVerb]?: OpenApiOperation;
62
+ };
63
+ export interface OpenApiDocument {
64
+ openapi: '3.1.0';
65
+ info: OpenApiInfo;
66
+ paths: Record<string, OpenApiPathItem>;
67
+ components?: {
68
+ schemas?: Record<string, JsonSchema>;
69
+ };
70
+ tags?: Array<{
71
+ name: string;
72
+ description?: string;
73
+ }>;
74
+ }
75
+ export interface ApiDocMetadata {
76
+ summary?: string;
77
+ description?: string;
78
+ operationId?: string;
79
+ deprecated?: boolean;
80
+ tags?: string[];
81
+ }
82
+ export interface CreateOpenApiDocumentOptions {
83
+ info?: Partial<OpenApiInfo>;
84
+ globalPrefix?: string;
85
+ }
@@ -0,0 +1,4 @@
1
+ // Minimal OpenAPI 3.1 subset Vela emits. Kept intentionally loose (index
2
+ // signatures on schemas, open tags array) so users can extend without
3
+ // fighting the types. Covers what createOpenApiDocument actually produces.
4
+ export { };
@@ -0,0 +1,4 @@
1
+ import type { JsonSchema } from './types';
2
+ export declare function zodToJsonSchema(schema: unknown): JsonSchema;
3
+ export declare function isOptional(schema: unknown): boolean;
4
+ export declare function isNullable(schema: unknown): boolean;
@@ -0,0 +1,57 @@
1
+ // Zod v4 exposes `schema.toJSONSchema()` on every schema instance — an
2
+ // authoritative, zero-cost converter. We delegate to it when present and
3
+ // strip the `$schema` meta so the result embeds cleanly into OpenAPI
4
+ // components. For non-Zod schemas (anything that doesn't expose the
5
+ // method) we return an empty (permissive) schema.
6
+ function stripOpenApiIncompatible(schema) {
7
+ const out = {
8
+ ...schema
9
+ };
10
+ delete out.$schema;
11
+ // Zod v4 emits `additionalProperties: false` by default; users rarely
12
+ // want that constraint in their published OpenAPI doc, so leave it in
13
+ // — it's a faithful reflection of the schema. No change here.
14
+ if (Array.isArray(out.properties)) {
15
+ // shouldn't happen, but guard
16
+ } else if (out.properties && typeof out.properties === 'object') {
17
+ const props = out.properties;
18
+ const cleaned = {};
19
+ for (const [key, val] of Object.entries(props)){
20
+ cleaned[key] = stripOpenApiIncompatible(val);
21
+ }
22
+ out.properties = cleaned;
23
+ }
24
+ if (out.items && typeof out.items === 'object') {
25
+ out.items = stripOpenApiIncompatible(out.items);
26
+ }
27
+ return out;
28
+ }
29
+ export function zodToJsonSchema(schema) {
30
+ if (!schema || typeof schema !== 'object') return {};
31
+ const maybe = schema.toJSONSchema;
32
+ if (typeof maybe === 'function') {
33
+ try {
34
+ const result = maybe.call(schema);
35
+ if (result && typeof result === 'object') {
36
+ return stripOpenApiIncompatible(result);
37
+ }
38
+ } catch {
39
+ // Zod may refuse to convert certain schemas; fall through.
40
+ }
41
+ }
42
+ return {};
43
+ }
44
+ // Zod v4 and v3 both name constructors `ZodOptional` / `ZodDefault` /
45
+ // `ZodNullable`. Checking the constructor name is cheap and avoids
46
+ // depending on internal `_def` shape.
47
+ function ctorName(schema) {
48
+ if (!schema || typeof schema !== 'object') return undefined;
49
+ return schema.constructor?.name;
50
+ }
51
+ export function isOptional(schema) {
52
+ const name = ctorName(schema);
53
+ return name === 'ZodOptional' || name === 'ZodDefault';
54
+ }
55
+ export function isNullable(schema) {
56
+ return ctorName(schema) === 'ZodNullable';
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velajs/vela",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "NestJS-compatible framework for edge runtimes, powered by Hono",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",