v0.4.1: Code-driven OpenAPI docs via swagger-jsdoc
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Has been cancelled

- 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
This commit is contained in:
DocFast Bot 2026-02-20 07:54:37 +00:00
parent 53755d6093
commit 792e2d9142
12 changed files with 1931 additions and 305 deletions

View file

@ -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

294
package-lock.json generated
View file

@ -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",

View file

@ -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": {

File diff suppressed because it is too large Load diff

View file

@ -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 <key>\` or \`X-API-Key: <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)`);

63
src/openapi-extra.yaml Normal file
View file

@ -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

View file

@ -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);

View file

@ -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: '<h1>Hello World</h1><p>My first PDF</p>'
* 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<void>; 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<void>; 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<void>; releasePdfSlot?: () => void }, res: Response) => {
let slotAcquired = false;
try {

View file

@ -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: '<h1>Hello World</h1><p>My first PDF</p>'
* 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<void>; releasePdfSlot?: () => void }, res: Response) => {
let slotAcquired = false;
try {
@ -93,7 +145,51 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise<void
}
});
// POST /v1/demo/markdown
/**
* @openapi
* /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.
* Markdown is converted to HTML then rendered to PDF with a DocFast watermark.
* 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
*/
router.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
let slotAcquired = false;
try {

View file

@ -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;

View file

@ -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 || {};

View file

@ -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;