diff --git a/server/bun.lockb b/server/bun.lockb index 8ec573aa1..2a5bc0dd6 100755 Binary files a/server/bun.lockb and b/server/bun.lockb differ diff --git a/server/package.json b/server/package.json index fd8020ee5..424a4621a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,30 +1,29 @@ { - "name": "@tcgdex/server", - "private": true, - "main": "dist/index.js", - "scripts": { - "compile": "bun compiler/index.ts", - "dev": "bun --watch src/index.ts", - "validate": "tsc --noEmit --project ./tsconfig.json", - "start": "bun src/index.ts" - }, - "license": "MIT", - "dependencies": { - "@dzeio/config": "^1", - "@dzeio/object-util": "^1", - "@sentry/node": "^7", - "@tcgdex/sdk": "^2", - "apicache": "^1", - "express": "^4", - "graphql": "^15", - "graphql-http": "^1.22.1", - "ruru": "^2.0.0-beta.11" - }, - "devDependencies": { - "@types/apicache": "^1", - "@types/express": "^4", - "@types/node": "^20", - "glob": "^10", - "typescript": "^4" - } + "name": "@tcgdex/server", + "private": true, + "main": "dist/index.js", + "scripts": { + "compile": "bun compiler/index.ts", + "dev": "bun --watch src/index.ts", + "validate": "tsc --noEmit --project ./tsconfig.json", + "start": "bun src/index.ts" + }, + "license": "MIT", + "dependencies": { + "@dzeio/config": "^1", + "@dzeio/object-util": "^1", + "@tcgdex/sdk": "^2", + "apicache": "^1", + "express": "^4", + "graphql": "^15", + "graphql-http": "^1.22.1", + "ruru": "^2.0.0-beta.14" + }, + "devDependencies": { + "@types/apicache": "^1", + "@types/express": "^4", + "@types/node": "^20", + "glob": "^10", + "typescript": "^4" + } } diff --git a/server/src/V2/endpoints/jsonEndpoints.ts b/server/src/V2/endpoints/jsonEndpoints.ts index 1594ff63b..7adb3998b 100644 --- a/server/src/V2/endpoints/jsonEndpoints.ts +++ b/server/src/V2/endpoints/jsonEndpoints.ts @@ -3,7 +3,8 @@ import { Card as SDKCard } from '@tcgdex/sdk' import apicache from 'apicache' import express, { Request } from 'express' import { Query } from '../../interfaces' -import { betterSorter, checkLanguage, sendError, unique } from '../../util' +import { Errors, sendError } from '../../libs/Errors' +import { betterSorter, checkLanguage, unique } from '../../util' import Card from '../Components/Card' import Serie from '../Components/Serie' import Set from '../Components/Set' @@ -97,7 +98,8 @@ server const { lang, what } = req.params if (!checkLanguage(lang)) { - return sendError('LanguageNotFoundError', res, lang) + sendError(Errors.LANGUAGE_INVALID, res, { lang }) + return } const query: Query = req.advQuery! @@ -114,7 +116,8 @@ server data = Serie.find(lang, query) break default: - return sendError('EndpointNotFoundError', res, what) + sendError(Errors.NOT_FOUND, res, { details: `You can only run random requests on "card", "set" or "serie" while you did on "${what}"` }) + return } const item = Math.min(data.length - 1, Math.max(0, Math.round(Math.random() * data.length))) req.DO_NOT_CACHE = true @@ -136,7 +139,8 @@ server } if (!checkLanguage(lang)) { - return sendError('LanguageNotFoundError', res, lang) + sendError(Errors.LANGUAGE_INVALID, res, { lang }) + return } let result: any @@ -193,12 +197,12 @@ server ).sort() break default: - sendError('EndpointNotFoundError', res, endpoint) + sendError(Errors.NOT_FOUND, res, { endpoint }) return } if (!result) { - sendError('NotFoundError', res) + sendError(Errors.NOT_FOUND, res) } res.json(result) }) @@ -217,7 +221,7 @@ server id = id.toLowerCase() if (!checkLanguage(lang)) { - return sendError('LanguageNotFoundError', res, lang) + return sendError(Errors.LANGUAGE_INVALID, res, { lang }) } let result: any | undefined @@ -243,6 +247,9 @@ server } break default: + if (!endpointToField[endpoint]) { + break + } result = { name: id, cards: Card.find(lang, {[endpointToField[endpoint]]: id}) @@ -250,7 +257,8 @@ server } } if (!result) { - return res.status(404).send({error: "Endpoint or id not found"}) + sendError(Errors.NOT_FOUND, res) + return } return res.send(result) @@ -271,7 +279,7 @@ server subid = subid.toLowerCase() if (!checkLanguage(lang)) { - return sendError('LanguageNotFoundError', res, lang) + return sendError(Errors.LANGUAGE_INVALID, res, { lang }) } let result: any | undefined @@ -283,7 +291,7 @@ server break } if (!result) { - return sendError('NotFoundError', res) + return sendError(Errors.NOT_FOUND, res) } return res.send(result) }) diff --git a/server/src/index.ts b/server/src/index.ts index 40d2b3a20..39329a100 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,7 +1,7 @@ -import Sentry from '@sentry/node' -import express, { NextFunction } from 'express' +import express, { type Response } from 'express' import jsonEndpoints from './V2/endpoints/jsonEndpoints' import graphql from './V2/graphql' +import { Errors, sendError } from './libs/Errors' import status from './status' // Current API version @@ -10,20 +10,11 @@ const VERSION = 2 // Init Express server const server = express() -// allow to catch servers errors -const sentryDSN = process.env.SENTRY_DSN - -if (sentryDSN) { - Sentry.init({ dsn: sentryDSN}) - - server.use(Sentry.Handlers.requestHandler()) -} - // Route logging / Error logging for debugging server.use((req, res, next) => { const now = new Date() // Date of request User-Agent 32 first chars request Method - let prefix = `\x1b[2m${now.toISOString()}\x1b[22m ${req.headers['user-agent']?.slice(0, 32).padEnd(32)} ${req.method.toUpperCase().padEnd(7)}` + const prefix = `\x1b[2m${now.toISOString()}\x1b[22m ${req.headers['user-agent']?.slice(0, 32).padEnd(32)} ${req.method.toUpperCase().padEnd(7)}` const url = new URL(req.url, `http://${req.headers.host}`) const fullURL = url.toString() @@ -82,12 +73,13 @@ server.use(`/v${VERSION}`, jsonEndpoints) // Status page server.use('/status', status) -if (sentryDSN) { - server.use(Sentry.Handlers.errorHandler()) -} +// handle 404 errors +server.use((_, res) => { + sendError(Errors.NOT_FOUND, res) +}) -// error logging to the backend -server.use((err: Error, _1: any, _2: any, next: NextFunction) => { +// General error handler +server.use((err: Error, _1: unknown, res: Response, _2: unknown) => { // add a full line dash to not miss it const columns = (process?.stdout?.columns ?? 32) - 7 const dashes = ''.padEnd(columns / 2, '-') @@ -97,9 +89,9 @@ server.use((err: Error, _1: any, _2: any, next: NextFunction) => { console.error(err) console.error(`\x1b[91m${dashes} ERROR ${dashes}\x1b[0m`) - next(err) + sendError(Errors.GENERAL, res, { err }) }) // Start server server.listen(3000) -console.log(`🚀 Server ready at localhost:3000`); +console.log('🚀 Server ready at localhost:3000'); diff --git a/server/src/libs/Errors.ts b/server/src/libs/Errors.ts new file mode 100644 index 000000000..89e8613e9 --- /dev/null +++ b/server/src/libs/Errors.ts @@ -0,0 +1,46 @@ +import type { Response } from 'express' +import { languages } from '../util' +import type RFC7807 from './RFCs/RFC7807' + +export enum Errors { + LANGUAGE_INVALID = 'language-invalid', + NOT_FOUND = 'not-found', + + GENERAL = 'general' +} + +const titles: Record = { + [Errors.LANGUAGE_INVALID]: 'The chosen language is not available in the database', + [Errors.NOT_FOUND]: 'The resource you are trying to reach does not exists', + + [Errors.GENERAL]: 'An unknown error occured, please contact a developper with this message' +} + +const status: Record = { + [Errors.LANGUAGE_INVALID]: 404, + [Errors.NOT_FOUND]: 404, + + [Errors.GENERAL]: 500 +} + +const details: Partial) => string>> = { + [Errors.LANGUAGE_INVALID]: (meta) => `You must use one of the following languages (${languages.join(', ')}) while you used "${meta?.lang}"`, +} + +export function sendError(error: Errors, res: Response, metadata?: Record) { + const json: RFC7807 & Record = { + type: `https://tcgdex.dev/errors/${error}`, + title: titles[error], + status: status[error], + endpoint: res.req.url, + method: res.req.method, + ...metadata + } + + const dt = details[error] + if (dt) { + json.details = dt(metadata) + } + + res.status(json.status ?? 500).json(json).end() +} diff --git a/server/src/libs/RFCs/RFC7807.ts b/server/src/libs/RFCs/RFC7807.ts new file mode 100644 index 000000000..407ebbcda --- /dev/null +++ b/server/src/libs/RFCs/RFC7807.ts @@ -0,0 +1,52 @@ +/** + * Add headers: + * Content-Type: application/problem+json + * + * following https://www.rfc-editor.org/rfc/rfc7807.html + */ +export default interface RFC7807 { + /** + * A URI reference [RFC3986] that identifies the + * problem type. + * + * This specification encourages that, when + * dereferenced, it provide human-readable documentation for the + * problem type (e.g., using HTML [W3C.REC-html5-20141028]). + * + * When + * this member is not present, its value is assumed to be + * "about:blank" + */ + type?: string + + /** + * A short, human-readable summary of the problem + * type. + * + * It SHOULD NOT change from occurrence to occurrence of the + * problem, except for purposes of localization (e.g., using + * proactive content negotiation; see [RFC7231], Section 3.4). + */ + title?: string + + /** + * The HTTP status code ([RFC7231], Section 6) + * generated by the origin server for this occurrence of the problem. + */ + status?: number + + /** + * A human-readable explanation specific to this + * occurrence of the problem. + */ + details?: string + + /** + * A URI reference that identifies the specific + * occurrence of the problem. + * + * It may or may not yield further + * information if dereferenced. + */ + instance?: string +} diff --git a/server/src/util.ts b/server/src/util.ts index 1db8fce0b..4700c30af 100644 --- a/server/src/util.ts +++ b/server/src/util.ts @@ -1,58 +1,35 @@ -import { mustBeObject, objectLoop } from '@dzeio/object-util' -import { SupportedLanguages } from '@tcgdex/sdk' -import { Response } from 'express' -import { Query } from './interfaces' +import { objectGet, objectLoop } from '@dzeio/object-util' +import type { SupportedLanguages } from '@tcgdex/sdk' +import type { Query } from './interfaces' + +export const languages = [ + 'en', + 'fr', + 'es', + 'it', + 'pt', + 'pt-br', + 'pt-pt', + 'de', + 'nl', + 'pl', + 'ru', + 'ja', + 'ko', + 'zh-tw', + 'id', + 'th', + 'zh-cn' +] as const export function checkLanguage(str: string): str is SupportedLanguages { - return [ - 'en', - 'fr', - 'es', - 'it', - 'pt', - 'pt-br', - 'pt-pt', - 'de', - 'nl', - 'pl', - 'ru', - 'ja', - 'ko', - 'zh-tw', - 'id', - 'th', - 'zh-cn' - ].includes(str) + return languages.includes(str as 'en') } export function unique(arr: Array): Array { return arr.reduce((p, c) => p.includes(c) ? p : [...p, c], [] as Array) } -export function sendError(error: 'UnknownError' | 'NotFoundError' | 'LanguageNotFoundError' | 'EndpointNotFoundError', res: Response, v?: any) { - let message = '' - let status = 404 - switch (error) { - case 'LanguageNotFoundError': - message = `Language not found (${v})` - break - case 'EndpointNotFoundError': - message = `Endpoint not found (${v})` - break - case 'NotFoundError': - message = 'The resource you are searching does not exists' - break - case 'UnknownError': - default: - message = `an unknown error occured (${v})` - status = 500 - break - } - res.status(status).json({ - message - }).end() -} - export function betterSorter(a: string, b: string) { const ra = parseInt(a, 10) const rb = parseInt(b, 10) @@ -242,7 +219,7 @@ export function handleValidation(data: Array, query: Query) { } return data.filter((v) => objectLoop(filters, (valueToValidate, key: string) => { - let value: any + let value: unknown // handle subfields if (key.includes('.')) { value = objectGet(v, key.split('.')) @@ -253,35 +230,11 @@ export function handleValidation(data: Array, query: Query) { })) } -/** - * go through an object to get a specific value - * @param obj the object to go through - * @param path the path to follow - * @returns the value or undefined - */ -function objectGet(obj: object, path: Array): any | undefined { - mustBeObject(obj) - let pointer: object = obj; - for (let index = 0; index < path.length; index++) { - const key = path[index]; - const nextIndex = index + 1; - if (!Object.prototype.hasOwnProperty.call(pointer, key) && nextIndex < path.length) { - return undefined - } - // if last index - if (nextIndex === path.length) { - return (pointer as any)[key] - } - // move pointer to new key - pointer = (pointer as any)[key] - } -} - /** * validate that the value is null or undefined * @param value the value the check * @returns if the value is undefined or null or not */ -function isNull(value: any): value is (undefined | null) { +export function isNull(value: unknown): value is (undefined | null) { return typeof value === 'undefined' || value === null }