diff --git a/packages/effect-http/package.json b/packages/effect-http/package.json index 187822d52..8c82fafed 100644 --- a/packages/effect-http/package.json +++ b/packages/effect-http/package.json @@ -41,8 +41,13 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "^10.1.0", + "@effect/cli": "^0.36.62", "@effect/platform": "^0.58.4", + "@effect/platform-node": "^0.53.3", "@effect/schema": "^0.68.6", - "effect": "^3.4.0" + "effect": "^3.4.0", + "effect-http-node": "^0.15.3", + "effect-log": "^0.31.5", + "importx": "^0.3.7" } } diff --git a/packages/effect-http/src/Api.ts b/packages/effect-http/src/Api.ts index aeeeb2f4d..ba20b9235 100644 --- a/packages/effect-http/src/Api.ts +++ b/packages/effect-http/src/Api.ts @@ -3,7 +3,7 @@ * * @since 1.0.0 */ -import type * as Schema from "@effect/schema/Schema" +import type * as CliConfig from "@effect/schema/Schema" import type * as Pipeable from "effect/Pipeable" import type * as Types from "effect/Types" @@ -154,12 +154,12 @@ export { * @category constructors * @since 1.0.0 */ - get, + make as endpoint, /** * @category constructors * @since 1.0.0 */ - make as endpoint, + get, /** * @category constructors * @since 1.0.0 @@ -251,7 +251,7 @@ export const setOptions: ( * @category schemas * @since 1.0.0 */ -export const FormData: Schema.Schema = ApiSchema.FormData +export const FormData: CliConfig.Schema = ApiSchema.FormData /** * @category refinements diff --git a/packages/effect-http/src/CliConfig.ts b/packages/effect-http/src/CliConfig.ts new file mode 100644 index 000000000..bc207b332 --- /dev/null +++ b/packages/effect-http/src/CliConfig.ts @@ -0,0 +1,32 @@ +import { Data } from "effect"; +import { Api } from "effect-http"; +import * as internal from "./internal/cliConfig.js"; + +/** + * @since 1.0.0 + * @category type id + */ +export const TypeId: unique symbol = internal.TypeId + +/** + * @since 1.0.0 + * @category type id + */ +export type TypeId = typeof TypeId + +export class CliConfig extends Data.Class<{ + [TypeId]: typeof TypeId; + api: Api.Api.Any; + server?: Partial<{ + port: number + }>; + client?: Partial<{ + baseUrl: string + }> +}> {} + +export const make = (config: Omit) => + new CliConfig({ ...config, [TypeId]: TypeId }); + +export const isCliConfig = (u: unknown): u is CliConfig => + internal.isCliConfig(u); diff --git a/packages/effect-http/src/bin.ts b/packages/effect-http/src/bin.ts new file mode 100644 index 000000000..6ebd6f2f2 --- /dev/null +++ b/packages/effect-http/src/bin.ts @@ -0,0 +1,163 @@ +import { + Command, + HelpDoc, + Options, + Prompt, + ValidationError, +} from "@effect/cli"; +import { NodeContext, NodeRuntime } from "@effect/platform-node"; +import * as HttpMiddleware from "@effect/platform/HttpMiddleware"; +import * as Path from "@effect/platform/Path"; +import { Console, Data, Effect, Option } from "effect"; +import { NodeServer } from "effect-http-node"; +import * as PrettyLogger from "effect-log/PrettyLogger"; +import * as importx from "importx"; +import pkg from "../package.json"; +import * as CliConfig from "./CliConfig.js"; +import * as ExampleServer from "./ExampleServer.js"; +import * as RouterBuilder from "./RouterBuilder.js"; +import { Api, ApiEndpoint } from "./index.js"; + +/** + * An error that occurs when loading the config file. + */ +class ConfigError extends Data.TaggedError("ConfigError")<{ + message: string; +}> {} + +const loadConfig = (relativePath: string) => + Effect.flatMap(Path.Path, (path) => { + const fullPath = path.join(process.cwd(), relativePath); + return Effect.tryPromise(() => + importx.import(fullPath, import.meta.url), + ).pipe( + Effect.mapError( + () => + new ConfigError({ message: `Failed to find config at ${fullPath}` }), + ), + Effect.flatMap((module) => + module?.default + ? Effect.succeed(module.default) + : new ConfigError({ + message: `No default export found in ${fullPath}`, + }), + ), + Effect.flatMap((defaultExport) => + CliConfig.isCliConfig(defaultExport) + ? Effect.succeed(defaultExport) + : new ConfigError({ message: `Invalid config found in ${fullPath}` }), + ), + Effect.withSpan("loadConfig", { attributes: { fullPath } }), + ); + }); + +const urlArg = Options.text("url").pipe( + Options.withDescription("URL to make the request to"), + Options.optional, +); + +const configArg = Options.file("config").pipe( + Options.withAlias("c"), + Options.withDescription("Path to the config file"), + Options.withDefault("./effect-http.config.ts"), + Options.mapEffect((s) => + loadConfig(s).pipe( + Effect.mapError((e) => + ValidationError.invalidArgument(HelpDoc.h1(e.message)), + ), + ), + ), +); + +const portArg = Options.integer("port").pipe( + Options.withAlias("p"), + Options.withDescription("Port to run the server on"), + Options.optional, +); + +export const genClientCli = (api: Api.Api.Any) => { + return api.groups + .map((group) => group.endpoints) + .flat() + .map((endpoint) => { + return Command.make( + ApiEndpoint.getId(endpoint), + { url: urlArg }, + (args) => Effect.log(`Making request to ${args.url}`), + ).pipe( + Command.withDescription( + ApiEndpoint.getOptions(endpoint).description || "", + ), + ); + }); +}; + +const serveCommand = Command.make("serve", { port: portArg }, (args) => + Effect.gen(function* () { + const { config } = yield* rootCommand; + const port = + Option.getOrUndefined(args.port) ?? config.server?.port ?? 11779; + yield* ExampleServer.make(config.api).pipe( + RouterBuilder.buildPartial, + HttpMiddleware.logger, + NodeServer.listen({ port }), + ); + }), +).pipe(Command.withDescription("Start an example server")); + +const clientCommand = Command.make("client", { url: urlArg }, (args) => + Effect.gen(function* () { + const { config } = yield* rootCommand; + const endpoints = config.api.groups.map((group) => group.endpoints).flat(); + + const selectedEndpoint = yield* Prompt.select({ + message: "Select an endpoint", + choices: endpoints.map((endpoint) => ({ + value: endpoint, + title: ApiEndpoint.getId(endpoint), + describe: ApiEndpoint.getOptions(endpoint).description, + })), + }); + + yield* Effect.log(selectedEndpoint); + }), +); + +const rootCommand = Command.make("effect-http", { + config: configArg, +}); + +/** + * List endpoints + */ +const listCommand = Command.make("list", {}, (args) => + Effect.gen(function* () { + const { config } = yield* rootCommand; + const endpoints = config.api.groups.map((group) => group.endpoints).flat(); + yield* Console.log( + endpoints + .map( + (e) => + `${ApiEndpoint.getId(e)}(${ApiEndpoint.getMethod(e)} ${ApiEndpoint.getPath(e)}): ${ApiEndpoint.getOptions(e).description}`, + ) + .join("\n"), + ); + }), +).pipe(Command.withDescription("List all endpoints")); + +const cli = Command.run( + rootCommand.pipe( + Command.withSubcommands([listCommand, serveCommand, clientCommand]), + ), + { + name: "Effect Http Cli", + version: `v${pkg.version}`, + }, +); + +cli(process.argv).pipe( + Effect.provide(NodeContext.layer), + Effect.catchAll(Effect.logError), + Effect.provide(PrettyLogger.layer({ showFiberId: false })), + NodeRuntime.runMain, +); diff --git a/packages/effect-http/src/effect-http.config.ts b/packages/effect-http/src/effect-http.config.ts new file mode 100644 index 000000000..6949651a7 --- /dev/null +++ b/packages/effect-http/src/effect-http.config.ts @@ -0,0 +1,20 @@ +import { Schema } from "@effect/schema"; +import { pipe } from "effect"; +import * as Api from "./Api.js"; +import * as CliConfig from "./CliConfig.js"; + +const api = pipe( + Api.make({ title: "Users API" }), + Api.addEndpoint( + Api.get("getUser", "/user", { "description": "Get a user by their id" }).pipe( + Api.setResponseBody(Schema.Number), + ) + ) +) + +export default CliConfig.make({ + api, + client: { + baseUrl: "http://localhost:3000" + } +}) diff --git a/packages/effect-http/src/internal/cliConfig.ts b/packages/effect-http/src/internal/cliConfig.ts new file mode 100644 index 000000000..52c383404 --- /dev/null +++ b/packages/effect-http/src/internal/cliConfig.ts @@ -0,0 +1,8 @@ +import type * as CliConfig from "../CliConfig.js" + +export const TypeId: CliConfig.TypeId = Symbol.for( + "effect-http/CliConfig/TypeId" +) as CliConfig.TypeId + +export const isCliConfig = (u: unknown): u is CliConfig.CliConfig => + typeof u === "object" && u !== null && TypeId in u