From bf54ab38096179b4cbee2a9e749b43013a4c88ab Mon Sep 17 00:00:00 2001 From: Avior Date: Thu, 10 Oct 2024 14:56:11 +0200 Subject: [PATCH] feat: Allow a user to filters elements on the API (#275) --- README.md | 2 +- __tests__/basic.test.js | 158 +++++++++++---- __tests__/cache.test.js | 54 +++++ __tests__/deprecated.test.js | 66 ++++++ package-lock.json | 58 ++++-- package.json | 6 +- src/Psr/SimpleCache/CacheAbstract.ts | 38 ++++ src/Psr/SimpleCache/CacheInterface.d.ts | 109 ++++++++++ src/Psr/SimpleCache/LocalStorageCache.ts | 91 +++++++++ src/Psr/SimpleCache/MemoryCache.ts | 58 ++++++ src/Query.ts | 88 ++++++++ src/Request.ts | 31 --- src/endpoints/Endpoint.ts | 26 +++ src/endpoints/SimpleEndpoint.ts | 24 +++ src/{interfaces.ts => interfaces.d.ts} | 9 + src/models/Card.ts | 198 ++++++++++++++++++ src/models/CardResume.ts | 47 +++++ src/models/Model.ts | 28 +++ src/models/Other.d.ts | 6 + src/models/Serie.ts | 21 ++ src/models/SerieResume.ts | 24 +++ src/models/Set.ts | 89 ++++++++ src/models/SetResume.ts | 25 +++ src/models/StringEndpoint.ts | 21 ++ src/tcgdex.browser.ts | 3 +- src/tcgdex.ts | 245 +++++++++++++++++++++-- src/utils.ts | 20 ++ tsconfig.json | 10 +- 28 files changed, 1429 insertions(+), 126 deletions(-) create mode 100644 __tests__/cache.test.js create mode 100644 __tests__/deprecated.test.js create mode 100644 src/Psr/SimpleCache/CacheAbstract.ts create mode 100644 src/Psr/SimpleCache/CacheInterface.d.ts create mode 100644 src/Psr/SimpleCache/LocalStorageCache.ts create mode 100644 src/Psr/SimpleCache/MemoryCache.ts create mode 100644 src/Query.ts delete mode 100644 src/Request.ts create mode 100644 src/endpoints/Endpoint.ts create mode 100644 src/endpoints/SimpleEndpoint.ts rename src/{interfaces.ts => interfaces.d.ts} (94%) create mode 100644 src/models/Card.ts create mode 100644 src/models/CardResume.ts create mode 100644 src/models/Model.ts create mode 100644 src/models/Other.d.ts create mode 100644 src/models/Serie.ts create mode 100644 src/models/SerieResume.ts create mode 100644 src/models/Set.ts create mode 100644 src/models/SetResume.ts create mode 100644 src/models/StringEndpoint.ts create mode 100644 src/utils.ts diff --git a/README.md b/README.md index f640c71..fcff41a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Github stars - the TCGdex JAvascript SDK is released under the MIT license. + the TCGdex JAvascript SDK is released under the MIT license. Discord Link diff --git a/__tests__/basic.test.js b/__tests__/basic.test.js index 4b7fd21..e755419 100644 --- a/__tests__/basic.test.js +++ b/__tests__/basic.test.js @@ -1,5 +1,7 @@ -const TCGdex = require("../src/tcgdex").default -const fetch = require('node-fetch') +/// + +const { default: TCGdex, Query } = require("../src/tcgdex") +import fetch from 'node-fetch' const fakeFetch = (response, status = 200) => jest.fn(() => Promise.resolve({ @@ -8,59 +10,131 @@ const fakeFetch = (response, status = 200) => jest.fn(() => }) ); - - test('Basic test', async () => { const tcgdex = new TCGdex('en') - TCGdex.fetch = fakeFetch({ok: true}) + TCGdex.fetch = fakeFetch({ ok: true }) const res = await tcgdex.fetch('cards', 'basic-test') - expect(res).toEqual({ok: true}) + expect(res).toEqual({ ok: true }) expect(TCGdex.fetch).toHaveBeenCalledTimes(1) }) -test('Cache test', async () => { - const tcgdex = new TCGdex('en') - TCGdex.fetch = fakeFetch({ok: 'a'}) - const res1 = await tcgdex.fetch('cards', 'cache-test') - expect(res1).toEqual({ok: 'a'}) - TCGdex.fetch = fakeFetch({ok: 'b'}) - const res2 = await tcgdex.fetch('cards', 'cache-test') - expect(res2).toEqual({ok: 'a'}) -}) - test('endpoint errors', async () => { const tcgdex = new TCGdex('en') - TCGdex.fetch = fakeFetch({ok: 'a'}) + TCGdex.fetch = fakeFetch({ ok: 'a' }) await expect(tcgdex.fetch('non existing endpoint')).rejects.toThrow() await expect(tcgdex.fetch()).rejects.toThrow() }) -test('404 test', async () => { - const tcgdex = new TCGdex('en') - TCGdex.fetch = fakeFetch(undefined, 404) - expect( - await tcgdex.fetch('cards', '404-test') - ).not.toBeDefined() -}) - -test('test real endpoints', async () => { +test(`404 error`, async () => { const tcgdex = new TCGdex('en') TCGdex.fetch = fetch - const endpoints = [ - {endpoint: 'fetchCard', params: ['swsh1-1']}, - {endpoint: 'fetchCard', params: ['1', 'Sword & Shield']}, - {endpoint: 'fetchCards', params: ['swsh1']}, - {endpoint: 'fetchCards', params: []}, - {endpoint: 'fetchSet', params: ['swsh1']}, - {endpoint: 'fetchSets', params: ['swsh']}, - {endpoint: 'fetchSets', params: []}, - {endpoint: 'fetchSeries', params: []}, - {endpoint: 'fetchSerie', params: ['swsh']}, - ] - for await (const item of endpoints) { - expect( - await tcgdex[item.endpoint](...item.params) - ).toBeDefined() - } + expect( + await tcgdex.card.get('404-error') + ).toBeNull() }) + +test(`test getting full set from list`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (await tcgdex.set.list())[0].getSet() + ).toBeTruthy() +}) + +test(`test getting full serie from list`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (await tcgdex.serie.list())[0].getSerie() + ).toBeTruthy() +}) + +test(`test getting full card from list`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (await tcgdex.card.list())[0].getCard() + ).toBeTruthy() +}) + + +test(`test get set from card`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (await tcgdex.card.get('swsh1-136')).getSet() + ).toBeTruthy() +}) + +test(`test get serie from set`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (await tcgdex.set.get('swsh1')).getSerie() + ).toBeTruthy() +}) + +test(`advanced query system`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + (await tcgdex.card.list( + Query.create() + .equal('name', 'Pikachu') + .greaterOrEqualThan('hp', 60) + .lesserThan('hp', 70) + .contains('localId', '5') + .not.contains('localId', 'tg') + .not.equal('id', 'cel25-5') + .sort('localId', 'ASC') + .paginate(3, 2) + )).length + ).toBe(2) +}) + +const endpoints = [ + { endpoint: 'card', params: ['swsh1-136'] }, + { endpoint: 'set', params: ['swsh1'] }, + { endpoint: 'serie', params: ['swsh'] }, + { endpoint: 'type', params: ['fire'] }, + { endpoint: 'retreat', params: ['1'] }, + { endpoint: 'rarity', params: ['common'] }, + { endpoint: 'illustrator', params: [''] }, + { endpoint: 'hp', params: ['30'] }, + { endpoint: 'categorie', params: ['pokemon'] }, + { endpoint: 'dexID', params: ['1'] }, + { endpoint: 'energyType', params: ['normal'] }, + { endpoint: 'regulationMark', params: ['f'] }, + { endpoint: 'stage', params: ['basic'] }, + { endpoint: 'suffixe', params: ['ex'] }, + { endpoint: 'trainerType', params: ['item'] }, + { endpoint: 'variant', params: ['normal'] }, +] + +for (const endpoint of endpoints) { + test(`test real ${endpoint.endpoint} endpoint list`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (tcgdex[endpoint.endpoint]).list() + ).toBeTruthy() + }) + + test(`test real ${endpoint.endpoint} endpoint item`, async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + + expect( + await (tcgdex[endpoint.endpoint]).get(endpoint.params[0]) + ).toBeTruthy() + }) + +} diff --git a/__tests__/cache.test.js b/__tests__/cache.test.js new file mode 100644 index 0000000..77d69e8 --- /dev/null +++ b/__tests__/cache.test.js @@ -0,0 +1,54 @@ +/// + +const { default: MemoryCache } = require("../src/Psr/SimpleCache/MemoryCache") + +const TCGdex = require("../src/tcgdex").default + +test('that cache store and get one element', async () => { + const cache = new MemoryCache() + cache.set('a', 'b') + expect(cache.get('a')).toBe('b') +}) + +test('that cache store and get multiple elements', async () => { + const cache = new MemoryCache() + cache.setMultiple({ + 'a': 'b', + 'c': 'd' + }) + expect(cache.getMultiple(['a', 'c'])).toStrictEqual({ + a: 'b', + c: 'd' + }) +}) + +test('cache expiration', async () => { + const cache = new MemoryCache() + cache.set('a', 'b', 1) + // wait 2 secs + await new Promise((res) => setTimeout(res, 2000)) + expect(cache.get('a')).toBeUndefined() +}) + +test('cache deletion', async () => { + const cache = new MemoryCache() + cache.set('a', 'b') + expect(cache.get('a')).toBe('b') + cache.delete('a') + expect(cache.get('a')).toBeUndefined() +}) + +test('cache cleared', async () => { + const cache = new MemoryCache() + cache.set('a', 'b') + expect(cache.get('a')).toBe('b') + cache.clear() + expect(cache.get('a')).toBeUndefined() +}) + +test('cache exists', async () => { + const cache = new MemoryCache() + expect(cache.has('a')).toBe(false) + cache.set('a', 'b') + expect(cache.has('a')).toBe(true) +}) diff --git a/__tests__/deprecated.test.js b/__tests__/deprecated.test.js new file mode 100644 index 0000000..4b7fd21 --- /dev/null +++ b/__tests__/deprecated.test.js @@ -0,0 +1,66 @@ +const TCGdex = require("../src/tcgdex").default +const fetch = require('node-fetch') + +const fakeFetch = (response, status = 200) => jest.fn(() => + Promise.resolve({ + status: status, + json: () => Promise.resolve(response), + }) +); + + + +test('Basic test', async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fakeFetch({ok: true}) + const res = await tcgdex.fetch('cards', 'basic-test') + expect(res).toEqual({ok: true}) + expect(TCGdex.fetch).toHaveBeenCalledTimes(1) +}) + +test('Cache test', async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fakeFetch({ok: 'a'}) + const res1 = await tcgdex.fetch('cards', 'cache-test') + expect(res1).toEqual({ok: 'a'}) + TCGdex.fetch = fakeFetch({ok: 'b'}) + const res2 = await tcgdex.fetch('cards', 'cache-test') + expect(res2).toEqual({ok: 'a'}) +}) + +test('endpoint errors', async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fakeFetch({ok: 'a'}) + await expect(tcgdex.fetch('non existing endpoint')).rejects.toThrow() + await expect(tcgdex.fetch()).rejects.toThrow() +}) + +test('404 test', async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fakeFetch(undefined, 404) + expect( + await tcgdex.fetch('cards', '404-test') + ).not.toBeDefined() +}) + +test('test real endpoints', async () => { + const tcgdex = new TCGdex('en') + TCGdex.fetch = fetch + const endpoints = [ + {endpoint: 'fetchCard', params: ['swsh1-1']}, + {endpoint: 'fetchCard', params: ['1', 'Sword & Shield']}, + {endpoint: 'fetchCards', params: ['swsh1']}, + {endpoint: 'fetchCards', params: []}, + {endpoint: 'fetchSet', params: ['swsh1']}, + {endpoint: 'fetchSets', params: ['swsh']}, + {endpoint: 'fetchSets', params: []}, + {endpoint: 'fetchSeries', params: []}, + {endpoint: 'fetchSerie', params: ['swsh']}, + ] + + for await (const item of endpoints) { + expect( + await tcgdex[item.endpoint](...item.params) + ).toBeDefined() + } +}) diff --git a/package-lock.json b/package-lock.json index 6d05fec..ab16cc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@tcgdex/sdk", - "version": "2.5.0", + "version": "2.6.0-beta.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@tcgdex/sdk", - "version": "2.5.0", + "version": "2.6.0-beta.1", "license": "MIT", "dependencies": { - "isomorphic-unfetch": "^3", - "unfetch": "^4" + "@dzeio/object-util": "^1", + "isomorphic-unfetch": "^3" }, "devDependencies": { "@babel/core": "^7", @@ -1750,11 +1750,9 @@ } }, "node_modules/@dzeio/object-util": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@dzeio/object-util/-/object-util-1.4.5.tgz", - "integrity": "sha512-V04GE77lipF2qnzMuA+T3blyPVo+ABKQLrmEteerXecA7G+TCisyQKIVMewFvF9qNsJ1LOVTckWW9wnRPyAwoQ==", - "dev": true, - "peer": true + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@dzeio/object-util/-/object-util-1.8.3.tgz", + "integrity": "sha512-/d0ezut7EGrEKedcD8K2Jb2NAMSFfhxNj4rpUBlGzmmakJjJCXAgXvSDLjUwYrgHuabxbxlAn90Wo727MCzWLA==" }, "node_modules/@eslint/eslintrc": { "version": "1.4.1", @@ -2768,10 +2766,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.11.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", - "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==", - "dev": true + "version": "20.12.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", + "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/node-fetch": { "version": "2.6.2", @@ -8889,6 +8890,12 @@ "typpy": "^2.3.4" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unfetch": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", @@ -10387,11 +10394,9 @@ } }, "@dzeio/object-util": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/@dzeio/object-util/-/object-util-1.4.5.tgz", - "integrity": "sha512-V04GE77lipF2qnzMuA+T3blyPVo+ABKQLrmEteerXecA7G+TCisyQKIVMewFvF9qNsJ1LOVTckWW9wnRPyAwoQ==", - "dev": true, - "peer": true + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@dzeio/object-util/-/object-util-1.8.3.tgz", + "integrity": "sha512-/d0ezut7EGrEKedcD8K2Jb2NAMSFfhxNj4rpUBlGzmmakJjJCXAgXvSDLjUwYrgHuabxbxlAn90Wo727MCzWLA==" }, "@eslint/eslintrc": { "version": "1.4.1", @@ -11183,10 +11188,13 @@ "dev": true }, "@types/node": { - "version": "18.11.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", - "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==", - "dev": true + "version": "20.12.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", + "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } }, "@types/node-fetch": { "version": "2.6.2", @@ -15647,6 +15655,12 @@ "typpy": "^2.3.4" } }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "unfetch": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", diff --git a/package.json b/package.json index 3092756..9bb0d23 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tcgdex/sdk", - "version": "2.5.1", + "version": "2.6.0-beta.1", "main": "./dist/tcgdex.node.js", "module": "./dist/tcgdex.node.mjs", "types": "./dist/tcgdex.node.d.ts", @@ -53,8 +53,8 @@ "node": ">=12" }, "dependencies": { - "isomorphic-unfetch": "^3", - "unfetch": "^4" + "@dzeio/object-util": "^1", + "isomorphic-unfetch": "^3" }, "scripts": { "prebuild": "node scripts/export-version-number.js", diff --git a/src/Psr/SimpleCache/CacheAbstract.ts b/src/Psr/SimpleCache/CacheAbstract.ts new file mode 100644 index 0000000..68c2b24 --- /dev/null +++ b/src/Psr/SimpleCache/CacheAbstract.ts @@ -0,0 +1,38 @@ +import { objectLoop } from '@dzeio/object-util' +import type CacheInterface from './CacheInterface' + +export default abstract class CacheAsbract implements CacheInterface { + + public getMultiple(keys: Array, defaultValues?: Array | undefined): Record { + const res: Record = {} + for (let idx = 0; idx < keys.length; idx++) { + const key = keys[idx] as string + const value = this.get(key, defaultValues?.[idx]) as T | undefined + if (typeof value === 'undefined') { + continue + } + res[key] = value + } + return res + } + + public setMultiple(values: Record, ttl?: number | undefined): boolean { + objectLoop(values, (v, k) => { + this.set(k, v, ttl) + }) + return true + } + + public deleteMultiple(keys: Array): boolean { + for (const key of keys) { + this.delete(key) + } + return true + } + + public abstract get(key: string, defaultValue?: T): T | undefined + public abstract set(key: string, value: T, ttl?: number): boolean + public abstract delete(key: string): boolean + public abstract clear(): boolean + public abstract has(key: string): boolean +} diff --git a/src/Psr/SimpleCache/CacheInterface.d.ts b/src/Psr/SimpleCache/CacheInterface.d.ts new file mode 100644 index 0000000..b1556e9 --- /dev/null +++ b/src/Psr/SimpleCache/CacheInterface.d.ts @@ -0,0 +1,109 @@ +export default interface CacheInterface { + /** + * Fetches a value from the cache. + * + * @param key The unique key of this item in the cache. + * @param defaultValue Default value to return if the key does not exist. + * + * @return T The value of the item from the cache, or $default in case of cache miss. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + get(key: string, defaultValue?: T): T | undefined + + /** + * Persists data in the cache, uniquely referenced by a key with an optional expiration TTL time. + * + * @param key The key of the item to store. + * @param value The value of the item to store. Must be serializable. + * @param {null|number} ttl The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * + * @return bool True on success and false on failure. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + set(key: string, value: T, ttl?: number): boolean + + /** + * Delete an item from the cache by its unique key. + * + * @param key The unique cache key of the item to delete. + * + * @return True if the item was successfully removed. False if there was an error. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + delete(key: string): boolean + + /** + * Wipes clean the entire cache's keys. + * + * @return boolean True on success and false on failure. + */ + clear(): boolean + + /** + * Obtains multiple cache items by their unique keys. + * + * @param keys A list of keys that can obtained in a single operation. + * @param defaultValues $default Default value to return for keys that do not exist. + * + * @return iterable A list of key => value pairs. Cache keys that do not exist or are stale will have $default as value. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. + */ + getMultiple(keys: Array, defaultValues?: Array): Record + + /** + * Persists a set of key => value pairs in the cache, with an optional TTL. + * + * @param values A list of key => value pairs for a multiple-set operation. + * @param ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * + * @return bool True on success and false on failure. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $values is neither an array nor a Traversable, + * or if any of the $values are not a legal value. + */ + setMultiple(values: Record, ttl?: number): boolean + + /** + * Deletes multiple cache items in a single operation. + * + * @param keys A list of string-based keys to be deleted. + * + * @return bool True if the items were successfully removed. False if there was an error. + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. + */ + deleteMultiple(keys: Array): boolean + + /** + * Determines whether an item is present in the cache. + * + * NOTE: It is recommended that has() is only to be used for cache warming type purposes + * and not to be used within your live applications operations for get/set, as this method + * is subject to a race condition where your has() will return true and immediately after, + * another script can remove it, making the state of your app out of date. + * + * @param key The cache item key. + * + * @return bool + * + * @throws \Psr\SimpleCache\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + has(key: string): boolean +} diff --git a/src/Psr/SimpleCache/LocalStorageCache.ts b/src/Psr/SimpleCache/LocalStorageCache.ts new file mode 100644 index 0000000..dc25b1b --- /dev/null +++ b/src/Psr/SimpleCache/LocalStorageCache.ts @@ -0,0 +1,91 @@ +import CacheAsbract from './CacheAbstract' + +interface CacheItem { + data: T + expire?: number | undefined +} + +/** + * A cache implementation that uses browser storage. + * + * This class extends `CacheAsbract` and provides a concrete implementation + * of the caching interface. It stores cached items in browser storage, + * which is suitable for storing small amounts of data. + */ +export default class BrowserStorageCache extends CacheAsbract { + private storage: Storage + public constructor(private readonly prefix?: string, session = false) { + super() + if (session) { + this.storage = window.sessionStorage + } else { + this.storage = window.localStorage + } + } + + public get(key: string, defaultValue?: T | undefined): T | undefined { + const raw = this.storage.getItem(this.getFinalKey(key)) + + if (!raw) { + return defaultValue ?? undefined + } + + const item: CacheItem = JSON.parse(raw) + + if (item.expire && item.expire < new Date().getTime()) { + this.delete(key) + return defaultValue ?? undefined + } + + return item.data + } + + public set(key: string, value: T, ttl?: number | undefined): boolean { + let expire = undefined + if (ttl) { + expire = (new Date()).getTime() + (ttl * 1000) + } + const data: CacheItem = { + data: value, + expire: expire + } + this.storage.setItem(this.getFinalKey(key), JSON.stringify(data)) + + return true + } + + public delete(key: string): boolean { + this.storage.removeItem(this.getFinalKey(key)) + + return true + } + + public clear(): boolean { + const keys = this.keys() + return this.deleteMultiple(keys) + } + + public has(key: string): boolean { + return !!this.storage.getItem(this.getFinalKey(key)) + } + + private keys(): Array { + const list: Array = [] + for (let idx = 0; idx < this.storage.length; idx++) { + const key = this.storage.key(idx) + if (!key || this.prefix && !key?.startsWith(`${this.prefix}/`)) { + continue + } + list.push(key) + } + + return list + } + + private getFinalKey(key: string): string { + if (!this.prefix) { + return key + } + return `${this.prefix}/${key}` + } +} diff --git a/src/Psr/SimpleCache/MemoryCache.ts b/src/Psr/SimpleCache/MemoryCache.ts new file mode 100644 index 0000000..15458b9 --- /dev/null +++ b/src/Psr/SimpleCache/MemoryCache.ts @@ -0,0 +1,58 @@ +import CacheAsbract from './CacheAbstract' + +interface CacheItem { + data: T + expire?: number | undefined +} + +/** + * Memory cache implementation that stores cached items in memory. + * This class extends the abstract `CacheAbstract` and provides a basic in-memory caching mechanism. + * + * @class MemoryCache + */ +export default class MemoryCache extends CacheAsbract { + private cache: Map> = new Map() + + public get(key: string, defaultValue?: T | undefined): T | undefined { + const item = this.cache.get(key) + + if (!item) { + return defaultValue ?? undefined + } + + if (item.expire && item.expire < new Date().getTime()) { + this.delete(key) + return defaultValue ?? undefined + } + + return item.data as T | undefined + } + + public set(key: string, value: T, ttl?: number | undefined): boolean { + let expire: number | undefined + if (ttl) { + expire = new Date().getTime() + ttl * 1000 + } + this.cache.set(key, { + data: value, + expire: expire + }) + + return true + } + + public delete(key: string): boolean { + this.cache.delete(key) + return true + } + + public clear(): boolean { + this.cache.clear() + return true + } + + public has(key: string): boolean { + return this.cache.has(key) + } +} diff --git a/src/Query.ts b/src/Query.ts new file mode 100644 index 0000000..682822c --- /dev/null +++ b/src/Query.ts @@ -0,0 +1,88 @@ +export default class Query { + public params: Array<{ key: string, value: string | number | boolean }> = [] + + public not: { + equal: (key: string, value: string) => Query + contains: (key: string, value: string) => Query + includes: (key: string, value: string) => Query + like: (key: string, value: string) => Query + isNull: (key: string) => Query + } = { + equal: (key: string, value: string) => { + this.params.push({ key: key, value: `neq:${value}` }) + return this + }, + contains: (key: string, value: string) => { + this.params.push({ key: key, value: `not:${value}` }) + return this + }, + includes: (key: string, value: string) => this.not.contains(key, value), + like: (key: string, value: string) => this.not.contains(key, value), + isNull: (key: string) => { + this.params.push({ key: key, value: 'notnull:' }) + return this + } + } + + public static create(): Query { + return new Query() + } + + public includes(key: string, value: string): this { + return this.contains(key, value) + } + + public like(key: string, value: string): this { + return this.contains(key, value) + } + + public contains(key: string, value: string): this { + this.params.push({ key: key, value: value }) + return this + } + + public equal(key: string, value: string): this { + this.params.push({ key: key, value: `eq:${value}` }) + + return this + } + + public sort(key: string, order: 'ASC' | 'DESC'): this { + this.params.push({ key: 'sort:field', value: key }) + this.params.push({ key: 'sort:order', value: order }) + + return this + } + + public greaterOrEqualThan(key: string, value: number) { + this.params.push({ key: key, value: `gte:${value}` }) + return this + } + + public lesserOrEqualThan(key: string, value: number) { + this.params.push({ key: key, value: `lte:${value}` }) + return this + } + + public greaterThan(key: string, value: number) { + this.params.push({ key: key, value: `gt:${value}` }) + return this + } + + public lesserThan(key: string, value: number) { + this.params.push({ key: key, value: `lt:${value}` }) + return this + } + + public isNull(key: string) { + this.params.push({ key: key, value: 'null:' }) + return this + } + + public paginate(page: number, itemsPerPage: number): this { + this.params.push({ key: 'pagination:page', value: page }) + this.params.push({ key: 'pagination:itemsPerPage', value: itemsPerPage }) + + return this + } +} diff --git a/src/Request.ts b/src/Request.ts deleted file mode 100644 index 7ca38bd..0000000 --- a/src/Request.ts +++ /dev/null @@ -1,31 +0,0 @@ -import TCGdex from './tcgdex' -import { version } from './version' - -export default class Request { - - // 1 hour of TTL by default - public static ttl = 1000 * 60 * 60 - - private static cache: Record = {} - - public static async fetch(url: string): Promise { - let request = this.cache[url] - const now = new Date().getTime() - if (!request || now - request.time > this.ttl) { - const unfetch = TCGdex.fetch - const resp = await unfetch(url, { - headers: { - 'user-agent': `@tcgdex/javascript-sdk/${version}` - } - }) - if (resp.status !== 200) { - return undefined - } - - this.cache[url] = { response: await resp.json(), time: now } - request = this.cache[url] - } - return request.response - } - -} diff --git a/src/endpoints/Endpoint.ts b/src/endpoints/Endpoint.ts new file mode 100644 index 0000000..8177d87 --- /dev/null +++ b/src/endpoints/Endpoint.ts @@ -0,0 +1,26 @@ +import type { Endpoints } from '../interfaces' +import Model from '../models/Model' +import type Query from '../Query' +import type TCGdex from '../tcgdex' + +export default class Endpoint { + public constructor( + protected readonly tcgdex: TCGdex, + protected readonly itemModel: new (sdk: TCGdex) => Item, + protected readonly listModel: new (sdk: TCGdex) => List, + protected readonly endpoint: Endpoints + ) { } + + public async get(id: string | number): Promise { + const res = await this.tcgdex.fetch(this.endpoint as 'cards', id as string) + if (!res) { + return null + } + return Model.build(new this.itemModel(this.tcgdex), res) + } + + public async list(query?: Query): Promise> { + const res = await this.tcgdex.fetchWithQuery([this.endpoint], query?.params) + return (res as Array ?? []).map((it) => Model.build(new this.listModel(this.tcgdex), it)) + } +} diff --git a/src/endpoints/SimpleEndpoint.ts b/src/endpoints/SimpleEndpoint.ts new file mode 100644 index 0000000..fa31516 --- /dev/null +++ b/src/endpoints/SimpleEndpoint.ts @@ -0,0 +1,24 @@ +import type { Endpoints } from '../interfaces' +import Model from '../models/Model' +import type Query from '../Query' +import type TCGdex from '../tcgdex' + +export default class SimpleEndpoint { + public constructor( + protected readonly tcgdex: TCGdex, + protected readonly itemModel: new (sdk: TCGdex) => Item, + protected readonly endpoint: Endpoints + ) {} + + public async get(id: string | number): Promise { + const res = await this.tcgdex.fetch(this.endpoint as 'cards', id as string) + if (!res) { + return null + } + return Model.build(new this.itemModel(this.tcgdex), res) + } + + public async list(query?: Query): Promise> { + return await this.tcgdex.fetchWithQuery([this.endpoint], query?.params) ?? [] + } +} diff --git a/src/interfaces.ts b/src/interfaces.d.ts similarity index 94% rename from src/interfaces.ts rename to src/interfaces.d.ts index 512c8d7..0cd2ac1 100644 --- a/src/interfaces.ts +++ b/src/interfaces.d.ts @@ -310,3 +310,12 @@ export interface StringEndpoint { name: string cards: Array } + +export type Quality = 'low' | 'high' + +export type Extension = 'jpg' | 'webp' | 'png' + +export type Endpoints = 'cards' | 'categories' | 'dex-ids' | 'energy-types' | + 'hp' | 'illustrators' | 'rarities' | 'regulation-marks' | + 'retreats' | 'series' | 'sets' | 'stages' | 'suffixes' | + 'trainer-types' | 'types' | 'variants' diff --git a/src/models/Card.ts b/src/models/Card.ts new file mode 100644 index 0000000..5d27cab --- /dev/null +++ b/src/models/Card.ts @@ -0,0 +1,198 @@ +import CardResume from './CardResume' +import type { Variants } from './Other' +import type TCGdexSet from './Set' +import type SetResume from './SetResume' + +// TODO: sort elements by alphabetical order +export default class Card extends CardResume { + /** + * Card illustrator + */ + public illustrator?: string + + /** + * Card Rarity + * + * - None https://www.tcgdex.net/database/sm/smp/SM01 + * - Common https://www.tcgdex.net/database/xy/xy9/1 + * - Uncommon https://www.tcgdex.net/database/xy/xy9/2 + * - Rare https://www.tcgdex.net/database/xy/xy9/3 + * - Ultra Rare + * - Secret Rare + */ + public rarity!: string + + /** + * Card Category + * + * - Pokemon + * - Trainer + * - Energy + */ + public category!: string + + /** + * Card Variants (Override Set Variants) + */ + public variants?: Variants + + /** + * Card Set + */ + public set!: SetResume + + /** + * Pokemon only elements + */ + + /** + * Pokemon Pokedex ID + */ + public dexId?: Array + + /** + * Pokemon HP + */ + public hp?: number + + /** + * Pokemon Types + * ex for multiple https://www.tcgdex.net/database/ex/ex13/17 + */ + public types?: Array + + /** + * Pokemon Sub Evolution + */ + public evolveFrom?: string + + /** + * Pokemon Weight + */ + public weight?: string + + /** + * Pokemon Description + */ + public description?: string + + /** + * Level of the Pokemon + * + * NOTE: can be equal to 'X' when the pokemon is a LEVEL-UP one + */ + public level?: number | string + + /** + * Pokemon Stage + * + * - Basic https://www.tcgdex.net/database/xy/xy9/1 + * - BREAK https://www.tcgdex.net/database/xy/xy9/18 + * - LEVEL-UP https://www.tcgdex.net/database/dp/dp1/121 + * - MEGA https://www.tcgdex.net/database/xy/xy1/2 + * - RESTORED https://www.tcgdex.net/database/bw/bw5/53 + * - Stage1 https://www.tcgdex.net/database/xy/xy9/2 + * - Stage2 https://www.tcgdex.net/database/xy/xy9/3 + * - VMAX https://www.tcgdex.net/database/swsh/swsh1/50 + */ + public stage?: string + + /** + * Card Suffix + * + * - EX https://www.tcgdex.net/database/ex/ex2/94 + * - GX https://www.tcgdex.net/database/sm/sm12/4 + * - V https://www.tcgdex.net/database/swsh/swsh1/1 + * - Legend https://www.tcgdex.net/database/hgss/hgss1/114 + * - Prime https://www.tcgdex.net/database/hgss/hgss2/85 + * - SP https://www.tcgdex.net/database/pl/pl1/7 + * - TAG TEAM-GX https://www.tcgdex.net/database/sm/sm12/226 + */ + public suffix?: string + + /** + * Pokemon Held Item + * + * ex https://www.tcgdex.net/database/dp/dp2/75 + */ + public item?: { + name: string + effect: string + } + + /** + * Pokemon Abilities + * + * multi abilities ex https://www.tcgdex.net/database/ex/ex15/10 + */ + public abilities?: Array<{ + type: string + name: string + effect: string + }> + + /** + * Pokemon Attacks + */ + public attacks?: Array<{ + cost?: Array + name: string + effect?: string + damage?: string | number + }> + + /** + * Pokemon Weaknesses + */ + public weaknesses?: Array<{ + type: string + value?: string + }> + + public resistances?: Array<{ + type: string + value?: string + }> + + public retreat?: number + + // Trainer/Energy + public effect?: string + + // Trainer Only + public trainerType?: string + + // Energy Only + public energyType?: string + + /** + * Define the rotation mark on cards >= Sword & Shield + */ + public regulationMark?: string + + /** + * Card ability to be played in official tournaments + * + * Note: all cards are avaialable to play in unlimited tournaments + */ + public legal!: { + + /** + * Ability to play in standard tournaments + */ + standard: boolean + + /** + * Ability to play in expanded tournaments + */ + expanded: boolean + } + + public override async getCard(): Promise { + return this + } + + public async getSet(): Promise { + return (await this.sdk.set.get(this.set.id))! + } +} diff --git a/src/models/CardResume.ts b/src/models/CardResume.ts new file mode 100644 index 0000000..da3b126 --- /dev/null +++ b/src/models/CardResume.ts @@ -0,0 +1,47 @@ +import type { Extension, Quality } from '../interfaces' +import type Card from './Card' +import Model from './Model' + +export default class CardResume extends Model { + /** + * Globally unique card ID based on the set ID and the cards ID within the set + */ + public id!: string + + /** + * Card image url without the extension and quality + * + * @see {@link getImageURL} + */ + public image?: string + + /** + * ID indexing this card within its set, usually just its number + */ + public localId!: string + + /** + * Card Name (Including the suffix if next to card name) + */ + public name!: string + + /** + * the the Card Image full URL + * + * @param {Quality} quality the quality you want your image to be in + * @param {Extension} extension extension you want you image to be + * @return the full card URL + */ + public getImageURL(quality: Quality = 'high', extension: Extension = 'png'): string { + return `${this.image}/${quality}.${extension}` + } + + /** + * Get the full Card + * + * @return the full card if available + */ + public async getCard(): Promise { + return (await this.sdk.card.get(this.id))! + } +} diff --git a/src/models/Model.ts b/src/models/Model.ts new file mode 100644 index 0000000..706c554 --- /dev/null +++ b/src/models/Model.ts @@ -0,0 +1,28 @@ +import { objectLoop } from '@dzeio/object-util' +import type TCGdex from '../tcgdex' + +export default abstract class Model { + + public constructor( + protected readonly sdk: TCGdex + ) { } + + /** + * build a model depending on the data given + * @param model the model to build + * @param data the data to fill it with + */ + public static build(model: T, data?: object): T { + if (!data) { + throw new Error('data is necessary.') + } + model.fill(data) + return model + } + + protected fill(obj: object) { + objectLoop(obj, (value, key) => { + (this as object)[key] = value + }) + } +} diff --git a/src/models/Other.d.ts b/src/models/Other.d.ts new file mode 100644 index 0000000..0f88424 --- /dev/null +++ b/src/models/Other.d.ts @@ -0,0 +1,6 @@ +export interface Variants { + normal?: boolean + reverse?: boolean + holo?: boolean + firstEdition?: boolean +} diff --git a/src/models/Serie.ts b/src/models/Serie.ts new file mode 100644 index 0000000..b910077 --- /dev/null +++ b/src/models/Serie.ts @@ -0,0 +1,21 @@ +import { objectLoop } from '@dzeio/object-util' +import Model from './Model' +import SerieResume from './SerieResume' +import SetResume from './SetResume' + +export default class Serie extends SerieResume { + public sets!: Array + + protected fill(obj: object): void { + objectLoop(obj, (value, key) => { + switch (key) { + case 'sets': + this.sets = (value as Array).map((it) => Model.build(new SetResume(this.sdk), it)) + break + default: + this[key] = value + break + } + }) + } +} diff --git a/src/models/SerieResume.ts b/src/models/SerieResume.ts new file mode 100644 index 0000000..8729211 --- /dev/null +++ b/src/models/SerieResume.ts @@ -0,0 +1,24 @@ +import type { Extension } from '../interfaces' +import Model from './Model' +import type Serie from './Serie' + +export default class SerieResume extends Model { + public id!: string + public name!: string + public logo?: string + + /** + * the the Card Image full URL + * + * @param {Quality} quality the quality you want your image to be in + * @param {Extension} extension extension you want you image to be + * @return the full card URL + */ + public getImageURL(extension: Extension = 'png'): string { + return `${this.logo}.${extension}` + } + + public async getSerie(): Promise { + return (await this.sdk.serie.get(this.id))! + } +} diff --git a/src/models/Set.ts b/src/models/Set.ts new file mode 100644 index 0000000..b4c8d47 --- /dev/null +++ b/src/models/Set.ts @@ -0,0 +1,89 @@ +import { objectLoop } from '@dzeio/object-util' +import CardResume from './CardResume' +import Model from './Model' +import type { Variants } from './Other' +import type SerieResume from './SerieResume' + +// biome-ignore lint/suspicious/noShadowRestrictedNames: +export default class Set extends Model { + public id!: string + public name!: string + public logo?: string + public symbol?: string + public serie!: SerieResume + public tcgOnline?: string + public variants?: Variants + + public releaseDate!: string + + /** + * Designate if the set is usable in tournaments + * + * Note: this is specific to the set and if a + * card is banned from the set it will still be true + */ + public legal!: { + + /** + * Ability to play in standard tournaments + */ + standard: boolean + + /** + * Ability to play in expanded tournaments + */ + expanded: boolean + } + + public cardCount!: { + + /** + * total of number of cards + */ + total: number + + /** + * number of cards officialy (on the bottom of each cards) + */ + official: number + + /** + * number of cards having a normal version + */ + normal: number + + /** + * number of cards having an reverse version + */ + reverse: number + + /** + * number of cards having an holo version + */ + holo: number + + /** + * Number of possible cards + */ + firstEd?: number + } + + public cards!: Array + + public async getSerie() { + return this.sdk.serie.get(this.serie.id) + } + + protected fill(obj: object): void { + objectLoop(obj, (value, key) => { + switch (key) { + case 'cards': + this.cards = (value as Array).map((it) => Model.build(new CardResume(this.sdk), it)) + break + default: + this[key] = value + break + } + }) + } +} diff --git a/src/models/SetResume.ts b/src/models/SetResume.ts new file mode 100644 index 0000000..dbd5519 --- /dev/null +++ b/src/models/SetResume.ts @@ -0,0 +1,25 @@ +import Model from './Model' +import type TCGdexSet from './Set' + +export default class SetResume extends Model { + public id!: string + public name!: string + public logo?: string + public symbol?: string + public cardCount!: { + + /** + * total of number of cards + */ + total: number + + /** + * number of cards officialy (on the bottom of each cards) + */ + official: number + } + + public async getSet(): Promise { + return (await this.sdk.set.get(this.id))! + } +} diff --git a/src/models/StringEndpoint.ts b/src/models/StringEndpoint.ts new file mode 100644 index 0000000..fe03d21 --- /dev/null +++ b/src/models/StringEndpoint.ts @@ -0,0 +1,21 @@ +import { objectLoop } from '@dzeio/object-util' +import CardResume from './CardResume' +import Model from './Model' + +export default class StringEndpoint extends Model { + public name!: string + public cards!: Array + + protected fill(obj: object): void { + objectLoop(obj, (value, key) => { + switch (key) { + case 'cards': + this.cards = (value as Array).map((it) => Model.build(new CardResume(this.sdk), it)) + break + default: + this[key] = value + break + } + }) + } +} diff --git a/src/tcgdex.browser.ts b/src/tcgdex.browser.ts index ffd4a6c..8104c03 100644 --- a/src/tcgdex.browser.ts +++ b/src/tcgdex.browser.ts @@ -1,6 +1,5 @@ -import unfetch from 'unfetch' import TCGdex from './tcgdex' -TCGdex.fetch = window.fetch ?? unfetch as any +TCGdex.fetch = window.fetch export default TCGdex diff --git a/src/tcgdex.ts b/src/tcgdex.ts index 01fb53b..e207503 100644 --- a/src/tcgdex.ts +++ b/src/tcgdex.ts @@ -1,24 +1,144 @@ -import RequestWrapper from './Request' -import { Serie, Set, Card, CardResume, SerieList, SetList, SupportedLanguages, StringEndpoint } from './interfaces' -type Endpoint = 'cards' | 'categories' | 'dex-ids' | 'energy-types' | 'hp' | 'illustrators' | 'rarities' | 'regulation-marks' | 'retreats' | 'series' | 'sets' | 'stages' | 'suffixes' | 'trainer-types' | 'types' | 'variants' +import type CacheInterface from './Psr/SimpleCache/CacheInterface' +import LocalStorageCache from './Psr/SimpleCache/LocalStorageCache' +import MemoryCache from './Psr/SimpleCache/MemoryCache' +import Query from './Query' +import Endpoint from './endpoints/Endpoint' +import SimpleEndpoint from './endpoints/SimpleEndpoint' +import type { + Card, + CardResume, + Endpoints, + Serie, + SerieList, + SetList, + StringEndpoint, + SupportedLanguages, + Set as TCGdexSet +} from './interfaces' +import CardModel from './models/Card' +import CardResumeModel from './models/CardResume' +import SerieModel from './models/Serie' +import SerieResume from './models/SerieResume' +import SetModel from './models/Set' +import SetResumeModel from './models/SetResume' +import StringEndpointModel from './models/StringEndpoint' +import { ENDPOINTS, detectContext } from './utils' +import { version } from './version' -const ENDPOINTS: Array = ['cards', 'categories', 'dex-ids', 'energy-types', 'hp', 'illustrators', 'rarities', 'regulation-marks', 'retreats', 'series', 'sets', 'stages', 'suffixes', 'trainer-types', 'types', 'variants'] -const BASE_URL = 'https://api.tcgdex.net/v2' export default class TCGdex { - public static fetch: typeof fetch + /** + * How the remote data is going to be fetched + */ + public static fetch: typeof fetch = fetch /** - * @deprecated to change the lang use `this.lang` + * @deprecated to change the lang use {@link TCGdex.getLang} and {@link TCGdex.setLang} */ public static defaultLang: SupportedLanguages = 'en' - public constructor(public lang?: SupportedLanguages) {} + /** + * the previously hidden caching system used by TCGdex to not kill the API + */ + public cache: CacheInterface = + detectContext() === 'browser' ? new LocalStorageCache('tcgdex-cache') : new MemoryCache() + + /** + * the default cache TTL, only subsequent requests will have their ttl changed + */ + public cacheTTL = 60 * 60 + + public readonly card = new Endpoint(this, CardModel, CardResumeModel, 'cards') + public readonly set = new Endpoint(this, SetModel, SetResumeModel, 'sets') + public readonly serie = new Endpoint(this, SerieModel, SerieResume, 'series') + + public readonly type = new SimpleEndpoint(this, StringEndpointModel, 'types') + public readonly retreat = new SimpleEndpoint(this, StringEndpointModel, 'retreats') + public readonly rarity = new SimpleEndpoint(this, StringEndpointModel, 'rarities') + public readonly illustrator = new SimpleEndpoint(this, StringEndpointModel, 'illustrators') + public readonly hp = new SimpleEndpoint(this, StringEndpointModel, 'hp') + public readonly categorie = new SimpleEndpoint(this, StringEndpointModel, 'categories') + public readonly dexID = new SimpleEndpoint(this, StringEndpointModel, 'dex-ids') + public readonly energyType = new SimpleEndpoint(this, StringEndpointModel, 'energy-types') + public readonly regulationMark = new SimpleEndpoint(this, StringEndpointModel, 'regulation-marks') + public readonly stage = new SimpleEndpoint(this, StringEndpointModel, 'stages') + public readonly suffixe = new SimpleEndpoint(this, StringEndpointModel, 'suffixes') + public readonly trainerType = new SimpleEndpoint(this, StringEndpointModel, 'trainer-types') + public readonly variant = new SimpleEndpoint(this, StringEndpointModel, 'variants') + + private lang: SupportedLanguages = 'en' + private endpointURL = 'https://api.tcgdex.net/v2' + + public constructor(lang: SupportedLanguages = 'en') { + this.setLang(lang) + } + + /** + * @deprecated use the constructor parameter or {@link TCGdex.setLang} when in an instance + */ + public static setDefaultLang(lang: SupportedLanguages) { + TCGdex.defaultLang = lang + } + + /** + * @deprecated use {@link TCGdex.setLang} when in an instance + */ + public static getDefaultLang(): SupportedLanguages { + return TCGdex.defaultLang + } + + /** + * the endpoint URL + * ex: `https://api.tcgdex.net/v2` + * @param endpoint the url + */ + public setEndpoint(endpoint: string) { + this.endpointURL = endpoint + } + public getEndpoint(): string { + return this.endpointURL + } + + /** + * set the current cache methodology + * @param cache the cache to use + */ + public setCache(cache: CacheInterface) { + this.cache = cache + } + + /** + * get the current cache methodology + * @param cache the cache to use + */ + public getCache(): CacheInterface { + return this.cache + } + + /** + * the endpoint URL + * ex: `https://api.tcgdex.net/v2` + * @param endpoint the url + */ + public setCacheTTL(seconds: number) { + this.cacheTTL = seconds + } + /** + * get the current useed cache ttl in seconds + * @returns the cache ttl in seconds + */ + public getCacheTTL(): number { + return this.cacheTTL + } public getLang(): SupportedLanguages { return this.lang ?? TCGdex.defaultLang ?? 'en' } + public setLang(lang: SupportedLanguages) { + this.lang = lang + } + /** * Shortcut to easily fetch a card using both it's global id and it's local ID * @param id the card global/local ID @@ -47,7 +167,7 @@ export default class TCGdex { /** * @deprecated use `this.fetch('sets', set)` */ - public async fetchSet(set: string): Promise { + public async fetchSet(set: string): Promise { return this.fetch('sets', set) } @@ -104,7 +224,7 @@ export default class TCGdex { * @param endpoint_0 'sets' * @param endpoint_1 {string} the set name or ID */ - public async fetch(...endpoint: ['sets', string]): Promise + public async fetch(...endpoint: ['sets', string]): Promise /** * Fetch every sets @@ -148,7 +268,7 @@ export default class TCGdex { * @param endpoint_1 {string} (Optionnal) some details to go from the index file to the item file (mostly the ID/name) * @param endpoint_2 {string} (Optionnal) only for sets the card local ID to fetch the card through the set */ - public async fetch(...endpoint: Array): Promise { + public async fetch(...endpoint: Array): Promise { if (endpoint.length === 0) { throw new Error('endpoint to fetch is empty!') } @@ -157,29 +277,112 @@ export default class TCGdex { if (!ENDPOINTS.includes(baseEndpoint)) { throw new Error(`unknown endpoint to fetch! (${baseEndpoint})`) } - return this.makeRequest(baseEndpoint, ...endpoint) + return this.actualFetch(this.getFullURL([baseEndpoint, ...endpoint])) } /** - * Function to make the request and normalize the whole path + * @param endpoint the endpoint to fetch + * @param query the query */ - private makeRequest(...url: Array) { + public async fetchWithQuery( + endpoint: [Endpoints, ...Array], + query?: Array<{ key: string, value: string | number | boolean }> + ): Promise { + if (endpoint.length === 0) { + throw new Error('endpoint to fetch is empty!') + } + const baseEndpoint = endpoint[0].toLowerCase() as Endpoints + if (!ENDPOINTS.includes(baseEndpoint)) { + throw new Error(`unknown endpoint to fetch! (${baseEndpoint})`) + } + return this.actualFetch(this.getFullURL(endpoint, query)) + } + + /** + * format the final URL + */ + private getFullURL( + url: Array, + searchParams?: Array<{ key: string, value: string | number | boolean }> + ): string { // Normalize path - const path = url.map((subPath) => encodeURI( - subPath + let path = url.map(this.encode).join('/') + + // handle the Search Params + if (searchParams) { + path += '?' + for (let idx = 0; idx < searchParams.length; idx++) { + const param = searchParams[idx] + if (idx !== 0) { + path += '&' + } + path += `${this.encode(param.key)}=${this.encode(param.value)}` + } + } + + // return with the endpoint and all the shit + return `${this.getEndpoint()}/${this.getLang()}/${path}` + } + + private async actualFetch(path: string): Promise { + // get and return the cached value if available + const cached = this.cache.get(path) + if (cached) { + return cached as T + } + + // the actual Fetch :D + const resp = await TCGdex.fetch(path, { + headers: { + 'user-agent': `@tcgdex/javascript-sdk/${version}` + } + }) + + // throw if a server-side error is occured + if (resp.status >= 500) { + try { + const json = JSON.stringify(await resp.json()) + throw new Error(json) + } catch { + throw new Error('TCGdex Server responded with an invalid error :(') + } + } + + // response is not valid :O + if (resp.status !== 200) { + return undefined + } + + // parse, put to cache and return + const json = await resp.json() + this.cache.set(path, json, this.cacheTTL) + return json as T + } + + /** + * encode a string to be used in an url + * @param str the string to encode to URL + * @returns the encoded string + */ + private encode(str: string | number | boolean): string { + return encodeURI( + str // Transform numbers to string .toString() // replace this special character with an escaped one .replace('?', '%3F') // normalize the string .normalize('NFC') - // remove some special chars by nothing + // remove some special chars // eslint-disable-next-line no-misleading-character-class .replace(/["'\u0300-\u036f]/gu, '') - )).join('/') - return RequestWrapper.fetch(`${BASE_URL}/${this.getLang()}/${path}`) + ) } - } -export * from './interfaces' + +export * from './models/Card' +export { + Query +} + diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..f95b0df --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,20 @@ +import type { Endpoints } from './interfaces' + +/** + * detect the current running context ofthe program + */ +export function detectContext(): 'browser' | 'server' { + try { + const isBrowser = !!window + return isBrowser ? 'browser' : 'server' + } catch { + return 'server' + } +} + +export const ENDPOINTS: Array = [ + 'cards', 'categories', 'dex-ids', 'energy-types', + 'hp', 'illustrators', 'rarities', 'regulation-marks', + 'retreats', 'series', 'sets', 'stages', 'suffixes', + 'trainer-types', 'types', 'variants' +] as const diff --git a/tsconfig.json b/tsconfig.json index 70326d5..5f8a9b3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,10 @@ { "extends": "./node_modules/@dzeio/config/tsconfig.base", - "include": ["./src/tcgdex.node.ts"], "compilerOptions": { - "target": "ES2015", - "rootDir": "./src", - } + "outDir": "dist", + "strictNullChecks": true + }, + "exclude": [ + "__tests__" + ] }