From 2cecad135e0502bd29d3eaa323a7d3c8733a54db Mon Sep 17 00:00:00 2001
From: Cody Tenney <53365073+c-wide@users.noreply.github.com>
Date: Fri, 7 Apr 2023 04:46:15 -0500
Subject: [PATCH] refactor: code overhaul, async/sync fix, better validation,
better dx & docs
* refactor: server start / config validation WIP
* refactor: serverStart & registerDefaultPaths
* chore: update acknowledgements
* refactor: async/sync fix & handle resource stop
* feat: validate registerResourcePath args
* refactor: path func result rework
* chore: update readme
---
README.md | 79 +++++++++++++++-------
src/config.ts | 46 +++++++++++++
src/getConfig.ts | 58 ----------------
src/index.ts | 25 ++++---
src/logger.ts | 29 +++++---
src/registerDefaultPaths.ts | 10 +++
src/registerResourcePath.ts | 131 ++++++++++++++++++++----------------
src/response.ts | 14 ++++
src/startServer.ts | 16 ++---
9 files changed, 239 insertions(+), 169 deletions(-)
create mode 100644 src/config.ts
delete mode 100644 src/getConfig.ts
create mode 100644 src/registerDefaultPaths.ts
diff --git a/README.md b/README.md
index 8d784ef..7670131 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,61 @@
-# Wide API
+
-## Description
+# 🌐 Wide API
-A resource for FiveM that provides an API to execute functions on the server.
+**A FiveM resource that provides an API to execute functions on the server.**
-### Getting Started
+
-1. Download & unpack the release files.
-2. Place the folder in your servers resources folder.
-3. Edit the config.json file if desired.
-4. Start the resource.
+## 📚 Table of Contents
-Note: All paths are prefixed with the name of the resource that registered it. (/wide-api/ensure-resource/)
+- [Getting Started](#-getting-started)
+- [Register Your Own Paths](#-register-your-own-paths)
+ - [Examples](#examples)
+- [Resource Restarter](#-resource-restarter)
+- [Handling Resource Restarts](#-handling-resource-restarts)
+- [Access Keys](#-access-keys)
+- [Caveats](#-caveats)
+- [Acknowledgements](#-acknowledgements)
-### Register your own paths
+## 🚀 Getting Started
-- This resource exposes one export:
+1. **Download** and unpack the release files.
+2. **Place** the folder in your server's `resources` folder.
+3. **Edit** the `config.json` file according to your preferences.
+4. **Start** the resource.
+## 🛠 Register Your Own Paths
+
+This resource exposes a single export. Your route handler can return any data you want or a custom ApiResponse. See [Caveats](#-caveats) below.
+
+```javascript
+registerResourcePath(path: string, handler: (queryParams: Record) => ApiResponse | unknown)
```
-registerResourcePath(path: string, handler: (queryParams: Record) => ApiResponse | void)
+
+### Examples
+
+- **Lua** (use the colon operator, not the dot operator)
+
+```lua
+exports["wide-api"]:registerResourcePath("yourPathName", function(queryParams)
+
+end)
+```
+
+- **JavaScript** (queryParams type is `Record`)
+
+```javascript
+globalThis.exports['wide-api'].registerResourcePath(
+ 'yourPathName',
+ (queryParams) => {},
+);
```
-### Resource restarter
+## 🔁 Resource Restarter
-- To use the 'ensure-resource' path, make sure it is enabled in the config.json file.
-- This path accepts one query parameter "resourceName". (/wide-api/ensure-resource?resourceName=baseevents/)
-- If using this path the resource will require the permission to use the "ensure", "start", and "stop" commands.
+- Enable the 'ensure-resource' path in the `config.json` file.
+- This path accepts one query parameter: `resourceName` (e.g. `/wide-api/ensure-resource?resourceName=baseevents/`).
+- If using this path, the resource will require permission to use the "ensure", "start", and "stop" commands.
```
add_ace resource.wide-api command.ensure allow
@@ -33,18 +63,19 @@ add_ace resource.wide-api command.start allow
add_ace resource.wide-api command.stop allow
```
-### Handling resource restarts
+## 🔄 Handling Resource Restarts
-- When the API starts listening the 'wide-api:startServer' command is triggered.
-- When creating your own paths you can listen for this event and re-register any paths you've created.
+- When the API starts listening, the 'wide-api:startServer' command is triggered.
+- To handle resource restarts, listen for this event and re-register any paths you've created.
-### Access Keys
+## 🔑 Access Keys
-- Access keys should be added in the config.json file.
+- Access keys should be added in the `config.json` file.
- If no access keys are provided, the server listens with unrestricted access.
-- If you do provide access keys then you must provide an access key in the 'x-api-key' header.
+- When access keys are specified, make sure to include an access key in the 'x-api-key' header for requests.
- Access keys should be added in the format: { description: string, key: string }
-### Acknowledgements
+## ⚠️ Caveats
-- [AvarianKnight](https://github.com/AvarianKnight) for the idea and rough draft.
+- All paths are prefixed with the name of the resource that registered it (e.g., /wide-api/ensure-resource/).
+- If your route handler does not return an object/table that matches the ApiResponse type, the API assumes a response code of 200 and provides your returned data on the "data" key of the API response. Check the [ApiResponse type](https://github.com/c-wide/wide-api/blob/acbee784552da106dc45106b058cb9cffde6d95b/src/response.ts#L25) for more details.
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..1a966b0
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,46 @@
+import jsonschema from 'jsonschema';
+import configSchema from '../config.schema.json';
+import type { LoggerLevels } from '~/logger';
+
+export type ResourceConfig = {
+ server: {
+ port: number;
+ enableCors: boolean;
+ accessKeys: Array<{ description: string; key: string }>;
+ };
+ defaultPaths: {
+ 'ensure-resource': boolean;
+ };
+ logger: {
+ level: LoggerLevels;
+ };
+ compareVersionOnStart: boolean;
+};
+
+export type ConfigValidationResponse =
+ | { status: 'success' }
+ | { status: 'error'; errors: Array };
+
+const resourceConfig: ResourceConfig = JSON.parse(
+ LoadResourceFile(GetCurrentResourceName(), 'config.json'),
+);
+
+export function validateConfig(): ConfigValidationResponse {
+ const validatorResponse = new jsonschema.Validator().validate(
+ resourceConfig,
+ configSchema,
+ );
+
+ if (!validatorResponse.valid) {
+ return {
+ status: 'error',
+ errors: validatorResponse.errors.map((error) => error.stack),
+ };
+ }
+
+ return { status: 'success' };
+}
+
+export function getConfig(): ResourceConfig {
+ return resourceConfig;
+}
diff --git a/src/getConfig.ts b/src/getConfig.ts
deleted file mode 100644
index 8ca7485..0000000
--- a/src/getConfig.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import jsonschema from 'jsonschema';
-import { Logger } from 'tslog';
-import configSchema from '../config.schema.json';
-
-export const LoggerLevel = {
- Debug: 'debug',
- Info: 'info',
- Warn: 'warn',
- Error: 'error',
-} as const;
-
-export type ServerConfig = {
- port: number;
- enableCors: boolean;
- accessKeys: Array<{ description: string; key: string }>;
-};
-
-export type ResourceConfig = {
- server: ServerConfig;
- defaultPaths: {
- 'ensure-resource': boolean;
- };
- logger: {
- level: typeof LoggerLevel[keyof typeof LoggerLevel];
- };
- compareVersionOnStart: boolean;
-};
-
-const resourceConfig: ResourceConfig = JSON.parse(
- LoadResourceFile(GetCurrentResourceName(), 'config.json'),
-);
-
-export function validateConfig(): boolean {
- const validatorResponse = new jsonschema.Validator().validate(
- resourceConfig,
- configSchema,
- );
-
- if (!validatorResponse.valid) {
- const logger = new Logger({
- prettyLogTemplate: '[{{dateIsoStr}}] [{{logLevelName}}] - ',
- });
-
- logger.fatal(
- `Invalid config.json detected. Errors: [${validatorResponse.errors.join(
- ', ',
- )}]`,
- );
-
- return false;
- }
-
- return true;
-}
-
-export function getConfig(): ResourceConfig {
- return resourceConfig;
-}
diff --git a/src/index.ts b/src/index.ts
index 67541a0..b29e870 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,24 +1,29 @@
-import { getConfig, validateConfig } from '~/getConfig';
+import { getConfig, validateConfig } from '~/config';
import { startServer } from '~/startServer';
-import { createEnsureResourcePath } from '~/ensureResource';
import { compareResourceVersion } from '~/versionChecker';
+import { logger, setLoggerMinLevel } from '~/logger';
on('onResourceStart', async (resourceName: string) => {
if (resourceName === GetCurrentResourceName()) {
- if (!validateConfig()) return;
+ const validationResult = validateConfig();
- const config = getConfig();
+ if (validationResult.status !== 'success') {
+ logger.fatal(
+ `Invalid config.json detected. Errors: [${validationResult.errors.join(
+ ', ',
+ )}]`,
+ );
- if (config.compareVersionOnStart) {
- await compareResourceVersion();
+ return;
}
- const pathArr: Array<() => void> = [];
+ const config = getConfig();
- if (config.defaultPaths['ensure-resource']) {
- pathArr.push(createEnsureResourcePath);
+ if (config.compareVersionOnStart) {
+ await compareResourceVersion();
}
- startServer(config.server, pathArr);
+ setLoggerMinLevel(config.logger.level);
+ startServer();
}
});
diff --git a/src/logger.ts b/src/logger.ts
index 73d5008..405f8b6 100644
--- a/src/logger.ts
+++ b/src/logger.ts
@@ -1,16 +1,25 @@
import { Logger } from 'tslog';
-import { getConfig, LoggerLevel } from '~/getConfig';
-const level = getConfig().logger.level;
+export const LoggerLevel = {
+ Debug: 'debug',
+ Info: 'info',
+ Warn: 'warn',
+ Error: 'error',
+} as const;
+
+export type LoggerLevels = typeof LoggerLevel[keyof typeof LoggerLevel];
+
+const LoggerLevelMap: Record = {
+ debug: 2,
+ info: 3,
+ warn: 4,
+ error: 5,
+};
export const logger = new Logger({
prettyLogTemplate: '[{{dateIsoStr}}] [{{logLevelName}}] - ',
- minLevel:
- level === LoggerLevel.Debug
- ? 2
- : level === LoggerLevel.Info
- ? 3
- : level === LoggerLevel.Warn
- ? 4
- : 5,
});
+
+export function setLoggerMinLevel(level: LoggerLevels) {
+ logger.settings.minLevel = LoggerLevelMap[level];
+}
diff --git a/src/registerDefaultPaths.ts b/src/registerDefaultPaths.ts
new file mode 100644
index 0000000..9d396d3
--- /dev/null
+++ b/src/registerDefaultPaths.ts
@@ -0,0 +1,10 @@
+import { getConfig } from '~/config';
+import { createEnsureResourcePath } from '~/ensureResource';
+
+export function registerDefaultPaths() {
+ const config = getConfig();
+
+ if (config.defaultPaths['ensure-resource']) {
+ createEnsureResourcePath();
+ }
+}
diff --git a/src/registerResourcePath.ts b/src/registerResourcePath.ts
index e3ceade..c2219c6 100644
--- a/src/registerResourcePath.ts
+++ b/src/registerResourcePath.ts
@@ -1,19 +1,52 @@
import express from 'express';
import { logger } from '~/logger';
-import { generateApiResponse, ResponseStatus } from '~/response';
-import type { ApiResponse } from '~/response';
+import {
+ generateApiResponse,
+ ResponseStatus,
+ type ApiResponse,
+ isApiResponse,
+} from '~/response';
export const dynamicRouteRouter = express.Router();
const resourcePathMap = new Map<
string,
- {
- [key: string]: (
+ Record<
+ string,
+ (
data: Record,
- ) => Promise | ApiResponse | void;
- }
+ ) => Promise | ApiResponse | unknown
+ >
>();
+function handlePathFuncResult(
+ data: ApiResponse | unknown,
+ res: express.Response,
+ resourceName: string,
+ path: string,
+) {
+ if (!data || !isApiResponse(data)) {
+ res.json(generateApiResponse(200, ResponseStatus.Success, data));
+ return;
+ }
+
+ try {
+ res.status(data.responseCode).json(data);
+ } catch (err) {
+ const response = generateApiResponse(
+ 500,
+ ResponseStatus.Error,
+ `Endpoint '${resourceName}/${path}' tried to send data that wasn't JSON compatible.`,
+ );
+
+ res.status(response.responseCode).send(response);
+
+ logger.error(response.message);
+
+ return;
+ }
+}
+
export function registerResourcePath<
T extends Record = Record,
U = unknown,
@@ -21,6 +54,11 @@ export function registerResourcePath<
path: string,
handler: (data: T) => Promise | void> | ApiResponse | void,
) {
+ if (typeof path !== 'string' || typeof handler !== 'function') {
+ logger.error('Invalid arguments passed to registerResourcePath.');
+ return;
+ }
+
const resourceName = GetInvokingResource() || GetCurrentResourceName();
if (!resourcePathMap.has(resourceName)) {
@@ -36,7 +74,7 @@ export function registerResourcePath<
resourcePathMap.set(resourceName, { ...resourcePaths, [path]: handler });
}
- dynamicRouteRouter.get(`/${resourceName}/${path}`, async (req, res) => {
+ dynamicRouteRouter.get(`/${resourceName}/${path}`, (req, res) => {
if (
!resourcePathMap.has(resourceName) ||
!resourcePathMap.get(resourceName)?.[path]
@@ -57,65 +95,23 @@ export function registerResourcePath<
const pathFunc = resourcePathMap.get(resourceName)?.[path];
if (!pathFunc) return;
- let data: Awaited>;
-
try {
- data = await pathFunc(req.query);
- } catch (err) {
- const response = generateApiResponse(
- 500,
- ResponseStatus.Error,
- `Callback is invalid for endpoint '${resourceName}/${path}', the target resource was most likely stopped.`,
- );
-
- res.status(response.responseCode).send(response);
+ const result = pathFunc(req.query);
- logger.error(response.message);
+ if (result instanceof Promise) {
+ return result.then((data) => {
+ handlePathFuncResult(data, res, resourceName, path);
+ });
+ }
- return;
- }
+ handlePathFuncResult(result, res, resourceName, path);
- if (!data) {
- res.json(generateApiResponse(200, ResponseStatus.Success));
return;
- }
-
- try {
- switch (data.status) {
- case ResponseStatus.Success:
- res
- .status(data.responseCode)
- .json(
- generateApiResponse(data.responseCode, data.status, data.data),
- );
-
- break;
- case ResponseStatus.Fail:
- res
- .status(data.responseCode)
- .json(
- generateApiResponse(
- data.responseCode,
- data.status,
- data.data as Record,
- ),
- );
-
- break;
- case ResponseStatus.Error:
- res
- .status(data.responseCode)
- .json(
- generateApiResponse(data.responseCode, data.status, data.message),
- );
-
- break;
- }
} catch (err) {
const response = generateApiResponse(
500,
ResponseStatus.Error,
- `Endpoint '${resourceName}/${path}' tried to send data that wasn't JSON compatible.`,
+ `Callback is invalid for endpoint '${resourceName}/${path}', the target resource was most likely stopped.`,
);
res.status(response.responseCode).send(response);
@@ -130,3 +126,24 @@ export function registerResourcePath<
}
global.exports('registerResourcePath', registerResourcePath);
+
+on('onResourceStop', (resourceName: string) => {
+ if (resourceName === GetCurrentResourceName()) return;
+
+ if (resourcePathMap.has(resourceName)) {
+ const pathData = resourcePathMap.get(resourceName);
+ if (!pathData) return;
+
+ const paths = Object.keys(pathData).map(
+ (path) => `/${resourceName}/${path}`,
+ );
+
+ dynamicRouteRouter.stack = dynamicRouteRouter.stack.filter(
+ (route) => !paths.includes(route.route?.path ?? ''),
+ );
+
+ resourcePathMap.delete(resourceName);
+
+ logger.info(`Resource '${resourceName}' was stopped, removed all paths.`);
+ }
+});
diff --git a/src/response.ts b/src/response.ts
index e74a256..4d94aa4 100644
--- a/src/response.ts
+++ b/src/response.ts
@@ -80,3 +80,17 @@ export function generateApiResponse(
throw new Error('Error generating API response.');
}
+
+const ResponseStatuses = Object.values(ResponseStatus) as Array;
+
+export function isApiResponse(data: unknown): data is ApiResponse {
+ return (
+ typeof data === 'object' &&
+ data !== null &&
+ 'responseCode' in data &&
+ typeof data.responseCode === 'number' &&
+ 'status' in data &&
+ typeof data.status === 'string' &&
+ ResponseStatuses.includes(data.status)
+ );
+}
diff --git a/src/startServer.ts b/src/startServer.ts
index 5162175..b549780 100644
--- a/src/startServer.ts
+++ b/src/startServer.ts
@@ -3,14 +3,14 @@ import cors from 'cors';
import { dynamicRouteRouter } from '~/registerResourcePath';
import { generateApiResponse, ResponseStatus } from '~/response';
import { logger } from '~/logger';
-import type { ServerConfig } from '~/getConfig';
+import { getConfig } from '~/config';
+import { registerDefaultPaths } from '~/registerDefaultPaths';
const app = express();
-export function startServer(
- { port, enableCors, accessKeys }: ServerConfig,
- defaultPaths: Array<() => void>,
-) {
+export function startServer() {
+ const { enableCors, accessKeys, port } = getConfig().server;
+
if (enableCors) {
app.use(cors());
logger.info('CORS is enabled for all requests.');
@@ -64,11 +64,7 @@ export function startServer(
app.listen(port, () => {
logger.info(`API listening on http://127.0.0.1:${port}/`);
-
+ registerDefaultPaths();
emit(`${GetCurrentResourceName()}:serverStarted`);
-
- defaultPaths.forEach((createPath) => {
- createPath();
- });
});
}