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

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);