diff --git a/packages/backend/package.json b/packages/backend/package.json index c2b55b3..727b665 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -9,8 +9,10 @@ }, "dependencies": { "@effect/schema": "catalog:", + "common": "workspace:^", "effect": "catalog:", "fast-check": "catalog:", + "make-api": "workspace:^", "ulid": "catalog:" } } diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index e43b297..4479e36 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,19 +1,28 @@ import { Schema as S } from "@effect/schema"; -import { DateTime, Duration, Effect, Option as O, pipe } from "effect"; +import { DateTime, Duration, Either as E, Effect, Option as O, pipe } from "effect"; import { RequestError } from "./RequestError"; import { Database } from "./services/database"; import { Request } from "./services/request"; -import { brotliCompress } from "node:zlib"; +import { Match } from "effect"; +import { Api } from "make-api"; +import { Login } from "common/api"; import { AccessLog } from "common/db"; -import { LoginRequest, LoginResponse } from "common/api"; +import { brotliCompress } from "node:zlib"; -const match = (method: string, ...pattern: readonly string[]) => Effect.gen(function* () { +const match = (api: Api.Api.Any) => Effect.gen(function* () { const req = yield* Request; - return req.method === method - && req.path.length === pattern.length - && pattern.every((x, i) => x === "*" || x === req.path[i]); + return req.method === api.method + && req.path.length === api.props.route.length + && api.props.route.every((token, i) => pipe( + Match.value(token), + Match.tags({ + Literal: ({ literal }) => req.path[i] === literal, + Param: ({ schema }) => E.isRight(S.decodeUnknownEither(schema)(req.path[i])), + }), + Match.exhaustive, + )); }); const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-])\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i; @@ -81,23 +90,23 @@ export const app = pipe( console.log(JSON.stringify(accessLog)); - if (yield* match("POST", "login")) { + if (yield* match(Login)) { - const body = yield* requestJson(LoginRequest); + const body = yield* requestJson(Login.props.request.schema); const user = yield* pipe( db.getUserByUsername(body.username), - Effect.catchTag("NoSuchElementException", () => new RequestError({ status: 404, body: "Invalid username or password" })), + Effect.catchTag("NoSuchElementException", () => new RequestError({ status: 401, body: "Invalid username or password" })), ); const valid = yield* Effect.promise(() => Bun.password.verify(body.password, user.password)); if (!valid) { - return yield* new RequestError({ status: 404, body: "Invalid username or password" }); + return yield* new RequestError({ status: 401, body: "Invalid username or password" }); } const sessionId = yield* db.createSession(user.userId); - const responseData = LoginResponse.make({ + const responseData = Login.props.response[200].schema.make({ userId: user.userId, username: user.username, admin: user.admin, @@ -105,7 +114,7 @@ export const app = pipe( const responseJson = yield* pipe( responseData, - S.encode(LoginResponse), + S.encode(Login.props.response[200].schema), Effect.map(JSON.stringify), ); const responseArray = new TextEncoder().encode(responseJson); diff --git a/packages/backend/src/services/database.ts b/packages/backend/src/services/database.ts index ca385b4..da44bef 100644 --- a/packages/backend/src/services/database.ts +++ b/packages/backend/src/services/database.ts @@ -1,6 +1,7 @@ import { Schema as S } from "@effect/schema"; import { Database as SqliteDatabase } from "bun:sqlite"; -import { SessionId, User, UserId } from "common"; +import { SessionId, UserId } from "common"; +import { User } from "common/db"; import { Context, Effect, Layer, pipe } from "effect"; import { NoSuchElementException } from "effect/Cause"; diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 4c3148f..292965e 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -5,5 +5,5 @@ }, "references": [ { "path": "../common" }, - ] + ], } diff --git a/packages/common/package.json b/packages/common/package.json index 00cae17..99792d6 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -3,12 +3,27 @@ "license": "UNLICENSED", "private": true, "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./api": { + "types": "./dist/api.d.ts", + "import": "./dist/api.js" + }, + "./db": { + "types": "./dist/db.d.ts", + "import": "./dist/db.js" + } + }, "devDependencies": { "typescript": "catalog:" }, "dependencies": { "@effect/schema": "catalog:", "effect": "catalog:", - "fast-check": "catalog:" + "fast-check": "catalog:", + "make-api": "workspace:^" } } diff --git a/packages/common/src/api.ts b/packages/common/src/api.ts index 9a3e536..4cc6137 100644 --- a/packages/common/src/api.ts +++ b/packages/common/src/api.ts @@ -1,13 +1,19 @@ +import { Api } from "make-api"; import { Schema as S } from "@effect/schema"; import { UserId } from "common"; +import { pipe } from "effect"; -export const LoginRequest = S.Struct({ - username: S.NonEmptyString, - password: S.NonEmptyString, -}); - -export const LoginResponse = S.Struct({ - userId: UserId, - username: S.NonEmptyString, - admin: S.Boolean, -}); +export const Login = pipe( + Api.make("POST", "login"), + Api.requestBodyJson(S.Struct({ + username: S.NonEmptyString, + password: S.NonEmptyString, + })), + Api.responseBodyJson(200, S.Struct({ + userId: UserId, + username: S.NonEmptyString, + admin: S.Boolean, + })), + Api.responseBodyText(400, S.String), + Api.responseBodyText(401, S.String), +); diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index f52a4d1..bf9b8f8 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -1,3 +1,6 @@ { "extends": "../../tsconfig.base.json", + "references": [ + { "path": "../make-api" }, + ], } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c188506..1872cff 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@effect/schema": "catalog:", + "common": "workspace:^", "effect": "catalog:", "fast-check": "catalog:", + "make-api": "workspace:^", "preact": "catalog:", "preact-iso": "catalog:" } diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx index bec7f9f..50f4809 100644 --- a/packages/frontend/src/routes/Login.tsx +++ b/packages/frontend/src/routes/Login.tsx @@ -1,9 +1,9 @@ import { Schema as S } from "@effect/schema"; -import { LoginRequest, LoginResponse } from "common/api"; +import { Login } from "common/api"; import { useId, useMemo, useRef } from "preact/hooks"; import { useStore } from "../store"; import * as style from "./Login.css"; -import { Effect, Fiber, pipe } from "effect"; +import { Effect, Fiber, Option as O, pipe } from "effect"; export default () => { @@ -13,16 +13,18 @@ export default () => { const setLoginUsername = useStore(state => state.setLoginUsername); const setLoginPassword = useStore(state => state.setLoginPassword); + const setUser = useStore(state => state.setUser); + const usernameId = useId(); const passwordId = useId(); const requestFiber = useRef | null>(null); const requestEffect = useMemo(() => Effect.gen(function* () { - const requestData = LoginRequest.make(LoginRequest.make({ username: loginUsername, password: loginPassword })); + const requestData = Login.props.request.schema.make({ username: loginUsername, password: loginPassword }); const requestJson = yield* pipe( requestData, - S.encode(LoginRequest), + S.encode(Login.props.request.schema), Effect.map(JSON.stringify), Effect.orDie, ); @@ -40,12 +42,13 @@ export default () => { const responseData = yield* pipe( Effect.promise(() => res.json()), - Effect.flatMap(S.decodeUnknown(LoginResponse)), + Effect.flatMap(S.decodeUnknown(Login.props.response[200].schema)), Effect.orDie, ); setLoginUsername(""); setLoginPassword(""); + setUser(O.some(responseData)); const a = document.createElement("a"); a.href = "/"; diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 7306f71..eb0b3fe 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -1,23 +1,44 @@ -import { identity } from "effect"; +import { UserId } from "common"; +import { identity, Option as O } from "effect"; import { useLayoutEffect, useState } from "preact/hooks"; export type Update = T | ((prev: T) => T); export type Updater = (action: Update) => void; +export const mapProp = (prop: K, action: T) => (object: O): O => { + return Object.freeze({ ...object, [prop]: typeof action === "function" ? action(object[prop]) : action }); +}; + +export namespace Store { + export interface User { + readonly username: string; + readonly userId: UserId; + readonly admin: boolean; + } +} + export interface Store { readonly loginUsername: string; readonly loginPassword: string; + readonly user: O.Option; + readonly setLoginUsername: Updater; readonly setLoginPassword: Updater; + + readonly setUser: Updater>; } let store: Store = Object.freeze({ loginUsername: "", loginPassword: "", - setLoginUsername: (action) => set(({ loginUsername }) => ({ loginUsername: typeof action === "function" ? action(loginUsername) : loginUsername })), - setLoginPassword: (action) => set(({ loginPassword }) => ({ loginPassword: typeof action === "function" ? action(loginPassword) : loginPassword })), + user: O.none(), + + setLoginUsername: (action) => set(mapProp("loginUsername", action)), + setLoginPassword: (action) => set(mapProp("loginPassword", action)), + + setUser: (action) => set(mapProp("user", action)), }); // --- STORE IMPLEMENTATION ---------------------------------------------------- diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json index 6157f4a..c71ee5d 100644 --- a/packages/frontend/tsconfig.json +++ b/packages/frontend/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.base.json", "references": [ { "path": "../common" }, - ] + ], } diff --git a/packages/make-api/package.json b/packages/make-api/package.json new file mode 100644 index 0000000..01a7e88 --- /dev/null +++ b/packages/make-api/package.json @@ -0,0 +1,20 @@ +{ + "name": "make-api", + "license": "UNLICENSED", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "devDependencies": { + "typescript": "catalog:" + }, + "dependencies": { + "@effect/schema": "catalog:", + "effect": "catalog:", + "fast-check": "catalog:" + } +} diff --git a/packages/make-api/src/Api.ts b/packages/make-api/src/Api.ts new file mode 100644 index 0000000..1133078 --- /dev/null +++ b/packages/make-api/src/Api.ts @@ -0,0 +1,142 @@ +import { Schema as S } from "@effect/schema"; +import type { Field } from "./Field"; +import type { Method } from "./Method"; +import type { MimeTypePredicate } from "./MimeType"; +import { QueryEmpty, type Query } from "./Query"; +import { RequestBodyFile, RequestBodyJson, RequestBodyMultipart, RequestBodyNone, RequestBodyText, RequestBodyUrlEncoded, type RequestBody } from "./RequestBody"; +import { ResponseBodyFile, ResponseBodyJson, ResponseBodyText, ResponseEmpty, type ResponseBody } from "./ResponseBody"; +import { Route, RouteLiteral, RouteParam } from "./Route"; + +export interface Api { + 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/make-api/src/Field.ts b/packages/make-api/src/Field.ts new file mode 100644 index 0000000..63f0856 --- /dev/null +++ b/packages/make-api/src/Field.ts @@ -0,0 +1,68 @@ +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/make-api/src/Method.ts b/packages/make-api/src/Method.ts new file mode 100644 index 0000000..ffad978 --- /dev/null +++ b/packages/make-api/src/Method.ts @@ -0,0 +1,7 @@ +export type Method = + | "GET" + | "POST" + | "PUT" + | "DELETE" + | "PATCH" + ; diff --git a/packages/make-api/src/MimeType.ts b/packages/make-api/src/MimeType.ts new file mode 100644 index 0000000..c18b2fe --- /dev/null +++ b/packages/make-api/src/MimeType.ts @@ -0,0 +1,3 @@ +import { Predicate } from "effect" + +export type MimeTypePredicate = T | readonly T[] | Predicate.Refinement; diff --git a/packages/make-api/src/Multiplicity.ts b/packages/make-api/src/Multiplicity.ts new file mode 100644 index 0000000..ff37349 --- /dev/null +++ b/packages/make-api/src/Multiplicity.ts @@ -0,0 +1,17 @@ +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/make-api/src/Query.ts b/packages/make-api/src/Query.ts new file mode 100644 index 0000000..7b9571c --- /dev/null +++ b/packages/make-api/src/Query.ts @@ -0,0 +1,7 @@ +import type { Field } from "./Field"; + +export namespace Query { + export type Any = { readonly [_: string]: Field.AnyUrlEncoded }; +} + +export const QueryEmpty: {} = Object.freeze<{}>({}); diff --git a/packages/make-api/src/RequestBody.ts b/packages/make-api/src/RequestBody.ts new file mode 100644 index 0000000..baaf6d3 --- /dev/null +++ b/packages/make-api/src/RequestBody.ts @@ -0,0 +1,96 @@ +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/make-api/src/ResponseBody.ts b/packages/make-api/src/ResponseBody.ts new file mode 100644 index 0000000..6e4b21e --- /dev/null +++ b/packages/make-api/src/ResponseBody.ts @@ -0,0 +1,67 @@ +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<{}>({}); diff --git a/packages/make-api/src/Route.ts b/packages/make-api/src/Route.ts new file mode 100644 index 0000000..4b87dbb --- /dev/null +++ b/packages/make-api/src/Route.ts @@ -0,0 +1,44 @@ +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/make-api/src/index.ts b/packages/make-api/src/index.ts new file mode 100644 index 0000000..f200a36 --- /dev/null +++ b/packages/make-api/src/index.ts @@ -0,0 +1,9 @@ +export * as Api from "./Api"; +export * from "./Field"; +export * from "./Method"; +export * from "./MimeType"; +export * from "./Multiplicity"; +export * from "./Query"; +export * from "./RequestBody"; +export * from "./ResponseBody"; +export * from "./Route"; diff --git a/packages/make-api/tsconfig.json b/packages/make-api/tsconfig.json new file mode 100644 index 0000000..f52a4d1 --- /dev/null +++ b/packages/make-api/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.base.json", +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0bc32..0cee2cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,12 +50,18 @@ importers: '@effect/schema': specifier: 'catalog:' version: 0.70.1(effect@3.6.0) + common: + specifier: workspace:^ + version: link:../common effect: specifier: 'catalog:' version: 3.6.0 fast-check: specifier: 'catalog:' version: 3.20.0 + make-api: + specifier: workspace:^ + version: link:../make-api ulid: specifier: 'catalog:' version: 2.3.0 @@ -78,6 +84,9 @@ importers: fast-check: specifier: 'catalog:' version: 3.20.0 + make-api: + specifier: workspace:^ + version: link:../make-api devDependencies: typescript: specifier: 'catalog:' @@ -88,12 +97,18 @@ importers: '@effect/schema': specifier: 'catalog:' version: 0.70.1(effect@3.6.0) + common: + specifier: workspace:^ + version: link:../common effect: specifier: 'catalog:' version: 3.6.0 fast-check: specifier: 'catalog:' version: 3.20.0 + make-api: + specifier: workspace:^ + version: link:../make-api preact: specifier: 'catalog:' version: 10.23.1 @@ -114,6 +129,22 @@ importers: specifier: 'catalog:' version: 5.3.5(@types/node@20.12.14) + packages/make-api: + dependencies: + '@effect/schema': + specifier: 'catalog:' + version: 0.70.1(effect@3.6.0) + effect: + specifier: 'catalog:' + version: 3.6.0 + fast-check: + specifier: 'catalog:' + version: 3.20.0 + devDependencies: + typescript: + specifier: 'catalog:' + version: 5.6.0-dev.20240802 + packages: '@ampproject/remapping@2.3.0': diff --git a/tsconfig.base.json b/tsconfig.base.json index 49fb7d0..e267ed8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -30,6 +30,7 @@ "common": ["./packages/common/src/index.ts"], "common/api": ["./packages/common/src/api.ts"], "common/db": ["./packages/common/src/db.ts"], + "make-api": ["./packages/make-api/index.ts"], }, }, "include": ["${configDir}/src"], diff --git a/tsconfig.json b/tsconfig.json index d02120e..97e426b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,5 +5,6 @@ { "path": "packages/backend" }, { "path": "packages/common" }, { "path": "packages/frontend" }, + { "path": "packages/make-api" }, ], }