Skip to content
Merged
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
60 changes: 55 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1054,9 +1054,10 @@ polymorfa api /v1/projects --surface platform

polymorfa api /v1/campaigns \
--surface platform \
--method POST \
--field projectId=project_123 \
--field name='August launch' \
-X POST \
-f projectId=project_123 \
-f name='August launch' \
-F dryRun=true \
--json

polymorfa api /v1/campaigns \
Expand All @@ -1072,8 +1073,57 @@ cat campaign.json | polymorfa api /v1/campaigns \

GET and HEAD fields become query parameters. Other methods encode fields as a
JSON object. `--input` reads one JSON value from a file or standard input and
is capped at 10 MiB. Use `--header name:value` for additional headers and
`--include` to include response metadata in human output.
is capped at 10 MiB. Use `-H/--header name:value` for additional headers and
`-i/--include` to include response metadata in human output. `-X` is short for
`--method`.

### Fields

- `-f/--raw-field name=value` always sends a string.
- `-F/--field name=value` converts `true`, `false`, `null`, and integers to JSON
values. `@path` reads a file and `@-` reads standard input as a string.
Anything else stays a string.
- Both accept `name[key]=value` to build nested objects and `name[]=value` to
append to an array. GET and HEAD query parameters accept values and `name[]`
arrays but not nested objects.

`--field` previously sent every value as a string. Use `-f` where a value such
as `10` or `true` must stay a string.

### Pagination and output filters

```shell
# Every page, one ID per line
polymorfa api /platform/customers --surface platform --paginate --jq '.data[].id'

# All pages as one array, then one jq program over it
polymorfa api /platform/customers --surface platform --paginate --slurp \
--jq 'map(.data | length) | add'

# Go-style template
polymorfa api /platform/customers --surface platform \
--template '{{range .data}}{{.id}}{{"\t"}}{{.name}}{{"\n"}}{{end}}'
```

- `--paginate` (GET only) follows `page.nextCursor` by sending it back as the
`cursor` query parameter until the cursor is null or `hasMore` is false. It
stops with an error after `--max-pages` pages (100 by default) and when the
API returns the same cursor twice. Without a filter, human output prints each
page as it arrives; `--json` prints one document whose `data` is the array of
page bodies and whose `metadata.pages` is the page count.
- `--slurp` requires `--paginate` and combines the page bodies into one array
before `--jq` or `--template` runs. Without `--slurp`, filters run on each page.
- `-q/--jq` runs a jq 1.8 program with an embedded WebAssembly jq, so no `jq`
binary is required. Strings print without quotes. The jq runtime has its own
environment, so `$ENV` does not expose your shell variables or credentials.
- `-t/--template` renders a Go-style template: `{{.path.to.value}}`,
`{{range .list}}…{{else}}…{{end}}`, `{{if .value}}…{{else}}…{{end}}`,
`{{$.root}}` inside a range, `{{json .x}}`, `{{len .x}}`, `{{upper .x}}`,
`{{lower .x}}`, `{{join ", " .list}}`, string literals such as `{{"\n"}}`,
comments, and `{{-`/`-}}` whitespace trimming. Missing values render as an
empty string.
- `--jq`, `--template`, and `--json` are mutually exclusive, as are `--include`
and a filter.

`--json` prints exactly one JSON document on stdout. `--quiet` suppresses
successful output without hiding failures. Diagnostics and errors go to stderr;
Expand Down
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
"devDependencies": {
Expand Down
248 changes: 248 additions & 0 deletions src/api/fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import {readFile, stat} from 'node:fs/promises'

import {PolymorfaValidationError} from '@polymorfa/sdk'

const MAX_FIELD_FILE_BYTES = 10 * 1024 * 1024

export type FieldValue =
string | number | boolean | null | FieldValue[] | {[key: string]: FieldValue}

export interface FieldInput {
/** `raw` for -f (always a string), `typed` for -F. */
readonly kind: 'raw' | 'typed'
readonly value: string
}

export interface FieldReaders {
readonly readFile?: (path: string) => Promise<string>
readonly readStdin?: () => Promise<string>
}

/**
* Parse `gh api`-style fields into one object.
*
* -f/--raw-field keeps the value as a string. -F/--field converts `true`,
* `false`, `null` and integers to JSON, and reads `@file` (or `@-` for stdin)
* as a string. Keys accept `name[sub]` nesting and `name[]` array appends.
*/
export async function parseFields(
fields: readonly FieldInput[],
readers: FieldReaders = {},
): Promise<Record<string, FieldValue> | undefined> {
if (fields.length === 0) return undefined
const root = Object.create(null) as Record<string, FieldValue>
let stdinUsed = false
for (const field of fields) {
const separator = field.value.indexOf('=')
if (separator <= 0) {
throw new PolymorfaValidationError(
'API fields must use the name=value form with a non-empty name.',
{code: 'invalid_field'},
)
}
const key = field.value.slice(0, separator)
const raw = field.value.slice(separator + 1)
let value: FieldValue
if (field.kind === 'raw') {
value = raw
} else if (raw.startsWith('@')) {
if (raw === '@-') {
if (stdinUsed) {
throw new PolymorfaValidationError(
'Only one field can read standard input.',
{code: 'invalid_field'},
)
}
stdinUsed = true
value = await (readers.readStdin ?? readStandardInput)()
} else {
value = await (readers.readFile ?? readFieldFile)(raw.slice(1))
}
} else {
value = typedValue(raw)
}
assign(root, parseKey(key), value, key)
}
return root
}

/** GET and HEAD fields become query parameters, which cannot nest objects. */
export function queryFromFields(
fields: Record<string, FieldValue>,
): Record<string, string | number | boolean | (string | number | boolean)[]> {
const query: Record<
string,
string | number | boolean | (string | number | boolean)[]
> = {}
for (const [key, value] of Object.entries(fields)) {
if (value === null) {
query[key] = 'null'
} else if (Array.isArray(value)) {
query[key] = value.map((item) => {
if (item === null) return 'null'
if (typeof item === 'object') throw nestedQuery(key)
return item
})
} else if (typeof value === 'object') {
throw nestedQuery(key)
} else {
query[key] = value
}
}
return query
}

function nestedQuery(key: string): PolymorfaValidationError {
return new PolymorfaValidationError(
`Field ${key} is nested; GET and HEAD query parameters accept only values and name[] arrays.`,
{code: 'invalid_field'},
)
}

function typedValue(raw: string): FieldValue {
if (raw === 'true') return true
if (raw === 'false') return false
if (raw === 'null') return null
if (/^-?(0|[1-9]\d*)$/.test(raw)) {
const parsed = Number(raw)
if (Number.isSafeInteger(parsed)) return parsed
}
return raw
}

type KeySegment = string | typeof APPEND
const APPEND = Symbol('append')

const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype'])

function parseKey(key: string): KeySegment[] {
const match = /^([^[\]]+)((?:\[[^[\]]*\])*)$/.exec(key)
if (match === null) {
throw new PolymorfaValidationError(
`Invalid field name ${key}. Use name, name[sub], or name[].`,
{code: 'invalid_field'},
)
}
const segments: KeySegment[] = [match[1] as string]
for (const [, part] of (match[2] ?? '').matchAll(/\[([^[\]]*)\]/g)) {
segments.push(part === '' ? APPEND : (part as string))
}
if (
segments.some(
(segment) => typeof segment === 'string' && FORBIDDEN_KEYS.has(segment),
)
) {
throw new PolymorfaValidationError(
`Invalid field name ${key}. __proto__, constructor, and prototype are not allowed.`,
{code: 'invalid_field'},
)
}
for (let index = 0; index < segments.length - 1; index++) {
if (segments[index] === APPEND) {
throw new PolymorfaValidationError(
`Invalid field name ${key}. [] may appear only at the end.`,
{code: 'invalid_field'},
)
}
}
return segments
}

function assign(
root: Record<string, FieldValue>,
segments: readonly KeySegment[],
value: FieldValue,
key: string,
): void {
let target: Record<string, FieldValue> = root
for (let index = 0; index < segments.length; index++) {
const segment = segments[index] as KeySegment
const last = index === segments.length - 1
const next = segments[index + 1]
if (segment === APPEND) return // handled by the previous segment
if (last) {
const existing = target[segment]
if (
existing !== undefined &&
existing !== null &&
typeof existing === 'object'
) {
throw conflict(key)
}
target[segment] = value
return
}
if (next === APPEND) {
const existing = target[segment]
if (existing === undefined) target[segment] = [value]
else if (Array.isArray(existing)) existing.push(value)
else throw conflict(key)
return
}
const existing = target[segment]
if (existing === undefined) {
const child = Object.create(null) as Record<string, FieldValue>
target[segment] = child
target = child
} else if (
typeof existing === 'object' &&
existing !== null &&
!Array.isArray(existing)
) {
target = existing
} else {
throw conflict(key)
}
}
}

function conflict(key: string): PolymorfaValidationError {
return new PolymorfaValidationError(
`Field ${key} conflicts with another field of a different shape.`,
{code: 'invalid_field'},
)
}

async function readFieldFile(path: string): Promise<string> {
try {
// Check the size before reading so an oversized file is rejected instead
// of being buffered in full. The post-read check still covers a race.
const {size} = await stat(path)
if (size > MAX_FIELD_FILE_BYTES) {
throw new PolymorfaValidationError(
'Field files must not exceed 10 MiB.',
{code: 'input_too_large'},
)
}
const contents = await readFile(path)
if (contents.byteLength > MAX_FIELD_FILE_BYTES) {
throw new PolymorfaValidationError(
'Field files must not exceed 10 MiB.',
{code: 'input_too_large'},
)
}
return contents.toString('utf8')
} catch (error) {
if (error instanceof PolymorfaValidationError) throw error
throw new PolymorfaValidationError(`Could not read field file: ${path}`, {
code: 'input_read_failed',
cause: error,
})
}
}

async function readStandardInput(): Promise<string> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of process.stdin as AsyncIterable<Uint8Array>) {
total += chunk.byteLength
if (total > MAX_FIELD_FILE_BYTES) {
throw new PolymorfaValidationError(
'Field input must not exceed 10 MiB.',
{code: 'input_too_large'},
)
}
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf8')
}
25 changes: 25 additions & 0 deletions src/api/jq.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {PolymorfaValidationError} from '@polymorfa/sdk'

/**
* Run a jq filter with the embedded WebAssembly jq (no native binary).
* Strings print raw, like `gh api --jq`; other values print as JSON.
* The WebAssembly runtime has its own environment, so `$ENV` never exposes
* host credentials.
*/
export async function applyJq(value: unknown, filter: string): Promise<string> {
const {raw} = await import('jq-wasm')
// jq-wasm parses a string argument as JSON *text*, so a decoded JSON string
// body would be re-parsed and fail. Serialize first so every value arrives
// as the JSON it stands for.
const result = await raw(JSON.stringify(value) ?? 'null', filter, ['-r'])
if (result.exitCode !== 0) {
const detail = result.stderr
.trim()
.replace(/^jq: error(?: \(at [^)]*\))?: /, '')
throw new PolymorfaValidationError(
`Invalid --jq filter: ${detail || `jq exited with ${result.exitCode}`}`,
{code: 'invalid_jq'},
)
}
return result.stdout
}
Loading
Loading