Make API
This commit is contained in:
parent
42d47b15c9
commit
5c60fbd93d
12
package.json
12
package.json
@ -20,18 +20,22 @@
|
|||||||
"build": "tsc --build"
|
"build": "tsc --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/schema": "^0.51.1",
|
"@effect/schema": "^0.64.3",
|
||||||
"effect": "2.0.0-next.58",
|
"effect": "^2.4.6",
|
||||||
"fast-check": "^3.14.0",
|
"fast-check": "^3.16.0",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.2"
|
"typescript": "^5.4.2"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/effect-api.d.ts",
|
"types": "./dist/effect-api.d.ts",
|
||||||
"import": "./dist/effect-api.js"
|
"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:
|
dependencies:
|
||||||
'@effect/schema':
|
'@effect/schema':
|
||||||
specifier: ^0.51.1
|
specifier: ^0.64.3
|
||||||
version: 0.51.1(effect@2.0.0-next.58)(fast-check@3.14.0)
|
version: 0.64.3(effect@2.4.6)(fast-check@3.16.0)
|
||||||
effect:
|
effect:
|
||||||
specifier: 2.0.0-next.58
|
specifier: ^2.4.6
|
||||||
version: 2.0.0-next.58
|
version: 2.4.6
|
||||||
fast-check:
|
fast-check:
|
||||||
specifier: ^3.14.0
|
specifier: ^3.16.0
|
||||||
version: 3.14.0
|
version: 3.16.0
|
||||||
tslib:
|
tslib:
|
||||||
specifier: ^2.6.2
|
specifier: ^2.6.2
|
||||||
version: 2.6.2
|
version: 2.6.2
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.3.2
|
specifier: ^5.4.2
|
||||||
version: 5.3.2
|
version: 5.4.2
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
/@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):
|
||||||
resolution: {integrity: sha512-ZuULzdmAUTtdC/OFaVsEtc7NUCCElYYBE6LJSc9sYG8sDQisA5AQnWFsRk6MZPxzD2EdpTlKgQE0gysn14sttA==}
|
resolution: {integrity: sha512-Pn/IG+X9GTXLVvDtdjptr3QWxpcH+m7LEgM6loLWk7DP8SPKqWdo3rFaZiILpJFci8QWEk7K23C3IJZOnMfTtA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
effect: 2.0.0-next.58
|
effect: ^2.4.6
|
||||||
fast-check: ^3.13.2
|
fast-check: ^3.13.2
|
||||||
dependencies:
|
dependencies:
|
||||||
effect: 2.0.0-next.58
|
effect: 2.4.6
|
||||||
fast-check: 3.14.0
|
fast-check: 3.16.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/effect@2.0.0-next.58:
|
/effect@2.4.6:
|
||||||
resolution: {integrity: sha512-3JtlhJWIZlKe2krJp8ZsKCWCsKMemRVMNOGKtW6bLbg1ZYDi9riuDO44heqEyh7mVBhGsLh/Ut+Hz4olFbfhyg==}
|
resolution: {integrity: sha512-gaZC+aopWvS67SX8RFT3mirabjpVBk5qshj/Z3IKW35DjzpQ8WhAE7VWwfBBWWMBuQ/B0RsbzKR8YGN2AivIgQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/fast-check@3.14.0:
|
/fast-check@3.16.0:
|
||||||
resolution: {integrity: sha512-9Z0zqASzDNjXBox/ileV/fd+4P+V/f3o4shM6QawvcdLFh8yjPG4h5BrHUZ8yzY6amKGDTAmRMyb/JZqe+dCgw==}
|
resolution: {integrity: sha512-k8GtQHi4pJoRQ1gVDFQno+/FVkowo/ehiz/aCj9O/D7HRWb1sSFzNrw+iPVU8QlWtH+jNwbuN+dDVg3QkS56DQ==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
dependencies:
|
dependencies:
|
||||||
pure-rand: 6.0.4
|
pure-rand: 6.0.4
|
||||||
@ -54,8 +54,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/typescript@5.3.2:
|
/typescript@5.4.2:
|
||||||
resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==}
|
resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: 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