Make API
This commit is contained in:
parent
42d47b15c9
commit
5c60fbd93d
12
package.json
12
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@ -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
147
src/Api.ts
Normal 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
102
src/Fetch.ts
Normal 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
66
src/effect-api.ts
Normal 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]],
|
||||
});
|
Loading…
Reference in New Issue
Block a user