From a67e7efb40841b6dc3e0d93b8743ac383a2dd2c7 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Tue, 25 Jun 2024 20:00:42 +0200 Subject: [PATCH] The rewrite --- .editorconfig | 5 ++ .vscode/settings.json | 2 +- AUTHORS | 1 - README.md | 4 +- bun.lockb | Bin 0 -> 5230 bytes package.json | 63 +++++-------- packages/api/package.json | 4 + packages/api/src/Api.ts | 148 +++++++++++++++++++++++++++++++ packages/api/src/Field.ts | 74 ++++++++++++++++ packages/api/src/Method.ts | 13 +++ packages/api/src/MimeType.ts | 9 ++ packages/api/src/Multiplicity.ts | 23 +++++ packages/api/src/Query.ts | 13 +++ packages/api/src/RequestBody.ts | 102 +++++++++++++++++++++ packages/api/src/ResponseBody.ts | 90 +++++++++++++++++++ packages/api/src/Route.ts | 50 +++++++++++ packages/backend/package.json | 7 ++ packages/backend/src/Api.ts | 107 ++++++++++++++++++++++ pnpm-lock.yaml | 61 ------------- src/Api.ts | 147 ------------------------------ src/Fetch.ts | 102 --------------------- src/effect-api.ts | 66 -------------- tsconfig.json | 91 ++++++++++--------- 23 files changed, 721 insertions(+), 461 deletions(-) delete mode 100644 AUTHORS create mode 100755 bun.lockb create mode 100644 packages/api/package.json create mode 100644 packages/api/src/Api.ts create mode 100644 packages/api/src/Field.ts create mode 100644 packages/api/src/Method.ts create mode 100644 packages/api/src/MimeType.ts create mode 100644 packages/api/src/Multiplicity.ts create mode 100644 packages/api/src/Query.ts create mode 100644 packages/api/src/RequestBody.ts create mode 100644 packages/api/src/ResponseBody.ts create mode 100644 packages/api/src/Route.ts create mode 100644 packages/backend/package.json create mode 100644 packages/backend/src/Api.ts delete mode 100644 pnpm-lock.yaml delete mode 100644 src/Api.ts delete mode 100644 src/Fetch.ts delete mode 100644 src/effect-api.ts diff --git a/.editorconfig b/.editorconfig index d9ac3e2..93fa07a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,5 +9,10 @@ insert_final_newline = true tab_width = 4 trim_trailing_whitespace = true +[*.json] +indent_size = 2 +indent_style = space +tab_width = 2 + [*.md] trim_trailing_whitespace = false diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d360cb..25fa621 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 8023d98..0000000 --- a/AUTHORS +++ /dev/null @@ -1 +0,0 @@ -Szymon Nowakowski (https://renati.me) diff --git a/README.md b/README.md index f1cc654..ca3c918 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # effect-api -A library for defining, using and implementing HTTP JSON APIs using Effect ecosystem + +A library for defining, using and implementing HTTP JSON APIs using Effect +ecosystem. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..3e1ede143d2ff6a5dfd8b4809844b6428d509b6d GIT binary patch literal 5230 zcmeHLeNDYih7R&9?pl%go+h45uU1NgC3cUiZfKv$7!2rnU$g!ta$OIaIt z)uw(>0Yyx;qNwdr)(Xeg)h@_dzffERIriuxmey6P($!TfVi)$_0^=|@={a=OQp&kZ$ z2b0Yl@CsgX{I-FBZB|lG1cR9|K-Pb9Fo;n&y!tRx3uo){`3n&if(;U z7{A5eHa>7Uoucck`hC}o+~$*m$5zDzExo(=rs1^R`^Kwfr%H0SENCv4|Hvu`&H@?Y&c8;0{=33?f2EAA@&!+2>nm&f9m`L0SNt%5rp%H*yxuK{&`S9ejl*H7=Re+mk|3M zP?!Yv$o&#LsQV+t|JWDfuRk`A_Q5?%_U+Srqfp}*i9b=JkI_Gcc;d z_I&Y&hv(i(Q3-H`aU?xumMQMn?XrxHt-d$o)Si}Nubb`q+e3$Sz97GTZ(gB?o3Xy( z&nN0HN7NpTD)5_rH@+!mx$5QXn^v_fZ`tkBGF5IIdtoBnZp)UI!2a|(*$CmHzFoUmfQx(YiHQK0#0g?cSADy_zUI(|Q3X5qHe5T|el~5{ zxnDZ+C2xM1x@F9rj9Gs*4W}DVCXVPT-}E3SfvQ^8c`4vpZrNXwU#ULdq#h~p5#W-W z+=+W})hhdv=N7m77F8ZB$#Hxlm@z+l>7KgVbywF4a7i3HajR3k-A}yp&edesQ(Q zI6pe`SWe(1$+5L>9-LXx6g^=jou*yw<7no#9}Yjmz7w^ZOY@BjIw~&sf{l8=#2ETi zRPmtfg>wQO0$e;_h)74ru73UMR&KKUuDFFo+eRhYd=k?_+K#?n7u|XvXa@kgXSvid6k8}_gfFeyr93nXuALx&*fIAREVvgv6;QTo`2FPtc z=M)A2`~P0*kCVlve2TM=*X|mY(NRVV%V|v}isV;RJVVn_vfv;YYg|NU=*P=KWm=Qb zV9C(;oM4?@EL~7-q~P%6OcnSeyn8OnANvFO zVEQ(aZ^@h&DZI6fg%->VN#-USOmYDDAB>zflH0k7-wva~sNkOLl4O4p@!LUExQbGX zNd8IkLud)1LeYj~sw9(iwkXw9kci~2B)5c?a7qackFl8maF6nMb@Px6mt>#;5|fsr zS;&b=&P#GqiC76VYM3R-eo1yJ5ibc2qLjguisZ*6e}xtm_&^!-A8f_}5(>$oNsbFP z;I(inRAl2=Pz;~W4Wt#6sE$u^nfjRa8L+8vwU?>F>clx7owxWAoa2*Zhy~a` z%ExUVkyWcp*BWS60pA=Zl8+Bq>264Y9<(L3r;&fbE(@7y0ROBHP?i`9UEV5=Yw4BjeQhPz^ z?gJp*Ww~>YAw(~h{D|cWR{?loFZW^d<7on=Yrb+ { + readonly method: Method; + readonly props: Props; +} + +export namespace Api { + export type Any = Api; + + export type Props = T extends Api ? Props : never; + + export type PropsAny = { + readonly route: Route.Any; + readonly request: RequestBody.Any; + readonly response: { readonly [_: number]: ResponseBody.Any }; + readonly query: Query.Any; + }; +} + +export const Api = (method: Method, props: Props): Api => Object.freeze>({ + method, + props, +}); + +// --- BUILDERS ---------------------------------------------------------------- + +export type RouteInputLiteral = Literal; + +export type RouteInputParam> = readonly [Name, Schema]; + +export namespace RouteInputLiteral { + export type Any = RouteInputLiteral; +} + +export namespace RouteInputParam { + export type Any = RouteInputParam>; +} + +export namespace RouteInput { + export type Any = readonly ( + | RouteInputLiteral.Any + | RouteInputParam.Any + )[]; + + export type Route = { readonly [K in keyof R]: + R[K] extends RouteInputLiteral ? RouteLiteral + : R[K] extends RouteInputParam ? RouteParam + : never; + }; +} + +export const make = (method: Method, ...routeInput: R): Api<{ + readonly route: RouteInput.Route, + readonly request: RequestBodyNone, + readonly response: {}, + readonly query: {}, +}> => Api(method, { + route: Route(...routeInput.map((r) => typeof r === "string" ? RouteLiteral(r) : RouteParam(r[0], r[1])) as RouteInput.Route), + request: RequestBodyNone, + response: ResponseEmpty, + query: QueryEmpty, +}); + +export const requestBodyText = >(schema: Schema) => + ({ method, props }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: RequestBodyText, + readonly response: T["props"]["response"], + readonly query: T["props"]["query"], + }> => Api(method, { ...props, request: RequestBodyText(schema) }); + +export const requestBodyJson = (schema: Schema) => + ({ method, props }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: RequestBodyJson, + readonly response: T["props"]["response"], + readonly query: T["props"]["query"], + }> => Api(method, { ...props, request: RequestBodyJson(schema) }); + +export const requestBodyUrlEncoded = (fields: Fields) => + ({ method, props }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: RequestBodyUrlEncoded, + readonly response: T["props"]["response"], + readonly query: T["props"]["query"], + }> => Api(method, { ...props, request: RequestBodyUrlEncoded(fields) }); + +export const requestBodyMultipart = (fields: Fields) => + ({ method, props }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: RequestBodyMultipart, + readonly response: T["props"]["response"], + readonly query: T["props"]["query"], + }> => Api(method, { ...props, request: RequestBodyMultipart(fields) }); + +export const requestBodyFile = (mimeType: MimeTypePredicate) => + ({ method, props }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: RequestBodyFile, + readonly response: T["props"]["response"], + readonly query: T["props"]["query"], + }> => Api(method, { ...props, request: RequestBodyFile(mimeType) }); + +export const responseBodyText = >(status: Status, schema: Schema) => + ({ method, props: { response, ...props } }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: T["props"]["request"], + readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyText : T["props"]["response"][K] }, + readonly query: T["props"]["query"], + }> => Api(method, { ...props, response: Object.freeze({ ...response, [status]: ResponseBodyText(schema) }) }); + +export const responseBodyJson = (status: Status, schema: Schema) => + ({ method, props: { response, ...props } }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: T["props"]["request"], + readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyJson : T["props"]["response"][K] }, + readonly query: T["props"]["query"], + }> => Api(method, { ...props, response: Object.freeze({ ...response, [status]: ResponseBodyJson(schema) }) }); + +export const responseBodyFile = (status: Status, mimeType: MimeTypePredicate) => + ({ method, props: { response, ...props } }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: T["props"]["request"], + readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyFile : T["props"]["response"][K] }, + readonly query: T["props"]["query"], + }> => Api(method, { ...props, response: Object.freeze({ ...response, [status]: ResponseBodyFile(mimeType) }) }); + +export const query = (query: Query) => + ({ method, props: { query: _, ...props } }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: T["props"]["request"], + readonly response: T["props"]["response"], + readonly query: Query, + }> => Api(method, { ...props, query }); diff --git a/packages/api/src/Field.ts b/packages/api/src/Field.ts new file mode 100644 index 0000000..c170373 --- /dev/null +++ b/packages/api/src/Field.ts @@ -0,0 +1,74 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Schema as S } from "@effect/schema"; +import type { MimeTypePredicate } from "./MimeType"; +import type { Multiplicity } from "./Multiplicity"; + +// --- INTERFACES -------------------------------------------------------------- + +export interface FieldText> { + readonly _tag: "Text"; + readonly multiplicity: M; + readonly schema: Schema; +} + +export interface FieldJson { + readonly _tag: "Json"; + readonly multiplicity: M; + readonly schema: Schema; +} + +export interface FieldFile { + readonly _tag: "File"; + readonly multiplicity: M; + readonly mimeType: MimeTypePredicate; +} + +export namespace FieldText { + export type Any = FieldText>; +} + +export namespace FieldJson { + export type Any = FieldJson; +} + +export namespace FieldFile { + export type Any = FieldFile; +} + +export namespace Field { + export type Any = + | FieldText.Any + | FieldJson.Any + | FieldFile.Any + ; + + export type AnyUrlEncoded = + | FieldText.Any + | FieldJson.Any + ; +} + +// --- CONSTRUCTORS ------------------------------------------------------------ + +export const FieldText = >(multiplicity: M, schema: Schema): FieldText => Object.freeze>({ + _tag: "Text", + multiplicity, + schema, +}); + +export const FieldJson = (multiplicity: M, schema: Schema): FieldJson => Object.freeze>({ + _tag: "Json", + multiplicity, + schema, +}); + +export const FieldFile = (multiplicity: M, mimeType: MimeTypePredicate): FieldFile => Object.freeze>({ + _tag: "File", + multiplicity, + mimeType, +}); diff --git a/packages/api/src/Method.ts b/packages/api/src/Method.ts new file mode 100644 index 0000000..93ebad3 --- /dev/null +++ b/packages/api/src/Method.ts @@ -0,0 +1,13 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +export type Method = + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" + ; diff --git a/packages/api/src/MimeType.ts b/packages/api/src/MimeType.ts new file mode 100644 index 0000000..6b37521 --- /dev/null +++ b/packages/api/src/MimeType.ts @@ -0,0 +1,9 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Predicate } from "effect" + +export type MimeTypePredicate = T | readonly T[] | Predicate.Refinement; diff --git a/packages/api/src/Multiplicity.ts b/packages/api/src/Multiplicity.ts new file mode 100644 index 0000000..0306ac2 --- /dev/null +++ b/packages/api/src/Multiplicity.ts @@ -0,0 +1,23 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import type { Option as O } from "effect"; + +export type Multiplicity = + | "single" + | "optional" + | "multiple" + | "any" + ; + +export namespace Multiplicity { + export type With = { + single: T, + optional: O.Option, + multiple: readonly [T, ...T[]], + any: readonly T[], + }[M]; +} diff --git a/packages/api/src/Query.ts b/packages/api/src/Query.ts new file mode 100644 index 0000000..49e6ca4 --- /dev/null +++ b/packages/api/src/Query.ts @@ -0,0 +1,13 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import type { Field } from "./Field"; + +export namespace Query { + export type Any = { readonly [_: string]: Field.AnyUrlEncoded }; +} + +export const QueryEmpty: {} = Object.freeze<{}>({}); diff --git a/packages/api/src/RequestBody.ts b/packages/api/src/RequestBody.ts new file mode 100644 index 0000000..ebf040f --- /dev/null +++ b/packages/api/src/RequestBody.ts @@ -0,0 +1,102 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Schema as S } from "@effect/schema"; +import type { Field } from "./Field"; +import type { MimeTypePredicate } from "./MimeType"; + +// --- INTERFACES -------------------------------------------------------------- + +export interface RequestBodyNone { + readonly _tag: "None"; +} + +export interface RequestBodyText> { + readonly _tag: "Text"; + readonly schema: Schema; +} + +export interface RequestBodyJson { + readonly _tag: "Json"; + readonly schema: Schema; +} + +export interface RequestBodyUrlEncoded { + readonly _tag: "UrlEncoded"; + readonly fields: Readonly; +} + +export interface RequestBodyMultipart { + readonly _tag: "Multipart"; + readonly fields: Readonly; +} + +export interface RequestBodyFile { + readonly _tag: "File"; + readonly mimeType: MimeTypePredicate; +} + +export namespace RequestBodyText { + export type Any = RequestBodyText>; +} + +export namespace RequestBodyJson { + export type Any = RequestBodyJson; +} + +export namespace RequestBodyUrlEncoded { + export type Any = RequestBodyUrlEncoded<{}>; +} + +export namespace RequestBodyMultipart { + export type Any = RequestBodyMultipart<{}>; +} + +export namespace RequestBodyFile { + export type Any = RequestBodyFile; +} + +export namespace RequestBody { + export type Any = + | RequestBodyNone + | RequestBodyText.Any + | RequestBodyJson.Any + | RequestBodyUrlEncoded.Any + | RequestBodyMultipart.Any + | RequestBodyFile.Any + ; +} + +// --- CONSTRUCTORS ------------------------------------------------------------ + +export const RequestBodyNone: RequestBodyNone = Object.freeze({ + _tag: "None", +}); + +export const RequestBodyText = >(schema: Schema): RequestBodyText => Object.freeze>({ + _tag: "Text", + schema, +}); + +export const RequestBodyJson = (schema: Schema): RequestBodyJson => Object.freeze>({ + _tag: "Json", + schema, +}); + +export const RequestBodyUrlEncoded = (fields: Fields): RequestBodyUrlEncoded => Object.freeze>({ + _tag: "UrlEncoded", + fields, +}); + +export const RequestBodyMultipart = (fields: Fields): RequestBodyMultipart => Object.freeze>({ + _tag: "Multipart", + fields, +}); + +export const RequestBodyFile = (mimeType: MimeTypePredicate): RequestBodyFile => Object.freeze>({ + _tag: "File", + mimeType, +}); diff --git a/packages/api/src/ResponseBody.ts b/packages/api/src/ResponseBody.ts new file mode 100644 index 0000000..2277492 --- /dev/null +++ b/packages/api/src/ResponseBody.ts @@ -0,0 +1,90 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Schema as S } from "@effect/schema"; +import type { MimeTypePredicate } from "./MimeType"; + +// --- INTERFACES -------------------------------------------------------------- + +export interface ResponseBodyNone { + readonly _tag: "None"; +} + +export interface ResponseBodyText> { + readonly _tag: "Text"; + readonly schema: Schema; +} + +export interface ResponseBodyJson { + readonly _tag: "Json"; + readonly schema: Schema; +} + +export interface ResponseBodyFile { + readonly _tag: "File"; + readonly mimeType: MimeTypePredicate; +} + +export namespace ResponseBodyText { + export type Any = ResponseBodyText>; +} + +export namespace ResponseBodyJson { + export type Any = ResponseBodyJson; +} + +export namespace ResponseBodyFile { + export type Any = ResponseBodyFile; +} + +export namespace ResponseBody { + export type Any = + | ResponseBodyNone + | ResponseBodyText.Any + | ResponseBodyJson.Any + | ResponseBodyFile.Any + ; +} + +// --- CONSTRUCTORS ------------------------------------------------------------ + +export const ResponseBodyNone: ResponseBodyNone = Object.freeze({ + _tag: "None", +}); + +export const ResponseBodyText = >(schema: Schema): ResponseBodyText => Object.freeze>({ + _tag: "Text", + schema, +}); + +export const ResponseBodyJson = (schema: Schema): ResponseBodyJson => Object.freeze>({ + _tag: "Json", + schema, +}); + +export const ResponseBodyFile = (mimeType: MimeTypePredicate): ResponseBodyFile => Object.freeze>({ + _tag: "File", + mimeType, +}); + +export const ResponseEmpty: {} = Object.freeze<{}>({}); +import type { Option as O } from "effect"; + +export type Multiplicity = + | "single" + | "optional" + | "multiple" + | "any" + ; + +export namespace Multiplicity { + export type With = { + single: T, + optional: O.Option, + multiple: readonly [T, ...T[]], + any: readonly T[], + }[M]; +} diff --git a/packages/api/src/Route.ts b/packages/api/src/Route.ts new file mode 100644 index 0000000..88d36f4 --- /dev/null +++ b/packages/api/src/Route.ts @@ -0,0 +1,50 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Schema as S } from "@effect/schema"; + +// --- INTERFACES -------------------------------------------------------------- + +export interface RouteLiteral { + readonly _tag: "Literal"; + readonly literal: Literal; +} + +export interface RouteParam> { + readonly _tag: "Param"; + readonly name: Name; + readonly schema: Schema; +} + +export namespace RouteLiteral { + export type Any = RouteLiteral; +} + +export namespace RouteParam { + export type Any = RouteParam>; +} + +export namespace Route { + export type Any = readonly ( + | RouteLiteral.Any + | RouteParam.Any + )[]; +} + +// --- CONSTRUCTORS ------------------------------------------------------------ + +export const RouteLiteral = (literal: Literal): RouteLiteral => Object.freeze>({ + _tag: "Literal", + literal, +}); + +export const RouteParam = >(name: Name, schema: Schema): RouteParam => Object.freeze>({ + _tag: "Param", + name, + schema, +}); + +export const Route = (...route: Route): Route => Object.freeze(route); diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..4e97009 --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,7 @@ +{ + "name": "@make-api/backend", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/packages/backend/src/Api.ts b/packages/backend/src/Api.ts new file mode 100644 index 0000000..120cf01 --- /dev/null +++ b/packages/backend/src/Api.ts @@ -0,0 +1,107 @@ +/*! + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { Schema as S } from "@effect/schema"; +import * as Api from "@make-api/api/Api"; +import * as Query from "@make-api/api/Query"; +import * as RequestBody from "@make-api/api/RequestBody"; +import * as Route from "@make-api/api/Route"; +import { Array as A, Effect, Match, Option as O, Record as R, pipe } from "effect"; + +const getUsers = pipe( + Api.make("GET", "/users"), + Api.responseBodyJson(200, S.Array(S.Struct({ + userId: S.UUID, + username: S.String, + email: S.String, + }))), + Api.responseBodyJson(500, S.Struct({ + error: S.String, + stackTrace: S.optional(S.String, { exact: true }), + })), +); + +type RouteParams = { readonly [_: string]: unknown }; // TODO + +const matchesRoute = (route: T, pathComponents: readonly string[]): boolean => { + if (route.length !== pathComponents.length) return false; + return pipe( + A.zip(route, pathComponents), + A.every(([token, pathComponent]) => token._tag !== "Literal" || token.literal === pathComponent), + ); +}; + +const parseRoute = (route: T) => { + const decoder = pipe( + route, + A.filterMap((r) => r._tag === "Param" ? O.some([r.name, r.schema] as const) : O.none()), + R.fromEntries, + S.Struct, + (_) => S.decode(_, { errors: "all" }), + ); + + return (pathComponents: readonly string[]) => pipe( + pathComponents, + A.filterMap((c, i) => { + const r = route[i]; + return r._tag === "Param" ? O.some([r.name, c] as const) : O.none(); + }), + R.fromEntries, + decoder, + ); +}; + +const parseQuery = (query: T) => { + const decoder = pipe(); + + return (searchParams: URLSearchParams) => pipe(); +}; + +const parseBody = (requestBody: T) => { + const decoder = pipe( + Match.value(requestBody), + Match.tags({ + None: () => {}, + Text: () => {}, + Json: () => {}, + UrlEncoded: () => {}, + Multipart: () => {}, + File: () => {}, + }), + ); + + return (req: Request) => pipe(); +}; + +const routeParser = parseRoute(getUsers.props.route); +const queryParser = parseQuery(getUsers.props.query); +const bodyParser = parseBody(getUsers.props.request); + +Bun.serve({ + fetch: (req) => { + const url = new URL(req.url); + + const pathname = url.pathname; + const pathComponents = pathname.replaceAll(/^\/|\/$/g, "").split("/"); + + const searchParams = url.searchParams; + + if (matchesRoute(getUsers.props.route, pathComponents)) { + const paramsEffect = routeParser(pathComponents); + const queryEffect = queryParser(searchParams); + const bodyEffect = bodyParser(req); + + const effect = Effect.all({ + params: paramsEffect, + query: queryEffect, + body: bodyEffect, + }); + } + }, + websocket: { + message: (ws, message) => {} + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 9a063aa..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,61 +0,0 @@ -lockfileVersion: '6.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -dependencies: - '@effect/schema': - specifier: ^0.64.3 - version: 0.64.3(effect@2.4.6)(fast-check@3.16.0) - effect: - specifier: ^2.4.6 - version: 2.4.6 - fast-check: - specifier: ^3.16.0 - version: 3.16.0 - tslib: - specifier: ^2.6.2 - version: 2.6.2 - -devDependencies: - typescript: - specifier: ^5.4.2 - version: 5.4.2 - -packages: - - /@effect/schema@0.64.3(effect@2.4.6)(fast-check@3.16.0): - resolution: {integrity: sha512-Pn/IG+X9GTXLVvDtdjptr3QWxpcH+m7LEgM6loLWk7DP8SPKqWdo3rFaZiILpJFci8QWEk7K23C3IJZOnMfTtA==} - peerDependencies: - effect: ^2.4.6 - fast-check: ^3.13.2 - dependencies: - effect: 2.4.6 - fast-check: 3.16.0 - dev: false - - /effect@2.4.6: - resolution: {integrity: sha512-gaZC+aopWvS67SX8RFT3mirabjpVBk5qshj/Z3IKW35DjzpQ8WhAE7VWwfBBWWMBuQ/B0RsbzKR8YGN2AivIgQ==} - dev: false - - /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 - dev: false - - /pure-rand@6.0.4: - resolution: {integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==} - dev: false - - /tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - dev: false - - /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 deleted file mode 100644 index f6c5da7..0000000 --- a/src/Api.ts +++ /dev/null @@ -1,147 +0,0 @@ -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 deleted file mode 100644 index df218dc..0000000 --- a/src/Fetch.ts +++ /dev/null @@ -1,102 +0,0 @@ -/// -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 deleted file mode 100644 index 5634afb..0000000 --- a/src/effect-api.ts +++ /dev/null @@ -1,66 +0,0 @@ -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]], -}); diff --git a/tsconfig.json b/tsconfig.json index 2fbd93e..fc2adcf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,56 +1,61 @@ { - "compilerOptions": { - "incremental": true, - "composite": true, + "compilerOptions": { + "incremental": true, + "composite": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", - "outDir": "./dist/", - "rootDir": "./src/", - "tsBuildInfoFile": "./.tsbuildinfo", + "target": "ESNext", + "lib": ["ESNext", "DOM"], - "target": "ES2022", - "lib": ["ES2022"], + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "removeComments": false, + "newLine": "lf", - "sourceMap": true, - "declaration": true, - "declarationMap": true, - "removeComments": false, - "newLine": "lf", + "noErrorTruncation": true, - "noErrorTruncation": true, + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "noImplicitThis": true, + "strictNullChecks": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "useUnknownInCatchVariables": true, - "strict": true, - "alwaysStrict": true, - "noImplicitAny": true, - "noImplicitThis": true, - "strictNullChecks": true, - "strictBindCallApply": true, - "strictFunctionTypes": true, - "strictPropertyInitialization": true, - "useUnknownInCatchVariables": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, - "noUnusedLocals": true, - "noUnusedParameters": true, - "allowUnusedLabels": false, - "allowUnreachableCode": false, + "noImplicitOverride": true, + "noImplicitReturns": true, - "noImplicitOverride": true, - "noImplicitReturns": true, + "noUncheckedIndexedAccess": false, + "noPropertyAccessFromIndexSignature": false, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, - "noUncheckedIndexedAccess": false, - "noPropertyAccessFromIndexSignature": false, - "noFallthroughCasesInSwitch": true, - "exactOptionalPropertyTypes": true, - "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "isolatedModules": true, + "importHelpers": false, - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "isolatedModules": true, - "importHelpers": true, + "skipLibCheck": false, - "skipLibCheck": false, - }, - "include": ["./src/**/*"], + "paths": { + "@make-api/api": ["./packages/api/src/index.ts"], + "@make-api/api/*": ["./packages/api/src/*"], + "@make-api/backend": ["./packages/backend/src/index.ts"], + "@make-api/backend/*": ["./packages/backend/src/*"], + "@make-api/frontend": ["./packages/frontend/src/index.ts"], + "@make-api/frontend/*": ["./packages/frontend/src/*"], + } + } }