@athenna/http 5.46.0 → 5.47.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@athenna/http",
3
- "version": "5.46.0",
3
+ "version": "5.47.0",
4
4
  "description": "The Athenna Http server. Built on top of fastify.",
5
5
  "license": "MIT",
6
6
  "author": "João Lenon <lenon@athenna.io>",
@@ -110,7 +110,8 @@
110
110
  "ora": "^8.2.0",
111
111
  "prettier": "^2.8.8",
112
112
  "vite": "^6.4.1",
113
- "vite-plugin-restart": "^0.4.2"
113
+ "vite-plugin-restart": "^0.4.2",
114
+ "zod": "^4.3.6"
114
115
  },
115
116
  "c8": {
116
117
  "all": true,
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @athenna/http
3
+ *
4
+ * (c) João Lenon <lenon@athenna.io>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+ import type { ZodError } from 'zod';
10
+ import { HttpException } from '#src/exceptions/HttpException';
11
+ export declare class ZodValidationException extends HttpException {
12
+ constructor(error: ZodError);
13
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @athenna/http
3
+ *
4
+ * (c) João Lenon <lenon@athenna.io>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+ import { HttpException } from '#src/exceptions/HttpException';
10
+ export class ZodValidationException extends HttpException {
11
+ constructor(error) {
12
+ const name = 'ValidationException';
13
+ const code = 'E_VALIDATION_ERROR';
14
+ const status = 422;
15
+ const message = 'Validation error happened.';
16
+ const details = error.issues;
17
+ super({ name, message, status, code, details });
18
+ }
19
+ }
package/src/index.d.ts CHANGED
@@ -32,6 +32,7 @@ declare module 'fastify' {
32
32
  }
33
33
  }
34
34
  export * from '#src/types';
35
+ export * from '#src/router/RouteSchema';
35
36
  export * from '#src/context/Request';
36
37
  export * from '#src/context/Response';
37
38
  export * from '#src/annotations/Controller';
package/src/index.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * file that was distributed with this source code.
8
8
  */
9
9
  export * from '#src/types';
10
+ export * from '#src/router/RouteSchema';
10
11
  export * from '#src/context/Request';
11
12
  export * from '#src/context/Response';
12
13
  export * from '#src/annotations/Controller';
@@ -7,8 +7,9 @@
7
7
  * file that was distributed with this source code.
8
8
  */
9
9
  import type { RouteJson, RouteHandler, RequestHandler, MiddlewareRecord, MiddlewareRouteType, TerminatorRouteType, InterceptorRouteType } from '#src/types';
10
- import type { HTTPMethods, FastifySchema, RouteOptions } from 'fastify';
10
+ import type { HTTPMethods, RouteOptions } from 'fastify';
11
11
  import { Macroable } from '@athenna/common';
12
+ import { type RouteSchemaOptions } from '#src/router/RouteSchema';
12
13
  export declare class Route extends Macroable {
13
14
  /**
14
15
  * Holds all the route implementations to be registered in the Server.
@@ -111,7 +112,7 @@ export declare class Route extends Macroable {
111
112
  * })
112
113
  * ```
113
114
  */
114
- schema(options: FastifySchema): Route;
115
+ schema(options: RouteSchemaOptions): Route;
115
116
  /**
116
117
  * Set up all rate limit options for route.
117
118
  *
@@ -9,6 +9,7 @@
9
9
  import { Is, Options, Macroable, Route as RouteHelper } from '@athenna/common';
10
10
  import { UndefinedMethodException } from '#src/exceptions/UndefinedMethodException';
11
11
  import { NotFoundValidatorException } from '#src/exceptions/NotFoundValidatorException';
12
+ import { normalizeRouteSchema } from '#src/router/RouteSchema';
12
13
  import { NotFoundMiddlewareException } from '#src/exceptions/NotFoundMiddlewareException';
13
14
  export class Route extends Macroable {
14
15
  constructor(url, methods, handler) {
@@ -237,7 +238,18 @@ export class Route extends Macroable {
237
238
  * ```
238
239
  */
239
240
  schema(options) {
240
- this.route.fastify.schema = options;
241
+ const { schema, zod } = normalizeRouteSchema(options);
242
+ this.route.fastify.schema = schema;
243
+ if (zod) {
244
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
245
+ // @ts-ignore
246
+ this.route.fastify.config.zod = zod;
247
+ }
248
+ else {
249
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
250
+ // @ts-ignore
251
+ delete this.route.fastify.config.zod;
252
+ }
241
253
  return this;
242
254
  }
243
255
  /**
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { RouteResourceTypes, TerminatorRouteType, MiddlewareRouteType, InterceptorRouteType } from '#src/types';
10
10
  import { Route } from '#src/router/Route';
11
+ import type { RouteSchemaOptions } from '#src/router/RouteSchema';
11
12
  import { Macroable } from '@athenna/common';
12
13
  export declare class RouteResource extends Macroable {
13
14
  /**
@@ -112,6 +113,18 @@ export declare class RouteResource extends Macroable {
112
113
  * ```
113
114
  */
114
115
  rateLimit(options: import('@fastify/rate-limit').RateLimitOptions): RouteResource;
116
+ /**
117
+ * Set up schema options for specific route resource methods.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * Route.resource('/test', 'TestController').schema({
122
+ * index: { response: { 200: { type: 'object' } } },
123
+ * store: { body: { type: 'object' } }
124
+ * })
125
+ * ```
126
+ */
127
+ schema(options: Partial<Record<RouteResourceTypes, RouteSchemaOptions>>): RouteResource;
115
128
  /**
116
129
  * Filter routes by name.
117
130
  */
@@ -171,6 +171,26 @@ export class RouteResource extends Macroable {
171
171
  this.routes.forEach(route => route.rateLimit(options));
172
172
  return this;
173
173
  }
174
+ /**
175
+ * Set up schema options for specific route resource methods.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * Route.resource('/test', 'TestController').schema({
180
+ * index: { response: { 200: { type: 'object' } } },
181
+ * store: { body: { type: 'object' } }
182
+ * })
183
+ * ```
184
+ */
185
+ schema(options) {
186
+ Object.entries(options).forEach(([name, schema]) => {
187
+ if (!schema) {
188
+ return;
189
+ }
190
+ this.filter([name]).forEach(route => route.schema(schema));
191
+ });
192
+ return this;
193
+ }
174
194
  /**
175
195
  * Filter routes by name.
176
196
  */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @athenna/http
3
+ *
4
+ * (c) João Lenon <lenon@athenna.io>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+ import type { ZodAny } from 'zod';
10
+ import type { FastifyReply, FastifyRequest, FastifySchema } from 'fastify';
11
+ type ZodRequestSchema = Partial<Record<'body' | 'headers' | 'params' | 'querystring', ZodAny>>;
12
+ type ZodResponseSchema = Record<number | string, ZodAny>;
13
+ export type RouteSchemaOptions = FastifySchema & {
14
+ body?: FastifySchema['body'] | ZodAny;
15
+ headers?: FastifySchema['headers'] | ZodAny;
16
+ params?: FastifySchema['params'] | ZodAny;
17
+ querystring?: FastifySchema['querystring'] | ZodAny;
18
+ response?: FastifySchema['response'] | ZodResponseSchema;
19
+ };
20
+ export type RouteZodSchemas = {
21
+ request: ZodRequestSchema;
22
+ response: ZodResponseSchema;
23
+ };
24
+ export declare function normalizeRouteSchema(options: RouteSchemaOptions): {
25
+ schema: FastifySchema;
26
+ zod: RouteZodSchemas | null;
27
+ };
28
+ export declare function parseRequestWithZod(req: FastifyRequest, schemas: RouteZodSchemas): Promise<void>;
29
+ export declare function parseResponseWithZod(reply: FastifyReply, payload: any, schemas: RouteZodSchemas): Promise<any>;
30
+ export {};
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @athenna/http
3
+ *
4
+ * (c) João Lenon <lenon@athenna.io>
5
+ *
6
+ * For the full copyright and license information, please view the LICENSE
7
+ * file that was distributed with this source code.
8
+ */
9
+ import { Is } from '@athenna/common';
10
+ import { ZodValidationException } from '#src/exceptions/ZodValidationException';
11
+ export function normalizeRouteSchema(options) {
12
+ const request = {};
13
+ const response = {};
14
+ const schema = { ...options };
15
+ const requestKeys = ['body', 'headers', 'params', 'querystring'];
16
+ requestKeys.forEach(key => {
17
+ if (!isZodSchema(options[key])) {
18
+ return;
19
+ }
20
+ request[key] = options[key];
21
+ schema[key] = toJsonSchema(options[key], 'input');
22
+ });
23
+ if (options.response && Is.Object(options.response)) {
24
+ schema.response = { ...options.response };
25
+ Object.entries(options.response).forEach(([statusCode, value]) => {
26
+ if (!isZodSchema(value)) {
27
+ return;
28
+ }
29
+ response[statusCode] = value;
30
+ schema.response[statusCode] = toJsonSchema(value, 'output');
31
+ });
32
+ }
33
+ const hasZodSchemas = Object.keys(request).length > 0 || Object.keys(response).length > 0;
34
+ return {
35
+ schema,
36
+ zod: hasZodSchemas ? { request, response } : null
37
+ };
38
+ }
39
+ export async function parseRequestWithZod(req, schemas) {
40
+ const requestSchemas = schemas.request;
41
+ if (requestSchemas.body) {
42
+ req.body = await parseSchema(requestSchemas.body, req.body);
43
+ }
44
+ if (requestSchemas.headers) {
45
+ req.headers = await parseSchema(requestSchemas.headers, req.headers);
46
+ }
47
+ if (requestSchemas.params) {
48
+ req.params = await parseSchema(requestSchemas.params, coerceDataForValidation(requestSchemas.params, req.params));
49
+ }
50
+ if (requestSchemas.querystring) {
51
+ req.query = await parseSchema(requestSchemas.querystring, coerceDataForValidation(requestSchemas.querystring, req.query));
52
+ }
53
+ }
54
+ export async function parseResponseWithZod(reply, payload, schemas) {
55
+ const schema = getResponseSchema(reply.statusCode, schemas.response);
56
+ if (!schema) {
57
+ return payload;
58
+ }
59
+ return parseSchema(schema, payload);
60
+ }
61
+ function getResponseSchema(statusCode, schemas) {
62
+ return (schemas[statusCode] ||
63
+ schemas[String(statusCode)] ||
64
+ schemas[`${String(statusCode)[0]}xx`] ||
65
+ schemas.default ||
66
+ null);
67
+ }
68
+ async function parseSchema(schema, data) {
69
+ const result = await schema.safeParseAsync(data);
70
+ if (!result.success) {
71
+ throw new ZodValidationException(result.error);
72
+ }
73
+ return result.data;
74
+ }
75
+ function toJsonSchema(schema, io) {
76
+ const jsonSchemaMethod = schema['~standard']?.jsonSchema?.[io] ||
77
+ schema.toJSONSchema;
78
+ if (!jsonSchemaMethod) {
79
+ return {};
80
+ }
81
+ const jsonSchema = jsonSchemaMethod({
82
+ target: 'draft-07',
83
+ libraryOptions: { unrepresentable: 'any' }
84
+ });
85
+ delete jsonSchema.$schema;
86
+ return jsonSchema;
87
+ }
88
+ function coerceDataForValidation(schema, data) {
89
+ return coerceDataByJsonSchema(toJsonSchema(schema, 'input'), data);
90
+ }
91
+ function coerceDataByJsonSchema(schema, data) {
92
+ if (Is.Undefined(data) || Is.Null(data) || !schema) {
93
+ return data;
94
+ }
95
+ if (schema.anyOf) {
96
+ return coerceWithAlternatives(schema.anyOf, data);
97
+ }
98
+ if (schema.oneOf) {
99
+ return coerceWithAlternatives(schema.oneOf, data);
100
+ }
101
+ if (schema.type === 'object' && Is.Object(data)) {
102
+ const coerced = { ...data };
103
+ const properties = schema.properties || {};
104
+ Object.entries(properties).forEach(([key, childSchema]) => {
105
+ if (!Object.hasOwn(coerced, key)) {
106
+ return;
107
+ }
108
+ coerced[key] = coerceDataByJsonSchema(childSchema, coerced[key]);
109
+ });
110
+ return coerced;
111
+ }
112
+ if (schema.type === 'array' && Is.Array(data) && schema.items) {
113
+ return data.map(item => coerceDataByJsonSchema(schema.items, item));
114
+ }
115
+ if (schema.type === 'number' || schema.type === 'integer') {
116
+ return coerceNumber(data, schema.type === 'integer');
117
+ }
118
+ return data;
119
+ }
120
+ function coerceWithAlternatives(schemas, data) {
121
+ let coerced = data;
122
+ schemas.forEach(schema => {
123
+ coerced = coerceDataByJsonSchema(schema, coerced);
124
+ });
125
+ return coerced;
126
+ }
127
+ function coerceNumber(value, integerOnly) {
128
+ if (!Is.String(value) || value.trim() === '') {
129
+ return value;
130
+ }
131
+ const parsed = integerOnly ? Number.parseInt(value, 10) : Number(value);
132
+ if (Number.isNaN(parsed)) {
133
+ return value;
134
+ }
135
+ if (integerOnly && !Number.isInteger(parsed)) {
136
+ return value;
137
+ }
138
+ return parsed;
139
+ }
140
+ function isZodSchema(value) {
141
+ return (Is.Defined(value) &&
142
+ Is.Function(value.parse) &&
143
+ Is.Function(value.safeParseAsync));
144
+ }
@@ -136,4 +136,11 @@ export declare class ServerImpl extends Macroable {
136
136
  * Add a new OPTIONS route to the http server.
137
137
  */
138
138
  options(options: Omit<RouteJson, 'methods'>): void;
139
+ private toRouteHooks;
140
+ private getFastifyOptionsWithOpenApiSchema;
141
+ private getOpenApiRouteSchema;
142
+ private getOpenApiPathCandidates;
143
+ private normalizePath;
144
+ private mergeFastifySchemas;
145
+ private mergeZodSchemas;
139
146
  }
@@ -7,6 +7,7 @@
7
7
  * file that was distributed with this source code.
8
8
  */
9
9
  import fastify from 'fastify';
10
+ import { normalizeRouteSchema, parseRequestWithZod, parseResponseWithZod } from '#src/router/RouteSchema';
10
11
  import { Options, Macroable, Is } from '@athenna/common';
11
12
  import { FastifyHandler } from '#src/handlers/FastifyHandler';
12
13
  export class ServerImpl extends Macroable {
@@ -187,9 +188,15 @@ export class ServerImpl extends Macroable {
187
188
  return;
188
189
  }
189
190
  const { middlewares, interceptors, terminators } = options.middlewares;
191
+ const fastifyOptions = this.getFastifyOptionsWithOpenApiSchema(options);
192
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
193
+ // @ts-ignore
194
+ const zodSchemas = fastifyOptions?.config?.zod;
190
195
  const route = {
191
196
  onSend: [],
197
+ preValidation: [],
192
198
  preHandler: [],
199
+ preSerialization: [],
193
200
  onResponse: [],
194
201
  url: options.url,
195
202
  method: options.methods,
@@ -204,6 +211,12 @@ export class ServerImpl extends Macroable {
204
211
  if (terminators.length) {
205
212
  route.onResponse = terminators.map(t => FastifyHandler.terminate(t));
206
213
  }
214
+ if (zodSchemas) {
215
+ route.preValidation = [async (req) => parseRequestWithZod(req, zodSchemas)];
216
+ route.preSerialization = [
217
+ async (_, reply, payload) => parseResponseWithZod(reply, payload, zodSchemas)
218
+ ];
219
+ }
207
220
  if (options.data && Is.Array(route.preHandler)) {
208
221
  route.preHandler?.unshift((req, _, done) => {
209
222
  req.data = {
@@ -213,7 +226,17 @@ export class ServerImpl extends Macroable {
213
226
  done();
214
227
  });
215
228
  }
216
- this.fastify.route({ ...route, ...options.fastify });
229
+ if (zodSchemas) {
230
+ fastifyOptions.preValidation = [
231
+ ...this.toRouteHooks(route.preValidation),
232
+ ...this.toRouteHooks(fastifyOptions.preValidation)
233
+ ];
234
+ fastifyOptions.preSerialization = [
235
+ ...this.toRouteHooks(route.preSerialization),
236
+ ...this.toRouteHooks(fastifyOptions.preSerialization)
237
+ ];
238
+ }
239
+ this.fastify.route({ ...route, ...fastifyOptions });
217
240
  }
218
241
  /**
219
242
  * Add a new GET route to the http server.
@@ -257,4 +280,91 @@ export class ServerImpl extends Macroable {
257
280
  options(options) {
258
281
  this.route({ ...options, methods: ['OPTIONS'] });
259
282
  }
283
+ toRouteHooks(hooks) {
284
+ if (!hooks) {
285
+ return [];
286
+ }
287
+ return Array.isArray(hooks) ? hooks : [hooks];
288
+ }
289
+ getFastifyOptionsWithOpenApiSchema(options) {
290
+ const automaticSchema = this.getOpenApiRouteSchema(options);
291
+ const fastifyOptions = { ...options.fastify };
292
+ if (!automaticSchema) {
293
+ return fastifyOptions;
294
+ }
295
+ const normalizedSchema = normalizeRouteSchema(automaticSchema);
296
+ const currentConfig = { ...(fastifyOptions.config || {}) };
297
+ fastifyOptions.schema = this.mergeFastifySchemas(normalizedSchema.schema, fastifyOptions.schema);
298
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
299
+ // @ts-ignore
300
+ const currentZod = currentConfig.zod;
301
+ const mergedZod = this.mergeZodSchemas(normalizedSchema.zod, currentZod);
302
+ if (mergedZod) {
303
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
304
+ // @ts-ignore
305
+ currentConfig.zod = mergedZod;
306
+ }
307
+ fastifyOptions.config = currentConfig;
308
+ return fastifyOptions;
309
+ }
310
+ getOpenApiRouteSchema(options) {
311
+ const paths = Config.get('openapi.paths', {});
312
+ const methods = options.methods || [];
313
+ if (!Is.Object(paths) || !options.url || !methods.length) {
314
+ return null;
315
+ }
316
+ const candidates = this.getOpenApiPathCandidates(options.url);
317
+ for (const candidate of candidates) {
318
+ const pathConfig = paths[candidate];
319
+ if (!Is.Object(pathConfig)) {
320
+ continue;
321
+ }
322
+ for (const method of methods) {
323
+ const methodConfig = pathConfig[method.toLowerCase()];
324
+ if (Is.Object(methodConfig)) {
325
+ return methodConfig;
326
+ }
327
+ }
328
+ }
329
+ return null;
330
+ }
331
+ getOpenApiPathCandidates(url) {
332
+ const normalized = this.normalizePath(url);
333
+ const openApi = normalized.replace(/:([A-Za-z0-9_]+)/g, '{$1}');
334
+ return Array.from(new Set([normalized, openApi]));
335
+ }
336
+ normalizePath(url) {
337
+ if (url === '/') {
338
+ return url;
339
+ }
340
+ return `/${url.replace(/^\//, '').replace(/\/$/, '')}`;
341
+ }
342
+ mergeFastifySchemas(base, override) {
343
+ const merged = {
344
+ ...base,
345
+ ...override
346
+ };
347
+ if (base?.response || override?.response) {
348
+ merged.response = {
349
+ ...(base?.response || {}),
350
+ ...(override?.response || {})
351
+ };
352
+ }
353
+ return merged;
354
+ }
355
+ mergeZodSchemas(base, override) {
356
+ if (!base && !override) {
357
+ return null;
358
+ }
359
+ return {
360
+ request: {
361
+ ...(base?.request || {}),
362
+ ...(override?.request || {})
363
+ },
364
+ response: {
365
+ ...(base?.response || {}),
366
+ ...(override?.response || {})
367
+ }
368
+ };
369
+ }
260
370
  }