UNPKG

@athenna/http

Version:

The Athenna Http server. Built on top of fastify.

394 lines (393 loc) 12.6 kB
/** * @athenna/http * * (c) João Lenon <lenon@athenna.io> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import fastify from 'fastify'; import { parseRequestWithZod, normalizeRouteSchema } from '#src/router/RouteSchema'; import { Options, Macroable, Is } from '@athenna/common'; import { FastifyHandler } from '#src/handlers/FastifyHandler'; export class ServerImpl extends Macroable { constructor(options) { super(); this.fastify = fastify.fastify({ ...options, exposeHeadRoutes: false }); this.isListening = false; this.fastify.decorateReply('body', null); this.fastify.decorateRequest('data', null); } /** * Get the representation of the internal radix tree used by the * router. */ getRoutes(options) { return this.fastify.printRoutes(options); } /** * Get the address info of the server. This method will return the * port used to listen the server, the family (IPv4, IPv6) and the * server address (127.0.0.1). */ getAddressInfo() { return this.fastify.server.address(); } /** * Get the port where the server is running. */ getPort() { return this.getAddressInfo()?.port; } /** * Get the host where the server is running. */ getHost() { return this.getAddressInfo()?.address; } /** * Get the fastify version that is running the server. */ getFastifyVersion() { return this.fastify.version; } /** * Return the swagger documentation generated by routes if using the * @fastify/swagger plugin in the server. If the plugin is not installed * will return null. */ async getSwagger(options) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const swagger = this.fastify.swagger; if (!swagger) { return null; } await this.fastify.ready(); return swagger(options); } /** * Set the error handler to handle errors that happens inside the server. */ setErrorHandler(handler) { this.fastify.setErrorHandler(FastifyHandler.error(handler)); this.fastify.setNotFoundHandler(FastifyHandler.notFoundError(handler)); return this; } /** * Register a plugin inside the fastify server. */ async plugin(plugin, options) { await this.fastify.register(plugin, options); } /** * Create a middleware that will be executed before the request gets * inside the route handler. */ middleware(handler) { this.fastify.addHook('preHandler', FastifyHandler.handle(handler)); return this; } /** * Create an interceptor that will be executed before the response * is returned. At this point you can still make modifications in the * response. */ intercept(handler) { this.fastify.addHook('onSend', FastifyHandler.intercept(handler)); return this; } /** * Create and terminator that will be executed after the response * is returned. At this point you cannot make modifications in the * response. */ terminate(handler) { this.fastify.addHook('onResponse', FastifyHandler.terminate(handler)); return this; } /** * Return a request handler to make internal requests to the Http server. */ request(options) { if (!options) { return this.fastify.inject(); } return this.fastify.inject(options); } /** * Make the server start listening for requests. */ async listen(options) { return this.fastify.listen(options).then(() => (this.isListening = true)); } /** * Return the FastifyVite instance if it exists. */ getVitePlugin() { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore if (!this.fastify.vite) { return; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return this.fastify.vite; } /** * Return ViteDevServer instance if it exists. */ getViteDevServer() { const vite = this.getVitePlugin(); if (!vite) { return; } return vite.getServer(); } /** * Start vite server. */ async viteReady() { const vite = this.getVitePlugin(); if (!vite) { return; } return vite.ready(); } /** * Close the server, */ async close() { if (!this.isListening) { return; } await this.fastify.close().then(() => (this.isListening = false)); } /** * Add a new route to the http server. */ route(options) { if (!options.middlewares) { options.middlewares = {}; } options.middlewares = Options.create(options.middlewares, { middlewares: [], terminators: [], interceptors: [] }); if (options.methods.length === 2 && options.methods.includes('HEAD')) { this.route({ ...options, methods: ['GET'] }); this.route({ ...options, methods: ['HEAD'], fastify: { ...options.fastify, schema: { ...options.fastify.schema, hide: true } } }); return; } const { middlewares, interceptors, terminators } = options.middlewares; const fastifyOptions = this.getFastifyOptionsWithOpenApiSchema(options); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const zodSchemas = fastifyOptions?.config?.zod; const route = { onSend: [], preValidation: [], preHandler: [], onResponse: [], url: options.url, method: options.methods, handler: FastifyHandler.request(options.handler) }; if (middlewares.length) { route.preHandler = middlewares.map(m => FastifyHandler.handle(m)); } if (interceptors.length) { route.onSend = interceptors.map(i => FastifyHandler.intercept(i)); } if (terminators.length) { route.onResponse = terminators.map(t => FastifyHandler.terminate(t)); } if (zodSchemas) { route.preValidation = [async (req) => parseRequestWithZod(req, zodSchemas)]; } if (options.data && Is.Array(route.preHandler)) { route.preHandler?.unshift((req, _, done) => { req.data = { ...options.data, ...req.data }; done(); }); } if (zodSchemas) { fastifyOptions.preValidation = [ ...this.toRouteHooks(route.preValidation), ...this.toRouteHooks(fastifyOptions.preValidation) ]; } this.fastify.route({ ...route, ...fastifyOptions }); } /** * Add a new GET route to the http server. */ get(options) { this.route({ ...options, methods: ['GET'] }); } /** * Add a new HEAD route to the http server. */ head(options) { this.route({ ...options, methods: ['HEAD'] }); } /** * Add a new POST route to the http server. */ post(options) { this.route({ ...options, methods: ['POST'] }); } /** * Add a new PUT route to the http server. */ put(options) { this.route({ ...options, methods: ['PUT'] }); } /** * Add a new PATCH route to the http server. */ patch(options) { this.route({ ...options, methods: ['PATCH'] }); } /** * Add a new DELETE route to the http server. */ delete(options) { this.route({ ...options, methods: ['DELETE'] }); } /** * Add a new OPTIONS route to the http server. */ options(options) { this.route({ ...options, methods: ['OPTIONS'] }); } toRouteHooks(hooks) { if (!hooks) { return []; } return Array.isArray(hooks) ? hooks : [hooks]; } getFastifyOptionsWithOpenApiSchema(options) { const automaticSchema = this.getOpenApiRouteSchema(options); const fastifyOptions = { ...options.fastify }; if (!automaticSchema) { this.configureSwaggerTransform(fastifyOptions); return fastifyOptions; } const normalizedSchema = normalizeRouteSchema(automaticSchema); const currentConfig = { ...(fastifyOptions.config || {}) }; const currentSwaggerSchema = // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore currentConfig.swaggerSchema || fastifyOptions.schema; fastifyOptions.schema = this.mergeFastifySchemas(normalizedSchema.schema, fastifyOptions.schema); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore currentConfig.swaggerSchema = this.mergeFastifySchemas(normalizedSchema.swaggerSchema, currentSwaggerSchema); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const currentZod = currentConfig.zod; const mergedZod = this.mergeZodSchemas(normalizedSchema.zod, currentZod); if (mergedZod) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore currentConfig.zod = mergedZod; } fastifyOptions.config = currentConfig; this.configureSwaggerTransform(fastifyOptions); return fastifyOptions; } configureSwaggerTransform(fastifyOptions) { const config = fastifyOptions?.config; if (!config?.swaggerSchema) { return; } const customTransform = config.swaggerTransform; if (customTransform === false) { return; } config.swaggerTransform = (args) => { const transformed = Is.Function(customTransform) ? customTransform(args) : args; if (transformed === false) { return false; } return { ...transformed, schema: this.mergeFastifySchemas(transformed?.schema || args.schema, config.swaggerSchema) }; }; } getOpenApiRouteSchema(options) { const paths = Config.get('openapi.paths', {}); const methods = options.methods || []; if (!Is.Object(paths) || !options.url || !methods.length) { return null; } const candidates = this.getOpenApiPathCandidates(options.url); for (const candidate of candidates) { const pathConfig = paths[candidate]; if (!Is.Object(pathConfig)) { continue; } for (const method of methods) { const methodConfig = pathConfig[method.toLowerCase()]; if (Is.Object(methodConfig)) { return methodConfig; } } } return null; } getOpenApiPathCandidates(url) { const normalized = this.normalizePath(url); const openApi = normalized.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); return Array.from(new Set([normalized, openApi])); } normalizePath(url) { if (url === '/') { return url; } return `/${url.replace(/^\//, '').replace(/\/$/, '')}`; } mergeFastifySchemas(base, override) { const merged = { ...base, ...override }; if (base?.response || override?.response) { merged.response = { ...(base?.response || {}), ...(override?.response || {}) }; } return merged; } mergeZodSchemas(base, override) { if (!base && !override) { return null; } return { request: { ...(base?.request || {}), ...(override?.request || {}) }, response: { ...(base?.response || {}), ...(override?.response || {}) } }; } }