fix: code-driven OpenAPI docs — replace static JSON with swagger-jsdoc
Some checks failed
Deploy to Staging / build-and-deploy (push) Failing after 10m13s
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:
parent
a70157d0ae
commit
713cc30ac7
10 changed files with 700 additions and 124 deletions
53
src/docs/openapi.ts
Normal file
53
src/docs/openapi.ts
Normal 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);
|
||||
10
src/index.ts
10
src/index.ts
|
|
@ -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"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue