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:
parent
09cbc693b5
commit
6c39790180
10 changed files with 87 additions and 32 deletions
3
packages/core/src/errors/ServerError/index.ts
Normal file
3
packages/core/src/errors/ServerError/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
// Needs to standardize
|
||||||
|
|
||||||
|
export default class ServerError extends Error {}
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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.' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) => ({
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue