587 lines
15 KiB
TypeScript
587 lines
15 KiB
TypeScript
import {
|
|
productContract,
|
|
type ApiFailure,
|
|
type ApiResult,
|
|
type BrowseEndpointRequest,
|
|
type BrowseFailureCode,
|
|
type BrowseResponse,
|
|
type DeleteBatchEndpointRequest,
|
|
type DeleteBatchFailureCode,
|
|
type DeleteBatchResponse,
|
|
type DeleteEndpointRequest,
|
|
type DeleteFailureCode,
|
|
type DeleteItemFailure,
|
|
type DeleteItemResponse,
|
|
type DeletePlanEndpointRequest,
|
|
type DeletePlanFailureCode,
|
|
type DeletePlanResponse,
|
|
type DeleteResponse,
|
|
type DownloadEndpointRequest,
|
|
type DownloadFailureCode,
|
|
type DownloadResponse,
|
|
type EntryResponse,
|
|
type MutationResult,
|
|
type ProductEndpoint,
|
|
type SessionEndpointRequest,
|
|
type SessionFailureCode,
|
|
type SessionResponse,
|
|
type UploadEndpointRequest,
|
|
type UploadFailureCode,
|
|
type UploadResponse
|
|
} from './generated/contract.ts';
|
|
|
|
export type FetchRequest = (
|
|
input: RequestInfo | URL,
|
|
init?: RequestInit
|
|
) => Promise<Response>;
|
|
|
|
export class BrowserProductClient implements ProductEndpoint {
|
|
private readonly fetchRequest: FetchRequest;
|
|
private readonly baseUrl: string;
|
|
|
|
constructor(
|
|
fetchRequest: FetchRequest = globalThis.fetch.bind(globalThis),
|
|
baseUrl = ''
|
|
) {
|
|
this.fetchRequest = fetchRequest;
|
|
this.baseUrl = baseUrl;
|
|
}
|
|
|
|
session(
|
|
request: SessionEndpointRequest
|
|
): Promise<ApiResult<SessionResponse, SessionFailureCode>> {
|
|
return this.jsonRequest(
|
|
productContract.session,
|
|
request,
|
|
decodeSessionResponse
|
|
);
|
|
}
|
|
|
|
browse(
|
|
request: BrowseEndpointRequest
|
|
): Promise<ApiResult<BrowseResponse, BrowseFailureCode>> {
|
|
return this.jsonRequest(
|
|
productContract.browse,
|
|
request,
|
|
decodeBrowseResponse
|
|
);
|
|
}
|
|
|
|
async download(
|
|
request: DownloadEndpointRequest
|
|
): Promise<ApiResult<DownloadResponse, DownloadFailureCode>> {
|
|
const contract = productContract.download;
|
|
const response = await this.send<DownloadFailureCode>(contract, request);
|
|
if (!response.ok) {
|
|
return response;
|
|
}
|
|
const body = response.value.body;
|
|
if (body === null) {
|
|
return unexpectedHttp(response.value, 'download response has no body');
|
|
}
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
body,
|
|
contentDisposition: response.value.headers.get('Content-Disposition'),
|
|
xContentTypeOptions: response.value.headers.get(
|
|
'X-Content-Type-Options'
|
|
),
|
|
contentSecurityPolicy: response.value.headers.get(
|
|
'Content-Security-Policy'
|
|
),
|
|
contentLength: optionalIntegerHeader(
|
|
response.value.headers.get('Content-Length')
|
|
)
|
|
}
|
|
};
|
|
}
|
|
|
|
upload(
|
|
request: UploadEndpointRequest
|
|
): Promise<ApiResult<UploadResponse, UploadFailureCode>> {
|
|
return this.jsonRequest(
|
|
productContract.upload,
|
|
request,
|
|
decodeUploadResponse
|
|
);
|
|
}
|
|
|
|
delete(
|
|
request: DeleteEndpointRequest
|
|
): Promise<ApiResult<DeleteResponse, DeleteFailureCode>> {
|
|
return this.jsonRequest(
|
|
productContract.delete,
|
|
request,
|
|
decodeDeleteResponse
|
|
);
|
|
}
|
|
|
|
deletePlan(
|
|
request: DeletePlanEndpointRequest
|
|
): Promise<ApiResult<DeletePlanResponse, DeletePlanFailureCode>> {
|
|
return this.jsonRequest(
|
|
productContract.deletePlan,
|
|
request,
|
|
decodeDeletePlanResponse
|
|
);
|
|
}
|
|
|
|
deleteBatch(
|
|
request: DeleteBatchEndpointRequest
|
|
): Promise<ApiResult<DeleteBatchResponse, DeleteBatchFailureCode>> {
|
|
return this.jsonRequest(
|
|
productContract.deleteBatch,
|
|
request,
|
|
decodeDeleteBatchResponse
|
|
);
|
|
}
|
|
|
|
downloadHref(request: DownloadEndpointRequest): string {
|
|
return buildUrl(
|
|
productContract.download.path,
|
|
productContract.download.query,
|
|
request,
|
|
this.baseUrl
|
|
);
|
|
}
|
|
|
|
async logout(csrfToken: string): Promise<void> {
|
|
const response = await this.fetchRequest(
|
|
this.baseUrl === '' ? '/logout' : new URL('/logout', this.baseUrl),
|
|
{
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'X-CSRF-Token': csrfToken }
|
|
}
|
|
);
|
|
if (!response.ok) {
|
|
throw new ProductRequestError({
|
|
kind: 'unexpected-http',
|
|
status: response.status,
|
|
message: response.statusText
|
|
});
|
|
}
|
|
}
|
|
|
|
private async jsonRequest<Success, Code extends string>(
|
|
contract: EndpointContract,
|
|
request: object,
|
|
decode: (value: unknown) => Success
|
|
): Promise<ApiResult<Success, Code>> {
|
|
const response = await this.send<Code>(contract, request);
|
|
if (!response.ok) {
|
|
return response;
|
|
}
|
|
try {
|
|
return {
|
|
ok: true,
|
|
value: decode(await response.value.json())
|
|
};
|
|
} catch (caught) {
|
|
return unexpectedHttp(
|
|
response.value,
|
|
caught instanceof Error ? caught.message : String(caught)
|
|
);
|
|
}
|
|
}
|
|
|
|
private async send<Code extends string>(
|
|
contract: EndpointContract,
|
|
request: object
|
|
): Promise<ApiResult<Response, Code>> {
|
|
const values = request as Record<string, unknown>;
|
|
const headers = new Headers({ Accept: acceptedResponse(contract) });
|
|
addHeaders(headers, contract.headers, values);
|
|
const body = requestBody(contract.body, values, headers);
|
|
const url = buildUrl(contract.path, contract.query, values, this.baseUrl);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await this.fetchRequest(url, {
|
|
method: contract.method,
|
|
credentials: 'same-origin',
|
|
headers,
|
|
body
|
|
});
|
|
} catch (caught) {
|
|
return {
|
|
ok: false,
|
|
failure: {
|
|
kind: 'network',
|
|
message: caught instanceof Error ? caught.message : String(caught)
|
|
}
|
|
};
|
|
}
|
|
|
|
if (response.ok) {
|
|
return { ok: true, value: response };
|
|
}
|
|
return {
|
|
ok: false,
|
|
failure: await decodeFailure<Code>(response, contract.failureCodes)
|
|
};
|
|
}
|
|
}
|
|
|
|
export const browserProductClient = new BrowserProductClient();
|
|
|
|
export class ProductRequestError extends Error {
|
|
readonly failure: ApiFailure<string>;
|
|
|
|
constructor(failure: ApiFailure<string>) {
|
|
super(failureMessage(failure));
|
|
this.name = 'ProductRequestError';
|
|
this.failure = failure;
|
|
}
|
|
}
|
|
|
|
export function requireValue<Value, Code extends string>(
|
|
result: ApiResult<Value, Code>
|
|
): Value {
|
|
if (result.ok) {
|
|
return result.value;
|
|
}
|
|
throw new ProductRequestError(result.failure);
|
|
}
|
|
|
|
type EndpointContract = {
|
|
readonly method: string;
|
|
readonly path: string;
|
|
readonly query: Readonly<Record<string, string>>;
|
|
readonly headers: Readonly<Record<string, string>>;
|
|
readonly body: 'none' | 'json' | 'binary';
|
|
readonly failureCodes: readonly string[];
|
|
};
|
|
|
|
function buildUrl(
|
|
path: string,
|
|
query: Readonly<Record<string, string>>,
|
|
request: object,
|
|
baseUrl: string
|
|
): string {
|
|
const values = request as Record<string, unknown>;
|
|
const parameters = new URLSearchParams();
|
|
for (const [field, wireName] of Object.entries(query)) {
|
|
const value = values[field];
|
|
if (value !== undefined && value !== null) {
|
|
parameters.set(wireName, String(value));
|
|
}
|
|
}
|
|
const suffix = parameters.size === 0 ? '' : `?${parameters.toString()}`;
|
|
const relative = `${path}${suffix}`;
|
|
return baseUrl === '' ? relative : new URL(relative, baseUrl).toString();
|
|
}
|
|
|
|
function addHeaders(
|
|
headers: Headers,
|
|
mapping: Readonly<Record<string, string>>,
|
|
values: Record<string, unknown>
|
|
): void {
|
|
for (const [field, wireName] of Object.entries(mapping)) {
|
|
const value = values[field];
|
|
if (value !== undefined && value !== null) {
|
|
headers.set(wireName, String(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
function requestBody(
|
|
bodyKind: EndpointContract['body'],
|
|
values: Record<string, unknown>,
|
|
headers: Headers
|
|
): BodyInit | undefined {
|
|
if (bodyKind === 'none') {
|
|
return undefined;
|
|
}
|
|
if (bodyKind === 'json') {
|
|
headers.set('Content-Type', 'application/json');
|
|
return JSON.stringify(values.body);
|
|
}
|
|
headers.set('Content-Type', 'application/octet-stream');
|
|
const body = values.body;
|
|
if (!(body instanceof Blob)) {
|
|
throw new TypeError('binary request body must be a Blob');
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function acceptedResponse(contract: EndpointContract): string {
|
|
return contract === productContract.download
|
|
? 'application/octet-stream'
|
|
: 'application/json';
|
|
}
|
|
|
|
async function decodeFailure<Code extends string>(
|
|
response: Response,
|
|
allowedCodes: readonly string[]
|
|
): Promise<ApiFailure<Code>> {
|
|
let value: unknown;
|
|
try {
|
|
value = await response.json();
|
|
} catch {
|
|
return {
|
|
kind: 'unexpected-http',
|
|
status: response.status,
|
|
message: `${response.status} ${response.statusText}`
|
|
};
|
|
}
|
|
if (!isRecord(value)) {
|
|
return unexpectedFailure(response, 'error response is not an object');
|
|
}
|
|
const code = value.error;
|
|
if (
|
|
typeof code !== 'string' ||
|
|
!allowedCodes.includes(code) ||
|
|
typeof value.message !== 'string'
|
|
) {
|
|
return unexpectedFailure(response, 'error response has an undeclared shape');
|
|
}
|
|
return {
|
|
kind: 'http',
|
|
status: response.status,
|
|
error: {
|
|
error: code as Code,
|
|
message: value.message,
|
|
details: value.details ?? null
|
|
}
|
|
};
|
|
}
|
|
|
|
function unexpectedFailure(
|
|
response: Response,
|
|
message: string
|
|
): ApiFailure<never> {
|
|
return {
|
|
kind: 'unexpected-http',
|
|
status: response.status,
|
|
message
|
|
};
|
|
}
|
|
|
|
function unexpectedHttp<Code extends string>(
|
|
response: Response,
|
|
message: string
|
|
): ApiResult<never, Code> {
|
|
return {
|
|
ok: false,
|
|
failure: {
|
|
kind: 'unexpected-http',
|
|
status: response.status,
|
|
message
|
|
}
|
|
};
|
|
}
|
|
|
|
function failureMessage(failure: ApiFailure<string>): string {
|
|
if (failure.kind === 'http') {
|
|
return failure.error.message;
|
|
}
|
|
if (failure.kind === 'unexpected-http') {
|
|
return `${failure.status} ${failure.message}`;
|
|
}
|
|
return failure.message;
|
|
}
|
|
|
|
function decodeSessionResponse(value: unknown): SessionResponse {
|
|
const object = record(value, 'session response');
|
|
return {
|
|
user_id: stringField(object, 'user_id'),
|
|
display: nullableStringField(object, 'display'),
|
|
csrf: stringField(object, 'csrf')
|
|
};
|
|
}
|
|
|
|
function decodeBrowseResponse(value: unknown): BrowseResponse {
|
|
const object = record(value, 'browse response');
|
|
const entries = object.entries;
|
|
if (!Array.isArray(entries)) {
|
|
throw new TypeError('browse response entries must be an array');
|
|
}
|
|
return {
|
|
entries: entries.map(decodeEntryResponse),
|
|
continuation: nullableStringField(object, 'continuation'),
|
|
partial: booleanField(object, 'partial')
|
|
};
|
|
}
|
|
|
|
function decodeEntryResponse(value: unknown): EntryResponse {
|
|
const object = record(value, 'entry response');
|
|
return {
|
|
name: stringField(object, 'name'),
|
|
is_prefix: booleanField(object, 'is_prefix'),
|
|
size: nullableIntegerField(object, 'size'),
|
|
path: stringField(object, 'path')
|
|
};
|
|
}
|
|
|
|
function decodeUploadResponse(value: unknown): UploadResponse {
|
|
return decodeMutationResponse(value);
|
|
}
|
|
|
|
function decodeDeleteResponse(value: unknown): DeleteResponse {
|
|
return decodeMutationResponse(value);
|
|
}
|
|
|
|
function decodeMutationResponse(value: unknown): UploadResponse {
|
|
const object = record(value, 'mutation response');
|
|
return {
|
|
operation_id: stringField(object, 'operation_id'),
|
|
result: mutationResultField(object, 'result'),
|
|
idempotency_window_seconds: integerField(
|
|
object,
|
|
'idempotency_window_seconds'
|
|
)
|
|
};
|
|
}
|
|
|
|
function decodeDeletePlanResponse(value: unknown): DeletePlanResponse {
|
|
const object = record(value, 'delete plan response');
|
|
const paths = object.paths;
|
|
if (!Array.isArray(paths) || !paths.every((path) => typeof path === 'string')) {
|
|
throw new TypeError('delete plan paths must be an array of strings');
|
|
}
|
|
return {
|
|
paths,
|
|
continuation: nullableStringField(object, 'continuation')
|
|
};
|
|
}
|
|
|
|
function decodeDeleteBatchResponse(value: unknown): DeleteBatchResponse {
|
|
const object = record(value, 'delete batch response');
|
|
const results = object.results;
|
|
if (!Array.isArray(results)) {
|
|
throw new TypeError('delete batch results must be an array');
|
|
}
|
|
return { results: results.map(decodeDeleteItemResponse) };
|
|
}
|
|
|
|
function decodeDeleteItemResponse(value: unknown): DeleteItemResponse {
|
|
const object = record(value, 'delete item response');
|
|
const path = stringField(object, 'path');
|
|
const operation_id = stringField(object, 'operation_id');
|
|
const result = nullableMutationResultField(object, 'result');
|
|
const error = nullableDeleteItemFailureField(object, 'error');
|
|
if (result !== null && error === null) {
|
|
return { path, operation_id, result, error };
|
|
}
|
|
if (result === null && error !== null) {
|
|
return { path, operation_id, result, error };
|
|
}
|
|
throw new TypeError('delete item response must contain exactly one outcome');
|
|
}
|
|
|
|
function nullableDeleteItemFailureField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): DeleteItemFailure | null {
|
|
const value = object[field];
|
|
if (
|
|
value === null ||
|
|
value === 'database_unavailable' ||
|
|
value === 'object_store_unavailable'
|
|
) {
|
|
return value;
|
|
}
|
|
throw new TypeError(`${field} must be a declared delete item failure or null`);
|
|
}
|
|
|
|
function record(value: unknown, label: string): Record<string, unknown> {
|
|
if (!isRecord(value)) {
|
|
throw new TypeError(`${label} must be an object`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function stringField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): string {
|
|
const value = object[field];
|
|
if (typeof value !== 'string') {
|
|
throw new TypeError(`${field} must be a string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function nullableStringField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): string | null {
|
|
const value = object[field];
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
return stringField(object, field);
|
|
}
|
|
|
|
function booleanField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): boolean {
|
|
const value = object[field];
|
|
if (typeof value !== 'boolean') {
|
|
throw new TypeError(`${field} must be a boolean`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function integerField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): number {
|
|
const value = object[field];
|
|
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
|
|
throw new TypeError(`${field} must be a safe integer`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function nullableIntegerField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): number | null {
|
|
if (object[field] === null) {
|
|
return null;
|
|
}
|
|
return integerField(object, field);
|
|
}
|
|
|
|
function mutationResultField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): MutationResult {
|
|
const value = object[field];
|
|
if (
|
|
value !== 'pending' &&
|
|
value !== 'succeeded' &&
|
|
value !== 'failed' &&
|
|
value !== 'rejected' &&
|
|
value !== 'conflict'
|
|
) {
|
|
throw new TypeError(`${field} must be a mutation result`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function nullableMutationResultField(
|
|
object: Record<string, unknown>,
|
|
field: string
|
|
): MutationResult | null {
|
|
if (object[field] === null) {
|
|
return null;
|
|
}
|
|
return mutationResultField(object, field);
|
|
}
|
|
|
|
function optionalIntegerHeader(value: string | null): number | null {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
const parsed = Number(value);
|
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
}
|