diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1260ed5..7704b92 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,4 +1,8 @@ { - "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"], + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ryanluker.vscode-coverage-gutters" + ], "unwantedRecommendations": [] } diff --git a/src/lib/endpoint.ts b/src/lib/endpoint.ts index 426fdf2..a6bd519 100644 --- a/src/lib/endpoint.ts +++ b/src/lib/endpoint.ts @@ -2,6 +2,7 @@ import { parseHTML } from "linkedom/worker"; import type { InferColumns, + InferFields, InferItem, InferList, InferQuery, @@ -32,7 +33,7 @@ export type EndpointOptions = { headers?: Record; locale?: strin export class Endpoint { /** * A generic Lodestone endpoint used to search and get items from Lodestone. - * @param {T} registry The provided endpoint registry containing field selectors to obtain + * @param {R} registry The provided endpoint registry containing field selectors to obtain * @param {EndpointOptions} [options] Default method options to use when fetching from Lodestone. * @since 0.1.0 */ @@ -41,6 +42,7 @@ export class Endpoint { protected readonly options?: EndpointOptions ) {} + /* v8 ignore next */ private check(response: Response): void { const { status } = response; if (status === 200) return; @@ -50,6 +52,7 @@ export class Endpoint { if (status >= 400) throw new LodestoneError(`Request failed with status ${status}.`); } + /* v8 ignore next */ private async req(path: string, options?: EndpointOptions): Promise { const { headers: rawHeaders = {}, locale = "na" } = options ?? this.options!; const headers = Object.fromEntries( @@ -184,6 +187,18 @@ export class Endpoint { } } + private pickSelectors( + selectors: T, + keys: K[] + ): Pick { + const out: Partial> = {}; + for (const key of keys) { + out[key] = selectors[key]; + } + return out as Pick; + } + + /* v8 ignore next */ private extract(dom: Document | Element, selectors: T): InferSelectors { const out: Record = {}; @@ -202,11 +217,12 @@ export class Endpoint { if (isArray) { const nodes = [...dom.querySelectorAll(sel.selector)]; - out[key] = nodes.map((n) => { + const array = nodes.map((n) => { const raw = this.getRawValue(n, sel); const extracted = this.applyRegex(raw, sel); return this.coerce(extracted, base); }); + out[key] = array.length > 0 ? array : undefined; continue; } @@ -230,10 +246,11 @@ export class Endpoint { * @returns {Promise[] | null>} * @since 0.1.0 */ - public async find( + public async find, string>> = []>( query: InferQuery, - options: EndpointOptions = {} + options: EndpointOptions & { fields?: F } = {} ): Promise[] | null> { + const { fields: filteredFields, ...rest } = options; this.validate(query); const parameters = new URLSearchParams( @@ -249,13 +266,17 @@ export class Endpoint { const document = await this.fetchDocument( `?${parameters}`, - Object.assign(this.options ?? {}, options) + Object.assign(this.options ?? {}, rest) ); if (!document) return null; + const selectedFields = filteredFields?.length + ? this.pickSelectors(this.registry.item.fields, filteredFields) + : this.registry.item.fields; + const entries = [...document.querySelectorAll("div.entry")]; const results = entries - .map((element) => this.extract(element, this.registry.list.fields)) + .map((element) => this.extract(element, selectedFields)) .filter((v) => v.id !== null || v.id !== undefined); return results as InferList[]; @@ -264,26 +285,38 @@ export class Endpoint { /** * @param {NumberResolvable} id The unique identifier for the Lodestone item. * @param {EndpointOptions & { columns?: Array> }} [options] Optional method overrides. - * @returns {Promise<(InferItem & Partial) | null>} + * @returns {Promise<(InferItem) | null>} * @since 0.1.0 */ - public async get( + public async get< + F extends Array, string>> | undefined = undefined, + C extends Array, string>> | undefined = undefined, + >( id: NumberResolvable, - options: EndpointOptions & { columns?: Array> } = {} - ): Promise<(InferItem & Partial>) | null> { - const { columns, ...rest } = Object.assign(this.options ?? {}, options); + options: EndpointOptions & { fields?: F; columns?: C } = {} + ): Promise | null> { + const { columns, fields: filteredFields, ...rest } = options; - const document = await this.fetchDocument(id.toString(), rest); + const document = await this.fetchDocument( + id.toString(), + Object.assign(this.options ?? {}, rest) + ); if (!document) return null; - const fields = this.extract(document, this.registry.item.fields); + const selectedFields = filteredFields?.length + ? this.pickSelectors(this.registry.item.fields, filteredFields) + : this.registry.item.fields; + + const fields = this.extract(document, selectedFields); if (columns && this.registry.item.columns) { for (const key of columns) { const value = await this.fetchColumn(id, String(key), rest); - if (value !== undefined) fields[key as string] = value as Primitives; + if (value !== undefined) { + fields[key as string] = value as Primitives; + } } } - return fields as InferItem & Partial>; + return fields as InferItem; } } diff --git a/src/lib/registry.ts b/src/lib/registry.ts index f37b99c..f13e6dc 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -1,5 +1,11 @@ // type definitions -export type Primitives = { boolean: boolean; number: number; string: string; date: Date; url: URL }; +export type Primitives = { + boolean: boolean; + number: number; + string: string; + date: Date; + url: URL; +}; export type Primitive = keyof Primitives; // selectors type definitions @@ -29,7 +35,9 @@ export type Registry = { export type ExtractPrimitive = Primitives[T]; export type ExtractQuery = T["type"]; -type RequiredKeys = { [K in keyof T]: T[K] extends { required: true } ? K : never }[keyof T]; +type RequiredKeys = { + [K in keyof T]: T[K] extends { required: true } ? K : never; +}[keyof T]; export type InferQuery = Partial<{ [K in keyof R["list"]["query"]]: ExtractPrimitive>; @@ -37,7 +45,10 @@ export type InferQuery = Partial<{ [K in RequiredKeys]: ExtractPrimitive>; }; -export type InferSelectors = { [K in keyof T]: InferSelector }; +export type InferSelectors = { + [K in keyof T]: InferSelector; +}; + export type InferSelector = T extends { type: infer U } ? U extends keyof Primitives ? Primitives[U] @@ -52,11 +63,38 @@ export type InferSelector = T extends { type: infer U } : never : never; -export type InferItem = InferSelectors; -export type InferList = InferSelectors; -export type InferColumns = R["item"]["columns"] extends Selectors +export type InferFields = InferSelectors; + +export type InferColumns = R["item"] extends { columns: Selectors } ? { [K in keyof R["item"]["columns"]]: InferSelector } - : never; + : object; + +// selection helpers +export type InferSelectedFields>> = { + [K in F[number]]: InferFields[K]; +}; + +export type InferSelectedColumns>> = { + [K in C[number]]: InferColumns[K]; +}; + +// item inference with optional selection +type InferItemFields = F extends undefined + ? InferFields + : F extends Array> + ? InferSelectedFields + : never; + +type InferItemColumns = C extends undefined + ? InferColumns + : C extends Array> + ? InferSelectedColumns + : never; + +export type InferItem = InferItemFields & + InferItemColumns; + +export type InferList = InferSelectors; // endpoint registry definitions export const character = { @@ -80,7 +118,7 @@ export const character = { avatar: { attribute: "src", selector: ".frame__chara__face > img:nth-child(1)", - type: "string", + type: "url", }, bio: { selector: ".character__selfintroduction", type: "string" }, data_center: { @@ -93,7 +131,7 @@ export const character = { crest: { attribute: "src", selector: "div.character__freecompany__crest > div > img", - type: "string[]", + type: "url[]", }, id: { attribute: "href", @@ -136,14 +174,14 @@ export const character = { portrait: { attribute: "src", selector: ".js__image_popup > img:nth-child(1)", - type: "string", + type: "url", }, pvp_team: { shape: { crest: { attribute: "src", selector: ".character__pvpteam__crest__image > img", - type: "string[]", + type: "url[]", }, id: { attribute: "href", @@ -161,7 +199,7 @@ export const character = { }, list: { fields: { - avatar: { attribute: "src", selector: ".entry__chara__face > img", type: "string" }, + avatar: { attribute: "src", selector: ".entry__chara__face > img", type: "url" }, data_center: { regex: /\[(?\w+)]/, selector: ".entry__world", type: "string" }, grand_company: { shape: { @@ -212,7 +250,7 @@ export const cwls = { formed: { regex: /ldst_strftime\((\d+),/, selector: ".heading__cwls__formed > script", - type: "string", + type: "date", }, members: { regex: /(?\d+)/, @@ -252,7 +290,7 @@ export const freecompany = { attribute: "src", selector: "div.ldst__window:nth-child(1) > div:nth-child(2) > a:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > img", - type: "string[]", + type: "url[]", }, data_center: { regex: /\[(?\w+)]/, @@ -270,7 +308,7 @@ export const freecompany = { formed: { regex: /ldst_strftime\((\d+),/, selector: "p.freecompany__text:nth-of-type(5) > script", - type: "string", + type: "date", }, grand_company: { shape: { @@ -324,7 +362,7 @@ export const freecompany = { crest: { attribute: "src", selector: ".entry__freecompany__crest__image > img", - type: "string[]", + type: "url[]", }, data_center: { regex: /\[(?\w+)]/, @@ -334,7 +372,7 @@ export const freecompany = { formed: { regex: /ldst_strftime\((\d+),/, selector: ".entry__freecompany__fc-day > script", - type: "string", + type: "date", }, grand_company: { shape: { @@ -425,13 +463,13 @@ export const pvpteam = { crest: { attribute: "src", selector: ".entry__pvpteam__crest__image > img", - type: "string[]", + type: "url[]", }, data_center: { selector: ".entry__pvpteam__name--dc", type: "string" }, formed: { regex: /ldst_strftime\((\d+),/, selector: ".entry__pvpteam__data--formed > script", - type: "string", + type: "date", }, name: { selector: ".entry__pvpteam__name--team", type: "string" }, }, @@ -441,7 +479,7 @@ export const pvpteam = { crest: { attribute: "src", selector: ".entry__pvpteam__search__crest__image > img", - type: "string[]", + type: "url[]", }, data_center: { selector: ".entry__world", type: "string" }, id: { diff --git a/tests/lib/lodestone.test.ts b/tests/lib/lodestone.test.ts index 71bc86c..2ff88b4 100644 --- a/tests/lib/lodestone.test.ts +++ b/tests/lib/lodestone.test.ts @@ -42,6 +42,28 @@ describe("Lodestone", () => { expect(result).not.toHaveProperty("mount"); expect(result).not.toHaveProperty("faceaccessory"); }); + + it("returns only the requested fields", async () => { + const result = await lodestone.character.get("29193229", { + fields: ["name"], + }); + + expect(result).toBeDefined(); + expect(result).toHaveProperty("name"); + expect(result).not.toHaveProperty("id"); + expect(result).not.toHaveProperty("title"); + }); + + it("allows selecting both fields and columns", async () => { + const result = await lodestone.character.get("29193229", { + columns: ["achievement"], + fields: ["name"], + }); + + expect(result).toHaveProperty("name"); + expect(result).toHaveProperty("achievement"); + expect(result).not.toHaveProperty("id"); + }); }); describe("find", () => { @@ -59,6 +81,20 @@ describe("Lodestone", () => { expect(Array.isArray(results)).toBe(true); expect(results?.length).toBeGreaterThan(0); }); + + it("returns only the requested fields", async () => { + const results = await lodestone.character.find( + { q: "Chomu Suke", worldname: "Raiden" }, + { fields: ["name"] } + ); + + expect(results).toBeDefined(); + expect(Array.isArray(results)).toBe(true); + + const first = results?.[0]; + expect(first).toHaveProperty("name"); + expect(first).not.toHaveProperty("id"); + }); }); }); @@ -100,6 +136,7 @@ describe("Lodestone", () => { describe("get", () => { it("returns null for an invalid identifier", async () => { const result = await lodestone.freecompany.get(0); + expect(result).toBeNull(); });