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
86 changes: 51 additions & 35 deletions demo/app-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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();
Expand All @@ -34,65 +35,80 @@ export class AppRoot extends LitElement {
return html`
<nav id="ia-sidebar">
<h2>Production-Ready</h2>
${productionEntries.map(e => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`)}
${productionEntries.map(
(e) => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`,
)}
<h2>Labs 🧪</h2>
${labsEntries.map(e => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`)}
${labsEntries.map((e) => html`<a href="#${e.id}">&lt;${e.tag}&gt;</a>`)}
</nav>
<div id="ia-content">
<h1>Internet Archive Elements</h1>
<h2>Production-Ready Elements</h2>
${productionEntries.map(e => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`)}
${productionEntries.map(
(e) => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`,
)}
<h2>Labs Elements</h2>
${labsEntries.map(e => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`)}
${labsEntries.map(
(e) => html`
<div id="${e.id}" class="ia-anchor">
${unsafeHTML(`<${e.storyTag}></${e.storyTag}>`)}
</div>
`,
)}
</div>
`;
}

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<string>();

// 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 },
);
});
}

Expand Down
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>>;
}
Loading
Loading