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