1
0
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:
Florian Bouillon 2024-07-25 12:29:06 +02:00 committed by GitHub
parent 698f66cf55
commit 31b1ae566e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 180 additions and 130 deletions

Binary file not shown.

View File

@ -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"
}
}

View File

@ -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)
})

View File

@ -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
View 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()
}

View 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
}

View File

@ -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
}