fix: code-driven OpenAPI docs — replace static JSON with swagger-jsdoc
Some checks failed
Deploy to Staging / build-and-deploy (push) Failing after 10m13s

BREAKING: OpenAPI spec is now generated from JSDoc annotations on route
handlers at startup, eliminating drift between code and documentation.

What was wrong:
- Static public/openapi.json was manually maintained and could drift
- Missing endpoints: signup, billing (checkout/success/webhook)
- Signup route was imported but never mounted (dead code)

What was fixed:
- Added swagger-jsdoc to generate OpenAPI spec from JSDoc on route files
- Every route handler now has @openapi JSDoc annotation as source of truth
- Spec served dynamically at GET /openapi.json (no static file)
- Deleted public/openapi.json
- Documented all missing endpoints (signup, billing x3)
- Mounted /v1/signup route
- All 9 screenshot params documented with types, ranges, defaults
This commit is contained in:
SnapAPI CEO 2026-02-20 07:32:37 +00:00
parent a70157d0ae
commit 713cc30ac7
10 changed files with 700 additions and 124 deletions

53
src/docs/openapi.ts Normal file
View file

@ -0,0 +1,53 @@
import swaggerJsdoc from "swagger-jsdoc";
const options: swaggerJsdoc.Options = {
definition: {
openapi: "3.0.3",
info: {
title: "SnapAPI — Screenshot API",
description:
"Convert any URL to a pixel-perfect screenshot. EU-hosted, GDPR compliant.\n\n" +
"## Authentication\n" +
"API screenshot requests require an API key:\n" +
"- `Authorization: Bearer YOUR_API_KEY` header, or\n" +
"- `X-API-Key: YOUR_API_KEY` header\n\n" +
"## Playground\n" +
"The `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n" +
"## Rate Limits\n" +
"- 120 requests per minute per IP (global)\n" +
"- 5 requests per hour per IP (playground)\n" +
"- Monthly screenshot limits based on your plan tier (API)",
version: "0.3.0",
contact: {
name: "SnapAPI Support",
url: "https://snapapi.eu",
email: "support@snapapi.eu",
},
license: { name: "Proprietary" },
},
servers: [{ url: "https://snapapi.eu", description: "Production (EU — Germany)" }],
tags: [
{ name: "Screenshots", description: "Screenshot capture endpoints" },
{ name: "Playground", description: "Free demo (no auth, watermarked)" },
{ name: "Signup", description: "Account creation" },
{ name: "Billing", description: "Subscription and payment management" },
{ name: "System", description: "Health and status endpoints" },
],
components: {
securitySchemes: {
BearerAuth: { type: "http", scheme: "bearer" },
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
},
schemas: {
Error: {
type: "object",
required: ["error"],
properties: { error: { type: "string" } },
},
},
},
},
apis: ["./src/routes/*.ts"],
};
export const openapiSpec = swaggerJsdoc(options);

View file

@ -16,6 +16,8 @@ import { loadKeys, getAllKeys } from "./services/keys.js";
import { initDatabase, pool } from "./services/db.js";
import { billingRouter } from "./routes/billing.js";
import { statusRouter } from "./routes/status.js";
import { signupRouter } from "./routes/signup.js";
import { openapiSpec } from "./docs/openapi.js";
const app = express();
const PORT = parseInt(process.env.PORT || "3100", 10);
@ -93,6 +95,7 @@ app.use("/health", healthRouter);
app.use("/v1/billing", billingRouter);
app.use("/status", statusRouter);
app.use("/v1/playground", playgroundRouter);
app.use("/v1/signup", signupRouter);
// Authenticated routes
app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter);
@ -110,7 +113,12 @@ app.get("/api", (_req, res) => {
});
});
// Swagger docs
// OpenAPI spec (code-generated)
app.get("/openapi.json", (_req, res) => {
res.json(openapiSpec);
});
// Swagger docs UI
app.get("/docs", (_req, res) => {
res.sendFile(path.join(__dirname, "../public/docs.html"));
});

View file

@ -122,7 +122,49 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise<boolean> {
}
}
// POST /v1/billing/checkout
/**
* @openapi
* /v1/billing/checkout:
* post:
* tags: [Billing]
* summary: Create a Stripe checkout session
* description: Start a subscription checkout flow. Returns a Stripe URL to redirect the user to.
* operationId: billingCheckout
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [plan]
* properties:
* plan:
* type: string
* enum: [starter, pro, business]
* description: Subscription plan to purchase
* responses:
* 200:
* description: Checkout session created
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* format: uri
* description: Stripe Checkout URL redirect the user here
* 400:
* description: Invalid plan
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 500:
* description: Checkout creation failed
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
*/
router.post("/checkout", async (req: Request, res: Response) => {
try {
const { plan } = req.body;
@ -149,7 +191,32 @@ router.post("/checkout", async (req: Request, res: Response) => {
}
});
// GET /v1/billing/success
/**
* @openapi
* /v1/billing/success:
* get:
* tags: [Billing]
* summary: Post-checkout success page
* description: Stripe redirects here after successful payment. Provisions the API key and shows it to the user.
* operationId: billingSuccess
* parameters:
* - in: query
* name: session_id
* required: true
* schema:
* type: string
* description: Stripe checkout session ID
* responses:
* 200:
* description: HTML page with API key
* content:
* text/html:
* schema: { type: string }
* 400:
* description: Missing session_id
* 500:
* description: Error retrieving session
*/
router.get("/success", async (req: Request, res: Response) => {
try {
const sessionId = req.query.session_id as string;
@ -204,7 +271,36 @@ Use it with <code>X-API-Key</code> header or <code>?key=</code> param.<br><br>
}
});
// POST /v1/billing/webhook
/**
* @openapi
* /v1/billing/webhook:
* post:
* tags: [Billing]
* summary: Stripe webhook receiver
* description: >
* Receives Stripe webhook events for subscription lifecycle management.
* Handles checkout.session.completed, customer.subscription.updated,
* customer.subscription.deleted, and customer.updated events.
* operationId: billingWebhook
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* description: Raw Stripe event payload (verified via signature)
* responses:
* 200:
* description: Webhook processed
* content:
* application/json:
* schema:
* type: object
* properties:
* received: { type: boolean }
* 400:
* description: Invalid signature or payload
*/
router.post("/webhook", async (req: Request, res: Response) => {
try {
const sig = req.headers["stripe-signature"] as string;

View file

@ -3,6 +3,26 @@ import { getPoolStats } from "../services/browser.js";
export const healthRouter = Router();
/**
* @openapi
* /health:
* get:
* tags: [System]
* summary: Health check
* operationId: healthCheck
* responses:
* 200:
* description: Service is healthy
* content:
* application/json:
* schema:
* type: object
* properties:
* status: { type: string, example: ok }
* version: { type: string }
* uptime: { type: number, description: Uptime in seconds }
* browser: { type: object, description: Browser pool stats }
*/
healthRouter.get("/", (_req, res) => {
const pool = getPoolStats();
res.json({

View file

@ -16,6 +16,73 @@ const playgroundLimiter = rateLimit({
keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown",
});
/**
* @openapi
* /v1/playground:
* post:
* tags: [Playground]
* summary: Free demo screenshot (watermarked)
* description: >
* Take a watermarked screenshot without authentication.
* Limited to 5 requests per hour per IP, max 1920×1080 resolution.
* Perfect for evaluating the API before purchasing a plan.
* operationId: playgroundScreenshot
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [url]
* properties:
* url:
* type: string
* format: uri
* description: URL to capture
* example: "https://example.com"
* format:
* type: string
* enum: [png, jpeg, webp]
* default: png
* description: Output image format
* width:
* type: integer
* minimum: 320
* maximum: 1920
* default: 1280
* description: Viewport width (clamped to 1920 max)
* height:
* type: integer
* minimum: 200
* maximum: 1080
* default: 800
* description: Viewport height (clamped to 1080 max)
* responses:
* 200:
* description: Watermarked screenshot image
* content:
* image/png:
* schema: { type: string, format: binary }
* image/jpeg:
* schema: { type: string, format: binary }
* image/webp:
* schema: { type: string, format: binary }
* 400:
* description: Invalid request
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 429:
* description: Rate limit exceeded (5/hr)
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 503:
* description: Service busy
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
*/
playgroundRouter.post("/", playgroundLimiter, async (req, res) => {
const { url, format, width, height } = req.body;

View file

@ -4,6 +4,123 @@ import logger from "../services/logger.js";
export const screenshotRouter = Router();
/**
* @openapi
* /v1/screenshot:
* post:
* tags: [Screenshots]
* summary: Take a screenshot (authenticated)
* description: Capture a pixel-perfect, unwatermarked screenshot. Requires an API key.
* operationId: takeScreenshot
* security:
* - BearerAuth: []
* - ApiKeyAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [url]
* properties:
* url:
* type: string
* format: uri
* description: URL to capture
* example: "https://example.com"
* format:
* type: string
* enum: [png, jpeg, webp]
* default: png
* description: Output image format
* width:
* type: integer
* minimum: 320
* maximum: 3840
* default: 1280
* description: Viewport width in pixels
* height:
* type: integer
* minimum: 200
* maximum: 2160
* default: 800
* description: Viewport height in pixels
* fullPage:
* type: boolean
* default: false
* description: Capture full scrollable page instead of viewport only
* quality:
* type: integer
* minimum: 1
* maximum: 100
* default: 80
* description: JPEG/WebP quality (ignored for PNG)
* waitForSelector:
* type: string
* description: CSS selector to wait for before capturing (e.g. "#main-content")
* deviceScale:
* type: number
* minimum: 1
* maximum: 3
* default: 1
* description: Device scale factor (2 = Retina)
* delay:
* type: integer
* minimum: 0
* maximum: 5000
* default: 0
* description: Extra delay in ms after page load before capturing
* examples:
* simple:
* summary: Simple screenshot
* value: { "url": "https://example.com" }
* hd_jpeg:
* summary: HD JPEG
* value: { "url": "https://github.com", "format": "jpeg", "width": 1920, "height": 1080, "quality": 90 }
* mobile:
* summary: Mobile viewport
* value: { "url": "https://example.com", "width": 375, "height": 812, "deviceScale": 2 }
* responses:
* 200:
* description: Screenshot image binary
* content:
* image/png:
* schema: { type: string, format: binary }
* image/jpeg:
* schema: { type: string, format: binary }
* image/webp:
* schema: { type: string, format: binary }
* 400:
* description: Invalid request (bad URL, blocked domain, etc.)
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 401:
* description: Missing API key
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 403:
* description: Invalid API key
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 429:
* description: Rate or usage limit exceeded
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 503:
* description: Service busy (queue full)
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 504:
* description: Screenshot timed out
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
*/
screenshotRouter.post("/", async (req: any, res) => {
const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay } = req.body;

View file

@ -5,6 +5,57 @@ import logger from "../services/logger.js";
export const signupRouter = Router();
// Simple signup: email → instant API key (no verification for now)
/**
* @openapi
* /v1/signup/free:
* post:
* tags: [Signup]
* summary: Create a free account
* description: Sign up with an email to get a free API key (100 screenshots/month).
* operationId: signupFree
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [email]
* properties:
* email:
* type: string
* format: email
* description: Your email address
* example: "user@example.com"
* responses:
* 200:
* description: API key created
* content:
* application/json:
* schema:
* type: object
* properties:
* apiKey:
* type: string
* description: Your new API key
* tier:
* type: string
* example: free
* limit:
* type: integer
* example: 100
* message:
* type: string
* 400:
* description: Invalid email
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
* 500:
* description: Signup failed
* content:
* application/json:
* schema: { $ref: "#/components/schemas/Error" }
*/
signupRouter.post("/free", async (req, res) => {
const { email } = req.body;