Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
58 changes: 58 additions & 0 deletions src/metadata-service-interface.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,4 +52,58 @@ export interface MetadataServiceInterface {
identifier: string,
keypath: string,
): Promise<Result<T, MetadataServiceError>>;

/**
* 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<K extends MetadataFieldKey>(
identifier: string,
field: K,
): Promise<Result<NonNullable<Metadata[K]>, MetadataServiceError>>;
}
65 changes: 65 additions & 0 deletions src/metadata-service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -60,4 +64,65 @@ export class MetadataService implements MetadataServiceInterface {

return { success: result.success.result };
}

/** @inheritdoc */
async fetchMetadataField<K extends MetadataFieldKey>(
identifier: string,
field: K,
): Promise<Result<NonNullable<Metadata[K]>, MetadataServiceError>> {
const result = await this.fetchMetadataValue<unknown>(
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<Metadata[K]> };
}

/**
* 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);
}
}
98 changes: 98 additions & 0 deletions test/metadata-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,104 @@ describe('MetadataService', () => {
});
});

describe('fetchMetadataField', () => {
class MockMetadataBackend implements MetadataBackendInterface {
response: any;
async fetchMetadata(
identifier: string,
keypath?: string,
): Promise<Result<any, MetadataServiceError>> {
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<Result<any, MetadataServiceError>> {
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(
Expand Down
Loading