Skip to content

Commit

Permalink
Add new docs and specification middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
Timothy-Gonzalez committed Sep 13, 2024
1 parent 2559ca3 commit 22a85ad
Show file tree
Hide file tree
Showing 8 changed files with 249 additions and 5 deletions.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"author": "Aydan Pirani <aydanpirani@gmail.com>",
"license": "ISC",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.1.1",
"@aws-sdk/client-s3": "^3.496.0",
"@aws-sdk/s3-presigned-post": "^3.499.0",
"@aws-sdk/s3-request-presigner": "^3.496.0",
Expand All @@ -44,8 +45,10 @@
"passport-google-oauth20": "^2.0.0",
"passport-strategy": "^1.0.0",
"status-code-enum": "^1.0.0",
"swagger-ui-express": "^5.0.1",
"tsc-watch": "^6.2.0",
"uuid": "^9.0.1"
"uuid": "^9.0.1",
"zod": "^3.23.8"
},
"repository": "git@github.com:HackIllinois/adonix.git",
"devDependencies": {
Expand All @@ -65,6 +68,7 @@
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-strategy": "^0.2.35",
"@types/supertest": "^2.0.14",
"@types/swagger-ui-express": "^4.1.6",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"eslint": "^8.46.0",
Expand Down
18 changes: 17 additions & 1 deletion src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ describe("sanity tests for app", () => {
it("should run", async () => {
const response = await get("/").expect(StatusCode.SuccessOK);

expect(response.text).toBe("API is working!!!");
expect(JSON.parse(response.text)).toMatchObject({
ok: true,
info: "Welcome to HackIllinois' backend API!",
docs: expect.stringMatching(/\/docs\/$/),
});
});

it("should generate valid API specification", async () => {
await get("/docs/").expect(StatusCode.SuccessOK);
const response = await get("/docs/json/").expect(StatusCode.SuccessOK);

expect(JSON.parse(response.text)).toHaveProperty(
"info",
expect.objectContaining({
title: "adonix",
}),
);
});
});
23 changes: 22 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import "./types";
import morgan from "morgan";
import express, { Request, Response } from "express";
import swaggerUi from "swagger-ui-express";

import admissionRouter from "./services/admission/admission-router";
import authRouter from "./services/auth/auth-router";
Expand All @@ -23,6 +25,7 @@ import { StatusCode } from "status-code-enum";
import Config from "./config";
import database from "./middleware/database";
import corsSelector from "./middleware/cors-selector";
import { getOpenAPISpec } from "./openapi";

const app = express();

Expand All @@ -36,6 +39,8 @@ if (!Config.TEST) {

// Automatically convert requests from json
app.use(express.json());
// eslint-disable-next-line no-magic-numbers
app.set("json spaces", 4);

// Add routers for each sub-service
// NOTE: only include database middleware if needed
Expand All @@ -55,9 +60,25 @@ app.use("/staff/", database, staffRouter);
app.use("/version/", versionRouter);
app.use("/user/", database, userRouter);

// Docs
app.use("/docs/json", (_req, res) => res.json(getOpenAPISpec()));
app.use(
"/docs",
swaggerUi.serveFiles(undefined, {
swaggerUrl: `${Config.ROOT_URL}/docs/json`,
}),
swaggerUi.setup(undefined, {
swaggerUrl: `${Config.ROOT_URL}/docs/json`,
}),
);

// Ensure that API is running
app.get("/", (_: Request, res: Response) => {
res.end("API is working!!!");
res.json({
ok: true,
info: "Welcome to HackIllinois' backend API!",
docs: `${Config.ROOT_URL}/docs/`,
});
});

// Throw an error if call is made to the wrong API endpoint
Expand Down
8 changes: 6 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,16 @@ function requireEnv(name: string): string {
return value;
}

const PORT = env.PORT ? parseInt(env.PORT) : 3000;

const Config = {
/* Jest */
/* Environments */
TEST: false, // False by default, will be mocked over
PROD: env.PROD ? true : false,

/* URLs */
PORT: env.PORT ? parseInt(env.PORT) : 3000,
PORT,
ROOT_URL: env.PROD ? "https://adonix.hackillinois.org" : `http://localhost:${PORT}`,

DEFAULT_DEVICE: Device.WEB,

Expand Down
77 changes: 77 additions & 0 deletions src/middleware/specification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { RequestHandler } from "express";
import { AnyZodObject, z } from "zod";
import StatusCode from "status-code-enum";
import { Response, Request, NextFunction } from "express";
import { registerPathSpecification } from "../openapi";
import { RouteConfig } from "@asteasolutions/zod-to-openapi";

export type Method = RouteConfig["method"];

export enum Tag {
ADMISSION = "Admission",
AUTH = "Auth",
EVENT = "Event",
MAIL = "Mail",
MENTOR = "Mentor",
NEWSLETTER = "Newsletter",
NOTIFICATION = "Notification",
PROFILE = "Profile",
PUZZLE = "Puzzle",
REGISTRATION = "Registration",
S3 = "S3",
SHOP = "Shop",
STAFF = "Staff",
VERSION = "Version",
}

export interface ResponseObject {
description: string;
schema: AnyZodObject;
}
export interface ResponsesObject {
[statusCode: string]: ResponseObject;
}

export interface Specification<Params = AnyZodObject, Responses = ResponsesObject, Body = AnyZodObject> {
path: string;
method: Method;
tag: Tag;
summary: string;
parameters?: Params;
body?: Body;
responses: Responses;
}

// Utility types to convert Responses into a set of possible schemas
type InferResponseBody<T> = T extends ResponseObject ? z.infer<T["schema"]> : never;
type ResponseBody<T extends ResponsesObject> = InferResponseBody<T[keyof T]>;

export default function specification<Params extends AnyZodObject, Responses extends ResponsesObject, Body extends AnyZodObject>(
spec: Specification<Params, Responses, Body>,
): RequestHandler<z.infer<Params>, ResponseBody<Responses>, z.infer<Body>> {
registerPathSpecification(spec);

return async (req: Request, res: Response, next: NextFunction) => {
if (spec.parameters) {
const result = await spec.parameters.safeParseAsync(req.params);
if (!result.success) {
res.status(StatusCode.ClientErrorBadRequest).json({
error: "BadRequest",
message: "Bad request made - invalid parameters format",
validationErrors: result.error.errors,
});
}
}
if (spec.body) {
const result = await spec.body.safeParseAsync(req.body);
if (!result.success) {
res.status(StatusCode.ClientErrorBadRequest).json({
error: "BadRequest",
message: "Bad request made - invalid body format",
validationErrors: result.error.errors,
});
}
}
next();
};
}
74 changes: 74 additions & 0 deletions src/openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { OpenApiGeneratorV31, OpenAPIRegistry, RouteConfig } from "@asteasolutions/zod-to-openapi";
import { AnyZodObject } from "zod";
import type { OpenAPIObject } from "openapi3-ts/oas31";
import Config from "./config";
import { ResponsesObject, Specification } from "./middleware/specification";

let openAPISpec: OpenAPIObject | undefined = undefined;
export const Registry = new OpenAPIRegistry();

function generateOpenAPISpec(): OpenAPIObject {
const generator = new OpenApiGeneratorV31(Registry.definitions);
return generator.generateDocument({
info: {
title: "adonix",
version: "1.0.0",
description: "HackIllinois' backend API",
},
openapi: "3.1.0",
servers: [
{
url: Config.ROOT_URL,
description: Config.PROD ? "Production" : "Local",
},
],
});
}

export function getOpenAPISpec(): OpenAPIObject {
if (!openAPISpec) {
openAPISpec = generateOpenAPISpec();
}

return openAPISpec;
}

export function registerPathSpecification<
Params extends AnyZodObject,
Responses extends ResponsesObject,
Body extends AnyZodObject,
>(specification: Specification<Params, Responses, Body>): void {
const { method, path, summary, parameters: params, tag } = specification;

const responses: RouteConfig["responses"] = {};
for (const [statusCode, response] of Object.entries(specification.responses)) {
responses[statusCode] = {
description: response.description,
content: {
"application/json": {
schema: response.schema,
},
},
};
}

const request: RouteConfig["request"] = { params };
if (specification.body) {
request.body = {
content: {
"application/json": {
schema: specification.body,
},
},
};
}

Registry.registerPath({
method,
path,
responses,
request,
summary,
tags: [tag],
});
}
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";

extendZodWithOpenApi(z);
44 changes: 44 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
"@jridgewell/gen-mapping" "^0.3.0"
"@jridgewell/trace-mapping" "^0.3.9"

"@asteasolutions/zod-to-openapi@^7.1.1":
version "7.1.1"
resolved "https://registry.yarnpkg.com/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.1.1.tgz#27784396d9f854db975a3b784095efef25258422"
integrity sha512-lF0d1gAc0lYLO9/BAGivwTwE2Sh9h6CHuDcbk5KnGBfIuAsAkDC+Fdat4dkQY3CS/zUWKHRmFEma0B7X132Ymw==
dependencies:
openapi3-ts "^4.1.2"

"@aws-crypto/crc32@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-3.0.0.tgz#07300eca214409c33e3ff769cd5697b57fdd38fa"
Expand Down Expand Up @@ -2604,6 +2611,14 @@
dependencies:
"@types/superagent" "*"

"@types/swagger-ui-express@^4.1.6":
version "4.1.6"
resolved "https://registry.yarnpkg.com/@types/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz#d0929e3fabac1a96a8a9c6c7ee8d42362c5cdf48"
integrity sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==
dependencies:
"@types/express" "*"
"@types/serve-static" "*"

"@types/tough-cookie@*":
version "4.0.5"
resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304"
Expand Down Expand Up @@ -5810,6 +5825,13 @@ open@^9.1.0:
is-inside-container "^1.0.0"
is-wsl "^2.2.0"

openapi3-ts@^4.1.2:
version "4.4.0"
resolved "https://registry.yarnpkg.com/openapi3-ts/-/openapi3-ts-4.4.0.tgz#eff29958e601deec24459ea811989a4fb59d4116"
integrity sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==
dependencies:
yaml "^2.5.0"

optionator@^0.9.3:
version "0.9.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64"
Expand Down Expand Up @@ -6710,6 +6732,18 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==

swagger-ui-dist@>=5.0.0:
version "5.17.14"
resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz#e2c222e5bf9e15ccf80ec4bc08b4aaac09792fd6"
integrity sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==

swagger-ui-express@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz#fb8c1b781d2793a6bd2f8a205a3f4bd6fa020dd8"
integrity sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==
dependencies:
swagger-ui-dist ">=5.0.0"

swc_mut_cjs_exports@^0.99.0:
version "0.99.0"
resolved "https://registry.yarnpkg.com/swc_mut_cjs_exports/-/swc_mut_cjs_exports-0.99.0.tgz#0588cf47ccb21b115b13c573ab3513c5e2b08cd1"
Expand Down Expand Up @@ -7118,6 +7152,11 @@ yallist@^4.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==

yaml@^2.5.0:
version "2.5.1"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.5.1.tgz#c9772aacf62cb7494a95b0c4f1fb065b563db130"
integrity sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==

yargs-parser@^21.0.1, yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
Expand Down Expand Up @@ -7153,3 +7192,8 @@ yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==

zod@^3.23.8:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==

0 comments on commit 22a85ad

Please sign in to comment.