From 792e2d9142890f4049ef026b355ea551006ca054 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 07:54:37 +0000 Subject: [PATCH] v0.4.1: Code-driven OpenAPI docs via swagger-jsdoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add swagger-jsdoc dependency for auto-generating OpenAPI spec from JSDoc - Add JSDoc @openapi annotations to all route handlers - Create scripts/generate-openapi.mjs build step - OpenAPI spec now auto-generated from code — no manual JSON editing - All 13 endpoints documented with full parameters - New demo endpoints documented, signup marked as deprecated - Updated info description: demo-first, no free tier references - Dockerfile updated to run openapi generation during build - Build script updated: npm run build generates spec before compile --- Dockerfile | 1 + package-lock.json | 294 +++++++- package.json | 8 +- public/openapi.json | 1214 ++++++++++++++++++++++++++-------- scripts/generate-openapi.mjs | 123 ++++ src/openapi-extra.yaml | 63 ++ src/routes/billing.ts | 30 +- src/routes/convert.ts | 153 ++++- src/routes/demo.ts | 100 ++- src/routes/health.ts | 50 ++ src/routes/recover.ts | 86 +++ src/routes/templates.ts | 114 +++- 12 files changed, 1931 insertions(+), 305 deletions(-) create mode 100644 scripts/generate-openapi.mjs create mode 100644 src/openapi-extra.yaml diff --git a/Dockerfile b/Dockerfile index 16c8ef5..19c6b2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN npx tsc RUN npm prune --omit=dev COPY scripts/ scripts/ COPY public/ public/ +RUN node scripts/generate-openapi.mjs RUN node scripts/build-html.cjs RUN rm -f public/swagger-ui && ln -s /app/node_modules/swagger-ui-dist public/swagger-ui diff --git a/package-lock.json b/package-lock.json index f9dad1c..3a19ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docfast-api", - "version": "0.2.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docfast-api", - "version": "0.2.1", + "version": "0.4.0", "dependencies": { "compression": "^1.8.1", "express": "^4.21.0", @@ -19,6 +19,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { @@ -33,6 +34,50 @@ "vitest": "^3.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -548,6 +593,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1047,6 +1098,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -1353,6 +1410,12 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", @@ -1477,6 +1540,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1541,6 +1614,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1665,6 +1744,12 @@ "node": ">= 0.6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1794,6 +1879,18 @@ "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "license": "BSD-3-Clause" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2213,6 +2310,12 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2348,6 +2451,27 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2513,6 +2637,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2582,6 +2717,26 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2689,6 +2844,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2794,6 +2961,13 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -2888,6 +3062,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -3810,6 +3993,56 @@ } } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/swagger-ui-dist": { "version": "5.31.0", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", @@ -4036,6 +4269,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4351,6 +4593,24 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -4388,6 +4648,36 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index c806717..1844068 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "main": "dist/index.js", "scripts": { "build:pages": "node scripts/build-pages.js && npx terser public/app.js -o public/app.min.js --compress --mangle", - "build": "npm run build:pages && tsc", + "build": "node scripts/generate-openapi.mjs && npm run build:pages && tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", - "test": "vitest run" + "test": "vitest run", + "generate-openapi": "node scripts/generate-openapi.mjs" }, "dependencies": { "compression": "^1.8.1", @@ -22,6 +23,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { @@ -36,4 +38,4 @@ "vitest": "^3.0.0" }, "type": "module" -} +} \ No newline at end of file diff --git a/public/openapi.json b/public/openapi.json index a8098fa..2192f5d 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3,56 +3,186 @@ "info": { "title": "DocFast API", "version": "1.0.0", - "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents", - "contact": { "name": "DocFast", "url": "https://docfast.dev", "email": "support@docfast.dev" } + "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` — no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", + "contact": { + "name": "DocFast", + "url": "https://docfast.dev", + "email": "support@docfast.dev" + } }, - "servers": [{ "url": "https://docfast.dev", "description": "Production" }], + "servers": [ + { + "url": "https://docfast.dev", + "description": "Production" + } + ], "tags": [ - { "name": "Conversion", "description": "Convert HTML, Markdown, or URLs to PDF" }, - { "name": "Templates", "description": "Built-in document templates" }, - { "name": "Account", "description": "Signup, key recovery, and email management" }, - { "name": "Billing", "description": "Stripe-powered subscription management" }, - { "name": "System", "description": "Health checks and usage stats" } + { + "name": "Demo", + "description": "Try the API without signing up — watermarked PDFs, rate-limited" + }, + { + "name": "Conversion", + "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" + }, + { + "name": "Templates", + "description": "Built-in document templates" + }, + { + "name": "Account", + "description": "Key recovery and email management" + }, + { + "name": "Billing", + "description": "Stripe-powered subscription management" + }, + { + "name": "System", + "description": "Health checks and usage stats" + } ], "components": { "securitySchemes": { - "BearerAuth": { "type": "http", "scheme": "bearer", "description": "API key as Bearer token" }, - "ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key via X-API-Key header" } + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "API key as Bearer token" + }, + "ApiKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key via X-API-Key header" + } }, "schemas": { "PdfOptions": { "type": "object", "properties": { - "format": { "type": "string", "enum": ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"], "default": "A4", "description": "Page size" }, - "landscape": { "type": "boolean", "default": false, "description": "Landscape orientation" }, + "format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size" + }, + "landscape": { + "type": "boolean", + "default": false, + "description": "Landscape orientation" + }, "margin": { "type": "object", "properties": { - "top": { "type": "string", "example": "20mm" }, - "bottom": { "type": "string", "example": "20mm" }, - "left": { "type": "string", "example": "15mm" }, - "right": { "type": "string", "example": "15mm" } - } + "top": { + "type": "string", + "description": "Top margin (e.g. \"10mm\", \"1in\")", + "default": "0" + }, + "right": { + "type": "string", + "description": "Right margin", + "default": "0" + }, + "bottom": { + "type": "string", + "description": "Bottom margin", + "default": "0" + }, + "left": { + "type": "string", + "description": "Left margin", + "default": "0" + } + }, + "description": "Page margins" }, - "printBackground": { "type": "boolean", "default": true, "description": "Print background graphics" }, - "filename": { "type": "string", "default": "document.pdf", "description": "Suggested filename for the PDF" } + "printBackground": { + "type": "boolean", + "default": true, + "description": "Print background colors and images" + }, + "filename": { + "type": "string", + "description": "Custom filename for Content-Disposition header", + "default": "document.pdf" + } } }, "Error": { "type": "object", "properties": { - "error": { "type": "string", "description": "Error message" } - } + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ] } } }, "paths": { + "/v1/billing/checkout": { + "post": { + "tags": [ + "Billing" + ], + "summary": "Create a Stripe checkout session", + "description": "Creates a Stripe Checkout session for a Pro subscription (€9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", + "responses": { + "200": { + "description": "Checkout session created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Stripe Checkout URL to redirect the user to" + } + } + } + } + } + }, + "413": { + "description": "Request body too large" + }, + "429": { + "description": "Too many checkout requests" + }, + "500": { + "description": "Failed to create checkout session" + } + } + } + }, "/v1/convert/html": { "post": { - "tags": ["Conversion"], + "tags": [ + "Conversion" + ], "summary": "Convert HTML to PDF", - "description": "Renders HTML content as a PDF. Supports full CSS including flexbox, grid, and custom fonts. Bare HTML fragments are auto-wrapped.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "requestBody": { "required": true, "content": { @@ -61,33 +191,78 @@ "allOf": [ { "type": "object", - "required": ["html"], + "required": [ + "html" + ], "properties": { - "html": { "type": "string", "description": "HTML content to convert", "example": "

Hello World

Your first PDF

" }, - "css": { "type": "string", "description": "Optional CSS to inject (for HTML fragments)" } + "html": { + "type": "string", + "description": "HTML content to convert. Can be a full document or a fragment.", + "example": "

Hello World

My first PDF

" + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", + "example": "body { font-family: sans-serif; padding: 40px; }" + } } }, - { "$ref": "#/components/schemas/PdfOptions" } + { + "$ref": "#/components/schemas/PdfOptions" + } ] } } } }, "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "400": { "description": "Invalid request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "401": { "description": "Missing or invalid API key" }, - "415": { "description": "Unsupported Content-Type" }, - "429": { "description": "Rate limit exceeded or server busy" } + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type (must be application/json)" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } } } }, "/v1/convert/markdown": { "post": { - "tags": ["Conversion"], + "tags": [ + "Conversion" + ], "summary": "Convert Markdown to PDF", - "description": "Converts Markdown content to a beautifully styled PDF with syntax highlighting.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Converts Markdown content to HTML and then to a PDF document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "requestBody": { "required": true, "content": { @@ -96,32 +271,77 @@ "allOf": [ { "type": "object", - "required": ["markdown"], + "required": [ + "markdown" + ], "properties": { - "markdown": { "type": "string", "description": "Markdown content to convert", "example": "# Hello World\n\nThis is **bold** and this is *italic*.\n\n- Item 1\n- Item 2" }, - "css": { "type": "string", "description": "Optional custom CSS" } + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject into the rendered HTML" + } } }, - { "$ref": "#/components/schemas/PdfOptions" } + { + "$ref": "#/components/schemas/PdfOptions" + } ] } } } }, "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "400": { "description": "Invalid request" }, - "401": { "description": "Missing or invalid API key" }, - "429": { "description": "Rate limit exceeded" } + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } } } }, "/v1/convert/url": { "post": { - "tags": ["Conversion"], + "tags": [ + "Conversion" + ], "summary": "Convert URL to PDF", - "description": "Fetches a URL and converts the rendered page to PDF. Only http/https URLs are supported. Private/reserved IPs are blocked (SSRF protection).", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "requestBody": { "required": true, "content": { @@ -130,35 +350,445 @@ "allOf": [ { "type": "object", - "required": ["url"], + "required": [ + "url" + ], "properties": { - "url": { "type": "string", "format": "uri", "description": "URL to convert", "example": "https://example.com" }, - "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle0", "networkidle2"], "default": "networkidle0", "description": "When to consider navigation complete" } + "url": { + "type": "string", + "format": "uri", + "description": "URL to convert (http or https only)", + "example": "https://example.com" + }, + "waitUntil": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle0", + "networkidle2" + ], + "default": "domcontentloaded", + "description": "When to consider navigation finished" + } } }, - { "$ref": "#/components/schemas/PdfOptions" } + { + "$ref": "#/components/schemas/PdfOptions" + } ] } } } }, "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "400": { "description": "Invalid URL or DNS failure" }, - "401": { "description": "Missing or invalid API key" }, - "429": { "description": "Rate limit exceeded" } + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing/invalid URL or URL resolves to private IP" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/demo/html": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert HTML to PDF (demo)", + "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert", + "example": "

Hello World

My first PDF

" + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (used when html is a fragment)" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/v1/demo/markdown": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert Markdown to PDF (demo)", + "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "markdown" + ], + "properties": { + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)" + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health check", + "description": "Returns service health status including database connectivity and browser pool stats.", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ] + }, + "version": { + "type": "string", + "example": "0.4.0" + }, + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + "version": { + "type": "string", + "example": "PostgreSQL 17.4" + } + } + }, + "pool": { + "type": "object", + "properties": { + "size": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "available": { + "type": "integer" + }, + "queueDepth": { + "type": "integer" + }, + "pdfCount": { + "type": "integer" + }, + "restarting": { + "type": "boolean" + }, + "uptimeSeconds": { + "type": "integer" + } + } + } + } + } + } + } + }, + "503": { + "description": "Service is degraded (database issue)" + } + } + } + }, + "/v1/recover": { + "post": { + "tags": [ + "Account" + ], + "summary": "Request API key recovery", + "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address associated with the API key" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Recovery code sent (or no-op if email not found)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovery_sent" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid email format" + }, + "429": { + "description": "Too many recovery attempts" + } + } + } + }, + "/v1/recover/verify": { + "post": { + "tags": [ + "Account" + ], + "summary": "Verify recovery code and retrieve API key", + "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "code" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "code": { + "type": "string", + "pattern": "^\\d{6}$", + "description": "6-digit verification code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "API key recovered", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovered" + }, + "apiKey": { + "type": "string", + "description": "The recovered API key" + }, + "tier": { + "type": "string", + "enum": [ + "free", + "pro" + ] + } + } + } + } + } + }, + "400": { + "description": "Invalid verification code or missing fields" + }, + "410": { + "description": "Verification code expired" + }, + "429": { + "description": "Too many failed attempts" + } } } }, "/v1/templates": { "get": { - "tags": ["Templates"], + "tags": [ + "Templates" + ], "summary": "List available templates", - "description": "Returns all available document templates with their field definitions.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Returns a list of all built-in document templates with their required fields.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "responses": { "200": { - "description": "Template list", + "description": "List of templates", "content": { "application/json": { "schema": { @@ -169,10 +799,34 @@ "items": { "type": "object", "properties": { - "id": { "type": "string", "example": "invoice" }, - "name": { "type": "string", "example": "Invoice" }, - "description": { "type": "string" }, - "fields": { "type": "array", "items": { "type": "string" } } + "id": { + "type": "string", + "example": "invoice" + }, + "name": { + "type": "string", + "example": "Invoice" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + } + } + } + } } } } @@ -180,243 +834,219 @@ } } } + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + } + } + } + }, + "/v1/templates/{id}/render": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Render a template to PDF", + "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Template ID (e.g. \"invoice\", \"receipt\")" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "Template data (fields depend on template). Can also be passed at root level." + }, + "_format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size override" + }, + "_margin": { + "type": "object", + "properties": { + "top": { + "type": "string" + }, + "right": { + "type": "string" + }, + "bottom": { + "type": "string" + }, + "left": { + "type": "string" + } + }, + "description": "Page margin override" + }, + "_filename": { + "type": "string", + "description": "Custom output filename" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing required template fields" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "404": { + "description": "Template not found" + }, + "500": { + "description": "Template rendering failed" + } + } + } + }, + "/v1/signup/free": { + "post": { + "tags": [ + "Account" + ], + "summary": "Free signup (discontinued)", + "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "responses": { + "410": { + "description": "Free accounts discontinued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Free accounts have been discontinued." + }, + "demo_endpoint": { + "type": "string", + "example": "/v1/demo/html" + }, + "pro_url": { + "type": "string", + "example": "https://docfast.dev/#pricing" + } + } + } + } + } } } } }, - "/v1/templates/{id}/render": { - "post": { - "tags": ["Templates"], - "summary": "Render a template to PDF", - "description": "Renders a template with the provided data and returns a PDF.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Template ID (e.g., 'invoice', 'receipt')" } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { "type": "object", "description": "Template data fields", "example": { "company": "Acme Corp", "items": [{ "description": "Widget", "quantity": 5, "price": 9.99 }] } } - } - } - } - } - }, - "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "404": { "description": "Template not found" } - } - } - }, - "/v1/signup/free": { - "post": { - "tags": ["Account"], - "summary": "Request a free API key", - "description": "Sends a 6-digit verification code to your email. Use `/v1/signup/verify` to complete signup.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { "type": "string", "format": "email", "example": "you@example.com" } - } - } - } - } - }, - "responses": { - "200": { - "description": "Verification code sent", - "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "verification_required" }, "message": { "type": "string" } } } } } - }, - "409": { "description": "Email already registered" }, - "429": { "description": "Too many signup attempts" } - } - } - }, - "/v1/signup/verify": { - "post": { - "tags": ["Account"], - "summary": "Verify email and get API key", - "description": "Verify your email with the 6-digit code to receive your API key.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email", "code"], - "properties": { - "email": { "type": "string", "format": "email" }, - "code": { "type": "string", "example": "123456", "description": "6-digit verification code" } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key issued", - "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "verified" }, "apiKey": { "type": "string", "example": "df_free_abc123..." }, "tier": { "type": "string", "example": "free" } } } } } - }, - "400": { "description": "Invalid code" }, - "410": { "description": "Code expired" }, - "429": { "description": "Too many attempts" } - } - } - }, - "/v1/recover": { - "post": { - "tags": ["Account"], - "summary": "Request API key recovery", - "description": "Sends a verification code to your registered email. Returns the same response whether or not the email exists (prevents enumeration).", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { "type": "string", "format": "email" } - } - } - } - } - }, - "responses": { - "200": { "description": "Recovery code sent (if account exists)" }, - "429": { "description": "Too many recovery attempts" } - } - } - }, - "/v1/recover/verify": { - "post": { - "tags": ["Account"], - "summary": "Verify recovery code and get API key", - "description": "Verify the recovery code to retrieve your API key. The key is shown only in the response — never sent via email.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email", "code"], - "properties": { - "email": { "type": "string", "format": "email" }, - "code": { "type": "string", "example": "123456" } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key recovered", - "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "recovered" }, "apiKey": { "type": "string" }, "tier": { "type": "string" } } } } } - }, - "400": { "description": "Invalid code" }, - "410": { "description": "Code expired" }, - "429": { "description": "Too many attempts" } - } - } - }, - "/v1/email-change": { - "post": { - "tags": ["Account"], - "summary": "Request email change", - "description": "Change the email associated with your API key. Sends a verification code to the new email address.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["apiKey", "newEmail"], - "properties": { - "apiKey": { "type": "string", "description": "Your current API key" }, - "newEmail": { "type": "string", "format": "email", "description": "New email address" } - } - } - } - } - }, - "responses": { - "200": { "description": "Verification code sent to new email" }, - "400": { "description": "Invalid input" }, - "401": { "description": "Invalid API key" }, - "409": { "description": "Email already in use" }, - "429": { "description": "Too many attempts" } - } - } - }, - "/v1/email-change/verify": { - "post": { - "tags": ["Account"], - "summary": "Verify email change", - "description": "Verify the code sent to your new email to complete the change.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["apiKey", "newEmail", "code"], - "properties": { - "apiKey": { "type": "string" }, - "newEmail": { "type": "string", "format": "email" }, - "code": { "type": "string", "example": "123456" } - } - } - } - } - }, - "responses": { - "200": { "description": "Email updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "updated" }, "newEmail": { "type": "string" } } } } } }, - "400": { "description": "Invalid code" }, - "401": { "description": "Invalid API key" }, - "410": { "description": "Code expired" } - } - } - }, - "/v1/billing/checkout": { - "post": { - "tags": ["Billing"], - "summary": "Start Pro subscription checkout", - "description": "Creates a Stripe Checkout session for the Pro plan ($9/mo). Returns a URL to redirect the user to.", - "responses": { - "200": { "description": "Checkout URL", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string", "format": "uri" } } } } } }, - "500": { "description": "Checkout creation failed" } - } - } - }, "/v1/usage": { "get": { - "tags": ["System"], - "summary": "Get usage statistics", - "description": "Returns your API usage statistics for the current billing period.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "tags": [ + "System" + ], + "summary": "Usage statistics (admin only)", + "description": "Returns usage statistics for the authenticated user. Requires admin API key.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "responses": { - "200": { "description": "Usage stats" } - } - } - }, - "/health": { - "get": { - "tags": ["System"], - "summary": "Health check", - "description": "Returns service health status. No authentication required.", - "responses": { - "200": { "description": "Service is healthy" } + "200": { + "description": "Usage statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "month": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Admin access required" + }, + "503": { + "description": "Admin access not configured" + } } } } } -} +} \ No newline at end of file diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 0000000..3997b3c --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Generates openapi.json from JSDoc annotations in route files. + * Run: node scripts/generate-openapi.mjs + * Output: public/openapi.json + */ +import swaggerJsdoc from 'swagger-jsdoc'; +import { writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const options = { + definition: { + openapi: '3.0.3', + info: { + title: 'DocFast API', + version: '1.0.0', + description: `Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates. + +## Authentication +All conversion and template endpoints require an API key via \`Authorization: Bearer \` or \`X-API-Key: \` header. + +## Demo Endpoints +Try the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs. + +## Rate Limits +- Demo: 5 PDFs/hour per IP (watermarked) +- Pro tier: 5,000 PDFs/month, 30 req/min + +## Getting Started +1. Try the demo at \`POST /v1/demo/html\` — no signup needed +2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs +3. Use your API key to convert documents`, + contact: { + name: 'DocFast', + url: 'https://docfast.dev', + email: 'support@docfast.dev' + } + }, + servers: [ + { url: 'https://docfast.dev', description: 'Production' } + ], + tags: [ + { name: 'Demo', description: 'Try the API without signing up — watermarked PDFs, rate-limited' }, + { name: 'Conversion', description: 'Convert HTML, Markdown, or URLs to PDF (requires API key)' }, + { name: 'Templates', description: 'Built-in document templates' }, + { name: 'Account', description: 'Key recovery and email management' }, + { name: 'Billing', description: 'Stripe-powered subscription management' }, + { name: 'System', description: 'Health checks and usage stats' } + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API key as Bearer token' + }, + ApiKeyHeader: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + description: 'API key via X-API-Key header' + } + }, + schemas: { + PdfOptions: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'], + default: 'A4', + description: 'Page size' + }, + landscape: { + type: 'boolean', + default: false, + description: 'Landscape orientation' + }, + margin: { + type: 'object', + properties: { + top: { type: 'string', description: 'Top margin (e.g. "10mm", "1in")', default: '0' }, + right: { type: 'string', description: 'Right margin', default: '0' }, + bottom: { type: 'string', description: 'Bottom margin', default: '0' }, + left: { type: 'string', description: 'Left margin', default: '0' } + }, + description: 'Page margins' + }, + printBackground: { + type: 'boolean', + default: true, + description: 'Print background colors and images' + }, + filename: { + type: 'string', + description: 'Custom filename for Content-Disposition header', + default: 'document.pdf' + } + } + }, + Error: { + type: 'object', + properties: { + error: { type: 'string', description: 'Error message' } + }, + required: ['error'] + } + } + } + }, + apis: [ + join(__dirname, '../src/routes/*.ts'), + join(__dirname, '../src/openapi-extra.yaml') + ] +}; + +const spec = swaggerJsdoc(options); +const outPath = join(__dirname, '../public/openapi.json'); +writeFileSync(outPath, JSON.stringify(spec, null, 2)); +console.log(`✅ Generated ${outPath} (${Object.keys(spec.paths || {}).length} paths)`); diff --git a/src/openapi-extra.yaml b/src/openapi-extra.yaml new file mode 100644 index 0000000..0e54464 --- /dev/null +++ b/src/openapi-extra.yaml @@ -0,0 +1,63 @@ +paths: + /v1/signup/free: + post: + tags: + - Account + summary: Free signup (discontinued) + description: | + Free accounts have been discontinued. Use the demo endpoint for testing + or subscribe to Pro for production use. + deprecated: true + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + responses: + '410': + description: Free accounts discontinued + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Free accounts have been discontinued.' + demo_endpoint: + type: string + example: '/v1/demo/html' + pro_url: + type: string + example: 'https://docfast.dev/#pricing' + /v1/usage: + get: + tags: + - System + summary: Usage statistics (admin only) + description: Returns usage statistics for the authenticated user. Requires admin API key. + security: + - BearerAuth: [] + - ApiKeyHeader: [] + responses: + '200': + description: Usage statistics + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + count: + type: integer + month: + type: string + '403': + description: Admin access required + '503': + description: Admin access not configured diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 98c8a91..8b66921 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -56,7 +56,35 @@ const checkoutLimiter = rateLimit({ message: { error: "Too many checkout requests. Please try again later." }, }); -// Create a Stripe Checkout session for Pro subscription +/** + * @openapi + * /v1/billing/checkout: + * post: + * tags: [Billing] + * summary: Create a Stripe checkout session + * description: | + * Creates a Stripe Checkout session for a Pro subscription (€9/month). + * Returns a URL to redirect the user to Stripe's hosted payment page. + * Rate limited to 3 requests per hour per IP. + * responses: + * 200: + * description: Checkout session created + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * description: Stripe Checkout URL to redirect the user to + * 413: + * description: Request body too large + * 429: + * description: Too many checkout requests + * 500: + * description: Failed to create checkout session + */ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) => { // Reject suspiciously large request bodies (>1KB) const contentLength = parseInt(req.headers["content-length"] || "0", 10); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 3f850a7..de7b984 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -49,7 +49,55 @@ interface ConvertBody { filename?: string; } -// POST /v1/convert/html +/** + * @openapi + * /v1/convert/html: + * post: + * tags: [Conversion] + * summary: Convert HTML to PDF + * description: Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [html] + * properties: + * html: + * type: string + * description: HTML content to convert. Can be a full document or a fragment. + * example: '

Hello World

My first PDF

' + * css: + * type: string + * description: Optional CSS to inject (only used when html is a fragment, not a full document) + * example: 'body { font-family: sans-serif; padding: 40px; }' + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing html field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type (must be application/json) + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { @@ -103,7 +151,54 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi } }); -// POST /v1/convert/markdown +/** + * @openapi + * /v1/convert/markdown: + * post: + * tags: [Conversion] + * summary: Convert Markdown to PDF + * description: Converts Markdown content to HTML and then to a PDF document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [markdown] + * properties: + * markdown: + * type: string + * description: Markdown content to convert + * example: '# Hello World\n\nThis is **bold** and *italic*.' + * css: + * type: string + * description: Optional CSS to inject into the rendered HTML + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing markdown field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { @@ -153,7 +248,59 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P } }); -// POST /v1/convert/url +/** + * @openapi + * /v1/convert/url: + * post: + * tags: [Conversion] + * summary: Convert URL to PDF + * description: | + * Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security. + * Private/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * description: URL to convert (http or https only) + * example: 'https://example.com' + * waitUntil: + * type: string + * enum: [load, domcontentloaded, networkidle0, networkidle2] + * default: domcontentloaded + * description: When to consider navigation finished + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing/invalid URL or URL resolves to private IP + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { diff --git a/src/routes/demo.ts b/src/routes/demo.ts index 9551f94..e664862 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -42,7 +42,59 @@ function sanitizeFilename(name: string): string { return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; } -// POST /v1/demo/html +/** + * @openapi + * /v1/demo/html: + * post: + * tags: [Demo] + * summary: Convert HTML to PDF (demo) + * description: | + * Public endpoint — no API key required. Rate limited to 5 requests per hour per IP. + * Output PDFs include a DocFast watermark. Upgrade to Pro for clean output. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [html] + * properties: + * html: + * type: string + * description: HTML content to convert + * example: '

Hello World

My first PDF

' + * css: + * type: string + * description: Optional CSS to inject (used when html is a fragment) + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: Watermarked PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing html field + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 415: + * description: Unsupported Content-Type + * 429: + * description: Demo rate limit exceeded (5/hour) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 503: + * description: Server busy + * 504: + * description: PDF generation timed out + */ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { @@ -93,7 +145,51 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { diff --git a/src/routes/health.ts b/src/routes/health.ts index 0c97dc0..39bfb23 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -10,6 +10,56 @@ export const healthRouter = Router(); const HEALTH_CHECK_TIMEOUT_MS = 3000; +/** + * @openapi + * /health: + * get: + * tags: [System] + * summary: Health check + * description: Returns service health status including database connectivity and browser pool stats. + * responses: + * 200: + * description: Service is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [ok, degraded] + * version: + * type: string + * example: '0.4.0' + * database: + * type: object + * properties: + * status: + * type: string + * enum: [ok, error] + * version: + * type: string + * example: 'PostgreSQL 17.4' + * pool: + * type: object + * properties: + * size: + * type: integer + * active: + * type: integer + * available: + * type: integer + * queueDepth: + * type: integer + * pdfCount: + * type: integer + * restarting: + * type: boolean + * uptimeSeconds: + * type: integer + * 503: + * description: Service is degraded (database issue) + */ healthRouter.get("/", async (_req, res) => { const poolStats = getPoolStats(); let databaseStatus: any; diff --git a/src/routes/recover.ts b/src/routes/recover.ts index 4ada099..2b8ca7e 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -15,6 +15,46 @@ const recoverLimiter = rateLimit({ legacyHeaders: false, }); +/** + * @openapi + * /v1/recover: + * post: + * tags: [Account] + * summary: Request API key recovery + * description: | + * Sends a 6-digit verification code to the email address if an account exists. + * Response is always the same regardless of whether the email exists (to prevent enumeration). + * Rate limited to 3 requests per hour. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * description: Email address associated with the API key + * responses: + * 200: + * description: Recovery code sent (or no-op if email not found) + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovery_sent + * message: + * type: string + * 400: + * description: Invalid email format + * 429: + * description: Too many recovery attempts + */ router.post("/", recoverLimiter, async (req: Request, res: Response) => { const { email } = req.body || {}; @@ -41,6 +81,52 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => { res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); }); +/** + * @openapi + * /v1/recover/verify: + * post: + * tags: [Account] + * summary: Verify recovery code and retrieve API key + * description: Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * description: 6-digit verification code + * responses: + * 200: + * description: API key recovered + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovered + * apiKey: + * type: string + * description: The recovered API key + * tier: + * type: string + * enum: [free, pro] + * 400: + * description: Invalid verification code or missing fields + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ router.post("/verify", recoverLimiter, async (req: Request, res: Response) => { const { email, code } = req.body || {}; diff --git a/src/routes/templates.ts b/src/routes/templates.ts index 944bbd8..5210f4f 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -9,7 +9,53 @@ function sanitizeFilename(name: string): string { export const templatesRouter = Router(); -// GET /v1/templates — list available templates +/** + * @openapi + * /v1/templates: + * get: + * tags: [Templates] + * summary: List available templates + * description: Returns a list of all built-in document templates with their required fields. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * responses: + * 200: + * description: List of templates + * content: + * application/json: + * schema: + * type: object + * properties: + * templates: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: invoice + * name: + * type: string + * example: Invoice + * description: + * type: string + * fields: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * required: + * type: boolean + * description: + * type: string + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + */ templatesRouter.get("/", (_req: Request, res: Response) => { const list = Object.entries(templates).map(([id, t]) => ({ id, @@ -20,7 +66,71 @@ templatesRouter.get("/", (_req: Request, res: Response) => { res.json({ templates: list }); }); -// POST /v1/templates/:id/render — render template to PDF +/** + * @openapi + * /v1/templates/{id}/render: + * post: + * tags: [Templates] + * summary: Render a template to PDF + * description: | + * Renders a built-in template with the provided data and returns a PDF. + * Use GET /v1/templates to see available templates and their required fields. + * Special fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename). + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Template ID (e.g. "invoice", "receipt") + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * description: Template data (fields depend on template). Can also be passed at root level. + * _format: + * type: string + * enum: [A4, Letter, Legal, A3, A5, Tabloid] + * default: A4 + * description: Page size override + * _margin: + * type: object + * properties: + * top: { type: string } + * right: { type: string } + * bottom: { type: string } + * left: { type: string } + * description: Page margin override + * _filename: + * type: string + * description: Custom output filename + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing required template fields + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 404: + * description: Template not found + * 500: + * description: Template rendering failed + */ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { try { const id = req.params.id as string;