mirror of
https://github.com/tcgdex/cards-database.git
synced 2025-06-06 13:29:55 +00:00
feat: Add standard Error reporting to user through RFC9457 (#519)
This commit is contained in:
parent
698f66cf55
commit
31b1ae566e
BIN
server/bun.lockb
BIN
server/bun.lockb
Binary file not shown.
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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');
|
||||
|
46
server/src/libs/Errors.ts
Normal file
46
server/src/libs/Errors.ts
Normal file
@ -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, string> = {
|
||||
[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, number> = {
|
||||
[Errors.LANGUAGE_INVALID]: 404,
|
||||
[Errors.NOT_FOUND]: 404,
|
||||
|
||||
[Errors.GENERAL]: 500
|
||||
}
|
||||
|
||||
const details: Partial<Record<Errors, (meta?: Record<string, unknown>) => 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<string, unknown>) {
|
||||
const json: RFC7807 & Record<string, unknown> = {
|
||||
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()
|
||||
}
|
52
server/src/libs/RFCs/RFC7807.ts
Normal file
52
server/src/libs/RFCs/RFC7807.ts
Normal file
@ -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
|
||||
}
|
@ -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<string>): Array<string> {
|
||||
return arr.reduce((p, c) => p.includes(c) ? p : [...p, c], [] as Array<string>)
|
||||
}
|
||||
|
||||
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<any>, 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<any>, 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<string | number | symbol>): 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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user