mirror of
https://github.com/tcgdex/cards-database.git
synced 2025-06-07 13:49:54 +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",
|
"name": "@tcgdex/server",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"compile": "bun compiler/index.ts",
|
"compile": "bun compiler/index.ts",
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"validate": "tsc --noEmit --project ./tsconfig.json",
|
"validate": "tsc --noEmit --project ./tsconfig.json",
|
||||||
"start": "bun src/index.ts"
|
"start": "bun src/index.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dzeio/config": "^1",
|
"@dzeio/config": "^1",
|
||||||
"@dzeio/object-util": "^1",
|
"@dzeio/object-util": "^1",
|
||||||
"@sentry/node": "^7",
|
"@tcgdex/sdk": "^2",
|
||||||
"@tcgdex/sdk": "^2",
|
"apicache": "^1",
|
||||||
"apicache": "^1",
|
"express": "^4",
|
||||||
"express": "^4",
|
"graphql": "^15",
|
||||||
"graphql": "^15",
|
"graphql-http": "^1.22.1",
|
||||||
"graphql-http": "^1.22.1",
|
"ruru": "^2.0.0-beta.14"
|
||||||
"ruru": "^2.0.0-beta.11"
|
},
|
||||||
},
|
"devDependencies": {
|
||||||
"devDependencies": {
|
"@types/apicache": "^1",
|
||||||
"@types/apicache": "^1",
|
"@types/express": "^4",
|
||||||
"@types/express": "^4",
|
"@types/node": "^20",
|
||||||
"@types/node": "^20",
|
"glob": "^10",
|
||||||
"glob": "^10",
|
"typescript": "^4"
|
||||||
"typescript": "^4"
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,8 @@ import { Card as SDKCard } from '@tcgdex/sdk'
|
|||||||
import apicache from 'apicache'
|
import apicache from 'apicache'
|
||||||
import express, { Request } from 'express'
|
import express, { Request } from 'express'
|
||||||
import { Query } from '../../interfaces'
|
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 Card from '../Components/Card'
|
||||||
import Serie from '../Components/Serie'
|
import Serie from '../Components/Serie'
|
||||||
import Set from '../Components/Set'
|
import Set from '../Components/Set'
|
||||||
@ -97,7 +98,8 @@ server
|
|||||||
const { lang, what } = req.params
|
const { lang, what } = req.params
|
||||||
|
|
||||||
if (!checkLanguage(lang)) {
|
if (!checkLanguage(lang)) {
|
||||||
return sendError('LanguageNotFoundError', res, lang)
|
sendError(Errors.LANGUAGE_INVALID, res, { lang })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const query: Query = req.advQuery!
|
const query: Query = req.advQuery!
|
||||||
@ -114,7 +116,8 @@ server
|
|||||||
data = Serie.find(lang, query)
|
data = Serie.find(lang, query)
|
||||||
break
|
break
|
||||||
default:
|
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)))
|
const item = Math.min(data.length - 1, Math.max(0, Math.round(Math.random() * data.length)))
|
||||||
req.DO_NOT_CACHE = true
|
req.DO_NOT_CACHE = true
|
||||||
@ -136,7 +139,8 @@ server
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!checkLanguage(lang)) {
|
if (!checkLanguage(lang)) {
|
||||||
return sendError('LanguageNotFoundError', res, lang)
|
sendError(Errors.LANGUAGE_INVALID, res, { lang })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: any
|
let result: any
|
||||||
@ -193,12 +197,12 @@ server
|
|||||||
).sort()
|
).sort()
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
sendError('EndpointNotFoundError', res, endpoint)
|
sendError(Errors.NOT_FOUND, res, { endpoint })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
sendError('NotFoundError', res)
|
sendError(Errors.NOT_FOUND, res)
|
||||||
}
|
}
|
||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
@ -217,7 +221,7 @@ server
|
|||||||
id = id.toLowerCase()
|
id = id.toLowerCase()
|
||||||
|
|
||||||
if (!checkLanguage(lang)) {
|
if (!checkLanguage(lang)) {
|
||||||
return sendError('LanguageNotFoundError', res, lang)
|
return sendError(Errors.LANGUAGE_INVALID, res, { lang })
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: any | undefined
|
let result: any | undefined
|
||||||
@ -243,6 +247,9 @@ server
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
|
if (!endpointToField[endpoint]) {
|
||||||
|
break
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
name: id,
|
name: id,
|
||||||
cards: Card.find(lang, {[endpointToField[endpoint]]: id})
|
cards: Card.find(lang, {[endpointToField[endpoint]]: id})
|
||||||
@ -250,7 +257,8 @@ server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return res.status(404).send({error: "Endpoint or id not found"})
|
sendError(Errors.NOT_FOUND, res)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return res.send(result)
|
return res.send(result)
|
||||||
|
|
||||||
@ -271,7 +279,7 @@ server
|
|||||||
subid = subid.toLowerCase()
|
subid = subid.toLowerCase()
|
||||||
|
|
||||||
if (!checkLanguage(lang)) {
|
if (!checkLanguage(lang)) {
|
||||||
return sendError('LanguageNotFoundError', res, lang)
|
return sendError(Errors.LANGUAGE_INVALID, res, { lang })
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: any | undefined
|
let result: any | undefined
|
||||||
@ -283,7 +291,7 @@ server
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return sendError('NotFoundError', res)
|
return sendError(Errors.NOT_FOUND, res)
|
||||||
}
|
}
|
||||||
return res.send(result)
|
return res.send(result)
|
||||||
})
|
})
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Sentry from '@sentry/node'
|
import express, { type Response } from 'express'
|
||||||
import express, { NextFunction } from 'express'
|
|
||||||
import jsonEndpoints from './V2/endpoints/jsonEndpoints'
|
import jsonEndpoints from './V2/endpoints/jsonEndpoints'
|
||||||
import graphql from './V2/graphql'
|
import graphql from './V2/graphql'
|
||||||
|
import { Errors, sendError } from './libs/Errors'
|
||||||
import status from './status'
|
import status from './status'
|
||||||
|
|
||||||
// Current API version
|
// Current API version
|
||||||
@ -10,20 +10,11 @@ const VERSION = 2
|
|||||||
// Init Express server
|
// Init Express server
|
||||||
const server = express()
|
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
|
// Route logging / Error logging for debugging
|
||||||
server.use((req, res, next) => {
|
server.use((req, res, next) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
// Date of request User-Agent 32 first chars request Method
|
// 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 url = new URL(req.url, `http://${req.headers.host}`)
|
||||||
const fullURL = url.toString()
|
const fullURL = url.toString()
|
||||||
@ -82,12 +73,13 @@ server.use(`/v${VERSION}`, jsonEndpoints)
|
|||||||
// Status page
|
// Status page
|
||||||
server.use('/status', status)
|
server.use('/status', status)
|
||||||
|
|
||||||
if (sentryDSN) {
|
// handle 404 errors
|
||||||
server.use(Sentry.Handlers.errorHandler())
|
server.use((_, res) => {
|
||||||
}
|
sendError(Errors.NOT_FOUND, res)
|
||||||
|
})
|
||||||
|
|
||||||
// error logging to the backend
|
// General error handler
|
||||||
server.use((err: Error, _1: any, _2: any, next: NextFunction) => {
|
server.use((err: Error, _1: unknown, res: Response, _2: unknown) => {
|
||||||
// add a full line dash to not miss it
|
// add a full line dash to not miss it
|
||||||
const columns = (process?.stdout?.columns ?? 32) - 7
|
const columns = (process?.stdout?.columns ?? 32) - 7
|
||||||
const dashes = ''.padEnd(columns / 2, '-')
|
const dashes = ''.padEnd(columns / 2, '-')
|
||||||
@ -97,9 +89,9 @@ server.use((err: Error, _1: any, _2: any, next: NextFunction) => {
|
|||||||
console.error(err)
|
console.error(err)
|
||||||
console.error(`\x1b[91m${dashes} ERROR ${dashes}\x1b[0m`)
|
console.error(`\x1b[91m${dashes} ERROR ${dashes}\x1b[0m`)
|
||||||
|
|
||||||
next(err)
|
sendError(Errors.GENERAL, res, { err })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
server.listen(3000)
|
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 { objectGet, objectLoop } from '@dzeio/object-util'
|
||||||
import { SupportedLanguages } from '@tcgdex/sdk'
|
import type { SupportedLanguages } from '@tcgdex/sdk'
|
||||||
import { Response } from 'express'
|
import type { Query } from './interfaces'
|
||||||
import { 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 {
|
export function checkLanguage(str: string): str is SupportedLanguages {
|
||||||
return [
|
return languages.includes(str as 'en')
|
||||||
'en',
|
|
||||||
'fr',
|
|
||||||
'es',
|
|
||||||
'it',
|
|
||||||
'pt',
|
|
||||||
'pt-br',
|
|
||||||
'pt-pt',
|
|
||||||
'de',
|
|
||||||
'nl',
|
|
||||||
'pl',
|
|
||||||
'ru',
|
|
||||||
'ja',
|
|
||||||
'ko',
|
|
||||||
'zh-tw',
|
|
||||||
'id',
|
|
||||||
'th',
|
|
||||||
'zh-cn'
|
|
||||||
].includes(str)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unique(arr: Array<string>): Array<string> {
|
export function unique(arr: Array<string>): Array<string> {
|
||||||
return arr.reduce((p, c) => p.includes(c) ? p : [...p, c], [] as 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) {
|
export function betterSorter(a: string, b: string) {
|
||||||
const ra = parseInt(a, 10)
|
const ra = parseInt(a, 10)
|
||||||
const rb = parseInt(b, 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) => {
|
return data.filter((v) => objectLoop(filters, (valueToValidate, key: string) => {
|
||||||
let value: any
|
let value: unknown
|
||||||
// handle subfields
|
// handle subfields
|
||||||
if (key.includes('.')) {
|
if (key.includes('.')) {
|
||||||
value = objectGet(v, key.split('.'))
|
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
|
* validate that the value is null or undefined
|
||||||
* @param value the value the check
|
* @param value the value the check
|
||||||
* @returns if the value is undefined or null or not
|
* @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
|
return typeof value === 'undefined' || value === null
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user