diff --git a/package-lock.json b/package-lock.json index 8b06d06..7ecd922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,17 +16,63 @@ "pg": "^8.13.0", "pino": "^10.3.1", "puppeteer": "^24.0.0", - "stripe": "^17.0.0" + "stripe": "^17.0.0", + "swagger-jsdoc": "^6.2.8" }, "devDependencies": { "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.0.0", + "@types/swagger-jsdoc": "^6.0.4", "tsx": "^4.0.0", "typescript": "^5.6.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", @@ -492,6 +538,12 @@ "node": ">=18" } }, + "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", @@ -612,6 +664,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", @@ -668,6 +726,13 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -780,6 +845,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", @@ -905,6 +976,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", @@ -952,6 +1033,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", @@ -1006,6 +1093,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "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/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -1036,6 +1132,12 @@ "node": ">= 0.8.0" } }, + "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", @@ -1155,6 +1257,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", @@ -1529,6 +1643,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", @@ -1664,6 +1784,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", @@ -1829,6 +1970,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", @@ -1898,6 +2050,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/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -1985,6 +2157,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", @@ -2084,6 +2268,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", @@ -2178,6 +2369,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", @@ -2919,6 +3119,38 @@ "node": ">=12.*" } }, + "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-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/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -3067,6 +3299,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", @@ -3144,6 +3385,15 @@ "node": ">=10" } }, + "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/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -3181,6 +3431,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 e7642b9..2dabef3 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,15 @@ "pg": "^8.13.0", "pino": "^10.3.1", "puppeteer": "^24.0.0", - "stripe": "^17.0.0" + "stripe": "^17.0.0", + "swagger-jsdoc": "^6.2.8" }, "devDependencies": { "@types/compression": "^1.8.1", "@types/express": "^5.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.0.0", + "@types/swagger-jsdoc": "^6.0.4", "tsx": "^4.0.0", "typescript": "^5.6.0" } diff --git a/public/openapi.json b/public/openapi.json deleted file mode 100644 index 4a8307f..0000000 --- a/public/openapi.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "SnapAPI — Screenshot API", - "description": "Convert any URL to a pixel-perfect screenshot. EU-hosted, GDPR compliant.\n\n## Authentication\nAPI screenshot requests require an API key:\n- `Authorization: Bearer YOUR_API_KEY` header, or\n- `X-API-Key: YOUR_API_KEY` header\n\n## Playground\nThe `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n## Rate Limits\n- 120 requests per minute per IP (global)\n- 5 requests per hour per IP (playground)\n- Monthly screenshot limits based on your plan tier (API)", - "version": "0.3.0", - "contact": { - "name": "SnapAPI Support", - "url": "https://snapapi.eu", - "email": "support@snapapi.eu" - }, - "license": {"name": "Proprietary"} - }, - "servers": [{"url": "https://snapapi.eu", "description": "Production (EU — Germany)"}], - "tags": [ - {"name": "Screenshots", "description": "Screenshot capture endpoints"}, - {"name": "Playground", "description": "Free demo (no auth, watermarked)"}, - {"name": "System", "description": "Health and status endpoints"} - ], - "paths": { - "/v1/screenshot": { - "post": { - "tags": ["Screenshots"], - "summary": "Take a screenshot (authenticated)", - "description": "Capture a pixel-perfect, unwatermarked screenshot. Requires an API key.", - "operationId": "takeScreenshot", - "security": [{"BearerAuth": []}, {"ApiKeyAuth": []}], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": {"$ref": "#/components/schemas/ScreenshotRequest"}, - "examples": { - "simple": {"summary": "Simple screenshot", "value": {"url": "https://example.com"}}, - "hd_jpeg": {"summary": "HD JPEG", "value": {"url": "https://github.com", "format": "jpeg", "width": 1920, "height": 1080, "quality": 90}}, - "mobile": {"summary": "Mobile", "value": {"url": "https://example.com", "width": 375, "height": 812, "deviceScale": 2}} - } - } - } - }, - "responses": { - "200": {"description": "Screenshot captured", "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}, "image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/webp": {"schema": {"type": "string", "format": "binary"}}}}, - "400": {"description": "Invalid request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "401": {"description": "Missing API key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "403": {"description": "Invalid API key", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "429": {"description": "Rate/usage limit exceeded", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "503": {"description": "Service busy", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "504": {"description": "Timeout", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} - } - } - }, - "/v1/playground": { - "post": { - "tags": ["Playground"], - "summary": "Free demo screenshot (watermarked)", - "description": "Take a watermarked screenshot without authentication. Limited to 5 requests per hour per IP, max 1920x1080 resolution. Perfect for evaluating the API before purchasing a plan.", - "operationId": "playgroundScreenshot", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["url"], - "properties": { - "url": {"type": "string", "format": "uri", "description": "URL to capture", "example": "https://example.com"}, - "format": {"type": "string", "enum": ["png", "jpeg", "webp"], "default": "png"}, - "width": {"type": "integer", "minimum": 320, "maximum": 1920, "default": 1280}, - "height": {"type": "integer", "minimum": 200, "maximum": 1080, "default": 800} - } - } - } - } - }, - "responses": { - "200": {"description": "Watermarked screenshot", "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}}}, - "400": {"description": "Invalid request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "429": {"description": "Rate limit (5/hr)", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, - "503": {"description": "Service busy", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}} - } - } - }, - "/health": { - "get": { - "tags": ["System"], - "summary": "Health check", - "operationId": "healthCheck", - "responses": { - "200": {"description": "Healthy", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"type": "string"}, "version": {"type": "string"}, "uptime": {"type": "number"}, "browser": {"type": "object"}}}}}} - } - } - } - }, - "components": { - "securitySchemes": { - "BearerAuth": {"type": "http", "scheme": "bearer"}, - "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"} - }, - "schemas": { - "ScreenshotRequest": { - "type": "object", - "required": ["url"], - "properties": { - "url": {"type": "string", "format": "uri", "example": "https://example.com"}, - "format": {"type": "string", "enum": ["png", "jpeg", "webp"], "default": "png"}, - "width": {"type": "integer", "minimum": 320, "maximum": 3840, "default": 1280}, - "height": {"type": "integer", "minimum": 200, "maximum": 2160, "default": 800}, - "fullPage": {"type": "boolean", "default": false}, - "quality": {"type": "integer", "minimum": 1, "maximum": 100, "default": 80}, - "waitForSelector": {"type": "string"}, - "deviceScale": {"type": "number", "minimum": 1, "maximum": 3, "default": 1}, - "delay": {"type": "integer", "minimum": 0, "maximum": 5000, "default": 0} - } - }, - "Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "string"}}} - } - } -} diff --git a/src/docs/openapi.ts b/src/docs/openapi.ts new file mode 100644 index 0000000..da27cee --- /dev/null +++ b/src/docs/openapi.ts @@ -0,0 +1,53 @@ +import swaggerJsdoc from "swagger-jsdoc"; + +const options: swaggerJsdoc.Options = { + definition: { + openapi: "3.0.3", + info: { + title: "SnapAPI — Screenshot API", + description: + "Convert any URL to a pixel-perfect screenshot. EU-hosted, GDPR compliant.\n\n" + + "## Authentication\n" + + "API screenshot requests require an API key:\n" + + "- `Authorization: Bearer YOUR_API_KEY` header, or\n" + + "- `X-API-Key: YOUR_API_KEY` header\n\n" + + "## Playground\n" + + "The `/v1/playground` endpoint requires no authentication but returns watermarked screenshots (5 requests/hour per IP).\n\n" + + "## Rate Limits\n" + + "- 120 requests per minute per IP (global)\n" + + "- 5 requests per hour per IP (playground)\n" + + "- Monthly screenshot limits based on your plan tier (API)", + version: "0.3.0", + contact: { + name: "SnapAPI Support", + url: "https://snapapi.eu", + email: "support@snapapi.eu", + }, + license: { name: "Proprietary" }, + }, + servers: [{ url: "https://snapapi.eu", description: "Production (EU — Germany)" }], + tags: [ + { name: "Screenshots", description: "Screenshot capture endpoints" }, + { name: "Playground", description: "Free demo (no auth, watermarked)" }, + { name: "Signup", description: "Account creation" }, + { name: "Billing", description: "Subscription and payment management" }, + { name: "System", description: "Health and status endpoints" }, + ], + components: { + securitySchemes: { + BearerAuth: { type: "http", scheme: "bearer" }, + ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, + }, + schemas: { + Error: { + type: "object", + required: ["error"], + properties: { error: { type: "string" } }, + }, + }, + }, + }, + apis: ["./src/routes/*.ts"], +}; + +export const openapiSpec = swaggerJsdoc(options); diff --git a/src/index.ts b/src/index.ts index b6c56fe..b1d4bc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ import { loadKeys, getAllKeys } from "./services/keys.js"; import { initDatabase, pool } from "./services/db.js"; import { billingRouter } from "./routes/billing.js"; import { statusRouter } from "./routes/status.js"; +import { signupRouter } from "./routes/signup.js"; +import { openapiSpec } from "./docs/openapi.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); @@ -93,6 +95,7 @@ app.use("/health", healthRouter); app.use("/v1/billing", billingRouter); app.use("/status", statusRouter); app.use("/v1/playground", playgroundRouter); +app.use("/v1/signup", signupRouter); // Authenticated routes app.use("/v1/screenshot", authMiddleware, usageMiddleware, screenshotRouter); @@ -110,7 +113,12 @@ app.get("/api", (_req, res) => { }); }); -// Swagger docs +// OpenAPI spec (code-generated) +app.get("/openapi.json", (_req, res) => { + res.json(openapiSpec); +}); + +// Swagger docs UI app.get("/docs", (_req, res) => { res.sendFile(path.join(__dirname, "../public/docs.html")); }); diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 554b90e..47cb982 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -122,7 +122,49 @@ async function isSnapAPIEvent(event: Stripe.Event): Promise { } } -// POST /v1/billing/checkout +/** + * @openapi + * /v1/billing/checkout: + * post: + * tags: [Billing] + * summary: Create a Stripe checkout session + * description: Start a subscription checkout flow. Returns a Stripe URL to redirect the user to. + * operationId: billingCheckout + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [plan] + * properties: + * plan: + * type: string + * enum: [starter, pro, business] + * description: Subscription plan to purchase + * responses: + * 200: + * description: Checkout session created + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * description: Stripe Checkout URL — redirect the user here + * 400: + * description: Invalid plan + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 500: + * description: Checkout creation failed + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + */ router.post("/checkout", async (req: Request, res: Response) => { try { const { plan } = req.body; @@ -149,7 +191,32 @@ router.post("/checkout", async (req: Request, res: Response) => { } }); -// GET /v1/billing/success +/** + * @openapi + * /v1/billing/success: + * get: + * tags: [Billing] + * summary: Post-checkout success page + * description: Stripe redirects here after successful payment. Provisions the API key and shows it to the user. + * operationId: billingSuccess + * parameters: + * - in: query + * name: session_id + * required: true + * schema: + * type: string + * description: Stripe checkout session ID + * responses: + * 200: + * description: HTML page with API key + * content: + * text/html: + * schema: { type: string } + * 400: + * description: Missing session_id + * 500: + * description: Error retrieving session + */ router.get("/success", async (req: Request, res: Response) => { try { const sessionId = req.query.session_id as string; @@ -204,7 +271,36 @@ Use it with X-API-Key header or ?key= param.

} }); -// POST /v1/billing/webhook +/** + * @openapi + * /v1/billing/webhook: + * post: + * tags: [Billing] + * summary: Stripe webhook receiver + * description: > + * Receives Stripe webhook events for subscription lifecycle management. + * Handles checkout.session.completed, customer.subscription.updated, + * customer.subscription.deleted, and customer.updated events. + * operationId: billingWebhook + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Raw Stripe event payload (verified via signature) + * responses: + * 200: + * description: Webhook processed + * content: + * application/json: + * schema: + * type: object + * properties: + * received: { type: boolean } + * 400: + * description: Invalid signature or payload + */ router.post("/webhook", async (req: Request, res: Response) => { try { const sig = req.headers["stripe-signature"] as string; diff --git a/src/routes/health.ts b/src/routes/health.ts index 9dd3128..2e9e435 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -3,6 +3,26 @@ import { getPoolStats } from "../services/browser.js"; export const healthRouter = Router(); +/** + * @openapi + * /health: + * get: + * tags: [System] + * summary: Health check + * operationId: healthCheck + * responses: + * 200: + * description: Service is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: { type: string, example: ok } + * version: { type: string } + * uptime: { type: number, description: Uptime in seconds } + * browser: { type: object, description: Browser pool stats } + */ healthRouter.get("/", (_req, res) => { const pool = getPoolStats(); res.json({ diff --git a/src/routes/playground.ts b/src/routes/playground.ts index e467192..764a144 100644 --- a/src/routes/playground.ts +++ b/src/routes/playground.ts @@ -16,6 +16,73 @@ const playgroundLimiter = rateLimit({ keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown", }); +/** + * @openapi + * /v1/playground: + * post: + * tags: [Playground] + * summary: Free demo screenshot (watermarked) + * description: > + * Take a watermarked screenshot without authentication. + * Limited to 5 requests per hour per IP, max 1920×1080 resolution. + * Perfect for evaluating the API before purchasing a plan. + * operationId: playgroundScreenshot + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * description: URL to capture + * example: "https://example.com" + * format: + * type: string + * enum: [png, jpeg, webp] + * default: png + * description: Output image format + * width: + * type: integer + * minimum: 320 + * maximum: 1920 + * default: 1280 + * description: Viewport width (clamped to 1920 max) + * height: + * type: integer + * minimum: 200 + * maximum: 1080 + * default: 800 + * description: Viewport height (clamped to 1080 max) + * responses: + * 200: + * description: Watermarked screenshot image + * content: + * image/png: + * schema: { type: string, format: binary } + * image/jpeg: + * schema: { type: string, format: binary } + * image/webp: + * schema: { type: string, format: binary } + * 400: + * description: Invalid request + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 429: + * description: Rate limit exceeded (5/hr) + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 503: + * description: Service busy + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + */ playgroundRouter.post("/", playgroundLimiter, async (req, res) => { const { url, format, width, height } = req.body; diff --git a/src/routes/screenshot.ts b/src/routes/screenshot.ts index 4d1091d..aa735e5 100644 --- a/src/routes/screenshot.ts +++ b/src/routes/screenshot.ts @@ -4,6 +4,123 @@ import logger from "../services/logger.js"; export const screenshotRouter = Router(); +/** + * @openapi + * /v1/screenshot: + * post: + * tags: [Screenshots] + * summary: Take a screenshot (authenticated) + * description: Capture a pixel-perfect, unwatermarked screenshot. Requires an API key. + * operationId: takeScreenshot + * security: + * - BearerAuth: [] + * - ApiKeyAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * description: URL to capture + * example: "https://example.com" + * format: + * type: string + * enum: [png, jpeg, webp] + * default: png + * description: Output image format + * width: + * type: integer + * minimum: 320 + * maximum: 3840 + * default: 1280 + * description: Viewport width in pixels + * height: + * type: integer + * minimum: 200 + * maximum: 2160 + * default: 800 + * description: Viewport height in pixels + * fullPage: + * type: boolean + * default: false + * description: Capture full scrollable page instead of viewport only + * quality: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 80 + * description: JPEG/WebP quality (ignored for PNG) + * waitForSelector: + * type: string + * description: CSS selector to wait for before capturing (e.g. "#main-content") + * deviceScale: + * type: number + * minimum: 1 + * maximum: 3 + * default: 1 + * description: Device scale factor (2 = Retina) + * delay: + * type: integer + * minimum: 0 + * maximum: 5000 + * default: 0 + * description: Extra delay in ms after page load before capturing + * examples: + * simple: + * summary: Simple screenshot + * value: { "url": "https://example.com" } + * hd_jpeg: + * summary: HD JPEG + * value: { "url": "https://github.com", "format": "jpeg", "width": 1920, "height": 1080, "quality": 90 } + * mobile: + * summary: Mobile viewport + * value: { "url": "https://example.com", "width": 375, "height": 812, "deviceScale": 2 } + * responses: + * 200: + * description: Screenshot image binary + * content: + * image/png: + * schema: { type: string, format: binary } + * image/jpeg: + * schema: { type: string, format: binary } + * image/webp: + * schema: { type: string, format: binary } + * 400: + * description: Invalid request (bad URL, blocked domain, etc.) + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 401: + * description: Missing API key + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 403: + * description: Invalid API key + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 429: + * description: Rate or usage limit exceeded + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 503: + * description: Service busy (queue full) + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 504: + * description: Screenshot timed out + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + */ screenshotRouter.post("/", async (req: any, res) => { const { url, format, width, height, fullPage, quality, waitForSelector, deviceScale, delay } = req.body; diff --git a/src/routes/signup.ts b/src/routes/signup.ts index 1a728e1..bc47ab2 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -5,6 +5,57 @@ import logger from "../services/logger.js"; export const signupRouter = Router(); // Simple signup: email → instant API key (no verification for now) +/** + * @openapi + * /v1/signup/free: + * post: + * tags: [Signup] + * summary: Create a free account + * description: Sign up with an email to get a free API key (100 screenshots/month). + * operationId: signupFree + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * description: Your email address + * example: "user@example.com" + * responses: + * 200: + * description: API key created + * content: + * application/json: + * schema: + * type: object + * properties: + * apiKey: + * type: string + * description: Your new API key + * tier: + * type: string + * example: free + * limit: + * type: integer + * example: 100 + * message: + * type: string + * 400: + * description: Invalid email + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + * 500: + * description: Signup failed + * content: + * application/json: + * schema: { $ref: "#/components/schemas/Error" } + */ signupRouter.post("/free", async (req, res) => { const { email } = req.body;