diff --git a/package-lock.json b/package-lock.json index 395841c5..7561ac3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "ISC", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.0.0", "@prisma/client": "^5.12.1", "@prisma/extension-accelerate": "^1.0.0", "@types/jsonwebtoken": "^9.0.5", @@ -22,6 +23,7 @@ "jose": "^5.1.1", "resend": "^2.0.0", "simple-statistics": "^7.8.3", + "swagger-ui-express": "^5.0.0", "zod": "^3.22.4", "zod-validation-error": "^2.1.0" }, @@ -34,6 +36,17 @@ "typescript": "^5.2.2" } }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.0.0.tgz", + "integrity": "sha512-rJRKHD2m6nUb/9ZheeN8nqOURX24WTzY8Sex1ZKT0Kpx+xfpRcD0fTD6vEeXNHGaDGxzu65Jj/jb2x6nLTjcMw==", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1640,6 +1653,14 @@ "wrappy": "1" } }, + "node_modules/openapi3-ts": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.3.1.tgz", + "integrity": "sha512-ha/kTOLhMQL7MvS9Abu/cpCXx5qwHQ++88YkUzn1CGfmM8JvCOG/4ZE6tRsexgXRFaoJrcwLyf81H2Y/CXALtA==", + "dependencies": { + "yaml": "^2.4.1" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -2258,6 +2279,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.6", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.6.tgz", + "integrity": "sha512-8P48+WvFKDF7YoDqmWq3EItwdOh7tJlPSZ7y6CNqQIPMQ+qZVI0iNlBMSzyU+PXOd1M8ndRiNKWOvfItREBvHg==" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.0.tgz", + "integrity": "sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2554,6 +2594,17 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/yaml": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", + "integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index 535a7c0b..73d3ec56 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "author": "", "license": "ISC", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.0.0", "@prisma/client": "^5.12.1", "@prisma/extension-accelerate": "^1.0.0", "@types/jsonwebtoken": "^9.0.5", @@ -27,6 +28,7 @@ "jose": "^5.1.1", "resend": "^2.0.0", "simple-statistics": "^7.8.3", + "swagger-ui-express": "^5.0.0", "zod": "^3.22.4", "zod-validation-error": "^2.1.0" }, diff --git a/src/index.ts b/src/index.ts index 12d290d5..340c540b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,23 @@ import { scouterScoutReports } from "./handler/analysis/scoutingLead/scouterScou import { pitDisplay } from "./handler/manager/pitDisplay"; import { addTournamentMatchesOneTime } from "./handler/manager/addTournamentMatchesOneTime"; import { getCSV } from "./handler/manager/getCSV"; +import swaggerUi from 'swagger-ui-express'; +import { generateOpenAPI } from "./lib/swagger"; +import swaggerValidationMiddleware from "./lib/middleware/swaggerMiddleware"; +import { z } from "zod"; + +declare global { + namespace Express { + interface Request { + swaggerDoc: any + openapi: any + clientInfo: any + auth?: { + }, + apiUser: any + } + } +} const resendEmailLimiter = rateLimit({ windowMs: 2 * 60 * 1000, @@ -105,6 +122,47 @@ const port = process.env.PORT || 3000; app.use(bodyParser.json()); +app.use( + '/hello', + swaggerValidationMiddleware({ + path: '/hello', + description: 'Test', + summary: 'Test', + tags: ['Test'], + method: 'get', + validateInput: true, + validateOutput: true, + response200: { + description: 'Test', + schema: z.object({ + message: z.string() + }) + }, + }) + .handle(async (req, res) => { + return res.send({ message: 'Hello, world!' }) + }) +) + +app.use('/swagger.json', (req, res) => { + // const settings = getAuthObj(req.query.url) + + return res.json(generateOpenAPI({})) +}) + + +app.use('/hello', (req, res) => { + return res.send('Hello, world!') +}) +app.use('/swagger', swaggerUi.serve, +(req, res, next) => { + + const options = {} + + req.swaggerDoc = generateOpenAPI({}) + swaggerUi.setup(req.swaggerDoc, options)(req, res, next) +}) + //general endpoints app.get('/v1/manager/tournament/:tournament/teams', requireAuth, getTeamsInTournament) @@ -242,6 +300,8 @@ app.get('/v1/analysis/csvplain', requireAuth, getCSV) // tested -getTBAData(); +// getTBAData(); -app.listen(port); +app.listen(port, () => { + console.log(`Server is running on port ${port}`); +}) diff --git a/src/lib/middleware/swaggerMiddleware.ts b/src/lib/middleware/swaggerMiddleware.ts new file mode 100644 index 00000000..06da335f --- /dev/null +++ b/src/lib/middleware/swaggerMiddleware.ts @@ -0,0 +1,207 @@ +import { Response, NextFunction, Request } from 'express' +import { AnyZodObject, z, ZodError, ZodType } from 'zod' +import { RouteConfig, OpenAPIRegistry, ResponseConfig, ZodRequestBody } from '@asteasolutions/zod-to-openapi' +import { registerRoute } from '../swagger' + +const supportedInputTypes = ['params', 'query', 'body', 'headers'] as const +type SupportedInputTypes = typeof supportedInputTypes[number] +import { RouteParameter } from '@asteasolutions/zod-to-openapi/dist/openapi-registry' + +type ErrorStructure = { + type: SupportedInputTypes | 'response' + error: ZodError +} + +type ResponseType = { + description: string, + schema: ZodType, +} +type CustomRouteConfig = { + method: RouteConfig['method'], + path: RouteConfig['path'], + tags: RouteConfig['tags'], + summary: RouteConfig['summary'], + description: RouteConfig['description'], + params?: AnyZodObject, + body?: ResponseType, + query?: RouteParameter, + validateInput: boolean, + validateOutput: boolean, + disableSwagger?: boolean, + response200?: ResponseType, + response202?: ResponseType, + response404?: ResponseType, + response400?: ResponseType, +} + +const validateInput = (routeConfig: CustomRouteConfig, req: Request) => { + if (routeConfig.params) { + try { + Object.assign(req.params, routeConfig.params.parse(req.params)) + } catch (error) { + throw new Error(JSON.stringify({ type: 'params', error } as ErrorStructure)) + } + } + + if (routeConfig.query) { + try { + Object.assign(req.query, routeConfig.query.parse(req.query)) + } catch (error) { + throw new Error(JSON.stringify({ type: 'query', error } as ErrorStructure)) + } + } + + if (routeConfig.body) { + try { + Object.assign(req.body, routeConfig.body.schema.parse(req.body)) + } catch (error) { + throw new Error(JSON.stringify({ type: 'body', error } as ErrorStructure)) + } + } +} + +function getRouteResponseType(obj: any, key: string): ResponseType | undefined { + if (obj[key]) { + return obj[key] as ResponseType + } + + return undefined +} + +const validateOutput = (routeConfig: CustomRouteConfig, res: Response) => { + const send = res.send + + res.send = function (body) { + // Override once! + res.send = send + + const config = getRouteResponseType(routeConfig, 'response' + res.statusCode) + + if (!config){ + return res.send('No response schema for status code: ' + res.statusCode) + } + + const result = config.schema.safeParse(body) + + if (result.success) { + return res.send(body) + } + + return res.status(422).send({ + response: (result as any).error.issues + }) + } +} +const validationFunction = (routeConfig: CustomRouteConfig) => (req: Request, res: Response, next: NextFunction) => { + try { + if (routeConfig.validateInput) { + validateInput(routeConfig, req) + } + + if (routeConfig.validateOutput) { + validateOutput(routeConfig, res) + } + + return next() + } catch (error) { + const errorObj = JSON.parse((error as any).message) as ErrorStructure + return res.status(422).send({ + [errorObj.type]: errorObj.error.issues + }) + } +} + +const ValidationError = z.array( + z.object({ + code: z.string(), + message: z.string(), + expected: z.string().optional(), + received: z.string().optional(), + path: z.array(z.string()), + }) +) + +// Define the type for the function parameter +type MyFunctionType< + Params = any, + Body = any, + Query = any, + ResponseTypes = any +> = ( + req: Request, + res: Response +) => any; + +type Protect = T extends undefined ? undefined : T + +type ResponseTypeRegistration = undefined | { + [statusCode: number]: ResponseConfig +} + +const registerResponse = (code: number, response?: ResponseType): ResponseTypeRegistration => { + if (!response) { + return undefined + } + + return { + [code]: { + description: response.description, + content: { + 'application/json': { + schema: response.schema, + }, + }, + }, + } +} + +const swaggerValidationMiddleware = (config: T) => { + const allResponses = [ + registerResponse(200, config.response200), + registerResponse(202, config.response202), + registerResponse(404, config.response404), + registerResponse(400, config.response400), + ].filter(Boolean) + + if (config.disableSwagger !== true) { + registerRoute({ + method: config.method, + path: config.path, + tags: config.tags, + summary: config.summary, + description: config.description, + request: { + params: config.params, + body: config.body ? { + description: config.body.description, + content: { + "application/json": { + schema: config.body.schema, + } + } + } : undefined, + query: config.query, + }, + responses: Object.assign({}, ...allResponses) as any + }) + } + + return { + handle: ( + callback: MyFunctionType< + Protect>>, + Protect['schema']>>, + Protect>>, + any + > + // TODO: Might be able to type this better + ): any[] => { + return [ + validationFunction(config), + callback + ] + } + } +} + +export default swaggerValidationMiddleware \ No newline at end of file diff --git a/src/lib/swagger/index.ts b/src/lib/swagger/index.ts new file mode 100644 index 00000000..f5494b59 --- /dev/null +++ b/src/lib/swagger/index.ts @@ -0,0 +1,89 @@ +import { + OpenAPIRegistry, + OpenApiGeneratorV3, + extendZodWithOpenApi, + RouteConfig, +} from '@asteasolutions/zod-to-openapi' + +import { + OpenAPIObjectConfig +} from '@asteasolutions/zod-to-openapi/dist/v3.0/openapi-generator' + + +import { z } from 'zod' + +extendZodWithOpenApi(z) + +type AuthObj = { +} + +type RouteConfigWithValidation = RouteConfig & { + protectedFn?: (auth: Partial) => boolean | Promise, +} + +const registry = new OpenAPIRegistry() + +const allRoutes: RouteConfigWithValidation[] = [] +const allComponents: Record = {} + +export const getRegistry = () => registry + +export function generateOpenAPI(auth: Partial) { + const newRegistry = new OpenAPIRegistry() + + // const userIdKey = newRegistry.registerComponent( + // 'securitySchemes', + // 'auth', + // { + // type: 'apiKey', + // in: 'header', + // name: 'x-user-id', + // } + // ) + + const config: OpenAPIObjectConfig = { + openapi: '3.0.3', + info: { + version: '1.0.0', + title: 'Lovat API', + description: 'This is an API specification for the Lovat API', + contact: { + name: 'TODO', + } + }, + servers: [ + { + url: process.env.SERVER_URL as string, + }, + ], + security: [ + { + // [userIdKey.name]: [], + }, + ] + } + + for (const route of allRoutes) { + const shouldShow = route?.protectedFn?.(auth ?? {}) ?? true + + if (!shouldShow) { + continue + } + newRegistry.registerPath(route) + } + + const document = new OpenApiGeneratorV3(newRegistry.definitions).generateDocument(config) + + return document +} + +// Swagger will be dynamically generated. +export const registerRoute = (routeConfig: RouteConfigWithValidation) => { + allRoutes.push(routeConfig) + return routeConfig +} + +export const registerParameter = (refId: string, zodSchema: T) => { + allComponents[`components/parameter/${refId}`] = zodSchema + return zodSchema +}