diff --git a/demo/app-root.ts b/demo/app-root.ts index 89f9ab1..eb66975 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -4,13 +4,12 @@ import { customElement } from 'lit/decorators.js'; // Lit's html`` tag cannot render variable tag names directly. import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -const storyModules = import.meta.glob( - ['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'], - { eager: true } -); +const storyModules = import.meta.glob(['../src/**/*-story.ts'], { + eager: true, +}); const storyEntries = Object.keys(storyModules) - .map(path => { + .map((path) => { const labs = path.includes('/src/labs/'); const parts = path.split('/'); const filename = parts[parts.length - 1]; // e.g. "ia-button-story.ts" @@ -19,13 +18,15 @@ const storyEntries = Object.keys(storyModules) }) .sort((a, b) => a.tag.localeCompare(b.tag)); -const productionEntries = storyEntries.filter(e => !e.labs); -const labsEntries = storyEntries.filter(e => e.labs); +const productionEntries = storyEntries.filter((e) => !e.labs); +const labsEntries = storyEntries.filter((e) => e.labs); const ALL_ENTRIES = [...productionEntries, ...labsEntries]; @customElement('app-root') export class AppRoot extends LitElement { - createRenderRoot() { return this; } + createRenderRoot() { + return this; + } private _observer?: IntersectionObserver; private _abortController = new AbortController(); @@ -34,33 +35,42 @@ export class AppRoot extends LitElement { return html`

Internet Archive Elements

Production-Ready Elements

- ${productionEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${productionEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}

Labs Elements

- ${labsEntries.map(e => html` -
- ${unsafeHTML(`<${e.storyTag}>`)} -
- `)} + ${labsEntries.map( + (e) => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `, + )}
`; } firstUpdated() { - const allIds = ALL_ENTRIES.map(e => e.id); + const allIds = ALL_ENTRIES.map((e) => e.id); const links = Object.fromEntries( - allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) + allIds.map((id) => [ + id, + this.querySelector(`#ia-sidebar a[href="#${id}"]`), + ]), ); const visible = new Set(); @@ -68,31 +78,37 @@ export class AppRoot extends LitElement { // Only anchors in the top 30% of the viewport count as "active". // The first (topmost) visible anchor wins. this._observer = new IntersectionObserver( - entries => { + (entries) => { for (const entry of entries) { if (entry.isIntersecting) visible.add(entry.target.id); else visible.delete(entry.target.id); } - const activeId = allIds.find(id => visible.has(id)) ?? allIds[0]; - allIds.forEach(id => links[id]?.classList.toggle('active', id === activeId)); + const activeId = allIds.find((id) => visible.has(id)) ?? allIds[0]; + allIds.forEach((id) => + links[id]?.classList.toggle('active', id === activeId), + ); }, { rootMargin: '0px 0px -70% 0px' }, ); - allIds.forEach(id => { + allIds.forEach((id) => { const el = document.getElementById(id); if (el) this._observer!.observe(el); }); - allIds.forEach(id => { - links[id]?.addEventListener('click', (e: Event) => { - e.preventDefault(); - const el = document.getElementById(id); - if (el) { - const top = el.getBoundingClientRect().top + window.scrollY; - window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); - } - }, { signal: this._abortController.signal }); + allIds.forEach((id) => { + links[id]?.addEventListener( + 'click', + (e: Event) => { + e.preventDefault(); + const el = document.getElementById(id); + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + } + }, + { signal: this._abortController.signal }, + ); }); } diff --git a/src/services/metadata-service/backend/default-metadata-backend.test.ts b/src/services/metadata-service/backend/default-metadata-backend.test.ts new file mode 100644 index 0000000..cac2b95 --- /dev/null +++ b/src/services/metadata-service/backend/default-metadata-backend.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from 'vitest'; + +import { DefaultMetadataBackend } from './default-metadata-backend'; +import { MetadataServiceErrorType } from '../metadata-service-error'; + +describe('DefaultMetadataBackend', () => { + const fetchBackup = window.fetch; + + afterEach(() => { + window.fetch = fetchBackup; + }); + + test('can fetch metadata', async () => { + window.fetch = (): Promise => + Promise.resolve(new Response('{ "foo": "bar" }')); + + const backend = new DefaultMetadataBackend(); + const result = await backend.fetchMetadata('foo'); + expect(result.success?.foo).toBe('bar'); + }); + + test('returns a networkError if there is a problem fetching using String type', async () => { + window.fetch = (): Promise => { + throw 'network error'; + }; + + const backend = new DefaultMetadataBackend(); + const result = await backend.fetchMetadata('foo'); + expect(result.error?.type).toBe(MetadataServiceErrorType.networkError); + expect(result.error?.message).toBe('network error'); + }); + + test('returns a networkError if there is a problem fetching using Error type', async () => { + window.fetch = (): Promise => { + throw new Error('network error'); + }; + + const backend = new DefaultMetadataBackend(); + const result = await backend.fetchMetadata('foo'); + expect(result.error?.type).toBe(MetadataServiceErrorType.networkError); + expect(result.error?.message).toBe('network error'); + }); + + test('returns a decodingError if there is a problem decoding the json', async () => { + window.fetch = (): Promise => + Promise.resolve(new Response('boop')); + + const backend = new DefaultMetadataBackend(); + const result = await backend.fetchMetadata('foo'); + expect(result.error?.type).toBe(MetadataServiceErrorType.decodingError); + }); + + test('appends the scope if provided', async () => { + let urlCalled = ''; + window.fetch = (input: RequestInfo | URL): Promise => { + urlCalled = input.toString(); + return Promise.resolve(new Response('boop')); + }; + + const backend = new DefaultMetadataBackend({ scope: 'foo' }); + await backend.fetchMetadata('foo'); + expect(urlCalled.includes('scope=foo')).toBe(true); + }); + + test('credentials for metadata endpoint', async () => { + let urlConfig: RequestInit | undefined; + window.fetch = ( + _input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + urlConfig = init; + return Promise.resolve(new Response('boop')); + }; + + const backend = new DefaultMetadataBackend({ + scope: 'foo', + includeCredentials: true, + }); + await backend.fetchMetadata('foo'); + expect(urlConfig?.credentials).toBe('include'); + }); +}); diff --git a/src/services/metadata-service/backend/default-metadata-backend.ts b/src/services/metadata-service/backend/default-metadata-backend.ts new file mode 100644 index 0000000..2178c4b --- /dev/null +++ b/src/services/metadata-service/backend/default-metadata-backend.ts @@ -0,0 +1,135 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Result } from '@src/types/result'; +import { + MetadataServiceError, + MetadataServiceErrorType, +} from '../metadata-service-error'; +import { MetadataBackendInterface } from './metadata-backend-interface'; + +/** + * The DefaultSearchBackend performs a `window.fetch` request to archive.org + */ +export class DefaultMetadataBackend implements MetadataBackendInterface { + private baseUrl: string; + + private includeCredentials: boolean; + + private requestScope?: string; + + constructor(options?: { + baseUrl?: string; + includeCredentials?: boolean; + scope?: string; + }) { + this.baseUrl = options?.baseUrl ?? 'archive.org'; + + if (options?.includeCredentials !== undefined) { + this.includeCredentials = options.includeCredentials; + } else { + // include credentials if the request is coming from an archive.org domain + // since credentialed requests are only allowed from archive.org domains + // due to CORS restrictions, see + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors/CORSNotSupportingCredentials + this.includeCredentials = + window.location.href.match(/^https?:\/\/.*archive\.org(:[0-9]+)?/) !== + null; + } + + if (options?.scope !== undefined) { + this.requestScope = options.scope; + } else { + const currentUrl = new URL(window.location.href); + const scope = currentUrl.searchParams.get('scope'); + if (scope) { + this.requestScope = scope; + } + } + } + + /** @inheritdoc */ + async fetchMetadata( + identifier: string, + keypath?: string, + ): Promise> { + const path = keypath ? `/${keypath}` : ''; + const url = `https://${this.baseUrl}/metadata/${identifier}${path}`; + return this.fetchUrl(url); + } + + /** + * Fires a request to the URL (with this backend's options applied) and + * asynchronously returns a Result object containing either the raw response + * JSON or a MetadataServiceError. + */ + protected async fetchUrl( + url: string, + options?: { + requestOptions?: RequestInit; + }, + ): Promise> { + const finalUrl = new URL(url); + if (this.requestScope) { + finalUrl.searchParams.set('scope', this.requestScope); + } + + let response: Response; + // first try the fetch and return a networkError if it fails + try { + const fetchOptions = options?.requestOptions ?? { + credentials: this.includeCredentials ? 'include' : 'same-origin', + }; + response = await fetch(finalUrl.href, fetchOptions); + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : 'Unknown error'; + return this.getErrorResult( + MetadataServiceErrorType.networkError, + message, + ); + } + + // then try json decoding and return a decodingError if it fails + try { + const json = await response.json(); + // the advanced search endpoint doesn't return an HTTP Error 400 + // and instead returns an HTTP 200 with an `error` key in the payload + const error = json['error']; + if (error) { + const forensics = json['forensics']; + return this.getErrorResult( + MetadataServiceErrorType.searchEngineError, + error, + forensics, + ); + } else { + // success + return { success: json }; + } + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : 'Unknown error'; + return this.getErrorResult( + MetadataServiceErrorType.decodingError, + message, + ); + } + } + + private getErrorResult( + errorType: MetadataServiceErrorType, + message?: string, + details?: any, + ): Result { + const error = new MetadataServiceError(errorType, message, details); + const result = { error }; + return result; + } +} diff --git a/src/services/metadata-service/backend/metadata-backend-interface.ts b/src/services/metadata-service/backend/metadata-backend-interface.ts new file mode 100644 index 0000000..e7ff671 --- /dev/null +++ b/src/services/metadata-service/backend/metadata-backend-interface.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Result } from '@src/types/result'; +import { MetadataServiceError } from '../metadata-service-error'; + +/** + * An interface to provide the network layer to the `MetadataService`. + * + * Objects implementing this interface are responsible for making calls to the Internet Archive + * `metadata` endpoint or otherwise providing a similar reponse in JSON format. + * + * @export + * @interface MetadataBackendInterface + */ +export interface MetadataBackendInterface { + /** + * Fetch metadata for a single item with an optional keypath + * + * @param identifier + * @param keypath + */ + fetchMetadata( + identifier: string, + keypath?: string, + ): Promise>; +} diff --git a/src/services/metadata-service/metadata-service-error.ts b/src/services/metadata-service/metadata-service-error.ts new file mode 100644 index 0000000..1d20e4d --- /dev/null +++ b/src/services/metadata-service/metadata-service-error.ts @@ -0,0 +1,23 @@ +export const MetadataServiceErrorType = { + networkError: 'MetadataService.NetworkError', + itemNotFound: 'MetadataService.ItemNotFound', + decodingError: 'MetadataService.DecodingError', + searchEngineError: 'MetadataService.SearchEngineError', +} as const; +export type MetadataServiceErrorType = + (typeof MetadataServiceErrorType)[keyof typeof MetadataServiceErrorType]; + +export class MetadataServiceError extends Error { + type: MetadataServiceErrorType; + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + details?: any; + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(type: MetadataServiceErrorType, message?: string, details?: any) { + super(message); + this.name = type; + this.type = type; + this.details = details; + } +} diff --git a/src/services/metadata-service/metadata-service-interface.ts b/src/services/metadata-service/metadata-service-interface.ts new file mode 100644 index 0000000..1dc9ce0 --- /dev/null +++ b/src/services/metadata-service/metadata-service-interface.ts @@ -0,0 +1,51 @@ +import type { Result } from '@src/types/result'; +import type { MetadataServiceError } from './metadata-service-error'; +import type { MetadataResponse } from './responses/metadata-response'; + +export interface MetadataServiceInterface { + /** + * Fetch metadata for a given identifier + * + * @param {string} identifier + * @returns {Promise>} + */ + fetchMetadata( + identifier: string, + ): Promise>; + + /** + * Fetch the metadata value for a given identifier and keypath + * + * The response from this request can take any form, object, array, string, etc. + * depending on the query. You can provide return typing in the response by + * specifying the type. Note, there is no automatic type conversion since it can be anything. + * + * For example: + * + * ```ts + * const collection = await searchService.fetchMetadataValue('goody', 'metadata/collection/0'); + * console.debug('collection:', collection); => 'Goody Collection' + * + * const files_count = await searchService.fetchMetadataValue('goody', 'files_count'); + * console.debug('files_count:', files_count); => 12 + * ``` + * + * Keypath examples: + * + * /metadata/:identifier/metadata // returns the entire metadata object + * /metadata/:identifier/server // returns the server for the given identifier + * /metadata/:identifier/files_count + * /metadata/:identifier/files?start=1&count=2 // query for files + * /metadata/:identifier/metadata/collection // all collections + * /metadata/:identifier/metadata/collection/0 // first collection + * /metadata/:identifier/metadata/title + * /metadata/:identifier/files/0/name // first file name + * + * @param identifier + * @param keypath + */ + fetchMetadataValue( + identifier: string, + keypath: string, + ): Promise>; +} diff --git a/src/services/metadata-service/metadata-service-story.ts b/src/services/metadata-service/metadata-service-story.ts new file mode 100644 index 0000000..daf91b2 --- /dev/null +++ b/src/services/metadata-service/metadata-service-story.ts @@ -0,0 +1,250 @@ +import { css, html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import '@demo/story-template'; +import { MetadataService } from './metadata-service'; +import type { MetadataResponse } from './responses/metadata-response'; +import type { MetadataServiceError } from './metadata-service-error'; + +/** + * Interactive demo for the `MetadataService`. It performs a live `fetch` + * against the archive.org metadata API and models the response — exercising + * `Result`, the `MetadataResponse`, and the typed `Metadata` model together. + */ + +const EXAMPLE_USAGE = `const result = await MetadataService.default.fetchMetadata('goody'); + +if (result.error) { + console.error(result.error.type, result.error.message); +} else { + const { metadata, files_count, server } = result.success; + metadata.title?.value; // 'The history of Little Goody Two-Shoes…' + metadata.mediatype?.value; // 'texts' +} + +// …or fetch a single value by keypath: +const title = await MetadataService.default + .fetchMetadataValue('goody', 'metadata/title'); +title.success; // 'The history of Little Goody Two-Shoes…'`; + +@customElement('metadata-service-story') +export class MetadataServiceStory extends LitElement { + @state() private identifier = 'goody'; + @state() private metaLoading = false; + @state() private response?: MetadataResponse; + @state() private metaError?: MetadataServiceError; + + @state() private keypath = 'metadata/title'; + @state() private valueLoading = false; + @state() private value?: unknown; + @state() private valueFetched = false; + @state() private valueError?: MetadataServiceError; + + render() { + return html` + +
+

+ Live fetch against + https://archive.org/metadata/<identifier>. The + service returns a Result wrapping a typed + MetadataResponse (or a + MetadataServiceError). +

+ +
+ + +
+ + ${this.renderMetadataResult()} + +
+ + +
+ + ${this.renderValueResult()} +
+
+ `; + } + + private renderMetadataResult() { + if (this.metaError) { + return html`
+ ${this.metaError.type} + ${this.metaError.message ? html`— ${this.metaError.message}` : ''} +
`; + } + const r = this.response; + if (!r) return html``; + const { metadata } = r; + const rows: [string, unknown][] = [ + ['metadata.title?.value', metadata.title?.value], + ['metadata.mediatype?.value', metadata.mediatype?.value], + ['metadata.date?.value', metadata.date?.value], + ['files_count', r.files_count], + ['item_size', r.item_size], + ['server', r.server], + ['files.length', r.files?.length], + ]; + return html`
+ + ${rows.map( + ([k, v]) => + html` + + + `, + )} +
${k}${this.format(v)}
+
`; + } + + private renderValueResult() { + if (this.valueError) { + return html`
+ ${this.valueError.type} + ${this.valueError.message ? html`— ${this.valueError.message}` : ''} +
`; + } + if (!this.valueFetched) return html``; + return html`
+ ${this.format(this.value)} +
`; + } + + private async fetchMetadata() { + this.metaLoading = true; + this.metaError = undefined; + this.response = undefined; + const result = await MetadataService.default.fetchMetadata(this.identifier); + if (result.error) this.metaError = result.error; + else this.response = result.success; + this.metaLoading = false; + } + + private async fetchValue() { + this.valueLoading = true; + this.valueError = undefined; + this.valueFetched = false; + const result = await MetadataService.default.fetchMetadataValue( + this.identifier, + this.keypath, + ); + if (result.error) { + this.valueError = result.error; + } else { + this.value = result.success; + this.valueFetched = true; + } + this.valueLoading = false; + } + + private format(value: unknown): string { + if (value === undefined || value === null) return '—'; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); + } + + static styles = css` + .intro { + margin-top: 0; + max-width: 42rem; + } + + .row { + display: flex; + align-items: flex-end; + gap: 10px; + margin: 10px 0; + } + + label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8rem; + font-weight: 600; + color: #666; + } + + input { + padding: 5px 7px; + font-size: 0.9rem; + min-width: 16rem; + } + + button { + padding: 6px 12px; + font-size: 0.85rem; + font-family: ui-monospace, monospace; + cursor: pointer; + border: 1px solid #194880; + background: #194880; + color: #fff; + border-radius: 4px; + } + + button[disabled] { + opacity: 0.6; + cursor: default; + } + + .result { + padding: 10px 12px; + border: 1px solid #ccc; + border-radius: 4px; + margin-bottom: 10px; + background: #fff; + font-size: 0.9rem; + } + + .result.err { + border-color: #a00; + color: #a00; + } + + table { + border-collapse: collapse; + width: 100%; + } + + td { + padding: 3px 6px; + border-bottom: 1px solid #eee; + vertical-align: top; + } + + td:first-child { + white-space: nowrap; + color: #555; + } + `; +} diff --git a/src/services/metadata-service/metadata-service.test.ts b/src/services/metadata-service/metadata-service.test.ts new file mode 100644 index 0000000..29a1d2c --- /dev/null +++ b/src/services/metadata-service/metadata-service.test.ts @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, test } from 'vitest'; + +import type { Result } from '@src/types/result'; + +import { MetadataBackendInterface } from './backend/metadata-backend-interface'; +import { MetadataService } from './metadata-service'; +import { + MetadataServiceError, + MetadataServiceErrorType, +} from './metadata-service-error'; +import { MetadataResponse } from './responses/metadata-response'; + +function generateMockMetadataResponse(identifier: string): any { + return { + created: 1586477049, + d1: 'ia600201.us.archive.org', + d2: 'ia800201.us.archive.org', + dir: '/foo', + files: [], + files_count: 0, + item_last_updated: 1463797130, + item_size: 0, + metadata: { identifier, mediatype: 'movies' }, + server: 'ia800201.us.archive.org', + uniq: 162444403, + workable_servers: ['ia800201.us.archive.org'], + }; +} + +describe('MetadataService', () => { + test('can request metadata when requested', async () => { + class MockMetadataBackend implements MetadataBackendInterface { + async fetchMetadata( + identifier: string, + ): Promise> { + return { success: generateMockMetadataResponse(identifier) }; + } + } + + const backend = new MockMetadataBackend(); + const service = new MetadataService(backend); + const result = await service.fetchMetadata('foo'); + expect(result.success?.metadata.identifier).toBe('foo'); + }); + + describe('requestMetadataValue', () => { + class MockMetadataBackend implements MetadataBackendInterface { + response: any; + async fetchMetadata( + _identifier: string, + _keypath?: string, + ): Promise> { + return { + success: { + result: this.response, + }, + }; + } + } + + test('can request a metadata value', async () => { + const backend = new MockMetadataBackend(); + const service = new MetadataService(backend); + + let expectedResult: any = 'foo'; + backend.response = expectedResult; + + let result = await service.fetchMetadataValue( + 'foo', + 'metadata', + ); + expect(result.success).toBe(expectedResult); + + expectedResult = { foo: 'bar' }; + backend.response = expectedResult; + + result = await service.fetchMetadataValue( + 'foo', + 'metadata', + ); + expect(result.success).toBe(expectedResult); + expect(result.success.foo).toBe('bar'); + }); + }); + + test('returns an error result if the item is not found', async () => { + class MockSearchBackend implements MetadataBackendInterface { + async fetchMetadata( + _identifier: string, + ): Promise> { + return { success: {} as any }; + } + } + + const backend = new MockSearchBackend(); + const service = new MetadataService(backend); + const result = await service.fetchMetadata('foo'); + expect(result.error).toBeDefined(); + expect(result.error?.type).toBe(MetadataServiceErrorType.itemNotFound); + + const valueResult = await service.fetchMetadataValue('foo', 'metadata'); + expect(valueResult.error).toBeDefined(); + expect(valueResult.error?.type).toBe(MetadataServiceErrorType.itemNotFound); + }); + + test('returns the network error if one occurs', async () => { + class MockSearchBackend implements MetadataBackendInterface { + async fetchMetadata( + _identifier: string, + ): Promise> { + const error = new MetadataServiceError( + MetadataServiceErrorType.networkError, + 'network error', + ); + return { error }; + } + } + + const backend = new MockSearchBackend(); + const service = new MetadataService(backend); + const metadataResult = await service.fetchMetadata('foo'); + expect(metadataResult.error).toBeDefined(); + expect(metadataResult.error?.type).toBe( + MetadataServiceErrorType.networkError, + ); + expect(metadataResult.error?.message).toBe('network error'); + + const metadataValueResult = await service.fetchMetadataValue('foo', 'bar'); + expect(metadataValueResult.error).toBeDefined(); + expect(metadataValueResult.error?.type).toBe( + MetadataServiceErrorType.networkError, + ); + expect(metadataValueResult.error?.message).toBe('network error'); + }); + + test('returns a decoding error if one occurs', async () => { + class MockSearchBackend implements MetadataBackendInterface { + async fetchMetadata( + _identifier: string, + ): Promise> { + const error = new MetadataServiceError( + MetadataServiceErrorType.decodingError, + 'decoding error', + ); + return { error }; + } + } + + const backend = new MockSearchBackend(); + const service = new MetadataService(backend); + const metadataResult = await service.fetchMetadata('foo'); + expect(metadataResult.error).toBeDefined(); + expect(metadataResult.error?.type).toBe( + MetadataServiceErrorType.decodingError, + ); + expect(metadataResult.error?.message).toBe('decoding error'); + }); +}); diff --git a/src/services/metadata-service/metadata-service.ts b/src/services/metadata-service/metadata-service.ts new file mode 100644 index 0000000..d374d53 --- /dev/null +++ b/src/services/metadata-service/metadata-service.ts @@ -0,0 +1,63 @@ +import type { Result } from '@src/types/result'; +import { DefaultMetadataBackend } from './backend/default-metadata-backend'; +import { MetadataBackendInterface } from './backend/metadata-backend-interface'; +import { + MetadataServiceError, + MetadataServiceErrorType, +} from './metadata-service-error'; +import type { MetadataServiceInterface } from './metadata-service-interface'; +import { MetadataResponse } from './responses/metadata-response'; + +/** + * The Metadata Service is responsible for taking the raw response provided by + * the backend and modeling it as a `MetadataResponse` object. + */ +export class MetadataService implements MetadataServiceInterface { + public static default: MetadataServiceInterface = new MetadataService( + new DefaultMetadataBackend(), + ); + + private backend: MetadataBackendInterface; + + constructor(backend: MetadataBackendInterface) { + this.backend = backend; + } + + /** @inheritdoc */ + async fetchMetadata( + identifier: string, + ): Promise> { + const rawResponse = await this.backend.fetchMetadata(identifier); + if (rawResponse.error) { + return rawResponse; + } + + if (rawResponse.success?.metadata === undefined) { + return { + error: new MetadataServiceError(MetadataServiceErrorType.itemNotFound), + }; + } + + const modeledResponse = new MetadataResponse(rawResponse.success); + return { success: modeledResponse }; + } + + /** @inheritdoc */ + async fetchMetadataValue( + identifier: string, + keypath: string, + ): Promise> { + const result = await this.backend.fetchMetadata(identifier, keypath); + if (result.error) { + return result; + } + + if (result.success?.result === undefined) { + return { + error: new MetadataServiceError(MetadataServiceErrorType.itemNotFound), + }; + } + + return { success: result.success.result }; + } +} diff --git a/src/services/metadata-service/responses/metadata-response.ts b/src/services/metadata-service/responses/metadata-response.ts new file mode 100644 index 0000000..3793135 --- /dev/null +++ b/src/services/metadata-service/responses/metadata-response.ts @@ -0,0 +1,66 @@ +import { File } from '@src/models/item-metadata/file'; +import { Metadata } from '@src/models/item-metadata/metadata'; +import { Review } from '@src/models/item-metadata/review'; +import type { SpeechMusicASREntry } from '@src/models/item-metadata/speech-music-asr-entry'; + +/** + * The main top-level reponse when fetching Metadata + * + * @export + * @class MetadataResponse + */ +export class MetadataResponse { + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + readonly rawResponse: Readonly>; + + readonly created: number; + + readonly d1: string; + + readonly d2: string; + + readonly dir: string; + + readonly files: File[]; + + readonly files_count: number; + + readonly item_last_updated: number; + + readonly item_size: number; + + readonly metadata: Metadata; + + readonly server: string; + + readonly uniq: number; + + readonly workable_servers: string[]; + + readonly speech_vs_music_asr?: SpeechMusicASREntry[]; + + readonly reviews?: Review[]; + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + constructor(json: Record) { + this.rawResponse = json; + this.created = json.created; + this.d1 = json.d1; + this.d2 = json.d2; + this.dir = json.dir; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + this.files = json.files?.map((file: Record) => new File(file)); + this.files_count = json.files_count; + this.item_last_updated = json.item_last_updated; + this.item_size = json.item_size; + this.metadata = new Metadata(json.metadata); + this.server = json.server; + this.uniq = json.uniq; + this.workable_servers = json.workable_servers; + this.speech_vs_music_asr = json.speech_vs_music_asr; + this.reviews = json.reviews?.map( + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + (entry: Record) => new Review(entry), + ); + } +} diff --git a/src/types/result.ts b/src/types/result.ts new file mode 100644 index 0000000..5a71acc --- /dev/null +++ b/src/types/result.ts @@ -0,0 +1,18 @@ +/** + * The Result is a container for a response. + * + * It contains an optional success result which is generic + * and can be anything depending on the context, + * or an Error or subclass of an error. + * + * This allows us to return rich, typed errors instead of + * an untyped Promise rejection. + * + * This is modeled after Swift's Result type: + * https://developer.apple.com/documentation/swift/result + */ +export interface Result { + success?: T; + + error?: E; +}