Document rate limit headers in OpenAPI spec
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- Add reusable header components (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After)
- Reference headers in 200 responses on all conversion and demo endpoints
- Add Retry-After header to 429 responses
- Update Rate Limits section in API description to mention response headers
- Add comprehensive tests for header documentation (21 new tests)
- All 809 tests passing
This commit is contained in:
OpenClaw Subagent 2026-03-18 11:06:22 +01:00
parent a3bba8f0d5
commit 70eb6908e3
18 changed files with 801 additions and 821 deletions

View file

@ -15,4 +15,94 @@ describe("OpenAPI spec accuracy", () => {
it("should mark /v1/signup/verify as deprecated", () => {
expect(spec.paths["/v1/signup/verify"]?.post?.deprecated).toBe(true);
});
describe("Rate limit headers", () => {
it("should define rate limit header components", () => {
expect(spec.components.headers).toBeDefined();
expect(spec.components.headers["X-RateLimit-Limit"]).toBeDefined();
expect(spec.components.headers["X-RateLimit-Remaining"]).toBeDefined();
expect(spec.components.headers["X-RateLimit-Reset"]).toBeDefined();
expect(spec.components.headers["Retry-After"]).toBeDefined();
});
it("X-RateLimit-Limit should be integer type with description", () => {
const header = spec.components.headers["X-RateLimit-Limit"];
expect(header.schema.type).toBe("integer");
expect(header.description).toContain("maximum");
});
it("X-RateLimit-Remaining should be integer type with description", () => {
const header = spec.components.headers["X-RateLimit-Remaining"];
expect(header.schema.type).toBe("integer");
expect(header.description).toContain("remaining");
});
it("X-RateLimit-Reset should be integer type with Unix timestamp description", () => {
const header = spec.components.headers["X-RateLimit-Reset"];
expect(header.schema.type).toBe("integer");
expect(header.description.toLowerCase()).toContain("unix");
expect(header.description.toLowerCase()).toContain("timestamp");
});
it("Retry-After should be integer type with description about seconds", () => {
const header = spec.components.headers["Retry-After"];
expect(header.schema.type).toBe("integer");
expect(header.description.toLowerCase()).toContain("second");
});
const conversionEndpoints = [
"/v1/convert/html",
"/v1/convert/markdown",
"/v1/convert/url",
];
const demoEndpoints = ["/v1/demo/html", "/v1/demo/markdown"];
const allRateLimitedEndpoints = [...conversionEndpoints, ...demoEndpoints];
allRateLimitedEndpoints.forEach((endpoint) => {
describe(`${endpoint}`, () => {
it("should include rate limit headers in 200 response", () => {
const response200 = spec.paths[endpoint]?.post?.responses["200"];
expect(response200).toBeDefined();
expect(response200.headers).toBeDefined();
expect(response200.headers["X-RateLimit-Limit"]).toBeDefined();
expect(response200.headers["X-RateLimit-Remaining"]).toBeDefined();
expect(response200.headers["X-RateLimit-Reset"]).toBeDefined();
});
it("should reference header components in 200 response", () => {
const headers = spec.paths[endpoint]?.post?.responses["200"]?.headers;
expect(headers["X-RateLimit-Limit"].$ref).toBe(
"#/components/headers/X-RateLimit-Limit"
);
expect(headers["X-RateLimit-Remaining"].$ref).toBe(
"#/components/headers/X-RateLimit-Remaining"
);
expect(headers["X-RateLimit-Reset"].$ref).toBe(
"#/components/headers/X-RateLimit-Reset"
);
});
it("should include Retry-After header in 429 response", () => {
const response429 = spec.paths[endpoint]?.post?.responses["429"];
expect(response429).toBeDefined();
expect(response429.headers).toBeDefined();
expect(response429.headers["Retry-After"]).toBeDefined();
expect(response429.headers["Retry-After"].$ref).toBe(
"#/components/headers/Retry-After"
);
});
});
});
it("should mention rate limit headers in API description", () => {
const description = spec.info.description;
expect(description).toContain("X-RateLimit-Limit");
expect(description).toContain("X-RateLimit-Remaining");
expect(description).toContain("X-RateLimit-Reset");
expect(description).toContain("Retry-After");
expect(description).toContain("429");
});
});
});

View file

@ -40,6 +40,13 @@ export const convertRouter = Router();
* responses:
* 200:
* description: PDF document
* headers:
* X-RateLimit-Limit:
* $ref: '#/components/headers/X-RateLimit-Limit'
* X-RateLimit-Remaining:
* $ref: '#/components/headers/X-RateLimit-Remaining'
* X-RateLimit-Reset:
* $ref: '#/components/headers/X-RateLimit-Reset'
* content:
* application/pdf:
* schema:
@ -55,6 +62,9 @@ export const convertRouter = Router();
* description: Unsupported Content-Type (must be application/json)
* 429:
* description: Rate limit or usage limit exceeded
* headers:
* Retry-After:
* $ref: '#/components/headers/Retry-After'
* 500:
* description: PDF generation failed
*/
@ -103,6 +113,13 @@ convertRouter.post("/html", async (req: Request, res: Response) => {
* responses:
* 200:
* description: PDF document
* headers:
* X-RateLimit-Limit:
* $ref: '#/components/headers/X-RateLimit-Limit'
* X-RateLimit-Remaining:
* $ref: '#/components/headers/X-RateLimit-Remaining'
* X-RateLimit-Reset:
* $ref: '#/components/headers/X-RateLimit-Reset'
* content:
* application/pdf:
* schema:
@ -118,6 +135,9 @@ convertRouter.post("/html", async (req: Request, res: Response) => {
* description: Unsupported Content-Type
* 429:
* description: Rate limit or usage limit exceeded
* headers:
* Retry-After:
* $ref: '#/components/headers/Retry-After'
* 500:
* description: PDF generation failed
*/
@ -169,6 +189,13 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => {
* responses:
* 200:
* description: PDF document
* headers:
* X-RateLimit-Limit:
* $ref: '#/components/headers/X-RateLimit-Limit'
* X-RateLimit-Remaining:
* $ref: '#/components/headers/X-RateLimit-Remaining'
* X-RateLimit-Reset:
* $ref: '#/components/headers/X-RateLimit-Reset'
* content:
* application/pdf:
* schema:
@ -184,6 +211,9 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => {
* description: Unsupported Content-Type
* 429:
* description: Rate limit or usage limit exceeded
* headers:
* Retry-After:
* $ref: '#/components/headers/Retry-After'
* 500:
* description: PDF generation failed
*/

View file

@ -101,6 +101,13 @@ interface DemoBody {
* responses:
* 200:
* description: Watermarked PDF document
* headers:
* X-RateLimit-Limit:
* $ref: '#/components/headers/X-RateLimit-Limit'
* X-RateLimit-Remaining:
* $ref: '#/components/headers/X-RateLimit-Remaining'
* X-RateLimit-Reset:
* $ref: '#/components/headers/X-RateLimit-Reset'
* content:
* application/pdf:
* schema:
@ -116,6 +123,9 @@ interface DemoBody {
* description: Unsupported Content-Type
* 429:
* description: Demo rate limit exceeded (5/hour)
* headers:
* Retry-After:
* $ref: '#/components/headers/Retry-After'
* content:
* application/json:
* schema:
@ -187,6 +197,13 @@ router.post("/html", async (req: Request, res: Response) => {
* responses:
* 200:
* description: Watermarked PDF document
* headers:
* X-RateLimit-Limit:
* $ref: '#/components/headers/X-RateLimit-Limit'
* X-RateLimit-Remaining:
* $ref: '#/components/headers/X-RateLimit-Remaining'
* X-RateLimit-Reset:
* $ref: '#/components/headers/X-RateLimit-Reset'
* content:
* application/pdf:
* schema:
@ -198,6 +215,9 @@ router.post("/html", async (req: Request, res: Response) => {
* description: Unsupported Content-Type
* 429:
* description: Demo rate limit exceeded (5/hour)
* headers:
* Retry-After:
* $ref: '#/components/headers/Retry-After'
* 503:
* description: Server busy
* 504:

View file

@ -11,7 +11,7 @@ const options: swaggerJsdoc.Options = {
title: "DocFast API",
version,
description:
"Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion endpoints require an API key via `Authorization: Bearer <key>` or `X-API-Key: <key>` header. Get your key at [docfast.dev](https://docfast.dev).\n\n## Rate Limits\n- Demo: 5 conversions/hour, watermarked output\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev) for clean output and higher limits\n3. Use your API key to convert documents",
"Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion endpoints require an API key via `Authorization: Bearer <key>` or `X-API-Key: <key>` header. Get your key at [docfast.dev](https://docfast.dev).\n\n## Rate Limits\n- Demo: 5 conversions/hour, watermarked output\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\nAll rate-limited endpoints return `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. On `429`, a `Retry-After` header indicates seconds until the next allowed request.\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev) for clean output and higher limits\n3. Use your API key to convert documents",
contact: {
name: "DocFast",
url: "https://docfast.dev",
@ -41,6 +41,36 @@ const options: swaggerJsdoc.Options = {
description: "API key via X-API-Key header",
},
},
headers: {
"X-RateLimit-Limit": {
description: "The maximum number of requests allowed in the current time window",
schema: {
type: "integer",
example: 30,
},
},
"X-RateLimit-Remaining": {
description: "The number of requests remaining in the current time window",
schema: {
type: "integer",
example: 29,
},
},
"X-RateLimit-Reset": {
description: "Unix timestamp (seconds since epoch) when the rate limit window resets",
schema: {
type: "integer",
example: 1679875200,
},
},
"Retry-After": {
description: "Number of seconds to wait before retrying the request (returned on 429 responses)",
schema: {
type: "integer",
example: 60,
},
},
},
schemas: {
PdfOptions: {
type: "object",