From 5c60fbd93db61749d4567e03dd5d13a61d8f0bec Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Thu, 14 Mar 2024 21:53:25 +0100 Subject: [PATCH] Make API --- package.json | 12 ++-- pnpm-lock.yaml | 38 ++++++------ src/Api.ts | 147 ++++++++++++++++++++++++++++++++++++++++++++++ src/Fetch.ts | 102 ++++++++++++++++++++++++++++++++ src/effect-api.ts | 66 +++++++++++++++++++++ 5 files changed, 342 insertions(+), 23 deletions(-) create mode 100644 src/Api.ts create mode 100644 src/Fetch.ts create mode 100644 src/effect-api.ts diff --git a/package.json b/package.json index 9349c27..ec83cba 100644 --- a/package.json +++ b/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" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88f042b..9a063aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/Api.ts b/src/Api.ts new file mode 100644 index 0000000..f6c5da7 --- /dev/null +++ b/src/Api.ts @@ -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; + +export type RouteInputSchema = readonly [name: K, schema: S.Schema]; + +export type RouteInputToken = + | RouteInputLiteral + | RouteInputSchema + ; + +// --- ROUTE ------------------------------------------------------------------- + +export enum RouteTokenType { + Literal = "literal", + Schema = "schema", +} + +export interface RouteTokenLiteral { + readonly type: RouteTokenType.Literal, + readonly literal: K, +} + +export interface RouteTokenSchema { + readonly type: RouteTokenType.Schema; + readonly name: K; + readonly schema: S.Schema; +} + +export type RouteToken = + | RouteTokenLiteral + | RouteTokenSchema + ; + +export function routeTokenLiteral(literal: K): RouteTokenLiteral { + return Object.freeze>({ + type: RouteTokenType.Literal, + literal, + }); +} + +export function routeTokenSchema(name: K, schema: S.Schema): RouteTokenSchema { + return Object.freeze>({ + type: RouteTokenType.Schema, + name, + schema, + }); +} + +// --- ROUTE FROM INPUT -------------------------------------------------------- + +export type RouteFromInput = { + readonly [K in keyof I]: + I[K] extends RouteInputLiteral ? RouteTokenLiteral<_K> + : I[K] extends RouteInputSchema ? RouteTokenSchema<_K, _T> + : never; +} + +export type ParamFromRouteToken = + T extends RouteTokenSchema ? { readonly [_ in K]: V } : never; + +export type ParamsFromRoute = + Types.Simplify>>; + +export function makeRoute(...tokens: RouteInput): RouteFromInput { + const names = new Set(); + const ret = new Array(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; +} + +// --- API --------------------------------------------------------------------- + +export interface ApiBase { + readonly requestBody: O.Option>; + readonly query: O.Option>; + readonly responseBody: O.Option>; + + readonly method: Method; + readonly route: readonly RouteToken[]; +} + +export type RequestType = S.Schema.Type>; +export type RequestEncoded = S.Schema.Encoded>; +export type QueryType = S.Schema.Type>; +export type QueryEncoded = S.Schema.Encoded>; +export type ResponseType = S.Schema.Type>; +export type ResponseEncoded = S.Schema.Encoded>; +export type Params = ParamsFromRoute; + +// --- MAKE API ---------------------------------------------------------------- + +export interface MakeApiProps { + readonly requestBody?: S.Schema; + readonly query?: S.Schema; + readonly responseBody?: S.Schema; + + readonly method: Method; + readonly route: readonly RouteInputToken[]; +} + +export interface MakeApi

{ + readonly requestBody: P extends { readonly requestBody: S.Schema } ? O.Some> : O.None; + readonly query: P extends { readonly query: S.Schema } ? O.Some> : O.None; + readonly responseBody: P extends { readonly responseBody: S.Schema } ? O.Some> : O.None; + + readonly method: P["method"]; + readonly route: RouteFromInput; +} + +export function make(props: P): MakeApi

{ + return Object.freeze({ + 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

; +} diff --git a/src/Fetch.ts b/src/Fetch.ts new file mode 100644 index 0000000..df218dc --- /dev/null +++ b/src/Fetch.ts @@ -0,0 +1,102 @@ +/// +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 = Object.freeze>({ + get: (target, p, receiver) => { + switch (p) { + case "body": + return (onError: (error: unknown) => E): Option.Option> => { + return pipe( + Reflect.get(target, p, receiver), + Option.fromNullable, + Option.map(Stream.fromReadableStream()) + ); + }; + } + } +}); + +const _wrapResponse: (response: Response) => _Response = (response) => { +}; + +const _fetch: (...args: Parameters) => Effect.Effect = (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(onError: (error: unknown) => E): Option.Option>; + readonly bodyUsed: boolean; + arrayBuffer(): Effect.Effect; + blob(): Effect.Effect; + formData(): Effect.Effect; + json(): Effect.Effect; + text(): Effect.Effect; + 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 +}; + diff --git a/src/effect-api.ts b/src/effect-api.ts new file mode 100644 index 0000000..5634afb --- /dev/null +++ b/src/effect-api.ts @@ -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]], +});