diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml index 29f7a0a..b0c947f 100644 --- a/.github/workflows/node-ci.yml +++ b/.github/workflows/node-ci.yml @@ -68,6 +68,12 @@ jobs: working-directory: "." run: npm run test + - name: Code Coverage Report - Base Fastify Server + uses: ./.github/actions/coverage-report + with: + package_name: "Base Fastify Server" + filename: "./libs/base-fastify-server/coverage/cobertura-coverage.xml" + - name: Code Coverage Report - Session Manager uses: ./.github/actions/coverage-report with: diff --git a/apps/session-manager/Dockerfile b/apps/session-manager/Dockerfile index a5954ff..1f3ea09 100644 --- a/apps/session-manager/Dockerfile +++ b/apps/session-manager/Dockerfile @@ -13,6 +13,10 @@ COPY package*.json . ARG SRC="session-manager" COPY apps/${SRC}/package*.json apps/${SRC}/ +ARG LIB="base-fastify-server" +COPY libs/${LIB}/package*.json libs/${LIB}/ +ARG LIB="base-schema" +COPY libs/${LIB}/package*.json libs/${LIB}/ ARG LIB="session-manager-schema" COPY libs/${LIB}/package*.json libs/${LIB}/ @@ -23,6 +27,10 @@ COPY tsconfig.base.json . ARG SRC="session-manager" COPY apps/${SRC} apps/${SRC} +ARG LIB="base-fastify-server" +COPY libs/${LIB} libs/${LIB} +ARG LIB="base-schema" +COPY libs/${LIB} libs/${LIB} ARG LIB="session-manager-schema" COPY libs/${LIB} libs/${LIB} @@ -39,6 +47,10 @@ COPY package*.json . ARG SRC="session-manager" COPY apps/${SRC}/package*.json apps/${SRC}/ +ARG LIB="base-fastify-server" +COPY libs/${LIB}/package*.json libs/${LIB}/ +ARG LIB="base-schema" +COPY libs/${LIB}/package*.json libs/${LIB}/ ARG LIB="session-manager-schema" COPY libs/${LIB}/package*.json libs/${LIB}/ @@ -47,9 +59,20 @@ RUN npm ci --omit=dev # Copy the build artifacts from build-env container ARG SRC="session-manager" COPY --from=build-env /app/apps/${SRC}/dist/src/ /app/apps/${SRC}/dist/src/ +ARG LIB="base-fastify-server" +COPY --from=build-env /app/libs/${LIB}/dist/src/ /app/libs/${LIB}/dist/src/ +ARG LIB="base-schema" +COPY --from=build-env /app/libs/${LIB}/dist/src/ /app/libs/${LIB}/dist/src/ ARG LIB="session-manager-schema" COPY --from=build-env /app/libs/${LIB}/dist/src/ /app/libs/${LIB}/dist/src/ WORKDIR /app/apps/session-manager +# Ensure server listens on a defined interface and port inside container +# This docker can map this to a real port on the host +ENV HOST=0.0.0.0 +ENV PORT=80 + +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --start-interval=1s --retries=3 CMD curl -f http://localhost:80/healthcheck || exit 1 + CMD ["npm", "start"] diff --git a/apps/session-manager/package.json b/apps/session-manager/package.json index 8c96086..4ae4dbf 100644 --- a/apps/session-manager/package.json +++ b/apps/session-manager/package.json @@ -8,13 +8,21 @@ "scripts": { "build": "tsc --build", "start": "node ./dist/src/index.js", - "start:dev": "node ./dist/src/index.js --dev", - "dev": "nodemon --exec 'npm run build && npm run start:dev' --watch src --watch ../../libs/session-manager-schema/src src/index.ts", + "start:dev": "node ./dist/src/index.js --dev | pino-pretty", + "dev": "nodemon --exec 'npm run build && npm run start:dev' --watch src --watch ../../libs/base-fastify-server/src --watch ../../libs/base-schema/src --watch ../../libs/session-manager-schema/src src/index.ts", "format": "prettier --check ./src ./tests", "format:fix": "prettier --write ./src ./tests", "lint": "eslint ./src ./tests", "lint:fix": "eslint ./src ./tests --fix", "test:dev": "vitest --ui --coverage", "test": "vitest run" + }, + "dependencies": { + "@fastify/awilix": "8.0.0", + "@fastify/swagger": "9.5.2", + "@fastify/swagger-ui": "5.2.3", + "awilix": "12.0.5", + "env-schema": "6.1.0", + "typebox": "1.0.41" } } diff --git a/apps/session-manager/src/app_config/app_config.ts b/apps/session-manager/src/app_config/app_config.ts new file mode 100644 index 0000000..28810c8 --- /dev/null +++ b/apps/session-manager/src/app_config/app_config.ts @@ -0,0 +1,52 @@ +import envSchema from 'env-schema'; +import { Type } from 'typebox'; +import type { Static } from 'typebox'; + +import { LogLevel } from '@scribear/base-fastify-server'; + +const CONFIG_SCHEMA = Type.Object({ + LOG_LEVEL: Type.Enum(LogLevel), + PORT: Type.Integer({ minimum: 0, maximum: 65_535 }), + HOST: Type.String(), +}); + +/** + * Class that loads and provides application configuration + */ +class AppConfig { + private _isDevelopment: boolean; + private _logLevel: LogLevel; + private _port: number; + private _host: string; + + get isDevelopment() { + return this._isDevelopment; + } + + get logLevel() { + return this._logLevel; + } + + get port() { + return this._port; + } + + get host() { + return this._host; + } + + constructor(path?: string) { + this._isDevelopment = process.argv.includes('--dev'); + + const env = envSchema>({ + dotenv: path ? { path, quiet: true } : { quiet: true }, + schema: CONFIG_SCHEMA, + }); + + this._logLevel = env.LOG_LEVEL; + this._port = env.PORT; + this._host = env.HOST; + } +} + +export default AppConfig; diff --git a/apps/session-manager/src/index.ts b/apps/session-manager/src/index.ts index 4e8fdc7..b6289ed 100644 --- a/apps/session-manager/src/index.ts +++ b/apps/session-manager/src/index.ts @@ -1,3 +1,31 @@ -import '@scribear/session-manager-schema'; +import AppConfig from './app_config/app_config.js'; +import createServer from './server/create_server.js'; -console.log('Hello from session-manager!'); +/** + * Main entrypoint for session manager server + */ +async function main() { + const config = new AppConfig(); + const { logger, fastify } = await createServer(config); + + // Handle uncaught exceptions and rejections + process.on('uncaughtException', (err) => { + logger.fatal({ msg: 'Uncaught exception', err }); + throw err; // terminate on uncaught errors + }); + + process.on('unhandledRejection', (reason) => { + const err = Error('Unhandled rejection', { cause: reason }); + logger.fatal({ msg: 'Unhandled rejection', err }); + throw err; // terminate on uncaught rejection + }); + + try { + await fastify.listen({ port: config.port, host: config.host }); + } catch (err) { + logger.fatal({ msg: 'Failed to start fastify webserver', err }); + throw err; // terminate if failed to start + } +} + +await main(); diff --git a/apps/session-manager/src/server/create_server.ts b/apps/session-manager/src/server/create_server.ts new file mode 100644 index 0000000..6c40682 --- /dev/null +++ b/apps/session-manager/src/server/create_server.ts @@ -0,0 +1,33 @@ +import { createBaseServer } from '@scribear/base-fastify-server'; + +import type AppConfig from '../app_config/app_config.js'; +import registerDependencies from './dependency_injection/register_dependencies.js'; +import calculatorRouter from './features/calculator/calculator.router.js'; +import healthcheckRouter from './features/healthcheck/healthcheck.router.js'; +import swagger from './plugins/swagger.js'; + +/** + * Initializes fastify server and registers dependencies + * @param config Application config + * @returns Initialized fastify server + */ +async function createServer(config: AppConfig) { + const { logger, dependencyContainer, fastify } = createBaseServer( + config.logLevel, + ); + + // Only include swagger docs if in development mode + if (config.isDevelopment) { + await fastify.register(swagger); + } + + registerDependencies(dependencyContainer, config); + + // Register routes + fastify.register(healthcheckRouter); + fastify.register(calculatorRouter); + + return { logger, fastify }; +} + +export default createServer; diff --git a/apps/session-manager/src/server/dependency_injection/register_dependencies.ts b/apps/session-manager/src/server/dependency_injection/register_dependencies.ts new file mode 100644 index 0000000..532b963 --- /dev/null +++ b/apps/session-manager/src/server/dependency_injection/register_dependencies.ts @@ -0,0 +1,67 @@ +// Need to import so that declare module '@fastify/awilix' below works +import '@fastify/awilix'; +import { type AwilixContainer, Lifetime, asClass, asValue } from 'awilix'; + +import type { BaseDependencies } from '@scribear/base-fastify-server'; + +import type AppConfig from '../../app_config/app_config.js'; +import CalculatorController from '../features/calculator/calculator.controller.js'; +import CalculatorService from '../features/calculator/calculator.service.js'; +import HealthcheckController from '../features/healthcheck/healthcheck.controller.js'; + +/** + * Define types for entities in dependency container + */ +interface AppDependencies extends BaseDependencies { + config: AppConfig; + + // Healthcheck + healthcheckController: HealthcheckController; + + // Calculator + calculatorController: CalculatorController; + calculatorService: CalculatorService; +} + +/** + * Ensure fastify awilix container is typed correctly + * @see https://github.com/fastify/fastify-awilix?tab=readme-ov-file#typescript-usage + */ +declare module '@fastify/awilix' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Cradle extends AppDependencies {} + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface RequestCradle extends AppDependencies {} +} + +/** + * Register all controller, service, and repository classes into dependency container + * @param dependencyContainer Container to load dependencies into + * @param config AppConfig to be registered into dependency controller + */ +function registerDependencies( + dependencyContainer: AwilixContainer, + config: AppConfig, +) { + dependencyContainer.register({ + // Config + config: asValue(config), + + // Healthcheck + healthcheckController: asClass(HealthcheckController, { + lifetime: Lifetime.SCOPED, + }), + + // Calculator + calculatorController: asClass(CalculatorController, { + lifetime: Lifetime.SCOPED, + }), + calculatorService: asClass(CalculatorService, { + lifetime: Lifetime.SCOPED, + }), + }); +} + +export default registerDependencies; +export type { AppDependencies }; diff --git a/apps/session-manager/src/server/dependency_injection/resolve_handler.ts b/apps/session-manager/src/server/dependency_injection/resolve_handler.ts new file mode 100644 index 0000000..abc1bc2 --- /dev/null +++ b/apps/session-manager/src/server/dependency_injection/resolve_handler.ts @@ -0,0 +1,44 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; + +import type { AppDependencies } from './register_dependencies.js'; + +/** + * Creates a wrapper function around provided controller route handler + * Wrapper function resolves controller from request dependency container scope + * and passes request/reply to handler + * @param controller Name of controller to resolve + * @param method Name of method on controller to call + * @returns Wrapped controller method + */ +function resolveHandler< + C extends keyof AppDependencies, + M extends keyof AppDependencies[C], +>(controller: C, method: M): AppDependencies[C][M] { + const wrapper = async (req: FastifyRequest, res: FastifyReply) => { + const routeController = req.diScope.resolve( + controller, + ) as AppDependencies[C]; + + // Throw exception if method is not a function + if ( + !(method in routeController) || + typeof routeController[method] !== 'function' + ) { + throw new Error( + `Failed to resolve handler: Property '${String(method)}' on controller '${controller}' is not a function.`, + ); + } + + const handler = routeController[method].bind(routeController) as ( + req: FastifyRequest, + res: FastifyReply, + ) => unknown; + + return await handler(req, res); + }; + + // Cast the type of the wrapper to be the same as the wrapped handler + return wrapper as AppDependencies[C][M]; +} + +export default resolveHandler; diff --git a/apps/session-manager/src/server/features/calculator/calculator.controller.ts b/apps/session-manager/src/server/features/calculator/calculator.controller.ts new file mode 100644 index 0000000..0541ead --- /dev/null +++ b/apps/session-manager/src/server/features/calculator/calculator.controller.ts @@ -0,0 +1,43 @@ +import type { + BaseFastifyReply, + BaseFastifyRequest, +} from '@scribear/base-fastify-server'; +import { + COMPUTE_BINOMIAL_SCHEMA, + type COMPUTE_MONOMIAL_SCHEMA, +} from '@scribear/session-manager-schema'; + +import type { AppDependencies } from '../../dependency_injection/register_dependencies.js'; +import type CalculatorService from './calculator.service.js'; + +class CalculatorController { + private _calculatorService: CalculatorService; + + constructor(calculatorService: AppDependencies['calculatorService']) { + this._calculatorService = calculatorService; + } + + binomial( + req: BaseFastifyRequest, + res: BaseFastifyReply, + ) { + const { a, b, op } = req.body; + + const result = this._calculatorService.binomial(a, b, op); + + res.code(200).send({ result, reqId: req.id }); + } + + monomial( + req: BaseFastifyRequest, + res: BaseFastifyReply, + ) { + const { a, op } = req.body; + + const result = this._calculatorService.monomial(a, op); + + res.code(200).send({ result, reqId: req.id }); + } +} + +export default CalculatorController; diff --git a/apps/session-manager/src/server/features/calculator/calculator.router.ts b/apps/session-manager/src/server/features/calculator/calculator.router.ts new file mode 100644 index 0000000..e1d27f8 --- /dev/null +++ b/apps/session-manager/src/server/features/calculator/calculator.router.ts @@ -0,0 +1,29 @@ +import type { BaseFastifyInstance } from '@scribear/base-fastify-server'; +import { + COMPUTE_BINOMIAL_ROUTE, + COMPUTE_BINOMIAL_SCHEMA, + COMPUTE_MONOMIAL_ROUTE, + COMPUTE_MONOMIAL_SCHEMA, +} from '@scribear/session-manager-schema'; + +import resolveHandler from '../../dependency_injection/resolve_handler.js'; + +/** + * Registers calculator demo routes + * @param fastify Fastify app instance + */ +function calculatorRouter(fastify: BaseFastifyInstance) { + fastify.route({ + ...COMPUTE_BINOMIAL_ROUTE, + schema: COMPUTE_BINOMIAL_SCHEMA, + handler: resolveHandler('calculatorController', 'binomial'), + }); + + fastify.route({ + ...COMPUTE_MONOMIAL_ROUTE, + schema: COMPUTE_MONOMIAL_SCHEMA, + handler: resolveHandler('calculatorController', 'monomial'), + }); +} + +export default calculatorRouter; diff --git a/apps/session-manager/src/server/features/calculator/calculator.service.ts b/apps/session-manager/src/server/features/calculator/calculator.service.ts new file mode 100644 index 0000000..26c4cf1 --- /dev/null +++ b/apps/session-manager/src/server/features/calculator/calculator.service.ts @@ -0,0 +1,35 @@ +import type { BaseLogger } from '@scribear/base-fastify-server'; + +import type { AppDependencies } from '../../dependency_injection/register_dependencies.js'; + +class CalculatorService { + private _log: BaseLogger; + + constructor(logger: AppDependencies['logger']) { + this._log = logger; + } + + binomial(a: number, b: number, op: '+' | '-') { + this._log.info( + `Performing bionomial operation: ${a.toString()} ${op} ${b.toString()}`, + ); + + if (op === '+') { + return a + b; + } else { + return a - b; + } + } + + monomial(a: number, op: 'square' | 'cube') { + this._log.info(`Performing monomial operation: ${op} ${a.toString()}`); + + if (op === 'square') { + return a * a; + } else { + return a * a * a; + } + } +} + +export default CalculatorService; diff --git a/apps/session-manager/src/server/features/healthcheck/healthcheck.controller.ts b/apps/session-manager/src/server/features/healthcheck/healthcheck.controller.ts new file mode 100644 index 0000000..ece0c99 --- /dev/null +++ b/apps/session-manager/src/server/features/healthcheck/healthcheck.controller.ts @@ -0,0 +1,16 @@ +import type { + BaseFastifyReply, + BaseFastifyRequest, +} from '@scribear/base-fastify-server'; +import { HEALTHCHECK_SCHEMA } from '@scribear/session-manager-schema'; + +class HealthcheckController { + healthcheck( + req: BaseFastifyRequest, + res: BaseFastifyReply, + ) { + res.code(200).send({ reqId: req.id }); + } +} + +export default HealthcheckController; diff --git a/apps/session-manager/src/server/features/healthcheck/healthcheck.router.ts b/apps/session-manager/src/server/features/healthcheck/healthcheck.router.ts new file mode 100644 index 0000000..4c373b0 --- /dev/null +++ b/apps/session-manager/src/server/features/healthcheck/healthcheck.router.ts @@ -0,0 +1,21 @@ +import type { BaseFastifyInstance } from '@scribear/base-fastify-server'; +import { + HEALTHCHECK_ROUTE, + HEALTHCHECK_SCHEMA, +} from '@scribear/session-manager-schema'; + +import resolveHandler from '../../dependency_injection/resolve_handler.js'; + +/** + * Registers healthcheck routes + * @param fastify Fastify app instance + */ +function healthcheckRouter(fastify: BaseFastifyInstance) { + fastify.route({ + ...HEALTHCHECK_ROUTE, + schema: HEALTHCHECK_SCHEMA, + handler: resolveHandler('healthcheckController', 'healthcheck'), + }); +} + +export default healthcheckRouter; diff --git a/apps/session-manager/src/server/plugins/swagger.ts b/apps/session-manager/src/server/plugins/swagger.ts new file mode 100644 index 0000000..b18c4d2 --- /dev/null +++ b/apps/session-manager/src/server/plugins/swagger.ts @@ -0,0 +1,34 @@ +import Swagger from '@fastify/swagger'; +import SwaggerUI from '@fastify/swagger-ui'; +import fastifyPlugin from 'fastify-plugin'; + +import type { BaseFastifyInstance } from '@scribear/base-fastify-server'; + +/** + * Registers Swagger and Swagger UI to generate API documentation + */ +export default fastifyPlugin(async (fastify: BaseFastifyInstance) => { + await fastify.register(Swagger, { + openapi: { + openapi: '3.1.0', + info: { + title: 'Session Manager API', + description: 'The Swagger API documentation for Session Manager API.', + version: '0.0.0', + }, + tags: [ + { + name: 'Healthcheck', + description: 'Server health probe endpoint', + }, + { name: 'Calculator', description: 'Server demo endpoints' }, + ], + }, + }); + + await fastify.register(SwaggerUI, { + routePrefix: '/api-docs', + }); + + fastify.log.info('Swagger documentation is available at /api-docs'); +}); diff --git a/apps/session-manager/template.env b/apps/session-manager/template.env new file mode 100644 index 0000000..5a56c7c --- /dev/null +++ b/apps/session-manager/template.env @@ -0,0 +1,6 @@ +#### Log level to use +LOG_LEVEL=info + +#### Host and port server should listen on +HOST=0.0.0.0 +PORT=8000 diff --git a/apps/session-manager/tests/integration/calculator/binomial.test.ts b/apps/session-manager/tests/integration/calculator/binomial.test.ts new file mode 100644 index 0000000..be56c8f --- /dev/null +++ b/apps/session-manager/tests/integration/calculator/binomial.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect } from 'vitest'; +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import { + type BaseFastifyInstance, + LogLevel, +} from '@scribear/base-fastify-server'; + +import AppConfig from '../../../src/app_config/app_config.js'; +import createServer from '../../../src/server/create_server.js'; + +describe('Integration Tests - /calculator/binomial', (it) => { + let fastify: BaseFastifyInstance; + let mockConfig: MockProxy; + + beforeEach(async () => { + mockConfig = mock({ + isDevelopment: false, + logLevel: LogLevel.SILENT, + }); + + const server = await createServer(mockConfig); + fastify = server.fastify; + }); + + /** + * Test that server correctly adds two numbers + */ + it('correctly adds two numbers', async () => { + // Arrange + const request = { a: 12, b: 34, op: '+' }; + const result = 46; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/binomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ result }); + }); + + /** + * Test that server correctly subtracts two numbers + */ + it('correctly subtracts two numbers', async () => { + // Arrange + const request = { a: 12, b: 34, op: '-' }; + const result = -22; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/binomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ result }); + }); + + /** + * Test that server rejects invalid operand + */ + it('rejects invalid operand', async () => { + // Arrange + const request = { a: 12, b: 34.5, op: '+' }; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/binomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + requestErrors: [ + { + key: '/body/b', + }, + ], + }); + }); + + /** + * Test that server rejects invalid operator + */ + it('rejects invalid operator', async () => { + // Arrange + const request = { a: 12, b: 34, op: '*' }; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/binomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(400); + }); +}); diff --git a/apps/session-manager/tests/integration/calculator/monomial.test.ts b/apps/session-manager/tests/integration/calculator/monomial.test.ts new file mode 100644 index 0000000..b88e966 --- /dev/null +++ b/apps/session-manager/tests/integration/calculator/monomial.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect } from 'vitest'; +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import { + type BaseFastifyInstance, + LogLevel, +} from '@scribear/base-fastify-server'; + +import AppConfig from '../../../src/app_config/app_config.js'; +import createServer from '../../../src/server/create_server.js'; + +describe('Integration Tests - /calculator/monomial', (it) => { + let fastify: BaseFastifyInstance; + let mockConfig: MockProxy; + + beforeEach(async () => { + mockConfig = mock({ + isDevelopment: false, + logLevel: LogLevel.SILENT, + }); + + const server = await createServer(mockConfig); + fastify = server.fastify; + }); + + /** + * Test that server correctly adds two numbers + */ + it('correctly adds two numbers', async () => { + // Arrange + const request = { a: 12, op: 'square' }; + const result = 144; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/monomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ result }); + }); + + /** + * Test that server correctly subtracts two numbers + */ + it('correctly subtracts two numbers', async () => { + // Arrange + const request = { a: 12, op: 'cube' }; + const result = 1728; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/monomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ result }); + }); + + /** + * Test that server rejects invalid operand + */ + it('rejects invalid operand', async () => { + // Arrange + const request = { a: 12.5, op: 'square' }; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/monomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + requestErrors: [ + { + key: '/body/a', + }, + ], + }); + }); + + /** + * Test that server rejects invalid operator + */ + it('rejects invalid operator', async () => { + // Arrange + const request = { a: 12, op: 'quad' }; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/calculator/monomial', + body: request, + }); + + // Assert + expect(response.statusCode).toBe(400); + }); +}); diff --git a/apps/session-manager/tests/integration/healthcheck/healthcheck.test.ts b/apps/session-manager/tests/integration/healthcheck/healthcheck.test.ts new file mode 100644 index 0000000..c2581e0 --- /dev/null +++ b/apps/session-manager/tests/integration/healthcheck/healthcheck.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect } from 'vitest'; +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import { + type BaseFastifyInstance, + LogLevel, +} from '@scribear/base-fastify-server'; + +import AppConfig from '../../../src/app_config/app_config.js'; +import createServer from '../../../src/server/create_server.js'; + +describe('Integration Tests - /healthcheck', (it) => { + let fastify: BaseFastifyInstance; + let mockConfig: MockProxy; + + beforeEach(async () => { + mockConfig = mock({ + isDevelopment: false, + logLevel: LogLevel.SILENT, + }); + + const server = await createServer(mockConfig); + fastify = server.fastify; + }); + + /** + * Test that server responds successfully on /healthcheck endpoint + */ + it('responds with 200 on /healthcheck', async () => { + // Arrange + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/healthcheck', + }); + + // Assert + expect(response.statusCode).toBe(200); + }); +}); diff --git a/apps/session-manager/tests/integration/swagger/api_docs.test.ts b/apps/session-manager/tests/integration/swagger/api_docs.test.ts new file mode 100644 index 0000000..4bb57b0 --- /dev/null +++ b/apps/session-manager/tests/integration/swagger/api_docs.test.ts @@ -0,0 +1,51 @@ +import { describe, expect } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import { LogLevel } from '@scribear/base-fastify-server'; + +import AppConfig from '../../../src/app_config/app_config.js'; +import createServer from '../../../src/server/create_server.js'; + +describe('Integration Tests - /api-docs', (it) => { + /** + * Test that server does not expose swagger /api-docs endpoint when not in development mode + */ + it('returns 404 on /api-docs when not in development mode', async () => { + // Arrange + const mockConfig = mock({ + isDevelopment: false, + logLevel: LogLevel.SILENT, + }); + const { fastify } = await createServer(mockConfig); + + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/api-docs', + }); + + // Assert + expect(response.statusCode).toBe(404); + }); + + /** + * Test that server does expose swagger /api-docs endpoint when in development mode + */ + it('returns 200 on /api-docs when in development mode', async () => { + // Arrange + const mockConfig = mock({ + isDevelopment: true, + logLevel: LogLevel.SILENT, + }); + const { fastify } = await createServer(mockConfig); + + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/api-docs', + }); + + // Assert + expect(response.statusCode).toBe(200); + }); +}); diff --git a/apps/session-manager/tests/unit/server/features/calculator/calculator.controller.test.ts b/apps/session-manager/tests/unit/server/features/calculator/calculator.controller.test.ts new file mode 100644 index 0000000..d4898e0 --- /dev/null +++ b/apps/session-manager/tests/unit/server/features/calculator/calculator.controller.test.ts @@ -0,0 +1,118 @@ +import { type Mock, beforeEach, describe, expect, vi } from 'vitest'; +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import type { + BaseFastifyReply, + BaseFastifyRequest, +} from '@scribear/base-fastify-server'; +import type { + COMPUTE_BINOMIAL_SCHEMA, + COMPUTE_MONOMIAL_SCHEMA, +} from '@scribear/session-manager-schema'; + +import CalculatorController from '../../../../../src/server/features/calculator/calculator.controller.js'; +import type CalculatorService from '../../../../../src/server/features/calculator/calculator.service.js'; + +describe('Calculator controller', () => { + const testRequestId = 'TEST_REQUEST_ID'; + let mockReply: { + code: Mock; + send: Mock; + }; + let mockCalculatorService: MockProxy; + let calculatorController: CalculatorController; + + beforeEach(() => { + mockReply = { + code: vi.fn().mockReturnThis(), + send: vi.fn(), + }; + + mockCalculatorService = mock(); + calculatorController = new CalculatorController(mockCalculatorService); + }); + + describe('binomial handler', (it) => { + /** + * Test that binomial handler correctly calls calculatorService and replies with result + */ + it('calls calculator service correctly and replies with result', () => { + // Arrange + const result = 46; + mockCalculatorService.binomial.mockReturnValue(result); + const mockReq = { + id: testRequestId, + body: { + a: 12, + b: 34, + op: '+', + }, + }; + + // Act + calculatorController.binomial( + mockReq as unknown as BaseFastifyRequest< + typeof COMPUTE_BINOMIAL_SCHEMA + >, + mockReply as unknown as BaseFastifyReply< + typeof COMPUTE_BINOMIAL_SCHEMA + >, + ); + + // Assert + // ignore linter error caused by vitest-mock-extended + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockCalculatorService.binomial).toHaveBeenCalledExactlyOnceWith( + mockReq.body.a, + mockReq.body.b, + mockReq.body.op, + ); + expect(mockReply.code).toHaveBeenCalledExactlyOnceWith(200); + expect(mockReply.send).toHaveBeenCalledExactlyOnceWith({ + result, + reqId: testRequestId, + }); + }); + }); + + describe('monomial handler', (it) => { + /** + * Test that monomial handler correctly calls calculatorService and replies with result + */ + it('calls calculator service corrently and replies with result', () => { + // Arrange + const result = 144; + mockCalculatorService.monomial.mockReturnValue(result); + const mockReq = { + id: testRequestId, + body: { + a: 12, + op: 'square', + }, + }; + + // Act + calculatorController.monomial( + mockReq as unknown as BaseFastifyRequest< + typeof COMPUTE_MONOMIAL_SCHEMA + >, + mockReply as unknown as BaseFastifyReply< + typeof COMPUTE_MONOMIAL_SCHEMA + >, + ); + + // Assert + // ignore linter error caused by vitest-mock-extended + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockCalculatorService.monomial).toHaveBeenCalledExactlyOnceWith( + mockReq.body.a, + mockReq.body.op, + ); + expect(mockReply.code).toHaveBeenCalledExactlyOnceWith(200); + expect(mockReply.send).toHaveBeenCalledExactlyOnceWith({ + result, + reqId: testRequestId, + }); + }); + }); +}); diff --git a/apps/session-manager/tests/unit/server/features/calculator/calculator.service.test.ts b/apps/session-manager/tests/unit/server/features/calculator/calculator.service.test.ts new file mode 100644 index 0000000..93d5be7 --- /dev/null +++ b/apps/session-manager/tests/unit/server/features/calculator/calculator.service.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect } from 'vitest'; +import { type MockProxy, mock } from 'vitest-mock-extended'; + +import type { BaseLogger } from '@scribear/base-fastify-server'; + +import CalculatorService from '../../../../../src/server/features/calculator/calculator.service.js'; + +describe('CalculatorService', () => { + let calculatorService: CalculatorService; + let mockLogger: MockProxy; + + beforeEach(() => { + mockLogger = mock(); + calculatorService = new CalculatorService(mockLogger); + }); + + describe('binomial', (it) => { + /** + * Test that calculator adds two numbers correctly + */ + it('correctly adds two numbers', () => { + // Arrange + const a = 10; + const b = 5; + const op = '+'; + + // Act + const result = calculatorService.binomial(a, b, op); + + // Assert + expect(result).toBe(15); + }); + + /** + * Test that calculator subtracts two numbers correctly + */ + it('correctly subtracts two numbers', () => { + // Arrange + const a = 10; + const b = 5; + const op = '-'; + + // Act + const result = calculatorService.binomial(a, b, op); + + // Assert + expect(result).toBe(5); + }); + }); + + describe('monomial', (it) => { + /** + * Test that calculator squares a number correctly + */ + it('correctly squares a number', () => { + // Arrange + const a = 5; + const op = 'square'; + + // Act + const result = calculatorService.monomial(a, op); + + // Assert + expect(result).toBe(25); + }); + + /** + * Test that calculator cubes a number correctly + */ + it('correctly cubes a number', () => { + // Arrange + const a = 3; + const op = 'cube'; + + // Act + const result = calculatorService.monomial(a, op); + + // Assert + expect(result).toBe(27); + }); + }); +}); diff --git a/apps/session-manager/tests/unit/server/features/healthcheck/healthcheck.controller.test.ts b/apps/session-manager/tests/unit/server/features/healthcheck/healthcheck.controller.test.ts new file mode 100644 index 0000000..cf8367d --- /dev/null +++ b/apps/session-manager/tests/unit/server/features/healthcheck/healthcheck.controller.test.ts @@ -0,0 +1,45 @@ +import { type Mock, beforeEach, describe, expect, vi } from 'vitest'; + +import type { + BaseFastifyReply, + BaseFastifyRequest, +} from '@scribear/base-fastify-server'; +import type { HEALTHCHECK_SCHEMA } from '@scribear/session-manager-schema'; + +import HealthcheckController from '../../../../../src/server/features/healthcheck/healthcheck.controller.js'; + +describe('Healthcheck controller', (it) => { + const testRequestId = 'TEST_REQUEST_ID'; + let mockReply: { + send: Mock; + code: Mock; + }; + + let healthcheckController: HealthcheckController; + + beforeEach(() => { + mockReply = { + send: vi.fn(), + code: vi.fn().mockReturnThis(), + }; + + healthcheckController = new HealthcheckController(); + }); + + it('responds with request id', () => { + // Arrange + const mockReq = { id: testRequestId }; + + // Act + healthcheckController.healthcheck( + mockReq as unknown as BaseFastifyRequest, + mockReply as unknown as BaseFastifyReply, + ); + + // Assert + expect(mockReply.code).toHaveBeenCalledExactlyOnceWith(200); + expect(mockReply.send).toHaveBeenCalledExactlyOnceWith({ + reqId: testRequestId, + }); + }); +}); diff --git a/apps/session-manager/tsconfig.json b/apps/session-manager/tsconfig.json index 1fc4162..3475fc4 100644 --- a/apps/session-manager/tsconfig.json +++ b/apps/session-manager/tsconfig.json @@ -6,10 +6,13 @@ }, "include": [ "src", - "tests" + "tests", ], // Link to dependencies "references": [ + { + "path": "../../libs/base-fastify-server" + }, { "path": "../../libs/session-manager-schema" } diff --git a/libs/base-fastify-server/package.json b/libs/base-fastify-server/package.json new file mode 100644 index 0000000..f11c8a2 --- /dev/null +++ b/libs/base-fastify-server/package.json @@ -0,0 +1,29 @@ +{ + "name": "@scribear/base-fastify-server", + "version": "0.0.0", + "description": "", + "author": "scribear", + "main": "dist/src/index.js", + "type": "module", + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "format": "prettier --check ./src ./tests", + "format:fix": "prettier --write ./src ./tests", + "lint": "eslint ./src ./tests", + "lint:fix": "eslint ./src ./tests --fix", + "test:dev": "vitest --ui --coverage", + "test": "vitest run" + }, + "dependencies": { + "@fastify/awilix": "8.0.0", + "@fastify/helmet": "13.0.2", + "@fastify/sensible": "6.0.3", + "@fastify/type-provider-typebox": "6.1.0", + "awilix": "12.0.5", + "fastify": "5.6.1", + "fastify-plugin": "5.1.0", + "pino": "10.1.0", + "uuid": "13.0.0" + } +} diff --git a/libs/base-fastify-server/src/index.ts b/libs/base-fastify-server/src/index.ts new file mode 100644 index 0000000..151d7d9 --- /dev/null +++ b/libs/base-fastify-server/src/index.ts @@ -0,0 +1,19 @@ +import createBaseServer from './server/create_base_server.js'; +import type { BaseLogger } from './server/create_logger.js'; +import { LogLevel } from './server/create_logger.js'; +import { BaseHttpError, HttpError } from './server/errors/http_errors.js'; +import type { BaseDependencies } from './server/types/base_dependencies.js'; +import type { + BaseFastifyInstance, + BaseFastifyReply, + BaseFastifyRequest, +} from './server/types/base_fastify_types.js'; + +export { createBaseServer, LogLevel, BaseHttpError, HttpError }; +export type { + BaseLogger, + BaseDependencies, + BaseFastifyInstance, + BaseFastifyReply, + BaseFastifyRequest, +}; diff --git a/libs/base-fastify-server/src/server/create_base_server.ts b/libs/base-fastify-server/src/server/create_base_server.ts new file mode 100644 index 0000000..94d23f2 --- /dev/null +++ b/libs/base-fastify-server/src/server/create_base_server.ts @@ -0,0 +1,79 @@ +import { fastifyAwilixPlugin } from '@fastify/awilix'; +import fastifyHelmet from '@fastify/helmet'; +import { fastifySensible } from '@fastify/sensible'; +import { + type AwilixContainer, + InjectionMode, + asValue, + createContainer, +} from 'awilix'; +import Fastify, { type FastifyServerOptions } from 'fastify'; +import { v4 as uuidv4 } from 'uuid'; + +import type { BaseLogger, LogLevel } from './create_logger.js'; +import { createLogger } from './create_logger.js'; +import scopeLogger from './hooks/on_request/scope_logger.js'; +import errorHandler from './plugins/error_handler.js'; +import jsonParser from './plugins/json_parser.js'; +import notFoundHandler from './plugins/not_found_handler.js'; +import schemaValidator from './plugins/schema_validator.js'; +import type { BaseDependencies } from './types/base_dependencies.js'; +import type { BaseFastifyInstance } from './types/base_fastify_types.js'; + +/** + * Creates fastify server, logger, dependency container and loads default plugins and hooks + * @param logLevel Minimum log severity level for created logger + * @param fastifyConfig Additional options for fastify server + * @returns object containing fastify server, logger, and dependency container + */ +function createBaseServer( + logLevel: LogLevel, + fastifyConfig?: FastifyServerOptions, +): { + logger: BaseLogger; + dependencyContainer: AwilixContainer; + fastify: BaseFastifyInstance; +} { + const logger = createLogger(logLevel); + + const dependencyContainer: AwilixContainer = + createContainer({ + injectionMode: InjectionMode.CLASSIC, + strict: true, + }); + dependencyContainer.register({ logger: asValue(logger) }); + + const fastify = Fastify({ + loggerInstance: logger, + ...fastifyConfig, + }); + + fastify.register(fastifyAwilixPlugin, { + container: dependencyContainer, + disposeOnClose: true, + disposeOnResponse: true, + strictBooleanEnforced: true, + }); + + // Use UUIDv4 for request ids + fastify.setGenReqId(() => uuidv4()); + + // Register plugins + fastify.register(fastifySensible); + fastify.register(fastifyHelmet); + fastify.register(errorHandler); + fastify.register(jsonParser); + fastify.register(notFoundHandler); + fastify.register(schemaValidator); + + // Register hooks + fastify.register(scopeLogger); + + return { + logger, + dependencyContainer, + fastify: fastify as BaseFastifyInstance, + }; +} + +export default createBaseServer; diff --git a/libs/base-fastify-server/src/server/create_logger.ts b/libs/base-fastify-server/src/server/create_logger.ts new file mode 100644 index 0000000..ce5655a --- /dev/null +++ b/libs/base-fastify-server/src/server/create_logger.ts @@ -0,0 +1,31 @@ +import { pino, stdSerializers } from 'pino'; +import type { Logger } from 'pino'; + +// Alias type to decouple log provider from application +type BaseLogger = Logger; + +enum LogLevel { + SILENT = 'silent', + TRACE = 'trace', + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +/** + * Creates a logger instance using app configuration + * @param logLevel Minimum severity level that should be logged + * @returns created logger + */ +function createLogger(logLevel: LogLevel): BaseLogger { + const logger = pino({ + level: logLevel, + serializers: { err: stdSerializers.errWithCause }, + }); + return logger; +} + +export type { BaseLogger }; +export { createLogger, LogLevel }; diff --git a/libs/base-fastify-server/src/server/errors/http_errors.ts b/libs/base-fastify-server/src/server/errors/http_errors.ts new file mode 100644 index 0000000..bbb174f --- /dev/null +++ b/libs/base-fastify-server/src/server/errors/http_errors.ts @@ -0,0 +1,62 @@ +type HttpErrorCodes = 400 | 401 | 403 | 404 | 429 | 500; + +/** + * Base class of errors thrown for fastify error handler use + * Message should be a user facing error message + */ +class BaseHttpError extends Error { + statusCode: HttpErrorCodes = 500; + code = 'HTTP_ERROR'; + override message = + 'Server encountered an unexpected error. Please try again later.'; +} + +/** + * BadRequest error that contains list of error messages and validation error keys + */ +class HttpBadRequest extends BaseHttpError { + override statusCode = 400 as const; + override message = 'Bad Request'; + constructor( + public requestErrors: { + message: string; + key: string; + }[], + ) { + super(); + if (Error.stackTraceLimit !== 0) { + Error.captureStackTrace(this, HttpBadRequest); + } + } +} + +/** + * Produces custom http error class derived from HttpError with statusCode set + * @param statusCode http status code of error to create + * @returns custom http error class + */ +function createHttpError(statusCode: HttpErrorCodes) { + return class CustomHttpError extends BaseHttpError { + constructor(message?: string) { + super(); + if (message !== undefined) this.message = message; + this.statusCode = statusCode; + if (Error.stackTraceLimit !== 0) { + Error.captureStackTrace(this, CustomHttpError); + } + } + }; +} + +/* eslint-disable @typescript-eslint/naming-convention */ +const HttpError = { + BadRequest: HttpBadRequest, + Unauthorized: createHttpError(401), + Forbidden: createHttpError(403), + NotFound: createHttpError(404), + TooManyRequests: createHttpError(429), + ServerError: createHttpError(500), +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +export { BaseHttpError, HttpError }; diff --git a/libs/base-fastify-server/src/server/hooks/on_request/scope_logger.ts b/libs/base-fastify-server/src/server/hooks/on_request/scope_logger.ts new file mode 100644 index 0000000..1fe9050 --- /dev/null +++ b/libs/base-fastify-server/src/server/hooks/on_request/scope_logger.ts @@ -0,0 +1,16 @@ +import { asValue } from 'awilix'; +import fastifyPlugin from 'fastify-plugin'; + +import type { BaseLogger } from '../../create_logger.js'; +import type { BaseFastifyInstance } from '../../types/base_fastify_types.js'; + +/** + * Fastify onRequest hook that registers logger with request scoped dependency container + * Allows logger to include reqId context + */ +export default fastifyPlugin((fastify: BaseFastifyInstance) => { + fastify.addHook('onRequest', (req, reply, done) => { + req.diScope.register({ logger: asValue(req.log as BaseLogger) }); + done(); + }); +}); diff --git a/libs/base-fastify-server/src/server/plugins/error_handler.ts b/libs/base-fastify-server/src/server/plugins/error_handler.ts new file mode 100644 index 0000000..b9c0e4c --- /dev/null +++ b/libs/base-fastify-server/src/server/plugins/error_handler.ts @@ -0,0 +1,55 @@ +import { FastifyError } from '@fastify/error'; +import fastifyPlugin from 'fastify-plugin'; + +import { SHARED_ERROR_REPLY_SCHEMA } from '@scribear/base-schema'; + +import { BaseHttpError, HttpError } from '../errors/http_errors.js'; +import type { + BaseFastifyInstance, + BaseFastifyReply, + BaseFastifyRequest, +} from '../types/base_fastify_types.js'; + +/** + * Custom fastify error handler + * Ensure BaseHttpErrors return correctly formatted responses + * All other errors are caught and return InternalServerError response + * Fastify errors are rethrown to be handled by fastify's default error handler + */ +export default fastifyPlugin((fastify: BaseFastifyInstance) => { + fastify.setErrorHandler( + ( + err: unknown, + req: BaseFastifyRequest<{ response: typeof SHARED_ERROR_REPLY_SCHEMA }>, + reply: BaseFastifyReply<{ response: typeof SHARED_ERROR_REPLY_SCHEMA }>, + ) => { + // Let default error handler manage FastifyErrors + if (err instanceof FastifyError) throw err; + + if (!(err instanceof BaseHttpError)) { + // If not BaseHttpError, return Internal Server Error + req.log.info({ + msg: 'Request encountered internal server error', + err, + }); + + return reply.code(500).send({ + message: + 'Sever encountered an unexpected error. Please try again later.', + reqId: req.id, + }); + } + + // If HttpBadRequest, include requestErrors in response + if (err instanceof HttpError.BadRequest) { + return reply + .code(err.statusCode) + .send({ requestErrors: err.requestErrors, reqId: req.id }); + } + + return reply + .code(err.statusCode) + .send({ message: err.message, reqId: req.id }); + }, + ); +}); diff --git a/libs/base-fastify-server/src/server/plugins/json_parser.ts b/libs/base-fastify-server/src/server/plugins/json_parser.ts new file mode 100644 index 0000000..53cae01 --- /dev/null +++ b/libs/base-fastify-server/src/server/plugins/json_parser.ts @@ -0,0 +1,28 @@ +import fastifyPlugin from 'fastify-plugin'; + +import { HttpError } from '../errors/http_errors.js'; +import type { BaseFastifyInstance } from '../types/base_fastify_types.js'; + +/** + * Custom fastify not found handler to throw custom 400 error + */ +export default fastifyPlugin((fastify: BaseFastifyInstance) => { + fastify.addContentTypeParser( + 'application/json', + { parseAs: 'buffer' }, + (req, body, done) => { + try { + const parsedBody: unknown = JSON.parse(body.toString()); + done(null, parsedBody); + } catch (parseError) { + req.log.info({ msg: 'Failed to parse JSON body', err: parseError }); + + done( + new HttpError.BadRequest([ + { message: 'Invalid JSON found in request body.', key: '/body' }, + ]), + ); + } + }, + ); +}); diff --git a/libs/base-fastify-server/src/server/plugins/not_found_handler.ts b/libs/base-fastify-server/src/server/plugins/not_found_handler.ts new file mode 100644 index 0000000..66325b1 --- /dev/null +++ b/libs/base-fastify-server/src/server/plugins/not_found_handler.ts @@ -0,0 +1,14 @@ +import type { FastifyRequest } from 'fastify'; +import fastifyPlugin from 'fastify-plugin'; + +import { HttpError } from '../errors/http_errors.js'; +import type { BaseFastifyInstance } from '../types/base_fastify_types.js'; + +/** + * Custom fastify not found handler to throw custom 404 error + */ +export default fastifyPlugin((fastify: BaseFastifyInstance) => { + fastify.setNotFoundHandler((req: FastifyRequest) => { + throw new HttpError.NotFound(`Route ${req.method}: ${req.url} not found`); + }); +}); diff --git a/libs/base-fastify-server/src/server/plugins/schema_validator.ts b/libs/base-fastify-server/src/server/plugins/schema_validator.ts new file mode 100644 index 0000000..5a52072 --- /dev/null +++ b/libs/base-fastify-server/src/server/plugins/schema_validator.ts @@ -0,0 +1,44 @@ +import { + type TSchema, + type TypeBoxTypeProvider, + TypeBoxValidatorCompiler, +} from '@fastify/type-provider-typebox'; +import fastifyPlugin from 'fastify-plugin'; +import type { FastifyRouteSchemaDef } from 'fastify/types/schema.js'; + +import { HttpError } from '../errors/http_errors.js'; +import type { BaseFastifyInstance } from '../types/base_fastify_types.js'; + +/** + * Custom fastify schema validator to throw custom 400 error + */ +export default fastifyPlugin((fastify: BaseFastifyInstance) => { + fastify.withTypeProvider(); + + fastify.setValidatorCompiler((schemaDef) => { + const validator = TypeBoxValidatorCompiler( + schemaDef as FastifyRouteSchemaDef, + ); + + return (...args) => { + const result = validator(...args) as { + value: unknown; + error?: { message: string; instancePath: string }[]; + }; + + if (!result.error) { + return { value: result.value }; + } + + const requestErrors = result.error.map(({ message, instancePath }) => { + return { + message, + key: `/${schemaDef.httpPart ?? ''}${instancePath}`, + }; + }); + return { + error: new HttpError.BadRequest(requestErrors), + }; + }; + }); +}); diff --git a/libs/base-fastify-server/src/server/types/base_dependencies.ts b/libs/base-fastify-server/src/server/types/base_dependencies.ts new file mode 100644 index 0000000..36673bc --- /dev/null +++ b/libs/base-fastify-server/src/server/types/base_dependencies.ts @@ -0,0 +1,23 @@ +import type { BaseLogger } from '../create_logger.js'; + +/** + * Define the dependencies in dependency container provided by base fastify server + */ +interface BaseDependencies { + logger: BaseLogger; +} + +/** + * Ensure fastify awilix container is typed correctly + * Applications using this base fastify instance should extend BaseDependencies with their own dependencies + * @see https://github.com/fastify/fastify-awilix?tab=readme-ov-file#typescript-usage + */ +declare module '@fastify/awilix' { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface Cradle extends BaseDependencies {} + + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface RequestCradle extends BaseDependencies {} +} + +export type { BaseDependencies }; diff --git a/libs/base-fastify-server/src/server/types/base_fastify_types.ts b/libs/base-fastify-server/src/server/types/base_fastify_types.ts new file mode 100644 index 0000000..70ad6e2 --- /dev/null +++ b/libs/base-fastify-server/src/server/types/base_fastify_types.ts @@ -0,0 +1,46 @@ +import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox'; +import type { + ContextConfigDefault, + FastifyInstance, + FastifyReply, + FastifyRequest, + RawReplyDefaultExpression, + RawRequestDefaultExpression, + RawServerDefault, +} from 'fastify'; +import type { RouteGenericInterface } from 'fastify/types/route.js'; +import type { FastifySchema } from 'fastify/types/schema.js'; + +import type { BaseLogger } from '../create_logger.js'; + +/** + * Custom types for Fastify to provide type checking for requests and replies according to route schema + * @see https://github.com/fastify/fastify-type-provider-typebox?tab=readme-ov-file#type-definition-of-fastifyrequest-fastifyreply--typeprovider + */ +type BaseFastifyInstance = FastifyInstance< + RawServerDefault, + RawRequestDefaultExpression, + RawReplyDefaultExpression, + BaseLogger, + TypeBoxTypeProvider +>; + +type BaseFastifyReply = FastifyReply< + RouteGenericInterface, + RawServerDefault, + RawRequestDefaultExpression, + RawReplyDefaultExpression, + ContextConfigDefault, + TSchema, + TypeBoxTypeProvider +>; + +type BaseFastifyRequest = FastifyRequest< + RouteGenericInterface, + RawServerDefault, + RawRequestDefaultExpression, + TSchema, + TypeBoxTypeProvider +>; + +export type { BaseFastifyInstance, BaseFastifyRequest, BaseFastifyReply }; diff --git a/libs/base-fastify-server/tests/integration/dependency_injection.test.ts b/libs/base-fastify-server/tests/integration/dependency_injection.test.ts new file mode 100644 index 0000000..f93df73 --- /dev/null +++ b/libs/base-fastify-server/tests/integration/dependency_injection.test.ts @@ -0,0 +1,58 @@ +import { asValue } from 'awilix'; +import type { AwilixContainer } from 'awilix'; +import { beforeEach, describe, expect } from 'vitest'; + +import { LogLevel } from '../../src/index.js'; +import createBaseServer from '../../src/server/create_base_server.js'; +import type { BaseDependencies } from '../../src/server/types/base_dependencies.js'; +import type { BaseFastifyInstance } from '../../src/server/types/base_fastify_types.js'; + +interface TestDependencies extends BaseDependencies { + test: string; +} + +describe('Integration Tests - Dependency Injection', (it) => { + let fastify: BaseFastifyInstance; + let container: AwilixContainer; + + beforeEach(() => { + const server = createBaseServer(LogLevel.SILENT); + + fastify = server.fastify; + container = server.dependencyContainer as AwilixContainer; + }); + + /** + * Test that dependency container contains logger + */ + it('makes logger available in dependency container', () => { + // Arrange + // Act + const logger = container.resolve('logger'); + + // Assert + expect(logger).not.toBeUndefined(); + }); + + /** + * Test that dependency container makes registered dependencies available in request container scope + */ + it('makes registered dependencies available in request handlers', async () => { + // Arrange + const testValue = 'TEST_VALUE'; + container.register({ test: asValue(testValue) }); + fastify.get('/di', (req, res) => { + return res.send(req.diScope.resolve('test')); + }); + + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/di', + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(response.payload).toBe(testValue); + }); +}); diff --git a/libs/base-fastify-server/tests/integration/error_handling.test.ts b/libs/base-fastify-server/tests/integration/error_handling.test.ts new file mode 100644 index 0000000..54b0620 --- /dev/null +++ b/libs/base-fastify-server/tests/integration/error_handling.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect } from 'vitest'; + +import { LogLevel } from '../../src/index.js'; +import createBaseServer from '../../src/server/create_base_server.js'; +import { HttpError } from '../../src/server/errors/http_errors.js'; +import type { BaseFastifyInstance } from '../../src/server/types/base_fastify_types.js'; + +/** + * Integration tests for server error handling + */ +describe('Integration Tests - Error Handling', (it) => { + let fastify: BaseFastifyInstance; + + beforeEach(() => { + const server = createBaseServer(LogLevel.SILENT); + + fastify = server.fastify; + }); + + /** + * Test that server returns correct response containg request error when BadRequest error is thrown in handler + */ + it('returns 400 response when BadRequest error is thrown in handler', async () => { + // Arrange + const requestErrors = [ + { message: 'something wrong', key: '/body' }, + { message: 'something else', key: '/body/a' }, + ]; + fastify.get('/error', () => { + throw new HttpError.BadRequest(requestErrors); + }); + + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/error', + }); + + // Assert + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ requestErrors }); + }); + + /** + * Test that server returns correct response when HttpErrors are thrown in handler + */ + it.for([ + { httpError: HttpError.Unauthorized, code: 401, name: 'Unauthorized' }, + { httpError: HttpError.Forbidden, code: 403, name: 'Forbidden' }, + { httpError: HttpError.NotFound, code: 404, name: 'NotFound' }, + { + httpError: HttpError.TooManyRequests, + code: 429, + name: 'TooManyRequests', + }, + { httpError: HttpError.ServerError, code: 500, name: 'ServerError' }, + ])( + 'returns $code response for $name error is thrown in handler', + async ({ httpError, code }) => { + // Arrange + const errorMessage = 'Test unauthorized'; + fastify.get('/error', () => { + throw new httpError(errorMessage); + }); + + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/error', + }); + + // Assert + expect(response.statusCode).toBe(code); + expect(response.json()).toMatchObject({ message: errorMessage }); + }, + ); + + /** + * Test that server returns correct response when an invalid route is requested + */ + it('returns 404 response when path is not found', async () => { + // Arrange + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/error/notfound', + }); + + // Assert + expect(response.statusCode).toBe(404); + expect(response.json()).toMatchObject({ + message: 'Route GET: /error/notfound not found', + }); + }); + + /** + * Test that server returns correct response when a server error is thrown in handler + */ + it('returns 500 response when general error is thrown in handler', async () => { + // Arrange + fastify.get('/error', () => { + throw new Error('Server Error'); + }); + + // Act + const response = await fastify.inject({ + method: 'GET', + url: '/error', + }); + + // Assert + expect(response.statusCode).toBe(500); + expect(response.json()).toMatchObject({ + message: 'Sever encountered an unexpected error. Please try again later.', + }); + }); +}); diff --git a/libs/base-fastify-server/tests/integration/request_validation.test.ts b/libs/base-fastify-server/tests/integration/request_validation.test.ts new file mode 100644 index 0000000..29d0a80 --- /dev/null +++ b/libs/base-fastify-server/tests/integration/request_validation.test.ts @@ -0,0 +1,89 @@ +import { Type } from 'typebox'; +import { beforeEach, describe, expect } from 'vitest'; + +import { LogLevel } from '../../src/index.js'; +import createBaseServer from '../../src/server/create_base_server.js'; +import type { + BaseFastifyInstance, + BaseFastifyReply, + BaseFastifyRequest, +} from '../../src/server/types/base_fastify_types.js'; + +describe('Integration Tests - Request validation', (it) => { + let fastify: BaseFastifyInstance; + + beforeEach(() => { + const server = createBaseServer(LogLevel.SILENT); + + fastify = server.fastify; + + const schema = { + params: Type.Object({ + id: Type.String({ minLength: 2 }), + }), + querystring: Type.Object({ + string: Type.String(), + }), + body: Type.Object({ + num: Type.Number({ minimum: 18 }), + }), + }; + + fastify.post( + '/schema/:id', + { schema }, + ( + req: BaseFastifyRequest, + reply: BaseFastifyReply, + ) => { + return reply.code(200).send(); + }, + ); + }); + + /** + * Test that valid requests are passed to request handler successfully + */ + it('returns 200 response for valid schema', async () => { + // Arrange + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/schema/some_id?string=string', + headers: { + 'content-type': 'application/json', + }, + payload: JSON.stringify({ num: 18 }), + }); + + // Assert + expect(response.statusCode).toBe(200); + }); + + /** + * Test that invalid requests return 400 error + */ + it('returns 400 response for invalid schema', async () => { + // Arrange + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/schema/some_id', + headers: { + 'content-type': 'application/json', + }, + payload: JSON.stringify({ num: 18 }), + }); + + // Assert + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + requestErrors: [ + { + key: '/querystring', + message: 'must have required properties string', + }, + ], + }); + }); +}); diff --git a/libs/base-fastify-server/tests/unit/server/hooks/on_request/scope_logger.test.ts b/libs/base-fastify-server/tests/unit/server/hooks/on_request/scope_logger.test.ts new file mode 100644 index 0000000..c8677f3 --- /dev/null +++ b/libs/base-fastify-server/tests/unit/server/hooks/on_request/scope_logger.test.ts @@ -0,0 +1,53 @@ +import type { AwilixContainer } from 'awilix'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { type Mock, beforeEach, describe, expect, vi } from 'vitest'; + +import type { BaseLogger } from '../../../../../src/server/create_logger.js'; +import scopeLogger from '../../../../../src/server/hooks/on_request/scope_logger.js'; + +// Override awilix asValue function to be a no-op +vi.mock('awilix', () => ({ + asValue: vi.fn((a: unknown) => a), +})); + +describe('Log Request Hook', (it) => { + const testRequestId = 'TEST-REQUST-ID'; + + let fastify: FastifyInstance; + let mockLogger: { child: Mock }; + let mockDiScope: { register: Mock }; + + beforeEach(() => { + mockLogger = { child: vi.fn().mockReturnThis() }; + mockDiScope = { register: vi.fn() }; + + fastify = Fastify({ genReqId: () => testRequestId }); + + fastify.decorateRequest('log', { + getter: () => mockLogger as unknown as BaseLogger, + setter: () => mockLogger, + }); + fastify.decorateRequest('diScope', { + getter: () => mockDiScope as unknown as AwilixContainer, + }); + + fastify.register(scopeLogger); + }); + + /** + * Test that scopeLogger hook registers logger with request scoped dependency container + */ + it('registers logger with dependency container', async () => { + // Arrange / Act + await fastify.inject({ + method: 'GET', + url: '/test/hello/world', + }); + + // Assert + expect(mockDiScope.register).toHaveBeenCalledExactlyOnceWith({ + logger: mockLogger, + }); + }); +}); diff --git a/libs/base-fastify-server/tests/unit/server/plugins/error_handler.test.ts b/libs/base-fastify-server/tests/unit/server/plugins/error_handler.test.ts new file mode 100644 index 0000000..8174111 --- /dev/null +++ b/libs/base-fastify-server/tests/unit/server/plugins/error_handler.test.ts @@ -0,0 +1,119 @@ +import Fastify, { type FastifyInstance, errorCodes } from 'fastify'; +import { beforeEach, describe, expect } from 'vitest'; + +import { HttpError } from '../../../../src/server/errors/http_errors.js'; +import errorHandler from '../../../../src/server/plugins/error_handler.js'; + +describe('Error Handler Plugin', (it) => { + const testRequestId = 'TEST-REQUEST-ID'; + + let fastify: FastifyInstance; + + beforeEach(() => { + fastify = Fastify({ genReqId: () => testRequestId }); + fastify.register(errorHandler); + }); + + /** + * Check that error handler handles HttpErrors by return API response containing message and request id + * Note: BadRequest has a different schema and is tested separately + */ + it.for([ + { httpError: HttpError.Unauthorized, code: 401, name: 'Unauthorized' }, + { httpError: HttpError.Forbidden, code: 403, name: 'Forbidden' }, + { httpError: HttpError.NotFound, code: 404, name: 'NotFound' }, + { + httpError: HttpError.TooManyRequests, + code: 429, + name: 'TooManyRequests', + }, + { httpError: HttpError.ServerError, code: 500, name: 'ServerError' }, + ])( + 'handles $name by returning error message and request id', + async ({ httpError, code }) => { + // Arrange + const errorMessage = 'Resource not found'; + fastify.get('/test', () => { + throw new httpError(errorMessage); + }); + + // Act + const response = await fastify.inject({ method: 'GET', url: '/test' }); + + // Assertions + expect(response.statusCode).toBe(code); + expect(JSON.parse(response.payload)).toStrictEqual({ + message: errorMessage, + reqId: testRequestId, + }); + }, + ); + + /** + * Check that error handler handles BadRequest by returning API response containing request id and list of request errors + */ + it('handles BadRequest by returning list of request errors', async () => { + // Arrange + const testRequestErrors = [ + { key: '/body/email', message: 'Invalid email format' }, + { key: '/body/password', message: 'Password is too short' }, + ]; + + fastify.get('/test', () => { + throw new HttpError.BadRequest(testRequestErrors); + }); + + // Act + const response = await fastify.inject({ method: 'GET', url: '/test' }); + + // Assert + expect(response.statusCode).toBe(400); + expect(JSON.parse(response.payload)).toStrictEqual({ + requestErrors: testRequestErrors, + reqId: testRequestId, + }); + }); + + /** + * Check that error handler lets fastify handle fastify errors + */ + it.for( + Object.entries(errorCodes).map(([key, value]) => { + return { name: key, error: value }; + }), + )('avoids handling fastify error: $name', async ({ error, name }) => { + // Arrange + fastify.get('/test', () => { + throw new error('Fastify threw some error'); + }); + + // Act + const response = await fastify.inject({ method: 'GET', url: '/test' }); + + // Assert + expect(response.json()).toMatchObject({ + // fastify error handler returns error name in code field + code: name, + }); + }); + + /** + * Check that error handler treats all other Errors as a 500 Server Error and returns appropriate response + */ + it('handles non-HttpError as a 500 Server Error', async () => { + // Arrange + fastify.get('/test', () => { + throw new Error('Something went wrong'); + }); + + // Act + const response = await fastify.inject({ method: 'GET', url: '/test' }); + + // Assert + expect(response.statusCode).toBe(500); + expect(JSON.parse(response.payload)).toStrictEqual({ + message: 'Sever encountered an unexpected error. Please try again later.', + reqId: testRequestId, + }); + }); +}); diff --git a/libs/base-fastify-server/tests/unit/server/plugins/json_parser.test.ts b/libs/base-fastify-server/tests/unit/server/plugins/json_parser.test.ts new file mode 100644 index 0000000..5d531cf --- /dev/null +++ b/libs/base-fastify-server/tests/unit/server/plugins/json_parser.test.ts @@ -0,0 +1,70 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { type Mock, beforeEach, describe, expect, vi } from 'vitest'; + +import { HttpError } from '../../../../src/server/errors/http_errors.js'; +import jsonParser from '../../../../src/server/plugins/json_parser.js'; + +describe('JSON Parser Plugin', (it) => { + let fastify: FastifyInstance; + let mockErrorHandler: Mock; + + beforeEach(() => { + mockErrorHandler = vi.fn().mockReturnValue(''); + + fastify = Fastify(); + + fastify.setErrorHandler(mockErrorHandler); + fastify.register(jsonParser); + + fastify.post('/test', async (req, reply) => { + return reply.send(req.body); + }); + }); + + /** + * Test that jsonParser is able to parse valid json + */ + it('parses a valid JSON body successfully', async () => { + // Arrange + const validPayload = { hello: 'world', count: 123 }; + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/test', + headers: { + 'content-type': 'application/json', + }, + payload: JSON.stringify(validPayload), + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual(validPayload); + }); + + /** + * Test that jsonParser throws BadRequest error if malformed JSON is received + */ + it('throws BadRequest error for a malformed JSON body', async () => { + // Arrange + const invalidPayload = '{"key": "value",}'; + + // Act + await fastify.inject({ + method: 'POST', + url: '/test', + headers: { + 'content-type': 'application/json', + }, + payload: invalidPayload, + }); + + // Assert + expect(mockErrorHandler).toHaveBeenCalledExactlyOnceWith( + expect.any(HttpError.BadRequest), + expect.anything(), + expect.anything(), + ); + }); +}); diff --git a/libs/base-fastify-server/tests/unit/server/plugins/not_found_handler.test.ts b/libs/base-fastify-server/tests/unit/server/plugins/not_found_handler.test.ts new file mode 100644 index 0000000..6be4b1c --- /dev/null +++ b/libs/base-fastify-server/tests/unit/server/plugins/not_found_handler.test.ts @@ -0,0 +1,37 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { type Mock, beforeEach, describe, expect, vi } from 'vitest'; + +import { HttpError } from '../../../../src/server/errors/http_errors.js'; +import notFoundHandler from '../../../../src/server/plugins/not_found_handler.js'; + +describe('Not Found Handler Plugin', (it) => { + let fastify: FastifyInstance; + let mockErrorHandler: Mock; + + beforeEach(() => { + mockErrorHandler = vi.fn().mockReturnValue(''); + + fastify = Fastify(); + + fastify.setErrorHandler(mockErrorHandler); + fastify.register(notFoundHandler); + }); + + /** + * Test that Not Found handler plugin throws Not Found error when called + */ + it('throws NotFound error', async () => { + // Arrange / Act + await fastify.inject({ + method: 'GET', + url: '/test', + }); + + // Assert + expect(mockErrorHandler).toHaveBeenCalledExactlyOnceWith( + expect.any(HttpError.NotFound), + expect.anything(), + expect.anything(), + ); + }); +}); diff --git a/libs/base-fastify-server/tests/unit/server/plugins/schema_validator.test.ts b/libs/base-fastify-server/tests/unit/server/plugins/schema_validator.test.ts new file mode 100644 index 0000000..cf8c401 --- /dev/null +++ b/libs/base-fastify-server/tests/unit/server/plugins/schema_validator.test.ts @@ -0,0 +1,89 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { Type } from 'typebox'; +import { type Mock, beforeEach, describe, expect, vi } from 'vitest'; + +import { HttpError } from '../../../../src/server/errors/http_errors.js'; +import schemaValidator from '../../../../src/server/plugins/schema_validator.js'; + +describe('Schema Validator Plugin', (it) => { + let fastify: FastifyInstance; + let mockErrorHandler: Mock; + + beforeEach(() => { + mockErrorHandler = vi.fn().mockReturnValue(''); + + fastify = Fastify(); + + fastify.setErrorHandler(mockErrorHandler); + fastify.register(schemaValidator); + }); + + /** + * Test that schemaValidator is able to parse valid requests + */ + it('successfully parses valid request', async () => { + // Arrange + const validPayload = { string: 'string', num: 123 }; + fastify.post( + '/test', + { + schema: { + body: Type.Object({ string: Type.String(), num: Type.Number() }), + }, + }, + (req, reply) => { + return reply.send(req.body); + }, + ); + + // Act + const response = await fastify.inject({ + method: 'POST', + url: '/test', + headers: { + 'content-type': 'application/json', + }, + payload: JSON.stringify(validPayload), + }); + + // Assert + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.payload)).toEqual(validPayload); + }); + + /** + * Test that schemaValidator throws BadRequest error when parsing invalid requests + */ + it('throws BadRequest error for invalid request', async () => { + // Arrange + const invalidPayload = { string: 'string', num: 'not a num' }; + fastify.post( + '/test', + { + schema: { + body: Type.Object({ string: Type.String(), num: Type.Number() }), + }, + }, + (req, reply) => { + return reply.send(req.body); + }, + ); + + // Act + await fastify.inject({ + method: 'POST', + url: '/test', + headers: { + 'content-type': 'application/json', + }, + payload: JSON.stringify(invalidPayload), + }); + + // Assert + expect(mockErrorHandler).toHaveBeenCalledExactlyOnceWith( + expect.any(HttpError.BadRequest), + expect.anything(), + expect.anything(), + ); + }); +}); diff --git a/libs/base-fastify-server/tsconfig.json b/libs/base-fastify-server/tsconfig.json new file mode 100644 index 0000000..b94b24c --- /dev/null +++ b/libs/base-fastify-server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + }, + "include": [ + "src", + "tests", + ], + // Link to shared packages + "references": [ + { + "path": "../base-schema/tsconfig.json" + } + ] +} diff --git a/libs/base-fastify-server/vitest.config.ts b/libs/base-fastify-server/vitest.config.ts new file mode 100644 index 0000000..cb09d16 --- /dev/null +++ b/libs/base-fastify-server/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineProject, mergeConfig } from 'vitest/config'; + +import sharedConfig from '../../vitest.shared.js'; + +export default mergeConfig( + sharedConfig, + defineProject({ + test: { + environment: 'node', + }, + }), +); diff --git a/libs/base-schema/package.json b/libs/base-schema/package.json new file mode 100644 index 0000000..a6bfbf3 --- /dev/null +++ b/libs/base-schema/package.json @@ -0,0 +1,19 @@ +{ + "name": "@scribear/base-schema", + "version": "0.0.0", + "description": "", + "author": "scribear", + "main": "dist/src/index.js", + "type": "module", + "scripts": { + "build": "tsc --build", + "dev": "tsc --build --watch", + "format": "prettier --check ./src", + "format:fix": "prettier --write ./src", + "lint": "eslint ./src", + "lint:fix": "eslint ./src --fix" + }, + "dependencies": { + "typebox": "1.0.41" + } +} diff --git a/libs/base-schema/src/index.ts b/libs/base-schema/src/index.ts new file mode 100644 index 0000000..bb3fb75 --- /dev/null +++ b/libs/base-schema/src/index.ts @@ -0,0 +1,5 @@ +import { SHARED_ERROR_REPLY_SCHEMA } from './shared/shared_error_reply_schema.js'; +import type { BaseRouteSchema } from './types/base_route_schema.js'; + +export { SHARED_ERROR_REPLY_SCHEMA }; +export type { BaseRouteSchema }; diff --git a/libs/base-schema/src/shared/shared_error_reply_schema.ts b/libs/base-schema/src/shared/shared_error_reply_schema.ts new file mode 100644 index 0000000..45689d7 --- /dev/null +++ b/libs/base-schema/src/shared/shared_error_reply_schema.ts @@ -0,0 +1,71 @@ +import { Type } from 'typebox'; + +/** + * Reply schema of shared API http error responses + */ +const SHARED_ERROR_REPLY_SCHEMA = { + 400: Type.Object( + { + requestErrors: Type.Array( + Type.Object({ + message: Type.String(), + key: Type.String({ default: '/body/some/nested/object/property' }), + }), + ), + reqId: Type.String(), + }, + { + description: + 'Response when request was invalid. Each validation error has a user facing message about the error. Keys start with the part of request that had error ("/body", "/headers", "/params", "/querystring").', + }, + ), + 401: Type.Object( + { + message: Type.String(), + reqId: Type.String(), + }, + { + description: + 'Response when request failed authentication. Message will contain user facing reason why.', + }, + ), + 403: Type.Object( + { + message: Type.String(), + reqId: Type.String(), + }, + { + description: + 'Response when request failed authorization. Message will contain user facing reason why.', + }, + ), + 404: Type.Object( + { + message: Type.String(), + reqId: Type.String(), + }, + { + description: 'Response when request had no matching path on server.', + }, + ), + 429: Type.Object( + { + message: Type.String(), + reqId: Type.String(), + }, + { + description: 'Response when request is rate limited.', + }, + ), + 500: Type.Object( + { + message: Type.String(), + reqId: Type.String(), + }, + { + description: 'Response when server encounters an unexpected error.', + }, + ), +}; + +export { SHARED_ERROR_REPLY_SCHEMA }; diff --git a/libs/base-schema/src/types/base_route_schema.ts b/libs/base-schema/src/types/base_route_schema.ts new file mode 100644 index 0000000..e93af56 --- /dev/null +++ b/libs/base-schema/src/types/base_route_schema.ts @@ -0,0 +1,18 @@ +/** + * Defines a route on server + */ +interface BaseRouteSchema { + method: + | 'GET' + | 'HEAD' + | 'TRACE' + | 'DELETE' + | 'OPTIONS' + | 'PATCH' + | 'PUT' + | 'POST'; + + url: string; +} + +export type { BaseRouteSchema }; diff --git a/libs/base-schema/tsconfig.json b/libs/base-schema/tsconfig.json new file mode 100644 index 0000000..71dbd8f --- /dev/null +++ b/libs/base-schema/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": [ + "src", + ], + // Link to dependencies + "references": [] +} diff --git a/libs/session-manager-schema/package.json b/libs/session-manager-schema/package.json index cfd1138..93bcb58 100644 --- a/libs/session-manager-schema/package.json +++ b/libs/session-manager-schema/package.json @@ -12,5 +12,8 @@ "format:fix": "prettier --write ./src", "lint": "eslint ./src", "lint:fix": "eslint ./src --fix" + }, + "dependencies": { + "typebox": "1.0.41" } } diff --git a/libs/session-manager-schema/src/calculator/compute_binomial.schema.ts b/libs/session-manager-schema/src/calculator/compute_binomial.schema.ts new file mode 100644 index 0000000..645ba59 --- /dev/null +++ b/libs/session-manager-schema/src/calculator/compute_binomial.schema.ts @@ -0,0 +1,41 @@ +import { Type } from 'typebox'; + +import { + type BaseRouteSchema, + SHARED_ERROR_REPLY_SCHEMA, +} from '@scribear/base-schema'; + +const COMPUTE_BINOMIAL_SCHEMA = { + description: 'Computes a binomial', + tags: ['Calculator'], + body: Type.Object( + { + a: Type.Integer({ description: 'First operand' }), + b: Type.Integer({ description: 'Second operand' }), + op: Type.Union([Type.Literal('+'), Type.Literal('-')], { + description: 'Operator', + }), + }, + { + description: 'Represents a single binomial expression', + }, + ), + response: { + 200: Type.Object( + { + result: Type.Integer({ description: 'Result value of computation' }), + reqId: Type.String(), + }, + { description: 'Successful computation response' }, + ), + 400: SHARED_ERROR_REPLY_SCHEMA[400], + 500: SHARED_ERROR_REPLY_SCHEMA[500], + }, +}; + +const COMPUTE_BINOMIAL_ROUTE: BaseRouteSchema = { + method: 'POST', + url: '/calculator/binomial', +}; + +export { COMPUTE_BINOMIAL_SCHEMA, COMPUTE_BINOMIAL_ROUTE }; diff --git a/libs/session-manager-schema/src/calculator/compute_monomial.schema.ts b/libs/session-manager-schema/src/calculator/compute_monomial.schema.ts new file mode 100644 index 0000000..c09834e --- /dev/null +++ b/libs/session-manager-schema/src/calculator/compute_monomial.schema.ts @@ -0,0 +1,40 @@ +import { Type } from 'typebox'; + +import { + type BaseRouteSchema, + SHARED_ERROR_REPLY_SCHEMA, +} from '@scribear/base-schema'; + +const COMPUTE_MONOMIAL_SCHEMA = { + description: 'Computes a monomial', + tags: ['Calculator'], + body: Type.Object( + { + a: Type.Integer({ description: 'Operand' }), + op: Type.Union([Type.Literal('square'), Type.Literal('cube')], { + description: 'Operator', + }), + }, + { + description: 'Represents a single monomial expression', + }, + ), + response: { + 200: Type.Object( + { + result: Type.Integer({ description: 'Result value of computation' }), + reqId: Type.String(), + }, + { description: 'Successful computation response' }, + ), + 400: SHARED_ERROR_REPLY_SCHEMA[400], + 500: SHARED_ERROR_REPLY_SCHEMA[500], + }, +}; + +const COMPUTE_MONOMIAL_ROUTE: BaseRouteSchema = { + method: 'POST', + url: '/calculator/monomial', +}; + +export { COMPUTE_MONOMIAL_SCHEMA, COMPUTE_MONOMIAL_ROUTE }; diff --git a/libs/session-manager-schema/src/healthcheck/healthcheck.schema.ts b/libs/session-manager-schema/src/healthcheck/healthcheck.schema.ts new file mode 100644 index 0000000..e6e9812 --- /dev/null +++ b/libs/session-manager-schema/src/healthcheck/healthcheck.schema.ts @@ -0,0 +1,26 @@ +import { Type } from 'typebox'; + +import { + type BaseRouteSchema, + SHARED_ERROR_REPLY_SCHEMA, +} from '@scribear/base-schema'; + +const HEALTHCHECK_SCHEMA = { + description: 'Probes liveliness of server', + tags: ['Healthcheck'], + response: { + 200: Type.Object( + { reqId: Type.String() }, + { description: 'Healthcheck successful' }, + ), + 400: SHARED_ERROR_REPLY_SCHEMA[400], + 500: SHARED_ERROR_REPLY_SCHEMA[500], + }, +}; + +const HEALTHCHECK_ROUTE: BaseRouteSchema = { + method: 'GET', + url: '/healthcheck', +}; + +export { HEALTHCHECK_SCHEMA, HEALTHCHECK_ROUTE }; diff --git a/libs/session-manager-schema/src/index.ts b/libs/session-manager-schema/src/index.ts index 1bb8299..6eeee24 100644 --- a/libs/session-manager-schema/src/index.ts +++ b/libs/session-manager-schema/src/index.ts @@ -1 +1,21 @@ -console.log('Hello from session-manager-schema!'); +import { + COMPUTE_BINOMIAL_ROUTE, + COMPUTE_BINOMIAL_SCHEMA, +} from './calculator/compute_binomial.schema.js'; +import { + COMPUTE_MONOMIAL_ROUTE, + COMPUTE_MONOMIAL_SCHEMA, +} from './calculator/compute_monomial.schema.js'; +import { + HEALTHCHECK_ROUTE, + HEALTHCHECK_SCHEMA, +} from './healthcheck/healthcheck.schema.js'; + +export { + COMPUTE_BINOMIAL_SCHEMA, + COMPUTE_BINOMIAL_ROUTE, + COMPUTE_MONOMIAL_SCHEMA, + COMPUTE_MONOMIAL_ROUTE, + HEALTHCHECK_ROUTE, + HEALTHCHECK_SCHEMA, +}; diff --git a/libs/session-manager-schema/tsconfig.json b/libs/session-manager-schema/tsconfig.json index 35ac266..0917141 100644 --- a/libs/session-manager-schema/tsconfig.json +++ b/libs/session-manager-schema/tsconfig.json @@ -8,5 +8,9 @@ "src", ], // Link to dependencies - "references": [] -} \ No newline at end of file + "references": [ + { + "path": "../base-schema/tsconfig.json" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 833db69..414349d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "eslint-plugin-react-refresh": "0.4.24", "globals": "16.4.0", "nodemon": "3.1.10", + "pino-pretty": "13.1.2", "prettier": "3.6.2", "typescript": "5.9.3", "typescript-eslint": "8.46.2", @@ -34,11 +35,44 @@ }, "apps/session-manager": { "name": "@scribear/session-manager", - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "@fastify/awilix": "8.0.0", + "@fastify/swagger": "9.5.2", + "@fastify/swagger-ui": "5.2.3", + "awilix": "12.0.5", + "env-schema": "6.1.0", + "typebox": "1.0.41" + } + }, + "libs/base-fastify-server": { + "name": "@scribear/base-fastify-server", + "version": "0.0.0", + "dependencies": { + "@fastify/awilix": "8.0.0", + "@fastify/helmet": "13.0.2", + "@fastify/sensible": "6.0.3", + "@fastify/type-provider-typebox": "6.1.0", + "awilix": "12.0.5", + "fastify": "5.6.1", + "fastify-plugin": "5.1.0", + "pino": "10.1.0", + "uuid": "13.0.0" + } + }, + "libs/base-schema": { + "name": "@scribear/base-schema", + "version": "0.0.0", + "dependencies": { + "typebox": "1.0.41" + } }, "libs/session-manager-schema": { "name": "@scribear/session-manager-schema", - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "typebox": "1.0.41" + } }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -542,6 +576,302 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/@fastify/awilix": { + "version": "8.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "awilix-manager": "^6.0.0", + "fastify-plugin": "^5.0.1" + }, + "peerDependencies": { + "awilix": ">=9.0.0", + "fastify": "^5.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/helmet": { + "version": "13.0.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "helmet": "^8.0.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/sensible": { + "version": "6.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", + "forwarded": "^0.2.0", + "http-errors": "^2.0.0", + "type-is": "^1.6.18", + "vary": "^1.1.2" + } + }, + "node_modules/@fastify/static": { + "version": "8.3.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^0.5.4", + "fastify-plugin": "^5.0.0", + "fastq": "^1.17.1", + "glob": "^11.0.0" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.5.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "5.2.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^8.0.0", + "fastify-plugin": "^5.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, + "node_modules/@fastify/type-provider-typebox": { + "version": "6.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peerDependencies": { + "typebox": "^1.0.13" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -586,6 +916,38 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.3", "dev": true, @@ -634,9 +996,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -648,7 +1016,6 @@ }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -656,7 +1023,6 @@ }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -666,6 +1032,10 @@ "node": ">= 8" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "license": "MIT" + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "dev": true, @@ -683,6 +1053,14 @@ "linux" ] }, + "node_modules/@scribear/base-fastify-server": { + "resolved": "libs/base-fastify-server", + "link": true + }, + "node_modules/@scribear/base-schema": { + "resolved": "libs/base-schema", + "link": true + }, "node_modules/@scribear/session-manager": { "resolved": "apps/session-manager", "link": true @@ -1124,6 +1502,10 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "dev": true, @@ -1159,18 +1541,60 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "dev": true, + "node_modules/ajv-formats": { + "version": "3.0.1", "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "ajv": "^8.0.0" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "ajv": "^8.0.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/anymatch": { @@ -1198,6 +1622,42 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.1.0", + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/awilix": { + "version": "12.0.5", + "license": "MIT", + "peer": true, + "dependencies": { + "camel-case": "^4.1.2", + "fast-glob": "^3.3.3" + }, + "engines": { + "node": ">=16.3.0" + } + }, + "node_modules/awilix-manager": { + "version": "6.1.0", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "awilix": ">=9.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -1237,7 +1697,6 @@ }, "node_modules/braces": { "version": "3.0.3", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -1287,6 +1746,14 @@ "node": ">=6" } }, + "node_modules/camel-case": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001751", "dev": true, @@ -1365,7 +1832,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1376,6 +1842,10 @@ }, "node_modules/color-name": { "version": "1.1.4", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", "dev": true, "license": "MIT" }, @@ -1389,14 +1859,30 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1407,9 +1893,16 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.3", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1428,11 +1921,95 @@ "dev": true, "license": "MIT" }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dotenv": { + "version": "17.2.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.239", "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-schema": { + "version": "6.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "dotenv": "^17.0.0", + "dotenv-expand": "10.0.0" + } + }, + "node_modules/env-schema/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/env-schema/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "dev": true, @@ -1486,6 +2063,10 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "dev": true, @@ -1861,14 +2442,21 @@ "node": ">=12.0.0" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -1883,7 +2471,6 @@ }, "node_modules/fast-glob/node_modules/glob-parent": { "version": "5.1.2", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -1897,14 +2484,145 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stringify": { + "version": "6.1.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.17.1", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "dev": true, "license": "MIT" }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.6.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@fastify/ajv-compiler": "^4.0.0", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.0.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify/node_modules/pino": { + "version": "9.14.0", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, "node_modules/fastq": { "version": "1.19.1", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -1928,7 +2646,6 @@ }, "node_modules/fill-range": { "version": "7.1.1", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -1937,6 +2654,18 @@ "node": ">=8" } }, + "node_modules/find-my-way": { + "version": "9.3.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "5.0.0", "dev": true, @@ -1969,6 +2698,27 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "dev": true, @@ -1977,15 +2727,49 @@ "node": ">=6.9.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "dev": true, + "node_modules/glob": { + "version": "11.0.3", "license": "ISC", "dependencies": { - "is-glob": "^4.0.3" + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=10.13.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.0.3", + "license": "ISC", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/globals": { @@ -2012,6 +2796,18 @@ "node": ">=8" } }, + "node_modules/helmet": { + "version": "8.1.0", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "dev": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "dev": true, @@ -2030,6 +2826,20 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/ignore": { "version": "5.3.2", "dev": true, @@ -2066,6 +2876,17 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "dev": true, @@ -2079,15 +2900,20 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -2112,7 +2938,6 @@ }, "node_modules/is-number": { "version": "7.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -2120,7 +2945,6 @@ }, "node_modules/isexe": { "version": "2.0.0", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -2184,11 +3008,32 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "4.1.1", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/javascript-natural-sort": { "version": "0.7.1", "dev": true, "license": "MIT" }, + "node_modules/joycon": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "dev": true, @@ -2221,6 +3066,38 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "dev": true, @@ -2262,6 +3139,39 @@ "node": ">= 0.8.0" } }, + "node_modules/light-my-request": { + "version": "6.6.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -2286,6 +3196,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lower-case": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -2326,9 +3243,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/merge2": { "version": "1.4.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2336,7 +3259,6 @@ }, "node_modules/micromatch": { "version": "4.0.8", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -2346,6 +3268,33 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "3.0.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "dev": true, @@ -2360,6 +3309,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mrmime": { "version": "2.0.1", "dev": true, @@ -2370,7 +3334,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2395,6 +3358,14 @@ "dev": true, "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-releases": { "version": "2.0.26", "dev": true, @@ -2474,6 +3445,25 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -2518,6 +3508,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -2529,6 +3523,14 @@ "node": ">=6" } }, + "node_modules/pascal-case": { + "version": "3.1.2", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -2539,12 +3541,32 @@ }, "node_modules/path-key": { "version": "3.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.0", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.2", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pathe": { "version": "2.0.3", "dev": true, @@ -2557,7 +3579,6 @@ }, "node_modules/picomatch": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -2566,6 +3587,71 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.1.0", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "license": "MIT" + }, "node_modules/postcss": { "version": "8.5.6", "dev": true, @@ -2616,11 +3702,34 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -2631,7 +3740,6 @@ }, "node_modules/queue-microtask": { "version": "1.2.3", - "dev": true, "funding": [ { "type": "github", @@ -2648,6 +3756,10 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, "node_modules/readdirp": { "version": "3.6.0", "dev": true, @@ -2659,6 +3771,20 @@ "node": ">=8.10.0" } }, + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "dev": true, @@ -2667,15 +3793,25 @@ "node": ">=4" } }, + "node_modules/ret": { + "version": "0.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/reusify": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.52.5", "dev": true, @@ -2718,7 +3854,6 @@ }, "node_modules/run-parallel": { "version": "1.2.0", - "dev": true, "funding": [ { "type": "github", @@ -2738,9 +3873,64 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2749,9 +3939,16 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2762,7 +3959,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2773,6 +3969,16 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "dev": true, @@ -2797,6 +4003,13 @@ "node": ">=18" } }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "dev": true, @@ -2805,11 +4018,25 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "dev": true, @@ -2820,6 +4047,86 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -2842,6 +4149,13 @@ "node": ">=8" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "dev": true, @@ -2905,7 +4219,6 @@ }, "node_modules/to-regex-range": { "version": "5.0.1", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -2914,6 +4227,20 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/totalist": { "version": "3.0.1", "dev": true, @@ -2991,6 +4318,10 @@ "dev": true, "license": "MIT" }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, @@ -3002,6 +4333,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typebox": { + "version": "1.0.41", + "license": "MIT", + "peer": true + }, "node_modules/typescript": { "version": "5.9.3", "dev": true, @@ -3084,11 +4431,28 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.1.11", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3288,7 +4652,6 @@ }, "node_modules/which": { "version": "2.0.2", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3323,11 +4686,100 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.1", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index 60298d9..99367e6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "eslint-plugin-react-refresh": "0.4.24", "globals": "16.4.0", "nodemon": "3.1.10", + "pino-pretty": "13.1.2", "prettier": "3.6.2", "typescript": "5.9.3", "typescript-eslint": "8.46.2",