Repository navigation
feat(api): search, grep and call any public Base44 API from the spec #663
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import type { Command } from "commander"; | ||
| import type { CLIContext, RunCommandResult } from "@/cli/types.js"; | ||
| import { Base44Command } from "@/cli/utils/index.js"; | ||
| import { | ||
| areaListing, | ||
| catalogIndex, | ||
| listOperations, | ||
| } from "@/core/api-spec/index.js"; | ||
| import { | ||
| formatJson, | ||
| loadSpecTask, | ||
| operationsInArea, | ||
| READ_ONLY_FILTER_HELP, | ||
| REFRESH_OPTION_HELP, | ||
| } from "./shared.js"; | ||
|
|
||
| async function catalogAction( | ||
| ctx: CLIContext, | ||
| area: string | undefined, | ||
| options: { readOnly?: boolean; refresh?: boolean }, | ||
| ): Promise<RunCommandResult> { | ||
| const { spec } = await loadSpecTask(ctx, options.refresh); | ||
| const operations = listOperations(spec); | ||
| const readOnly = (ops: typeof operations) => | ||
| options.readOnly ? ops.filter((op) => op.readOnly) : ops; | ||
| if (area === undefined) { | ||
| return { stdout: formatJson(catalogIndex(readOnly(operations))) }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a fan of the naming but not critical |
||
| } | ||
| // Look the area up before filtering, so an area with no reads is empty, not unknown. | ||
| const inArea = readOnly(operationsInArea(operations, area)); | ||
| return { stdout: formatJson(areaListing(area, inArea)) }; | ||
| } | ||
|
|
||
| export function getApiCatalogCommand(): Command { | ||
| return new Base44Command("catalog", { | ||
| requireAuth: false, | ||
| requireAppContext: false, | ||
| }) | ||
| .description("List the API's areas, or every operation in one area") | ||
| .argument("[area]", 'An area from the list, e.g. "workflows"') | ||
| .option("--read-only", READ_ONLY_FILTER_HELP) | ||
| .option("--refresh", REFRESH_OPTION_HELP) | ||
| .action(catalogAction); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| import type { Command } from "commander"; | ||
| import type { CLIContext, RunCommandResult } from "@/cli/types.js"; | ||
| import { Base44Command, ensureAuth } from "@/cli/utils/index.js"; | ||
| import { | ||
| type ApiResponse, | ||
| getOperationContract, | ||
| listOperations, | ||
| matchOperation, | ||
| normalizePath, | ||
| sendApiRequest, | ||
| } from "@/core/api-spec/index.js"; | ||
| import { InvalidInputError } from "@/core/errors.js"; | ||
| import { writeFile } from "@/core/utils/fs.js"; | ||
| import { | ||
| formatJson, | ||
| loadSpecTask, | ||
| REFRESH_OPTION_HELP, | ||
| readFormBody, | ||
| readRequestBody, | ||
| reportedFetchCommand, | ||
| withoutQuery, | ||
| } from "./shared.js"; | ||
|
|
||
| interface FetchOptions { | ||
| body?: string; | ||
| form: string[]; | ||
| output?: string; | ||
| readOnly?: boolean; | ||
| refresh?: boolean; | ||
| help?: boolean; | ||
| } | ||
|
|
||
| async function findOperation( | ||
| ctx: CLIContext, | ||
| method: string, | ||
| path: string, | ||
| { refresh, flag }: { refresh?: boolean; flag: "--help" | "--read-only" }, | ||
| ) { | ||
| const { spec } = await loadSpecTask(ctx, refresh); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I must say that the term spec is a little confusing to me. Is it the standard? In wix it's called schema afaik |
||
| const op = matchOperation(listOperations(spec), method, path); | ||
| if (!op) { | ||
| throw new InvalidInputError( | ||
| `No operation in the API spec matches ${method} ${withoutQuery(path)}.`, | ||
| { | ||
| hints: [ | ||
| { | ||
| message: "The cached spec may predate this route; fetch it again", | ||
| command: `base44 api fetch ${method} ${path} ${flag} --refresh`, | ||
| }, | ||
| { | ||
| message: "Find the operation and its path template", | ||
| command: 'base44 api search "<what you want to do>"', | ||
| }, | ||
| ], | ||
| }, | ||
| ); | ||
| } | ||
| return { spec, op }; | ||
| } | ||
|
|
||
| const MULTIPART_HINT = | ||
| "Send this body with --form name=value, and --form name=@path for files."; | ||
|
|
||
| const CONTRACT_HELP = | ||
| "\nThe contract is JSON; cut it down with e.g. | jq '.parameters, .requestBodySchema'"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The \n shouldn't be here but at the concatenation |
||
|
|
||
| const TEXT_CONTENT_TYPES = [ | ||
| "json", | ||
| "text/", | ||
| "xml", | ||
| "javascript", | ||
| "x-www-form-urlencoded", | ||
| ]; | ||
|
|
||
| /** | ||
| * The body as text, or undefined when it is binary: a declared non-text type, | ||
| * or bytes that aren't valid UTF-8 (a body without a content type, say). | ||
| */ | ||
| function decodeText(body: Uint8Array, contentType: string): string | undefined { | ||
| const type = contentType.toLowerCase(); | ||
| if (type && !TEXT_CONTENT_TYPES.some((hint) => type.includes(hint))) { | ||
| return undefined; | ||
| } | ||
| try { | ||
| return new TextDecoder("utf-8", { fatal: true }).decode(body); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The body as the server sent it. JSON is pretty-printed only in a terminal: | ||
| * re-serializing would round integers above 2^53. Under --json a non-JSON | ||
| * text body is wrapped; an empty body prints its status; a binary one is | ||
| * described, and saved only with --output. | ||
| */ | ||
| async function formatResponse( | ||
| { status, contentType, body }: ApiResponse, | ||
| output: string | undefined, | ||
| jsonMode: boolean, | ||
| ): Promise<string> { | ||
| if (output !== undefined) { | ||
| await writeFile(output, body); | ||
| return formatJson({ status, contentType, bytes: body.length, output }); | ||
| } | ||
| if (body.length === 0) { | ||
| return formatJson({ status }); | ||
| } | ||
| const text = decodeText(body, contentType); | ||
| if (text === undefined) { | ||
| return formatJson({ | ||
| status, | ||
| contentType, | ||
| bytes: body.length, | ||
| note: "Binary body not printed; add --output <file> to save it.", | ||
| }); | ||
| } | ||
| const asIs = text.endsWith("\n") ? text : `${text}\n`; | ||
| try { | ||
| const parsed: unknown = JSON.parse(text); | ||
| return process.stdout.isTTY ? formatJson(parsed) : asIs; | ||
| } catch { | ||
| return jsonMode ? formatJson({ status, contentType, body: text }) : asIs; | ||
| } | ||
| } | ||
|
|
||
| async function fetchAction( | ||
| ctx: CLIContext, | ||
| methodArg: string | undefined, | ||
| path: string | undefined, | ||
| options: FetchOptions, | ||
| command: Command, | ||
| ): Promise<RunCommandResult> { | ||
| // First: the CLI recorded the raw arguments and --body for error reporting, | ||
| // and any endpoint's body or query can carry secrets. | ||
| ctx.errorReporter.setContext({ | ||
| command: reportedFetchCommand(methodArg ?? "", path ?? "", { ...options }), | ||
| }); | ||
| if (!methodArg || !path) { | ||
| if (options.help) { | ||
| // helpInformation() leaves out addHelpText, so add the line here too. | ||
| const usage = `${command.helpInformation()}${CONTRACT_HELP}\n`; | ||
| return { stdout: ctx.jsonMode ? formatJson({ usage }) : usage }; | ||
| } | ||
| throw new InvalidInputError( | ||
| "Provide a method and a path, e.g. GET /api/apps.", | ||
| ); | ||
| } | ||
| const method = methodArg.toUpperCase(); | ||
| const target = normalizePath(path); | ||
|
|
||
| if (options.help) { | ||
| const { spec, op } = await findOperation(ctx, method, target, { | ||
| refresh: options.refresh, | ||
| flag: "--help", | ||
| }); | ||
| const contract = getOperationContract(spec, op); | ||
| return { | ||
| stdout: formatJson( | ||
| contract.requestBodyContentType === "multipart/form-data" | ||
| ? { ...contract, hint: MULTIPART_HINT } | ||
| : contract, | ||
| ), | ||
| }; | ||
| } | ||
|
|
||
| // --help and the --read-only refusal send nothing, so they need no login. | ||
| if (options.readOnly) { | ||
| const { op } = await findOperation(ctx, method, target, { | ||
| refresh: options.refresh, | ||
| flag: "--read-only", | ||
| }); | ||
| if (!op.readOnly) { | ||
| throw new InvalidInputError( | ||
| `Refused under --read-only: ${op.method} ${op.path} is not a read-only operation. Nothing was sent.`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| if (options.body !== undefined && options.form.length > 0) { | ||
| throw new InvalidInputError("Use --body or --form, not both."); | ||
| } | ||
| await ensureAuth(ctx); | ||
| const body = | ||
| options.form.length > 0 | ||
| ? await readFormBody(options.form) | ||
| : await readRequestBody(options.body, ctx.log); | ||
| const response = await ctx.runTask( | ||
| `Calling ${method} ${target}`, | ||
| () => sendApiRequest(method, target, body), | ||
| { | ||
| successMessage: `Called ${method} ${target}`, | ||
| errorMessage: "Request failed", | ||
| }, | ||
| ); | ||
| return { | ||
| stdout: await formatResponse(response, options.output, ctx.jsonMode), | ||
| }; | ||
| } | ||
|
|
||
| export function getApiFetchCommand(): Command { | ||
| return new Base44Command("fetch", { | ||
| requireAuth: false, | ||
| requireAppContext: false, | ||
| }) | ||
| .description( | ||
| "Call a Base44 API operation; add --help after a path for its contract", | ||
| ) | ||
| .helpOption(false) | ||
| .argument("[method]", "HTTP method, e.g. GET or POST") | ||
| .argument( | ||
| "[path]", | ||
| 'Concrete path with any query string, e.g. "/api/apps/<app-id>/workflows/runs?status=failed"', | ||
| ) | ||
| .option( | ||
| "--body <json>", | ||
| "Request body: JSON, @file.json, or @- to read it from stdin", | ||
| ) | ||
| .option( | ||
| "--form <field>", | ||
| "Multipart field: name=value, or name=@file to attach a file (repeatable)", | ||
| (field: string, fields: string[]) => [...fields, field], | ||
| [], | ||
| ) | ||
| .option("--output <file>", "Save the response body to a file") | ||
| .option( | ||
| "--read-only", | ||
| "Refuse, before sending, any operation the spec does not mark read-only", | ||
| ) | ||
| .option("--refresh", REFRESH_OPTION_HELP) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I would rename it to be more explicit like --invalidate-cache |
||
| .option( | ||
| "-h, --help", | ||
| "With a method and path, print that operation's contract; otherwise this help", | ||
| ) | ||
| .addHelpText("after", CONTRACT_HELP) | ||
| .action(fetchAction); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ouch, many undefined checks around the code