118 lines
3.5 KiB
TypeScript
118 lines
3.5 KiB
TypeScript
import { Console, Data, Effect, Option, ParseResult, pipe, Record, Schema } from "effect";
|
|
import { constant } from "effect/Function";
|
|
import * as Api from "./Api.ts";
|
|
import * as Body from "./Body.ts";
|
|
import * as Cbor from "./Cbor.ts";
|
|
import { fetch, FetchError } from "./Fetch.ts";
|
|
|
|
export class ServerDie extends Data.TaggedError("ServerDie")<{ cause: unknown }> { }
|
|
export class UnexpectedStatus extends Data.TaggedError("UnexpectedStatusCode")<{ status: number }> { }
|
|
|
|
export interface ClientOptions {
|
|
readonly baseUrl?: string | URL | undefined;
|
|
}
|
|
|
|
export const client = <const Record extends { readonly [_: string]: Api.ApiAny }>(
|
|
bundle: Api.ApiBundle<Record>,
|
|
{ baseUrl }: ClientOptions = {},
|
|
) => {
|
|
return Object.freeze(Record.map(bundle.record, (api, key) => {
|
|
const url = new URL(`/api/${key}`, baseUrl ?? location.origin);
|
|
|
|
const requestEncoder = encodeBody(api.request);
|
|
const responseDecoder = decodeBody(api.response);
|
|
const errorDecoder = decodeBody(api.error);
|
|
|
|
return Effect.fn(`call.${key}`)(function* (request: any) {
|
|
|
|
const body = yield* pipe(
|
|
request,
|
|
requestEncoder,
|
|
Effect.flatMap(({ body, headers }) => fetch(url, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
body,
|
|
headers,
|
|
})),
|
|
Effect.catchTags({
|
|
CborEncodeError: Effect.die,
|
|
ParseError: Effect.die,
|
|
}),
|
|
);
|
|
|
|
switch (body.status) {
|
|
case 200: {
|
|
const result = yield* responseDecoder(body).pipe(Effect.orDie);
|
|
return result;
|
|
}
|
|
case 400: {
|
|
const result = yield* errorDecoder(body).pipe(Effect.orDie);
|
|
return yield* Effect.fail(result);
|
|
}
|
|
case 500: {
|
|
const result = yield* readBody(body).pipe(Effect.orDie);
|
|
return yield* Effect.die(new ServerDie({ cause: result }));
|
|
}
|
|
default: {
|
|
return yield* Effect.die(new UnexpectedStatus({ status: body.status }));
|
|
}
|
|
}
|
|
});
|
|
})) as { readonly [K in keyof Record]: (request: Record[K]["request"]["Type"]) => Effect.Effect<Record[K]["response"]["Type"], Record[K]["error"]["Type"] | FetchError, never> };
|
|
};
|
|
|
|
export const readBody = (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
|
|
body,
|
|
Body.bytes,
|
|
Effect.map((bytes) => bytes.byteLength > 0 ? Option.some(bytes) : Option.none()),
|
|
Effect.flatMap((maybeBytes) => pipe(
|
|
maybeBytes,
|
|
Effect.transposeMapOption(Cbor.decode),
|
|
)),
|
|
Effect.map(Option.getOrUndefined),
|
|
);
|
|
|
|
export const decodeBody = <A>(schema: Schema.Schema<A, any>) => {
|
|
const decoder = Schema.decodeUnknown(schema);
|
|
return (body: { bytes(): Promise<Uint8Array<ArrayBuffer>> }) => pipe(
|
|
readBody(body),
|
|
Effect.flatMap(decoder),
|
|
Effect.tapErrorTag("ParseError", (error) => pipe(
|
|
error,
|
|
ParseResult.TreeFormatter.formatError,
|
|
Effect.flatMap(Console.error),
|
|
)),
|
|
);
|
|
};
|
|
|
|
export interface BodyData {
|
|
readonly body: null | Uint8Array<ArrayBuffer>;
|
|
readonly headers: { readonly [_: string]: string };
|
|
}
|
|
|
|
export const encodeBody = <A>(schema: Schema.Schema<A, any>) => {
|
|
const encoder = Cbor.encodeSchema(schema);
|
|
return (a: A) => pipe(
|
|
a,
|
|
Option.fromNullable,
|
|
Effect.transposeMapOption(encoder),
|
|
Effect.map(Option.match({
|
|
onNone: emptyBody,
|
|
onSome: cborBody,
|
|
}))
|
|
);
|
|
};
|
|
|
|
export const emptyBody: () => BodyData = constant(Object.freeze<BodyData>({
|
|
body: null,
|
|
headers: Object.freeze({}),
|
|
}));
|
|
|
|
export const cborBody: (bytes: Uint8Array<ArrayBuffer>) => BodyData = (bytes) => Object.freeze<BodyData>({
|
|
body: bytes,
|
|
headers: Object.freeze({
|
|
"Content-Type": "application/cbor",
|
|
"Content-Length": String(bytes.byteLength),
|
|
}),
|
|
});
|