JUMBO refactor, still work in progress
This commit is contained in:
67
packages/common/src/Api.test.ts
Normal file
67
packages/common/src/Api.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { HashMap, Schema } from "effect";
|
||||
import * as Api from "./Api";
|
||||
|
||||
describe("bundle", () => {
|
||||
test("constructs a HashMap", () => {
|
||||
const foo = Api.make(Schema.Void, Schema.String, Schema.String);
|
||||
const bar = Api.make(Schema.Void, Schema.Number, Schema.String);
|
||||
const bundle = Api.bundle({ foo, bar });
|
||||
|
||||
expect(HashMap.unsafeGet(bundle.map, "foo")).toBe(foo);
|
||||
expect(HashMap.unsafeGet(bundle.map, "bar")).toBe(bar);
|
||||
});
|
||||
|
||||
test("constructs a record", () => {
|
||||
const foo = Api.make(Schema.Void, Schema.String, Schema.String);
|
||||
const bar = Api.make(Schema.Void, Schema.Number, Schema.String);
|
||||
const bundle = Api.bundle({ foo, bar });
|
||||
|
||||
expect(bundle.record.foo).toBe(foo);
|
||||
expect(bundle.record.bar).toBe(bar);
|
||||
});
|
||||
|
||||
test("freezes the object", () => {
|
||||
const bundle = Api.bundle({
|
||||
foo: Api.make(Schema.Void, Schema.String, Schema.String),
|
||||
bar: Api.make(Schema.Void, Schema.Number, Schema.String),
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(bundle)).toBeTrue();
|
||||
});
|
||||
|
||||
test("freezes the record", () => {
|
||||
const bundle = Api.bundle({
|
||||
foo: Api.make(Schema.Void, Schema.String, Schema.String),
|
||||
bar: Api.make(Schema.Void, Schema.Number, Schema.String),
|
||||
});
|
||||
|
||||
expect(Object.isFrozen(bundle.record)).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe("make", () => {
|
||||
test("constructs an object", () => {
|
||||
const request = Schema.String.annotations({ title: "request" });
|
||||
const response = Schema.String.annotations({ title: "response" });
|
||||
const error = Schema.String.annotations({ title: "error" });
|
||||
|
||||
const api = Api.make(request, response, error);
|
||||
|
||||
expect(api.request).toBe(request);
|
||||
expect(api.response).toBe(response);
|
||||
expect(api.error).toBe(error);
|
||||
});
|
||||
|
||||
test("freezes the object", () => {
|
||||
const request = Schema.String.annotations({ title: "request" });
|
||||
const response = Schema.String.annotations({ title: "response" });
|
||||
const error = Schema.String.annotations({ title: "error" });
|
||||
|
||||
const api = Api.make(request, response, error);
|
||||
|
||||
expect(Object.isFrozen(api)).toBeTrue();
|
||||
})
|
||||
});
|
||||
59
packages/common/src/Api.ts
Normal file
59
packages/common/src/Api.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Data, Effect, HashMap, ParseResult, Schema } from "effect";
|
||||
|
||||
export class InternalServerError extends Data.Error<{ data: unknown }> { }
|
||||
export class RequestParseError extends Data.Error<{ cause: ParseResult.ParseError }> { }
|
||||
export class ResponseParseError extends Data.Error<{ cause: ParseResult.ParseError }> { }
|
||||
export class UnexpectedResponseContentType extends Data.Error<{ contentType: string }> { }
|
||||
export class UnexpectedResponseStatus extends Data.Error<{ status: number, statusText: string }> { }
|
||||
|
||||
export interface Api<
|
||||
RequestSchema extends Schema.Schema.AnyNoContext,
|
||||
ResponseSchema extends Schema.Schema.AnyNoContext,
|
||||
ErrorSchema extends Schema.Schema.AnyNoContext,
|
||||
> {
|
||||
readonly request: RequestSchema;
|
||||
readonly response: ResponseSchema;
|
||||
readonly error: ErrorSchema;
|
||||
}
|
||||
|
||||
export type ApiAny = Api<
|
||||
Schema.Schema.AnyNoContext,
|
||||
Schema.Schema.AnyNoContext,
|
||||
Schema.Schema.AnyNoContext
|
||||
>;
|
||||
|
||||
export type ApiBundle<
|
||||
Record extends { readonly [_: string]: ApiAny },
|
||||
> = {
|
||||
readonly map: HashMap.HashMap<string, ApiAny>;
|
||||
readonly record: Record;
|
||||
}
|
||||
|
||||
export type ApiBundleAny = ApiBundle<{ readonly [_: string]: ApiAny }>;
|
||||
|
||||
export type ApiBundleImpl<
|
||||
Record extends { readonly [_: string]: ApiAny },
|
||||
> = {
|
||||
readonly [K in keyof Record]: (request: Record[K]["request"]["Type"]) => Effect.Effect<Record[K]["response"]["Type"], Record[K]["error"]["Type"], any>;
|
||||
}
|
||||
|
||||
export function bundle<
|
||||
const Record extends { readonly [_: string]: ApiAny; }
|
||||
>(record: Record): ApiBundle<Record> {
|
||||
return Object.freeze({
|
||||
map: HashMap.fromIterable(Object.entries(record)),
|
||||
record: Object.freeze(Object.assign({}, record)),
|
||||
});
|
||||
}
|
||||
|
||||
export function make<
|
||||
RequestSchema extends Schema.Schema.AnyNoContext = typeof Schema.Void,
|
||||
ResponseSchema extends Schema.Schema.AnyNoContext = typeof Schema.Void,
|
||||
ErrorSchema extends Schema.Schema.AnyNoContext = typeof Schema.Void,
|
||||
>(
|
||||
request: RequestSchema = Schema.Void as unknown as RequestSchema,
|
||||
response: ResponseSchema = Schema.Void as unknown as ResponseSchema,
|
||||
error: ErrorSchema = Schema.Void as unknown as ErrorSchema,
|
||||
): Api<RequestSchema, ResponseSchema, ErrorSchema> {
|
||||
return Object.freeze({ request, response, error });
|
||||
};
|
||||
92
packages/common/src/Body.test.ts
Normal file
92
packages/common/src/Body.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, test } from "bun:test";
|
||||
import { Cause, Effect } from "effect";
|
||||
import * as Body from "./Body";
|
||||
import * as Test from "./Test";
|
||||
|
||||
describe("arrayBuffer", () => {
|
||||
test("succeeds", async () => {
|
||||
const buffer = new ArrayBuffer(4);
|
||||
const body = { arrayBuffer: () => Promise.resolve(buffer) };
|
||||
|
||||
const effect = Body.arrayBuffer(body);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectSuccess(exit, buffer);
|
||||
});
|
||||
|
||||
test("fails", async () => {
|
||||
const error = new RangeError();
|
||||
const body = { arrayBuffer: () => Promise.reject(error) };
|
||||
|
||||
const effect = Body.arrayBuffer(body);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailure(exit, Cause.fail(new Body.BodyError({ cause: error })));
|
||||
});
|
||||
|
||||
test("dies", async () => {
|
||||
const error = new Error();
|
||||
const body = { arrayBuffer: () => Promise.reject(error) };
|
||||
|
||||
const effect = Body.arrayBuffer(body);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailure(exit, Cause.die(error));
|
||||
});
|
||||
});
|
||||
|
||||
describe("blob", () => {
|
||||
test("succeeds", async () => {
|
||||
const blob = new Blob(["foo"]);
|
||||
const body = { blob: () => Promise.resolve(blob) };
|
||||
|
||||
const effect = Body.blob(body);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectSuccess(exit, blob);
|
||||
});
|
||||
|
||||
test("dies", async () => {
|
||||
const error = new Error();
|
||||
const body = { blob: () => Promise.reject(error) };
|
||||
|
||||
const effect = Body.blob(body);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailure(exit, Cause.die(error));
|
||||
})
|
||||
});
|
||||
|
||||
describe("body", () => {
|
||||
test.todo("succeeds with none");
|
||||
test.todo("succeeds with some");
|
||||
});
|
||||
|
||||
describe("bytes", () => {
|
||||
test.todo("succeeds");
|
||||
test.todo("fails");
|
||||
test.todo("dies");
|
||||
});
|
||||
|
||||
describe("formData", () => {
|
||||
test.todo("succeeds");
|
||||
test.todo("fails");
|
||||
test.todo("dies");
|
||||
});
|
||||
|
||||
describe("json", () => {
|
||||
test.todo("succeeds");
|
||||
test.todo("fails");
|
||||
test.todo("dies");
|
||||
});
|
||||
|
||||
describe("stream", () => {
|
||||
test.todo("succeeds");
|
||||
});
|
||||
|
||||
describe("json", () => {
|
||||
test.todo("succeeds");
|
||||
test.todo("dies");
|
||||
});
|
||||
70
packages/common/src/Body.ts
Normal file
70
packages/common/src/Body.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Data, Effect, Option, pipe, Stream } from "effect";
|
||||
|
||||
export class BodyError extends Data.TaggedClass("BodyError")<{ cause: RangeError | TypeError | SyntaxError }> { }
|
||||
export class StreamError extends Data.TaggedClass("StreamError")<{ cause: unknown }> { }
|
||||
|
||||
export function arrayBuffer(body: { arrayBuffer(): Promise<ArrayBuffer> }): Effect.Effect<ArrayBuffer, BodyError> {
|
||||
return Effect.tryPromise({
|
||||
try: () => body.arrayBuffer(),
|
||||
catch: (cause) => {
|
||||
if (cause instanceof RangeError) return new BodyError({ cause });
|
||||
else throw cause;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function blob(body: { blob(): Promise<Blob> }): Effect.Effect<Blob> {
|
||||
return Effect.promise(() => body.blob());
|
||||
}
|
||||
|
||||
export function body(body: { readonly body: ReadableStream<Uint8Array> | null; }): Option.Option<Stream.Stream<Uint8Array, StreamError>> {
|
||||
return pipe(
|
||||
body.body,
|
||||
Option.fromNullable,
|
||||
Option.map((stream) => Stream.fromReadableStream({
|
||||
evaluate: () => stream,
|
||||
onError: (cause) => new StreamError({ cause }),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function bytes(body: { bytes(): Promise<Uint8Array> }): Effect.Effect<Uint8Array, BodyError> {
|
||||
return Effect.tryPromise({
|
||||
try: () => body.bytes(),
|
||||
catch: (cause) => {
|
||||
if (cause instanceof RangeError) return new BodyError({ cause });
|
||||
else throw cause;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function formData(body: { formData(): Promise<FormData> }): Effect.Effect<FormData, BodyError> {
|
||||
return Effect.tryPromise({
|
||||
try: () => body.formData(),
|
||||
catch: (cause) => {
|
||||
if (cause instanceof TypeError) return new BodyError({ cause });
|
||||
else throw cause;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function json(body: { json(): Promise<unknown> }): Effect.Effect<unknown, BodyError> {
|
||||
return Effect.tryPromise({
|
||||
try: () => body.json(),
|
||||
catch: (cause) => {
|
||||
if (cause instanceof SyntaxError) return new BodyError({ cause });
|
||||
else throw cause;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function stream(body: { stream(): ReadableStream<Uint8Array> }): Stream.Stream<Uint8Array, StreamError> {
|
||||
return Stream.fromReadableStream({
|
||||
evaluate: () => body.stream(),
|
||||
onError: (cause) => new StreamError({ cause }),
|
||||
});
|
||||
}
|
||||
|
||||
export function text(body: { text(): Promise<string> }): Effect.Effect<string> {
|
||||
return Effect.promise(() => body.text());
|
||||
}
|
||||
117
packages/common/src/Cbor.test.ts
Normal file
117
packages/common/src/Cbor.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { describe, test } from "bun:test";
|
||||
import * as cbor from "cbor2";
|
||||
import { Effect, Schema } from "effect";
|
||||
import * as Cbor from "./Cbor";
|
||||
import * as Test from "./Test";
|
||||
|
||||
describe("encode", () => {
|
||||
test("succeeds", async () => {
|
||||
const object = {
|
||||
x: "foo",
|
||||
y: new Date(2025, 0, 1),
|
||||
z: new Uint8Array([0, 1, 2, 3]),
|
||||
};
|
||||
|
||||
const effect = Cbor.encode(object);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectSuccess(exit, cbor.encode(object));
|
||||
});
|
||||
|
||||
test("fails", async () => {
|
||||
const object = {
|
||||
x: Symbol(),
|
||||
};
|
||||
|
||||
const effect = Cbor.encode(object);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailureTag(exit, "CborEncodeError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("decode", () => {
|
||||
test("succeeds", async () => {
|
||||
const object = {
|
||||
x: "foo",
|
||||
y: new Date(2025, 0, 1),
|
||||
z: new Uint8Array([0, 1, 2, 3]),
|
||||
};
|
||||
|
||||
const effect = Cbor.decode(cbor.encode(object));
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectSuccess(exit, object);
|
||||
});
|
||||
|
||||
test("fails", async () => {
|
||||
const array = new Uint8Array([0xFE]);
|
||||
|
||||
const effect = Cbor.decode(array);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailureTag(exit, "CborDecodeError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeSchema", () => {
|
||||
test("succeeds", async () => {
|
||||
const schema = Schema.Struct({
|
||||
x: Schema.String,
|
||||
y: Schema.DateFromSelf,
|
||||
z: Schema.Uint8ArrayFromSelf,
|
||||
});
|
||||
|
||||
const object = schema.make({
|
||||
x: "foo",
|
||||
y: new Date(2025, 0, 1),
|
||||
z: new Uint8Array([0, 1, 2, 3]),
|
||||
});
|
||||
|
||||
const effect = Cbor.encodeSchema(schema)(object);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectSuccess(exit, cbor.encode(object));
|
||||
});
|
||||
|
||||
test.todo("fails");
|
||||
});
|
||||
|
||||
describe("decodeSchema", () => {
|
||||
test("succeeds", async () => {
|
||||
const schema = Schema.Struct({
|
||||
x: Schema.String,
|
||||
y: Schema.DateFromSelf,
|
||||
z: Schema.Uint8ArrayFromSelf,
|
||||
});
|
||||
|
||||
const object = schema.make({
|
||||
x: "foo",
|
||||
y: new Date(2025, 0, 1),
|
||||
z: new Uint8Array([0, 1, 2, 3]),
|
||||
});
|
||||
|
||||
const effect = Cbor.decodeSchema(schema)(cbor.encode(object));
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectSuccess(exit, object);
|
||||
});
|
||||
|
||||
test("fails with CborDecodeError", async () => {
|
||||
const array = new Uint8Array([0xFE]);
|
||||
|
||||
const effect = Cbor.decodeSchema(Schema.Any)(array);
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailureTag(exit, "CborDecodeError");
|
||||
});
|
||||
|
||||
test("fails with ParseError", async () => {
|
||||
const effect = Cbor.decodeSchema(Schema.Number)(cbor.encode("foo"));
|
||||
const exit = await Effect.runPromiseExit(effect);
|
||||
|
||||
Test.expectFailureTag(exit, "ParseError");
|
||||
});
|
||||
});
|
||||
35
packages/common/src/Cbor.ts
Normal file
35
packages/common/src/Cbor.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as cbor from "cbor2";
|
||||
import { Data, Effect, pipe, Schema } from "effect";
|
||||
|
||||
export class CborDecodeError extends Data.TaggedError("CborDecodeError")<{ cause: unknown }> { }
|
||||
export class CborEncodeError extends Data.TaggedError("CborEncodeError")<{ cause: unknown }> { }
|
||||
|
||||
export function encode(u: unknown): Effect.Effect<Uint8Array, CborEncodeError> {
|
||||
return Effect.try({
|
||||
try: () => cbor.encode(u),
|
||||
catch: (cause) => new CborEncodeError({ cause }),
|
||||
});
|
||||
}
|
||||
|
||||
export function decode(u: Uint8Array): Effect.Effect<unknown, CborDecodeError> {
|
||||
return Effect.try({
|
||||
try: () => cbor.decode(u),
|
||||
catch: (cause) => new CborDecodeError({ cause }),
|
||||
});
|
||||
}
|
||||
|
||||
export const encodeSchema = <A, I>(schema: Schema.Schema<A, I>) => {
|
||||
const schemaEncoder = Schema.encode(schema);
|
||||
return (a: A) => pipe(
|
||||
schemaEncoder(a),
|
||||
Effect.flatMap((i) => encode(i)),
|
||||
);
|
||||
};
|
||||
|
||||
export const decodeSchema = <A>(schema: Schema.Schema<A, any>) => {
|
||||
const schemaDecoder = Schema.decodeUnknown(schema);
|
||||
return (u: Uint8Array) => pipe(
|
||||
decode(u),
|
||||
Effect.flatMap((u) => schemaDecoder(u)),
|
||||
);
|
||||
};
|
||||
109
packages/common/src/Client.ts
Normal file
109
packages/common/src/Client.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Data, Effect, Option, pipe, Record, Schema } from "effect";
|
||||
import { constant } from "effect/Function";
|
||||
import * as Api from "./Api";
|
||||
import * as Body from "./Body";
|
||||
import * as Cbor from "./Cbor";
|
||||
import { fetch, FetchError } from "./Fetch";
|
||||
|
||||
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);
|
||||
|
||||
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: Body) => 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: Body) => Effect.flatMap(readBody(body), decoder);
|
||||
};
|
||||
|
||||
export interface BodyData {
|
||||
readonly body: null | Uint8Array;
|
||||
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) => BodyData = (bytes) => Object.freeze<BodyData>({
|
||||
body: bytes,
|
||||
headers: Object.freeze({
|
||||
"Content-Type": "application/cbor",
|
||||
"Content-Length": String(bytes.byteLength),
|
||||
}),
|
||||
});
|
||||
15
packages/common/src/Fetch.ts
Normal file
15
packages/common/src/Fetch.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Data, Effect } from "effect";
|
||||
|
||||
export class FetchError extends Data.TaggedError("FetchError")<{ cause: TypeError }> { }
|
||||
|
||||
const _fetch = (input: string | URL, init?: RequestInit) => Effect.tryPromise({
|
||||
try: (signal) => fetch(input, { ...init, signal }),
|
||||
catch: (cause) => {
|
||||
if (cause instanceof TypeError) return new FetchError({ cause });
|
||||
else throw cause;
|
||||
},
|
||||
});
|
||||
|
||||
export {
|
||||
_fetch as fetch,
|
||||
};
|
||||
40
packages/common/src/Test.ts
Normal file
40
packages/common/src/Test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/// <reference types="bun" />
|
||||
|
||||
import { expect } from "bun:test";
|
||||
import { Cause, Exit, Predicate } from "effect";
|
||||
|
||||
export function expectSuccess<A, E>(
|
||||
actual: Exit.Exit<A, E>,
|
||||
expected: A,
|
||||
): asserts actual is Exit.Success<A, never> {
|
||||
expect<Exit.Exit<A, E>>(Exit.succeed(expected)).toStrictEqual(actual);
|
||||
}
|
||||
|
||||
export function expectFailure<A, E>(
|
||||
actual: Exit.Exit<A, E>,
|
||||
expected: Cause.Cause<E>,
|
||||
): asserts actual is Exit.Failure<never, E> {
|
||||
expect<Exit.Exit<A, E>>(Exit.failCause(expected)).toStrictEqual(actual);
|
||||
}
|
||||
|
||||
export function expectFailureTag<A, E, Tag extends string>(
|
||||
actual: Exit.Exit<A, E>,
|
||||
tag: Tag,
|
||||
): asserts actual is Exit.Failure<never, E & { readonly _tag: Tag }> {
|
||||
if (!Exit.isFailure(actual)) {
|
||||
expect(actual).fail("Expected Exit to be a Failure");
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
const { cause } = actual;
|
||||
if (!Cause.isFailType(cause)) {
|
||||
expect(cause).fail("Expected Cause to be a Fail");
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
const { error } = cause;
|
||||
if (!Predicate.isTagged(tag)) {
|
||||
expect(error).fail(`Expected error to be tagged with ${JSON.stringify(tag)}`);
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,26 @@
|
||||
import { Brand } from "effect";
|
||||
import { pipe, Schema } from "effect";
|
||||
|
||||
export type UUID = Brand.Branded<string, "UUID">;
|
||||
export const UUID = Brand.nominal<UUID>();
|
||||
export const Sha256 = pipe(
|
||||
Schema.Uint8ArrayFromSelf,
|
||||
Schema.filter((arr) => arr.byteLength === 32),
|
||||
Schema.brand("Sha256"),
|
||||
);
|
||||
export type Sha256 = typeof Sha256.Type;
|
||||
|
||||
export type Sha256_Bin = Brand.Branded<Uint8Array, "Sha256">;
|
||||
export const Sha256_Bin = Brand.nominal<Sha256_Bin>();
|
||||
export const SessionId = pipe(Schema.String, Schema.brand("SessionId"));
|
||||
export type SessionId = typeof SessionId.Type;
|
||||
|
||||
export type Sha256_Hex = Brand.Branded<string, "Sha256">;
|
||||
export const Sha256_Hex = Brand.nominal<Sha256_Hex>();
|
||||
export const AttachmentId = pipe(Schema.UUID, Schema.brand("AttachmentId"));
|
||||
export type AttachmentId = typeof AttachmentId.Type;
|
||||
|
||||
export type AttachmentId = Brand.Branded<UUID, "AttachmentId">;
|
||||
export const AttachmentId = Brand.nominal<AttachmentId>();
|
||||
export const PieceId = pipe(Schema.UUID, Schema.brand("PieceId"));
|
||||
export type PieceId = typeof PieceId.Type;
|
||||
|
||||
export type PieceId = Brand.Branded<UUID, "PieceId">;
|
||||
export const PieceId = Brand.nominal<PieceId>();
|
||||
export const RepertoireId = pipe(Schema.UUID, Schema.brand("RepertoireId"));
|
||||
export type RepertoireId = typeof RepertoireId.Type;
|
||||
|
||||
export type RepertoireId = Brand.Branded<UUID, "RepertoireId">;
|
||||
export const RepertoireId = Brand.nominal<RepertoireId>();
|
||||
export const RequestId = pipe(Schema.UUID, Schema.brand("RequestId"));
|
||||
export type RequestId = typeof RequestId.Type;
|
||||
|
||||
export type RequestId = Brand.Branded<UUID, "RequestId">;
|
||||
export const RequestId = Brand.nominal<RequestId>();
|
||||
|
||||
export type SessionId = Brand.Branded<string, "SessionId">;
|
||||
export const SessionId = Brand.nominal<SessionId>();
|
||||
|
||||
export type UserId = Brand.Branded<UUID, "UserId">;
|
||||
export const UserId = Brand.nominal<UserId>();
|
||||
export const UserId = pipe(Schema.UUID, Schema.brand("UserId"));
|
||||
export type UserId = typeof UserId.Type;
|
||||
|
||||
206
packages/common/src/the_api.ts
Normal file
206
packages/common/src/the_api.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { pipe, Schema } from "effect";
|
||||
import * as Api from "./Api";
|
||||
import { AttachmentId, PieceId, RepertoireId, Sha256, UserId } from "common";
|
||||
import { constant } from "effect/Function";
|
||||
|
||||
// --- MARK: COMMON TYPES ------------------------------------------------------
|
||||
|
||||
export enum Role {
|
||||
Viewer = "Viewer",
|
||||
Editor = "Editor",
|
||||
}
|
||||
|
||||
export const SystemInformation = Schema.Struct({
|
||||
createdBy: pipe(UserId, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
createdAt: Schema.DateTimeUtc,
|
||||
modifiedBy: pipe(UserId, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
modifiedAt: pipe(Schema.DateTimeUtc, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
});
|
||||
|
||||
export const Pagination = Schema.Struct({
|
||||
offset: pipe(Schema.Int, Schema.greaterThanOrEqualTo(0), Schema.optionalWith({ default: constant(0), exact: true })),
|
||||
limit: pipe(Schema.Int, Schema.between(1, 100), Schema.optionalWith({ default: constant(100), exact: true })),
|
||||
});
|
||||
|
||||
export type SystemInformation = typeof SystemInformation.Type;
|
||||
export type Pagination = typeof Pagination.Type;
|
||||
|
||||
// --- MARK: RESPONSE TYPES ----------------------------------------------------
|
||||
|
||||
export const Me = Schema.Struct({
|
||||
userId: UserId,
|
||||
displayName: Schema.NonEmptyString,
|
||||
roles: Schema.HashSet(Schema.Enums(Role)),
|
||||
});
|
||||
|
||||
export const Other = Schema.Struct({
|
||||
userId: UserId,
|
||||
displayName: Schema.NonEmptyString,
|
||||
});
|
||||
|
||||
export const Attachment = Schema.Struct({
|
||||
attachmentId: AttachmentId,
|
||||
pieceId: PieceId,
|
||||
filename: Schema.NonEmptyString,
|
||||
sha256: Sha256,
|
||||
mediaType: Schema.NonEmptyString,
|
||||
}).pipe(Schema.extend(SystemInformation));
|
||||
|
||||
export const Piece = Schema.Struct({
|
||||
pieceId: PieceId,
|
||||
name: Schema.NonEmptyString,
|
||||
composer: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
lyricist: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
arranger: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
attachments: pipe(Attachment, Schema.Array),
|
||||
}).pipe(Schema.extend(SystemInformation));
|
||||
|
||||
export const Piece_Create = Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
composer: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
lyricist: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
arranger: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
});
|
||||
|
||||
export const Piece_Query = Schema.Struct({
|
||||
name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
author: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
}).pipe(Schema.extend(Pagination));
|
||||
|
||||
export const Repertoire = Schema.Struct({
|
||||
repertoireId: RepertoireId,
|
||||
name: Schema.NonEmptyString,
|
||||
entries: pipe(PieceId, Schema.Array),
|
||||
}).pipe(Schema.extend(SystemInformation));
|
||||
|
||||
export const Repertoire_Create = Schema.Struct({
|
||||
name: Schema.NonEmptyString,
|
||||
entries: pipe(PieceId, Schema.Array),
|
||||
});
|
||||
|
||||
export const Repertoire_Query = Schema.Struct({
|
||||
name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||
}).pipe(Schema.extend(Pagination));
|
||||
|
||||
export type Me = typeof Me.Type;
|
||||
export type Other = typeof Other.Type;
|
||||
export type Attachment = typeof Attachment.Type;
|
||||
export type Piece = typeof Piece.Type;
|
||||
export type Piece_Create = typeof Piece_Create.Type;
|
||||
export type Piece_Query = typeof Piece_Query.Type;
|
||||
export type Repertoire = typeof Repertoire.Type;
|
||||
export type Repertoire_Query = typeof Repertoire_Query.Type;
|
||||
|
||||
// --- MARK: ERROR TYPES -------------------------------------------------------
|
||||
|
||||
export const Unauthenticated = Schema.TaggedStruct("Unauthenticated", {});
|
||||
export const Unauthorized = Schema.TaggedStruct("Unauthorized", {});
|
||||
export const NotFound = Schema.TaggedStruct("NotFound", {});
|
||||
|
||||
export type Unauthenticated = typeof Unauthenticated.Type;
|
||||
export type Unauthorized = typeof Unauthorized.Type;
|
||||
export type NotFound = typeof NotFound.Type;
|
||||
|
||||
export default Api.bundle({
|
||||
|
||||
// --- Authentication ---
|
||||
|
||||
me: Api.make(Schema.Void, Me, Unauthenticated),
|
||||
logout: Api.make(Schema.Void, Schema.Void),
|
||||
getUser: Api.make(
|
||||
UserId,
|
||||
Other,
|
||||
Schema.Union(Unauthenticated, NotFound),
|
||||
),
|
||||
|
||||
// --- Piece CRUD ---
|
||||
|
||||
createPiece: Api.make(
|
||||
Piece_Create,
|
||||
Piece,
|
||||
Schema.Union(Unauthenticated, Unauthorized),
|
||||
),
|
||||
getPiece: Api.make(
|
||||
PieceId,
|
||||
Piece,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
queryPieces: Api.make(
|
||||
Piece_Query,
|
||||
pipe(PieceId, Schema.Array),
|
||||
Schema.Union(Unauthenticated, Unauthorized),
|
||||
),
|
||||
updatePiece: Api.make(
|
||||
Piece_Create.pipe(Schema.extend(Schema.Struct({ pieceId: PieceId }))),
|
||||
Piece,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
deletePiece: Api.make(
|
||||
PieceId,
|
||||
Schema.Void,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
|
||||
// --- Attachment CRUD ---
|
||||
|
||||
createAttachment: Api.make(
|
||||
Schema.Struct({
|
||||
pieceId: PieceId,
|
||||
filename: Schema.NonEmptyString,
|
||||
mediaType: Schema.NonEmptyString,
|
||||
data: Schema.Uint8ArrayFromSelf,
|
||||
}),
|
||||
Attachment,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
getAttachment: Api.make(
|
||||
AttachmentId,
|
||||
Schema.Struct({
|
||||
filename: Schema.NonEmptyString,
|
||||
mediaType: Schema.NonEmptyString,
|
||||
data: Schema.Uint8ArrayFromSelf,
|
||||
}),
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
updateAttachment: Api.make(
|
||||
Schema.Struct({
|
||||
attachmentId: AttachmentId,
|
||||
filename: Schema.NonEmptyString,
|
||||
}),
|
||||
Attachment,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
deleteAttachment: Api.make(
|
||||
AttachmentId,
|
||||
Schema.Void,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
|
||||
// --- Repertoire CRUD ---
|
||||
|
||||
createRepertoire: Api.make(
|
||||
Repertoire_Create,
|
||||
Repertoire,
|
||||
Schema.Union(Unauthenticated, Unauthorized),
|
||||
),
|
||||
getRepertoire: Api.make(
|
||||
RepertoireId,
|
||||
Repertoire,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
queryRepertoire: Api.make(
|
||||
Repertoire_Query,
|
||||
pipe(RepertoireId, Schema.Array),
|
||||
Schema.Union(Unauthenticated, Unauthorized),
|
||||
),
|
||||
updateRepertoire: Api.make(
|
||||
Repertoire_Create.pipe(Schema.extend(Schema.Struct({ repertoireId: RepertoireId }))),
|
||||
Repertoire,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
deleteRepertoire: Api.make(
|
||||
RepertoireId,
|
||||
Schema.Void,
|
||||
Schema.Union(Unauthenticated, Unauthorized, NotFound),
|
||||
),
|
||||
});
|
||||
Reference in New Issue
Block a user