Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
@@ -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<Response> =>
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<Response> => {
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<Response> => {
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<Response> =>
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<Response> => {
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<Response> => {
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');
});
});
135 changes: 135 additions & 0 deletions src/services/metadata-service/backend/default-metadata-backend.ts
Original file line number Diff line number Diff line change
@@ -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<Result<any, MetadataServiceError>> {
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<Result<any, MetadataServiceError>> {
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<any, MetadataServiceError> {
const error = new MetadataServiceError(errorType, message, details);
const result = { error };
return result;
}
}
Original file line number Diff line number Diff line change
@@ -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<Result<any, MetadataServiceError>>;
}
23 changes: 23 additions & 0 deletions src/services/metadata-service/metadata-service-error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
51 changes: 51 additions & 0 deletions src/services/metadata-service/metadata-service-interface.ts
Original file line number Diff line number Diff line change
@@ -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<Result<MetadataResponse, MetadataServiceError>>}
*/
fetchMetadata(
identifier: string,
): Promise<Result<MetadataResponse, MetadataServiceError>>;

/**
* 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<string>('goody', 'metadata/collection/0');
* console.debug('collection:', collection); => 'Goody Collection'
*
* const files_count = await searchService.fetchMetadataValue<number>('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<T>(
identifier: string,
keypath: string,
): Promise<Result<T, MetadataServiceError>>;
}
Loading
Loading