1
0
mirror of https://github.com/tcgdex/cards-database.git synced 2025-08-15 01:41:59 +00:00

Compare commits

...

12 Commits

Author SHA1 Message Date
41bf9afde7 feat: Add ability for users to requests using the subfields values (#477) 2024-01-22 01:48:04 +01:00
2cfa860f6d fix: Undefined values crashing the request (#476) 2024-01-22 01:42:59 +01:00
b168b86006 chore: Update Bruno tests (#473) 2024-01-08 01:33:47 +01:00
dependabot[bot]
3a441887b8 build: bump actions/setup-node from 3 to 4 (#470)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Avior <github@avior.me>
2024-01-08 00:50:15 +01:00
dependabot[bot]
e36c92a0b0 build: bump actions/checkout from 3 to 4 (#469)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 00:48:09 +01:00
33007d83bc fix: Invalid card when searching using the set and localid (#472) 2024-01-08 00:40:31 +01:00
8684fb14e4 feat: Add bruno to tests the APIs (#468) 2024-01-03 23:42:56 +01:00
8fd7afeb32 fix: better sorting defaults
Signed-off-by: Avior <github@avior.me>
2024-01-03 23:24:52 +01:00
28fcb66fc9 fix: 404 error for options requests
Signed-off-by: Avior <github@avior.me>
2024-01-03 21:06:52 +01:00
12ed23b5a2 fix: invalid OPTIONS request handling (#467) 2024-01-03 20:25:15 +01:00
b4dbdef4fa fix: set order not following the old way (#465) 2024-01-03 12:16:57 +01:00
ef23029d24 fix: log that want to production
Signed-off-by: Avior <github@avior.me>
2024-01-03 03:51:42 +01:00
23 changed files with 403 additions and 54 deletions

8
.bruno/bruno.json Normal file
View File

@@ -0,0 +1,8 @@
{
"version": "1",
"name": "TCGdex",
"type": "collection",
"presets": {
"requestType": "http"
}
}

View File

@@ -0,0 +1,28 @@
meta {
name: Get the cards list
type: http
seq: 1
}
get {
url: {{BASE_URL}}/v2/en/cards?sort:field=name&sort:order=DESC&pagination:page=1&pagination:itemsPerPage=4
body: none
auth: none
}
query {
sort:field: name
sort:order: DESC
pagination:page: 1
pagination:itemsPerPage: 4
~name: furret
}
assert {
res.status: eq 200
res.body.length: eq 4
}
docs {
Fully describe the card list request, it also has every parameters it can
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get one card
type: http
seq: 2
}
get {
url: {{BASE_URL}}/v2/en/cards/swsh3-136
body: none
auth: none
}
assert {
res.status: eq 200
res.body.id: eq swsh3-136
}

View File

@@ -0,0 +1,3 @@
vars {
BASE_URL: http://localhost:3000
}

View File

@@ -0,0 +1,3 @@
vars {
BASE_URL: https://api.tcgdex.net
}

View File

@@ -0,0 +1,22 @@
meta {
name: 466 - Invalid Sorting
type: http
seq: 1
}
get {
url: {{BASE_URL}}/v2/en/sets/swsh8/53
body: none
auth: none
}
assert {
res.body.id: eq swsh8-53
res.status: eq 200
}
docs {
Validate the issue seen in
https://github.com/tcgdex/cards-database/issues/466
}

View File

@@ -0,0 +1,15 @@
meta {
name: 467 - Validate that we can run OPTIONS
type: http
seq: 2
}
options {
url: {{BASE_URL}}/status
body: none
auth: none
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,22 @@
meta {
name: 471 - Invalid Set Sorting
type: http
seq: 3
}
get {
url: {{BASE_URL}}/v2/en/sets/swsh12/10
body: none
auth: none
}
assert {
res.body.id: eq swsh12-010
res.status: eq 200
}
docs {
Validate the issue seen in
https://github.com/tcgdex/cards-database/issues/471
}

View File

@@ -0,0 +1,25 @@
meta {
name: 474 - Queries crashing the server
type: http
seq: 2
}
get {
url: {{BASE_URL}}/v2/en/cards?legal.standard=true
body: none
auth: none
}
query {
legal.standard: true
}
assert {
res.status: eq 200
}
docs {
Validate the issue seen in
https://github.com/tcgdex/cards-database/issues/474
}

View File

@@ -0,0 +1,25 @@
meta {
name: 475 - Ability to query subfileds
type: http
seq: 4
}
get {
url: {{BASE_URL}}/v2/en/cards?legal.standard=true
body: none
auth: none
}
query {
legal.standard: true
}
assert {
res.status: eq 200
}
docs {
Validate the issue seen in
https://github.com/tcgdex/cards-database/issues/474
}

33
.bruno/graphql.bru Normal file
View File

@@ -0,0 +1,33 @@
meta {
name: GraphQL API
type: graphql
seq: 1
}
post {
url: {{BASE_URL}}/v2/graphql
body: graphql
auth: none
}
body:graphql {
query Pouet {
cards {
id
localId
name
set {
id
name
serie {
id
name
}
}
}
}
}
assert {
res.status: eq 200
}

View File

@@ -0,0 +1,25 @@
meta {
name: Get a list of sets
type: http
seq: 3
}
get {
url: {{BASE_URL}}/v2/en/sets?sort:field=name&sort:order=DESC&pagination:page=1&pagination:itemsPerPage=1&name=Dark
body: none
auth: none
}
query {
sort:field: name
sort:order: DESC
pagination:page: 1
pagination:itemsPerPage: 1
name: Dark
}
assert {
res.status: eq 200
res.body[0].id: eq swsh3
res.body.length: eq 1
}

16
.bruno/sets/get-a-set.bru Normal file
View File

@@ -0,0 +1,16 @@
meta {
name: Get a set
type: http
seq: 2
}
get {
url: {{BASE_URL}}/v2/en/sets/swsh3
body: none
auth: none
}
assert {
res.status: eq 200
res.body.id: eq swsh3
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get one card from a set
type: http
seq: 1
}
get {
url: {{BASE_URL}}/v2/en/sets/swsh3/136
body: none
auth: none
}
assert {
res.status: eq 200
res.body.id: eq swsh3-136
}

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Docker meta
id: meta

View File

@@ -5,8 +5,8 @@ jobs:
name: Conventional PR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: beemojs/conventional-pr-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Setup BunJS

View File

@@ -4,7 +4,7 @@
"main": "dist/index.js",
"scripts": {
"compile": "bun compiler/index.ts",
"dev": "bun --watch --hot src/index.ts",
"dev": "bun --watch src/index.ts",
"validate": "tsc --noEmit --project ./tsconfig.json",
"start": "bun src/index.ts"
},

View File

@@ -69,7 +69,7 @@ export default class Card implements LocalCard {
}
public static findOne(lang: SupportedLanguages, query: Query<SDKCard>) {
const res = handleValidation(this.getAll(lang), query)
const res = handleSort(handleValidation(this.getAll(lang), query), query)
if (res.length === 0) {
return undefined
}

View File

@@ -30,8 +30,8 @@ const endpointToField: Record<string, keyof SDKCard> = {
}
server
// Midleware that handle caching
.use(apicache.middleware('1 day', (req: Request) => req.method === 'GET', {}))
// Midleware that handle caching only in production and on GET requests
.use(apicache.middleware('1 day', (req: Request) => process.env.NODE_ENV === 'production' && req.method === 'GET', {}))
// .get('/cache/performance', (req, res) => {
// res.json(apicache.getPerformance())
@@ -76,7 +76,7 @@ server
items[cat][item] = finalValue
})
console.log(items)
// @ts-expect-error normal behavior
req.advQuery = items
@@ -186,23 +186,23 @@ server
let result: any | undefined
switch (endpoint) {
case 'cards':
result = Card.findOne(lang, { filters: { id }})?.full()
result = Card.findOne(lang, { filters: { id }, strict: true })?.full()
if (!result) {
result = Card.findOne(lang, { filters: { name: id }})?.full()
result = Card.findOne(lang, { filters: { name: id }, strict: true })?.full()
}
break
case 'sets':
result = Set.findOne(lang, { filters: { id }})?.full()
result = Set.findOne(lang, { filters: { id }, strict: true })?.full()
if (!result) {
result = Set.findOne(lang, {filters: { name: id }})?.full()
result = Set.findOne(lang, {filters: { name: id }, strict: true })?.full()
}
break
case 'series':
result = Serie.findOne(lang, { filters: { id }})?.full()
result = Serie.findOne(lang, { filters: { id }, strict: true })?.full()
if (!result) {
result = Serie.findOne(lang, { filters: { name: id }})?.full()
result = Serie.findOne(lang, { filters: { name: id }, strict: true })?.full()
}
break
default:
@@ -242,7 +242,7 @@ server
switch (endpoint) {
case 'sets':
result = Card
.findOne(lang, { filters: { localId: subid, set: id }})?.full()
.findOne(lang, { filters: { localId: subid, set: id }, strict: true})?.full()
break
}
if (!result) {

View File

@@ -8,21 +8,11 @@ const VERSION = 2
// Init Express server
const server = express()
// Set CORS global headers
server.use((_, res, next) => {
res
.setHeader('Access-Control-Allow-Origin', '*')
.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
.setHeader('Access-Control-Allow-Headers', 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range')
.setHeader('Access-Control-Expose-Headers', 'Content-Length,Content-Range')
next()
})
// 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.padEnd(7)}`
let 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()
@@ -51,6 +41,21 @@ server.use((req, res, next) => {
next()
})
// Set CORS global headers
server.use((req, res, next) => {
res
.setHeader('Access-Control-Allow-Origin', '*')
.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
.setHeader('Access-Control-Allow-Headers', 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range')
.setHeader('Access-Control-Expose-Headers', 'Content-Length,Content-Range')
if (req.method.toUpperCase() === 'OPTIONS') {
res.status(200).send('ok')
return
}
next()
})
server.get('/', (_, res) => {
res.redirect('https://www.tcgdex.dev/?ref=api.tccgdex.net')
})

View File

@@ -28,6 +28,10 @@ export interface Query<T extends {} = {}> {
*/
filters?: Partial<{ [Key in keyof T]: T[Key] extends object ? string | number | Array<string | number> : T[Key] | Array<T[Key]> }>
/**
* instead of filtering text search it will search using the full string
*/
strict?: boolean
/**
* data sorting
*

View File

@@ -1,4 +1,4 @@
import { objectLoop } from '@dzeio/object-util'
import { mustBeObject, objectLoop } from '@dzeio/object-util'
import { SupportedLanguages } from '@tcgdex/sdk'
import { Response } from 'express'
import { Query } from './interfaces'
@@ -59,23 +59,35 @@ export function betterSorter(a: string, b: string) {
*
* @param validator the validation object
* @param value the value to validate
* @param strict change how the results are fetched
* @returns `true` if valid else `false`
*/
export function validateItem(validator: any | Array<any>, value: any): boolean {
export function validateItem(validator: any | Array<any>, value: any, strict: boolean = false): boolean {
// convert to number is possible
if (/^\d+$/.test(validator)) {
validator = parseInt(validator)
}
// do a comparaison with null values
if (isNull(value)) {
return isNull(validator)
}
if (typeof value === 'object') {
return objectLoop(value, (v) => {
// invert signal so that an early exit mean that a match was found!
return !objectLoop(value, (v) => {
// early exit to not infinitively loop through objects
if (typeof v === 'object') return true
// check for each childs
return validateItem(validator, v)
// check for each childs until one match
return !validateItem(validator, v, strict)
})
}
// loop to validate for an array
if (Array.isArray(validator)) {
for (const sub of validator) {
const res = validateItem(sub, value)
const res = validateItem(sub, value, strict)
if (res) {
return true
}
@@ -83,8 +95,12 @@ export function validateItem(validator: any | Array<any>, value: any): boolean {
return false
}
if (typeof validator === 'string') {
// run a string validation
return value.toString().toLowerCase().includes(validator.toLowerCase())
// run a strict string validation
if (strict) {
return value.toString().toLowerCase() === validator.toLowerCase()
} else {
return value.toString().toLowerCase().includes(validator.toLowerCase())
}
} else if (typeof validator === 'number') {
// run a number validation
if (typeof value === 'number') {
@@ -104,10 +120,16 @@ export function validateItem(validator: any | Array<any>, value: any): boolean {
* @returns the sorted data
*/
export function handleSort(data: Array<any>, query: Query<any>) {
const sort: Query<any>['sort'] = query.sort ?? {field: 'id', order: 'ASC'}
const field = sort.field
const order = sort.order ?? 'ASC'
// handle when data has no entries
if (data.length === 0) {
return data
}
const defaultSortPriority = ['releaseDate', 'localId', 'id']
const firstEntry = data[0]
const field = query.sort?.field ?? defaultSortPriority.find((it) => it in firstEntry) ?? 'id'
const order = query.sort?.order ?? 'ASC'
// early exit if the order is not correctly set
if (order !== 'ASC' && order !== 'DESC') {
@@ -116,22 +138,43 @@ export function handleSort(data: Array<any>, query: Query<any>) {
}
if (!(field in firstEntry)) {
console.warn('can\'t sort using the field', field)
return data
}
const sortType = typeof data[0][field]
if (sortType === 'number') {
if (order === 'ASC') {
return data.sort((a, b) => a[field] - b[field])
} else {
return data.sort((a, b) => b[field] - a[field])
}
} else {
if (order === 'ASC') {
return data.sort((a, b) => a[field] > b[field] ? 1 : -1)
} else {
return data.sort((a, b) => a[field] > b[field] ? -1 : 1)
}
return data.sort((a, b) => advSort(a[field], b[field], order))
}
/**
*
* @param order the base ordering
* @returns a function that is feed in the `sort` function
*/
const advSort = (a: string | number, b: string | number, order: 'ASC' | 'DESC' = 'ASC') => {
a = tryParse(a) ?? a
b = tryParse(b) ?? b
if (order === 'DESC') {
const tmp = a
a = b
b = tmp
}
if (typeof a === 'number' && typeof b === 'number') {
return a - b
}
return a.toString().localeCompare(b.toString())
}
function tryParse(value: string | number): number | null {
if (typeof value === 'number') {
return value
}
if (/^-?\d+$/.test(value)) {
return parseInt(value)
}
return null
}
/**
@@ -142,7 +185,7 @@ export function handleSort(data: Array<any>, query: Query<any>) {
* @returns the data that is in the paginated query
*/
export function handlePagination(data: Array<any>, query: Query<any>) {
if (!query.pagination) {
if (!query.pagination || data.length === 0) {
return data
}
const itemsPerPage = query.pagination.itemsPerPage ?? 100
@@ -168,7 +211,47 @@ export function handleValidation(data: Array<any>, query: Query) {
return data
}
return data.filter((v) => objectLoop(filters, (valueToValidate, key) => {
return validateItem(valueToValidate, v[key])
return data.filter((v) => objectLoop(filters, (valueToValidate, key: string) => {
let value: any
// handle subfields
if (key.includes('.')) {
value = objectGet(v, key.split('.'))
} else {
value = v[key]
}
return validateItem(valueToValidate, value, query.strict)
}))
}
/**
* 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) {
return typeof value === 'undefined' || value === null
}