From 2dba957adca2ce7b8a4956ce83a57218af45177c Mon Sep 17 00:00:00 2001 From: Bruce Berrios <58147810+Bruception@users.noreply.github.com> Date: Wed, 9 Mar 2022 07:02:37 -0500 Subject: [PATCH] Add public api docs (#2656) by bruce * Add public documentation * typo * added hard limit on the leaderboards * ignoring worker when compiling * added leaderboard routes * leaderboards accept apekeys * Fix docs * Fix * Specify parse base * Add ape rate limiter * added documentation link * updated message Co-authored-by: Miodec --- backend/api/controllers/leaderboards.ts | 6 +- backend/api/routes/index.ts | 40 +-- backend/api/routes/leaderboards.ts | 14 +- backend/api/routes/swagger.ts | 48 ++++ backend/api/routes/users.ts | 4 +- .../internal-swagger.json} | 0 backend/documentation/public-swagger.json | 257 ++++++++++++++++++ backend/package-lock.json | 54 ++++ backend/package.json | 2 + backend/tsconfig.json | 2 +- frontend/static/index.html | 12 +- 11 files changed, 392 insertions(+), 47 deletions(-) create mode 100644 backend/api/routes/swagger.ts rename backend/{swagger.json => documentation/internal-swagger.json} (100%) create mode 100644 backend/documentation/public-swagger.json diff --git a/backend/api/controllers/leaderboards.ts b/backend/api/controllers/leaderboards.ts index ddad9207d..c565eb5ca 100644 --- a/backend/api/controllers/leaderboards.ts +++ b/backend/api/controllers/leaderboards.ts @@ -7,12 +7,14 @@ class LeaderboardsController { const { language, mode, mode2, skip, limit = 50 } = req.query; const { uid } = req.ctx.decodedToken; + const queryLimit = Math.min(parseInt(limit as string, 10), 50); + const leaderboard = await LeaderboardsDAO.get( mode, mode2, language, - parseInt(skip as string), - parseInt(limit as string) + parseInt(skip as string, 10), + queryLimit ); const normalizedLeaderboard = _.map(leaderboard, (entry) => { diff --git a/backend/api/routes/index.ts b/backend/api/routes/index.ts index dd7e7494a..d26d6c612 100644 --- a/backend/api/routes/index.ts +++ b/backend/api/routes/index.ts @@ -1,17 +1,16 @@ import _ from "lodash"; +import psas from "./psas"; import users from "./users"; +import quotes from "./quotes"; import configs from "./configs"; import results from "./results"; import presets from "./presets"; -import psas from "./psas"; -import leaderboards from "./leaderboards"; -import quotes from "./quotes"; import apeKeys from "./ape-keys"; +import leaderboards from "./leaderboards"; +import addSwaggerMiddlewares from "./swagger"; import { asyncHandler } from "../../middlewares/api-utils"; import { MonkeyResponse } from "../../utils/monkey-response"; import { Application, NextFunction, Response, Router } from "express"; -import swStats from "swagger-stats"; -import SwaggerSpec from "../../swagger.json"; const pathOverride = process.env.API_PATH_OVERRIDE; const BASE_ROUTE = pathOverride ? `/${pathOverride}` : ""; @@ -31,34 +30,7 @@ const API_ROUTE_MAP = { function addApiRoutes(app: Application): void { let requestsProcessed = 0; - app.use( - swStats.getMiddleware({ - name: "Monkeytype API", - // hostname: process.env.MODE === "dev" ? "localhost": process.env.STATS_HOSTNAME, - // ip: process.env.MODE === "dev" ? "127.0.0.1": process.env.STATS_IP, - uriPath: "/stats", - authentication: process.env.MODE !== "dev", - apdexThreshold: 100, - swaggerSpec: SwaggerSpec, - onAuthenticate: (_req, username, password) => { - return ( - username === process.env.STATS_USERNAME && - password === process.env.STATS_PASSWORD - ); - }, - onResponseFinish: (_req, res, rrr) => { - //@ts-ignore ignored because monkeyMessage doesnt exist on the type - rrr.http.response.message = res.monkeyMessage; - if (process.env.MODE === "dev") { - return; - } - const authHeader = rrr.http.request.headers?.authorization ?? "None"; - const authType = authHeader.split(" "); - _.set(rrr.http.request, "headers.authorization", authType[0]); - _.set(rrr.http.request, "headers['x-forwarded-for']", ""); - }, - }) - ); + addSwaggerMiddlewares(app); app.use( (req: MonkeyTypes.Request, res: Response, next: NextFunction): void => { @@ -85,7 +57,7 @@ function addApiRoutes(app: Application): void { }) ); - app.get("/psa", (req, res) => { + app.get("/psa", (_req, res) => { res.json([ { message: diff --git a/backend/api/routes/leaderboards.ts b/backend/api/routes/leaderboards.ts index cd2ca2fe3..b76947fbb 100644 --- a/backend/api/routes/leaderboards.ts +++ b/backend/api/routes/leaderboards.ts @@ -1,23 +1,24 @@ import joi from "joi"; +import { Router } from "express"; +import * as RateLimit from "../../middlewares/rate-limit"; +import apeRateLimit from "../../middlewares/ape-rate-limit"; import { authenticateRequest } from "../../middlewares/auth"; import LeaderboardsController from "../controllers/leaderboards"; -import * as RateLimit from "../../middlewares/rate-limit"; import { asyncHandler, validateRequest } from "../../middlewares/api-utils"; -import { Router } from "express"; const router = Router(); router.get( "/", RateLimit.leaderboardsGet, - authenticateRequest({ isPublic: true }), + authenticateRequest({ isPublic: true, acceptApeKeys: true }), validateRequest({ query: { language: joi.string().required(), mode: joi.string().required(), mode2: joi.string().required(), - skip: joi.number().min(0).required(), - limit: joi.number(), + skip: joi.number().min(0), + limit: joi.number().min(0).max(50), }, validationErrorMessage: "Missing parameters", }), @@ -27,7 +28,8 @@ router.get( router.get( "/rank", RateLimit.leaderboardsGet, - authenticateRequest(), + authenticateRequest({ acceptApeKeys: true }), + apeRateLimit, validateRequest({ query: { language: joi.string().required(), diff --git a/backend/api/routes/swagger.ts b/backend/api/routes/swagger.ts new file mode 100644 index 000000000..9b73b47cd --- /dev/null +++ b/backend/api/routes/swagger.ts @@ -0,0 +1,48 @@ +import _ from "lodash"; +import { Application } from "express"; +import swaggerStats from "swagger-stats"; +import swaggerUi from "swagger-ui-express"; +import publicSwaggerSpec from "../../documentation/public-swagger.json"; +import internalSwaggerSpec from "../../documentation/internal-swagger.json"; + +const SWAGGER_UI_OPTIONS = { + customCss: ".swagger-ui .topbar { display: none }", + customSiteTitle: "Monkeytype API Documentation", +}; + +function addSwaggerMiddlewares(app: Application): void { + app.use( + swaggerStats.getMiddleware({ + name: "Monkeytype API", + uriPath: "/stats", + authentication: process.env.MODE !== "dev", + apdexThreshold: 100, + swaggerSpec: internalSwaggerSpec, + onAuthenticate: (_req, username, password) => { + return ( + username === process.env.STATS_USERNAME && + password === process.env.STATS_PASSWORD + ); + }, + onResponseFinish: (_req, res, rrr) => { + //@ts-ignore ignored because monkeyMessage doesnt exist in response + rrr.http.response.message = res.monkeyMessage; + if (process.env.MODE === "dev") { + return; + } + const authHeader = rrr.http.request.headers?.authorization ?? "None"; + const authType = authHeader.split(" "); + _.set(rrr.http.request, "headers.authorization", authType[0]); + _.set(rrr.http.request, "headers['x-forwarded-for']", ""); + }, + }) + ); + + app.use( + "/documentation", + swaggerUi.serve, + swaggerUi.setup(publicSwaggerSpec, SWAGGER_UI_OPTIONS) + ); +} + +export default addSwaggerMiddlewares; diff --git a/backend/api/routes/users.ts b/backend/api/routes/users.ts index a2123cdfe..2203cb8f1 100644 --- a/backend/api/routes/users.ts +++ b/backend/api/routes/users.ts @@ -4,7 +4,7 @@ import { Router } from "express"; import UserController from "../controllers/user"; import { asyncHandler, validateRequest } from "../../middlewares/api-utils"; import * as RateLimit from "../../middlewares/rate-limit"; -import ApeRateLimit from "../../middlewares/ape-rate-limit"; +import apeRateLimit from "../../middlewares/ape-rate-limit"; import { isUsernameValid } from "../../utils/validation"; const router = Router(); @@ -207,7 +207,7 @@ router.get( authenticateRequest({ acceptApeKeys: true, }), - ApeRateLimit, + apeRateLimit, validateRequest({ query: { mode: joi.string().required(), diff --git a/backend/swagger.json b/backend/documentation/internal-swagger.json similarity index 100% rename from backend/swagger.json rename to backend/documentation/internal-swagger.json diff --git a/backend/documentation/public-swagger.json b/backend/documentation/public-swagger.json new file mode 100644 index 000000000..8a219f667 --- /dev/null +++ b/backend/documentation/public-swagger.json @@ -0,0 +1,257 @@ +{ + "swagger": "2.0", + "info": { + "description": "Documentation for the public endpoints provided by the Monketype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY` ", + "version": "1.0.0", + "title": "Monkeytype API", + "termsOfService": "https://monkeytype.com/terms-of-service", + "contact": { + "name": "Developer", + "email": "jack@monkeytype.com" + } + }, + "basePath": "/", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": [ + { + "name": "users", + "description": "User data and related operations" + }, + { + "name": "leaderboards", + "description": "Leaderboard data and related operations" + } + ], + "paths": { + "/users/personalBests": { + "get": { + "tags": ["users"], + "summary": "Gets a user's personal best data", + "parameters": [ + { + "name": "mode", + "in": "query", + "description": "The primary mode (i.e., time)", + "required": true, + "type": "string" + }, + { + "name": "mode2", + "in": "query", + "description": "The secondary mode (i.e., 60)", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/PersonalBest" + } + } + } + } + }, + "/leaderboards": { + "get": { + "tags": ["leaderboards"], + "summary": "Gets global leaderboard data", + "parameters": [ + { + "name": "language", + "in": "query", + "description": "The leaderboard's language (i.e., english)", + "required": true, + "type": "string" + }, + { + "name": "mode", + "in": "query", + "description": "The primary mode (i.e., time)", + "required": true, + "type": "string" + }, + { + "name": "mode2", + "in": "query", + "description": "The secondary mode (i.e., 60)", + "required": true, + "type": "string" + }, + { + "name": "skip", + "in": "query", + "description": "How many leaderboard entries to skip", + "required": false, + "type": "number", + "minimum": 0 + }, + { + "name": "limit", + "in": "query", + "description": "How many leaderboard entries to request", + "required": false, + "type": "number", + "minimum": 0, + "maximum": 50 + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/LeaderboardEntry" + } + } + } + } + }, + "/leaderboards/rank": { + "get": { + "tags": ["leaderboards"], + "summary": "Gets your qualifying rank from a leaderboard", + "parameters": [ + { + "name": "language", + "in": "query", + "description": "The leaderboard's language (i.e., english)", + "required": true, + "type": "string" + }, + { + "name": "mode", + "in": "query", + "description": "The primary mode (i.e., time)", + "required": true, + "type": "string" + }, + { + "name": "mode2", + "in": "query", + "description": "The secondary mode (i.e., 60)", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/LeaderboardEntry" + } + } + } + } + } + }, + "definitions": { + "Response": { + "type": "object", + "required": ["message", "data"], + "properties": { + "message": { + "type": "string" + }, + "data": { + "type": "object" + } + } + }, + "PersonalBest": { + "type": "object", + "properties": { + "acc": { + "type": "number", + "format": "double", + "example": 94.44 + }, + "consistency": { + "type": "number", + "format": "double", + "example": 75.98 + }, + "difficulty": { + "type": "string", + "example": "normal" + }, + "lazyMode": { + "type": "boolean", + "example": false + }, + "language": { + "type": "string", + "example": "english" + }, + "punctuation": { + "type": "boolean", + "example": false + }, + "raw": { + "type": "number", + "format": "double", + "example": 116.6 + }, + "wpm": { + "type": "number", + "format": "double", + "example": 107.6 + }, + "timestamp": { + "type": "integer", + "example": 1644438189583 + } + } + }, + "LeaderboardEntry": { + "type": "object", + "properties": { + "_id": { + "type": "string", + "example": "6226b17aebc27a4a8d1ce04b" + }, + "acc": { + "type": "number", + "format": "double", + "example": 97.96 + }, + "consistency": { + "type": "number", + "format": "double", + "example": 83.29 + }, + "lazyMode": { + "type": "boolean", + "example": false + }, + "name": { + "type": "string", + "example": "Miodec" + }, + "punctuation": { + "type": "boolean", + "example": false + }, + "rank": { + "type": "integer", + "example": 3506 + }, + "raw": { + "type": "number", + "format": "double", + "example": 145.18 + }, + "wpm": { + "type": "number", + "format": "double", + "example": 141.18 + }, + "timestamp": { + "type": "integer", + "example": 1644438189583 + } + } + } + } +} diff --git a/backend/package-lock.json b/backend/package-lock.json index 3fcddc950..88f8b27ff 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "simple-git": "2.45.1", "string-similarity": "4.0.4", "swagger-stats": "0.99.2", + "swagger-ui-express": "4.3.0", "ua-parser-js": "0.7.28", "uuid": "8.3.2" }, @@ -39,6 +40,7 @@ "@types/node": "17.0.18", "@types/node-fetch": "2.6.1", "@types/swagger-stats": "0.95.4", + "@types/swagger-ui-express": "4.1.3", "@types/ua-parser-js": "0.7.36", "@types/uuid": "8.3.4" }, @@ -796,6 +798,16 @@ "prom-client": ">=11.5.3" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz", + "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/ua-parser-js": { "version": "0.7.36", "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", @@ -4474,6 +4486,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.6.1.tgz", + "integrity": "sha512-GyP8Hx9qGs7cN6gIK5rTG/NX9CmDDHjq1wzIYlRyJVWZir/D5xarkAroZDYTf4j13ontCQSUZ4Jw83XQoVbB+g==" + }, + "node_modules/swagger-ui-express": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", + "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", + "dependencies": { + "swagger-ui-dist": ">=4.1.3" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0" + } + }, "node_modules/tar": { "version": "6.1.11", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", @@ -5606,6 +5637,16 @@ "prom-client": ">=11.5.3" } }, + "@types/swagger-ui-express": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.3.tgz", + "integrity": "sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "@types/ua-parser-js": { "version": "0.7.36", "resolved": "https://registry.npmjs.org/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz", @@ -8449,6 +8490,19 @@ } } }, + "swagger-ui-dist": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-4.6.1.tgz", + "integrity": "sha512-GyP8Hx9qGs7cN6gIK5rTG/NX9CmDDHjq1wzIYlRyJVWZir/D5xarkAroZDYTf4j13ontCQSUZ4Jw83XQoVbB+g==" + }, + "swagger-ui-express": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.3.0.tgz", + "integrity": "sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==", + "requires": { + "swagger-ui-dist": ">=4.1.3" + } + }, "tar": { "version": "6.1.11", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", diff --git a/backend/package.json b/backend/package.json index 2cd8dcfb7..0227184a8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -35,6 +35,7 @@ "simple-git": "2.45.1", "string-similarity": "4.0.4", "swagger-stats": "0.99.2", + "swagger-ui-express": "4.3.0", "ua-parser-js": "0.7.28", "uuid": "8.3.2" }, @@ -46,6 +47,7 @@ "@types/node": "17.0.18", "@types/node-fetch": "2.6.1", "@types/swagger-stats": "0.95.4", + "@types/swagger-ui-express": "4.1.3", "@types/ua-parser-js": "0.7.36", "@types/uuid": "8.3.4" } diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 8a2115bb4..980d516db 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -13,5 +13,5 @@ "esModuleInterop": true, "strictNullChecks": true }, - "exclude": ["node_modules", "build"] + "exclude": ["node_modules", "build", "worker.js"] } diff --git a/frontend/static/index.html b/frontend/static/index.html index 58274b625..b843135f5 100644 --- a/frontend/static/index.html +++ b/frontend/static/index.html @@ -4159,10 +4159,18 @@ - +

reset settings