From 27b17cf3d0aa2b7d2fd04fb2823141e61165067d Mon Sep 17 00:00:00 2001 From: amandasmyths Date: Mon, 27 Jul 2026 23:41:49 +0000 Subject: [PATCH] feat: document error responses in Swagger for intents controller feat: add idempotency key support to intent creation fix: return 400 for pagination limits exceeding maximum fix: unify error response shape across all exception types - Add @ApiResponse decorators to all IntentsController routes documenting the specific HTTP exceptions they can throw (404, 409, 403, 410, 400) - Support optional idempotencyKey field in CreateIntentDto to deduplicate requests within a 24-hour TTL window - Validate pagination limit parameter and reject requests with limit > 100 with a 400 response instead of silently clamping - Standardize HttpExceptionFilter to ensure all error responses follow the unified { error: string, details?: unknown } shape - Add tests for pagination limit validation and idempotency deduplication closes #75 closes #76 closes #77 closes #78 --- src/common/http-exception.filter.ts | 4 ++-- src/intents/dto/create-intent.dto.ts | 5 +++++ src/intents/intents.controller.ts | 31 +++++++++++++++++++++++++--- src/intents/intents.service.ts | 21 ++++++++++++++++++- test/intents.e2e-spec.ts | 26 +++++++++++++++++++++++ 5 files changed, 81 insertions(+), 6 deletions(-) diff --git a/src/common/http-exception.filter.ts b/src/common/http-exception.filter.ts index cc4b44d..bbe1201 100644 --- a/src/common/http-exception.filter.ts +++ b/src/common/http-exception.filter.ts @@ -28,9 +28,9 @@ export class HttpExceptionFilter implements ExceptionFilter { return; } - // Already custom-shaped bodies passed directly to an exception constructor, + // Custom-shaped bodies passed directly to an exception constructor, // e.g. new BadRequestException({ error: "...", fillAmount, minDstAmount }) - if (typeof b.error === "string" && typeof b.message !== "string") { + if (typeof b.error === "string" && !b.statusCode) { response.status(status).json(b); return; } diff --git a/src/intents/dto/create-intent.dto.ts b/src/intents/dto/create-intent.dto.ts index ab9f69b..dc71c49 100644 --- a/src/intents/dto/create-intent.dto.ts +++ b/src/intents/dto/create-intent.dto.ts @@ -66,4 +66,9 @@ export class CreateIntentDto { @IsOptional() @IsInt() deadline?: number; + + @ApiPropertyOptional({ description: "Idempotency key for deduplicating duplicate requests" }) + @IsOptional() + @IsString() + idempotencyKey?: string; } diff --git a/src/intents/intents.controller.ts b/src/intents/intents.controller.ts index 6d3296d..773b1ac 100644 --- a/src/intents/intents.controller.ts +++ b/src/intents/intents.controller.ts @@ -11,7 +11,14 @@ import { Post, Query, } from "@nestjs/common"; -import { ApiTags } from "@nestjs/swagger"; +import { + ApiTags, + ApiNotFoundResponse, + ApiConflictResponse, + ApiForbiddenResponse, + ApiGoneResponse, + ApiBadRequestResponse, +} from "@nestjs/swagger"; import { IntentsService } from "./intents.service"; import { IntentsGateway } from "./intents.gateway"; import { SolversService } from "../solvers/solvers.service"; @@ -31,6 +38,7 @@ export class IntentsController { ) {} @Get() + @ApiBadRequestResponse({ description: "Invalid limit or offset" }) list( @Query("state") state?: string, @Query("user") user?: string, @@ -44,7 +52,10 @@ export class IntentsController { if (user) intents = intents.filter((i) => i.user.toLowerCase() === user.toLowerCase()); if (chain) intents = intents.filter((i) => i.srcChain === chain); - const limit = Math.min(parseInt(limitRaw, 10), 100); + const limit = parseInt(limitRaw, 10); + if (limit > 100) { + throw new BadRequestException("Limit exceeds maximum allowed value of 100"); + } const offset = parseInt(offsetRaw, 10); const page = intents.slice(offset, offset + limit); @@ -64,6 +75,7 @@ export class IntentsController { } @Get(":id") + @ApiNotFoundResponse({ description: "Intent not found" }) getOne(@Param("id") id: string) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); @@ -71,6 +83,7 @@ export class IntentsController { } @Post() + @ApiBadRequestResponse({ description: "Invalid request body" }) create(@Body() dto: CreateIntentDto) { const now = Math.floor(Date.now() / 1000); const intent = this.intentsService.create({ @@ -91,12 +104,16 @@ export class IntentsController { }, minDstAmount: dto.minDstAmount, deadline: dto.deadline ?? now + 1800, - }); + }, dto.idempotencyKey); this.intentsGateway.broadcast({ type: "intent_created", intent }); return intent; } @Post(":id/accept") + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiConflictResponse({ description: "Intent is not in open state" }) + @ApiGoneResponse({ description: "Intent has expired" }) + @ApiForbiddenResponse({ description: "Solver not registered or inactive" }) accept(@Param("id") id: string, @Body() dto: AcceptIntentDto) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); @@ -125,6 +142,11 @@ export class IntentsController { } @Post(":id/fill") + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiConflictResponse({ description: "Intent is not in accepted state" }) + @ApiForbiddenResponse({ description: "Wrong solver for this intent" }) + @ApiGoneResponse({ description: "Fill window has expired" }) + @ApiBadRequestResponse({ description: "Fill amount below minimum" }) fill(@Param("id") id: string, @Body() dto: FillIntentDto) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); @@ -166,6 +188,9 @@ export class IntentsController { } @Post(":id/cancel") + @ApiNotFoundResponse({ description: "Intent not found" }) + @ApiForbiddenResponse({ description: "Unauthorized" }) + @ApiConflictResponse({ description: "Intent is not in open state" }) cancel(@Param("id") id: string, @Body() dto: CancelIntentDto) { const intent = this.intentsService.get(id); if (!intent) throw new NotFoundException("Intent not found"); diff --git a/src/intents/intents.service.ts b/src/intents/intents.service.ts index 86ed406..969f967 100644 --- a/src/intents/intents.service.ts +++ b/src/intents/intents.service.ts @@ -6,13 +6,26 @@ import { buildSeedIntents } from "./intents.seed"; @Injectable() export class IntentsService { private readonly intents = new Map(); + private readonly idempotencyCache = new Map(); constructor() { this.seed(); } - create(data: Omit): Intent { + create(data: Omit, idempotencyKey?: string): Intent { const now = Math.floor(Date.now() / 1000); + + if (idempotencyKey) { + const cached = this.idempotencyCache.get(idempotencyKey); + if (cached && cached.expiresAt > now) { + const cachedIntent = this.intents.get(cached.intentId); + if (cachedIntent) { + return cachedIntent; + } + } + this.idempotencyCache.delete(idempotencyKey); + } + const intent: Intent = { ...data, intentId: uuidv4(), @@ -21,6 +34,12 @@ export class IntentsService { deadline: data.deadline ?? now + 1800, }; this.intents.set(intent.intentId, intent); + + if (idempotencyKey) { + const ttl = 86400; // 24 hours + this.idempotencyCache.set(idempotencyKey, { intentId: intent.intentId, expiresAt: now + ttl }); + } + return intent; } diff --git a/test/intents.e2e-spec.ts b/test/intents.e2e-spec.ts index 00c272b..2644d05 100644 --- a/test/intents.e2e-spec.ts +++ b/test/intents.e2e-spec.ts @@ -44,6 +44,14 @@ describe("IntentsController (e2e)", () => { expect(res.body.limit).toBe(2); }); + it("GET /api/v1/intents with limit > 100 returns 400", async () => { + const res = await request(app.getHttpServer()) + .get("/api/v1/intents") + .query({ limit: 500 }) + .expect(400); + expect(res.body.error).toBe("Limit exceeds maximum allowed value of 100"); + }); + it("GET /api/v1/intents/open returns only open intents", async () => { const res = await request(app.getHttpServer()).get("/api/v1/intents/open").expect(200); expect(res.body.intents.every((i: { state: string }) => i.state === "open")).toBe(true); @@ -164,4 +172,22 @@ describe("IntentsController (e2e)", () => { } expect(res.body.bestQuote.dstAmount).toBe(res.body.quotes[0].dstAmount); }); + + it("POST /api/v1/intents with idempotencyKey deduplicates requests", async () => { + const idempotencyKey = "test-key-" + Date.now(); + const createBody = { ...validCreateBody, user: "GIDEMPOTENCY1234567", idempotencyKey }; + + const res1 = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send(createBody) + .expect(201); + + const res2 = await request(app.getHttpServer()) + .post("/api/v1/intents") + .send(createBody) + .expect(201); + + expect(res1.body.intentId).toBe(res2.body.intentId); + expect(res1.body.createdAt).toBe(res2.body.createdAt); + }); });