feat: embrace full REST API and OpenAPI+Swagger
This commit is contained in:
139
package-lock.json
generated
139
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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