Generate the browser contract from ProductApi
This commit is contained in:
parent
67cc0cb802
commit
c9642f4067
7 changed files with 2572 additions and 130 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
272
web/src/generated/contract.ts
Normal file
272
web/src/generated/contract.ts
Normal 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>>;
|
||||
}
|
||||
|
|
@ -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
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;
|
||||
}
|
||||
98
web/test/contract-integration.mjs
Normal file
98
web/test/contract-integration.mjs
Normal 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
1
web/test/fixtures/contract.json
vendored
Normal 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
189
web/test/transport.test.mjs
Normal 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' }
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue