Ctrl K
ComunidadGuías…

cli/commands.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type * as v from "valibot";
import {
  type AdminApiChild,
  type AdminApiResource,
  DeleteBodySchema,
} from "#shared/admin-api-resources.ts";
import { CliError } from "./errors.ts";

/** The resource table the parser and describe read. The CLI passes the
 *  production table; tests pass their own, so a new table row reaches the
 *  commands with no CLI change. */
export type Table = Record<string, AdminApiResource>;

/** The standard verbs every resource answers with. */
export const STANDARD_VERBS = [
  "create",
  "delete",
  "describe",
  "get",
  "list",
  "update",
] as const;

export type StandardVerb = (typeof STANDARD_VERBS)[number];

/** One child row a write addresses, resolved off the table. */
export type ChildAddress = {
  def: AdminApiChild;
  /** Absent on a create: the child collection takes the new row. */
  id: number | undefined;
  key: string;
};

export type ParsedCommand =
  | { kind: "describe"; resources: readonly [string, AdminApiResource][] }
  | { entry: AdminApiResource; kind: "list"; resource: string }
  | { entry: AdminApiResource; id: number; kind: "read"; resource: string }
  | {
      entry: AdminApiResource;
      id: number;
      kind: "custom";
      resource: string;
      routeKey: string;
    }
  | {
      body: string | undefined;
      child: ChildAddress | undefined;
      entry: AdminApiResource;
      kind: "write";
      parentId: number;
      resource: string;
      verb: "create" | "delete" | "update";
    };

/** Whether a resource serves a standard verb. A `serves` declaration lists
 *  the routes the server answers; absent means all five. A verb the resource
 *  does not serve is a usage error, never a request the server 404s. */
const servesVerb = (
  entry: AdminApiResource,
  verb: Exclude<StandardVerb, "describe">,
): boolean => entry.serves === undefined || entry.serves[verb] === true;

/** The usage error for a verb a resource does not serve. */
const verbUnservedError = (verb: string, resource: string): CliError =>
  new CliError(
    "usage",
    `The '${verb}' command is not available for '${resource}'.`,
  );

const unknownResource = (raw: string, table: Table): CliError =>
  new CliError(
    "usage",
    `Unknown resource '${raw}'. The valid resources are: ${Object.keys(
      table,
    ).join(", ")}.`,
  );

/** Read one own property off a table record, or undefined for a name the
 *  table never declared. Inherited properties do not count, so
 *  'constructor' names no resource and no route. */
const ownEntry = <T>(record: Record<string, T>, key: string): T | undefined =>
  Object.hasOwn(record, key) ? record[key] : undefined;

/** The table entry one resource name resolves to; an unknown name is a usage
 *  error. */
const requireEntry = (table: Table, raw: string): AdminApiResource => {
  const entry = ownEntry(table, raw);
  if (!entry) throw unknownResource(raw, table);
  return entry;
};

/** One whole-number id segment. */
const parseId = (raw: string, what: string): number => {
  if (!/^\d+$/.test(raw)) {
    throw new CliError(
      "usage",
      `The ${what} must be a whole number, got '${raw}'.`,
    );
  }
  return Number(raw);
};

const tooManyArguments = (): CliError =>
  new CliError("usage", "Too many arguments.");

const requireBody = (raw: string | undefined, verb: string): string => {
  if (raw === undefined) {
    throw new CliError(
      "usage",
      `The '${verb}' command needs a JSON body argument, or '-' to read it from stdin.`,
    );
  }
  return raw;
};

/** The usage error for a write whose id argument is missing. */
const missingWriteIdError = (
  verb: "create" | "delete" | "update",
  entry: AdminApiResource,
): CliError => {
  if (verb === "create") {
    return new CliError(
      "usage",
      "The 'create' command needs a JSON body argument, or '-' to read it from stdin.",
    );
  }
  return new CliError(
    "usage",
    `The '${verb}' command needs the ${entry.label.toLowerCase()} id.`,
  );
};

/** One child-addressing failure: the path segments did not fit the child
 *  shapes the table declares. */
const badAddress = (raw: string): CliError =>
  new CliError(
    "usage",
    `The id argument '${raw}' does not fit. Use '<id>' or '<parentId>/<child>/<childId>'.`,
  );

/** One child write address: `parentId/childKey` for a create, and
 *  `parentId/childKey/childId` for an update or a delete. */
const CHILD_ADDRESS = /^(\d+)\/([^/]+)(?:\/(\d+))?$/;

/** Resolve one child address against the table. */
const parseChildWrite = (
  resource: string,
  entry: AdminApiResource,
  raw: string,
): { child: ChildAddress; parentId: number } => {
  const match = CHILD_ADDRESS.exec(raw);
  if (!match) throw badAddress(raw);
  // The address regex guarantees the parent id and the child key groups.
  const parentIdText = match[1] as string;
  const key = match[2] as string;
  const children = entry.children;
  const def = children === undefined ? undefined : ownEntry(children, key);
  if (!def) {
    const names = Object.keys(children ?? {}).join(", ") || "none";
    throw new CliError(
      "usage",
      `The resource '${resource}' has no child '${key}'. Its children are: ${names}.`,
    );
  }
  const childIdText = match[3];
  return {
    child: {
      def,
      id:
        childIdText === undefined
          ? undefined
          : parseId(childIdText, "child id"),
      key,
    },
    parentId: parseId(parentIdText, `${entry.label.toLowerCase()} id`),
  };
};

/** Resolve the resource name one command names, off the table. */
const resolveResource = (
  table: Table,
  resourceRaw: string | undefined,
  verb: string,
): { entry: AdminApiResource; name: string } => {
  if (resourceRaw === undefined) {
    throw new CliError(
      "usage",
      `The '${verb}' command needs a resource. The valid resources are: ${Object.keys(
        table,
      ).join(", ")}.`,
    );
  }
  const entry = requireEntry(table, resourceRaw);
  return { entry, name: resourceRaw };
};

/** Parse list: `list <resource>`. */
const parseList = (table: Table, args: readonly string[]): ParsedCommand => {
  const [, resourceRaw, extra] = args;
  if (extra !== undefined) throw tooManyArguments();
  const { entry, name } = resolveResource(table, resourceRaw, "list");
  if (!servesVerb(entry, "list")) throw verbUnservedError("list", name);
  return { entry, kind: "list", resource: name };
};

/** Parse get: `get <resource> <id>`. */
const parseRead = (table: Table, args: readonly string[]): ParsedCommand => {
  const [, resourceRaw, idRaw, extra, ...rest] = args;
  if (extra !== undefined || rest.length > 0) throw tooManyArguments();
  const { entry, name } = resolveResource(table, resourceRaw, "get");
  if (!servesVerb(entry, "get")) throw verbUnservedError("get", name);
  if (idRaw === undefined) {
    throw new CliError(
      "usage",
      `The 'get' command needs the ${entry.label.toLowerCase()} id.`,
    );
  }
  return {
    entry,
    id: parseId(idRaw, `${entry.label.toLowerCase()} id`),
    kind: "read",
    resource: name,
  };
};

/** The write body: create and update require one; a delete may omit it. */
const writeBody = (
  verb: "create" | "delete" | "update",
  bodyRaw: string | undefined,
): string | undefined => {
  if (verb === "delete") return bodyRaw;
  return requireBody(bodyRaw, verb);
};

/** One child address checked against its verb: a create addresses the
 *  collection, an update or a delete the one row. */
const checkChildVerbAddress = (
  verb: "create" | "delete" | "update",
  child: ChildAddress,
  raw: string,
): void => {
  if (verb === "create" && child.id !== undefined) throw badAddress(raw);
  if (verb !== "create" && child.id === undefined) {
    // The admin api serves a child row's PUT and DELETE only with the
    // child id in the path; the collection answers no such method.
    throw new CliError(
      "usage",
      `The '${verb}' command needs the child id. Use '<parentId>/<child>/<childId>'.`,
    );
  }
};

/** Parse create, update, and delete over one resolved resource. */
const parseWrite = (
  resource: string,
  entry: AdminApiResource,
  verb: "create" | "delete" | "update",
  args: readonly string[],
): ParsedCommand => {
  if (!servesVerb(entry, verb)) throw verbUnservedError(verb, resource);
  const [, , idRaw, bodyRaw, extra, ...rest] = args;
  if (extra !== undefined || rest.length > 0) throw tooManyArguments();
  if (idRaw === undefined) throw missingWriteIdError(verb, entry);
  // A child address starts with the parent id: "3/options". A JSON body
  // never starts with digits and a slash.
  const childForm = /^\d+\//.test(idRaw);
  if (verb === "create" && !childForm) {
    if (bodyRaw !== undefined) throw tooManyArguments();
    // A create's first argument after the resource is the body itself.
    return {
      body: requireBody(idRaw, "create"),
      child: undefined,
      entry,
      kind: "write",
      parentId: 0,
      resource,
      verb,
    };
  }
  if (childForm) {
    const { child, parentId } = parseChildWrite(resource, entry, idRaw);
    checkChildVerbAddress(verb, child, idRaw);
    return {
      body: writeBody(verb, bodyRaw),
      child,
      entry,
      kind: "write",
      parentId,
      resource,
      verb,
    };
  }
  return {
    body: writeBody(verb, bodyRaw),
    child: undefined,
    entry,
    kind: "write",
    parentId: parseId(idRaw, `${entry.label.toLowerCase()} id`),
    resource,
    verb,
  };
};

/** Parse one custom-route command: `<routeKey> <resource> <id>`. */
const parseCustom = (
  table: Table,
  routeKey: string,
  args: readonly string[],
): ParsedCommand => {
  const [, resourceRaw, idRaw, extra, ...rest] = args;
  if (
    !Object.values(table).some(
      (item) =>
        item.custom !== undefined && Object.hasOwn(item.custom, routeKey),
    )
  ) {
    throw new CliError(
      "usage",
      `Unknown command '${routeKey}'. The valid commands are: ${STANDARD_VERBS.join(
        ", ",
      )}, and every custom route 'describe' names.`,
    );
  }
  if (extra !== undefined || rest.length > 0) throw tooManyArguments();
  if (resourceRaw === undefined || idRaw === undefined) {
    throw new CliError(
      "usage",
      `The '${routeKey}' command needs the resource and the id. Usage: ${routeKey} <resource> <id>.`,
    );
  }
  const entry = requireEntry(table, resourceRaw);
  if (entry.custom === undefined || !Object.hasOwn(entry.custom, routeKey)) {
    const routeNames = Object.keys(entry.custom ?? {});
    const names = routeNames.length > 0 ? routeNames.join(", ") : "none";
    throw new CliError(
      "usage",
      `The command '${routeKey}' is not a route of '${resourceRaw}'. The routes of '${resourceRaw}' are: ${names}.`,
    );
  }
  return {
    entry,
    id: parseId(idRaw, `${entry.label.toLowerCase()} id`),
    kind: "custom",
    resource: resourceRaw,
    routeKey,
  };
};

/** Parse describe: `describe` for every resource, or `describe <resource>`. */
const parseDescribe = (
  table: Table,
  args: readonly string[],
): ParsedCommand => {
  const [, resourceRaw, extra, ...rest] = args;
  if (extra !== undefined || rest.length > 0) throw tooManyArguments();
  if (resourceRaw === undefined) {
    return { kind: "describe", resources: Object.entries(table) };
  }
  const entry = requireEntry(table, resourceRaw);
  return { kind: "describe", resources: [[resourceRaw, entry]] };
};

/** Parse the CLI's arguments into one command, or throw the usage error that
 *  names the valid choices. Everything resolves from the table: a resource,
 *  child, or custom route the table adds needs no parser change. */
export const parseCommand = (
  table: Table,
  args: readonly string[],
): ParsedCommand => {
  const [head] = args;
  if (head === undefined) {
    throw new CliError(
      "usage",
      "The CLI needs a command. The valid commands are: describe, list, get, create, update, delete, and every custom route 'describe' names.",
    );
  }
  if (head === "describe") return parseDescribe(table, args);
  if (head === "list") return parseList(table, args);
  if (head === "get") return parseRead(table, args);
  if (head === "create" || head === "update" || head === "delete") {
    const { entry, name } = resolveResource(table, args[1], head);
    return parseWrite(name, entry, head, args);
  }
  return parseCustom(table, head, args);
};

/** The body schema one write validates against before it sends. Absent: the
 *  server owns the validation and the CLI sends the parsed JSON as it is. */
export const bodySchemaFor = (
  entry: AdminApiResource,
  command: Extract<ParsedCommand, { kind: "write" }>,
): v.GenericSchema | undefined => {
  if (command.verb === "delete") return DeleteBodySchema;
  if (command.child) return command.child.def.bodySchema;
  return command.verb === "create"
    ? entry.createBodySchema
    : entry.updateBodySchema;
};