feat: embrace full REST API and OpenAPI+Swagger

This commit is contained in:
mattia
2025-01-06 16:36:53 +01:00
parent f9c61bae6f
commit 79e4a4c012
10 changed files with 507 additions and 119 deletions

139
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "game-logger",
"version": "1.0.0",
"version": "0.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "game-logger",
"version": "1.0.0",
"version": "0.0.2",
"license": "ISC",
"dependencies": {
"@ts-rest/core": "^3.51.0",
@@ -21,6 +21,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@anatine/zod-openapi": "^2.2.6",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
@@ -28,6 +29,7 @@
"@mui/material": "^6.3.1",
"@mui/x-date-pickers": "^7.23.3",
"@tanstack/react-query": "^4.0.0",
"@ts-rest/open-api": "^3.51.0",
"@ts-rest/react-query": "^3.51.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.0.0",
@@ -35,13 +37,16 @@
"@types/node": "^22.10.5",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/swagger-ui-express": "^4.1.7",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"date-fns": "^4.1.0",
"material-react-table": "^3.1.0",
"openapi3-ts": "^4.4.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router": "^7.1.1",
"swagger-ui-express": "^5.0.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"
@@ -72,6 +77,20 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@anatine/zod-openapi": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@anatine/zod-openapi/-/zod-openapi-2.2.6.tgz",
"integrity": "sha512-Z5sr2Nq2xifEpPbPdUcvyl776LY652oR3VHMV++WFSmRrRL8RDP2XTkbuGn+vgfVNOD7UrndYwCWnxaiw7IZog==",
"dev": true,
"license": "MIT",
"dependencies": {
"ts-deepmerge": "^6.0.3"
},
"peerDependencies": {
"openapi3-ts": "^4.1.2",
"zod": "^3.20.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -1666,6 +1685,14 @@
"win32"
]
},
"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==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@tanstack/match-sorter-utils": {
"version": "8.19.4",
"resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz",
@@ -1820,6 +1847,55 @@
}
}
},
"node_modules/@ts-rest/open-api": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/@ts-rest/open-api/-/open-api-3.51.0.tgz",
"integrity": "sha512-fvpvRr6HIbAMNZR//QQQi75z5qTxMEBMRtmbaBXVi5e1WVVwOK7P6YBaGWTQp6DXSvsZVULX5VZXmsDd1Z1dew==",
"dev": true,
"license": "MIT",
"dependencies": {
"@anatine/zod-openapi": "^1.12.0",
"openapi3-ts": "^2.0.2"
},
"peerDependencies": {
"@ts-rest/core": "~3.51.0",
"zod": "^3.22.3"
}
},
"node_modules/@ts-rest/open-api/node_modules/@anatine/zod-openapi": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/@anatine/zod-openapi/-/zod-openapi-1.14.2.tgz",
"integrity": "sha512-q0qHfnuNYVKu0Swrnnvfj9971AEyW7c8v9jCOZGCl5ZbyGMNG4RPyJkRcMi/JC8CRfdOe0IDfNm1nNsi2avprg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ts-deepmerge": "^6.0.3"
},
"peerDependencies": {
"openapi3-ts": "^2.0.0 || ^3.0.0",
"zod": "^3.20.0"
}
},
"node_modules/@ts-rest/open-api/node_modules/openapi3-ts": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz",
"integrity": "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^1.10.2"
}
},
"node_modules/@ts-rest/open-api/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 6"
}
},
"node_modules/@ts-rest/react-query": {
"version": "3.51.0",
"resolved": "https://registry.npmjs.org/@ts-rest/react-query/-/react-query-3.51.0.tgz",
@@ -2112,6 +2188,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/swagger-ui-express": {
"version": "4.1.7",
"resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.7.tgz",
"integrity": "sha512-ovLM9dNincXkzH4YwyYpll75vhzPBlWx6La89wwvYH7mHjVpf0X0K/vR/aUM7SRxmr5tt9z7E5XJcjQ46q+S3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/serve-static": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz",
@@ -3892,6 +3979,16 @@
"wrappy": "1"
}
},
"node_modules/openapi3-ts": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.4.0.tgz",
"integrity": "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==",
"dev": true,
"license": "MIT",
"dependencies": {
"yaml": "^2.5.0"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -4714,6 +4811,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz",
"integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==",
"dev": true,
"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==",
"dev": true,
"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/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -4746,6 +4869,16 @@
"tree-kill": "cli.js"
}
},
"node_modules/ts-deepmerge": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.2.1.tgz",
"integrity": "sha512-8CYSLazCyj0DJDpPIxOFzJG46r93uh6EynYjuey+bxcLltBeqZL7DMfaE5ZPzZNFlav7wx+2TDa/mBl8gkTYzw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14.13.1"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
@@ -5114,8 +5247,6 @@
"integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},

View File

@@ -17,6 +17,7 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@anatine/zod-openapi": "^2.2.6",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
@@ -24,6 +25,7 @@
"@mui/material": "^6.3.1",
"@mui/x-date-pickers": "^7.23.3",
"@tanstack/react-query": "^4.0.0",
"@ts-rest/open-api": "^3.51.0",
"@ts-rest/react-query": "^3.51.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.0.0",
@@ -31,13 +33,16 @@
"@types/node": "^22.10.5",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/swagger-ui-express": "^4.1.7",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"date-fns": "^4.1.0",
"material-react-table": "^3.1.0",
"openapi3-ts": "^4.4.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router": "^7.1.1",
"swagger-ui-express": "^5.0.1",
"ts-node-dev": "^2.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.7"

View File

@@ -1,65 +1,227 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import { extendZodWithOpenApi } from "@anatine/zod-openapi";
import { OpenAPIObject } from "openapi3-ts/oas31";
////// INITIALIZATION
extendZodWithOpenApi(z);
const c = initContract();
//////
const ErrorSchema = z.object({
code: z.number().optional().openapi({
title: "Error code",
description: "Optional code to uniquely identify this error",
}),
message: z.string().openapi({
title: "Error message",
description: "Human readable message to describe the error",
}),
});
////// POST LOG
const SingleMetadataSchema = z.object({
key: z.string().openapi({
title: "Key",
description: "Name of this metadata property",
example: "attack-power",
}),
value: z.string().openapi({
title: "Value",
description: "Value of the metadata property",
example: "923",
}),
});
export type SingleMetadata = z.infer<typeof SingleMetadataSchema>;
const PostLogRequestParamsSchema = z.object({
gameName: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
saveGuid: z.string().uuid().optional().openapi({
title: "Save GUID",
description:
"A unique identifier for a whole game, which can be part of a multi-session game",
example: "11111111-2222-3333-4444-555555555555",
}),
sessionGuid: z.string().uuid().openapi({
title: "Session GUID",
description: "A unique identifier for the single play session",
example: "11111111-2222-3333-4444-555555555555",
}),
});
const PostLogRequestBodySchema = z.object({
message: z.string().openapi({
title: "Message",
description: "A human readable message that describes the log line",
example: "Clicked on the telephone",
}),
metadata: SingleMetadataSchema.array().openapi({
title: "Metadata",
description: "Extra data saved with the log message",
}),
});
////// GET SESSIONS
const GetSessionsResponseSchema = z.object({
total: z.number().positive(),
total: z.number().positive().openapi({
title: "Total logs",
description:
"The total number of logs satisfying the filters, regardless of pagination",
}),
sessions: z
.object({
id: z.string(),
save_id: z.string(),
game_name: z.string(),
version: z.string(),
start_time: z.date(),
end_time: z.date(),
num_events: z.number().positive(),
id: z.string().openapi({
title: "Session GUID",
description: "A unique identifier for the single play session",
example: "11111111-2222-3333-4444-555555555555",
}),
save_id: z.string().openapi({
title: "Save GUID",
description:
"A unique identifier for a whole game, which can be part of a multi-session game",
example: "11111111-2222-3333-4444-555555555555",
}),
game_name: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
start_time: z.date().openapi({
title: "Start Time",
description: "Time of the first log event for this session",
example: "2025-01-06T14:46:38.000Z",
}),
end_time: z.date().openapi({
title: "Start Time",
description: "Time of the last log event for this session",
example: "2025-01-06T14:46:38.000Z",
}),
num_events: z.number().positive().openapi({
title: "# Events",
description: "Number of events in this session",
example: "48",
}),
})
.array(),
});
export type GetSessionsResponse = z.infer<typeof GetSessionsResponseSchema>;
const GetSessionsRequestQuery = z.object({
skip: z.coerce.number().optional(),
count: z.coerce.number().optional(),
id: z.string().optional(),
const GetSessionsRequestQuerySchema = z.object({
skip: z.coerce.number().optional().openapi({
title: "Skip",
default:
"How many log events to skip from the beginning of this session (used for pagination)",
example: "30",
}),
count: z.coerce.number().optional().openapi({
title: "Count",
default: "How many log events to return at most (used for pagination)",
example: "10",
}),
id: z.string().optional().openapi({
title: "Session GUID",
description: "A filter for the session GUID",
example: "11111111-2222-3333-4444-555555555555",
}),
});
//////
////// GET SESSION
const GetSessionResponseSchema = z.object({
game_name: z.string(),
version: z.string(),
log_entries: z.object({
message: z.string(),
timestamp: z.date(),
metadata: z.record(z.string(), z.string())
}).array(),
game_name: z.string().openapi({
title: "Game name",
description: "Name of the game that generated this log",
example: "Asteroid",
}),
version: z.string().openapi({
title: "Version",
description: "Version of the game that generated this log",
example: "1.7.0",
}),
log_entries: z
.object({
message: z.string().openapi({
title: "Message",
description: "A human readable message that describes the log line",
example: "Clicked on the telephone",
}),
timestamp: z.date().openapi({
title: "Timestamp",
description: "Date/time when the event occoured",
example: "2025-01-06T14:46:38.000Z",
}),
metadata: z.record(z.string(), z.string()).openapi({
title: "Metadata",
description: "Extra data saved with the log message",
}),
})
.array(),
});
export type GetSessionResponse = z.infer<typeof GetSessionResponseSchema>;
//////
////// CONTRACT
export const contract = c.router({
getSessions: {
method: "GET",
path: "/api/get-sessions",
responses: {
200: GetSessionsResponseSchema,
export const contract = c.router(
{
postLog: {
method: "POST",
path: "/logs/:gameName/:version/:saveGuid?/:sessionGuid",
body: PostLogRequestBodySchema,
pathParams: PostLogRequestParamsSchema,
responses: {
204: c.noBody(),
422: ErrorSchema,
500: ErrorSchema,
},
summary: "post a new log",
},
getSessions: {
method: "GET",
path: "/get-sessions",
responses: {
200: GetSessionsResponseSchema,
},
query: GetSessionsRequestQuerySchema,
summary: "get all the sessions",
},
getSession: {
method: "GET",
path: "/get-session/:id",
responses: {
200: GetSessionResponseSchema,
},
summary: "get a session by id",
},
getOpenApi: {
method: "GET",
path: "/get-openapi",
responses: {
200: c.type<OpenAPIObject>(),
},
summary: "get the OpenAPI document for this API"
},
query: GetSessionsRequestQuery,
summary: "get all the sessions",
},
getSession: {
method: "GET",
path: "/api/get-session/:id",
responses: {
200: GetSessionResponseSchema,
},
summary: "get a session by id",
{
pathPrefix: "/api",
}
});
);

58
src/server/db/post-log.ts Normal file
View File

@@ -0,0 +1,58 @@
import { SingleMetadata } from "../contract";
import { HttpError } from "../http-error";
import { db } from "./init";
export async function postLog(
gameName: string,
version: string,
saveGuid: string | undefined,
sessionGuid: string,
message: string,
metadata: SingleMetadata[]
) {
if (
gameName === "" ||
sessionGuid === "" ||
message === "" ||
version === ""
) {
const errorMessage = `One of the necessary fields was missing: gameName=${gameName}, sessionGuid=${sessionGuid}, message=${message}, version=${version}`;
throw new HttpError(422, errorMessage);
} else {
await db.transaction().execute(async (trx) => {
// upsert the session info
await trx
.insertInto("session_info")
.ignore()
.values({
id: sessionGuid,
game_name: gameName,
save_id: saveGuid || "",
version,
})
.execute();
// add the main log line
const insertResult = await trx
.insertInto("log_entries")
.values({
session_info_id: sessionGuid,
message: message,
timestamp: new Date(),
})
.executeTakeFirstOrThrow();
// add all the metadata
if (metadata.length > 0) {
await trx
.insertInto("log_metadata")
.values(
metadata.map(({ key, value }) => ({
log_entry_id: Number(insertResult.insertId),
key,
value,
}))
)
.execute();
}
});
}
}

13
src/server/http-error.ts Normal file
View File

@@ -0,0 +1,13 @@
export class HttpError extends Error {
private _code: number;
constructor(code: number, msg: string) {
super(msg);
this._code = code;
Object.setPrototypeOf(this, HttpError);
}
public get code() {
return this._code;
}
}

View File

@@ -1,13 +1,17 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { registerPostMessage } from "./post-message";
import { installRouter } from "./router";
import path from "path";
import { installCors } from "./install-cors";
import { installMeta } from "./install-meta";
import { OpenAPIObject } from "openapi3-ts/oas31";
const app = express();
// enable cors
installCors(app);
// serve che client files
app.use(express.static("./dist/client"));
@@ -15,11 +19,11 @@ app.use(express.static("./dist/client"));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
// install the ts-rest router
installRouter(app);
// install the openapi/swagger/etc... metadata about the api
const openAPIObject = installMeta(app);
// register the POST in multipart to add messages
registerPostMessage(app);
// install the ts-rest router
installRouter(app, openAPIObject as unknown as OpenAPIObject /* required because of mixups in versioning betwee @ts-rest/open-api, @anatine/zop-openapi and openapi3-ts */);
// handle every other GET route with index.html
app.get("*", function (_req, res, next) {

View File

@@ -0,0 +1,16 @@
import cors, { CorsOptions } from "cors";
import { Express } from "express";
export const postCorsOptions: CorsOptions = {
origin: (process.env.HTTP_POST_ALLOWED_ORIGINS || "*").split(","),
methods: ["POST", "GET"],
};
export function installCors(app: Express) {
console.log(
`Installing CORS; enabled for origins ${
process.env.HTTP_POST_ALLOWED_ORIGINS
} and methods ${(postCorsOptions.methods as string[]).join(", ")}`
);
app.use(cors(postCorsOptions));
}

View File

@@ -0,0 +1,29 @@
import { Express } from "express";
import { generateOpenApi } from "@ts-rest/open-api";
import { contract } from "../common/contract";
import * as swaggerUi from "swagger-ui-express";
export function installMeta(app: Express) {
const openApiDocument = generateOpenApi(
contract,
{
info: {
title: "Game Logger API",
version: "0.1.0",
contact: {
email: "onefoxonewolf@gmail.com",
name: "owof games",
url: "https://owof.games",
},
description: "API to write and read logs from a game",
},
},
{
setOperationId: true,
}
);
app.use("/swagger-ui", swaggerUi.serve, swaggerUi.setup(openApiDocument));
return openApiDocument;
}

View File

@@ -1,70 +0,0 @@
import type { Express } from "express";
import multer from "multer";
import cors, { type CorsOptions } from "cors";
import { addEntry } from "./db";
const upload = multer();
const postCorsOptions: CorsOptions = {
origin: (process.env.HTTP_POST_ALLOWED_ORIGINS || "*").split(","),
methods: ["POST"],
};
export function registerPostMessage(app: Express) {
app.options("/", cors(postCorsOptions));
app.post("/", cors(postCorsOptions), (req, res) => {
console.log("\nReceived logging message:");
const metadata: [string, string][] = [];
let gameName: string = "";
let sessionGuid: string = "";
let message: string = "";
let version: string = "";
let saveGuid: string = "";
for (const key of Object.keys(req.body)) {
const value = req.body[key];
console.log(` ${key}: ${value}`);
switch (key) {
case "saveGuid":
saveGuid = value;
break;
case "gameName":
gameName = value;
break;
case "sessionGuid":
sessionGuid = value;
break;
case "message":
message = value;
break;
case "version":
version = value;
break;
default:
metadata.push([key, req.body[key]]);
break;
}
}
if (
gameName === "" ||
sessionGuid === "" ||
message === "" ||
version === ""
) {
res.status(422); // unprocessable entity: used when the body has correct syntax but wrong semantics
const errorMessage = `One of the necessary fields was missing: gameName=${gameName}, sessionGuid=${sessionGuid}, message=${message}, version=${version}`;
console.error(errorMessage);
res.send(errorMessage);
} else {
addEntry(saveGuid, sessionGuid, gameName, version, message, metadata)
.then(() => {
res.status(204); // no content
res.send("");
})
.catch((err) => {
console.error(err);
res.status(500); // internal server error
res.send("Internal server error");
});
}
});
}

View File

@@ -3,10 +3,44 @@ import type { Express } from "express";
import { contract } from "../common/contract";
import { getSessions } from "./db/get-sessions";
import { getSession } from "./db/get-session";
import { postLog } from "./db/post-log";
import { HttpError } from "./http-error";
import { OpenAPIObject } from "openapi3-ts/oas31";
export function installRouter(app: Express) {
export function installRouter(app: Express, openAPIObject: OpenAPIObject) {
const s = initServer();
const router = s.router(contract, {
postLog: async ({ params, body }) => {
try {
await postLog(
params.gameName,
params.version,
params.saveGuid,
params.sessionGuid,
body.message,
body.metadata
);
return {
status: 204,
};
} catch (e) {
if (e instanceof HttpError && e.code === 422) {
return {
status: 422,
body: {
message: e.message,
},
};
} else {
return {
status: 500,
body: {
message: String(e),
},
};
}
}
},
getSessions: async ({ query: { skip, count, id } }) => {
const body = await getSessions(skip, count, id);
return {
@@ -21,6 +55,12 @@ export function installRouter(app: Express) {
body,
};
},
getOpenApi: async () => {
return {
status: 200,
body: openAPIObject,
};
},
});
createExpressEndpoints(contract, router, app);