From 0bfdcc7a595ac8624df5229f38aa5056ab944722 Mon Sep 17 00:00:00 2001 From: blaine-arcjet <146491715+blaine-arcjet@users.noreply.github.com> Date: Wed, 28 Feb 2024 04:52:28 -0700 Subject: [PATCH] feat(decorate): Allow decorating Headers object directly (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows decorating a `Header` object directly. This really cleans up the App Router example 🎉 --- decorate/index.ts | 103 ++- decorate/test/decorate.test.ts | 755 ++++++++++++++++++ .../app/api-app/arcjet/route.ts | 16 +- 3 files changed, 834 insertions(+), 40 deletions(-) diff --git a/decorate/index.ts b/decorate/index.ts index 0967a35dd..a3da8ffdc 100644 --- a/decorate/index.ts +++ b/decorate/index.ts @@ -6,10 +6,16 @@ import { ArcjetRuleResult, } from "@arcjet/protocol"; +interface HeaderLike { + has(name: string): boolean; + get(name: string): string | null; + set(name: string, value: string): void; +} + interface ResponseLike { // If this is defined, we can expect to be working with a `Response` or // `NextResponse`. - headers: Headers; + headers: HeaderLike; } interface OutgoingMessageLike { @@ -22,10 +28,15 @@ interface OutgoingMessageLike { ) => unknown; } -export interface ArcjetResponse { +export interface ArcjetCanDecorate { + // If these are defined, we can expect to be working with `Headers` directly + has?: (name: string) => boolean; + get?: (name: string) => string | null; + set?: (name: string, value: string) => void; + // If this is defined, we can expect to be working with a `Response` or // `NextResponse`. - headers?: Headers; + headers?: HeaderLike; // Otherwise, we'll be working with an `http.OutgoingMessage` and we'll need // to use these values. @@ -38,16 +49,14 @@ export interface ArcjetResponse { ) => unknown; } -function isResponseLike(response: ArcjetResponse): response is ResponseLike { - if (typeof response.headers === "undefined") { - return false; - } - +function isHeaderLike(value: ArcjetCanDecorate): value is HeaderLike { if ( - "has" in response.headers && - typeof response.headers.has === "function" && - "set" in response.headers && - typeof response.headers.set === "function" + "has" in value && + typeof value.has === "function" && + "get" in value && + typeof value.get === "function" && + "set" in value && + typeof value.set === "function" ) { return true; } @@ -55,8 +64,16 @@ function isResponseLike(response: ArcjetResponse): response is ResponseLike { return false; } +function isResponseLike(value: ArcjetCanDecorate): value is ResponseLike { + if (typeof value.headers === "undefined") { + return false; + } + + return isHeaderLike(value.headers); +} + function isOutgoingMessageLike( - response: ArcjetResponse, + response: ArcjetCanDecorate, ): response is OutgoingMessageLike { if (typeof response.headersSent !== "boolean") { return false; @@ -142,16 +159,17 @@ function nearestLimit( } /** - * Decorates a response with `RateLimit` and `RateLimit-Policy` headers based + * Decorates an object with `RateLimit` and `RateLimit-Policy` headers based * on an {@link ArcjetDecision} and conforming to the [Rate Limit fields for * HTTP](https://ietf-wg-httpapi.github.io/ratelimit-headers/draft-ietf-httpapi-ratelimit-headers.html) * draft specification. * - * @param response The response to decorate—must be similar to a DOM Response or node's OutgoingMessage. + * @param value The object to decorate—must be similar to {@link Headers}, {@link Response} or + * {@link OutgoingMessage}. * @param decision The {@link ArcjetDecision} that was made by calling `protect()` on the SDK. */ export function setRateLimitHeaders( - response: ArcjetResponse, + value: ArcjetCanDecorate, decision: ArcjetDecision, ) { const rateLimitReasons = decision.results @@ -211,55 +229,78 @@ export function setRateLimitHeaders( } } - if (isResponseLike(response)) { - if (response.headers.has("RateLimit")) { + if (isHeaderLike(value)) { + if (value.has("RateLimit")) { + logger.warn( + "Response already contains `RateLimit` header\n Original: %s\n New: %s", + value.get("RateLimit"), + limit, + ); + } + if (value.has("RateLimit-Policy")) { + logger.warn( + "Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", + value.get("RateLimit-Policy"), + limit, + ); + } + + value.set("RateLimit", limit); + value.set("RateLimit-Policy", policy); + + // The response was handled + return; + } + + if (isResponseLike(value)) { + if (value.headers.has("RateLimit")) { logger.warn( "Response already contains `RateLimit` header\n Original: %s\n New: %s", - response.headers.get("RateLimit"), + value.headers.get("RateLimit"), limit, ); } - if (response.headers.has("RateLimit-Policy")) { + if (value.headers.has("RateLimit-Policy")) { logger.warn( "Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", - response.headers.get("RateLimit-Policy"), + value.headers.get("RateLimit-Policy"), limit, ); } - response.headers.set("RateLimit", limit); - response.headers.set("RateLimit-Policy", policy); + value.headers.set("RateLimit", limit); + value.headers.set("RateLimit-Policy", policy); // The response was handled return; } - if (isOutgoingMessageLike(response)) { - if (response.headersSent) { + if (isOutgoingMessageLike(value)) { + if (value.headersSent) { logger.error( "Headers have already been sent—cannot set RateLimit header", ); return; } - if (response.hasHeader("RateLimit")) { + if (value.hasHeader("RateLimit")) { logger.warn( "Response already contains `RateLimit` header\n Original: %s\n New: %s", - response.getHeader("RateLimit"), + value.getHeader("RateLimit"), limit, ); } - if (response.hasHeader("RateLimit-Policy")) { + if (value.hasHeader("RateLimit-Policy")) { logger.warn( "Response already contains `RateLimit-Policy` header\n Original: %s\n New: %s", - response.getHeader("RateLimit-Policy"), + value.getHeader("RateLimit-Policy"), limit, ); } - response.setHeader("RateLimit", limit); - response.setHeader("RateLimit-Policy", policy); + value.setHeader("RateLimit", limit); + value.setHeader("RateLimit-Policy", policy); // The response was handled return; diff --git a/decorate/test/decorate.test.ts b/decorate/test/decorate.test.ts index 905f525d5..8af6f9c17 100644 --- a/decorate/test/decorate.test.ts +++ b/decorate/test/decorate.test.ts @@ -20,6 +20,761 @@ afterEach(() => { function noop() {} describe("setRateLimitHeaders", () => { + describe("Header object", () => { + test("empty results do not set headers", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("no rate limit results do not set headers", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetReason(), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("does not error if headers is missing `has` function", () => { + const headers = { + get: jest.fn((name: string) => null), + set: jest.fn((name: string, value: string) => null), + }; + + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.get).not.toHaveBeenCalled(); + expect(headers.set).not.toHaveBeenCalled(); + }); + + test("does not error if headers is missing `get` function", () => { + const headers = { + has: jest.fn((name: string) => false), + set: jest.fn((name: string, value: string) => null), + }; + + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has).not.toHaveBeenCalled(); + expect(headers.set).not.toHaveBeenCalled(); + }); + + test("does not error if headers is missing `set` function", () => { + const headers = { + has: jest.fn((name: string) => false), + get: jest.fn((name: string) => null), + }; + + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has).not.toHaveBeenCalled(); + expect(headers.get).not.toHaveBeenCalled(); + }); + + test("duplicate rate limit policies do not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit result `max` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + // @ts-expect-error + max: { abc: "xyz" }, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit result `window` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + // @ts-expect-error + window: { abc: "xyz" }, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit result `remaining` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + // @ts-expect-error + remaining: { abc: "xyz" }, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit result `reset` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + // @ts-expect-error + reset: { abc: "xyz" }, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("adds rate limit headers when only top-level reason exists", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [], + ttl: 0, + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=1, remaining=0, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("1;w=100"); + }); + + test("invalid rate limit reason `max` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [], + ttl: 0, + reason: new ArcjetRateLimitReason({ + // @ts-expect-error + max: { abc: "xyz" }, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit reason `window` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [], + ttl: 0, + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + // @ts-expect-error + window: { abc: "xyz" }, + }), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit reason `remaining` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [], + ttl: 0, + reason: new ArcjetRateLimitReason({ + max: 1, + // @ts-expect-error + remaining: { abc: "xyz" }, + reset: 100, + window: 100, + }), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("invalid rate limit reason `reset` does not set headers", () => { + const errorLogSpy = jest.spyOn(logger, "error").mockImplementation(noop); + + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [], + ttl: 0, + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + // @ts-expect-error + reset: { abc: "xyz" }, + window: 100, + }), + }), + ); + + expect(errorLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(false); + expect(headers.has("RateLimit-Policy")).toEqual(false); + }); + + test("adds rate limit headers when result exists", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=1, remaining=0, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("1;w=100"); + }); + + test("selects nearest limit and sets multiple policies when multiple rate limit headers results exist", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 100, + remaining: 99, + reset: 1000, + window: 1000, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=1, remaining=0, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("1;w=100, 100;w=1000"); + }); + + test("selects nearest limit in any order", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 100, + remaining: 99, + reset: 1000, + window: 1000, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=1, remaining=0, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("1;w=100, 100;w=1000"); + }); + + test("selects nearest reset if remaining are equal", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 100, + remaining: 99, + reset: 100, + window: 100, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1000, + remaining: 99, + reset: 1000, + window: 1000, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=100, remaining=99, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("100;w=100, 1000;w=1000"); + }); + + test("selects nearest reset in any order", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1000, + remaining: 99, + reset: 1000, + window: 1000, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 100, + remaining: 99, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=100, remaining=99, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("100;w=100, 1000;w=1000"); + }); + + test("selects lowest max if reset are equal", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 100, + remaining: 99, + reset: 100, + window: 100, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1000, + remaining: 99, + reset: 100, + window: 1000, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=100, remaining=99, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("100;w=100, 1000;w=1000"); + }); + + test("selects lowest max in any order", () => { + const headers = new Headers(); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1000, + remaining: 99, + reset: 100, + window: 1000, + }), + }), + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 100, + remaining: 99, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=100, remaining=99, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("100;w=100, 1000;w=1000"); + }); + + test("warns but adds the rate limit header if RateLimit already exists", () => { + const warnLogSpy = jest.spyOn(logger, "warn").mockImplementation(noop); + + const headers = new Headers(); + headers.set("RateLimit", "abcXYZ"); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(warnLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=1, remaining=0, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("1;w=100"); + }); + + test("warns but adds the rate limit header if RateLimit-Policy already exists", () => { + const warnLogSpy = jest.spyOn(logger, "warn").mockImplementation(noop); + + const headers = new Headers(); + headers.set("RateLimit-Policy", "abcXYZ"); + setRateLimitHeaders( + headers, + new ArcjetAllowDecision({ + results: [ + new ArcjetRuleResult({ + ttl: 0, + state: "RUN", + conclusion: "ALLOW", + reason: new ArcjetRateLimitReason({ + max: 1, + remaining: 0, + reset: 100, + window: 100, + }), + }), + ], + ttl: 0, + reason: new ArcjetReason(), + }), + ); + expect(warnLogSpy).toHaveBeenCalled(); + expect(headers.has("RateLimit")).toEqual(true); + expect(headers.get("RateLimit")).toEqual( + "limit=1, remaining=0, reset=100", + ); + expect(headers.has("RateLimit-Policy")).toEqual(true); + expect(headers.get("RateLimit-Policy")).toEqual("1;w=100"); + }); + }); + describe("Response object", () => { test("empty results do not set headers", () => { const resp = new Response(); diff --git a/examples/nextjs-14-decorate/app/api-app/arcjet/route.ts b/examples/nextjs-14-decorate/app/api-app/arcjet/route.ts index 1e16e71c7..bb8bab382 100644 --- a/examples/nextjs-14-decorate/app/api-app/arcjet/route.ts +++ b/examples/nextjs-14-decorate/app/api-app/arcjet/route.ts @@ -23,24 +23,22 @@ const aj = arcjet({ export async function GET(req: Request) { const decision = await aj.protect(req); - let response: NextResponse; + const headers = new Headers(); + + setRateLimitHeaders(headers, decision); + if (decision.isDenied()) { - response = NextResponse.json( + return NextResponse.json( { error: "Too Many Requests", reason: decision.reason, }, { status: 429, + headers }, ); - } else { - response = NextResponse.json({ - message: "Hello World" - }); } - setRateLimitHeaders(response, decision); - - return response; + return NextResponse.json({ message: "Hello World" }, { headers }); }