feat: embrace full REST API and OpenAPI+Swagger
This commit is contained in:
@@ -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
58
src/server/db/post-log.ts
Normal 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
13
src/server/http-error.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
16
src/server/install-cors.ts
Normal file
16
src/server/install-cors.ts
Normal 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));
|
||||
}
|
||||
29
src/server/install-meta.ts
Normal file
29
src/server/install-meta.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user