diff --git a/packages/core/src/errors/ServerError/index.ts b/packages/core/src/errors/ServerError/index.ts new file mode 100644 index 000000000..286656e5a --- /dev/null +++ b/packages/core/src/errors/ServerError/index.ts @@ -0,0 +1,3 @@ +// Needs to standardize + +export default class ServerError extends Error {} diff --git a/packages/core/src/middleware/koa-error-handler.test.ts b/packages/core/src/middleware/koa-error-handler.test.ts index 90bf725e2..8eca4bee8 100644 --- a/packages/core/src/middleware/koa-error-handler.test.ts +++ b/packages/core/src/middleware/koa-error-handler.test.ts @@ -38,8 +38,9 @@ describe('koaErrorHandler middleware', () => { expect(ctx.body).toEqual(mockBody); }); - it('expect to throw if error type is not RequestError', async () => { + it('expect status 500 if error type is not RequestError', async () => { next.mockRejectedValueOnce(new Error('err')); - await expect(koaErrorHandler()(ctx, next)).rejects.toThrow(); + await koaErrorHandler()(ctx, next); + expect(ctx.status).toEqual(500); }); }); diff --git a/packages/core/src/middleware/koa-error-handler.ts b/packages/core/src/middleware/koa-error-handler.ts index 28f855574..3600b1ee2 100644 --- a/packages/core/src/middleware/koa-error-handler.ts +++ b/packages/core/src/middleware/koa-error-handler.ts @@ -1,17 +1,22 @@ import { RequestErrorBody } from '@logto/schemas'; import { Middleware } from 'koa'; +import envSet from '@/env-set'; import RequestError from '@/errors/RequestError'; export default function koaErrorHandler(): Middleware< StateT, ContextT, - BodyT | RequestErrorBody + BodyT | RequestErrorBody | { message: string } > { return async (ctx, next) => { try { await next(); } catch (error: unknown) { + if (!envSet.values.isProduction) { + console.error(error); + } + if (error instanceof RequestError) { ctx.status = error.status; ctx.body = error.body; @@ -19,7 +24,8 @@ export default function koaErrorHandler(): Middleware< return; } - throw error; + ctx.status = 500; + ctx.body = { message: 'Internal server error.' }; } }; } diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index f3ace258d..2cf3861f4 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -5,35 +5,40 @@ import { IMiddleware, IRouterParamContext } from 'koa-router'; import { ZodType } from 'zod'; import RequestError from '@/errors/RequestError'; +import ServerError from '@/errors/ServerError'; +import assertThat from '@/utils/assert-that'; -export type GuardConfig = { +export type GuardConfig = { query?: ZodType; body?: ZodType; params?: ZodType; + response?: ZodType; + status?: number | number[]; }; -export type Guarded = { +export type GuardedRequest = { query: QueryT; body: BodyT; params: ParametersT; }; -export type WithGuardedContext< +export type WithGuardedRequestContext< ContextT extends IRouterParamContext, GuardQueryT, GuardBodyT, GuardParametersT > = ContextT & { - guard: Guarded; + guard: GuardedRequest; }; export type WithGuardConfig< Type, GuardQueryT = unknown, GuardBodyT = unknown, - GuardParametersT = unknown + GuardParametersT = unknown, + GuardResponseT = unknown > = Type & { - config: GuardConfig; + config: GuardConfig; }; export const isGuardMiddleware = ( @@ -56,30 +61,32 @@ const tryParse = ( export default function koaGuard< StateT, ContextT extends IRouterParamContext, - ResponseBodyT, GuardQueryT = undefined, GuardBodyT = undefined, - GuardParametersT = undefined + GuardParametersT = undefined, + GuardResponseT = unknown >({ query, body, params, -}: GuardConfig): MiddlewareType< + response, + status, +}: GuardConfig): MiddlewareType< StateT, - WithGuardedContext, - ResponseBodyT + WithGuardedRequestContext, + GuardResponseT > { const guard: MiddlewareType< StateT, - WithGuardedContext, - ResponseBodyT + WithGuardedRequestContext, + GuardResponseT > = async (ctx, next) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions ctx.guard = { query: tryParse('query', query, ctx.request.query), body: tryParse('body', body, ctx.request.body), params: tryParse('params', params, ctx.params), - } as Guarded; // Have to do this since it's too complicated for TS + } as GuardedRequest; // Have to do this since it's too complicated for TS return next(); }; @@ -87,15 +94,28 @@ export default function koaGuard< const guardMiddleware: WithGuardConfig< MiddlewareType< StateT, - WithGuardedContext, - ResponseBodyT + WithGuardedRequestContext, + GuardResponseT > > = async function (ctx, next) { if (body) { return koaBody()(ctx, async () => guard(ctx, next)); } - return guard(ctx, next); + await guard(ctx, next); + + if (status !== undefined) { + assertThat( + Array.isArray(status) + ? status.includes(ctx.response.status) + : status === ctx.response.status, + new ServerError() + ); + } + + if (response !== undefined) { + assertThat(response.safeParse(ctx.body).success, new ServerError()); + } }; // Intended diff --git a/packages/core/src/routes/log.test.ts b/packages/core/src/routes/log.test.ts index 5d3cf20ec..d3db95451 100644 --- a/packages/core/src/routes/log.test.ts +++ b/packages/core/src/routes/log.test.ts @@ -2,8 +2,9 @@ import { LogCondition } from '@/queries/log'; import logRoutes from '@/routes/log'; import { createRequester } from '@/utils/test-utils'; -const mockLog = { id: 1 }; -const mockLogs = [mockLog, { id: 2 }]; +const mockBody = { type: 'a', payload: {}, createdAt: 123 }; +const mockLog = { id: '1', ...mockBody }; +const mockLogs = [mockLog, { id: '2', ...mockBody }]; /* eslint-disable @typescript-eslint/no-unused-vars */ const countLogs = jest.fn(async (condition: LogCondition) => ({ diff --git a/packages/core/src/routes/log.ts b/packages/core/src/routes/log.ts index 2c3d65937..3c1fca537 100644 --- a/packages/core/src/routes/log.ts +++ b/packages/core/src/routes/log.ts @@ -1,3 +1,4 @@ +import { Logs } from '@logto/schemas'; import { object, string } from 'zod'; import koaGuard from '@/middleware/koa-guard'; @@ -38,7 +39,7 @@ export default function logRoutes(router: T) { router.get( '/logs/:id', - koaGuard({ params: object({ id: string().min(1) }) }), + koaGuard({ params: object({ id: string().min(1) }), response: Logs.guard }), async (ctx, next) => { const { params: { id }, diff --git a/packages/core/src/routes/status.ts b/packages/core/src/routes/status.ts index c126a7075..10fd9de18 100644 --- a/packages/core/src/routes/status.ts +++ b/packages/core/src/routes/status.ts @@ -1,7 +1,9 @@ +import koaGuard from '@/middleware/koa-guard'; + import { AnonymousRouter } from './types'; export default function statusRoutes(router: T) { - router.get('/status', async (ctx, next) => { + router.get('/status', koaGuard({ status: 204 }), async (ctx, next) => { ctx.status = 204; return next(); diff --git a/packages/schemas/src/foundations/schemas.ts b/packages/schemas/src/foundations/schemas.ts index 9bba3d3c8..570cf3cb9 100644 --- a/packages/schemas/src/foundations/schemas.ts +++ b/packages/schemas/src/foundations/schemas.ts @@ -4,10 +4,14 @@ type ParseOptional = undefined extends K ? ZodOptional>> : ZodType; -export type Guard> = ZodObject<{ +export type CreateGuard> = ZodObject<{ [key in keyof T]-?: ParseOptional; }>; +export type Guard> = ZodObject<{ + [key in keyof T]: ZodType; +}>; + export type SchemaValuePrimitive = string | number | boolean | undefined; export type SchemaValue = SchemaValuePrimitive | Record | unknown[] | null; export type SchemaLike = { @@ -22,6 +26,7 @@ export type GeneratedSchema = keyof Schema extends st [key in keyof Schema]: string; }; fieldKeys: ReadonlyArray; - createGuard: Guard; + createGuard: CreateGuard; + guard: Guard; }> : never; diff --git a/packages/schemas/src/gen/index.ts b/packages/schemas/src/gen/index.ts index 38ba73fa5..a39f7d020 100644 --- a/packages/schemas/src/gen/index.ts +++ b/packages/schemas/src/gen/index.ts @@ -174,7 +174,7 @@ const generate = async () => { })); if (tableWithTypes.length > 0) { - tsTypes.push('GeneratedSchema', 'Guard'); + tsTypes.push('GeneratedSchema', 'Guard', 'CreateGuard'); } /* eslint-enable @silverhand/fp/no-mutating-methods */ diff --git a/packages/schemas/src/gen/schema.ts b/packages/schemas/src/gen/schema.ts index 69bd12c9f..080e387fe 100644 --- a/packages/schemas/src/gen/schema.ts +++ b/packages/schemas/src/gen/schema.ts @@ -32,23 +32,38 @@ export const generateSchema = ({ name, fields }: TableWithType) => { ), '};', '', - `const createGuard: Guard<${databaseEntryType}> = z.object({`, + `const createGuard: CreateGuard<${databaseEntryType}> = z.object({`, // eslint-disable-next-line complexity ...fields.map(({ name, type, isArray, isEnum, nullable, hasDefaultValue, tsType }) => { if (tsType) { return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString( - nullable && !hasDefaultValue && '.nullable()' + nullable && '.nullable()' )}${conditionalString((nullable || hasDefaultValue) && '.optional()')},`; } return ` ${camelcase(name)}: z.${ isEnum ? `nativeEnum(${type})` : `${type}()` }${conditionalString(isArray && '.array()')}${conditionalString( - nullable && !hasDefaultValue && '.nullable()' + nullable && '.nullable()' )}${conditionalString((nullable || hasDefaultValue) && '.optional()')},`; }), ' });', '', + `const guard: Guard<${modelName}> = z.object({`, + // eslint-disable-next-line complexity + ...fields.map(({ name, type, isArray, isEnum, nullable, tsType }) => { + if (tsType) { + return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString( + nullable && '.nullable()' + )},`; + } + + return ` ${camelcase(name)}: z.${ + isEnum ? `nativeEnum(${type})` : `${type}()` + }${conditionalString(isArray && '.array()')}${conditionalString(nullable && '.nullable()')},`; + }), + ' });', + '', `export const ${camelcase(name, { pascalCase: true, })}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`, @@ -59,8 +74,9 @@ export const generateSchema = ({ name, fields }: TableWithType) => { ' },', ' fieldKeys: [', ...fields.map(({ name }) => ` '${camelcase(name)}',`), - ' ],', + ' ] as const,', ' createGuard,', + ' guard,', '});', ].join('\n'); };