This commit is contained in:
Szymon Nowakowski 2024-03-14 21:53:25 +01:00
parent 42d47b15c9
commit 5c60fbd93d
5 changed files with 342 additions and 23 deletions

View File

@ -20,18 +20,22 @@
"build": "tsc --build"
},
"dependencies": {
"@effect/schema": "^0.51.1",
"effect": "2.0.0-next.58",
"fast-check": "^3.14.0",
"@effect/schema": "^0.64.3",
"effect": "^2.4.6",
"fast-check": "^3.16.0",
"tslib": "^2.6.2"
},
"devDependencies": {
"typescript": "^5.3.2"
"typescript": "^5.4.2"
},
"exports": {
".": {
"types": "./dist/effect-api.d.ts",
"import": "./dist/effect-api.js"
},
"./Api": {
"types": "./dist/Api.d.ts",
"import": "./dist/Api.js"
}
}
}

View File

@ -6,41 +6,41 @@ settings:
dependencies:
'@effect/schema':
specifier: ^0.51.1
version: 0.51.1(effect@2.0.0-next.58)(fast-check@3.14.0)
specifier: ^0.64.3
version: 0.64.3(effect@2.4.6)(fast-check@3.16.0)
effect:
specifier: 2.0.0-next.58
version: 2.0.0-next.58
specifier: ^2.4.6
version: 2.4.6
fast-check:
specifier: ^3.14.0
version: 3.14.0
specifier: ^3.16.0
version: 3.16.0
tslib:
specifier: ^2.6.2
version: 2.6.2
devDependencies:
typescript:
specifier: ^5.3.2
version: 5.3.2
specifier: ^5.4.2
version: 5.4.2
packages:
/@effect/schema@0.51.1(effect@2.0.0-next.58)(fast-check@3.14.0):
resolution: {integrity: sha512-ZuULzdmAUTtdC/OFaVsEtc7NUCCElYYBE6LJSc9sYG8sDQisA5AQnWFsRk6MZPxzD2EdpTlKgQE0gysn14sttA==}
/@effect/schema@0.64.3(effect@2.4.6)(fast-check@3.16.0):
resolution: {integrity: sha512-Pn/IG+X9GTXLVvDtdjptr3QWxpcH+m7LEgM6loLWk7DP8SPKqWdo3rFaZiILpJFci8QWEk7K23C3IJZOnMfTtA==}
peerDependencies:
effect: 2.0.0-next.58
effect: ^2.4.6
fast-check: ^3.13.2
dependencies:
effect: 2.0.0-next.58
fast-check: 3.14.0
effect: 2.4.6
fast-check: 3.16.0
dev: false
/effect@2.0.0-next.58:
resolution: {integrity: sha512-3JtlhJWIZlKe2krJp8ZsKCWCsKMemRVMNOGKtW6bLbg1ZYDi9riuDO44heqEyh7mVBhGsLh/Ut+Hz4olFbfhyg==}
/effect@2.4.6:
resolution: {integrity: sha512-gaZC+aopWvS67SX8RFT3mirabjpVBk5qshj/Z3IKW35DjzpQ8WhAE7VWwfBBWWMBuQ/B0RsbzKR8YGN2AivIgQ==}
dev: false
/fast-check@3.14.0:
resolution: {integrity: sha512-9Z0zqASzDNjXBox/ileV/fd+4P+V/f3o4shM6QawvcdLFh8yjPG4h5BrHUZ8yzY6amKGDTAmRMyb/JZqe+dCgw==}
/fast-check@3.16.0:
resolution: {integrity: sha512-k8GtQHi4pJoRQ1gVDFQno+/FVkowo/ehiz/aCj9O/D7HRWb1sSFzNrw+iPVU8QlWtH+jNwbuN+dDVg3QkS56DQ==}
engines: {node: '>=8.0.0'}
dependencies:
pure-rand: 6.0.4
@ -54,8 +54,8 @@ packages:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false
/typescript@5.3.2:
resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==}
/typescript@5.4.2:
resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==}
engines: {node: '>=14.17'}
hasBin: true
dev: true

147
src/Api.ts Normal file
View File

@ -0,0 +1,147 @@
import * as S from "@effect/schema/Schema";
import * as O from "effect/Option";
import * as Types from "effect/Types";
// --- METHOD ------------------------------------------------------------------
export type Method =
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
;
// --- ROUTE INPUT -------------------------------------------------------------
export type RouteInputLiteral<K extends string> = K;
export type RouteInputSchema<K extends string, T> = readonly [name: K, schema: S.Schema<T, string>];
export type RouteInputToken =
| RouteInputLiteral<string>
| RouteInputSchema<string, any>
;
// --- ROUTE -------------------------------------------------------------------
export enum RouteTokenType {
Literal = "literal",
Schema = "schema",
}
export interface RouteTokenLiteral<K extends string> {
readonly type: RouteTokenType.Literal,
readonly literal: K,
}
export interface RouteTokenSchema<K extends string, T> {
readonly type: RouteTokenType.Schema;
readonly name: K;
readonly schema: S.Schema<T, string>;
}
export type RouteToken =
| RouteTokenLiteral<string>
| RouteTokenSchema<string, any>
;
export function routeTokenLiteral<const K extends string>(literal: K): RouteTokenLiteral<K> {
return Object.freeze<RouteTokenLiteral<K>>({
type: RouteTokenType.Literal,
literal,
});
}
export function routeTokenSchema<const K extends string, const T>(name: K, schema: S.Schema<T, string>): RouteTokenSchema<K, T> {
return Object.freeze<RouteTokenSchema<K, T>>({
type: RouteTokenType.Schema,
name,
schema,
});
}
// --- ROUTE FROM INPUT --------------------------------------------------------
export type RouteFromInput<I extends readonly RouteInputToken[]> = {
readonly [K in keyof I]:
I[K] extends RouteInputLiteral<infer _K> ? RouteTokenLiteral<_K>
: I[K] extends RouteInputSchema<infer _K, infer _T> ? RouteTokenSchema<_K, _T>
: never;
}
export type ParamFromRouteToken<T extends RouteToken> =
T extends RouteTokenSchema<infer K, infer V> ? { readonly [_ in K]: V } : never;
export type ParamsFromRoute<I extends readonly RouteToken[]> =
Types.Simplify<Types.UnionToIntersection<ParamFromRouteToken<I[number]>>>;
export function makeRoute<const RouteInput extends readonly RouteInputToken[]>(...tokens: RouteInput): RouteFromInput<RouteInput> {
const names = new Set<string>();
const ret = new Array<RouteToken>(tokens.length);
for (let i = 0; i < tokens.length; ++i) {
const token = tokens[i];
if (typeof token === "string") {
ret[i] = routeTokenLiteral(token);
} else {
const [name, schema] = token;
if (names.has(name)) {
throw new Error(`Duplicate URL parameter name in route: ${name}`);
}
ret[i] = routeTokenSchema(name, schema);
}
}
return Object.freeze(ret) as RouteFromInput<RouteInput>;
}
// --- API ---------------------------------------------------------------------
export interface ApiBase {
readonly requestBody: O.Option<S.Schema<any, any>>;
readonly query: O.Option<S.Schema<any, { readonly [_: string]: string }>>;
readonly responseBody: O.Option<S.Schema<any, any>>;
readonly method: Method;
readonly route: readonly RouteToken[];
}
export type RequestType<T extends ApiBase> = S.Schema.Type<O.Option.Value<T["requestBody"]>>;
export type RequestEncoded<T extends ApiBase> = S.Schema.Encoded<O.Option.Value<T["requestBody"]>>;
export type QueryType<T extends ApiBase> = S.Schema.Type<O.Option.Value<T["query"]>>;
export type QueryEncoded<T extends ApiBase> = S.Schema.Encoded<O.Option.Value<T["query"]>>;
export type ResponseType<T extends ApiBase> = S.Schema.Type<O.Option.Value<T["responseBody"]>>;
export type ResponseEncoded<T extends ApiBase> = S.Schema.Encoded<O.Option.Value<T["responseBody"]>>;
export type Params<T extends ApiBase> = ParamsFromRoute<T["route"]>;
// --- MAKE API ----------------------------------------------------------------
export interface MakeApiProps {
readonly requestBody?: S.Schema<any, any>;
readonly query?: S.Schema<any, any>;
readonly responseBody?: S.Schema<any, any>;
readonly method: Method;
readonly route: readonly RouteInputToken[];
}
export interface MakeApi<P extends MakeApiProps> {
readonly requestBody: P extends { readonly requestBody: S.Schema<infer A, infer I> } ? O.Some<S.Schema<A, I>> : O.None<never>;
readonly query: P extends { readonly query: S.Schema<infer A, infer I> } ? O.Some<S.Schema<A, I>> : O.None<never>;
readonly responseBody: P extends { readonly responseBody: S.Schema<infer A, infer I> } ? O.Some<S.Schema<A, I>> : O.None<never>;
readonly method: P["method"];
readonly route: RouteFromInput<P["route"]>;
}
export function make<const P extends MakeApiProps>(props: P): MakeApi<P> {
return Object.freeze<any>({
requestBody: O.fromNullable(props.requestBody),
query: O.fromNullable(props.query),
responseBody: O.fromNullable(props.responseBody),
method: props.method,
route: makeRoute(...props.route),
} satisfies ApiBase) as MakeApi<P>;
}

102
src/Fetch.ts Normal file
View File

@ -0,0 +1,102 @@
/// <reference lib="dom" />
import * as Effect from "effect/Effect";
import { pipe } from "effect/Function";
import * as Option from "effect/Option";
import * as Predicate from "effect/Predicate";
import * as Stream from "effect/Stream";
export const FetchExceptionTypeId: unique symbol = Symbol.for("effect-api/Fetch/errors/FetchException");
export const RequestArrayBufferExceptionTypeId: unique symbol = Symbol.for("effect-api/Fetch/errors/RequestArrayBufferException");
export type FetchExceptionTypeId = typeof FetchExceptionTypeId;
export type RequestArrayBufferExceptionTypeId = typeof RequestArrayBufferExceptionTypeId;
export interface FetchException extends Error {
readonly _tag: "FetchException"
readonly [FetchExceptionTypeId]: FetchExceptionTypeId;
readonly error: TypeError;
}
export interface RequestArrayBufferException extends Error {
readonly _tag: "RequestArrayBufferException"
readonly [RequestArrayBufferExceptionTypeId]: RequestArrayBufferExceptionTypeId;
readonly error: RangeError;
}
const FetchExceptionProto = Object.create(Error.prototype, {
_tag: { value: "FetchException" },
[FetchExceptionTypeId]: { value: FetchExceptionTypeId },
});
const RequestArrayBufferExceptionProto = Object.create(Error.prototype, {
_tag: { value: "RequestArrayBufferException" },
[RequestArrayBufferExceptionTypeId]: { value: RequestArrayBufferExceptionTypeId },
});
export const FetchException: (error: TypeError) => FetchException = (error) => Object.create(FetchExceptionProto, {
error: { value: error, enumerable: true },
});
export const RequestArrayBufferException: (error: RangeError) => RequestArrayBufferException = (error) => Object.create(RequestArrayBufferExceptionProto, {
error: { value: error, enumerable: true },
});
export const isFetchException: (u: unknown) => u is FetchException = (u): u is FetchException => Predicate.hasProperty(u, FetchExceptionTypeId);
export const isRequestArrayBufferException: (u: unknown) => u is RequestArrayBufferException = (u): u is RequestArrayBufferException => Predicate.hasProperty(u, RequestArrayBufferExceptionTypeId);
const _wrapResponseHandler: ProxyHandler<Response> = Object.freeze<ProxyHandler<Response>>({
get: (target, p, receiver) => {
switch (p) {
case "body":
return <E>(onError: (error: unknown) => E): Option.Option<Stream.Stream<never, E, Uint8Array>> => {
return pipe(
Reflect.get(target, p, receiver),
Option.fromNullable,
Option.map(Stream.fromReadableStream())
);
};
}
}
});
const _wrapResponse: (response: Response) => _Response = (response) => {
};
const _fetch: (...args: Parameters<typeof fetch>) => Effect.Effect<never, FetchException, _Response> = (input, init) => {
return pipe(
Effect.tryPromise({
try: (signal) => fetch(input, { signal, ...init }),
catch: (error) => {
if (error instanceof TypeError) {
return FetchException(error);
}
throw error;
},
}),
Effect.map(_wrapResponse),
);
};
interface _Response {
body<E>(onError: (error: unknown) => E): Option.Option<Stream.Stream<never, E, Uint8Array>>;
readonly bodyUsed: boolean;
arrayBuffer(): Effect.Effect<never, RequestArrayBufferException, ArrayBuffer>;
blob(): Effect.Effect<never, ___, Blob>;
formData(): Effect.Effect<never, ___, FormData>;
json(): Effect.Effect<never, ___, unknown>;
text(): Effect.Effect<never, ___, string>;
readonly headers: Headers;
readonly ok: boolean;
readonly redirected: boolean;
readonly status: number;
readonly statusText: string;
readonly type: ResponseType;
readonly url: string;
clone(): _Response;
}
export {
_fetch as fetch,
type _Request as Request
};

66
src/effect-api.ts Normal file
View File

@ -0,0 +1,66 @@
export * as Api from "./Api.js";
// --- EXPERIMENTS -------------------------------------------------------------
import * as Api from "./Api.js";
import * as S from "@effect/schema/Schema";
const Id = S.struct({ _id: S.ULID });
const Product = S.struct({
name: S.string,
price: S.bigint,
});
const WithId = S.extend(Id);
const LoginRequest = S.struct({
username: S.string,
password: S.string,
});
const login = Api.make({
method: "POST",
route: ["login"],
requestBody: LoginRequest,
});
const logout = Api.make({
method: "POST",
route: ["logout"],
});
const getProducts = Api.make({
method: "GET",
route: ["products"],
responseBody: S.array(WithId(Product)),
});
const getProduct = Api.make({
method: "GET",
route: ["products", ["_id", S.ULID]],
responseBody: WithId(Product),
});
const postProduct = Api.make({
method: "POST",
route: ["products"],
requestBody: Product, responseBody: Id,
});
const putProduct = Api.make({
method: "PUT",
route: ["products", ["_id", S.ULID]],
requestBody: Product,
});
const patchProduct = Api.make({
method: "PATCH",
route: ["products", ["_id", S.ULID]],
requestBody: S.partial(Product),
});
const deleteProduct = Api.make({
method: "POST",
route: ["products", ["_id", S.ULID]],
});