From 5e02b08ecce2c0c19f02da4d63ada489176e5845 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Oct 2025 15:22:33 +0200 Subject: [PATCH 01/16] Add Swagger/OpenAPI documentation for Analytics API - Install swagger-jsdoc and swagger-ui-express - Create comprehensive OpenAPI configuration with API overview - Document analytics endpoint with detailed JSDoc annotations - Add schema definitions for all response types (Spotify, Apple, Hoster, etc.) - Create AVAILABLE_QUERIES.md listing all 50+ query endpoints - Expose Swagger UI at /api-docs and OpenAPI spec at /api-docs.json - Make documentation publicly accessible (no auth required) The documentation includes: - All available data sources (Spotify, Apple Podcasts, generic hosters) - Complete parameter definitions with examples - Response schemas with property descriptions - Authentication requirements - CSV export format support - Example usage for common scenarios --- docs/AVAILABLE_QUERIES.md | 154 ++++++++++++ package-lock.json | 462 ++++++++++++++++++++++++++++++---- package.json | 6 +- src/config/swagger-schemas.ts | 305 ++++++++++++++++++++++ src/config/swagger.ts | 158 ++++++++++++ src/index.ts | 128 +++++++++- 6 files changed, 1163 insertions(+), 50 deletions(-) create mode 100644 docs/AVAILABLE_QUERIES.md create mode 100644 src/config/swagger-schemas.ts create mode 100644 src/config/swagger.ts diff --git a/docs/AVAILABLE_QUERIES.md b/docs/AVAILABLE_QUERIES.md new file mode 100644 index 0000000..cd5c66d --- /dev/null +++ b/docs/AVAILABLE_QUERIES.md @@ -0,0 +1,154 @@ +# Available Analytics Query Endpoints + +This document lists all available query endpoints in the Open Podcast Analytics API. + +All endpoints follow the pattern: `/analytics/v1/{podcast_id}/{query_name}` + +Query parameters: +- `start`: Start date (YYYY-MM-DD format) +- `end`: End date (YYYY-MM-DD format) + +Add `/csv` to the end of any endpoint to get CSV format instead of JSON. + +## Spotify Endpoints + +### Podcast-Level Metrics +- `reportSpotifyPodcastBaseMetrics` - Base podcast metrics (streams, listeners, followers) +- `reportSpotifyPlays` - Play counts over time +- `reportSpotifyUniqueListeners` - Unique listener counts +- `spotifyPlaysSum` - Sum of plays for a date range + +### Episode-Level Metrics +- `spotifyEpisodesMetricsExport` - Detailed episode metrics export + +### Demographics +- `podcastAge` - Age distribution of podcast listeners +- `podcastGender` - Gender distribution of podcast listeners +- `episodesAge` - Age distribution per episode +- `episodesGender` - Gender distribution per episode + +### Geographic Data +- `spotifyCountries` - Geographic distribution by country + +### Impressions & Discovery +- `spotifyImpressions` - Total impressions over time +- `spotifyImpressionsSources` - Impressions by source (HOME, SEARCH, LIBRARY, OTHER) +- `spotifyImpressionsFunnel` - Conversion funnel from impressions to streams + +## Apple Podcasts Endpoints + +### Podcast-Level Metrics +- `reportApplePodcastBaseMetrics` - Base podcast metrics (plays, listeners, engagement) +- `applePodcastFollowers` - Follower counts and trends +- `reportApplePlays` - Play counts over time + +### Episode-Level Metrics +- `appleEpisodesPlays` - Episode play counts +- `appleEpisodesLTR` - Episode listen-through rates +- `reportEpisodesLTRHistogram` - Detailed LTR histograms per episode + +### Geographic Data +- `rawApplePodcastCountries` - Geographic distribution by country + +## Cross-Platform Endpoints + +### Episode Metrics +- `episodesTotalMetrics` - Combined Apple + Spotify total metrics per episode +- `episodesDailyMetrics` - Combined daily metrics per episode +- `episodesMetadata` - Episode metadata from all platforms +- `episodesLTR` - Combined listen-through rates +- `episodesLTRHistogram` - Combined LTR histograms + +### Podcast Metrics +- `podcastMetadata` - Podcast metadata from all platforms +- `podcastFollowers` - Combined follower counts + +## Generic Hoster Endpoints + +### Downloads & Performance +- `reportHosterDownloads` - Download counts over time +- `reportTopEpisodesPerformance` - Top performing episodes +- `reportEpisodeTotalPlays` - Total plays per episode (lifetime) + +### Distribution Metrics +- `reportHosterPlatforms` - Platform/app distribution (e.g., Apple Podcasts, Spotify, Overcast) +- `reportHosterClients` - Client/device distribution + +### Podigee-Specific +- `reportHosterPodigeePodcastOverview` - Podcast overview metrics (Podigee hosting) + +## Chart Rankings + +- `chartsRankings` - Chart positions and rankings across platforms + +## Anchor (Legacy) + +### Episode Metrics +- `reportAnchorEpisodeMetadata` - Episode metadata +- `reportAnchorEpisodeLTRHistogram` - Episode listen-through histogram +- `reportAnchorAvgEpisodeLTRHistogram` - Average LTR across episodes + +### Audience +- `reportAnchorTotalPlaysByEpisode` - Total plays per episode +- `reportAnchorPlays` - Plays over time +- `reportAnchorTopEpisodes` - Top performing episodes + +## Utility Endpoints + +- `ping` - Health check / connection test + +## Example Usage + +### Get Spotify podcast metrics for August 2024 (JSON) +```bash +GET /analytics/v1/123/reportSpotifyPodcastBaseMetrics?start=2024-08-01&end=2024-08-31 +Authorization: Bearer YOUR_TOKEN +``` + +### Get platform distribution for August 2024 (CSV) +```bash +GET /analytics/v1/123/reportHosterPlatforms/csv?start=2024-08-01&end=2024-08-31 +Authorization: Bearer YOUR_TOKEN +``` + +### Get combined episode metrics (defaults to yesterday if no dates provided) +```bash +GET /analytics/v1/123/episodesTotalMetrics +Authorization: Bearer YOUR_TOKEN +``` + +## Response Format + +### JSON Response +```json +{ + "meta": { + "query": "reportSpotifyPodcastBaseMetrics", + "podcastId": "123", + "date": "2024-09-24T12:00:00Z", + "startDate": "2024-08-01", + "endDate": "2024-08-31" + }, + "data": [ + { + "total_episodes": 50, + "starts": 10000, + "streams": 8500, + "listeners": 5000, + "followers": 2500, + "date": "2024-08-31" + } + ] +} +``` + +### CSV Response +When requesting CSV format (by adding `/csv` to the endpoint), the response will be a CSV file with appropriate headers. + +## Notes + +- All endpoints require authentication via Bearer token +- Users can only access podcast IDs they have permission for +- Date parameters are optional - defaults to yesterday if not provided +- Invalid date formats or date ranges will return 400 Bad Request +- Non-existent query endpoints will return 404 Not Found diff --git a/package-lock.json b/package-lock.json index bebf9dc..d216a76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "hbs": "^4.2.0", "mathjs": "^11.4.0", "moment": "^2.29.4", - "mysql2": "^2.3.3" + "mysql2": "^2.3.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/body-parser": "^1.19.2", @@ -26,6 +28,8 @@ "@types/jest": "^30.0.0", "@types/mysql2": "types/mysql2", "@types/node": "^18.7.18", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", "concurrently": "^7.4.0", @@ -60,6 +64,50 @@ "node": ">=6.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.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1191,6 +1239,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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1226,6 +1280,13 @@ "node": ">= 8" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -1651,8 +1712,7 @@ "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", - "dev": true + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, "node_modules/@types/json5": { "version": "0.0.29", @@ -1748,6 +1808,24 @@ "dev": true, "license": "MIT" }, + "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/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -2288,8 +2366,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "1.1.1", @@ -2448,8 +2525,7 @@ "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==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -2500,7 +2576,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -2603,6 +2678,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", @@ -2828,8 +2909,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "node_modules/concurrently": { "version": "7.6.0", @@ -3081,7 +3161,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "dependencies": { "esutils": "^2.0.2" }, @@ -3774,7 +3853,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -4141,8 +4219,7 @@ "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==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { "version": "2.3.2", @@ -4578,7 +4655,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -5530,7 +5606,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5646,6 +5721,20 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "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.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5658,6 +5747,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "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/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -5828,7 +5923,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -6119,7 +6213,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "dependencies": { "wrappy": "1" } @@ -6139,6 +6232,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -6246,7 +6346,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -7241,6 +7340,92 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "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/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.29.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.1.tgz", + "integrity": "sha512-qyjpz0qgcomRr41a5Aye42o69TKwCeHM9F8htLGVeUMKekNS6qAqz9oS7CtSvgGJSppSNAYAIh7vrfrSdHj9zw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -7929,8 +8114,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { "version": "4.0.2", @@ -7959,6 +8143,15 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "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.6.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", @@ -8006,6 +8199,36 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "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" + } } }, "dependencies": { @@ -8019,6 +8242,40 @@ "@jridgewell/trace-mapping": "^0.3.9" } }, + "@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==", + "requires": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "@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==" + }, + "@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==" + }, + "@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "requires": { + "@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" + } + }, "@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -8891,6 +9148,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@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==" + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -8917,6 +9179,11 @@ "fastq": "^1.6.0" } }, + "@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==" + }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -9274,8 +9541,7 @@ "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", - "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", - "dev": true + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, "@types/json5": { "version": "0.0.29", @@ -9366,6 +9632,22 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "@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 + }, + "@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -9766,8 +10048,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "array-flatten": { "version": "1.1.1", @@ -9890,8 +10171,7 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "binary-extensions": { "version": "2.2.0", @@ -9937,7 +10217,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -10011,6 +10290,11 @@ "get-intrinsic": "^1.0.2" } }, + "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==" + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -10169,8 +10453,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, "concurrently": { "version": "7.6.0", @@ -10351,7 +10634,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "requires": { "esutils": "^2.0.2" } @@ -10858,8 +11140,7 @@ "esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==" }, "etag": { "version": "1.8.1", @@ -11161,8 +11442,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "fsevents": { "version": "2.3.2", @@ -11464,7 +11744,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -12175,7 +12454,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" } @@ -12258,6 +12536,16 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "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==" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -12270,6 +12558,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -12396,7 +12689,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "requires": { "brace-expansion": "^1.1.7" } @@ -12620,7 +12912,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "requires": { "wrappy": "1" } @@ -12634,6 +12925,12 @@ "mimic-fn": "^2.1.0" } }, + "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==", + "peer": true + }, "optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -12707,8 +13004,7 @@ "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==", - "dev": true + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, "path-key": { "version": "3.1.1", @@ -13432,6 +13728,63 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "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==", + "requires": { + "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" + }, + "dependencies": { + "commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "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" + } + } + } + }, + "swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "requires": { + "@apidevtools/swagger-parser": "10.0.3" + } + }, + "swagger-ui-dist": { + "version": "5.29.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.1.tgz", + "integrity": "sha512-qyjpz0qgcomRr41a5Aye42o69TKwCeHM9F8htLGVeUMKekNS6qAqz9oS7CtSvgGJSppSNAYAIh7vrfrSdHj9zw==", + "requires": { + "@scarf/scarf": "=1.4.0" + } + }, + "swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "requires": { + "swagger-ui-dist": ">=5.0.0" + } + }, "tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -13906,8 +14259,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "write-file-atomic": { "version": "4.0.2", @@ -13930,6 +14282,11 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==" + }, "yargs": { "version": "17.6.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.6.2.tgz", @@ -13962,6 +14319,25 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "requires": { + "commander": "^9.4.1", + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "optional": true + } + } } } } diff --git a/package.json b/package.json index b3aaf87..98dd5a7 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "hbs": "^4.2.0", "mathjs": "^11.4.0", "moment": "^2.29.4", - "mysql2": "^2.3.3" + "mysql2": "^2.3.3", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@types/body-parser": "^1.19.2", @@ -28,6 +30,8 @@ "@types/jest": "^30.0.0", "@types/mysql2": "types/mysql2", "@types/node": "^18.7.18", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", "@typescript-eslint/eslint-plugin": "^5.45.0", "@typescript-eslint/parser": "^5.45.0", "concurrently": "^7.4.0", diff --git a/src/config/swagger-schemas.ts b/src/config/swagger-schemas.ts new file mode 100644 index 0000000..0c94272 --- /dev/null +++ b/src/config/swagger-schemas.ts @@ -0,0 +1,305 @@ +/** + * OpenAPI schema definitions for common response types + * These schemas are referenced in the API documentation + */ + +export const schemas = { + /** + * @openapi + * components: + * schemas: + * # Spotify Schemas + * SpotifyPodcastMetrics: + * type: object + * properties: + * total_episodes: + * type: integer + * description: Total number of episodes + * starts: + * type: integer + * description: Number of stream starts + * streams: + * type: integer + * description: Number of completed streams + * listeners: + * type: integer + * description: Unique listener count + * followers: + * type: integer + * description: Total followers + * date: + * type: string + * format: date + * + * SpotifyEpisodePerformance: + * type: object + * properties: + * episode_id: + * type: string + * episode_name: + * type: string + * median_percentage: + * type: integer + * description: Median listen-through percentage + * median_seconds: + * type: integer + * description: Median listen time in seconds + * percentile_25: + * type: integer + * percentile_50: + * type: integer + * percentile_75: + * type: integer + * percentile_100: + * type: integer + * + * SpotifyDemographics: + * type: object + * properties: + * age_group: + * type: string + * enum: [0-17, 18-22, 23-27, 28-34, 35-44, 45-59, 60+] + * gender: + * type: string + * enum: [male, female, non-binary, not-specified] + * listeners: + * type: integer + * country: + * type: string + * description: ISO country code + * + * # Apple Podcasts Schemas + * ApplePodcastMetrics: + * type: object + * properties: + * plays: + * type: integer + * description: Total play count + * listeners: + * type: integer + * description: Unique listener count + * engaged_listeners: + * type: integer + * description: Engaged listener count + * total_time_listened: + * type: integer + * description: Total listening time in seconds + * date: + * type: string + * format: date + * + * AppleEpisodeDetails: + * type: object + * properties: + * episode_id: + * type: integer + * episode_name: + * type: string + * plays_count: + * type: integer + * unique_listeners: + * type: integer + * unique_engaged_listeners: + * type: integer + * engaged_plays_count: + * type: integer + * total_time_listened: + * type: integer + * quarter1_median: + * type: number + * description: Median listener retention at 25% + * quarter2_median: + * type: number + * description: Median listener retention at 50% + * quarter3_median: + * type: number + * description: Median listener retention at 75% + * quarter4_median: + * type: number + * description: Median listener retention at 100% + * + * AppleFollowerStats: + * type: object + * properties: + * date: + * type: string + * format: date + * total_followers: + * type: integer + * gained: + * type: integer + * description: Followers gained on this day + * lost: + * type: integer + * description: Followers lost on this day + * + * # Episode Mapping Schemas + * EpisodeMetadata: + * type: object + * properties: + * account_id: + * type: integer + * spotify_episode_id: + * type: string + * apple_episode_id: + * type: integer + * episode_name: + * type: string + * guid: + * type: string + * release_date: + * type: string + * format: date-time + * duration: + * type: integer + * description: Duration in seconds + * artwork_url: + * type: string + * format: uri + * + * EpisodeTotalMetrics: + * type: object + * properties: + * account_id: + * type: integer + * spotify_episode_id: + * type: string + * apple_episode_id: + * type: integer + * guid: + * type: string + * date: + * type: string + * format: date + * total_apple_plays: + * type: integer + * total_apple_listeners: + * type: integer + * total_apple_engaged_listeners: + * type: integer + * total_apple_time_listened: + * type: integer + * total_spotify_starts: + * type: integer + * total_spotify_streams: + * type: integer + * total_spotify_listeners: + * type: integer + * + * # Hoster Schemas + * HosterPlatformDistribution: + * type: object + * properties: + * platform_name: + * type: string + * description: Platform/app name (e.g., Apple Podcasts, Spotify, Overcast) + * total_downloads: + * type: integer + * percentage: + * type: number + * format: float + * description: Percentage of total downloads + * + * HosterClientDistribution: + * type: object + * properties: + * client_name: + * type: string + * description: Client/device type + * total_downloads: + * type: integer + * percentage: + * type: number + * format: float + * + * # Chart Schemas + * ChartRanking: + * type: object + * properties: + * date: + * type: string + * format: date + * category: + * type: string + * country: + * type: string + * rank: + * type: integer + * platform: + * type: string + * enum: [spotify, apple] + * + * # Listen-Through Rate (LTR) Schemas + * LTRHistogram: + * type: object + * properties: + * episode_id: + * type: string + * episode_name: + * type: string + * seconds: + * type: integer + * description: Time offset in seconds + * apple_listeners: + * type: integer + * spotify_listeners: + * type: integer + * apple_percentage: + * type: number + * description: Percentage of listeners at this point (Apple) + * spotify_percentage: + * type: number + * description: Percentage of listeners at this point (Spotify) + * + * # Impressions Schemas (Spotify) + * SpotifyImpressions: + * type: object + * properties: + * date: + * type: string + * format: date + * impressions: + * type: integer + * description: Total impressions on Spotify + * + * SpotifyImpressionsSources: + * type: object + * properties: + * source: + * type: string + * enum: [HOME, SEARCH, LIBRARY, OTHER] + * impression_count: + * type: integer + * date_start: + * type: string + * format: date + * date_end: + * type: string + * format: date + * + * SpotifyFunnel: + * type: object + * properties: + * date: + * type: string + * format: date + * step: + * type: string + * enum: [impressions, considerations, streams] + * count: + * type: integer + * conversion_percent: + * type: number + * format: float + * + * # Error Response + * Error: + * type: object + * properties: + * message: + * type: string + * tracingId: + * type: string + * description: Error tracking ID for debugging + */ +}; diff --git a/src/config/swagger.ts b/src/config/swagger.ts new file mode 100644 index 0000000..b714f46 --- /dev/null +++ b/src/config/swagger.ts @@ -0,0 +1,158 @@ +import swaggerJsdoc from 'swagger-jsdoc'; +import { version } from '../../package.json'; +import './swagger-schemas'; + +const options: swaggerJsdoc.Options = { + definition: { + openapi: '3.0.0', + info: { + title: 'Open Podcast Analytics API', + version: version, + description: ` +# Open Podcast Analytics API + +Comprehensive podcast analytics API providing metrics from multiple platforms including Spotify, Apple Podcasts, and custom hosting providers. + +## Available Data + +### Spotify Data +- Episode and podcast streams (starts and completed streams, daily granularity) +- Listener counts (unique listeners over time) +- Followers of the podcast (daily snapshots) +- Episode performance (median listen percentage, median seconds, percentiles, listen-through curves on a per-second basis) +- Audience demographics (age groups, gender, country, at both episode and podcast level) +- Podcast metadata (episode titles, descriptions, artwork, URLs, release dates, explicit flag, duration, language) +- Historical metadata snapshots (track changes over time) +- Impressions and funnel data (total impressions, impressions by source such as home, search, library, other, funnel stages from impressions to considerations to streams, conversion percentages per day) + +### Apple Podcasts Data +- Episode metadata (episode name, collection/podcast name, release datetime, GUID, episode number, type) +- Episode details (plays, total time listened, unique engaged listeners, unique listeners, engaged plays vs. total plays) +- Playback histograms (listen-through on a time-bucket basis, top countries and cities, median listeners at different quarters of the episode) +- Trends (daily episode-level metrics: plays, total time listened, unique listeners, engaged listeners) +- Trends (daily podcast-level metrics: same as above, aggregated across episodes) +- Listening time split by followers vs. non-followers +- Follower statistics (total followers, unfollowers, gained/lost per day) + +### General Metrics +- Plays, streams, downloads (episode and podcast level, daily) +- Listen-through data (per-second or per-bucket playback curves, histograms, medians, percentiles) +- Audience demographics (age, gender, country) +- Followers and subscribers (absolute numbers and gained/lost trends) +- Impressions and funnel steps (Spotify) +- Metadata (titles, descriptions, release dates, artwork, language) +- Historical snapshots (to track changes over time) + +## Authentication + +All endpoints require a Bearer token in the Authorization header: + +\`\`\` +Authorization: Bearer YOUR_TOKEN +\`\`\` + +## Date Parameters + +Most endpoints accept date range parameters: +- \`start\`: Start date in YYYY-MM-DD format +- \`end\`: End date in YYYY-MM-DD format + `, + contact: { + name: 'Open Podcast', + url: 'https://openpodcast.dev', + email: 'echo@openpodcast.dev', + }, + license: { + name: 'MIT', + url: 'https://github.com/openpodcast/api/blob/main/LICENSE', + }, + }, + servers: [ + { + url: 'https://api.openpodcast.dev', + description: 'Production server', + }, + { + url: 'http://localhost:3000', + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Enter your API token', + }, + }, + parameters: { + podcastId: { + name: 'podcast_id', + in: 'path', + required: true, + schema: { + type: 'integer', + }, + description: 'Podcast ID', + }, + startDate: { + name: 'start', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: '2024-01-01', + }, + description: 'Start date (YYYY-MM-DD)', + }, + endDate: { + name: 'end', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: '2024-12-31', + }, + description: 'End date (YYYY-MM-DD)', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + tags: [ + { + name: 'Spotify', + description: 'Spotify podcast analytics endpoints', + }, + { + name: 'Apple Podcasts', + description: 'Apple Podcasts analytics endpoints', + }, + { + name: 'Episodes', + description: 'Episode-level metrics across platforms', + }, + { + name: 'Podcast', + description: 'Podcast-level metrics and metadata', + }, + { + name: 'Hoster', + description: 'Generic hosting provider metrics', + }, + { + name: 'Charts', + description: 'Chart rankings and positions', + }, + ], + }, + apis: ['./src/api/*.ts', './src/index.ts', './src/config/swagger-schemas.ts'], +}; + +export const swaggerSpec = swaggerJsdoc(options); diff --git a/src/index.ts b/src/index.ts index ce1784d..352fd92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ import { formatDate, nowString } from './utils/dateHelpers' import { AccountKeyRepository } from './db/AccountKeyRepository' import fs from 'fs' import path from 'path' +import swaggerUi from 'swagger-ui-express' +import { swaggerSpec } from './config/swagger' const config = new Config() @@ -168,6 +170,8 @@ const publicEndpoints = [ '^/status', '^/feedback/*', '^/comments/*', + '^/api-docs', + '^/api-docs.json', ] const authController = new AuthController(accountKeyRepo) @@ -270,12 +274,101 @@ app.get( } ) -// Analytics endpoint, which returns a JSON of the query results. -// Check that the user is allowed to access the endpoint -// and then run the query -// The endpoint must contain a version number and a query name -// e.g. /analytics/v1/1234/someQuery or /analytics/v1/1234/someQuery/csv -// where 1234 is the podcast id and someQuery is the name of the query +/** + * @openapi + * /analytics/{version}/{podcastId}/{query}/{format}: + * get: + * summary: Get analytics data for a podcast + * description: | + * Returns analytics data for a specific podcast and query endpoint. + * + * Supports multiple data sources (Spotify, Apple Podcasts, generic hosters) and various metrics including: + * - Episode and podcast performance metrics + * - Audience demographics + * - Listen-through data + * - Follower statistics + * - Chart rankings + * + * Results can be returned in JSON or CSV format. + * tags: + * - Analytics + * parameters: + * - name: version + * in: path + * required: true + * schema: + * type: string + * enum: [v1] + * description: API version + * - $ref: '#/components/parameters/podcastId' + * - name: query + * in: path + * required: true + * schema: + * type: string + * description: Query endpoint name (e.g., episodesTotalMetrics, reportHosterPlatforms) + * examples: + * spotify: + * value: reportSpotifyPodcastBaseMetrics + * summary: Spotify podcast metrics + * apple: + * value: reportApplePodcastBaseMetrics + * summary: Apple Podcasts metrics + * episodes: + * value: episodesTotalMetrics + * summary: Combined episode metrics + * hoster: + * value: reportHosterPlatforms + * summary: Platform distribution + * - name: format + * in: path + * required: false + * schema: + * type: string + * enum: [csv] + * description: Optional output format (defaults to JSON) + * - $ref: '#/components/parameters/startDate' + * - $ref: '#/components/parameters/endDate' + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Analytics data returned successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * meta: + * type: object + * properties: + * query: + * type: string + * podcastId: + * type: string + * date: + * type: string + * format: date-time + * startDate: + * type: string + * format: date + * endDate: + * type: string + * format: date + * data: + * type: array + * items: + * type: object + * text/csv: + * schema: + * type: string + * 401: + * description: Unauthorized - Invalid or missing token, or no access to podcast + * 404: + * description: Query endpoint not found or no data available + * 400: + * description: Invalid parameters (e.g., invalid date format, unsupported format) + */ app.get( '/analytics/:version/:podcastId/:query/:format?', async (req: Request, res: Response, next: NextFunction) => { @@ -510,6 +603,29 @@ app.get( }) ) +/** + * @openapi + * /api-docs: + * get: + * summary: API Documentation + * description: Interactive API documentation powered by Swagger UI + * tags: + * - Documentation + * responses: + * 200: + * description: Returns the Swagger UI HTML page + */ +app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'Open Podcast API Documentation', +})) + +// Serve OpenAPI spec as JSON +app.get('/api-docs.json', (req: Request, res: Response) => { + res.setHeader('Content-Type', 'application/json') + res.send(swaggerSpec) +}) + // catch 404 and forward to error handler app.use(function (req: Request, res: Response, next: NextFunction) { const err = new HttpError(`Not Found: ${req.originalUrl}`) From 66fffbb524b11d54a86a02676584cedd2981b4e0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Oct 2025 15:32:19 +0200 Subject: [PATCH 02/16] Fix migration 6 to check if column exists before adding Prevents 'Duplicate column name' error when running migrations on a fresh database that already has the column in schema.sql --- .../migrations/6_anchor_episode_publish_time.sql | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/db_schema/migrations/6_anchor_episode_publish_time.sql b/db_schema/migrations/6_anchor_episode_publish_time.sql index f560b22..9507ddb 100644 --- a/db_schema/migrations/6_anchor_episode_publish_time.sql +++ b/db_schema/migrations/6_anchor_episode_publish_time.sql @@ -1,3 +1,16 @@ -ALTER TABLE anchorPodcastEpisodes ADD COLUMN publishOn DATETIME DEFAULT NULL AFTER created; +-- Check if column exists before adding it +SET @col_exists = (SELECT COUNT(*) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'anchorPodcastEpisodes' + AND COLUMN_NAME = 'publishOn'); + +SET @query = IF(@col_exists = 0, + 'ALTER TABLE anchorPodcastEpisodes ADD COLUMN publishOn DATETIME DEFAULT NULL AFTER created', + 'SELECT "Column publishOn already exists" AS message'); + +PREPARE stmt FROM @query; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; INSERT INTO migrations (migration_id, migration_name) VALUES (6, 'anchor episode publish time'); \ No newline at end of file From c69b8ff48a9b6358c38e4ca4f171ace9d4eae0db Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Oct 2025 16:20:21 +0200 Subject: [PATCH 03/16] cleanup --- src/config/swagger.ts | 74 ++----------------------------------------- src/index.ts | 40 ++++++++++++++++++----- 2 files changed, 35 insertions(+), 79 deletions(-) diff --git a/src/config/swagger.ts b/src/config/swagger.ts index b714f46..bc4d2ab 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -8,55 +8,7 @@ const options: swaggerJsdoc.Options = { info: { title: 'Open Podcast Analytics API', version: version, - description: ` -# Open Podcast Analytics API - -Comprehensive podcast analytics API providing metrics from multiple platforms including Spotify, Apple Podcasts, and custom hosting providers. - -## Available Data - -### Spotify Data -- Episode and podcast streams (starts and completed streams, daily granularity) -- Listener counts (unique listeners over time) -- Followers of the podcast (daily snapshots) -- Episode performance (median listen percentage, median seconds, percentiles, listen-through curves on a per-second basis) -- Audience demographics (age groups, gender, country, at both episode and podcast level) -- Podcast metadata (episode titles, descriptions, artwork, URLs, release dates, explicit flag, duration, language) -- Historical metadata snapshots (track changes over time) -- Impressions and funnel data (total impressions, impressions by source such as home, search, library, other, funnel stages from impressions to considerations to streams, conversion percentages per day) - -### Apple Podcasts Data -- Episode metadata (episode name, collection/podcast name, release datetime, GUID, episode number, type) -- Episode details (plays, total time listened, unique engaged listeners, unique listeners, engaged plays vs. total plays) -- Playback histograms (listen-through on a time-bucket basis, top countries and cities, median listeners at different quarters of the episode) -- Trends (daily episode-level metrics: plays, total time listened, unique listeners, engaged listeners) -- Trends (daily podcast-level metrics: same as above, aggregated across episodes) -- Listening time split by followers vs. non-followers -- Follower statistics (total followers, unfollowers, gained/lost per day) - -### General Metrics -- Plays, streams, downloads (episode and podcast level, daily) -- Listen-through data (per-second or per-bucket playback curves, histograms, medians, percentiles) -- Audience demographics (age, gender, country) -- Followers and subscribers (absolute numbers and gained/lost trends) -- Impressions and funnel steps (Spotify) -- Metadata (titles, descriptions, release dates, artwork, language) -- Historical snapshots (to track changes over time) - -## Authentication - -All endpoints require a Bearer token in the Authorization header: - -\`\`\` -Authorization: Bearer YOUR_TOKEN -\`\`\` - -## Date Parameters - -Most endpoints accept date range parameters: -- \`start\`: Start date in YYYY-MM-DD format -- \`end\`: End date in YYYY-MM-DD format - `, + description: 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers. See https://github.com/openpodcast/api/blob/main/docs/AVAILABLE_QUERIES.md for a complete list of available queries.', contact: { name: 'Open Podcast', url: 'https://openpodcast.dev', @@ -127,28 +79,8 @@ Most endpoints accept date range parameters: ], tags: [ { - name: 'Spotify', - description: 'Spotify podcast analytics endpoints', - }, - { - name: 'Apple Podcasts', - description: 'Apple Podcasts analytics endpoints', - }, - { - name: 'Episodes', - description: 'Episode-level metrics across platforms', - }, - { - name: 'Podcast', - description: 'Podcast-level metrics and metadata', - }, - { - name: 'Hoster', - description: 'Generic hosting provider metrics', - }, - { - name: 'Charts', - description: 'Chart rankings and positions', + name: 'Analytics', + description: 'Query podcast analytics data from multiple sources', }, ], }, diff --git a/src/index.ts b/src/index.ts index 352fd92..1b3ad11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,16 +280,18 @@ app.get( * get: * summary: Get analytics data for a podcast * description: | - * Returns analytics data for a specific podcast and query endpoint. + * Query analytics data for a specific podcast. * - * Supports multiple data sources (Spotify, Apple Podcasts, generic hosters) and various metrics including: - * - Episode and podcast performance metrics - * - Audience demographics - * - Listen-through data - * - Follower statistics - * - Chart rankings + * The `query` parameter specifies which metric to retrieve. Common queries include: + * - `reportSpotifyPodcastBaseMetrics` - Spotify podcast metrics (streams, listeners, followers) + * - `reportApplePodcastBaseMetrics` - Apple Podcasts metrics (plays, listeners, engagement) + * - `episodesTotalMetrics` - Combined episode metrics across platforms + * - `reportHosterPlatforms` - Platform/app distribution (e.g., Apple Podcasts, Spotify, Overcast) + * - `reportHosterClients` - Client/device distribution * - * Results can be returned in JSON or CSV format. + * See [AVAILABLE_QUERIES.md](https://github.com/openpodcast/api/blob/main/docs/AVAILABLE_QUERIES.md) for the complete list of 50+ available queries. + * + * Results can be returned in JSON (default) or CSV format by appending `/csv` to the path. * tags: * - Analytics * parameters: @@ -344,21 +346,43 @@ app.get( * properties: * query: * type: string + * example: reportHosterPlatforms * podcastId: * type: string + * example: "123" * date: * type: string * format: date-time + * example: "2024-09-24T12:00:00Z" * startDate: * type: string * format: date + * example: "2024-08-01" * endDate: * type: string * format: date + * example: "2024-08-31" * data: * type: array * items: * type: object + * example: + * meta: + * query: reportHosterPlatforms + * podcastId: "123" + * date: "2024-09-24T12:00:00Z" + * startDate: "2024-08-01" + * endDate: "2024-08-31" + * data: + * - platform_name: "Apple Podcasts" + * total_downloads: 15420 + * percentage: 45.2 + * - platform_name: "Spotify" + * total_downloads: 10280 + * percentage: 30.1 + * - platform_name: "Overcast" + * total_downloads: 4120 + * percentage: 12.1 * text/csv: * schema: * type: string From 2e6309ff1f1a66cd1a5e428fffcfee03a0b550f3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Oct 2025 16:24:58 +0200 Subject: [PATCH 04/16] cleanup --- src/config/swagger.ts | 2 +- src/index.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/config/swagger.ts b/src/config/swagger.ts index bc4d2ab..0c303a3 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -8,7 +8,7 @@ const options: swaggerJsdoc.Options = { info: { title: 'Open Podcast Analytics API', version: version, - description: 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers. See https://github.com/openpodcast/api/blob/main/docs/AVAILABLE_QUERIES.md for a complete list of available queries.', + description: 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers. Query endpoints are defined as SQL files in db_schema/queries/v1/.', contact: { name: 'Open Podcast', url: 'https://openpodcast.dev', diff --git a/src/index.ts b/src/index.ts index 1b3ad11..2ad74c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,18 +280,27 @@ app.get( * get: * summary: Get analytics data for a podcast * description: | - * Query analytics data for a specific podcast. + * Query analytics data for a specific podcast using SQL-based query endpoints. * - * The `query` parameter specifies which metric to retrieve. Common queries include: + * **How it works:** The `query` parameter maps to SQL files in `db_schema/queries/v1/`. + * For example, `reportHosterPlatforms` executes the SQL query defined in + * `db_schema/queries/v1/reportHosterPlatforms.sql`. + * + * **Common queries:** * - `reportSpotifyPodcastBaseMetrics` - Spotify podcast metrics (streams, listeners, followers) * - `reportApplePodcastBaseMetrics` - Apple Podcasts metrics (plays, listeners, engagement) * - `episodesTotalMetrics` - Combined episode metrics across platforms * - `reportHosterPlatforms` - Platform/app distribution (e.g., Apple Podcasts, Spotify, Overcast) * - `reportHosterClients` - Client/device distribution + * - `podcastFollowers` - Combined follower counts from all platforms + * - `episodesLTRHistogram` - Listen-through rate histograms per episode * - * See [AVAILABLE_QUERIES.md](https://github.com/openpodcast/api/blob/main/docs/AVAILABLE_QUERIES.md) for the complete list of 50+ available queries. + * **Output formats:** + * - JSON (default): Returns structured data with metadata + * - CSV: Append `/csv` to the path for CSV export * - * Results can be returned in JSON (default) or CSV format by appending `/csv` to the path. + * **Available queries:** 50+ SQL queries covering Spotify, Apple Podcasts, cross-platform metrics, + * hoster data, demographics, impressions, and chart rankings. * tags: * - Analytics * parameters: From 1518956c7530a2059a692953846d88b4d662a113 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Oct 2025 16:27:26 +0200 Subject: [PATCH 05/16] Add SQL queries --- src/config/generate-query-docs.ts | 180 ++++++++++++++++++++++++++++++ src/config/swagger.ts | 8 ++ 2 files changed, 188 insertions(+) create mode 100644 src/config/generate-query-docs.ts diff --git a/src/config/generate-query-docs.ts b/src/config/generate-query-docs.ts new file mode 100644 index 0000000..152448b --- /dev/null +++ b/src/config/generate-query-docs.ts @@ -0,0 +1,180 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * Generates OpenAPI documentation for SQL query endpoints + * Reads SQL files from db_schema/queries/v1/ and creates endpoint specs + */ + +const QUERIES_DIR = path.join(__dirname, '../../db_schema/queries/v1'); + +// Category mappings based on query name patterns +const categorizeQuery = (queryName: string): string => { + if (queryName.includes('Spotify') || queryName.includes('spotify')) return 'Spotify'; + if (queryName.includes('Apple') || queryName.includes('apple')) return 'Apple Podcasts'; + if (queryName.includes('Hoster') || queryName.includes('hoster')) return 'Hoster'; + if (queryName.includes('episodes') || queryName.includes('Episodes')) return 'Episodes'; + if (queryName.includes('podcast') || queryName.includes('Podcast')) return 'Podcast'; + if (queryName.includes('charts') || queryName.includes('Charts')) return 'Charts'; + if (queryName.includes('Anchor') || queryName.includes('anchor')) return 'Anchor (Legacy)'; + return 'Other'; +}; + +// Extract description from SQL comment at the top of file +const extractDescription = (sqlContent: string): string => { + const lines = sqlContent.split('\n'); + const commentLines: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('--')) { + commentLines.push(trimmed.substring(2).trim()); + } else if (trimmed && !trimmed.startsWith('--')) { + break; // Stop at first non-comment, non-empty line + } + } + + return commentLines.join(' ') || 'Analytics query endpoint'; +}; + +export const generateQueryDocs = () => { + const queries: any[] = []; + + if (!fs.existsSync(QUERIES_DIR)) { + console.warn(`Queries directory not found: ${QUERIES_DIR}`); + return queries; + } + + const files = fs.readdirSync(QUERIES_DIR).filter(f => f.endsWith('.sql')); + + for (const file of files) { + const queryName = file.replace('.sql', ''); + const filePath = path.join(QUERIES_DIR, file); + const sqlContent = fs.readFileSync(filePath, 'utf-8'); + const description = extractDescription(sqlContent); + const category = categorizeQuery(queryName); + + queries.push({ + name: queryName, + category, + description, + path: `/analytics/v1/{podcastId}/${queryName}`, + }); + } + + return queries; +}; + +// Generate OpenAPI paths object for all queries +export const generateQueryPaths = () => { + const queries = generateQueryDocs(); + const paths: any = {}; + + // Group by category for better organization + const byCategory = queries.reduce((acc, q) => { + if (!acc[q.category]) acc[q.category] = []; + acc[q.category].push(q); + return acc; + }, {} as Record); + + for (const query of queries) { + paths[query.path] = { + get: { + summary: query.name, + description: query.description, + tags: [query.category], + parameters: [ + { + name: 'podcastId', + in: 'path', + required: true, + schema: { type: 'integer' }, + description: 'Podcast ID', + }, + { + name: 'start', + in: 'query', + required: false, + schema: { type: 'string', format: 'date', example: '2024-01-01' }, + description: 'Start date (YYYY-MM-DD)', + }, + { + name: 'end', + in: 'query', + required: false, + schema: { type: 'string', format: 'date', example: '2024-12-31' }, + description: 'End date (YYYY-MM-DD)', + }, + ], + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Query results', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + meta: { + type: 'object', + properties: { + query: { type: 'string' }, + podcastId: { type: 'string' }, + startDate: { type: 'string', format: 'date' }, + endDate: { type: 'string', format: 'date' }, + }, + }, + data: { + type: 'array', + items: { type: 'object' }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + // Also add CSV endpoint + paths[`${query.path}/csv`] = { + get: { + summary: `${query.name} (CSV)`, + description: `${query.description} Returns data in CSV format.`, + tags: [query.category], + parameters: [ + { + name: 'podcastId', + in: 'path', + required: true, + schema: { type: 'integer' }, + }, + { + name: 'start', + in: 'query', + schema: { type: 'string', format: 'date' }, + }, + { + name: 'end', + in: 'query', + schema: { type: 'string', format: 'date' }, + }, + ], + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'CSV data', + content: { + 'text/csv': { + schema: { type: 'string' }, + }, + }, + }, + }, + }, + }; + } + + return { paths, categories: Object.keys(byCategory) }; +}; diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 0c303a3..713c2f5 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -1,6 +1,9 @@ import swaggerJsdoc from 'swagger-jsdoc'; import { version } from '../../package.json'; import './swagger-schemas'; +import { generateQueryPaths } from './generate-query-docs'; + +const { paths: queryPaths, categories } = generateQueryPaths(); const options: swaggerJsdoc.Options = { definition: { @@ -82,7 +85,12 @@ const options: swaggerJsdoc.Options = { name: 'Analytics', description: 'Query podcast analytics data from multiple sources', }, + ...categories.map(cat => ({ + name: cat, + description: `${cat} analytics endpoints`, + })), ], + paths: queryPaths, }, apis: ['./src/api/*.ts', './src/index.ts', './src/config/swagger-schemas.ts'], }; From 5314229190c5d24266d85d2e5421f8a52b111e3e Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 8 Oct 2025 13:58:57 +0200 Subject: [PATCH 06/16] Clean up schema --- db_schema/queries/v1/ping.ts | 1 + src/config/generate-query-docs.ts | 327 ++++++++++++++++-------------- src/config/swagger-schemas.ts | 305 ---------------------------- src/config/swagger.ts | 183 ++++++++--------- 4 files changed, 267 insertions(+), 549 deletions(-) create mode 100644 db_schema/queries/v1/ping.ts delete mode 100644 src/config/swagger-schemas.ts diff --git a/db_schema/queries/v1/ping.ts b/db_schema/queries/v1/ping.ts new file mode 100644 index 0000000..e901a28 --- /dev/null +++ b/db_schema/queries/v1/ping.ts @@ -0,0 +1 @@ +SELECT @start as start, @end as end, "pong" as result \ No newline at end of file diff --git a/src/config/generate-query-docs.ts b/src/config/generate-query-docs.ts index 152448b..0939e7e 100644 --- a/src/config/generate-query-docs.ts +++ b/src/config/generate-query-docs.ts @@ -1,180 +1,201 @@ -import fs from 'fs'; -import path from 'path'; +import fs from 'fs' +import path from 'path' /** * Generates OpenAPI documentation for SQL query endpoints * Reads SQL files from db_schema/queries/v1/ and creates endpoint specs */ -const QUERIES_DIR = path.join(__dirname, '../../db_schema/queries/v1'); +const QUERIES_DIR = path.join(__dirname, '../../db_schema/queries/v1') // Category mappings based on query name patterns const categorizeQuery = (queryName: string): string => { - if (queryName.includes('Spotify') || queryName.includes('spotify')) return 'Spotify'; - if (queryName.includes('Apple') || queryName.includes('apple')) return 'Apple Podcasts'; - if (queryName.includes('Hoster') || queryName.includes('hoster')) return 'Hoster'; - if (queryName.includes('episodes') || queryName.includes('Episodes')) return 'Episodes'; - if (queryName.includes('podcast') || queryName.includes('Podcast')) return 'Podcast'; - if (queryName.includes('charts') || queryName.includes('Charts')) return 'Charts'; - if (queryName.includes('Anchor') || queryName.includes('anchor')) return 'Anchor (Legacy)'; - return 'Other'; -}; + if (queryName.includes('Spotify') || queryName.includes('spotify')) + return 'Spotify' + if (queryName.includes('Apple') || queryName.includes('apple')) + return 'Apple Podcasts' + if (queryName.includes('Hoster') || queryName.includes('hoster')) + return 'Hoster' + if (queryName.includes('episodes') || queryName.includes('Episodes')) + return 'Episodes' + if (queryName.includes('podcast') || queryName.includes('Podcast')) + return 'Podcast' + if (queryName.includes('charts') || queryName.includes('Charts')) + return 'Charts' + if (queryName.includes('Anchor') || queryName.includes('anchor')) + return 'Anchor (Legacy)' + return 'Other' +} // Extract description from SQL comment at the top of file const extractDescription = (sqlContent: string): string => { - const lines = sqlContent.split('\n'); - const commentLines: string[] = []; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith('--')) { - commentLines.push(trimmed.substring(2).trim()); - } else if (trimmed && !trimmed.startsWith('--')) { - break; // Stop at first non-comment, non-empty line + const lines = sqlContent.split('\n') + const commentLines: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.startsWith('--')) { + commentLines.push(trimmed.substring(2).trim()) + } else if (trimmed && !trimmed.startsWith('--')) { + break // Stop at first non-comment, non-empty line + } } - } - return commentLines.join(' ') || 'Analytics query endpoint'; -}; + return commentLines.join('
') || 'Analytics query endpoint' +} export const generateQueryDocs = () => { - const queries: any[] = []; + const queries: any[] = [] - if (!fs.existsSync(QUERIES_DIR)) { - console.warn(`Queries directory not found: ${QUERIES_DIR}`); - return queries; - } - - const files = fs.readdirSync(QUERIES_DIR).filter(f => f.endsWith('.sql')); - - for (const file of files) { - const queryName = file.replace('.sql', ''); - const filePath = path.join(QUERIES_DIR, file); - const sqlContent = fs.readFileSync(filePath, 'utf-8'); - const description = extractDescription(sqlContent); - const category = categorizeQuery(queryName); + if (!fs.existsSync(QUERIES_DIR)) { + console.warn(`Queries directory not found: ${QUERIES_DIR}`) + return queries + } - queries.push({ - name: queryName, - category, - description, - path: `/analytics/v1/{podcastId}/${queryName}`, - }); - } + const files = fs.readdirSync(QUERIES_DIR).filter((f) => f.endsWith('.sql')) + + for (const file of files) { + const queryName = file.replace('.sql', '') + const filePath = path.join(QUERIES_DIR, file) + const sqlContent = fs.readFileSync(filePath, 'utf-8') + const description = extractDescription(sqlContent) + const category = categorizeQuery(queryName) + + queries.push({ + name: queryName, + category, + description, + path: `/analytics/v1/{podcastId}/${queryName}`, + }) + } - return queries; -}; + return queries +} // Generate OpenAPI paths object for all queries export const generateQueryPaths = () => { - const queries = generateQueryDocs(); - const paths: any = {}; - - // Group by category for better organization - const byCategory = queries.reduce((acc, q) => { - if (!acc[q.category]) acc[q.category] = []; - acc[q.category].push(q); - return acc; - }, {} as Record); - - for (const query of queries) { - paths[query.path] = { - get: { - summary: query.name, - description: query.description, - tags: [query.category], - parameters: [ - { - name: 'podcastId', - in: 'path', - required: true, - schema: { type: 'integer' }, - description: 'Podcast ID', - }, - { - name: 'start', - in: 'query', - required: false, - schema: { type: 'string', format: 'date', example: '2024-01-01' }, - description: 'Start date (YYYY-MM-DD)', - }, - { - name: 'end', - in: 'query', - required: false, - schema: { type: 'string', format: 'date', example: '2024-12-31' }, - description: 'End date (YYYY-MM-DD)', - }, - ], - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'Query results', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - meta: { - type: 'object', - properties: { - query: { type: 'string' }, - podcastId: { type: 'string' }, - startDate: { type: 'string', format: 'date' }, - endDate: { type: 'string', format: 'date' }, - }, + const queries = generateQueryDocs() + const paths: any = {} + + // Group by category for better organization + const byCategory = queries.reduce((acc, q) => { + if (!acc[q.category]) acc[q.category] = [] + acc[q.category].push(q) + return acc + }, {} as Record) + + for (const query of queries) { + paths[query.path] = { + get: { + summary: query.name, + description: query.description, + tags: [query.category], + parameters: [ + { + name: 'podcastId', + in: 'path', + required: true, + schema: { type: 'integer' }, + description: 'Podcast ID', }, - data: { - type: 'array', - items: { type: 'object' }, + { + name: 'start', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: '2024-01-01', + }, + description: 'Start date (YYYY-MM-DD)', + }, + { + name: 'end', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: '2024-12-31', + }, + description: 'End date (YYYY-MM-DD)', + }, + ], + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'Query results', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + meta: { + type: 'object', + properties: { + query: { type: 'string' }, + podcastId: { type: 'string' }, + startDate: { + type: 'string', + format: 'date', + }, + endDate: { + type: 'string', + format: 'date', + }, + }, + }, + data: { + type: 'array', + items: { type: 'object' }, + }, + }, + }, + }, + }, }, - }, }, - }, }, - }, - }, - }, - }; - - // Also add CSV endpoint - paths[`${query.path}/csv`] = { - get: { - summary: `${query.name} (CSV)`, - description: `${query.description} Returns data in CSV format.`, - tags: [query.category], - parameters: [ - { - name: 'podcastId', - in: 'path', - required: true, - schema: { type: 'integer' }, - }, - { - name: 'start', - in: 'query', - schema: { type: 'string', format: 'date' }, - }, - { - name: 'end', - in: 'query', - schema: { type: 'string', format: 'date' }, - }, - ], - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'CSV data', - content: { - 'text/csv': { - schema: { type: 'string' }, - }, + } + + // Also add CSV endpoint + paths[`${query.path}/csv`] = { + get: { + summary: `${query.name} (CSV)`, + description: `${query.description} Returns data in CSV format.`, + tags: [query.category], + parameters: [ + { + name: 'podcastId', + in: 'path', + required: true, + schema: { type: 'integer' }, + }, + { + name: 'start', + in: 'query', + schema: { type: 'string', format: 'date' }, + }, + { + name: 'end', + in: 'query', + schema: { type: 'string', format: 'date' }, + }, + ], + security: [{ bearerAuth: [] }], + responses: { + 200: { + description: 'CSV data', + content: { + 'text/csv': { + schema: { type: 'string' }, + }, + }, + }, + }, }, - }, - }, - }, - }; - } - - return { paths, categories: Object.keys(byCategory) }; -}; + } + } + + return { paths, categories: Object.keys(byCategory) } +} diff --git a/src/config/swagger-schemas.ts b/src/config/swagger-schemas.ts deleted file mode 100644 index 0c94272..0000000 --- a/src/config/swagger-schemas.ts +++ /dev/null @@ -1,305 +0,0 @@ -/** - * OpenAPI schema definitions for common response types - * These schemas are referenced in the API documentation - */ - -export const schemas = { - /** - * @openapi - * components: - * schemas: - * # Spotify Schemas - * SpotifyPodcastMetrics: - * type: object - * properties: - * total_episodes: - * type: integer - * description: Total number of episodes - * starts: - * type: integer - * description: Number of stream starts - * streams: - * type: integer - * description: Number of completed streams - * listeners: - * type: integer - * description: Unique listener count - * followers: - * type: integer - * description: Total followers - * date: - * type: string - * format: date - * - * SpotifyEpisodePerformance: - * type: object - * properties: - * episode_id: - * type: string - * episode_name: - * type: string - * median_percentage: - * type: integer - * description: Median listen-through percentage - * median_seconds: - * type: integer - * description: Median listen time in seconds - * percentile_25: - * type: integer - * percentile_50: - * type: integer - * percentile_75: - * type: integer - * percentile_100: - * type: integer - * - * SpotifyDemographics: - * type: object - * properties: - * age_group: - * type: string - * enum: [0-17, 18-22, 23-27, 28-34, 35-44, 45-59, 60+] - * gender: - * type: string - * enum: [male, female, non-binary, not-specified] - * listeners: - * type: integer - * country: - * type: string - * description: ISO country code - * - * # Apple Podcasts Schemas - * ApplePodcastMetrics: - * type: object - * properties: - * plays: - * type: integer - * description: Total play count - * listeners: - * type: integer - * description: Unique listener count - * engaged_listeners: - * type: integer - * description: Engaged listener count - * total_time_listened: - * type: integer - * description: Total listening time in seconds - * date: - * type: string - * format: date - * - * AppleEpisodeDetails: - * type: object - * properties: - * episode_id: - * type: integer - * episode_name: - * type: string - * plays_count: - * type: integer - * unique_listeners: - * type: integer - * unique_engaged_listeners: - * type: integer - * engaged_plays_count: - * type: integer - * total_time_listened: - * type: integer - * quarter1_median: - * type: number - * description: Median listener retention at 25% - * quarter2_median: - * type: number - * description: Median listener retention at 50% - * quarter3_median: - * type: number - * description: Median listener retention at 75% - * quarter4_median: - * type: number - * description: Median listener retention at 100% - * - * AppleFollowerStats: - * type: object - * properties: - * date: - * type: string - * format: date - * total_followers: - * type: integer - * gained: - * type: integer - * description: Followers gained on this day - * lost: - * type: integer - * description: Followers lost on this day - * - * # Episode Mapping Schemas - * EpisodeMetadata: - * type: object - * properties: - * account_id: - * type: integer - * spotify_episode_id: - * type: string - * apple_episode_id: - * type: integer - * episode_name: - * type: string - * guid: - * type: string - * release_date: - * type: string - * format: date-time - * duration: - * type: integer - * description: Duration in seconds - * artwork_url: - * type: string - * format: uri - * - * EpisodeTotalMetrics: - * type: object - * properties: - * account_id: - * type: integer - * spotify_episode_id: - * type: string - * apple_episode_id: - * type: integer - * guid: - * type: string - * date: - * type: string - * format: date - * total_apple_plays: - * type: integer - * total_apple_listeners: - * type: integer - * total_apple_engaged_listeners: - * type: integer - * total_apple_time_listened: - * type: integer - * total_spotify_starts: - * type: integer - * total_spotify_streams: - * type: integer - * total_spotify_listeners: - * type: integer - * - * # Hoster Schemas - * HosterPlatformDistribution: - * type: object - * properties: - * platform_name: - * type: string - * description: Platform/app name (e.g., Apple Podcasts, Spotify, Overcast) - * total_downloads: - * type: integer - * percentage: - * type: number - * format: float - * description: Percentage of total downloads - * - * HosterClientDistribution: - * type: object - * properties: - * client_name: - * type: string - * description: Client/device type - * total_downloads: - * type: integer - * percentage: - * type: number - * format: float - * - * # Chart Schemas - * ChartRanking: - * type: object - * properties: - * date: - * type: string - * format: date - * category: - * type: string - * country: - * type: string - * rank: - * type: integer - * platform: - * type: string - * enum: [spotify, apple] - * - * # Listen-Through Rate (LTR) Schemas - * LTRHistogram: - * type: object - * properties: - * episode_id: - * type: string - * episode_name: - * type: string - * seconds: - * type: integer - * description: Time offset in seconds - * apple_listeners: - * type: integer - * spotify_listeners: - * type: integer - * apple_percentage: - * type: number - * description: Percentage of listeners at this point (Apple) - * spotify_percentage: - * type: number - * description: Percentage of listeners at this point (Spotify) - * - * # Impressions Schemas (Spotify) - * SpotifyImpressions: - * type: object - * properties: - * date: - * type: string - * format: date - * impressions: - * type: integer - * description: Total impressions on Spotify - * - * SpotifyImpressionsSources: - * type: object - * properties: - * source: - * type: string - * enum: [HOME, SEARCH, LIBRARY, OTHER] - * impression_count: - * type: integer - * date_start: - * type: string - * format: date - * date_end: - * type: string - * format: date - * - * SpotifyFunnel: - * type: object - * properties: - * date: - * type: string - * format: date - * step: - * type: string - * enum: [impressions, considerations, streams] - * count: - * type: integer - * conversion_percent: - * type: number - * format: float - * - * # Error Response - * Error: - * type: object - * properties: - * message: - * type: string - * tracingId: - * type: string - * description: Error tracking ID for debugging - */ -}; diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 713c2f5..53b74f9 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -1,98 +1,99 @@ -import swaggerJsdoc from 'swagger-jsdoc'; -import { version } from '../../package.json'; -import './swagger-schemas'; -import { generateQueryPaths } from './generate-query-docs'; +import swaggerJsdoc from 'swagger-jsdoc' +import { version } from '../../package.json' +import { generateQueryPaths } from './generate-query-docs' -const { paths: queryPaths, categories } = generateQueryPaths(); +const { paths: queryPaths, categories } = generateQueryPaths() const options: swaggerJsdoc.Options = { - definition: { - openapi: '3.0.0', - info: { - title: 'Open Podcast Analytics API', - version: version, - description: 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers. Query endpoints are defined as SQL files in db_schema/queries/v1/.', - contact: { - name: 'Open Podcast', - url: 'https://openpodcast.dev', - email: 'echo@openpodcast.dev', - }, - license: { - name: 'MIT', - url: 'https://github.com/openpodcast/api/blob/main/LICENSE', - }, - }, - servers: [ - { - url: 'https://api.openpodcast.dev', - description: 'Production server', - }, - { - url: 'http://localhost:3000', - description: 'Development server', - }, - ], - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: 'Enter your API token', - }, - }, - parameters: { - podcastId: { - name: 'podcast_id', - in: 'path', - required: true, - schema: { - type: 'integer', - }, - description: 'Podcast ID', - }, - startDate: { - name: 'start', - in: 'query', - required: false, - schema: { - type: 'string', - format: 'date', - example: '2024-01-01', - }, - description: 'Start date (YYYY-MM-DD)', + definition: { + openapi: '3.0.0', + info: { + title: 'Open Podcast Analytics API', + version: version, + description: + 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers. Query endpoints are defined as SQL files in db_schema/queries/v1/.', + contact: { + name: 'Open Podcast', + url: 'https://openpodcast.dev', + email: 'echo@openpodcast.dev', + }, + license: { + name: 'MIT', + url: 'https://github.com/openpodcast/api/blob/main/LICENSE', + }, }, - endDate: { - name: 'end', - in: 'query', - required: false, - schema: { - type: 'string', - format: 'date', - example: '2024-12-31', - }, - description: 'End date (YYYY-MM-DD)', + servers: [ + { + url: 'https://api.openpodcast.dev', + description: 'Production server', + }, + { + url: 'http://localhost:3000', + description: 'Development server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Enter your API token', + }, + }, + parameters: { + podcastId: { + name: 'podcast_id', + in: 'path', + required: true, + schema: { + type: 'integer', + }, + description: 'Podcast ID', + }, + startDate: { + name: 'start', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: '2024-01-01', + }, + description: 'Start date (YYYY-MM-DD)', + }, + endDate: { + name: 'end', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: '2024-12-31', + }, + description: 'End date (YYYY-MM-DD)', + }, + }, }, - }, + security: [ + { + bearerAuth: [], + }, + ], + tags: [ + { + name: 'Analytics', + description: + 'Query podcast analytics data from multiple sources', + }, + ...categories.map((cat) => ({ + name: cat, + description: `${cat} analytics endpoints`, + })), + ], + paths: queryPaths, }, - security: [ - { - bearerAuth: [], - }, - ], - tags: [ - { - name: 'Analytics', - description: 'Query podcast analytics data from multiple sources', - }, - ...categories.map(cat => ({ - name: cat, - description: `${cat} analytics endpoints`, - })), - ], - paths: queryPaths, - }, - apis: ['./src/api/*.ts', './src/index.ts', './src/config/swagger-schemas.ts'], -}; + apis: ['./src/api/*.ts', './src/index.ts'], +} -export const swaggerSpec = swaggerJsdoc(options); +export const swaggerSpec = swaggerJsdoc(options) From 98445efff3d00a862ddf6083808be72587a53a14 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 8 Oct 2025 14:19:38 +0200 Subject: [PATCH 07/16] Cleanup swagger API and only expose single endpoints --- db_schema/queries/v1/episodesDailyMetrics.sql | 4 +- src/config/generate-query-docs.ts | 49 ++---- src/config/swagger.ts | 9 +- src/index.ts | 152 +----------------- 4 files changed, 22 insertions(+), 192 deletions(-) diff --git a/db_schema/queries/v1/episodesDailyMetrics.sql b/db_schema/queries/v1/episodesDailyMetrics.sql index 59bd1bd..036b7dc 100644 --- a/db_schema/queries/v1/episodesDailyMetrics.sql +++ b/db_schema/queries/v1/episodesDailyMetrics.sql @@ -1,5 +1,5 @@ --- Daily episodes metrics from Apple and Spotify platforms using episodeMapping view --- Returns daily performance metrics for plays, listeners, streams, and engagement data +-- @doc +-- Returns daily performance metrics for plays, listeners, streams, and engagement data of episodes from Apple and Spotify. SELECT -- Episode identifiers from mapping view diff --git a/src/config/generate-query-docs.ts b/src/config/generate-query-docs.ts index 0939e7e..ed73eb6 100644 --- a/src/config/generate-query-docs.ts +++ b/src/config/generate-query-docs.ts @@ -58,9 +58,18 @@ export const generateQueryDocs = () => { const queryName = file.replace('.sql', '') const filePath = path.join(QUERIES_DIR, file) const sqlContent = fs.readFileSync(filePath, 'utf-8') - const description = extractDescription(sqlContent) + + let description = extractDescription(sqlContent) const category = categorizeQuery(queryName) + // First line has to start with `-- @doc`. If not, ignore the file + if (!description.startsWith('@doc')) { + continue + } else { + // Remove @doc from description + description = description.replace('@doc', '').trim() + } + queries.push({ name: queryName, category, @@ -157,44 +166,6 @@ export const generateQueryPaths = () => { }, }, } - - // Also add CSV endpoint - paths[`${query.path}/csv`] = { - get: { - summary: `${query.name} (CSV)`, - description: `${query.description} Returns data in CSV format.`, - tags: [query.category], - parameters: [ - { - name: 'podcastId', - in: 'path', - required: true, - schema: { type: 'integer' }, - }, - { - name: 'start', - in: 'query', - schema: { type: 'string', format: 'date' }, - }, - { - name: 'end', - in: 'query', - schema: { type: 'string', format: 'date' }, - }, - ], - security: [{ bearerAuth: [] }], - responses: { - 200: { - description: 'CSV data', - content: { - 'text/csv': { - schema: { type: 'string' }, - }, - }, - }, - }, - }, - } } return { paths, categories: Object.keys(byCategory) } diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 53b74f9..93d0767 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -11,9 +11,9 @@ const options: swaggerJsdoc.Options = { title: 'Open Podcast Analytics API', version: version, description: - 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers. Query endpoints are defined as SQL files in db_schema/queries/v1/.', + 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers.', contact: { - name: 'Open Podcast', + name: 'Open Podcast Developer Documentation', url: 'https://openpodcast.dev', email: 'echo@openpodcast.dev', }, @@ -81,11 +81,6 @@ const options: swaggerJsdoc.Options = { }, ], tags: [ - { - name: 'Analytics', - description: - 'Query podcast analytics data from multiple sources', - }, ...categories.map((cat) => ({ name: cat, description: `${cat} analytics endpoints`, diff --git a/src/index.ts b/src/index.ts index 2ad74c2..1c6b4b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -274,134 +274,6 @@ app.get( } ) -/** - * @openapi - * /analytics/{version}/{podcastId}/{query}/{format}: - * get: - * summary: Get analytics data for a podcast - * description: | - * Query analytics data for a specific podcast using SQL-based query endpoints. - * - * **How it works:** The `query` parameter maps to SQL files in `db_schema/queries/v1/`. - * For example, `reportHosterPlatforms` executes the SQL query defined in - * `db_schema/queries/v1/reportHosterPlatforms.sql`. - * - * **Common queries:** - * - `reportSpotifyPodcastBaseMetrics` - Spotify podcast metrics (streams, listeners, followers) - * - `reportApplePodcastBaseMetrics` - Apple Podcasts metrics (plays, listeners, engagement) - * - `episodesTotalMetrics` - Combined episode metrics across platforms - * - `reportHosterPlatforms` - Platform/app distribution (e.g., Apple Podcasts, Spotify, Overcast) - * - `reportHosterClients` - Client/device distribution - * - `podcastFollowers` - Combined follower counts from all platforms - * - `episodesLTRHistogram` - Listen-through rate histograms per episode - * - * **Output formats:** - * - JSON (default): Returns structured data with metadata - * - CSV: Append `/csv` to the path for CSV export - * - * **Available queries:** 50+ SQL queries covering Spotify, Apple Podcasts, cross-platform metrics, - * hoster data, demographics, impressions, and chart rankings. - * tags: - * - Analytics - * parameters: - * - name: version - * in: path - * required: true - * schema: - * type: string - * enum: [v1] - * description: API version - * - $ref: '#/components/parameters/podcastId' - * - name: query - * in: path - * required: true - * schema: - * type: string - * description: Query endpoint name (e.g., episodesTotalMetrics, reportHosterPlatforms) - * examples: - * spotify: - * value: reportSpotifyPodcastBaseMetrics - * summary: Spotify podcast metrics - * apple: - * value: reportApplePodcastBaseMetrics - * summary: Apple Podcasts metrics - * episodes: - * value: episodesTotalMetrics - * summary: Combined episode metrics - * hoster: - * value: reportHosterPlatforms - * summary: Platform distribution - * - name: format - * in: path - * required: false - * schema: - * type: string - * enum: [csv] - * description: Optional output format (defaults to JSON) - * - $ref: '#/components/parameters/startDate' - * - $ref: '#/components/parameters/endDate' - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Analytics data returned successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * meta: - * type: object - * properties: - * query: - * type: string - * example: reportHosterPlatforms - * podcastId: - * type: string - * example: "123" - * date: - * type: string - * format: date-time - * example: "2024-09-24T12:00:00Z" - * startDate: - * type: string - * format: date - * example: "2024-08-01" - * endDate: - * type: string - * format: date - * example: "2024-08-31" - * data: - * type: array - * items: - * type: object - * example: - * meta: - * query: reportHosterPlatforms - * podcastId: "123" - * date: "2024-09-24T12:00:00Z" - * startDate: "2024-08-01" - * endDate: "2024-08-31" - * data: - * - platform_name: "Apple Podcasts" - * total_downloads: 15420 - * percentage: 45.2 - * - platform_name: "Spotify" - * total_downloads: 10280 - * percentage: 30.1 - * - platform_name: "Overcast" - * total_downloads: 4120 - * percentage: 12.1 - * text/csv: - * schema: - * type: string - * 401: - * description: Unauthorized - Invalid or missing token, or no access to podcast - * 404: - * description: Query endpoint not found or no data available - * 400: - * description: Invalid parameters (e.g., invalid date format, unsupported format) - */ app.get( '/analytics/:version/:podcastId/:query/:format?', async (req: Request, res: Response, next: NextFunction) => { @@ -636,22 +508,14 @@ app.get( }) ) -/** - * @openapi - * /api-docs: - * get: - * summary: API Documentation - * description: Interactive API documentation powered by Swagger UI - * tags: - * - Documentation - * responses: - * 200: - * description: Returns the Swagger UI HTML page - */ -app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'Open Podcast API Documentation', -})) +app.use( + '/api-docs', + swaggerUi.serve, + swaggerUi.setup(swaggerSpec, { + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'Open Podcast API Documentation', + }) +) // Serve OpenAPI spec as JSON app.get('/api-docs.json', (req: Request, res: Response) => { From ce3264e59c87ae099ccf3f58ef3ac09e44e7b779 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:15:16 +0200 Subject: [PATCH 08/16] get rid of not needed stuff --- .../6_anchor_episode_publish_time.sql | 15 +- db_schema/queries/v1/ping.ts | 1 - docs/AVAILABLE_QUERIES.md | 154 ------------------ 3 files changed, 1 insertion(+), 169 deletions(-) delete mode 100644 db_schema/queries/v1/ping.ts delete mode 100644 docs/AVAILABLE_QUERIES.md diff --git a/db_schema/migrations/6_anchor_episode_publish_time.sql b/db_schema/migrations/6_anchor_episode_publish_time.sql index 9507ddb..f560b22 100644 --- a/db_schema/migrations/6_anchor_episode_publish_time.sql +++ b/db_schema/migrations/6_anchor_episode_publish_time.sql @@ -1,16 +1,3 @@ --- Check if column exists before adding it -SET @col_exists = (SELECT COUNT(*) - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() - AND TABLE_NAME = 'anchorPodcastEpisodes' - AND COLUMN_NAME = 'publishOn'); - -SET @query = IF(@col_exists = 0, - 'ALTER TABLE anchorPodcastEpisodes ADD COLUMN publishOn DATETIME DEFAULT NULL AFTER created', - 'SELECT "Column publishOn already exists" AS message'); - -PREPARE stmt FROM @query; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; +ALTER TABLE anchorPodcastEpisodes ADD COLUMN publishOn DATETIME DEFAULT NULL AFTER created; INSERT INTO migrations (migration_id, migration_name) VALUES (6, 'anchor episode publish time'); \ No newline at end of file diff --git a/db_schema/queries/v1/ping.ts b/db_schema/queries/v1/ping.ts deleted file mode 100644 index e901a28..0000000 --- a/db_schema/queries/v1/ping.ts +++ /dev/null @@ -1 +0,0 @@ -SELECT @start as start, @end as end, "pong" as result \ No newline at end of file diff --git a/docs/AVAILABLE_QUERIES.md b/docs/AVAILABLE_QUERIES.md deleted file mode 100644 index cd5c66d..0000000 --- a/docs/AVAILABLE_QUERIES.md +++ /dev/null @@ -1,154 +0,0 @@ -# Available Analytics Query Endpoints - -This document lists all available query endpoints in the Open Podcast Analytics API. - -All endpoints follow the pattern: `/analytics/v1/{podcast_id}/{query_name}` - -Query parameters: -- `start`: Start date (YYYY-MM-DD format) -- `end`: End date (YYYY-MM-DD format) - -Add `/csv` to the end of any endpoint to get CSV format instead of JSON. - -## Spotify Endpoints - -### Podcast-Level Metrics -- `reportSpotifyPodcastBaseMetrics` - Base podcast metrics (streams, listeners, followers) -- `reportSpotifyPlays` - Play counts over time -- `reportSpotifyUniqueListeners` - Unique listener counts -- `spotifyPlaysSum` - Sum of plays for a date range - -### Episode-Level Metrics -- `spotifyEpisodesMetricsExport` - Detailed episode metrics export - -### Demographics -- `podcastAge` - Age distribution of podcast listeners -- `podcastGender` - Gender distribution of podcast listeners -- `episodesAge` - Age distribution per episode -- `episodesGender` - Gender distribution per episode - -### Geographic Data -- `spotifyCountries` - Geographic distribution by country - -### Impressions & Discovery -- `spotifyImpressions` - Total impressions over time -- `spotifyImpressionsSources` - Impressions by source (HOME, SEARCH, LIBRARY, OTHER) -- `spotifyImpressionsFunnel` - Conversion funnel from impressions to streams - -## Apple Podcasts Endpoints - -### Podcast-Level Metrics -- `reportApplePodcastBaseMetrics` - Base podcast metrics (plays, listeners, engagement) -- `applePodcastFollowers` - Follower counts and trends -- `reportApplePlays` - Play counts over time - -### Episode-Level Metrics -- `appleEpisodesPlays` - Episode play counts -- `appleEpisodesLTR` - Episode listen-through rates -- `reportEpisodesLTRHistogram` - Detailed LTR histograms per episode - -### Geographic Data -- `rawApplePodcastCountries` - Geographic distribution by country - -## Cross-Platform Endpoints - -### Episode Metrics -- `episodesTotalMetrics` - Combined Apple + Spotify total metrics per episode -- `episodesDailyMetrics` - Combined daily metrics per episode -- `episodesMetadata` - Episode metadata from all platforms -- `episodesLTR` - Combined listen-through rates -- `episodesLTRHistogram` - Combined LTR histograms - -### Podcast Metrics -- `podcastMetadata` - Podcast metadata from all platforms -- `podcastFollowers` - Combined follower counts - -## Generic Hoster Endpoints - -### Downloads & Performance -- `reportHosterDownloads` - Download counts over time -- `reportTopEpisodesPerformance` - Top performing episodes -- `reportEpisodeTotalPlays` - Total plays per episode (lifetime) - -### Distribution Metrics -- `reportHosterPlatforms` - Platform/app distribution (e.g., Apple Podcasts, Spotify, Overcast) -- `reportHosterClients` - Client/device distribution - -### Podigee-Specific -- `reportHosterPodigeePodcastOverview` - Podcast overview metrics (Podigee hosting) - -## Chart Rankings - -- `chartsRankings` - Chart positions and rankings across platforms - -## Anchor (Legacy) - -### Episode Metrics -- `reportAnchorEpisodeMetadata` - Episode metadata -- `reportAnchorEpisodeLTRHistogram` - Episode listen-through histogram -- `reportAnchorAvgEpisodeLTRHistogram` - Average LTR across episodes - -### Audience -- `reportAnchorTotalPlaysByEpisode` - Total plays per episode -- `reportAnchorPlays` - Plays over time -- `reportAnchorTopEpisodes` - Top performing episodes - -## Utility Endpoints - -- `ping` - Health check / connection test - -## Example Usage - -### Get Spotify podcast metrics for August 2024 (JSON) -```bash -GET /analytics/v1/123/reportSpotifyPodcastBaseMetrics?start=2024-08-01&end=2024-08-31 -Authorization: Bearer YOUR_TOKEN -``` - -### Get platform distribution for August 2024 (CSV) -```bash -GET /analytics/v1/123/reportHosterPlatforms/csv?start=2024-08-01&end=2024-08-31 -Authorization: Bearer YOUR_TOKEN -``` - -### Get combined episode metrics (defaults to yesterday if no dates provided) -```bash -GET /analytics/v1/123/episodesTotalMetrics -Authorization: Bearer YOUR_TOKEN -``` - -## Response Format - -### JSON Response -```json -{ - "meta": { - "query": "reportSpotifyPodcastBaseMetrics", - "podcastId": "123", - "date": "2024-09-24T12:00:00Z", - "startDate": "2024-08-01", - "endDate": "2024-08-31" - }, - "data": [ - { - "total_episodes": 50, - "starts": 10000, - "streams": 8500, - "listeners": 5000, - "followers": 2500, - "date": "2024-08-31" - } - ] -} -``` - -### CSV Response -When requesting CSV format (by adding `/csv` to the endpoint), the response will be a CSV file with appropriate headers. - -## Notes - -- All endpoints require authentication via Bearer token -- Users can only access podcast IDs they have permission for -- Date parameters are optional - defaults to yesterday if not provided -- Invalid date formats or date ranges will return 400 Bad Request -- Non-existent query endpoints will return 404 Not Found From 7fc0ec56af6b0ef2e7ca8a22f45ee84e43315d0b Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:45:41 +0200 Subject: [PATCH 09/16] Add logs target to Makefile for viewing docker-compose logs --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 42d87c2..3d5da63 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,10 @@ up-db: ## docker-compose up db touch dbinit.sql docker compose up db +.PHONY: logs +logs: ## docker-compose logs -f + docker compose logs -f + .PHONY: down-db down-db: ## docker-compose down db and remove volumes docker compose down -v From 88b408b8e24f638ef5e997f00e361c5167437c4d Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:46:02 +0200 Subject: [PATCH 10/16] Refactor swagger configuration to use hardcoded version and dynamic date for examples; streamline server definitions for production and development environments. --- src/config/swagger.ts | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 93d0767..3079cca 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -1,9 +1,32 @@ import swaggerJsdoc from 'swagger-jsdoc' -import { version } from '../../package.json' import { generateQueryPaths } from './generate-query-docs' +// Use hardcoded version to avoid rootDir issues with package.json import +const version = '1.0.0' + +// Calculate dynamic date (2 days ago) +const getTwoDaysAgo = (): string => { + const date = new Date() + date.setDate(date.getDate() - 2) + return date.toISOString().split('T')[0] // YYYY-MM-DD format +} + const { paths: queryPaths, categories } = generateQueryPaths() +const servers = [ + { + url: 'https://api.openpodcast.dev', + description: 'OpenPodcast API', + }, +] + +if (process.env.NODE_ENV !== 'production') { + servers.push({ + url: 'http://localhost:8080', + description: 'Local development server', + }) +} + const options: swaggerJsdoc.Options = { definition: { openapi: '3.0.0', @@ -22,16 +45,7 @@ const options: swaggerJsdoc.Options = { url: 'https://github.com/openpodcast/api/blob/main/LICENSE', }, }, - servers: [ - { - url: 'https://api.openpodcast.dev', - description: 'Production server', - }, - { - url: 'http://localhost:3000', - description: 'Development server', - }, - ], + servers, components: { securitySchemes: { bearerAuth: { @@ -58,7 +72,7 @@ const options: swaggerJsdoc.Options = { schema: { type: 'string', format: 'date', - example: '2024-01-01', + example: getTwoDaysAgo(), }, description: 'Start date (YYYY-MM-DD)', }, @@ -69,7 +83,7 @@ const options: swaggerJsdoc.Options = { schema: { type: 'string', format: 'date', - example: '2024-12-31', + example: getTwoDaysAgo(), }, description: 'End date (YYYY-MM-DD)', }, From 242de77f5d42aa55389a820500fcd803ca3b3145 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:49:48 +0200 Subject: [PATCH 11/16] Add CORS support to enable API calls from Swagger UI; update dependencies in package.json and package-lock.json --- package-lock.json | 61 +++++++++++++++++++++++++++++++++++++++++++---- package.json | 2 ++ src/index.ts | 11 +++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d216a76..b1c2acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@types/cors": "^2.8.19", "ajv": "^8.11.0", "ajv-formats": "^2.1.1", "body-parser": "^1.20.1", + "cors": "^2.8.5", "dotenv": "^16.0.2", "express": "^4.18.1", "express-validator": "^6.14.2", @@ -1395,6 +1397,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.10.tgz", @@ -1744,8 +1755,7 @@ "node_modules/@types/node": { "version": "18.11.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.17.tgz", - "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==", - "dev": true + "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==" }, "node_modules/@types/prettier": { "version": "2.7.2", @@ -2982,6 +2992,19 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -6146,6 +6169,15 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -9292,6 +9324,14 @@ "@types/node": "*" } }, + "@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "requires": { + "@types/node": "*" + } + }, "@types/eslint": { "version": "8.4.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.10.tgz", @@ -9571,8 +9611,7 @@ "@types/node": { "version": "18.11.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.17.tgz", - "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==", - "dev": true + "integrity": "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng==" }, "@types/prettier": { "version": "2.7.2", @@ -10507,6 +10546,15 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -12866,6 +12914,11 @@ "path-key": "^3.0.0" } }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", diff --git a/package.json b/package.json index 98dd5a7..dbd0e0d 100644 --- a/package.json +++ b/package.json @@ -11,9 +11,11 @@ "license": "MIT", "private": true, "dependencies": { + "@types/cors": "^2.8.19", "ajv": "^8.11.0", "ajv-formats": "^2.1.1", "body-parser": "^1.20.1", + "cors": "^2.8.5", "dotenv": "^16.0.2", "express": "^4.18.1", "express-validator": "^6.14.2", diff --git a/src/index.ts b/src/index.ts index 1c6b4b9..0d47e00 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import express, { RequestHandler, NextFunction, } from 'express' +import cors from 'cors' import bodyParser from 'body-parser' import { EventsApi, ConnectorApi } from './api' import { EventRepository } from './db/EventRepository' @@ -179,6 +180,16 @@ const authController = new AuthController(accountKeyRepo) const app: Express = express() const port = config.getExpressPort() +// Configure CORS to allow Swagger UI to make API calls +app.use( + cors({ + origin: ['http://localhost:8080', 'https://api.openpodcast.dev'], + credentials: true, + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }) +) + // extract json payload from body automatically app.use(bodyParser.json({ limit: '5mb' })) From ec7330f2fc1432ba453a3e04106be25c662b23e8 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:52:17 +0200 Subject: [PATCH 12/16] Enhance security by removing X-Powered-By header and updating CORS configuration --- src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 0d47e00..4225913 100644 --- a/src/index.ts +++ b/src/index.ts @@ -184,12 +184,14 @@ const port = config.getExpressPort() app.use( cors({ origin: ['http://localhost:8080', 'https://api.openpodcast.dev'], - credentials: true, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], }) ) +// Remove X-Powered-By header for security +app.disable('x-powered-by') + // extract json payload from body automatically app.use(bodyParser.json({ limit: '5mb' })) From 064aad0c396ce9862babc4b9434dafd657edb182 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:52:32 +0200 Subject: [PATCH 13/16] Fix Makefile to correctly run docker-compose in detached mode --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3d5da63..8d89e64 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ help: ## help message, list all command .PHONY: up docker-run up docker-run: docker-build ## docker-compose up touch dbinit.sql - docker compose up --build + docker compose -d up --build .PHONY: up-db up-db: ## docker-compose up db From 01221981e4d748031de6f82019598f7f3b5585b8 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 18:56:24 +0200 Subject: [PATCH 14/16] Improve error handling in API responses with specific messages for 401 and 404 status codes --- src/index.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4225913..9a82a65 100644 --- a/src/index.ts +++ b/src/index.ts @@ -563,10 +563,21 @@ app.use(function ( // if it is not a known http error, print it for debugging purposes console.log(err) } - res.status( + + const status = err instanceof HttpError || err instanceof AuthError ? err.status : 500 - ) - res.send(`Something's wrong. We're looking into it. (${tracingId})`) + res.status(status) + + // Provide more specific error messages for common HTTP status codes + if (status === 401) { + res.send( + `Authentication required. Please provide a valid API token. (${tracingId})` + ) + } else if (status === 404) { + res.send(`Resource not found. (${tracingId})`) + } else { + res.send(`Something's wrong. We're looking into it. (${tracingId})`) + } }) dbInit.init().then(() => { From fde898ce549ccafad7b2ec5fe78611045590f025 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 19:00:15 +0200 Subject: [PATCH 15/16] Expose getTwoDaysAgo function for dynamic date generation in OpenAPI documentation --- src/config/generate-query-docs.ts | 5 +++-- src/config/swagger.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/config/generate-query-docs.ts b/src/config/generate-query-docs.ts index ed73eb6..fddf72d 100644 --- a/src/config/generate-query-docs.ts +++ b/src/config/generate-query-docs.ts @@ -1,5 +1,6 @@ import fs from 'fs' import path from 'path' +import { getTwoDaysAgo } from './swagger' /** * Generates OpenAPI documentation for SQL query endpoints @@ -114,7 +115,7 @@ export const generateQueryPaths = () => { schema: { type: 'string', format: 'date', - example: '2024-01-01', + example: getTwoDaysAgo(), }, description: 'Start date (YYYY-MM-DD)', }, @@ -125,7 +126,7 @@ export const generateQueryPaths = () => { schema: { type: 'string', format: 'date', - example: '2024-12-31', + example: getTwoDaysAgo(), }, description: 'End date (YYYY-MM-DD)', }, diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 3079cca..fe0dad2 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -5,7 +5,7 @@ import { generateQueryPaths } from './generate-query-docs' const version = '1.0.0' // Calculate dynamic date (2 days ago) -const getTwoDaysAgo = (): string => { +export const getTwoDaysAgo = (): string => { const date = new Date() date.setDate(date.getDate() - 2) return date.toISOString().split('T')[0] // YYYY-MM-DD format From dd7540799f455eb26debab9e9590f6746e10a7e1 Mon Sep 17 00:00:00 2001 From: Wolfgang Gassler Date: Mon, 13 Oct 2025 19:24:37 +0200 Subject: [PATCH 16/16] Add documentation comments for SQL queries to clarify data returned and field descriptions --- db_schema/queries/v1/appleEpisodesLTR.sql | 4 ++++ db_schema/queries/v1/appleEpisodesPlays.sql | 4 ++++ db_schema/queries/v1/applePodcastFollowers.sql | 4 ++++ db_schema/queries/v1/chartsRankings.sql | 5 +++-- db_schema/queries/v1/episodesAge.sql | 4 ++++ db_schema/queries/v1/episodesDailyMetrics.sql | 1 + db_schema/queries/v1/episodesGender.sql | 4 ++++ db_schema/queries/v1/episodesLTR.sql | 4 ++++ db_schema/queries/v1/episodesLTRHistogram.sql | 4 ++++ db_schema/queries/v1/episodesMetadata.sql | 5 +++-- db_schema/queries/v1/episodesTotalMetrics.sql | 4 ++++ db_schema/queries/v1/ping.sql | 4 ++++ db_schema/queries/v1/podcastAge.sql | 4 ++++ db_schema/queries/v1/podcastFollowers.sql | 4 ++++ db_schema/queries/v1/podcastGender.sql | 4 ++++ db_schema/queries/v1/podcastMetadata.sql | 5 +++-- db_schema/queries/v1/spotifyCountries.sql | 4 ++++ db_schema/queries/v1/spotifyImpressions.sql | 4 ++++ db_schema/queries/v1/spotifyImpressionsFunnel.sql | 5 +++-- db_schema/queries/v1/spotifyImpressionsSources.sql | 4 +++- db_schema/queries/v1/spotifyPlaysSum.sql | 4 ++++ 21 files changed, 76 insertions(+), 9 deletions(-) diff --git a/db_schema/queries/v1/appleEpisodesLTR.sql b/db_schema/queries/v1/appleEpisodesLTR.sql index 58c2cee..16081e1 100644 --- a/db_schema/queries/v1/appleEpisodesLTR.sql +++ b/db_schema/queries/v1/appleEpisodesLTR.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Apple Podcasts episode listener-through-rate (LTR) data showing retention at 25%, 50%, 75%, and 100% completion points. +-- Fields: Episode Name, GUID, Date, Quarter 1-4 LTR percentages, Apple Listeners count + WITH apple as ( SELECT diff --git a/db_schema/queries/v1/appleEpisodesPlays.sql b/db_schema/queries/v1/appleEpisodesPlays.sql index ae52729..9e5f21b 100644 --- a/db_schema/queries/v1/appleEpisodesPlays.sql +++ b/db_schema/queries/v1/appleEpisodesPlays.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Apple Podcasts episode performance metrics including plays, listening time, and engagement data. +-- Fields: Date, Episode Name, GUID, Plays Count, Total Time Listened, Unique Engaged Listeners, Unique Listeners + WITH apple as ( SELECT diff --git a/db_schema/queries/v1/applePodcastFollowers.sql b/db_schema/queries/v1/applePodcastFollowers.sql index b5ea85a..bdb3413 100644 --- a/db_schema/queries/v1/applePodcastFollowers.sql +++ b/db_schema/queries/v1/applePodcastFollowers.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Apple Podcasts follower metrics showing daily follower growth and churn. +-- Fields: Date, Total Followers, Total Unfollowers, Gained Followers, Lost Followers + SELECT atf_date as `date`, atf_totalfollowers as total_followers, diff --git a/db_schema/queries/v1/chartsRankings.sql b/db_schema/queries/v1/chartsRankings.sql index 9a8e5c7..6a7a90b 100644 --- a/db_schema/queries/v1/chartsRankings.sql +++ b/db_schema/queries/v1/chartsRankings.sql @@ -1,5 +1,6 @@ --- Chart rankings query using provider IDs from podcasts table --- Requires @account_id parameter to be set +-- @doc +-- Returns podcast and episode chart rankings from Spotify and Apple Podcasts across different markets and categories. +-- Fields: Platform, Item Type, Show ID, Episode ID, Episode Name, Market, Chart Name, Position, Chart Date WITH podcast_ids AS ( SELECT diff --git a/db_schema/queries/v1/episodesAge.sql b/db_schema/queries/v1/episodesAge.sql index 6514e03..dad2148 100644 --- a/db_schema/queries/v1/episodesAge.sql +++ b/db_schema/queries/v1/episodesAge.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Spotify episode listener demographics by age group with listener counts and percentages. +-- Fields: Date, Episode GUID, Age Group, Listeners Count, Percentage of Total + WITH data as ( SELECT spa_date,episode_id,spa_facet,spa_gender_female+spa_gender_male+spa_gender_non_binary+spa_gender_not_specified as listeners diff --git a/db_schema/queries/v1/episodesDailyMetrics.sql b/db_schema/queries/v1/episodesDailyMetrics.sql index 036b7dc..b450951 100644 --- a/db_schema/queries/v1/episodesDailyMetrics.sql +++ b/db_schema/queries/v1/episodesDailyMetrics.sql @@ -1,5 +1,6 @@ -- @doc -- Returns daily performance metrics for plays, listeners, streams, and engagement data of episodes from Apple and Spotify. +-- Fields: Podcast ID, Spotify Episode ID, Apple Episode ID, GUID, Date, Spotify Starts, Spotify Streams, Spotify Listeners, Apple Plays, Apple Unique Listeners, Apple Engaged Listeners, Apple Total Time Listened SELECT -- Episode identifiers from mapping view diff --git a/db_schema/queries/v1/episodesGender.sql b/db_schema/queries/v1/episodesGender.sql index 93947fe..844f3ec 100644 --- a/db_schema/queries/v1/episodesGender.sql +++ b/db_schema/queries/v1/episodesGender.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Spotify episode listener demographics broken down by gender and age group. +-- Fields: Date, Episode GUID, Gender, Listeners Count, Age Group + WITH data as ( SELECT spa_date,ep_guid,spa_facet,spa_gender_female,spa_gender_male,spa_gender_non_binary,spa_gender_not_specified diff --git a/db_schema/queries/v1/episodesLTR.sql b/db_schema/queries/v1/episodesLTR.sql index a2a0e03..9b1d443 100644 --- a/db_schema/queries/v1/episodesLTR.sql +++ b/db_schema/queries/v1/episodesLTR.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns combined episode listener-through-rate (LTR) data from both Spotify and Apple Podcasts showing retention at different completion points. +-- Fields: Episode Name, GUID, Date, Combined Quarter 1-4 LTR, Individual Platform LTR values, Listener Counts + WITH spotify as ( SELECT diff --git a/db_schema/queries/v1/episodesLTRHistogram.sql b/db_schema/queries/v1/episodesLTRHistogram.sql index 4bab796..1ecf45e 100644 --- a/db_schema/queries/v1/episodesLTRHistogram.sql +++ b/db_schema/queries/v1/episodesLTRHistogram.sql @@ -1,4 +1,8 @@ +-- @doc +-- Returns detailed listener retention histogram data for episodes from both Spotify and Apple Podcasts at 15-second intervals. +-- Fields: Episode GUID, Date, Max Listeners (Spotify/Apple), Histogram Data Arrays + WITH spotify as ( SELECT JSON_ARRAYAGG(JSON_OBJECT(sample_id-1,listeners)) as histogram,episode_id,account_id,spp_date,spp_sample_max FROM spotifyEpisodePerformance CROSS JOIN diff --git a/db_schema/queries/v1/episodesMetadata.sql b/db_schema/queries/v1/episodesMetadata.sql index 1cbe05a..bcaab45 100644 --- a/db_schema/queries/v1/episodesMetadata.sql +++ b/db_schema/queries/v1/episodesMetadata.sql @@ -1,5 +1,6 @@ --- Combined episodes metadata from Apple and Spotify platforms using episodeMapping view --- Returns metadata for all episodes with their basic information +-- @doc +-- Returns comprehensive episode metadata from both Spotify and Apple Podcasts including URLs, descriptions, release dates, and technical details. +-- Fields: Account ID, Episode IDs, Name, GUID, URLs, Release Dates, Descriptions, Duration, Language, Content Flags SELECT -- Common episode identifiers from mapping view diff --git a/db_schema/queries/v1/episodesTotalMetrics.sql b/db_schema/queries/v1/episodesTotalMetrics.sql index 45025aa..4f2cbb8 100644 --- a/db_schema/queries/v1/episodesTotalMetrics.sql +++ b/db_schema/queries/v1/episodesTotalMetrics.sql @@ -1,6 +1,10 @@ -- Total episodes metrics from Apple and Spotify platforms using episodeMapping view -- Returns cumulative/total performance metrics for plays, listeners, and engagement data +-- @doc +-- Returns aggregated total metrics for episodes combining data from Spotify, Apple Podcasts, and hosting providers over the specified date range. +-- Fields: Episode identifiers, Total Streams, Plays, Listeners, Downloads, Time Listened across all platforms + SELECT -- Episode identifiers from mapping view em.account_id, diff --git a/db_schema/queries/v1/ping.sql b/db_schema/queries/v1/ping.sql index e901a28..fc43932 100644 --- a/db_schema/queries/v1/ping.sql +++ b/db_schema/queries/v1/ping.sql @@ -1 +1,5 @@ +-- @doc +-- Simple health check endpoint that echoes back the provided date parameters. +-- Fields: Start Date, End Date, Result Status + SELECT @start as start, @end as end, "pong" as result \ No newline at end of file diff --git a/db_schema/queries/v1/podcastAge.sql b/db_schema/queries/v1/podcastAge.sql index 251bab8..c0da0f2 100644 --- a/db_schema/queries/v1/podcastAge.sql +++ b/db_schema/queries/v1/podcastAge.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Spotify podcast listener demographics by age group with listener counts and percentages of total audience. +-- Fields: Date, Age Group, Listeners Count, Percentage of Total + WITH data as ( SELECT diff --git a/db_schema/queries/v1/podcastFollowers.sql b/db_schema/queries/v1/podcastFollowers.sql index 5e16507..c8e714e 100644 --- a/db_schema/queries/v1/podcastFollowers.sql +++ b/db_schema/queries/v1/podcastFollowers.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns podcast follower metrics across platforms showing daily follower counts and growth trends. +-- Fields: Date, Platform, Followers Count, Growth metrics + SELECT * FROM podcastFollowers WHERE account_id = @podcast_id diff --git a/db_schema/queries/v1/podcastGender.sql b/db_schema/queries/v1/podcastGender.sql index 175d5c1..8821c9c 100644 --- a/db_schema/queries/v1/podcastGender.sql +++ b/db_schema/queries/v1/podcastGender.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Spotify podcast listener demographics broken down by gender and age group. +-- Fields: Date, Gender, Listeners Count, Age Group + WITH data as ( SELECT * FROM spotifyPodcastAggregate diff --git a/db_schema/queries/v1/podcastMetadata.sql b/db_schema/queries/v1/podcastMetadata.sql index e3142eb..b0d39bc 100644 --- a/db_schema/queries/v1/podcastMetadata.sql +++ b/db_schema/queries/v1/podcastMetadata.sql @@ -1,5 +1,6 @@ --- Combined podcast metadata from Apple and Spotify platforms --- Returns the latest metadata information for the podcast +-- @doc +-- Returns comprehensive podcast metadata including basic information, Spotify statistics, and Apple follower data. +-- Fields: Account ID, Podcast Name, Artwork URL, Release Date, Publisher, Latest Platform Statistics SELECT pm.account_id, diff --git a/db_schema/queries/v1/spotifyCountries.sql b/db_schema/queries/v1/spotifyCountries.sql index bf533b4..61bcbcd 100644 --- a/db_schema/queries/v1/spotifyCountries.sql +++ b/db_schema/queries/v1/spotifyCountries.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns Spotify podcast listener geographic distribution by country with listener counts and percentages. +-- Fields: Country Short Code, Listeners Count, Percentage of Total + WITH data as ( SELECT diff --git a/db_schema/queries/v1/spotifyImpressions.sql b/db_schema/queries/v1/spotifyImpressions.sql index 81e731a..ab9fe9c 100644 --- a/db_schema/queries/v1/spotifyImpressions.sql +++ b/db_schema/queries/v1/spotifyImpressions.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns daily Spotify impression metrics showing how many times the podcast appeared in user interfaces. +-- Fields: Account ID, Date, Impressions Count + SELECT account_id, date, diff --git a/db_schema/queries/v1/spotifyImpressionsFunnel.sql b/db_schema/queries/v1/spotifyImpressionsFunnel.sql index 1e9f70c..8b37270 100644 --- a/db_schema/queries/v1/spotifyImpressionsFunnel.sql +++ b/db_schema/queries/v1/spotifyImpressionsFunnel.sql @@ -1,5 +1,6 @@ --- Gets the latest 30-day funnel data (impressions -> considerations -> streams) --- Returns data for the most recent day within the requested date range +-- @doc +-- Returns Spotify conversion funnel data showing the progression from impressions to considerations to streams with conversion rates. +-- Fields: Account ID, Date, Step ID, Step Count, Conversion Percentage SELECT DISTINCT account_id, diff --git a/db_schema/queries/v1/spotifyImpressionsSources.sql b/db_schema/queries/v1/spotifyImpressionsSources.sql index 498497b..5bbdf6e 100644 --- a/db_schema/queries/v1/spotifyImpressionsSources.sql +++ b/db_schema/queries/v1/spotifyImpressionsSources.sql @@ -1,4 +1,6 @@ --- Gets impressions breakdown by source (HOME, SEARCH, LIBRARY, OTHER) +-- @doc +-- Returns Spotify impressions broken down by source location (HOME, SEARCH, LIBRARY, OTHER) showing where users discover the podcast. +-- Fields: Account ID, Date Start, Date End, Source ID, Impression Count SELECT DISTINCT account_id, diff --git a/db_schema/queries/v1/spotifyPlaysSum.sql b/db_schema/queries/v1/spotifyPlaysSum.sql index e68317c..da1fa66 100644 --- a/db_schema/queries/v1/spotifyPlaysSum.sql +++ b/db_schema/queries/v1/spotifyPlaysSum.sql @@ -1,3 +1,7 @@ +-- @doc +-- Returns aggregated Spotify podcast metrics totals including episodes, starts, streams, listeners, and followers over the date range. +-- Fields: Podcast ID, Total Episodes, Starts, Streams, Listeners, Followers + SELECT account_id as podcast_id, SUM(spm_total_episodes) as spotify_total_episodes,