Rhys Sullivanfresh starte9a8cbc8 months agoHistory
314 lines · 9.3 KB · TypeScript
RawDownload
import * as HttpApi from "@effect/platform/HttpApi";import * as AST from "effect/SchemaAST";import * as Option from "effect/Option"; type ParsedArgs = {  positional: Array<string>;  flags: Record<string, string | boolean>;}; type EndpointField = {  name: string;  optional: boolean;  ast: AST.AST;}; export type EndpointCommand = {  groupName: string;  endpointName: string;  endpointCliName: string;  method: string;  path: string;  pathFields: Array<EndpointField>;  payloadFields: Array<EndpointField>;  urlParamFields: Array<EndpointField>;  hasPayloadSchema: boolean;}; type CliInvokeOptions = {  request: Record<string, unknown>;  command: EndpointCommand;}; export type HttpApiCliRunnerOptions = {  parsed: ParsedArgs;  invoke: (input: CliInvokeOptions) => Promise<unknown>;  print: (value: unknown) => void;  resolveDefaultPathValue?: (name: string) => Promise<string | undefined>;}; export type HttpApiCli = {  readonly helpText: string;  readonly commands: ReadonlyArray<EndpointCommand>;  readonly execute: (options: {    groupName: string;    endpointName: string;    flags: Record<string, string | boolean>;    invoke: (input: CliInvokeOptions) => Promise<unknown>;    print: (value: unknown) => void;    resolveDefaultPathValue?: (name: string) => Promise<string | undefined>;  }) => Promise<boolean>;  readonly run: (options: HttpApiCliRunnerOptions) => Promise<boolean>;}; const toKebab = (value: string): string =>  value    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")    .replace(/[^a-zA-Z0-9]+/g, "-")    .replace(/--+/g, "-")    .replace(/^-|-$/g, "")    .toLowerCase(); const unwrapAst = (ast: AST.AST): AST.AST => {  if (ast._tag === "Transformation") {    return unwrapAst(ast.to);  }  if (ast._tag === "Suspend") {    return unwrapAst(ast.f());  }  return ast;}; const collectTypeLiteralFields = (ast: AST.AST): Array<EndpointField> => {  const candidate = unwrapAst(ast);  if (candidate._tag !== "TypeLiteral") {    return [];  }   return candidate.propertySignatures.flatMap((signature) =>    typeof signature.name === "string"      ? [          {            name: signature.name,            optional: signature.isOptional,            ast: signature.type,          },        ]      : [],  );}; const isBooleanTrue = (value: string): boolean => {  const normalized = value.trim().toLowerCase();  return normalized === "1" || normalized === "true" || normalized === "yes";}; const coerceByAst = (raw: string, ast: AST.AST): unknown => {  const candidate = unwrapAst(ast);  switch (candidate._tag) {    case "StringKeyword":      return raw;    case "NumberKeyword": {      const numeric = Number(raw);      if (!Number.isFinite(numeric)) {        throw new Error(`Expected number, got: ${raw}`);      }      return numeric;    }    case "BooleanKeyword":      return isBooleanTrue(raw);    case "Literal": {      if (`${candidate.literal}`.toLowerCase() !== raw.toLowerCase()) {        throw new Error(`Expected literal ${String(candidate.literal)}, got ${raw}`);      }      return candidate.literal;    }    case "Union": {      const unionCandidates = candidate.types.filter((type) => type._tag !== "UndefinedKeyword");      for (const unionType of unionCandidates) {        try {          return coerceByAst(raw, unionType);        } catch {          continue;        }      }      return raw;    }    default:      return raw;  }}; const usageForCommand = (command: EndpointCommand): string => {  const flags = [    ...command.pathFields.map((field) => `--${field.name} <value>`),    ...command.payloadFields.map((field) =>      field.optional ? `[--${field.name} <value>]` : `--${field.name} <value>`,    ),    ...command.urlParamFields.map((field) =>      field.optional ? `[--${field.name} <value>]` : `--${field.name} <value>`,    ),    ...(command.hasPayloadSchema ? ["[--payload-json '<json>']"] : []),  ].join(" ");   return `executor ${command.groupName} ${command.endpointCliName}${flags.length > 0 ? ` ${flags}` : ""}`;}; const buildCommandList = (api: HttpApi.HttpApi.Any): Array<EndpointCommand> => {  const commands: Array<EndpointCommand> = [];  HttpApi.reflect(api as HttpApi.HttpApi.AnyWithProps, {    onGroup: () => undefined,    onEndpoint: ({ group, endpoint }) => {      const payloadSchema = Option.getOrNull(endpoint.payloadSchema);      const urlParamsSchema = Option.getOrNull(endpoint.urlParamsSchema);      const pathSchema = Option.getOrNull(endpoint.pathSchema);       commands.push({        groupName: group.identifier,        endpointName: endpoint.name,        endpointCliName: toKebab(endpoint.name),        method: endpoint.method,        path: endpoint.path,        pathFields: pathSchema ? collectTypeLiteralFields(pathSchema.ast) : [],        payloadFields: payloadSchema ? collectTypeLiteralFields(payloadSchema.ast) : [],        urlParamFields: urlParamsSchema ? collectTypeLiteralFields(urlParamsSchema.ast) : [],        hasPayloadSchema: payloadSchema !== null,      });    },  });  return commands;}; const buildRequestObject = async (  command: EndpointCommand,  options: HttpApiCliRunnerOptions,): Promise<Record<string, unknown>> => {  const request: Record<string, unknown> = {};   if (command.pathFields.length > 0) {    const path: Record<string, unknown> = {};    for (const field of command.pathFields) {      const flagValue = options.parsed.flags[field.name];      const explicit = typeof flagValue === "string" ? flagValue : undefined;      const fallback = options.resolveDefaultPathValue        ? await options.resolveDefaultPathValue(field.name)        : undefined;      const chosen = explicit ?? fallback;      if (!chosen) {        throw new Error(          `Missing required path parameter --${field.name}. Usage: ${usageForCommand(command)}`,        );      }      path[field.name] = coerceByAst(chosen, field.ast);    }    request.path = path;  }   if (command.urlParamFields.length > 0) {    const urlParams: Record<string, unknown> = {};    for (const field of command.urlParamFields) {      const value = options.parsed.flags[field.name];      if (typeof value !== "string") {        if (field.optional) {          continue;        }        throw new Error(          `Missing required url parameter --${field.name}. Usage: ${usageForCommand(command)}`,        );      }      urlParams[field.name] = coerceByAst(value, field.ast);    }    request.urlParams = urlParams;  }   if (command.hasPayloadSchema) {    const payloadJsonFlag = options.parsed.flags["payload-json"];    if (typeof payloadJsonFlag === "string") {      try {        request.payload = JSON.parse(payloadJsonFlag) as unknown;      } catch {        throw new Error(`Invalid JSON in --payload-json for ${command.groupName}.${command.endpointName}`);      }      return request;    }     if (command.payloadFields.length > 0) {      const payload: Record<string, unknown> = {};      for (const field of command.payloadFields) {        const value = options.parsed.flags[field.name];        if (typeof value !== "string") {          if (field.optional) {            continue;          }          throw new Error(            `Missing required payload field --${field.name}. Usage: ${usageForCommand(command)}`,          );        }        payload[field.name] = coerceByAst(value, field.ast);      }      request.payload = payload;    }  }   return request;}; export const fromApi = (  api: HttpApi.HttpApi.Any,): HttpApiCli => {  const commands = buildCommandList(api);  const commandByKey = new Map<string, EndpointCommand>();   for (const command of commands) {    commandByKey.set(`${command.groupName}:${command.endpointCliName}`, command);    commandByKey.set(`${command.groupName}:${command.endpointName}`, command);  }   const helpText = [    "HttpApi-derived commands:",    ...commands      .sort((left, right) => {        const groupSort = left.groupName.localeCompare(right.groupName);        if (groupSort !== 0) {          return groupSort;        }        return left.endpointCliName.localeCompare(right.endpointCliName);      })      .map((command) =>        `  ${command.groupName} ${command.endpointCliName}  (${command.method} ${command.path})`,      ),  ].join("\n");   return {    helpText,    commands,    execute: async (input) => {      const command = commandByKey.get(`${input.groupName}:${input.endpointName}`);      if (!command) {        return false;      }       const request = await buildRequestObject(command, {        parsed: {          positional: [input.groupName, input.endpointName],          flags: input.flags,        },        invoke: input.invoke,        print: input.print,        resolveDefaultPathValue: input.resolveDefaultPathValue,      });      const result = await input.invoke({ request, command });      input.print(result);      return true;    },    run: async (options) => {      const [groupName, endpointName] = options.parsed.positional;      if (!groupName || !endpointName) {        return false;      }       const command = commandByKey.get(`${groupName}:${endpointName}`);      if (!command) {        return false;      }       const request = await buildRequestObject(command, options);      const result = await options.invoke({        request,        command,      });      options.print(result);      return true;    },  };};