0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add response guard (#1542)

* feat(core): add response guard

* refactor(core): print error if not prod
This commit is contained in:
Gao Sun 2022-07-15 17:01:51 +08:00 committed by GitHub
parent 09cbc693b5
commit 6c39790180
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 87 additions and 32 deletions

View file

@ -0,0 +1,3 @@
// Needs to standardize
export default class ServerError extends Error {}

View file

@ -38,8 +38,9 @@ describe('koaErrorHandler middleware', () => {
expect(ctx.body).toEqual(mockBody); 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')); next.mockRejectedValueOnce(new Error('err'));
await expect(koaErrorHandler()(ctx, next)).rejects.toThrow(); await koaErrorHandler()(ctx, next);
expect(ctx.status).toEqual(500);
}); });
}); });

View file

@ -1,17 +1,22 @@
import { RequestErrorBody } from '@logto/schemas'; import { RequestErrorBody } from '@logto/schemas';
import { Middleware } from 'koa'; import { Middleware } from 'koa';
import envSet from '@/env-set';
import RequestError from '@/errors/RequestError'; import RequestError from '@/errors/RequestError';
export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware< export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
StateT, StateT,
ContextT, ContextT,
BodyT | RequestErrorBody BodyT | RequestErrorBody | { message: string }
> { > {
return async (ctx, next) => { return async (ctx, next) => {
try { try {
await next(); await next();
} catch (error: unknown) { } catch (error: unknown) {
if (!envSet.values.isProduction) {
console.error(error);
}
if (error instanceof RequestError) { if (error instanceof RequestError) {
ctx.status = error.status; ctx.status = error.status;
ctx.body = error.body; ctx.body = error.body;
@ -19,7 +24,8 @@ export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
return; return;
} }
throw error; ctx.status = 500;
ctx.body = { message: 'Internal server error.' };
} }
}; };
} }

View file

@ -5,35 +5,40 @@ import { IMiddleware, IRouterParamContext } from 'koa-router';
import { ZodType } from 'zod'; import { ZodType } from 'zod';
import RequestError from '@/errors/RequestError'; import RequestError from '@/errors/RequestError';
import ServerError from '@/errors/ServerError';
import assertThat from '@/utils/assert-that';
export type GuardConfig<QueryT, BodyT, ParametersT> = { export type GuardConfig<QueryT, BodyT, ParametersT, ResponseT> = {
query?: ZodType<QueryT>; query?: ZodType<QueryT>;
body?: ZodType<BodyT>; body?: ZodType<BodyT>;
params?: ZodType<ParametersT>; params?: ZodType<ParametersT>;
response?: ZodType<ResponseT>;
status?: number | number[];
}; };
export type Guarded<QueryT, BodyT, ParametersT> = { export type GuardedRequest<QueryT, BodyT, ParametersT> = {
query: QueryT; query: QueryT;
body: BodyT; body: BodyT;
params: ParametersT; params: ParametersT;
}; };
export type WithGuardedContext< export type WithGuardedRequestContext<
ContextT extends IRouterParamContext, ContextT extends IRouterParamContext,
GuardQueryT, GuardQueryT,
GuardBodyT, GuardBodyT,
GuardParametersT GuardParametersT
> = ContextT & { > = ContextT & {
guard: Guarded<GuardQueryT, GuardBodyT, GuardParametersT>; guard: GuardedRequest<GuardQueryT, GuardBodyT, GuardParametersT>;
}; };
export type WithGuardConfig< export type WithGuardConfig<
Type, Type,
GuardQueryT = unknown, GuardQueryT = unknown,
GuardBodyT = unknown, GuardBodyT = unknown,
GuardParametersT = unknown GuardParametersT = unknown,
GuardResponseT = unknown
> = Type & { > = Type & {
config: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>; config: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT, GuardResponseT>;
}; };
export const isGuardMiddleware = <Type extends IMiddleware>( export const isGuardMiddleware = <Type extends IMiddleware>(
@ -56,30 +61,32 @@ const tryParse = <Output, Definition, Input>(
export default function koaGuard< export default function koaGuard<
StateT, StateT,
ContextT extends IRouterParamContext, ContextT extends IRouterParamContext,
ResponseBodyT,
GuardQueryT = undefined, GuardQueryT = undefined,
GuardBodyT = undefined, GuardBodyT = undefined,
GuardParametersT = undefined GuardParametersT = undefined,
GuardResponseT = unknown
>({ >({
query, query,
body, body,
params, params,
}: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT>): MiddlewareType< response,
status,
}: GuardConfig<GuardQueryT, GuardBodyT, GuardParametersT, GuardResponseT>): MiddlewareType<
StateT, StateT,
WithGuardedContext<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>, WithGuardedRequestContext<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>,
ResponseBodyT GuardResponseT
> { > {
const guard: MiddlewareType< const guard: MiddlewareType<
StateT, StateT,
WithGuardedContext<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>, WithGuardedRequestContext<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>,
ResponseBodyT GuardResponseT
> = async (ctx, next) => { > = async (ctx, next) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
ctx.guard = { ctx.guard = {
query: tryParse('query', query, ctx.request.query), query: tryParse('query', query, ctx.request.query),
body: tryParse('body', body, ctx.request.body), body: tryParse('body', body, ctx.request.body),
params: tryParse('params', params, ctx.params), params: tryParse('params', params, ctx.params),
} as Guarded<GuardQueryT, GuardBodyT, GuardParametersT>; // Have to do this since it's too complicated for TS } as GuardedRequest<GuardQueryT, GuardBodyT, GuardParametersT>; // Have to do this since it's too complicated for TS
return next(); return next();
}; };
@ -87,15 +94,28 @@ export default function koaGuard<
const guardMiddleware: WithGuardConfig< const guardMiddleware: WithGuardConfig<
MiddlewareType< MiddlewareType<
StateT, StateT,
WithGuardedContext<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>, WithGuardedRequestContext<ContextT, GuardQueryT, GuardBodyT, GuardParametersT>,
ResponseBodyT GuardResponseT
> >
> = async function (ctx, next) { > = async function (ctx, next) {
if (body) { if (body) {
return koaBody<StateT, ContextT>()(ctx, async () => guard(ctx, next)); return koaBody<StateT, ContextT>()(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 // Intended

View file

@ -2,8 +2,9 @@ import { LogCondition } from '@/queries/log';
import logRoutes from '@/routes/log'; import logRoutes from '@/routes/log';
import { createRequester } from '@/utils/test-utils'; import { createRequester } from '@/utils/test-utils';
const mockLog = { id: 1 }; const mockBody = { type: 'a', payload: {}, createdAt: 123 };
const mockLogs = [mockLog, { id: 2 }]; const mockLog = { id: '1', ...mockBody };
const mockLogs = [mockLog, { id: '2', ...mockBody }];
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
const countLogs = jest.fn(async (condition: LogCondition) => ({ const countLogs = jest.fn(async (condition: LogCondition) => ({

View file

@ -1,3 +1,4 @@
import { Logs } from '@logto/schemas';
import { object, string } from 'zod'; import { object, string } from 'zod';
import koaGuard from '@/middleware/koa-guard'; import koaGuard from '@/middleware/koa-guard';
@ -38,7 +39,7 @@ export default function logRoutes<T extends AuthedRouter>(router: T) {
router.get( router.get(
'/logs/:id', '/logs/:id',
koaGuard({ params: object({ id: string().min(1) }) }), koaGuard({ params: object({ id: string().min(1) }), response: Logs.guard }),
async (ctx, next) => { async (ctx, next) => {
const { const {
params: { id }, params: { id },

View file

@ -1,7 +1,9 @@
import koaGuard from '@/middleware/koa-guard';
import { AnonymousRouter } from './types'; import { AnonymousRouter } from './types';
export default function statusRoutes<T extends AnonymousRouter>(router: T) { export default function statusRoutes<T extends AnonymousRouter>(router: T) {
router.get('/status', async (ctx, next) => { router.get('/status', koaGuard({ status: 204 }), async (ctx, next) => {
ctx.status = 204; ctx.status = 204;
return next(); return next();

View file

@ -4,10 +4,14 @@ type ParseOptional<K> = undefined extends K
? ZodOptional<ZodType<Exclude<K, undefined>>> ? ZodOptional<ZodType<Exclude<K, undefined>>>
: ZodType<K>; : ZodType<K>;
export type Guard<T extends Record<string, unknown>> = ZodObject<{ export type CreateGuard<T extends Record<string, unknown>> = ZodObject<{
[key in keyof T]-?: ParseOptional<T[key]>; [key in keyof T]-?: ParseOptional<T[key]>;
}>; }>;
export type Guard<T extends Record<string, unknown>> = ZodObject<{
[key in keyof T]: ZodType<T[key]>;
}>;
export type SchemaValuePrimitive = string | number | boolean | undefined; export type SchemaValuePrimitive = string | number | boolean | undefined;
export type SchemaValue = SchemaValuePrimitive | Record<string, unknown> | unknown[] | null; export type SchemaValue = SchemaValuePrimitive | Record<string, unknown> | unknown[] | null;
export type SchemaLike<Key extends string = string> = { export type SchemaLike<Key extends string = string> = {
@ -22,6 +26,7 @@ export type GeneratedSchema<Schema extends SchemaLike> = keyof Schema extends st
[key in keyof Schema]: string; [key in keyof Schema]: string;
}; };
fieldKeys: ReadonlyArray<keyof Schema>; fieldKeys: ReadonlyArray<keyof Schema>;
createGuard: Guard<Schema>; createGuard: CreateGuard<Schema>;
guard: Guard<Schema>;
}> }>
: never; : never;

View file

@ -174,7 +174,7 @@ const generate = async () => {
})); }));
if (tableWithTypes.length > 0) { if (tableWithTypes.length > 0) {
tsTypes.push('GeneratedSchema', 'Guard'); tsTypes.push('GeneratedSchema', 'Guard', 'CreateGuard');
} }
/* eslint-enable @silverhand/fp/no-mutating-methods */ /* eslint-enable @silverhand/fp/no-mutating-methods */

View file

@ -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 // eslint-disable-next-line complexity
...fields.map(({ name, type, isArray, isEnum, nullable, hasDefaultValue, tsType }) => { ...fields.map(({ name, type, isArray, isEnum, nullable, hasDefaultValue, tsType }) => {
if (tsType) { if (tsType) {
return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString( return ` ${camelcase(name)}: ${camelcase(tsType)}Guard${conditionalString(
nullable && !hasDefaultValue && '.nullable()' nullable && '.nullable()'
)}${conditionalString((nullable || hasDefaultValue) && '.optional()')},`; )}${conditionalString((nullable || hasDefaultValue) && '.optional()')},`;
} }
return ` ${camelcase(name)}: z.${ return ` ${camelcase(name)}: z.${
isEnum ? `nativeEnum(${type})` : `${type}()` isEnum ? `nativeEnum(${type})` : `${type}()`
}${conditionalString(isArray && '.array()')}${conditionalString( }${conditionalString(isArray && '.array()')}${conditionalString(
nullable && !hasDefaultValue && '.nullable()' nullable && '.nullable()'
)}${conditionalString((nullable || hasDefaultValue) && '.optional()')},`; )}${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, { `export const ${camelcase(name, {
pascalCase: true, pascalCase: true,
})}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`, })}: GeneratedSchema<${databaseEntryType}> = Object.freeze({`,
@ -59,8 +74,9 @@ export const generateSchema = ({ name, fields }: TableWithType) => {
' },', ' },',
' fieldKeys: [', ' fieldKeys: [',
...fields.map(({ name }) => ` '${camelcase(name)}',`), ...fields.map(({ name }) => ` '${camelcase(name)}',`),
' ],', ' ] as const,',
' createGuard,', ' createGuard,',
' guard,',
'});', '});',
].join('\n'); ].join('\n');
}; };