From 6a433a91065471b16f19221cfc104fc4978b9e1f Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Thu, 8 Oct 2026 16:40:51 -0700 Subject: [PATCH 1/2] WEBDEV-9417: Migrate result-type into elements Ported from @internetarchive/result-type 0.0.1. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CjuFdHEwY7MowcqkUxxudu --- .../result-type/result-type-story.test.ts | 55 ++++++ src/services/result-type/result-type-story.ts | 166 ++++++++++++++++++ src/services/result-type/result-type.ts | 1 + src/services/result-type/result.test.ts | 29 +++ src/services/result-type/result.ts | 18 ++ 5 files changed, 269 insertions(+) create mode 100644 src/services/result-type/result-type-story.test.ts create mode 100644 src/services/result-type/result-type-story.ts create mode 100644 src/services/result-type/result-type.ts create mode 100644 src/services/result-type/result.test.ts create mode 100644 src/services/result-type/result.ts diff --git a/src/services/result-type/result-type-story.test.ts b/src/services/result-type/result-type-story.test.ts new file mode 100644 index 00000000..9148f60d --- /dev/null +++ b/src/services/result-type/result-type-story.test.ts @@ -0,0 +1,55 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html } from 'lit'; + +import type { ResultTypeStory } from './result-type-story'; +import './result-type-story'; + +async function divide(a: string, b: string) { + const el = await fixture( + html``, + ); + const root = el.shadowRoot!; + const [inputA, inputB] = Array.from( + root.querySelectorAll('input[type="text"]'), + ) as HTMLInputElement[]; + inputA.value = a; + inputA.dispatchEvent(new Event('input')); + inputB.value = b; + inputB.dispatchEvent(new Event('input')); + (root.querySelector('form') as HTMLFormElement).requestSubmit(); + await el.updateComplete; + return { + call: root.querySelector('.call')?.textContent, + output: root.querySelector('.output')?.textContent, + }; +} + +describe('result-type story', () => { + test('shows no result until the form is submitted', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot!.querySelector('.output')).to.be.null; + }); + + test('returns a success value', async () => { + const { call, output } = await divide('10', '4'); + + expect(call).to.equal('divide("10", "4")'); + expect(output).to.equal('{ success: 2.5 }'); + }); + + test('returns an error for a zero divisor', async () => { + const { output } = await divide('1', '0'); + + expect(output).to.equal('{ error: DivisionError("divide-by-zero") }'); + }); + + test('returns an error for input that is not a number', async () => { + const { output } = await divide('abc', '2'); + + expect(output).to.equal('{ error: DivisionError("not-a-number") }'); + }); +}); diff --git a/src/services/result-type/result-type-story.ts b/src/services/result-type/result-type-story.ts new file mode 100644 index 00000000..6f6f93b1 --- /dev/null +++ b/src/services/result-type/result-type-story.ts @@ -0,0 +1,166 @@ +import { css, html, LitElement, nothing, type CSSResultGroup } from 'lit'; +import { state } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import interfaceSource from './result.ts?raw'; + +import '@demo/service-template'; +import type { Result } from './result-type'; + +class DivisionError extends Error { + readonly kind: 'not-a-number' | 'divide-by-zero'; + + constructor(kind: 'not-a-number' | 'divide-by-zero') { + super(kind); + this.kind = kind; + } +} + +/** The kind of function that returns a Result instead of throwing. */ +function divide(a: string, b: string): Result { + const top = Number(a); + const bottom = Number(b); + if ( + a.trim() === '' || + b.trim() === '' || + Number.isNaN(top) || + Number.isNaN(bottom) + ) { + return { error: new DivisionError('not-a-number') }; + } + if (bottom === 0) return { error: new DivisionError('divide-by-zero') }; + return { success: top / bottom }; +} + +const USAGE = `import type { Result } from '@internetarchive/elements/services/result-type/result-type'; + +function divide(a: number, b: number): Result { + if (b === 0) return { error: new DivisionError('divide-by-zero') }; + return { success: a / b }; +} + +const { success, error } = divide(1, 0); +if (error) console.log(error.kind); // 'divide-by-zero'`; + +@customElement('result-type-story') +export class ResultTypeStory extends LitElement { + @state() private a = ''; + + @state() private b = ''; + + @state() private result?: { call: string; output: string }; + + render() { + return html` + +
+ + + +
+ ${this.result + ? html`${this.result.call} + ${this.result.output}` + : nothing} +
+
+
+

+ Result is a type, so there's nothing to call. A + function returns one instead of throwing, and the caller checks + error before using success. This page runs + a small divide that does that. +

+
+
+ `; + } + + private run(e: Event) { + e.preventDefault(); + const result = divide(this.a, this.b); + this.result = { + call: `divide(${JSON.stringify(this.a)}, ${JSON.stringify(this.b)})`, + output: result.error + ? `{ error: DivisionError("${result.error.kind}") }` + : `{ success: ${result.success} }`, + }; + } + + static get styles(): CSSResultGroup { + return css` + form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; + } + + label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.8rem; + } + + input[type='text'] { + min-width: 0; + width: 8rem; + max-width: 100%; + } + + .result[hidden] { + display: none; + } + + .result { + box-sizing: border-box; + flex-basis: 100%; + min-width: 0; + max-width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + padding: 0.5rem; + background: #fff; + border: 1px solid #ccc; + font-size: 0.85rem; + } + + .call, + .output { + overflow-wrap: anywhere; + } + + .output { + font-weight: 600; + } + `; + } +} diff --git a/src/services/result-type/result-type.ts b/src/services/result-type/result-type.ts new file mode 100644 index 00000000..1a11e29c --- /dev/null +++ b/src/services/result-type/result-type.ts @@ -0,0 +1 @@ +export type { Result } from './result'; diff --git a/src/services/result-type/result.test.ts b/src/services/result-type/result.test.ts new file mode 100644 index 00000000..2e78d861 --- /dev/null +++ b/src/services/result-type/result.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import type { Result } from './result'; + +describe('Result', () => { + it('can be initialized with a success value', async () => { + const result: Result = { + success: 'foo', + }; + expect(result.success).to.equal('foo'); + expect(result.error).to.equal(undefined); + }); + + it('can be initialized with an error', async () => { + type FooErrorType = 'networkError' | 'decodingError'; + class FooError extends Error { + type?: FooErrorType; + + constructor(type: FooErrorType) { + super(); + this.type = type; + } + } + const result: Result = { + error: new FooError('decodingError'), + }; + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal('decodingError'); + }); +}); diff --git a/src/services/result-type/result.ts b/src/services/result-type/result.ts new file mode 100644 index 00000000..5a71accf --- /dev/null +++ b/src/services/result-type/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; +} From 97188286537878aa5c8336245ad558c4fd7e88a3 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Thu, 8 Oct 2026 17:39:00 -0700 Subject: [PATCH 2/2] WEBDEV-9422: Migrate user-service into elements Ported from @internetarchive/user-service 1.0.0. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01CjuFdHEwY7MowcqkUxxudu --- package.json | 4 +- pnpm-lock.yaml | 24 ++ .../mock-responses.test-helper.ts | 52 +++++ src/services/user-service/models/response.ts | 36 +++ .../models/user-image-info.test.ts | 59 +++++ .../user-service/models/user-image-info.ts | 136 +++++++++++ src/services/user-service/models/user.test.ts | 71 ++++++ src/services/user-service/models/user.ts | 105 +++++++++ .../user-service/user-service-error.ts | 18 ++ .../user-service/user-service-story.test.ts | 87 +++++++ .../user-service/user-service-story.ts | 194 ++++++++++++++++ .../user-service/user-service.test.ts | 217 ++++++++++++++++++ src/services/user-service/user-service.ts | 150 ++++++++++++ 13 files changed, 1152 insertions(+), 1 deletion(-) create mode 100644 src/services/user-service/mock-responses.test-helper.ts create mode 100644 src/services/user-service/models/response.ts create mode 100644 src/services/user-service/models/user-image-info.test.ts create mode 100644 src/services/user-service/models/user-image-info.ts create mode 100644 src/services/user-service/models/user.test.ts create mode 100644 src/services/user-service/models/user.ts create mode 100644 src/services/user-service/user-service-error.ts create mode 100644 src/services/user-service/user-service-story.test.ts create mode 100644 src/services/user-service/user-service-story.ts create mode 100644 src/services/user-service/user-service.test.ts create mode 100644 src/services/user-service/user-service.ts diff --git a/package.json b/package.json index 452d6f43..0c771225 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,13 @@ "dayjs": "^1.11.23", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", - "tslib": "^2.8.1" + "tslib": "^2.8.1", + "typescript-cookie": "^1.0.6" }, "devDependencies": { "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.1", + "@internetarchive/local-cache": "1.0.0", "@lit/localize-tools": "^0.8.2", "@open-wc/testing-helpers": "^3.0.1", "@typescript-eslint/eslint-plugin": "^8.46.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2e2ea35..a4777d01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: tslib: specifier: ^2.8.1 version: 2.8.1 + typescript-cookie: + specifier: ^1.0.6 + version: 1.0.6 devDependencies: '@eslint/eslintrc': specifier: ^3.3.5 @@ -194,6 +197,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.5 + '@internetarchive/local-cache': + specifier: 1.0.0 + version: 1.0.0 '@lit/localize-tools': specifier: ^0.8.2 version: 0.8.2 @@ -531,6 +537,9 @@ packages: '@internetarchive/icon-close@1.4.1': resolution: {integrity: sha512-nN1rGfScGssGFpfEew4azsrsMyUJtkDWqUDlckJi31PtyQnbZqUmZ07UO3Aw+wZIo3cU1MHxb5VxDy3Rmr8QGA==} + '@internetarchive/local-cache@1.0.0': + resolution: {integrity: sha512-KGY2gwSBpxrEf7+zOIdgLF/KKhk6KnBAH5nphKpoUEm8mn3ht8wPdUACZ13hUqagZPY3txCLuPPbjiKNH5RPeA==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1641,6 +1650,9 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + idb-keyval@6.3.0: + resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -2502,6 +2514,10 @@ packages: resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} + typescript-cookie@1.0.6: + resolution: {integrity: sha512-s+BZr7/9BUG6Kg7jGGcOY/4XJcP+iZRFdF3q4FPTfRSP83ivLWF94OcH8PrzGmnS8Ab9qP7ENu/ikLwNFsIafA==} + engines: {node: '>=14'} + typescript@5.2.2: resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} @@ -2887,6 +2903,10 @@ snapshots: dependencies: lit: 3.3.3 + '@internetarchive/local-cache@1.0.0': + dependencies: + idb-keyval: 6.3.0 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -4195,6 +4215,8 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + idb-keyval@6.3.0: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -5150,6 +5172,8 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 + typescript-cookie@1.0.6: {} + typescript@5.2.2: {} typescript@5.9.3: {} diff --git a/src/services/user-service/mock-responses.test-helper.ts b/src/services/user-service/mock-responses.test-helper.ts new file mode 100644 index 00000000..af1423aa --- /dev/null +++ b/src/services/user-service/mock-responses.test-helper.ts @@ -0,0 +1,52 @@ +import { UserImageInfoResponse, UserResponse } from './models/response'; +import { User } from './models/user'; + +export function getMockApiResponseFromString(body = '', status = 200) { + return new window.Response(body, { + status, + headers: { 'Content-type': 'application/json' }, + }); +} + +export function getMockApiResponseFromObject(body = {}, status = 200) { + return getMockApiResponseFromString(JSON.stringify(body), status); +} + +export const mockUserImageInfoResponse: UserImageInfoResponse = { + name: 'foo.jpg', + source: 'original', + mtime: '1234', + size: '5678', + md5: 'abc123', + crc32: '4243243', + sha1: '1234432njknk', + format: 'Item Image', + rotation: '0', +}; + +export const mockUserResponse: UserResponse = { + username: 'foo@bar.com', + itemname: '@fooey-mcbarrison', + screenname: 'Foo-Bar', + privs: ['/'], + image_info: mockUserImageInfoResponse, +}; + +export const mockUser = User.fromUserResponse(mockUserResponse); + +export function getSuccessResponse() { + return getMockApiResponseFromObject({ + success: true, + value: mockUserResponse, + }); +} + +export function getFailureResponse() { + return getMockApiResponseFromObject( + { + success: false, + error: 'Authentication failed', + }, + 401, + ); +} diff --git a/src/services/user-service/models/response.ts b/src/services/user-service/models/response.ts new file mode 100644 index 00000000..daa46487 --- /dev/null +++ b/src/services/user-service/models/response.ts @@ -0,0 +1,36 @@ +export interface UserImageInfoResponse { + name?: string; + source?: string; + mtime?: string; + size?: string; + md5?: string; + crc32?: string; + sha1?: string; + format?: string; + rotation?: string; +} + +export interface UserResponse { + username: string; + itemname: string; + screenname: string; + privs: string[]; + image_info: UserImageInfoResponse; +} + +export interface UserServiceResponse { + /** + * Was the request successful? + */ + success: boolean; + + /** + * If the request was successful, this will populate with a User object + */ + value?: UserResponse; + + /** + * If the request was not successful, this will contain the error message + */ + error?: string; +} diff --git a/src/services/user-service/models/user-image-info.test.ts b/src/services/user-service/models/user-image-info.test.ts new file mode 100644 index 00000000..8ad33c12 --- /dev/null +++ b/src/services/user-service/models/user-image-info.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { UserImageInfo } from './user-image-info'; + +describe('UserImageInfo', () => { + it('can be instantiated', async () => { + const imageInfo = new UserImageInfo({ + name: 'foo.jpg', + source: 'original', + mtime: 12345, + }); + + expect(imageInfo.name).to.equal('foo.jpg'); + expect(imageInfo.source).to.equal('original'); + expect(imageInfo.mtime).to.equal(12345); + }); + + it('can be deserialized from response', async () => { + const imageInfo = UserImageInfo.fromResponse({ + name: 'foo.jpg', + source: 'original', + mtime: '12345', + size: '5435', + rotation: '0', + }); + + expect(imageInfo.name).to.equal('foo.jpg'); + expect(imageInfo.source).to.equal('original'); + expect(imageInfo.mtime).to.equal(12345); + expect(imageInfo.size).to.equal(5435); + expect(imageInfo.rotation).to.equal(0); + }); + + it('deserialized the bad number fields make them undefined', async () => { + const imageInfo = UserImageInfo.fromResponse({ + name: 'foo.jpg', + source: 'original', + mtime: 'abc', + }); + + expect(imageInfo.name).to.equal('foo.jpg'); + expect(imageInfo.source).to.equal('original'); + expect(imageInfo.mtime).to.be.undefined; + expect(imageInfo.size).to.be.undefined; + expect(imageInfo.rotation).to.be.undefined; + }); + + it('deserialized without number fields make them undefined', async () => { + const imageInfo = UserImageInfo.fromResponse({ + name: 'foo.jpg', + source: 'original', + }); + + expect(imageInfo.name).to.equal('foo.jpg'); + expect(imageInfo.source).to.equal('original'); + expect(imageInfo.mtime).to.be.undefined; + expect(imageInfo.size).to.be.undefined; + expect(imageInfo.rotation).to.be.undefined; + }); +}); diff --git a/src/services/user-service/models/user-image-info.ts b/src/services/user-service/models/user-image-info.ts new file mode 100644 index 00000000..ef3612d1 --- /dev/null +++ b/src/services/user-service/models/user-image-info.ts @@ -0,0 +1,136 @@ +import { + Byte, + ByteParser, + NumberParser, +} from '../../field-parsers/field-parsers'; +import { UserImageInfoResponse } from './response'; + +export interface UserImageInfoInterface { + /** + * The image filename + */ + name?: string; + + /** + * The image source, ie `original` or `derivative` + */ + source?: string; + + /** + * The last modified epoch + */ + mtime?: number; + + /** + * The file size in bytes + */ + size?: Byte; + + /** + * The md5 hash + */ + md5?: string; + + /** + * The image crc32 + */ + crc32?: string; + + /** + * The image sha1 + */ + sha1?: string; + + /** + * The image format. Will likely be "Item Image" + */ + format?: string; + + /** + * The image rotation + */ + rotation?: number; +} + +export class UserImageInfo implements UserImageInfoInterface { + /** @inheritdoc */ + name?: string; + + /** @inheritdoc */ + source?: string; + + /** @inheritdoc */ + mtime?: number; + + /** @inheritdoc */ + size?: Byte; + + /** @inheritdoc */ + md5?: string; + + /** @inheritdoc */ + crc32?: string; + + /** @inheritdoc */ + sha1?: string; + + /** @inheritdoc */ + format?: string; + + /** @inheritdoc */ + rotation?: number; + + static fromResponse(userResponse: UserImageInfoResponse): UserImageInfo { + const numberParser = NumberParser.shared; + const byteParser = ByteParser.shared; + let mtime: number | undefined; + let size: number | undefined; + let rotation: number | undefined; + + if (userResponse.mtime) { + mtime = numberParser.parseValue(userResponse.mtime); + } + + if (userResponse.size) { + size = byteParser.parseValue(userResponse.size); + } + + if (userResponse.rotation) { + rotation = numberParser.parseValue(userResponse.rotation); + } + + return new UserImageInfo({ + name: userResponse.name, + source: userResponse.source, + mtime, + size, + md5: userResponse.md5, + crc32: userResponse.crc32, + sha1: userResponse.sha1, + format: userResponse.format, + rotation, + }); + } + + constructor(options: { + name?: string; + source?: string; + mtime?: number; + size?: number; + md5?: string; + crc32?: string; + sha1?: string; + format?: string; + rotation?: number; + }) { + this.name = options.name; + this.source = options.source; + this.mtime = options.mtime; + this.size = options.size; + this.md5 = options.md5; + this.crc32 = options.crc32; + this.sha1 = options.sha1; + this.format = options.format; + this.rotation = options.rotation; + } +} diff --git a/src/services/user-service/models/user.test.ts b/src/services/user-service/models/user.test.ts new file mode 100644 index 00000000..ff4abef7 --- /dev/null +++ b/src/services/user-service/models/user.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { User } from './user'; + +describe('User', () => { + it('can be instantiated', async () => { + const user = new User({ + username: 'foo@bar.org', + itemname: '@foo-user', + screenname: 'Foo-Bar', + privs: ['/'], + image_info: { + name: 'foo.jpg', + mtime: 1234, + }, + }); + + expect(user.username).to.equal('foo@bar.org'); + expect(user.itemname).to.equal('@foo-user'); + expect(user.screenname).to.equal('Foo-Bar'); + expect(user.userid).to.equal('foo-user'); + expect(user.privs).to.deep.equal(['/']); + expect(user.image_info.name).to.equal('foo.jpg'); + expect(user.image_info.mtime).to.equal(1234); + }); + + it('userid is the itemname if it has no at sign', async () => { + const user = new User({ + username: 'foo@bar.org', + itemname: 'foo-user', + screenname: 'Foo-Bar', + privs: ['/'], + image_info: { + name: 'foo.jpg', + mtime: 4567, + }, + }); + + expect(user.itemname).to.equal('foo-user'); + expect(user.userid).to.equal('foo-user'); + expect(user.image_info.name).to.equal('foo.jpg'); + expect(user.image_info.mtime).to.equal(4567); + }); + + it('properly populates `isArchiveOrg` value', async () => { + const user = new User({ + username: 'foo@bar.org', + itemname: 'foo-user', + screenname: 'Foo-Bar', + privs: ['/'], + image_info: { + name: 'foo.jpg', + mtime: 4567, + }, + }); + + expect(user.isArchiveOrgUser).to.be.false; + + const user2 = new User({ + username: 'foo@archive.org', + itemname: 'foo-user', + screenname: 'Foo-Bar', + privs: ['/'], + image_info: { + name: 'foo.jpg', + mtime: 4567, + }, + }); + + expect(user2.isArchiveOrgUser).to.be.true; + }); +}); diff --git a/src/services/user-service/models/user.ts b/src/services/user-service/models/user.ts new file mode 100644 index 00000000..9a0fd56f --- /dev/null +++ b/src/services/user-service/models/user.ts @@ -0,0 +1,105 @@ +import { UserResponse } from './response'; +import { UserImageInfo, UserImageInfoInterface } from './user-image-info'; + +export interface UserInterface { + /** + * The user's email address + */ + username: string; + + /** + * The user's item identifier + * + * eg. `@foo-user` + */ + itemname: string; + + /** + * A common use of the itemname is to remove the leading `@`, + * which is referred to as the userid. + * + * eg. `foo-user` + */ + userid: string; + + /** + * The user's screen name, typically used for display purposes + * + * eg. `Foo-User` + */ + screenname: string; + + /** + * Array of privileges for the user + */ + privs: string[]; + + /** + * Info about the user's profile picture + */ + image_info: UserImageInfoInterface; + + /** + * If the user has an archive.org email address + */ + isArchiveOrgUser: boolean; +} + +export class User implements UserInterface { + /** @inheritdoc */ + username: string; + + /** @inheritdoc */ + itemname: string; + + /** @inheritdoc */ + userid: string; + + /** @inheritdoc */ + screenname: string; + + /** @inheritdoc */ + privs: string[]; + + /** @inheritdoc */ + image_info: UserImageInfoInterface; + + /** @inheritdoc */ + isArchiveOrgUser: boolean; + + /** + * Construct a UserModelInterface object from a UserResponse + * + * @static + * @param {UserResponse} userResponse + * @returns {UserInterface} + * @memberof User + */ + static fromUserResponse(userResponse: UserResponse): UserInterface { + return new User({ + username: userResponse.username, + itemname: userResponse.itemname, + screenname: userResponse.screenname, + privs: userResponse.privs, + image_info: UserImageInfo.fromResponse(userResponse.image_info), + }); + } + + constructor(options: { + username: string; + itemname: string; + screenname: string; + privs: string[]; + image_info: UserImageInfoInterface; + }) { + this.username = options.username; + this.itemname = options.itemname; + this.screenname = options.screenname; + this.privs = options.privs; + this.image_info = options.image_info; + this.isArchiveOrgUser = this.username.endsWith('@archive.org'); + + const { itemname } = options; + this.userid = itemname.startsWith('@') ? itemname.substring(1) : itemname; + } +} diff --git a/src/services/user-service/user-service-error.ts b/src/services/user-service/user-service-error.ts new file mode 100644 index 00000000..6ed8b86d --- /dev/null +++ b/src/services/user-service/user-service-error.ts @@ -0,0 +1,18 @@ +export const UserServiceErrorType = { + userNotLoggedIn: 'UserService.userNotLoggedIn', + networkError: 'UserService.networkError', + decodingError: 'UserService.decodingError', +} as const; + +export type UserServiceErrorType = + (typeof UserServiceErrorType)[keyof typeof UserServiceErrorType]; + +export class UserServiceError extends Error { + type: UserServiceErrorType; + + constructor(type: UserServiceErrorType, message?: string) { + super(message); + this.name = type; + this.type = type; + } +} diff --git a/src/services/user-service/user-service-story.test.ts b/src/services/user-service/user-service-story.test.ts new file mode 100644 index 00000000..5454f386 --- /dev/null +++ b/src/services/user-service/user-service-story.test.ts @@ -0,0 +1,87 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { getCookie, removeCookie, setCookie } from 'typescript-cookie'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { html } from 'lit'; + +import type { UserServiceStory } from './user-service-story'; +import './user-service-story'; + +async function run(options: { source?: string; username?: string } = {}) { + const el = await fixture( + html``, + ); + const root = el.shadowRoot!; + if (options.source) { + const select = root.querySelector('select') as HTMLSelectElement; + select.value = options.source; + select.dispatchEvent(new Event('change')); + } + if (options.username) { + const input = root.querySelector('input[type="text"]') as HTMLInputElement; + input.value = options.username; + input.dispatchEvent(new Event('input')); + } + (root.querySelector('form') as HTMLFormElement).requestSubmit(); + await vi.waitFor(() => { + if (!root.querySelector('.output')) throw new Error('still running'); + }); + await el.updateComplete; + return { + call: root.querySelector('.call')?.textContent, + output: root.querySelector('.output')?.textContent ?? '', + }; +} + +describe('user-service story', () => { + afterEach(() => { + removeCookie('logged-in-user'); + vi.restoreAllMocks(); + }); + + test('shows nothing until it is run', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot!.querySelector('.output')).to.be.null; + }); + + test('returns the sample user for the typed username', async () => { + const shown = await run({ username: 'someone@example.org' }); + + expect(shown.call).to.equal('getLoggedInUser()'); + const user = JSON.parse(shown.output); + expect(user.username).to.equal('someone@example.org'); + expect(user.screenname).to.equal('Foo-Bar'); + }); + + test('reports not signed in when the sample says authentication failed', async () => { + const shown = await run({ source: 'sample-denied' }); + + expect(shown.output).to.equal( + 'error: UserService.userNotLoggedIn (Authentication failed)', + ); + }); + + test('reports a network error for the live call when the request fails', async () => { + vi.spyOn(window, 'fetch').mockRejectedValue(new TypeError('offline')); + + const shown = await run({ source: 'live' }); + + expect(shown.output).to.equal('error: UserService.networkError (offline)'); + }); + + test('puts the logged-in-user cookie back as it found it', async () => { + setCookie('logged-in-user', 'before@example.org'); + + await run({ username: 'during@example.org' }); + + expect(getCookie('logged-in-user')).to.equal('before@example.org'); + }); + + test('leaves no cookie behind when there was none', async () => { + await run(); + + expect(getCookie('logged-in-user')).to.be.undefined; + }); +}); diff --git a/src/services/user-service/user-service-story.ts b/src/services/user-service/user-service-story.ts new file mode 100644 index 00000000..c3819112 --- /dev/null +++ b/src/services/user-service/user-service-story.ts @@ -0,0 +1,194 @@ +import { css, html, LitElement, nothing, type CSSResultGroup } from 'lit'; +import { state } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; +import { getCookie, removeCookie, setCookie } from 'typescript-cookie'; +import serviceSource from './user-service.ts?raw'; +import errorSource from './user-service-error.ts?raw'; +import userSource from './models/user.ts?raw'; + +import '@demo/service-template'; +import { UserService } from './user-service'; +import { mockUserResponse } from './mock-responses.test-helper'; + +type Source = 'sample' | 'sample-denied' | 'live'; + +const COOKIE = 'logged-in-user'; + +const USAGE = `import { UserService } from '@internetarchive/elements/services/user-service/user-service'; + +const { success: user, error } = await new UserService().getLoggedInUser(); +if (error) console.log(error.type); // 'UserService.userNotLoggedIn' +else console.log(user.screenname);`; + +/** Strips the import statements, which are noise in the API listing. */ +const withoutImports = (source: string) => + source.replace(/^import[\s\S]*?from '[^']+';\n/gm, '').trim(); + +const API_SOURCE = [serviceSource, errorSource, userSource] + .map(withoutImports) + .join('\n\n'); + +const jsonUrl = (body: unknown) => + `data:application/json,${encodeURIComponent(JSON.stringify(body))}`; + +@customElement('user-service-story') +export class UserServiceStory extends LitElement { + @state() private source: Source = 'sample'; + + @state() private username = ''; + + @state() private running = false; + + @state() private result?: { call: string; output: string }; + + render() { + return html` + +
+ + + +
+ ${this.result + ? html`${this.result.call} +
${this.result.output}
` + : nothing} +
+
+
+

+ getLoggedInUser() needs the + ${COOKIE} cookie that archive.org sets when you sign + in. This page sets it to the username below for the length of the + call and puts back what was there. The sample data answers from a + canned response and sends nothing. The live call goes to archive.org + with your credentials, which the browser only allows from + archive.org itself, so from here it fails with a network error. +

+
+
+ `; + } + + private pickSource(e: Event) { + this.source = (e.target as HTMLSelectElement).value as Source; + this.result = undefined; + } + + private async run(e: Event) { + e.preventDefault(); + const username = this.username.trim() || mockUserResponse.username; + const endpoint = + this.source === 'sample' + ? jsonUrl({ + success: true, + value: { ...mockUserResponse, username }, + }) + : this.source === 'sample-denied' + ? jsonUrl({ success: false, error: 'Authentication failed' }) + : undefined; + const service = new UserService( + endpoint ? { userServiceEndpoint: endpoint } : undefined, + ); + + const previousCookie = getCookie(COOKIE); + setCookie(COOKIE, username); + this.running = true; + let output: string; + try { + const { success: user, error } = await service.getLoggedInUser(); + output = error + ? `error: ${error.type}${error.message ? ` (${error.message})` : ''}` + : JSON.stringify(user, null, 2); + } finally { + if (previousCookie === undefined) removeCookie(COOKIE); + else setCookie(COOKIE, previousCookie); + this.running = false; + } + this.result = { + call: 'getLoggedInUser()', + output, + }; + } + + static get styles(): CSSResultGroup { + return css` + form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; + } + + label { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.8rem; + } + + input[type='text'] { + min-width: 0; + width: 12rem; + max-width: 100%; + } + + .result[hidden] { + display: none; + } + + .result { + box-sizing: border-box; + flex-basis: 100%; + min-width: 0; + max-width: 100%; + display: flex; + flex-direction: column; + gap: 4px; + padding: 0.5rem; + background: #fff; + border: 1px solid #ccc; + font-size: 0.85rem; + } + + .output { + margin: 0; + font-weight: 600; + overflow-x: auto; + } + `; + } +} diff --git a/src/services/user-service/user-service.test.ts b/src/services/user-service/user-service.test.ts new file mode 100644 index 00000000..33150be0 --- /dev/null +++ b/src/services/user-service/user-service.test.ts @@ -0,0 +1,217 @@ +import { removeCookie, setCookie } from 'typescript-cookie'; +import { LocalCache } from '@internetarchive/local-cache'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MockInstance } from 'vitest'; +import { UserService } from './user-service'; +import { UserServiceErrorType } from './user-service-error'; +import { + getFailureResponse, + getSuccessResponse, + getMockApiResponseFromString, + mockUserResponse, +} from './mock-responses.test-helper'; + +let fetchStub: MockInstance | undefined; + +describe('UserService', () => { + beforeEach(() => { + fetchStub = vi.spyOn(window, 'fetch'); + removeCookie('logged-in-user'); + setCookie('logged-in-user', 'foo@bar.com'); + fetchStub?.mockResolvedValue(getSuccessResponse()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('configuration', () => { + it('uses the default endpoint', async () => { + const userService = new UserService(); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls[0][0]).to.equal( + 'https://archive.org/services/user.php?op=whoami', + ); + }); + + it('can customize the endpoint', async () => { + const userService = new UserService({ + userServiceEndpoint: 'https://foo.org/user', + }); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls[0][0]).to.equal('https://foo.org/user'); + }); + }); + + describe('getCurrentUserInfo', () => { + it('can fetch logged in user info', async () => { + const userService = new UserService(); + const result = await userService.getLoggedInUser(); + expect(result.success?.screenname).to.equal('Foo-Bar'); + }); + + it('returns UserServiceErrorType.userNotLoggedIn if user does not have IA cookies', async () => { + removeCookie('logged-in-user'); + + const userService = new UserService(); + const result = await userService.getLoggedInUser(); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal(UserServiceErrorType.userNotLoggedIn); + }); + + it('returns UserServiceErrorType.userNotLoggedIn if authentication error', async () => { + // cookie may have expired + fetchStub?.mockResolvedValue(getFailureResponse()); + + const userService = new UserService(); + const result = await userService.getLoggedInUser(); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal(UserServiceErrorType.userNotLoggedIn); + }); + + it('returns UserServiceErrorType.networkError if there is a network failure', async () => { + fetchStub?.mockRejectedValue(new Error()); + + const userService = new UserService(); + const result = await userService.getLoggedInUser(); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal(UserServiceErrorType.networkError); + }); + + it('returns UserServiceErrorType.networkError with proper message', async () => { + fetchStub?.mockRejectedValue(new Error('oh dear')); + + const userService = new UserService(); + const result = await userService.getLoggedInUser(); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal(UserServiceErrorType.networkError); + expect(result.error?.message).to.equal('oh dear'); + }); + + it('returns UserServiceErrorType.decodingError if the decoding fails', async () => { + const response = getMockApiResponseFromString('blah blah blah'); + fetchStub?.mockResolvedValue(response); + + const userService = new UserService(); + const result = await userService.getLoggedInUser(); + expect(result.success).to.equal(undefined); + expect(result.error?.type).to.equal(UserServiceErrorType.decodingError); + }); + + it('chains concurrent requests into a single fetch', async () => { + const cache = new LocalCache({ namespace: 'boop' }); + const userService = new UserService({ + cache, + userCacheKey: 'foo-cache', + }); + + const results = await Promise.all([ + userService.getLoggedInUser(), + userService.getLoggedInUser(), + userService.getLoggedInUser(), + userService.getLoggedInUser(), + ]); + + // 4 concurrent requests will only make a single fetch + expect(fetchStub?.mock.calls.length).to.equal(1); + + // validate all of the results got populated properly + expect(results[0].success?.screenname).to.equal('Foo-Bar'); + expect(results[1].success?.screenname).to.equal('Foo-Bar'); + expect(results[2].success?.screenname).to.equal('Foo-Bar'); + expect(results[3].success?.screenname).to.equal('Foo-Bar'); + + cache.delete('foo-cache'); + }); + + it('allows new requests after concurrent requests are completed', async () => { + // don't use the cache so we can verify fetch behavior + const userService = new UserService(); + + await Promise.all([ + userService.getLoggedInUser(), + userService.getLoggedInUser(), + userService.getLoggedInUser(), + userService.getLoggedInUser(), + ]); + expect(fetchStub?.mock.calls.length).to.equal(1); + + // after the concurrent requests finish, since we don't have a cache, + // another fetch gets made + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(2); + }); + }); + + describe('caching', () => { + it('does not use cache if one not passed in', async () => { + const userService = new UserService({ + userCacheKey: 'foo-cache', + }); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(1); + fetchStub?.mockResolvedValue(getSuccessResponse()); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(2); + }); + + it('can cache the user object in localCache', async () => { + const cache = new LocalCache({ namespace: 'boop' }); + const userService = new UserService({ + cache, + userCacheKey: 'foo-cache', + }); + await userService.getLoggedInUser(); + const cachedResult = await cache.get('foo-cache'); + expect(cachedResult).to.deep.equal(mockUserResponse); + cache.delete('foo-cache'); + }); + + it('returns a cached user', async () => { + const cache = new LocalCache({ namespace: 'boop' }); + const userService = new UserService({ + cache, + userCacheKey: 'foo-cache', + }); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(1); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(1); + cache.delete('foo-cache'); + }); + + it('refetches the user if the cached user does not match the cookie user', async () => { + const cache = new LocalCache({ namespace: 'boop' }); + const userService = new UserService({ + cache, + userCacheKey: 'foo-cache', + }); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(1); + + // now return a different user from the cookie + setCookie('logged-in-user', 'user@foo.com'); + + await userService.getLoggedInUser(); + // we should have fetched again + expect(fetchStub?.mock.calls.length).to.equal(2); + cache.delete('foo-cache'); + }); + + it('reuses cached user if the cached user matches the cookie user with urlencoding', async () => { + const cache = new LocalCache({ namespace: 'boop' }); + const userService = new UserService({ + cache, + userCacheKey: 'foo-cache', + }); + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(1); + + setCookie('logged-in-user', 'foo%40bar.com'); + + await userService.getLoggedInUser(); + expect(fetchStub?.mock.calls.length).to.equal(1); + cache.delete('foo-cache'); + }); + }); +}); diff --git a/src/services/user-service/user-service.ts b/src/services/user-service/user-service.ts new file mode 100644 index 00000000..a00ca769 --- /dev/null +++ b/src/services/user-service/user-service.ts @@ -0,0 +1,150 @@ +import { getCookie } from 'typescript-cookie'; +import type { Result } from '../result-type/result-type'; +import { UserResponse, UserServiceResponse } from './models/response'; +import { User } from './models/user'; +import type { UserInterface } from './models/user'; +import { UserServiceError, UserServiceErrorType } from './user-service-error'; + +export interface UserServiceInterface { + /** + * Return the current user info or null if no current user + * + * @returns {Promise>} + * @memberof UserServiceInterface + */ + getLoggedInUser(): Promise>; +} + +/** + * An interface for a cache to conform to for caching User info + * + * @export + * @interface UserServiceCacheInterface + */ +export interface UserServiceCacheInterface { + set(options: { key: string; value: any; ttl?: number }): Promise; + + get(key: string): Promise; +} + +export class UserService implements UserServiceInterface { + private userServiceEndpoint: string; + + private cache?: UserServiceCacheInterface; + + private userCacheKey; + + private cacheTTL?: number; + + constructor(options?: { + userServiceEndpoint?: string; + cache?: UserServiceCacheInterface; + cacheTTL?: number; + userCacheKey?: string; + }) { + this.userServiceEndpoint = + options?.userServiceEndpoint ?? + 'https://archive.org/services/user.php?op=whoami'; + this.cache = options?.cache; + this.cacheTTL = options?.cacheTTL; + this.userCacheKey = options?.userCacheKey ?? 'loggedInUserInfo'; + } + + /** @inheritdoc */ + async getLoggedInUser(): Promise> { + const cookieUsername = getCookie('logged-in-user'); + + const hasCookies = cookieUsername !== undefined; + if (!hasCookies) + return { + error: new UserServiceError(UserServiceErrorType.userNotLoggedIn), + }; + + // check for cached user + const persistedUser = await this.getPersistedUser(); + if (persistedUser) { + const user = User.fromUserResponse(persistedUser); + // verify that the cached used matches the user in the cookie + // otherwise fetch new user info for the cookie'd user + const decodedCookie = decodeURIComponent(cookieUsername); + const nameMatches = decodedCookie === user.username; + if (nameMatches) { + // increase the cache TTL if successful + await this.persistUser(persistedUser); + return { success: user }; + } + } + + // if another fetch is in progress, chain this request to it + if (this.fetchPromise) { + this.fetchPromise = this.fetchPromise.then((response) => { + return response; + }); + return this.fetchPromise; + } + + // assign the fetch promise so chaining starts + this.fetchPromise = this.fetchUser(); + // fetch the result + const result = await this.fetchPromise; + // reset it so subsequent requests go through normal flow + this.fetchPromise = undefined; + return result; + } + + private fetchPromise?: Promise>; + + private async fetchUser(): Promise> { + let response: Response; + try { + response = await fetch(this.userServiceEndpoint, { + credentials: 'include', + }); + } catch (err) { + return { + error: new UserServiceError( + UserServiceErrorType.networkError, + (err as Error).message, + ), + }; + } + + let result: UserServiceResponse; + try { + result = (await response.json()) as UserServiceResponse; + } catch (err) { + return { + error: new UserServiceError( + UserServiceErrorType.decodingError, + (err as Error).message, + ), + }; + } + + if (!result.success || !result.value) { + return { + error: new UserServiceError( + UserServiceErrorType.userNotLoggedIn, + result.error, + ), + }; + } + + const userResponse = result.value; + const user = User.fromUserResponse(userResponse); + await this.persistUser(userResponse); + return { success: user }; + } + + private async getPersistedUser(): Promise { + return this.cache?.get(this.userCacheKey); + } + + private async persistUser(user: UserResponse): Promise { + await this.cache?.set({ + key: this.userCacheKey, + value: user, + ttl: this.cacheTTL, // if set, otherwise will default to the localCache default + }); + } +}