Skip to content

cli #608

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft

cli #608

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/effect-http/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
8 changes: 4 additions & 4 deletions packages/effect-http/src/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -251,7 +251,7 @@ export const setOptions: (
* @category schemas
* @since 1.0.0
*/
export const FormData: Schema.Schema<FormData> = ApiSchema.FormData
export const FormData: CliConfig.Schema<FormData> = ApiSchema.FormData

/**
* @category refinements
Expand Down
32 changes: 32 additions & 0 deletions packages/effect-http/src/CliConfig.ts
Original file line number Diff line number Diff line change
@@ -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<CliConfig, TypeId>) =>
new CliConfig({ ...config, [TypeId]: TypeId });

export const isCliConfig = (u: unknown): u is CliConfig =>
internal.isCliConfig(u);
163 changes: 163 additions & 0 deletions packages/effect-http/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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,
);
20 changes: 20 additions & 0 deletions packages/effect-http/src/effect-http.config.ts
Original file line number Diff line number Diff line change
@@ -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"
}
})
8 changes: 8 additions & 0 deletions packages/effect-http/src/internal/cliConfig.ts
Original file line number Diff line number Diff line change
@@ -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