diff --git a/packages/connector-apple/src/index.ts b/packages/connector-apple/src/index.ts index a8570abab..d0c7396f1 100644 --- a/packages/connector-apple/src/index.ts +++ b/packages/connector-apple/src/index.ts @@ -23,7 +23,7 @@ export default class AppleConnector implements SocialConnector { const result = appleConfigGuard.safeParse(config); if (!result.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message); + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error); } }; diff --git a/packages/connector-types/src/index.ts b/packages/connector-types/src/index.ts index fc36eab2b..c6a723dfb 100644 --- a/packages/connector-types/src/index.ts +++ b/packages/connector-types/src/index.ts @@ -41,10 +41,13 @@ export enum ConnectorErrorCodes { export class ConnectorError extends Error { public code: ConnectorErrorCodes; + public data: unknown; - constructor(code: ConnectorErrorCodes, message?: string) { + constructor(code: ConnectorErrorCodes, data?: unknown) { + const message = typeof data === 'string' ? data : 'Connector error occurred.'; super(message); this.code = code; + this.data = typeof data === 'string' ? { message: data } : data; } } diff --git a/packages/console/src/components/Toast/index.module.scss b/packages/console/src/components/Toast/index.module.scss index 7357f75f5..d8d481142 100644 --- a/packages/console/src/components/Toast/index.module.scss +++ b/packages/console/src/components/Toast/index.module.scss @@ -31,5 +31,6 @@ div.toast { &.error { border: 1px solid var(--color-error); background-color: var(--color-danger-toast-background); + white-space: pre-line; } } diff --git a/packages/console/src/hooks/use-api.ts b/packages/console/src/hooks/use-api.ts index 967cdd47c..5d1e227f0 100644 --- a/packages/console/src/hooks/use-api.ts +++ b/packages/console/src/hooks/use-api.ts @@ -20,7 +20,9 @@ export class RequestError extends Error { const toastError = async (response: Response) => { try { const data = await response.json(); - toast.error(data.message || t('admin_console.errors.unknown_server_error')); + toast.error( + [data.message, data.details].join('\n') || t('admin_console.errors.unknown_server_error') + ); } catch { toast.error(t('admin_console.errors.unknown_server_error')); } diff --git a/packages/core/src/errors/RequestError/index.ts b/packages/core/src/errors/RequestError/index.ts index b16f837e2..065b261bc 100644 --- a/packages/core/src/errors/RequestError/index.ts +++ b/packages/core/src/errors/RequestError/index.ts @@ -1,8 +1,22 @@ import { LogtoErrorCode, LogtoErrorI18nKey } from '@logto/phrases'; import { RequestErrorBody, RequestErrorMetadata } from '@logto/schemas'; +import { conditional, Optional } from '@silverhand/essentials'; import i18next from 'i18next'; import pick from 'lodash.pick'; +import { ZodError } from 'zod'; +const formatZodError = ({ issues }: ZodError): string[] => + issues.map((issue) => { + const base = `Error in key path "${issue.path.map((node) => String(node)).join('.')}": (${ + issue.code + }) `; + + if (issue.code === 'invalid_type') { + return base + `Expected ${issue.expected} but received ${issue.received}.`; + } + + return base + issue.message; + }); export default class RequestError extends Error { code: LogtoErrorCode; status: number; @@ -27,6 +41,10 @@ export default class RequestError extends Error { } get body(): RequestErrorBody { - return pick(this, 'message', 'code', 'data'); + return pick(this, 'message', 'code', 'data', 'details'); + } + + get details(): Optional { + return conditional(this.data instanceof ZodError && formatZodError(this.data).join('\n')); } } diff --git a/packages/core/src/middleware/koa-connector-error-handler.ts b/packages/core/src/middleware/koa-connector-error-handler.ts index 3b704ce37..3a2cfd2a7 100644 --- a/packages/core/src/middleware/koa-connector-error-handler.ts +++ b/packages/core/src/middleware/koa-connector-error-handler.ts @@ -12,10 +12,7 @@ export default function koaConnectorErrorHandler(): Middleware throw error; } - const { code, message } = error; - - // Original OIDCProvider Error description and details are provided in the data field - const data = { message }; + const { code, data } = error; switch (code) { case ConnectorErrorCodes.InsufficientRequestParameters: diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 00845c345..f3ace258d 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -1,4 +1,4 @@ -import { has } from '@silverhand/essentials'; +import { has, Optional } from '@silverhand/essentials'; import { MiddlewareType } from 'koa'; import koaBody from 'koa-body'; import { IMiddleware, IRouterParamContext } from 'koa-router'; @@ -41,6 +41,18 @@ export const isGuardMiddleware = ( ): function_ is WithGuardConfig => function_.name === 'guardMiddleware' && has(function_, 'config'); +const tryParse = ( + type: 'query' | 'body' | 'params', + guard: Optional>, + data: unknown +) => { + try { + return guard?.parse(data); + } catch (error: unknown) { + throw new RequestError({ code: 'guard.invalid_input', type }, error); + } +}; + export default function koaGuard< StateT, ContextT extends IRouterParamContext, @@ -62,16 +74,12 @@ export default function koaGuard< WithGuardedContext, ResponseBodyT > = async (ctx, next) => { - try { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - ctx.guard = { - query: query?.parse(ctx.request.query), - body: body?.parse(ctx.request.body), - params: params?.parse(ctx.params), - } as Guarded; // Have to do this since it's too complicated for TS - } catch (error: unknown) { - throw new RequestError('guard.invalid_input', error); - } + // 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 return next(); }; diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 9932a5f2f..becf334f8 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -546,7 +546,7 @@ const errors = { jwt_sub_missing: 'Missing `sub` in JWT.', }, guard: { - invalid_input: 'The request input is invalid.', + invalid_input: 'The request {{type}} is invalid.', invalid_pagination: 'The request pagination value is invalid.', }, oidc: { diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index dec754d10..c8c46a87c 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -524,7 +524,7 @@ const errors = { jwt_sub_missing: 'JWT 缺失 `sub`', }, guard: { - invalid_input: '请求输入无效', + invalid_input: '请求中 {{type}} 无效', invalid_pagination: '分页参数无效', }, oidc: { diff --git a/packages/schemas/src/api/error.ts b/packages/schemas/src/api/error.ts index 2229d96ad..89e07c384 100644 --- a/packages/schemas/src/api/error.ts +++ b/packages/schemas/src/api/error.ts @@ -6,4 +6,9 @@ export type RequestErrorMetadata = Record & { expose?: boolean; }; -export type RequestErrorBody = { message: string; data: unknown; code: LogtoErrorCode }; +export type RequestErrorBody = { + message: string; + data: unknown; + code: LogtoErrorCode; + details?: string; +};