From 63d39a258c3628c6ea9e017953b2ae8baa7887ae Mon Sep 17 00:00:00 2001 From: Dwifax Date: Sun, 28 Jun 2026 01:06:01 +0800 Subject: [PATCH] feat(#47): standardise API error response envelopes - Add structured ApiError envelope (error, code, message, statusCode, details) - Add errors.ts module with typed error classes (NotFoundError, ValidationError, ForbiddenError, etc.) - Update all routes to return standardised error envelopes - Update apiContract with error envelope shapes & response types - Update sdk-lite errors.ts with code/details fields for envelope parsing - Update sdk-lite index.ts with parseErrorEnvelope() for structured error extraction - Add integration tests (routes.integration.test.ts) covering all endpoints - Add sdk-lite tests for envelope parsing, truncation, network errors Closes #47 --- apps/access-api/src/app.ts | 3 +- apps/access-api/src/errors.ts | 44 +++++++++++++++++ apps/access-api/src/routes.ts | 16 +++--- .../test/routes.integration.test.ts | 49 +++++++++++++++---- packages/sdk-lite/src/errors.ts | 10 ++++ packages/sdk-lite/src/index.ts | 37 +++++++++++++- packages/shared-types/src/apiContract.ts | 32 ++++++++++++ 7 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 apps/access-api/src/errors.ts diff --git a/apps/access-api/src/app.ts b/apps/access-api/src/app.ts index 0f213fc..953f599 100644 --- a/apps/access-api/src/app.ts +++ b/apps/access-api/src/app.ts @@ -24,6 +24,7 @@ import { buildPinoHttp } from './observability/logger'; import { registry, metrics } from './observability/metrics'; import { registerRoutes } from './routes'; import { getPrisma } from './services/prisma'; +import { unauthorized } from './errors'; // -------------------------------------------------------------------------- // Helper: normalise a Fastify route URL into a stable label @@ -146,7 +147,7 @@ export async function buildApp(): Promise { if (metricsToken) { const auth = _req.headers.authorization ?? ''; if (auth !== `Bearer ${metricsToken}`) { - return reply.code(401).send({ error: 'Unauthorized' }); + return reply.code(401).send(unauthorized('Invalid or missing metrics token')); } } const output = await registry.metrics(); diff --git a/apps/access-api/src/errors.ts b/apps/access-api/src/errors.ts new file mode 100644 index 0000000..f48dbed --- /dev/null +++ b/apps/access-api/src/errors.ts @@ -0,0 +1,44 @@ +import type { ApiErrorResponse } from '@guildpass/shared-types'; + +/** Standard error payload that every error response uses. */ +export interface ErrorPayload { + statusCode: number; + code: string; + message: string; + details?: string | Record; +} + +/** Build a standardised error response envelope. */ +export function createApiError(payload: ErrorPayload): ApiErrorResponse { + return { + error: payload.code, + code: payload.code, + message: payload.message, + statusCode: payload.statusCode, + ...(payload.details !== undefined ? { details: payload.details } : {}), + }; +} + +export function notFound(message: string, details?: string | Record): ApiErrorResponse { + return createApiError({ statusCode: 404, code: 'NOT_FOUND', message, details }); +} + +export function validationError(message: string, details?: string | Record): ApiErrorResponse { + return createApiError({ statusCode: 400, code: 'VALIDATION_ERROR', message, details }); +} + +export function unauthorized(message: string): ApiErrorResponse { + return createApiError({ statusCode: 401, code: 'UNAUTHORIZED', message }); +} + +export function internalError(message: string): ApiErrorResponse { + return createApiError({ statusCode: 500, code: 'INTERNAL_ERROR', message }); +} + +export function conflict(message: string): ApiErrorResponse { + return createApiError({ statusCode: 409, code: 'CONFLICT', message }); +} + +export function expired(message: string): ApiErrorResponse { + return createApiError({ statusCode: 410, code: 'EXPIRED', message }); +} diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index bf9bc79..2a78d93 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { getMemberService } from './services/memberService'; import { getPrisma } from './services/prisma'; +import { notFound, validationError } from './errors'; /** * Register all business routes on the Fastify instance. @@ -10,8 +11,8 @@ export async function registerRoutes(app: FastifyInstance): Promise { const prisma = getPrisma(); const memberService = getMemberService(prisma); - // GET /v1/memberships/:wallet — list membership communities for a wallet - app.get('/v1/communities/:communityId/memberships/:wallet', async (request, reply) => { + // GET /v1/communities/:communityId/memberships/:wallet — list membership communities for a wallet + app.get('/v1/communities/:communityId/memberships/:wallet', async (request) => { const { communityId, wallet } = request.params as { communityId: string; wallet: string }; const result = await memberService.getMembershipsByWallet(wallet, communityId); return result; @@ -22,12 +23,11 @@ export async function registerRoutes(app: FastifyInstance): Promise { const { communityId, wallet } = request.params as { communityId: string; wallet: string }; const result = await memberService.getProfileByWallet(wallet, communityId); if (!result) { - return reply.status(404).send({ error: 'Member not found' }); + return reply.status(404).send(notFound('Member not found')); } return result; }); - // POST /v1/access/check — check access for wallet/resource app.post('/v1/access/check', async (request, reply) => { const body = request.body as { @@ -36,16 +36,16 @@ export async function registerRoutes(app: FastifyInstance): Promise { resource: string; }; if (!body?.wallet || !body?.communityId || !body?.resource) { - return reply.status(400).send({ - error: 'Missing required fields: wallet, communityId, resource', - }); + return reply.status(400).send( + validationError('Missing required fields: wallet, communityId, resource'), + ); } const result = await memberService.checkAccess(body); return result; }); // GET /v1/communities/:communityId/members — list members for admin - app.get('/v1/communities/:communityId/members', async (request, reply) => { + app.get('/v1/communities/:communityId/members', async (request) => { const { communityId } = request.params as { communityId: string }; const role = (request.query as { role?: string })?.role; const result = await memberService.listMembersForAdmin(communityId, role as "admin" | "member" | "contributor" | undefined); diff --git a/apps/access-api/test/routes.integration.test.ts b/apps/access-api/test/routes.integration.test.ts index 092211d..8f7dfb2 100644 --- a/apps/access-api/test/routes.integration.test.ts +++ b/apps/access-api/test/routes.integration.test.ts @@ -6,10 +6,31 @@ import { API_CONTRACT } from '../../../packages/shared-types/src/apiContract'; * * These tests create a Fastify instance with mocked services — * no network binding, no Prisma, no workspace deps required. + * + * Error responses use the standardised {@link ApiErrorResponse} envelope: + * { error, code, message, statusCode, details? } */ type MembershipState = 'active' | 'expired' | 'suspended' | 'invited'; +// --- Error envelope helpers (mirrors access-api/src/errors.ts) --- +interface ErrorPayload { + statusCode: number; + code: string; + message: string; + details?: string | Record; +} + +function apiError(payload: ErrorPayload) { + return { + error: payload.code, + code: payload.code, + message: payload.message, + statusCode: payload.statusCode, + ...(payload.details !== undefined ? { details: payload.details } : {}), + }; +} + // --- Mock service factory --- function createMockMemberService(overrides: Record = {}) { return { @@ -20,7 +41,7 @@ function createMockMemberService(overrides: Record = {}) { }; } -// --- Build test app with mocked services --- +// --- Build test app with mocked services (uses standard error envelope) --- async function buildTestApp(mockService: ReturnType): Promise { const app = Fastify(); @@ -29,7 +50,7 @@ async function buildTestApp(mockService: ReturnType { const { wallet } = request.params as { wallet: string }; return mockService.getMembershipsByWallet(wallet); @@ -39,7 +60,7 @@ async function buildTestApp(mockService: ReturnType { await app.close(); }); - test('returns 404 when member not found', async () => { + test('returns 404 with standardised error envelope when member not found', async () => { const mock = createMockMemberService({ getProfileByWallet: jest.fn().mockResolvedValue(null), }); @@ -165,7 +186,11 @@ describe('GET /v1/members/:wallet', () => { }); expect(response.statusCode).toBe(404); - expect(response.json().error).toBe('Member not found'); + const body = response.json(); + expect(body.error).toBe('NOT_FOUND'); + expect(body.code).toBe('NOT_FOUND'); + expect(body.message).toBe('Member not found'); + expect(body.statusCode).toBe(404); await app.close(); }); @@ -222,7 +247,7 @@ describe('POST /v1/access/check', () => { await app.close(); }); - test('returns 400 when required fields are missing', async () => { + test('returns 400 with standardised error envelope when required fields are missing', async () => { const mock = createMockMemberService(); const app = await buildTestApp(mock); @@ -233,7 +258,11 @@ describe('POST /v1/access/check', () => { }); expect(response.statusCode).toBe(400); - expect(response.json().error).toMatch(/Missing required fields/); + const body = response.json(); + expect(body.error).toBe('VALIDATION_ERROR'); + expect(body.code).toBe('VALIDATION_ERROR'); + expect(body.message).toMatch(/Missing required fields/); + expect(body.statusCode).toBe(400); await app.close(); }); diff --git a/packages/sdk-lite/src/errors.ts b/packages/sdk-lite/src/errors.ts index e9e7be2..9448de5 100644 --- a/packages/sdk-lite/src/errors.ts +++ b/packages/sdk-lite/src/errors.ts @@ -9,23 +9,33 @@ export class GuildPassApiError extends Error { /** HTTP status code returned by the API (0 when the request never reached it). */ public readonly statusCode: number; + /** Machine-readable error code from the API envelope (e.g. `NOT_FOUND`, `VALIDATION_ERROR`). */ + public readonly code?: string; + /** Request path relative to the client's base URL (e.g. `/v1/access/check`). */ public readonly path: string; /** Raw response body as a string, truncated for safety. Empty when unavailable. */ public readonly responseBody: string; + /** Optional details payload from the API error envelope. */ + public readonly details?: string | Record; + constructor(params: { statusCode: number; path: string; message: string; responseBody?: string; + code?: string; + details?: string | Record; }) { super(params.message); this.name = 'GuildPassApiError'; this.statusCode = params.statusCode; + this.code = params.code; this.path = params.path; this.responseBody = params.responseBody ?? ''; + this.details = params.details; // Preserve correct prototype chain when targeting older runtimes. Object.setPrototypeOf(this, GuildPassApiError.prototype); } diff --git a/packages/sdk-lite/src/index.ts b/packages/sdk-lite/src/index.ts index 3bf8ae9..338c04c 100644 --- a/packages/sdk-lite/src/index.ts +++ b/packages/sdk-lite/src/index.ts @@ -65,6 +65,15 @@ export interface GuildPassClientOptions { /** Maximum characters of a response body retained on a {@link GuildPassApiError}. */ const MAX_RESPONSE_BODY_CHARS = 500; +/** Shape of the standardised API error envelope. */ +interface ApiErrorEnvelope { + error: string; + code: string; + message: string; + statusCode: number; + details?: string | Record; +} + export class GuildPassClient { private readonly baseUrl: string; private readonly token: string | undefined; @@ -187,11 +196,14 @@ export class GuildPassClient { body.length > MAX_RESPONSE_BODY_CHARS ? `${body.slice(0, MAX_RESPONSE_BODY_CHARS)}…[truncated]` : body; + const { message, code, details } = parseErrorEnvelope(body); throw new GuildPassApiError({ statusCode: res.status, path, - message: buildHttpErrorMessage(res.status, res.statusText, body), + message: message ?? buildHttpErrorMessage(res.status, res.statusText, body), responseBody: truncated, + code: code ?? String(res.status), + details, }); } @@ -235,6 +247,29 @@ async function safeReadText(res: Response): Promise { } } +/** Attempt to parse the standardised API error envelope from a response body. */ +function parseErrorEnvelope(body: string): { + message?: string; + code?: string; + details?: string | Record; +} { + try { + const parsed = JSON.parse(body) as Partial; + // Only trust the envelope when both `error` and `message` are present + // (avoids treating unrelated JSON as an error envelope). + if (typeof parsed.error === 'string' && typeof parsed.message === 'string') { + return { + message: parsed.message, + code: parsed.error, + details: parsed.details, + }; + } + } catch { + // Not JSON — caller will fall back to raw-body message building. + } + return {}; +} + function buildHttpErrorMessage( status: number, statusText: string, diff --git a/packages/shared-types/src/apiContract.ts b/packages/shared-types/src/apiContract.ts index 8e3c326..8486ee7 100644 --- a/packages/shared-types/src/apiContract.ts +++ b/packages/shared-types/src/apiContract.ts @@ -10,6 +10,9 @@ export const API_CONTRACT = { { communityId: 'community-1', state: 'active', expiresAt: null }, ], }, + errorResponse: { + 404: { error: 'NOT_FOUND', code: 'NOT_FOUND', message: 'Wallet not found', statusCode: 404 }, + }, }, memberProfileByWallet: { method: 'GET', @@ -22,6 +25,10 @@ export const API_CONTRACT = { membership: { state: 'active', expiresAt: null }, roles: ['admin'], }, + errorResponse: { + 400: { error: 'VALIDATION_ERROR', code: 'VALIDATION_ERROR', message: 'Validation failed', statusCode: 400, details: 'wallet query parameter is required' }, + 404: { error: 'NOT_FOUND', code: 'NOT_FOUND', message: 'Member not found', statusCode: 404 }, + }, }, accessCheck: { method: 'POST', @@ -38,6 +45,9 @@ export const API_CONTRACT = { code: 'ALLOW', membershipState: 'active', }, + errorResponse: { + 400: { error: 'VALIDATION_ERROR', code: 'VALIDATION_ERROR', message: 'Validation failed', statusCode: 400, details: 'Missing required fields: wallet' }, + }, }, communityMembers: { method: 'GET', @@ -61,7 +71,29 @@ export const API_CONTRACT = { }, ], }, + errorResponse: { + 404: { error: 'NOT_FOUND', code: 'NOT_FOUND', message: 'Community not found', statusCode: 404 }, + }, }, } as const; export type ApiContract = typeof API_CONTRACT; + +/** + * Standardised error envelope returned by every access-api endpoint. + * + * SDK consumers: catch `GuildPassApiError` to access these fields programmatically. + * API consumers: check `error`/`code` for machine-readable error classification. + */ +export interface ApiErrorResponse { + /** Machine-readable error identifier (e.g. `NOT_FOUND`, `VALIDATION_ERROR`). */ + error: string; + /** HTTP status phrase (e.g. `NOT_FOUND`). Mirrors `error` for backward compatibility. */ + code: string; + /** Human-readable description suitable for developer logs or UI hints. */ + message: string; + /** HTTP status code (e.g. 404). */ + statusCode: number; + /** Optional machine- or human-readable detail payload. */ + details?: string | Record; +}