Document rate limit headers in OpenAPI spec
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled
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:
parent
a3bba8f0d5
commit
70eb6908e3
18 changed files with 801 additions and 821 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue