Generate the browser contract from ProductApi

This commit is contained in:
ms2k 2026-07-20 03:20:08 +09:00
commit c9642f4067
7 changed files with 2572 additions and 130 deletions

View file

@ -1,5 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import type {
DeleteBatchItem as DeleteItem,
EntryResponse as Entry,
Root,
SessionResponse as Session
} from './generated/contract.ts';
import {
captureOperation,
deleteWarning,
@ -10,36 +16,10 @@
sharedUploadWarning,
unfinishedDeleteItems,
type CapturedOperation,
type DeleteItem,
type DeleteItemResult,
type MutationFollowup,
type MutationResult,
type PendingDelete,
type Root
type PendingDelete
} from './mutations';
type Session = {
user_id: string;
display: string | null;
csrf: string;
};
type Entry = {
name: string;
is_prefix: boolean;
size: number | null;
path: string;
};
type BrowseResponse = {
entries: Entry[];
continuation: string | null;
partial: boolean;
};
type MutationResponse = {
result: MutationResult;
};
import { browserProductClient, requireValue } from './transport.ts';
type PendingUpload = {
file: File;
@ -69,7 +49,7 @@
onMount(async () => {
try {
session = await requestJson<Session>('/api/session');
session = requireValue(await browserProductClient.session({}));
resumableDelete = readPendingDelete(
localStorage.getItem(recursiveStorageKey)
);
@ -79,36 +59,18 @@
}
});
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const body = (await response.json()) as { message?: string };
message = body.message ?? message;
} catch {
// The HTTP status remains the stable fallback when no JSON error exists.
}
throw new Error(message);
}
return (await response.json()) as T;
}
function apiUrl(path: string, values: Record<string, string>): string {
return `${path}?${new URLSearchParams(values).toString()}`;
}
async function browse(append: boolean): Promise<void> {
busy = true;
error = '';
try {
const values: Record<string, string> = { root };
if (prefix !== '') values.prefix = prefix;
if (query !== '') values.q = query;
if (append && continuation !== null) values.continuation = continuation;
const page = await requestJson<BrowseResponse>(apiUrl('/api/browse', values), {
headers: { Accept: 'application/json' }
});
const page = requireValue(
await browserProductClient.browse({
root,
...(prefix === '' ? {} : { prefix }),
...(query === '' ? {} : { q: query }),
...(append && continuation !== null ? { continuation } : {})
})
);
entries = append ? [...entries, ...page.entries] : page.entries;
continuation = page.continuation;
partial = page.partial;
@ -175,21 +137,17 @@
notice = '';
try {
const digest = await sha256(pendingUpload.file);
const url = apiUrl('/api/upload', {
root: pendingUpload.root,
path: pendingUpload.path,
operation_id: pendingUpload.operationId
});
const response = await requestJson<MutationResponse>(url, {
method: 'POST',
headers: {
'Content-Type': 'application/octet-stream',
'X-Declared-Size': String(pendingUpload.file.size),
'X-Content-SHA256': digest,
'X-CSRF-Token': session.csrf
},
body: pendingUpload.file
});
const response = requireValue(
await browserProductClient.upload({
root: pendingUpload.root,
path: pendingUpload.path,
operationId: pendingUpload.operationId,
declaredSize: pendingUpload.file.size,
contentSha256: digest,
csrfToken: session.csrf,
body: pendingUpload.file
})
);
uploadFollowup = mutationFollowup(response.result);
if (uploadFollowup !== 'complete') {
throw new Error(
@ -256,16 +214,16 @@
busy = true;
error = '';
try {
const response = await requestJson<MutationResponse>('/api/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
root: pendingSingleDelete.root,
path: pendingSingleDelete.path,
operation_id: pendingSingleDelete.operationId,
csrf: session.csrf
const response = requireValue(
await browserProductClient.delete({
body: {
root: pendingSingleDelete.root,
path: pendingSingleDelete.path,
operation_id: pendingSingleDelete.operationId,
csrf: session.csrf
}
})
});
);
singleDeleteFollowup = mutationFollowup(response.result);
if (singleDeleteFollowup !== 'complete') {
throw new Error(
@ -295,8 +253,11 @@
for (let page = 0; page < 100; page += 1) {
let items = loadPendingDelete(deleteRoot, path);
if (items.length === 0) {
const plan = await requestJson<{ paths: string[] }>(
apiUrl('/api/delete-plan', { root: deleteRoot, path })
const plan = requireValue(
await browserProductClient.deletePlan({
root: deleteRoot,
path
})
);
if (plan.paths.length === 0) {
localStorage.removeItem(recursiveStorageKey);
@ -311,13 +272,14 @@
}));
savePendingDelete(deleteRoot, path, items);
}
const response = await requestJson<{ results: DeleteItemResult[] }>(
'/api/delete-batch',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ root: deleteRoot, csrf: session.csrf, items })
}
const response = requireValue(
await browserProductClient.deleteBatch({
body: {
root: deleteRoot,
csrf: session.csrf,
items
}
})
);
const unfinished = unfinishedDeleteItems(
response.results,
@ -381,7 +343,7 @@
}
function downloadUrl(entry: Entry): string {
return apiUrl('/api/download', { root, path: entry.path });
return browserProductClient.downloadHref({ root, path: entry.path });
}
async function logout(): Promise<void> {

View file

@ -0,0 +1,272 @@
export interface DeclaredApiError<Code extends string> {
error: Code;
message: string;
details: unknown | null;
}
export type ApiFailure<Code extends string> =
| { kind: 'http'; status: number; error: DeclaredApiError<Code> }
| { kind: 'unexpected-http'; status: number; message: string }
| { kind: 'network'; message: string };
export type ApiResult<Value, Code extends string> =
| { ok: true; value: Value }
| { ok: false; failure: ApiFailure<Code> };
export const contractInvariants = {
relativePath: 'NFC canonical relative path with no empty, dot, backslash, or control segment',
operationId: '1 to 128 URL-safe ASCII characters from the unreserved set',
csrfToken: '43 base64url characters',
contentSha256: '64 lowercase hexadecimal characters',
declaredSize: 'positive integer no greater than Number.MAX_SAFE_INTEGER'
} as const;
export type UserId = string;
export type CsrfToken = string;
export interface SessionResponse {
user_id: UserId;
display: string | null;
csrf: CsrfToken;
}
export type AuthenticationErrorCode = 'authentication_required' | 'malformed_authentication';
export type Root = 'personal' | 'shared';
export type RelativePath = string;
export type SearchQuery = string;
export type PaginationToken = string;
export interface EntryResponse {
name: string;
is_prefix: boolean;
size: number | null;
path: string;
}
export interface BrowseResponse {
entries: Array<EntryResponse>;
continuation: PaginationToken | null;
partial: boolean;
}
export type BrowseErrorCode = 'object_store_error';
export interface DownloadResponse {
body: ReadableStream<Uint8Array>;
contentDisposition: string | null;
xContentTypeOptions: string | null;
contentSecurityPolicy: string | null;
contentLength: number | null;
}
export type DownloadErrorCode = 'object_store_error' | 'not_found' | 'range_not_supported' | 'throttle_unavailable' | 'bandwidth_exceeded';
export type OperationId = string;
export type DeclaredSize = number;
export type ContentSha256 = string;
export type MutationResult = 'pending' | 'succeeded' | 'failed' | 'rejected' | 'conflict';
export interface UploadResponse {
operation_id: OperationId;
result: MutationResult;
idempotency_window_seconds: number;
}
export type UploadErrorCode = 'csrf_validation_failed' | 'invalid_body_stream' | 'size_mismatch' | 'object_too_large' | 'content_identity_mismatch' | 'throttle_unavailable' | 'bandwidth_exceeded' | 'mutation_rejected';
export interface DeleteRequest {
root: Root;
path: RelativePath;
operation_id: OperationId;
csrf: CsrfToken;
}
export interface DeleteResponse {
operation_id: OperationId;
result: MutationResult;
idempotency_window_seconds: number;
}
export type DeleteErrorCode = 'csrf_validation_failed' | 'mutation_rejected';
export interface DeletePlanResponse {
paths: Array<RelativePath>;
continuation: PaginationToken | null;
}
export type DeletePrefixErrorCode = 'csrf_validation_failed' | 'prefix_mutation_rejected';
export interface DeleteBatchItem {
path: RelativePath;
operation_id: OperationId;
}
export interface DeleteBatchRequest {
root: Root;
csrf: CsrfToken;
items: Array<DeleteBatchItem>;
}
export interface DeleteItemResponse {
path: RelativePath;
operation_id: OperationId;
result: MutationResult | null;
error: string | null;
}
export interface DeleteBatchResponse {
results: Array<DeleteItemResponse>;
}
export type SessionEndpointRequest = Record<string, never>;
export type SessionFailureCode = AuthenticationErrorCode;
export interface BrowseEndpointRequest {
root?: Root;
prefix?: RelativePath;
q?: SearchQuery;
continuation?: PaginationToken;
}
export type BrowseFailureCode = AuthenticationErrorCode | BrowseErrorCode;
export interface DownloadEndpointRequest {
root: Root;
path: RelativePath;
range?: string;
}
export type DownloadFailureCode = AuthenticationErrorCode | DownloadErrorCode;
export interface UploadEndpointRequest {
root: Root;
path: RelativePath;
operationId: OperationId;
declaredSize: DeclaredSize;
contentSha256: ContentSha256;
csrfToken: CsrfToken;
body: Blob;
}
export type UploadFailureCode = AuthenticationErrorCode | UploadErrorCode;
export interface DeleteEndpointRequest {
body: DeleteRequest;
}
export type DeleteFailureCode = AuthenticationErrorCode | DeleteErrorCode;
export interface DeletePlanEndpointRequest {
root: Root;
path: RelativePath;
continuation?: PaginationToken;
}
export type DeletePlanFailureCode = AuthenticationErrorCode | DeletePrefixErrorCode;
export interface DeleteBatchEndpointRequest {
body: DeleteBatchRequest;
}
export type DeleteBatchFailureCode = AuthenticationErrorCode | DeletePrefixErrorCode;
export const productContract = {
session: {
method: 'GET',
path: '/api/session',
query: { },
headers: { },
body: 'none',
failureCodes: ['authentication_required', 'malformed_authentication']
},
browse: {
method: 'GET',
path: '/api/browse',
query: { root: 'root', prefix: 'prefix', q: 'q', continuation: 'continuation' },
headers: { },
body: 'none',
failureCodes: ['authentication_required', 'malformed_authentication', 'object_store_error']
},
download: {
method: 'GET',
path: '/api/download',
query: { root: 'root', path: 'path' },
headers: { range: 'Range' },
body: 'none',
failureCodes: ['authentication_required', 'malformed_authentication', 'object_store_error', 'not_found', 'range_not_supported', 'throttle_unavailable', 'bandwidth_exceeded']
},
upload: {
method: 'POST',
path: '/api/upload',
query: { root: 'root', path: 'path', operationId: 'operation_id' },
headers: { declaredSize: 'X-Declared-Size', contentSha256: 'X-Content-SHA256', csrfToken: 'X-CSRF-Token' },
body: 'binary',
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'invalid_body_stream', 'size_mismatch', 'object_too_large', 'content_identity_mismatch', 'throttle_unavailable', 'bandwidth_exceeded', 'mutation_rejected']
},
delete: {
method: 'POST',
path: '/api/delete',
query: { },
headers: { },
body: 'json',
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'mutation_rejected']
},
deletePlan: {
method: 'GET',
path: '/api/delete-plan',
query: { root: 'root', path: 'path', continuation: 'continuation' },
headers: { },
body: 'none',
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'prefix_mutation_rejected']
},
deleteBatch: {
method: 'POST',
path: '/api/delete-batch',
query: { },
headers: { },
body: 'json',
failureCodes: ['authentication_required', 'malformed_authentication', 'csrf_validation_failed', 'prefix_mutation_rejected']
},
} as const;
export interface ProductEndpoint {
session(request: SessionEndpointRequest): Promise<ApiResult<SessionResponse, SessionFailureCode>>;
browse(request: BrowseEndpointRequest): Promise<ApiResult<BrowseResponse, BrowseFailureCode>>;
download(request: DownloadEndpointRequest): Promise<ApiResult<DownloadResponse, DownloadFailureCode>>;
upload(request: UploadEndpointRequest): Promise<ApiResult<UploadResponse, UploadFailureCode>>;
delete(request: DeleteEndpointRequest): Promise<ApiResult<DeleteResponse, DeleteFailureCode>>;
deletePlan(request: DeletePlanEndpointRequest): Promise<ApiResult<DeletePlanResponse, DeletePlanFailureCode>>;
deleteBatch(request: DeleteBatchEndpointRequest): Promise<ApiResult<DeleteBatchResponse, DeleteBatchFailureCode>>;
}

View file

@ -1,21 +1,13 @@
export type Root = 'personal' | 'shared';
import type {
DeleteBatchItem,
DeleteItemResponse,
MutationResult,
Root
} from './generated/contract.ts';
export type MutationResult =
| 'succeeded'
| 'pending'
| 'failed'
| 'rejected'
| 'conflict';
export type DeleteItem = {
path: string;
operation_id: string;
};
export type DeleteItemResult = DeleteItem & {
result: MutationResult | null;
error: string | null;
};
export type DeleteItem = DeleteBatchItem;
export type DeleteItemResult = DeleteItemResponse;
export type { MutationResult, Root };
export type PendingDelete = {
root: Root;

548
web/src/transport.ts Normal file
View 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;
}

View file

@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import {
BrowserProductClient,
requireValue
} from '../src/transport.ts';
const baseUrl = process.argv[2];
if (baseUrl === undefined) {
throw new Error('missing integration base URL');
}
const client = new BrowserProductClient(globalThis.fetch.bind(globalThis), baseUrl);
const csrf = 'a'.repeat(43);
const path = 'folder/문서 a.txt';
const operationId = 'operation-1';
const session = requireValue(await client.session({}));
assert.deepEqual(session, {
user_id: 'user-1',
display: 'User',
csrf
});
const browse = requireValue(
await client.browse({
root: 'shared',
prefix: path,
q: 'needle',
continuation: 'next-page'
})
);
assert.equal(browse.entries[0].path, path);
assert.equal(browse.continuation, 'next-page');
assert.equal(browse.partial, true);
const download = requireValue(
await client.download({ root: 'shared', path })
);
assert.equal(await new Response(download.body).text(), 'abc');
assert.equal(download.contentDisposition, 'attachment; filename="file.txt"');
assert.equal(download.xContentTypeOptions, 'nosniff');
assert.equal(download.contentSecurityPolicy, "default-src 'none'");
assert.equal(download.contentLength, 3);
const upload = requireValue(
await client.upload({
root: 'personal',
path,
operationId,
declaredSize: 3,
contentSha256: 'b'.repeat(64),
csrfToken: csrf,
body: new Blob(['abc'])
})
);
assert.equal(upload.operation_id, operationId);
assert.equal(upload.result, 'succeeded');
const deletion = requireValue(
await client.delete({
body: {
root: 'shared',
path,
operation_id: operationId,
csrf
}
})
);
assert.equal(deletion.operation_id, operationId);
assert.equal(deletion.result, 'succeeded');
const plan = requireValue(
await client.deletePlan({
root: 'shared',
path,
continuation: 'next-page'
})
);
assert.deepEqual(plan.paths, [path]);
assert.equal(plan.continuation, 'next-page');
const batch = requireValue(
await client.deleteBatch({
body: {
root: 'shared',
csrf,
items: [{ path, operation_id: operationId }]
}
})
);
assert.deepEqual(batch.results, [
{
path,
operation_id: operationId,
result: 'succeeded',
error: null
}
]);

1
web/test/fixtures/contract.json vendored Normal file
View file

@ -0,0 +1 @@
{"browse_response":{"continuation":"next-page","entries":[{"is_prefix":false,"name":"문서.txt","path":"folder/문서.txt","size":7}],"partial":false},"delete_batch_item":{"operation_id":"operation-1","path":"folder/문서.txt"},"delete_batch_request":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","items":[{"operation_id":"operation-1","path":"folder/문서.txt"}],"root":"shared"},"delete_batch_response":{"results":[{"error":null,"operation_id":"operation-1","path":"folder/문서.txt","result":"succeeded"}]},"delete_item_response":{"error":null,"operation_id":"operation-1","path":"folder/문서.txt","result":"succeeded"},"delete_plan_response":{"continuation":"next-page","paths":["folder/문서.txt"]},"delete_request":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation_id":"operation-1","path":"folder/문서.txt","root":"shared"},"delete_response":{"idempotency_window_seconds":3600,"operation_id":"operation-1","result":"pending"},"entry_response":{"is_prefix":false,"name":"문서.txt","path":"folder/문서.txt","size":7},"errors":[{"details":null,"error":"authentication_required","message":"login required"},{"details":null,"error":"malformed_authentication","message":"authentication data is malformed"},{"details":null,"error":"object_store_error","message":"store"},{"details":null,"error":"object_store_error","message":"store"},{"details":null,"error":"not_found","message":"object not found"},{"details":null,"error":"range_not_supported","message":"Range downloads are not supported"},{"details":null,"error":"throttle_unavailable","message":"bandwidth throttle unavailable"},{"details":null,"error":"bandwidth_exceeded","message":"bandwidth limit exceeded"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"invalid_body_stream","message":"body"},{"details":null,"error":"size_mismatch","message":"request body does not match declared size"},{"details":null,"error":"object_too_large","message":"object too large"},{"details":null,"error":"content_identity_mismatch","message":"request body digest does not match"},{"details":null,"error":"throttle_unavailable","message":"bandwidth throttle unavailable"},{"details":null,"error":"bandwidth_exceeded","message":"bandwidth limit exceeded"},{"details":null,"error":"mutation_rejected","message":"mutation"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"mutation_rejected","message":"mutation"},{"details":null,"error":"csrf_validation_failed","message":"CSRF validation failed"},{"details":null,"error":"prefix_mutation_rejected","message":"prefix"}],"session_response":{"csrf":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","display":"User","user_id":"user-1"},"upload_response":{"idempotency_window_seconds":3600,"operation_id":"operation-1","result":"succeeded"}}

189
web/test/transport.test.mjs Normal file
View file

@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
BrowserProductClient,
requireValue
} from '../src/transport.ts';
const fixtures = JSON.parse(
await readFile(new URL('./fixtures/contract.json', import.meta.url), 'utf8')
);
test('transport derives routes, credentials, and encoded queries from the contract', async () => {
const calls = [];
const client = new BrowserProductClient(
async (input, init) => {
calls.push({ input: String(input), init });
return jsonResponse(fixtures.browse_response);
},
'https://nuthatch.test/'
);
const response = requireValue(
await client.browse({
root: 'shared',
prefix: '문서/a b',
q: 'needle',
continuation: 'next/token'
})
);
assert.deepEqual(response, fixtures.browse_response);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.credentials, 'same-origin');
assert.equal(calls[0].init.method, 'GET');
const url = new URL(calls[0].input);
assert.equal(url.pathname, '/api/browse');
assert.equal(url.searchParams.get('root'), 'shared');
assert.equal(url.searchParams.get('prefix'), '문서/a b');
assert.equal(url.searchParams.get('q'), 'needle');
assert.equal(url.searchParams.get('continuation'), 'next/token');
});
test('binary upload preserves the Blob and required generated headers', async () => {
const calls = [];
const client = new BrowserProductClient(async (input, init) => {
calls.push({ input: String(input), init });
return jsonResponse(fixtures.upload_response);
});
const body = new Blob(['payload']);
const response = requireValue(
await client.upload({
root: 'personal',
path: 'folder/문서.txt',
operationId: 'operation-1',
declaredSize: body.size,
contentSha256: 'a'.repeat(64),
csrfToken: 'b'.repeat(43),
body
})
);
assert.deepEqual(response, fixtures.upload_response);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.body, body);
assert.equal(calls[0].init.headers.get('Content-Type'), 'application/octet-stream');
assert.equal(calls[0].init.headers.get('X-Declared-Size'), '7');
assert.equal(calls[0].init.headers.get('X-Content-SHA256'), 'a'.repeat(64));
assert.equal(calls[0].init.headers.get('X-CSRF-Token'), 'b'.repeat(43));
const url = new URL(calls[0].input, 'https://nuthatch.test');
assert.equal(url.pathname, '/api/upload');
assert.equal(url.searchParams.get('operation_id'), 'operation-1');
assert.equal(url.searchParams.get('path'), 'folder/문서.txt');
});
test('download exposes its stream and declared response headers', async () => {
const client = new BrowserProductClient(async () =>
new Response(new Uint8Array([1, 2, 3]), {
headers: {
'Content-Disposition': 'attachment; filename="file.bin"',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'",
'Content-Length': '3'
}
})
);
const download = requireValue(
await client.download({ root: 'shared', path: 'file.bin' })
);
assert.deepEqual(
new Uint8Array(await new Response(download.body).arrayBuffer()),
new Uint8Array([1, 2, 3])
);
assert.equal(download.contentDisposition, 'attachment; filename="file.bin"');
assert.equal(download.xContentTypeOptions, 'nosniff');
assert.equal(download.contentSecurityPolicy, "default-src 'none'");
assert.equal(download.contentLength, 3);
});
test('shared fixtures decode through every JSON transport method', async () => {
const responses = [
fixtures.session_response,
fixtures.delete_response,
fixtures.delete_plan_response,
fixtures.delete_batch_response
];
const client = new BrowserProductClient(async () =>
jsonResponse(responses.shift())
);
assert.deepEqual(requireValue(await client.session({})), fixtures.session_response);
assert.deepEqual(
requireValue(await client.delete({ body: fixtures.delete_request })),
fixtures.delete_response
);
assert.deepEqual(
requireValue(
await client.deletePlan({
root: 'shared',
path: 'folder/문서.txt',
continuation: 'next-page'
})
),
fixtures.delete_plan_response
);
assert.deepEqual(
requireValue(
await client.deleteBatch({ body: fixtures.delete_batch_request })
),
fixtures.delete_batch_response
);
});
test('declared, undeclared, and network failures remain distinct without retries', async () => {
const declaredClient = new BrowserProductClient(async () =>
jsonResponse(
{
error: 'csrf_validation_failed',
message: 'CSRF validation failed',
details: null
},
403
)
);
const declared = await declaredClient.delete({
body: fixtures.delete_request
});
assert.equal(declared.ok, false);
assert.equal(declared.failure.kind, 'http');
assert.equal(declared.failure.error.error, 'csrf_validation_failed');
const undeclaredClient = new BrowserProductClient(async () =>
jsonResponse(
{ error: 'unknown_error', message: 'unknown', details: null },
500
)
);
const undeclared = await undeclaredClient.session({});
assert.equal(undeclared.ok, false);
assert.equal(undeclared.failure.kind, 'unexpected-http');
let attempts = 0;
const networkClient = new BrowserProductClient(async () => {
attempts += 1;
throw new Error('offline');
});
const network = await networkClient.browse({});
assert.equal(network.ok, false);
assert.equal(network.failure.kind, 'network');
assert.equal(attempts, 1);
});
test('checked generated artifact contains no transport policy', async () => {
const generated = await readFile(
new URL('../src/generated/contract.ts', import.meta.url),
'utf8'
);
assert.doesNotMatch(generated, /\bfetch\s*\(/);
assert.doesNotMatch(generated, /\bcredentials\b/);
assert.doesNotMatch(generated, /\bretry\b/i);
});
function jsonResponse(value, status = 200) {
return new Response(JSON.stringify(value), {
status,
headers: { 'Content-Type': 'application/json' }
});
}