diff --git a/Makefile b/Makefile index 42d87c2..8d89e64 100644 --- a/Makefile +++ b/Makefile @@ -8,13 +8,17 @@ 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 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 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 59bd1bd..b450951 100644 --- a/db_schema/queries/v1/episodesDailyMetrics.sql +++ b/db_schema/queries/v1/episodesDailyMetrics.sql @@ -1,5 +1,6 @@ --- 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. +-- 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, diff --git a/package-lock.json b/package-lock.json index bebf9dc..b1c2acc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,20 @@ "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", "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 +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", @@ -60,6 +66,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 +1241,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 +1282,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", @@ -1334,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", @@ -1651,8 +1723,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", @@ -1684,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", @@ -1748,6 +1818,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 +2376,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 +2535,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 +2586,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 +2688,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 +2919,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", @@ -2902,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", @@ -3081,7 +3184,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 +3876,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 +4242,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 +4678,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 +5629,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 +5744,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 +5770,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 +5946,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" }, @@ -6052,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", @@ -6119,7 +6245,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 +6264,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 +6378,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 +7372,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 +8146,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 +8175,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 +8231,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 +8274,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 +9180,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 +9211,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", @@ -9025,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", @@ -9274,8 +9581,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", @@ -9305,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", @@ -9366,6 +9671,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 +10087,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 +10210,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 +10256,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 +10329,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 +10492,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", @@ -10224,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", @@ -10351,7 +10682,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 +11188,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 +11490,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 +11792,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 +12502,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 +12584,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 +12606,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 +12737,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" } @@ -12574,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", @@ -12620,7 +12965,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 +12978,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 +13057,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 +13781,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 +14312,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 +14335,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 +14372,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..dbd0e0d 100644 --- a/package.json +++ b/package.json @@ -11,16 +11,20 @@ "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", "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 +32,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/generate-query-docs.ts b/src/config/generate-query-docs.ts new file mode 100644 index 0000000..fddf72d --- /dev/null +++ b/src/config/generate-query-docs.ts @@ -0,0 +1,173 @@ +import fs from 'fs' +import path from 'path' +import { getTwoDaysAgo } from './swagger' + +/** + * 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') + + 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, + 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: getTwoDaysAgo(), + }, + description: 'Start date (YYYY-MM-DD)', + }, + { + name: 'end', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: getTwoDaysAgo(), + }, + 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' }, + }, + }, + }, + }, + }, + }, + }, + }, + } + } + + return { paths, categories: Object.keys(byCategory) } +} diff --git a/src/config/swagger.ts b/src/config/swagger.ts new file mode 100644 index 0000000..fe0dad2 --- /dev/null +++ b/src/config/swagger.ts @@ -0,0 +1,108 @@ +import swaggerJsdoc from 'swagger-jsdoc' +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) +export 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', + info: { + title: 'Open Podcast Analytics API', + version: version, + description: + 'Podcast analytics API providing metrics from Spotify, Apple Podcasts, and custom hosting providers.', + contact: { + name: 'Open Podcast Developer Documentation', + url: 'https://openpodcast.dev', + email: 'echo@openpodcast.dev', + }, + license: { + name: 'MIT', + url: 'https://github.com/openpodcast/api/blob/main/LICENSE', + }, + }, + servers, + 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: getTwoDaysAgo(), + }, + description: 'Start date (YYYY-MM-DD)', + }, + endDate: { + name: 'end', + in: 'query', + required: false, + schema: { + type: 'string', + format: 'date', + example: getTwoDaysAgo(), + }, + description: 'End date (YYYY-MM-DD)', + }, + }, + }, + security: [ + { + bearerAuth: [], + }, + ], + tags: [ + ...categories.map((cat) => ({ + name: cat, + description: `${cat} analytics endpoints`, + })), + ], + paths: queryPaths, + }, + apis: ['./src/api/*.ts', './src/index.ts'], +} + +export const swaggerSpec = swaggerJsdoc(options) diff --git a/src/index.ts b/src/index.ts index ce1784d..9a82a65 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' @@ -37,6 +38,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 +171,8 @@ const publicEndpoints = [ '^/status', '^/feedback/*', '^/comments/*', + '^/api-docs', + '^/api-docs.json', ] const authController = new AuthController(accountKeyRepo) @@ -175,6 +180,18 @@ 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'], + 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' })) @@ -270,12 +287,6 @@ 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 app.get( '/analytics/:version/:podcastId/:query/:format?', async (req: Request, res: Response, next: NextFunction) => { @@ -510,6 +521,21 @@ app.get( }) ) +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}`) @@ -537,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(() => {