From f454b2256996e468b73a358ba84ed0a0b14f4823 Mon Sep 17 00:00:00 2001 From: DrMint <29893320+DrMint@users.noreply.github.com> Date: Mon, 21 Aug 2023 02:53:33 +0200 Subject: [PATCH] Added generated SDK + route --- src/collections/Weapons/Weapons.ts | 4 +- .../Weapons/endpoints/getBySlugEndpoint.ts | 124 +++++++++++++++++ src/sdk.ts | 129 ++++++++++++++++++ src/server.ts | 19 ++- 4 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/collections/Weapons/endpoints/getBySlugEndpoint.ts create mode 100644 src/sdk.ts diff --git a/src/collections/Weapons/Weapons.ts b/src/collections/Weapons/Weapons.ts index 14f7311..628ac07 100644 --- a/src/collections/Weapons/Weapons.ts +++ b/src/collections/Weapons/Weapons.ts @@ -1,6 +1,5 @@ import { RowLabelArgs } from "payload/dist/admin/components/forms/RowLabel/types"; import { CollectionGroups, Collections, KeysTypes } from "../../constants"; -import { createGetByEndpoint } from "../../endpoints/createGetByEndpoint"; import { createGetSlugsEndpoint } from "../../endpoints/createGetSlugsEndpoint"; import { imageField } from "../../fields/imageField/imageField"; import { keysField } from "../../fields/keysField/keysField"; @@ -8,6 +7,7 @@ import { slugField } from "../../fields/slugField/slugField"; import { translatedFields } from "../../fields/translatedFields/translatedFields"; import { buildVersionedCollectionConfig } from "../../utils/versionedCollectionConfig"; import { AppearanceRowLabel } from "./components/AppearanceRowLabel"; +import { getBySlugEndpoint } from "./endpoints/getBySlugEndpoint"; import { importFromStrapi } from "./endpoints/importFromStrapi"; const fields = { @@ -46,7 +46,7 @@ export const Weapons = buildVersionedCollectionConfig({ endpoints: [ importFromStrapi, createGetSlugsEndpoint(Collections.Weapons), - createGetByEndpoint(Collections.Weapons, "slug"), + getBySlugEndpoint, ], fields: [ { diff --git a/src/collections/Weapons/endpoints/getBySlugEndpoint.ts b/src/collections/Weapons/endpoints/getBySlugEndpoint.ts new file mode 100644 index 0000000..1fd8be6 --- /dev/null +++ b/src/collections/Weapons/endpoints/getBySlugEndpoint.ts @@ -0,0 +1,124 @@ +import { Collections } from "../../../constants"; +import { createGetByEndpoint } from "../../../endpoints/createGetByEndpoint"; +import { EndpointBasicWeapon, EndpointWeapon } from "../../../sdk"; +import { Key, Language, Recorder, Weapon, WeaponsThumbnail } from "../../../types/collections"; +import { isDefined, isUndefined } from "../../../utils/asserts"; + +export const getBySlugEndpoint = createGetByEndpoint( + Collections.Weapons, + "slug", + (weapon: Weapon): EndpointWeapon => { + let group: EndpointWeapon["group"] = undefined; + + // We only send the group if the group has at least 2 weapons (1 weapon beside the current one) + // The weapons are ordered alphabetically using their slugs + if ( + typeof weapon.group === "object" && + isDefined(weapon.group.weapons) && + weapon.group.weapons.length > 1 + ) { + const { slug, translations = [], weapons } = weapon.group; + + const groupWeapons: EndpointBasicWeapon[] = []; + weapons.forEach((groupWeapon) => { + if (typeof groupWeapon === "object" && groupWeapon.id !== weapon.id) { + groupWeapons.push(convertWeaponToEndpointBasicWeapon(groupWeapon)); + } + }); + + groupWeapons.sort((a, b) => a.slug.localeCompare(b.slug)); + + group = { + slug, + translations: translations.map(({ language, name }) => ({ + language: getLanguageId(language), + name, + })), + weapons: groupWeapons, + }; + } + + return { + ...convertWeaponToEndpointBasicWeapon(weapon), + appearances: weapon.appearances.map(({ categories, translations }) => ({ + categories: categories.map(getKeyId), + translations: translations.map( + ({ + language, + sourceLanguage, + transcribers = [], + translators = [], + proofreaders = [], + ...otherTranslatedProps + }) => ({ + language: getLanguageId(language), + sourceLanguage: getLanguageId(sourceLanguage), + transcribers: transcribers.map(getRecorderId), + translators: translators.map(getRecorderId), + proofreaders: proofreaders.map(getRecorderId), + ...otherTranslatedProps, + }) + ), + })), + group, + }; + } +); + +const getRecorderId = (recorder: string | Recorder) => + typeof recorder === "object" ? recorder.id : recorder; +const getKeyId = (key: string | Key) => (typeof key === "object" ? key.id : key); +const getLanguageId = (language: string | Language) => + typeof language === "object" ? language.id : language; + +const getThumbnail = (thumbnail?: string | WeaponsThumbnail): WeaponsThumbnail | undefined => { + if (isUndefined(thumbnail)) return undefined; + if (typeof thumbnail === "string") return undefined; + delete thumbnail.weapon; + return thumbnail; +}; + +const convertWeaponToEndpointBasicWeapon = ({ + slug, + thumbnail, + type, + appearances, +}: Weapon): EndpointBasicWeapon => { + const categories = new Set(); + appearances.forEach((appearance) => + appearance.categories.forEach((category) => categories.add(getKeyId(category))) + ); + + const languages = new Set(); + appearances.forEach(({ translations }) => + translations.forEach(({ language }) => languages.add(getLanguageId(language))) + ); + + const translations: EndpointWeapon["translations"] = [...languages.values()].map( + (targetLanguage) => { + const names = new Set(); + appearances.forEach(({ translations }) => { + const translation = translations.find( + ({ language }) => getLanguageId(language) === targetLanguage + ); + if (translation) { + names.add(translation.name); + } + }); + const [name, ...aliases] = names; + + if (isUndefined(name)) + throw new Error("A weapon should always have a name for each of its translatiion"); + + return { language: targetLanguage, name: name, aliases }; + } + ); + + return { + slug, + thumbnail: getThumbnail(thumbnail), + type: getKeyId(type), + categories: [...categories.values()], + translations, + }; +}; diff --git a/src/sdk.ts b/src/sdk.ts new file mode 100644 index 0000000..6ac212b --- /dev/null +++ b/src/sdk.ts @@ -0,0 +1,129 @@ +import { Collections } from "./constants"; +import { WeaponsThumbnail } from "./types/collections"; + +class NodeCache { + constructor(_params: any) {} + getTtl(_key: string): number | undefined { + return undefined; + } + get(_key: string): T | undefined { + return undefined; + } + set(_key: string, _value: T, _ttl: number | string) {} +} + +// END MOCKING SECTION + +const REFRESH_FREQUENCY_IN_SEC = 60; +const CACHE = new NodeCache({ + checkperiod: REFRESH_FREQUENCY_IN_SEC, + deleteOnExpire: true, + forceString: true, + maxKeys: 1, +}); +const TOKEN_KEY = "token"; + +type PayloadLoginResponse = { + token: string; + exp: number; +}; + +const refreshToken = async () => { + const loginUrl = payloadApiUrl(Collections.Recorders, "login"); + const loginResult = await fetch(loginUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: process.env.PAYLOAD_USER, + password: process.env.PAYLOAD_PASSWORD, + }), + }); + logResponse(loginResult); + + if (loginResult.status !== 200) { + throw new Error("Unable to login"); + } + + const loginJson = (await loginResult.json()) as PayloadLoginResponse; + const { token, exp } = loginJson; + const now = Math.floor(Date.now() / 1000); + const ttl = Math.floor(exp - now - REFRESH_FREQUENCY_IN_SEC * 2); + const ttlInMinutes = Math.floor(ttl / 60); + console.log("Token was refreshed. TTL is", ttlInMinutes, "minutes."); + CACHE.set(TOKEN_KEY, token, ttl); + return token; +}; + +const getToken = async (): Promise => { + const cachedToken = CACHE.get(TOKEN_KEY); + if (cachedToken !== undefined) { + const cachedTokenTtl = CACHE.getTtl(TOKEN_KEY) as number; + const diffInMinutes = Math.floor((cachedTokenTtl - Date.now()) / 1000 / 60); + console.log("Retrieved token from cache. TTL is", diffInMinutes, "minutes."); + return cachedToken; + } + console.log("Refreshing token"); + return await refreshToken(); +}; + +const injectAuth = async (init?: RequestInit): Promise => ({ + ...init, + headers: { ...init?.headers, Authorization: `JWT ${await getToken()}` }, +}); + +const logResponse = (res: Response) => console.log(res.status, res.statusText, res.url); + +const payloadApiUrl = (collection: Collections, endpoint?: string): string => + `${process.env.PAYLOAD_API_URL}/${collection}${endpoint === undefined ? "" : `/${endpoint}`}`; + +const request = async (url: string, init?: RequestInit): Promise => { + const result = await fetch(url, await injectAuth(init)); + logResponse(result); + + if (result.status !== 200) { + throw new Error("Unhandled fetch error"); + } + + return result; +}; + +// SDK and Types + +export type EndpointWeapon = EndpointBasicWeapon & { + appearances: { + categories: string[]; + translations: { + language: string; + sourceLanguage: string; + name: string; + description?: string; + level1?: string; + level2?: string; + level3?: string; + level4?: string; + transcribers: string[]; + translators: string[]; + proofreaders: string[]; + }[]; + }[]; + group?: { + slug: string; + translations: { language: string; name: string }[]; + weapons: EndpointBasicWeapon[]; + }; +}; + +export type EndpointBasicWeapon = { + slug: string; + type: string; + categories: string[]; + translations: { language: string; name: string; aliases: string[] }[]; + thumbnail?: WeaponsThumbnail; +}; + +export const payload = { + getSlugsWeapons: async (): Promise => + await (await request(payloadApiUrl(Collections.Weapons, "slugs"))).json(), + getWeapon: async (slug: string): Promise => + await (await request(payloadApiUrl(Collections.Weapons, `slug/${slug}`))).json(), +}; diff --git a/src/server.ts b/src/server.ts index c080516..221545c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import "dotenv/config"; import express from "express"; +import { readFileSync } from "fs"; import path from "path"; import payload from "payload"; import { Collections, RecordersRoles } from "./constants"; @@ -46,7 +47,7 @@ const start = async () => { email: process.env.SEEDING_ADMIN_EMAIL, password: process.env.SEEDING_ADMIN_PASSWORD, username: process.env.SEEDING_ADMIN_USERNAME, - role: [RecordersRoles.Admin], + role: [RecordersRoles.Admin, RecordersRoles.Api], anonymize: false, }; await payload.create({ @@ -61,6 +62,22 @@ const start = async () => { // Add your own express routes here app.use("/public", express.static(path.join(__dirname, "../public"))); + app.get("/api/sdk", (_, res) => { + const collections = readFileSync(path.join(__dirname, "types/collections.ts"), "utf-8"); + + const constantsHeader = "/////////////// CONSTANTS ///////////////\n"; + const constants = readFileSync(path.join(__dirname, "constants.ts"), "utf-8"); + + const sdkHeader = "////////////////// SDK //////////////////\n"; + const sdkLines = readFileSync(path.join(__dirname, "sdk.ts"), "utf-8").split("\n"); + const endMockingLine = sdkLines.findIndex((line) => line === "// END MOCKING SECTION") ?? 0; + const sdk = + `import NodeCache from "node-cache";\n\n` + sdkLines.slice(endMockingLine + 1).join("\n"); + + res.type("text/plain"); + res.send([collections, constantsHeader, constants, sdkHeader, sdk].join("\n\n")); + }); + app.get("/robots.txt", (_, res) => { res.type("text/plain"); res.send("User-agent: *\nDisallow: /");