nuthatch/web/src/transport.ts

587 lines
15 KiB
TypeScript
Raw Normal View History

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;
}