Add make-api package
This commit is contained in:
@@ -9,8 +9,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/schema": "catalog:",
|
"@effect/schema": "catalog:",
|
||||||
|
"common": "workspace:^",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fast-check": "catalog:",
|
"fast-check": "catalog:",
|
||||||
|
"make-api": "workspace:^",
|
||||||
"ulid": "catalog:"
|
"ulid": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
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 { RequestError } from "./RequestError";
|
||||||
import { Database } from "./services/database";
|
import { Database } from "./services/database";
|
||||||
import { Request } from "./services/request";
|
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 { 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;
|
const req = yield* Request;
|
||||||
|
|
||||||
return req.method === method
|
return req.method === api.method
|
||||||
&& req.path.length === pattern.length
|
&& req.path.length === api.props.route.length
|
||||||
&& pattern.every((x, i) => x === "*" || x === req.path[i]);
|
&& 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;
|
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));
|
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(
|
const user = yield* pipe(
|
||||||
db.getUserByUsername(body.username),
|
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));
|
const valid = yield* Effect.promise(() => Bun.password.verify(body.password, user.password));
|
||||||
if (!valid) {
|
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 sessionId = yield* db.createSession(user.userId);
|
||||||
|
|
||||||
const responseData = LoginResponse.make({
|
const responseData = Login.props.response[200].schema.make({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
admin: user.admin,
|
admin: user.admin,
|
||||||
@@ -105,7 +114,7 @@ export const app = pipe(
|
|||||||
|
|
||||||
const responseJson = yield* pipe(
|
const responseJson = yield* pipe(
|
||||||
responseData,
|
responseData,
|
||||||
S.encode(LoginResponse),
|
S.encode(Login.props.response[200].schema),
|
||||||
Effect.map(JSON.stringify),
|
Effect.map(JSON.stringify),
|
||||||
);
|
);
|
||||||
const responseArray = new TextEncoder().encode(responseJson);
|
const responseArray = new TextEncoder().encode(responseJson);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
import { Schema as S } from "@effect/schema";
|
||||||
import { Database as SqliteDatabase } from "bun:sqlite";
|
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 { Context, Effect, Layer, pipe } from "effect";
|
||||||
import { NoSuchElementException } from "effect/Cause";
|
import { NoSuchElementException } from "effect/Cause";
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../common" },
|
{ "path": "../common" },
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,27 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/schema": "catalog:",
|
"@effect/schema": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fast-check": "catalog:"
|
"fast-check": "catalog:",
|
||||||
|
"make-api": "workspace:^"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { Api } from "make-api";
|
||||||
import { Schema as S } from "@effect/schema";
|
import { Schema as S } from "@effect/schema";
|
||||||
import { UserId } from "common";
|
import { UserId } from "common";
|
||||||
|
import { pipe } from "effect";
|
||||||
|
|
||||||
export const LoginRequest = S.Struct({
|
export const Login = pipe(
|
||||||
username: S.NonEmptyString,
|
Api.make("POST", "login"),
|
||||||
password: S.NonEmptyString,
|
Api.requestBodyJson(S.Struct({
|
||||||
});
|
username: S.NonEmptyString,
|
||||||
|
password: S.NonEmptyString,
|
||||||
export const LoginResponse = S.Struct({
|
})),
|
||||||
userId: UserId,
|
Api.responseBodyJson(200, S.Struct({
|
||||||
username: S.NonEmptyString,
|
userId: UserId,
|
||||||
admin: S.Boolean,
|
username: S.NonEmptyString,
|
||||||
});
|
admin: S.Boolean,
|
||||||
|
})),
|
||||||
|
Api.responseBodyText(400, S.String),
|
||||||
|
Api.responseBodyText(401, S.String),
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"references": [
|
||||||
|
{ "path": "../make-api" },
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/schema": "catalog:",
|
"@effect/schema": "catalog:",
|
||||||
|
"common": "workspace:^",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fast-check": "catalog:",
|
"fast-check": "catalog:",
|
||||||
|
"make-api": "workspace:^",
|
||||||
"preact": "catalog:",
|
"preact": "catalog:",
|
||||||
"preact-iso": "catalog:"
|
"preact-iso": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
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 { useId, useMemo, useRef } from "preact/hooks";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import * as style from "./Login.css";
|
import * as style from "./Login.css";
|
||||||
import { Effect, Fiber, pipe } from "effect";
|
import { Effect, Fiber, Option as O, pipe } from "effect";
|
||||||
|
|
||||||
export default () => {
|
export default () => {
|
||||||
|
|
||||||
@@ -13,16 +13,18 @@ export default () => {
|
|||||||
const setLoginUsername = useStore(state => state.setLoginUsername);
|
const setLoginUsername = useStore(state => state.setLoginUsername);
|
||||||
const setLoginPassword = useStore(state => state.setLoginPassword);
|
const setLoginPassword = useStore(state => state.setLoginPassword);
|
||||||
|
|
||||||
|
const setUser = useStore(state => state.setUser);
|
||||||
|
|
||||||
const usernameId = useId();
|
const usernameId = useId();
|
||||||
const passwordId = useId();
|
const passwordId = useId();
|
||||||
|
|
||||||
const requestFiber = useRef<Fiber.RuntimeFiber<void> | null>(null);
|
const requestFiber = useRef<Fiber.RuntimeFiber<void> | null>(null);
|
||||||
const requestEffect = useMemo(() => Effect.gen(function* () {
|
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(
|
const requestJson = yield* pipe(
|
||||||
requestData,
|
requestData,
|
||||||
S.encode(LoginRequest),
|
S.encode(Login.props.request.schema),
|
||||||
Effect.map(JSON.stringify),
|
Effect.map(JSON.stringify),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
);
|
);
|
||||||
@@ -40,12 +42,13 @@ export default () => {
|
|||||||
|
|
||||||
const responseData = yield* pipe(
|
const responseData = yield* pipe(
|
||||||
Effect.promise(() => res.json()),
|
Effect.promise(() => res.json()),
|
||||||
Effect.flatMap(S.decodeUnknown(LoginResponse)),
|
Effect.flatMap(S.decodeUnknown(Login.props.response[200].schema)),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
);
|
);
|
||||||
|
|
||||||
setLoginUsername("");
|
setLoginUsername("");
|
||||||
setLoginPassword("");
|
setLoginPassword("");
|
||||||
|
setUser(O.some(responseData));
|
||||||
|
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = "/";
|
a.href = "/";
|
||||||
|
|||||||
@@ -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";
|
import { useLayoutEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
export type Update<T> = T | ((prev: T) => T);
|
export type Update<T> = T | ((prev: T) => T);
|
||||||
export type Updater<T> = (action: Update<T>) => void;
|
export type Updater<T> = (action: Update<T>) => void;
|
||||||
|
|
||||||
|
export const mapProp = <const K extends string, T>(prop: K, action: T) => <O extends { readonly [_ in K]: 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 {
|
export interface Store {
|
||||||
readonly loginUsername: string;
|
readonly loginUsername: string;
|
||||||
readonly loginPassword: string;
|
readonly loginPassword: string;
|
||||||
|
|
||||||
|
readonly user: O.Option<Store.User>;
|
||||||
|
|
||||||
readonly setLoginUsername: Updater<string>;
|
readonly setLoginUsername: Updater<string>;
|
||||||
readonly setLoginPassword: Updater<string>;
|
readonly setLoginPassword: Updater<string>;
|
||||||
|
|
||||||
|
readonly setUser: Updater<O.Option<Store.User>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let store: Store = Object.freeze<Store>({
|
let store: Store = Object.freeze<Store>({
|
||||||
loginUsername: "",
|
loginUsername: "",
|
||||||
loginPassword: "",
|
loginPassword: "",
|
||||||
|
|
||||||
setLoginUsername: (action) => set(({ loginUsername }) => ({ loginUsername: typeof action === "function" ? action(loginUsername) : loginUsername })),
|
user: O.none(),
|
||||||
setLoginPassword: (action) => set(({ loginPassword }) => ({ loginPassword: typeof action === "function" ? action(loginPassword) : loginPassword })),
|
|
||||||
|
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
|
||||||
|
setLoginPassword: (action) => set(mapProp("loginPassword", action)),
|
||||||
|
|
||||||
|
setUser: (action) => set(mapProp("user", action)),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- STORE IMPLEMENTATION ----------------------------------------------------
|
// --- STORE IMPLEMENTATION ----------------------------------------------------
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../common" },
|
{ "path": "../common" },
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
20
packages/make-api/package.json
Normal file
20
packages/make-api/package.json
Normal file
@@ -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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
142
packages/make-api/src/Api.ts
Normal file
142
packages/make-api/src/Api.ts
Normal file
@@ -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<Props extends Api.PropsAny> {
|
||||||
|
readonly method: Method;
|
||||||
|
readonly props: Props;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Api {
|
||||||
|
export type Any = Api<PropsAny>;
|
||||||
|
|
||||||
|
export type Props<T extends Api.Any> = T extends Api<infer Props> ? 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 = <const Props extends Api.PropsAny>(method: Method, props: Props): Api<Props> => Object.freeze<Api<Props>>({
|
||||||
|
method,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- BUILDERS ----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type RouteInputLiteral<Literal extends string> = Literal;
|
||||||
|
|
||||||
|
export type RouteInputParam<Name extends string, Schema extends S.Schema<any, string>> = readonly [Name, Schema];
|
||||||
|
|
||||||
|
export namespace RouteInputLiteral {
|
||||||
|
export type Any = RouteInputLiteral<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RouteInputParam {
|
||||||
|
export type Any = RouteInputParam<string, S.Schema<any, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RouteInput {
|
||||||
|
export type Any = readonly (
|
||||||
|
| RouteInputLiteral.Any
|
||||||
|
| RouteInputParam.Any
|
||||||
|
)[];
|
||||||
|
|
||||||
|
export type Route<R extends RouteInput.Any> = { readonly [K in keyof R]:
|
||||||
|
R[K] extends RouteInputLiteral<infer Literal> ? RouteLiteral<Literal>
|
||||||
|
: R[K] extends RouteInputParam<infer Name, infer Schema> ? RouteParam<Name, Schema>
|
||||||
|
: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const make = <const R extends RouteInput.Any>(method: Method, ...routeInput: R): Api<{
|
||||||
|
readonly route: RouteInput.Route<R>,
|
||||||
|
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<R>),
|
||||||
|
request: RequestBodyNone,
|
||||||
|
response: ResponseEmpty,
|
||||||
|
query: QueryEmpty,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const requestBodyText = <const Schema extends S.Schema<any, string>>(schema: Schema) =>
|
||||||
|
<const T extends Api.Any>({ method, props }: T): Api<{
|
||||||
|
readonly route: T["props"]["route"],
|
||||||
|
readonly request: RequestBodyText<Schema>,
|
||||||
|
readonly response: T["props"]["response"],
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, request: RequestBodyText(schema) });
|
||||||
|
|
||||||
|
export const requestBodyJson = <const Schema extends S.Schema.Any>(schema: Schema) =>
|
||||||
|
<const T extends Api.Any>({ method, props }: T): Api<{
|
||||||
|
readonly route: T["props"]["route"],
|
||||||
|
readonly request: RequestBodyJson<Schema>,
|
||||||
|
readonly response: T["props"]["response"],
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, request: RequestBodyJson(schema) });
|
||||||
|
|
||||||
|
export const requestBodyUrlEncoded = <const Fields extends { readonly [_: string]: Field.AnyUrlEncoded }>(fields: Fields) =>
|
||||||
|
<const T extends Api.Any>({ method, props }: T): Api<{
|
||||||
|
readonly route: T["props"]["route"],
|
||||||
|
readonly request: RequestBodyUrlEncoded<Fields>,
|
||||||
|
readonly response: T["props"]["response"],
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, request: RequestBodyUrlEncoded(fields) });
|
||||||
|
|
||||||
|
export const requestBodyMultipart = <const Fields extends { readonly [_: string]: Field.Any }>(fields: Fields) =>
|
||||||
|
<const T extends Api.Any>({ method, props }: T): Api<{
|
||||||
|
readonly route: T["props"]["route"],
|
||||||
|
readonly request: RequestBodyMultipart<Fields>,
|
||||||
|
readonly response: T["props"]["response"],
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, request: RequestBodyMultipart(fields) });
|
||||||
|
|
||||||
|
export const requestBodyFile = <const MimeType extends string>(mimeType: MimeTypePredicate<MimeType>) =>
|
||||||
|
<const T extends Api.Any>({ method, props }: T): Api<{
|
||||||
|
readonly route: T["props"]["route"],
|
||||||
|
readonly request: RequestBodyFile<MimeType>,
|
||||||
|
readonly response: T["props"]["response"],
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, request: RequestBodyFile(mimeType) });
|
||||||
|
|
||||||
|
export const responseBodyText = <const Status extends number, const Schema extends S.Schema<any, string>>(status: Status, schema: Schema) =>
|
||||||
|
<const T extends Api.Any>({ 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<Schema> : T["props"]["response"][K] },
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyText(schema) }) });
|
||||||
|
|
||||||
|
export const responseBodyJson = <const Status extends number, const Schema extends S.Schema.Any>(status: Status, schema: Schema) =>
|
||||||
|
<const T extends Api.Any>({ 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<Schema> : T["props"]["response"][K] },
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyJson(schema) }) });
|
||||||
|
|
||||||
|
export const responseBodyFile = <const Status extends number, const MimeType extends string>(status: Status, mimeType: MimeTypePredicate<MimeType>) =>
|
||||||
|
<const T extends Api.Any>({ 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<MimeType> : T["props"]["response"][K] },
|
||||||
|
readonly query: T["props"]["query"],
|
||||||
|
}> => Api(method, { ...props, response: Object.freeze<any>({ ...response, [status]: ResponseBodyFile(mimeType) }) });
|
||||||
|
|
||||||
|
export const query = <const Query extends Query.Any>(query: Query) =>
|
||||||
|
<const T extends Api.Any>({ 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 });
|
||||||
68
packages/make-api/src/Field.ts
Normal file
68
packages/make-api/src/Field.ts
Normal file
@@ -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<M extends Multiplicity, Schema extends S.Schema<any, string>> {
|
||||||
|
readonly _tag: "Text";
|
||||||
|
readonly multiplicity: M;
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldJson<M extends Multiplicity, Schema extends S.Schema.Any> {
|
||||||
|
readonly _tag: "Json";
|
||||||
|
readonly multiplicity: M;
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldFile<M extends Multiplicity, MimeType extends string> {
|
||||||
|
readonly _tag: "File";
|
||||||
|
readonly multiplicity: M;
|
||||||
|
readonly mimeType: MimeTypePredicate<MimeType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace FieldText {
|
||||||
|
export type Any = FieldText<Multiplicity, S.Schema<any, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace FieldJson {
|
||||||
|
export type Any = FieldJson<Multiplicity, S.Schema.Any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace FieldFile {
|
||||||
|
export type Any = FieldFile<Multiplicity, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Field {
|
||||||
|
export type Any =
|
||||||
|
| FieldText.Any
|
||||||
|
| FieldJson.Any
|
||||||
|
| FieldFile.Any
|
||||||
|
;
|
||||||
|
|
||||||
|
export type AnyUrlEncoded =
|
||||||
|
| FieldText.Any
|
||||||
|
| FieldJson.Any
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONSTRUCTORS ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const FieldText = <const M extends Multiplicity, const Schema extends S.Schema<any, string>>(multiplicity: M, schema: Schema): FieldText<M, Schema> => Object.freeze<FieldText<M, Schema>>({
|
||||||
|
_tag: "Text",
|
||||||
|
multiplicity,
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FieldJson = <const M extends Multiplicity, const Schema extends S.Schema.Any>(multiplicity: M, schema: Schema): FieldJson<M, Schema> => Object.freeze<FieldJson<M, Schema>>({
|
||||||
|
_tag: "Json",
|
||||||
|
multiplicity,
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FieldFile = <const M extends Multiplicity, const MimeType extends string>(multiplicity: M, mimeType: MimeTypePredicate<MimeType>): FieldFile<M, MimeType> => Object.freeze<FieldFile<M, MimeType>>({
|
||||||
|
_tag: "File",
|
||||||
|
multiplicity,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
7
packages/make-api/src/Method.ts
Normal file
7
packages/make-api/src/Method.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type Method =
|
||||||
|
| "GET"
|
||||||
|
| "POST"
|
||||||
|
| "PUT"
|
||||||
|
| "DELETE"
|
||||||
|
| "PATCH"
|
||||||
|
;
|
||||||
3
packages/make-api/src/MimeType.ts
Normal file
3
packages/make-api/src/MimeType.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { Predicate } from "effect"
|
||||||
|
|
||||||
|
export type MimeTypePredicate<T extends string> = T | readonly T[] | Predicate.Refinement<string, T>;
|
||||||
17
packages/make-api/src/Multiplicity.ts
Normal file
17
packages/make-api/src/Multiplicity.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Option as O } from "effect";
|
||||||
|
|
||||||
|
export type Multiplicity =
|
||||||
|
| "single"
|
||||||
|
| "optional"
|
||||||
|
| "multiple"
|
||||||
|
| "any"
|
||||||
|
;
|
||||||
|
|
||||||
|
export namespace Multiplicity {
|
||||||
|
export type With<M extends Multiplicity, T> = {
|
||||||
|
single: T,
|
||||||
|
optional: O.Option<T>,
|
||||||
|
multiple: readonly [T, ...T[]],
|
||||||
|
any: readonly T[],
|
||||||
|
}[M];
|
||||||
|
}
|
||||||
7
packages/make-api/src/Query.ts
Normal file
7
packages/make-api/src/Query.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { Field } from "./Field";
|
||||||
|
|
||||||
|
export namespace Query {
|
||||||
|
export type Any = { readonly [_: string]: Field.AnyUrlEncoded };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryEmpty: {} = Object.freeze<{}>({});
|
||||||
96
packages/make-api/src/RequestBody.ts
Normal file
96
packages/make-api/src/RequestBody.ts
Normal file
@@ -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<Schema extends S.Schema<any, string>> {
|
||||||
|
readonly _tag: "Text";
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestBodyJson<Schema extends S.Schema.Any> {
|
||||||
|
readonly _tag: "Json";
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestBodyUrlEncoded<Fields extends { readonly [_: string]: Field.AnyUrlEncoded }> {
|
||||||
|
readonly _tag: "UrlEncoded";
|
||||||
|
readonly fields: Readonly<Fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestBodyMultipart<Fields extends { readonly [_: string]: Field.Any }> {
|
||||||
|
readonly _tag: "Multipart";
|
||||||
|
readonly fields: Readonly<Fields>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestBodyFile<MimeType extends string> {
|
||||||
|
readonly _tag: "File";
|
||||||
|
readonly mimeType: MimeTypePredicate<MimeType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RequestBodyText {
|
||||||
|
export type Any = RequestBodyText<S.Schema<any, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RequestBodyJson {
|
||||||
|
export type Any = RequestBodyJson<S.Schema.Any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RequestBodyUrlEncoded {
|
||||||
|
export type Any = RequestBodyUrlEncoded<{}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RequestBodyMultipart {
|
||||||
|
export type Any = RequestBodyMultipart<{}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RequestBodyFile {
|
||||||
|
export type Any = RequestBodyFile<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RequestBody {
|
||||||
|
export type Any =
|
||||||
|
| RequestBodyNone
|
||||||
|
| RequestBodyText.Any
|
||||||
|
| RequestBodyJson.Any
|
||||||
|
| RequestBodyUrlEncoded.Any
|
||||||
|
| RequestBodyMultipart.Any
|
||||||
|
| RequestBodyFile.Any
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONSTRUCTORS ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const RequestBodyNone: RequestBodyNone = Object.freeze<RequestBodyNone>({
|
||||||
|
_tag: "None",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RequestBodyText = <const Schema extends S.Schema<any, string>>(schema: Schema): RequestBodyText<Schema> => Object.freeze<RequestBodyText<Schema>>({
|
||||||
|
_tag: "Text",
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RequestBodyJson = <const Schema extends S.Schema.Any>(schema: Schema): RequestBodyJson<Schema> => Object.freeze<RequestBodyJson<Schema>>({
|
||||||
|
_tag: "Json",
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RequestBodyUrlEncoded = <const Fields extends { readonly [_: string]: Field.AnyUrlEncoded }>(fields: Fields): RequestBodyUrlEncoded<Fields> => Object.freeze<RequestBodyUrlEncoded<Fields>>({
|
||||||
|
_tag: "UrlEncoded",
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RequestBodyMultipart = <const Fields extends { readonly [_: string]: Field.Any }>(fields: Fields): RequestBodyMultipart<Fields> => Object.freeze<RequestBodyMultipart<Fields>>({
|
||||||
|
_tag: "Multipart",
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RequestBodyFile = <const MimeType extends string>(mimeType: MimeTypePredicate<MimeType>): RequestBodyFile<MimeType> => Object.freeze<RequestBodyFile<MimeType>>({
|
||||||
|
_tag: "File",
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
67
packages/make-api/src/ResponseBody.ts
Normal file
67
packages/make-api/src/ResponseBody.ts
Normal file
@@ -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<Schema extends S.Schema<any, string>> {
|
||||||
|
readonly _tag: "Text";
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBodyJson<Schema extends S.Schema.Any> {
|
||||||
|
readonly _tag: "Json";
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseBodyFile<MimeType extends string> {
|
||||||
|
readonly _tag: "File";
|
||||||
|
readonly mimeType: MimeTypePredicate<MimeType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ResponseBodyText {
|
||||||
|
export type Any = ResponseBodyText<S.Schema<any, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ResponseBodyJson {
|
||||||
|
export type Any = ResponseBodyJson<S.Schema.Any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ResponseBodyFile {
|
||||||
|
export type Any = ResponseBodyFile<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ResponseBody {
|
||||||
|
export type Any =
|
||||||
|
| ResponseBodyNone
|
||||||
|
| ResponseBodyText.Any
|
||||||
|
| ResponseBodyJson.Any
|
||||||
|
| ResponseBodyFile.Any
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONSTRUCTORS ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ResponseBodyNone: ResponseBodyNone = Object.freeze<ResponseBodyNone>({
|
||||||
|
_tag: "None",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseBodyText = <const Schema extends S.Schema<any, string>>(schema: Schema): ResponseBodyText<Schema> => Object.freeze<ResponseBodyText<Schema>>({
|
||||||
|
_tag: "Text",
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseBodyJson = <const Schema extends S.Schema.Any>(schema: Schema): ResponseBodyJson<Schema> => Object.freeze<ResponseBodyJson<Schema>>({
|
||||||
|
_tag: "Json",
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseBodyFile = <const MimeType extends string>(mimeType: MimeTypePredicate<MimeType>): ResponseBodyFile<MimeType> => Object.freeze<ResponseBodyFile<MimeType>>({
|
||||||
|
_tag: "File",
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResponseEmpty: {} = Object.freeze<{}>({});
|
||||||
44
packages/make-api/src/Route.ts
Normal file
44
packages/make-api/src/Route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Schema as S } from "@effect/schema";
|
||||||
|
|
||||||
|
// --- INTERFACES --------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RouteLiteral<Literal extends string> {
|
||||||
|
readonly _tag: "Literal";
|
||||||
|
readonly literal: Literal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteParam<Name extends string, Schema extends S.Schema<any, string>> {
|
||||||
|
readonly _tag: "Param";
|
||||||
|
readonly name: Name;
|
||||||
|
readonly schema: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RouteLiteral {
|
||||||
|
export type Any = RouteLiteral<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace RouteParam {
|
||||||
|
export type Any = RouteParam<string, S.Schema<any, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Route {
|
||||||
|
export type Any = readonly (
|
||||||
|
| RouteLiteral.Any
|
||||||
|
| RouteParam.Any
|
||||||
|
)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONSTRUCTORS ------------------------------------------------------------
|
||||||
|
|
||||||
|
export const RouteLiteral = <const Literal extends string>(literal: Literal): RouteLiteral<Literal> => Object.freeze<RouteLiteral<Literal>>({
|
||||||
|
_tag: "Literal",
|
||||||
|
literal,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RouteParam = <const Name extends string, const Schema extends S.Schema<any, string>>(name: Name, schema: Schema): RouteParam<Name, Schema> => Object.freeze<RouteParam<Name, Schema>>({
|
||||||
|
_tag: "Param",
|
||||||
|
name,
|
||||||
|
schema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = <const Route extends Route.Any>(...route: Route): Route => Object.freeze<Route>(route);
|
||||||
9
packages/make-api/src/index.ts
Normal file
9
packages/make-api/src/index.ts
Normal file
@@ -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";
|
||||||
3
packages/make-api/tsconfig.json
Normal file
3
packages/make-api/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
}
|
||||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -50,12 +50,18 @@ importers:
|
|||||||
'@effect/schema':
|
'@effect/schema':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.70.1(effect@3.6.0)
|
version: 0.70.1(effect@3.6.0)
|
||||||
|
common:
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../common
|
||||||
effect:
|
effect:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
fast-check:
|
fast-check:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.20.0
|
version: 3.20.0
|
||||||
|
make-api:
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../make-api
|
||||||
ulid:
|
ulid:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 2.3.0
|
version: 2.3.0
|
||||||
@@ -78,6 +84,9 @@ importers:
|
|||||||
fast-check:
|
fast-check:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.20.0
|
version: 3.20.0
|
||||||
|
make-api:
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../make-api
|
||||||
devDependencies:
|
devDependencies:
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
@@ -88,12 +97,18 @@ importers:
|
|||||||
'@effect/schema':
|
'@effect/schema':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 0.70.1(effect@3.6.0)
|
version: 0.70.1(effect@3.6.0)
|
||||||
|
common:
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../common
|
||||||
effect:
|
effect:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
fast-check:
|
fast-check:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.20.0
|
version: 3.20.0
|
||||||
|
make-api:
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../make-api
|
||||||
preact:
|
preact:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 10.23.1
|
version: 10.23.1
|
||||||
@@ -114,6 +129,22 @@ importers:
|
|||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.3.5(@types/node@20.12.14)
|
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:
|
packages:
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
"common": ["./packages/common/src/index.ts"],
|
"common": ["./packages/common/src/index.ts"],
|
||||||
"common/api": ["./packages/common/src/api.ts"],
|
"common/api": ["./packages/common/src/api.ts"],
|
||||||
"common/db": ["./packages/common/src/db.ts"],
|
"common/db": ["./packages/common/src/db.ts"],
|
||||||
|
"make-api": ["./packages/make-api/index.ts"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"include": ["${configDir}/src"],
|
"include": ["${configDir}/src"],
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
{ "path": "packages/backend" },
|
{ "path": "packages/backend" },
|
||||||
{ "path": "packages/common" },
|
{ "path": "packages/common" },
|
||||||
{ "path": "packages/frontend" },
|
{ "path": "packages/frontend" },
|
||||||
|
{ "path": "packages/make-api" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user