Generate the browser contract from ProductApi
This commit is contained in:
parent
67cc0cb802
commit
c9642f4067
1 changed files with 2572 additions and 130 deletions
548
web/src/transport.ts
Normal file
548
web/src/transport.ts
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
import {
|
||||
productContract,
|
||||
type ApiFailure,
|
||||
type ApiResult,
|
||||
type BrowseEndpointRequest,
|
||||
type BrowseFailureCode,
|
||||
type BrowseResponse,
|
||||
type DeleteBatchEndpointRequest,
|
||||
type DeleteBatchFailureCode,
|
||||
type DeleteBatchResponse,
|
||||
type DeleteEndpointRequest,
|
||||
type DeleteFailureCode,
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
return {
|
||||
path: stringField(object, 'path'),
|
||||
operation_id: stringField(object, 'operation_id'),
|
||||
result: nullableMutationResultField(object, 'result'),
|
||||
error: nullableStringField(object, 'error')
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue