@athenna/http
Version:
The Athenna Http server. Built on top of fastify.
394 lines (393 loc) • 12.6 kB
JavaScript
/**
* @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 || {})
}
};
}
}