diff --git a/package-lock.json b/package-lock.json index d047a6b..e17e8f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0-only", "dependencies": { "@internetarchive/field-parsers": "^0.1.4", - "@internetarchive/iaux-item-metadata": "^1.3.0", + "@internetarchive/iaux-item-metadata": "^1.5.0", "@internetarchive/result-type": "^0.0.1", "typescript-memoize": "^1.1.1" }, @@ -655,9 +655,9 @@ "license": "AGPL-3.0-only" }, "node_modules/@internetarchive/iaux-item-metadata": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@internetarchive/iaux-item-metadata/-/iaux-item-metadata-1.3.0.tgz", - "integrity": "sha512-T6qYs8i8b6PXTQEEpGZQUsFcg/FxpP9Zk+LWNJ4MWLWe4piRioPE2jReNlOwLX6nAx6yGcJ9BZQMDzmHbjK3jg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@internetarchive/iaux-item-metadata/-/iaux-item-metadata-1.5.0.tgz", + "integrity": "sha512-p1wK181a2qrHsSaL+yc6WG3JNbNlD+8g3wFKIIKzbT9ipfmsYnqjOlc8JlQT5OcXXMq4EYYCw21Agi//n9gaTw==", "license": "AGPL-3.0-only", "dependencies": { "@internetarchive/field-parsers": "^1.2.0", diff --git a/package.json b/package.json index 13bfcfe..33312db 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "dependencies": { "@internetarchive/field-parsers": "^0.1.4", - "@internetarchive/iaux-item-metadata": "^1.3.0", + "@internetarchive/iaux-item-metadata": "^1.5.0", "@internetarchive/result-type": "^0.0.1", "typescript-memoize": "^1.1.1" }, diff --git a/src/metadata-service-interface.ts b/src/metadata-service-interface.ts index 2605e69..db6ef42 100644 --- a/src/metadata-service-interface.ts +++ b/src/metadata-service-interface.ts @@ -1,3 +1,7 @@ +import type { + Metadata, + MetadataFieldKey, +} from '@internetarchive/iaux-item-metadata'; import type { Result } from '@internetarchive/result-type'; import type { MetadataServiceError } from './metadata-service-error'; import type { MetadataResponse } from './responses/metadata-response'; @@ -48,4 +52,58 @@ export interface MetadataServiceInterface { identifier: string, keypath: string, ): Promise>; + + /** + * Fetch a single item metadata field, modeled as its `MetadataField` type. + * + * Like {@link fetchMetadataValue}, but hands back a constructed field rather + * than the raw value, so callers get its parsing and normalization instead of + * reimplementing them. + * + * This matters most for fields the API sends inconsistently. `collection` is + * an array on an item in several collections but a bare string on an item in + * one, so reading `[0]` off the raw value is wrong half the time, while + * `value` is the first entry either way. + * + * ```ts + * const result = await metadataService.fetchMetadataField( + * 'bra-bfr', + * 'collection', + * ); + * result.success?.value; // 'kodi_archive' + * result.success?.values; // ['kodi_archive', 'community'] + * ``` + * + * The field name is any field `Metadata` declares, and the returned type + * follows from it, so a date comes back parsed: + * + * ```ts + * const added = await metadataService.fetchMetadataField( + * 'bra-bfr', + * 'addeddate', + * ); + * added.success?.value; // Date + * ``` + * + * A field `Metadata` doesn't declare isn't reachable here — add a typed + * getter there rather than working around it, so every caller shares one + * definition of a field's type. For anything that isn't an item metadata + * field (`files_count`, `server`, `files/0/name`), use + * {@link fetchMetadataValue}. + * + * A value the field's parser rejects leaves `value` undefined while + * `rawValue` keeps the original, and an empty array leaves `value` undefined + * too; `values.length` tells those apart. + * + * Errors match {@link fetchMetadataValue}. Note the API reports both an + * unknown identifier and an unknown field as a payload `error`, which + * surfaces as `searchEngineError` rather than `itemNotFound`. + * + * @param identifier + * @param field Name of a field declared on `Metadata` + */ + fetchMetadataField( + identifier: string, + field: K, + ): Promise, MetadataServiceError>>; } diff --git a/src/metadata-service.ts b/src/metadata-service.ts index cbd8f09..9b7792d 100644 --- a/src/metadata-service.ts +++ b/src/metadata-service.ts @@ -1,3 +1,7 @@ +import { + Metadata, + type MetadataFieldKey, +} from '@internetarchive/iaux-item-metadata'; import type { Result } from '@internetarchive/result-type'; import { DefaultMetadataBackend } from './backend/default-metadata-backend'; import { MetadataBackendInterface } from './backend/metadata-backend-interface'; @@ -60,4 +64,65 @@ export class MetadataService implements MetadataServiceInterface { return { success: result.success.result }; } + + /** @inheritdoc */ + async fetchMetadataField( + identifier: string, + field: K, + ): Promise, MetadataServiceError>> { + const result = await this.fetchMetadataValue( + identifier, + `metadata/${field}`, + ); + if (result.error) { + return { error: result.error }; + } + + // Required for narrowing. The backend maps a missing item or field to an + // error rather than an absent result, so this is defensive. + if (result.success === undefined) { + return { + error: new MetadataServiceError(MetadataServiceErrorType.itemNotFound), + }; + } + + if (!MetadataService.isFieldValue(result.success)) { + return { + error: new MetadataServiceError( + MetadataServiceErrorType.decodingError, + `Value of '${field}' for '${identifier}' is not a scalar or array of scalars`, + result.success, + ), + }; + } + + // Metadata's own getter supplies both the field class and its type, so the + // mapping from field name to field class lives in one place. + const modeled = new Metadata({ [field]: result.success })[field]; + if (!modeled) { + return { + error: new MetadataServiceError( + MetadataServiceErrorType.decodingError, + `Could not model '${field}' for '${identifier}'`, + result.success, + ), + }; + } + + return { success: modeled as NonNullable }; + } + + /** + * Whether a raw value is something a `MetadataField` can parse. + * + * Without this an object-valued field would still construct, and + * `String(value)` would quietly yield `'[object Object]'` inside an otherwise + * successful result. + */ + private static isFieldValue(value: unknown): boolean { + const isScalar = (v: unknown): boolean => + typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'; + if (Array.isArray(value)) return value.every(isScalar); + return isScalar(value); + } } diff --git a/test/metadata-service.test.ts b/test/metadata-service.test.ts index e66b26e..a1f001c 100644 --- a/test/metadata-service.test.ts +++ b/test/metadata-service.test.ts @@ -71,6 +71,104 @@ describe('MetadataService', () => { }); }); + describe('fetchMetadataField', () => { + class MockMetadataBackend implements MetadataBackendInterface { + response: any; + async fetchMetadata( + identifier: string, + keypath?: string, + ): Promise> { + return { success: { result: this.response } }; + } + } + + function serviceReturning(response: any): MetadataService { + const backend = new MockMetadataBackend(); + backend.response = response; + return new MetadataService(backend); + } + + it('models a field the API sent as an array', async () => { + const service = serviceReturning(['kodi_archive', 'community']); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success?.value).to.equal('kodi_archive'); + expect(result.success?.values).to.deep.equal([ + 'kodi_archive', + 'community', + ]); + }); + + it('models a field the API sent as a bare string', async () => { + // An item in a single collection gets a string rather than a list, which + // is the case that makes reading the raw value directly unsafe. + const service = serviceReturning('audio'); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success?.value).to.equal('audio'); + expect(result.success?.values).to.deep.equal(['audio']); + }); + + it('casts according to the field, without being told which', async () => { + const service = serviceReturning('2018-08-13 10:08:32'); + + const result = await service.fetchMetadataField('foo', 'addeddate'); + expect(result.success?.value).to.be.instanceOf(Date); + expect(result.success?.value?.getUTCFullYear()).to.equal(2018); + }); + + it('leaves an out-of-range enum value undefined but keeps the raw', async () => { + const service = serviceReturning('not-a-mediatype'); + + const result = await service.fetchMetadataField('foo', 'mediatype'); + expect(result.success?.value).to.equal(undefined); + expect(result.success?.rawValue).to.equal('not-a-mediatype'); + }); + + it('keeps an empty array distinguishable from a rejected value', async () => { + const service = serviceReturning([]); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success?.value).to.equal(undefined); + expect(result.success?.values).to.deep.equal([]); + }); + + it('rejects a value that is not a scalar or array of scalars', async () => { + // Without this the field would still construct and String() would yield + // '[object Object]' inside a successful result. + const service = serviceReturning([{ reviewer: 'someone' }]); + + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal( + MetadataServiceErrorType.decodingError, + ); + }); + + it('passes a backend error through', async () => { + // What an unknown identifier or field actually produces: the API returns + // a payload `error` key, which the backend maps to searchEngineError. + class FailingBackend implements MetadataBackendInterface { + async fetchMetadata(): Promise> { + return { + error: new MetadataServiceError( + MetadataServiceErrorType.searchEngineError, + "Couldn't get part '/nope' of 'metadata' for item foo", + ), + }; + } + } + + const service = new MetadataService(new FailingBackend()); + const result = await service.fetchMetadataField('foo', 'collection'); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal( + MetadataServiceErrorType.searchEngineError, + ); + expect(result.error?.message).to.contain("Couldn't get part"); + }); + }); + it('returns an error result if the item is not found', async () => { class MockSearchBackend implements MetadataBackendInterface { async fetchMetadata(