Implement the RPC client and error decoding in src/client.ts and src/errors.ts with unit tests #188

Closed
opened 2026-09-14 23:08:38 +00:00 by david · 1 comment
Owner

Summary

Implement the Penpot RPC transport in extensions/penpot/src/client.ts and error decoding in extensions/penpot/src/errors.ts: authenticated command calls against <PENPOT_URL>/api/rpc/command/<command>, with readable errors for both JSON validation failures and opaque Transit-encoded bodies. Unit-tested with node --test against a stubbed fetch.

Background

Depends on: #187

Penpot's RPC API is a command dispatch endpoint: POST <base>/api/rpc/command/<command> with header Authorization: Token <token> (not Bearer), and GET for read-only commands. The API is internal and unversioned, so the client must be tolerant: it should surface the server's own error payload rather than inventing messages.

Confirmed behaviour on Penpot 2.17 (validated against a live instance):

  • With Accept: application/json, a validation failure returns readable JSON: {"type":"validation","code":"params-validation","explain":"…"} where explain is a precise Malli validation path. Surface explain verbatim rather than paraphrasing it — it names the exact field that was wrong.
  • The binfile import/export endpoints speak Transit for both payloads and errors even when the request and Accept are JSON. This produces an opaque string that must never be shown raw. A full minimal Transit reader is built in the binfile milestone; in this step it is enough to (a) detect a Transit-looking body and (b) decode the small subset needed to make errors readable, or otherwise wrap it in a clear "unreadable error payload" message that includes the raw bytes.
  • A missing/invalid token yields a non-2xx response; the message must be human-readable, not a raw dump.

This client is the single transport used by every read and write tool in later milestones.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/penpot-api/

docs/reference/transit-format/

  • https://github.com/cognitect/transit-format — the Transit format specification: why JSON-with-tags ({"~#uri": …}, "~u<uuid>", "~:keyword") is produced, and what a decoder must handle. Needed to recognise and (partially) decode Transit error bodies here and fully in the binfile milestone.

docs/reference/nodejs/

Implementation Details

extensions/penpot/src/errors.ts

  • Define a PenpotError (or Result-based) shape with a small, closed set of categories, e.g. config | auth | validation | not-found | transport | server | unexpected — mirroring the categorization style of extensions/forgejo/src/errors.ts and extensions/mongodb/src/errors.ts (same idea, no shared import).
  • formatPenpotError(...): category prefix plus a message that always includes the command name, the HTTP status when there was one, and — for validation — the server's explain verbatim.
  • decodeErrorBody(status, contentType, rawText): { category, message }:
    • Parses JSON when the body looks like JSON; reads type, code, explain, message fields when present.
    • Detects Transit-shaped bodies (leading ["^ , ~#, ~:, or a ~-tagged map) and either decodes the tags needed to extract a readable message or returns an "unreadable Transit error payload" message that includes the raw body — never render raw Transit as if it were prose.
    • Falls back to a truncated raw body when nothing else matches (cap the excerpt, e.g. 500 chars, so a giant HTML error page cannot flood context).
  • Encode the known non-obvious statuses: 401/403 → auth/permission guidance (mention that the token needs edit rights for writes), 404 → hint that the command or file id is wrong and that PENPOT_URL must not carry an /api suffix, 400 with validation → the server's explain.

extensions/penpot/src/client.ts

  • penpotRequest(command, options) where options carries method (GET default for reads, POST for writes), an optional JSON body, and optional query params.
  • Builds the URL from resolveConfig() (import from ./env.ts), appending /api/rpc/command/<command>; never double-appends /api.
  • Sends Authorization: Token <token>, Accept: application/json, and Content-Type: application/json when there is a body.
  • Cancels/aborts on a caller-supplied AbortSignal and on a bounded timeout; maps network failures to the transport category with an actionable message (unreachable host, TLS error, timeout).
  • Returns parsed JSON on success (empty body → undefined), and a categorised error result on failure. Do not throw for expected API failures — callers decide how to present them.
  • Keeps the raw Response/status available where a caller needs it (the commit path inspects revn-conflict specifically).
  • No logging of the token. Never interpolate the token into an error message.

Write co-located tests next to the module (keep the same test location convention as src/env.ts), stubbing globalThis.fetch — the repo's extensions/postgres/extensions/mongodb tests show the fake-client style. Tests must not require network access or credentials.

Acceptance Criteria

  • penpotRequest targets <base>/api/rpc/command/<command> and sends Authorization: Token <token> (asserted on the captured request).
  • A base URL already ending in /api is not double-appended.
  • A JSON validation error with an explain field is surfaced with the explain text verbatim and a validation category.
  • A Transit-encoded error body produces a readable message that includes the raw payload excerpt, and never renders raw Transit as prose.
  • 401/403 produce auth/permission guidance mentioning edit rights for writes; 404 mentions the /api-suffix and id/command causes.
  • Network failure/timeout produces a transport error naming the host, not an unhandled exception.
  • The token never appears in any error message or returned error object.
  • Unit tests for the client and error decoding are green with no network access.

Test Plan

# no credentials or network required — fetch is stubbed in the tests
node --test extensions/penpot/src/client.test.ts extensions/penpot/src/errors.test.ts

Manual smoke check against a real instance (requires PENPOT_URL/PENPOT_TOKEN):

PENPOT_URL="$PENPOT_URL" PENPOT_TOKEN="$PENPOT_TOKEN" node --input-type=module -e "
import { penpotRequest } from './extensions/penpot/src/client.ts';
console.log(await penpotRequest('get-profile'));
"

Then repeat with a deliberately invalid token and confirm the printed error is human-readable and contains no raw Transit dump.

## Summary Implement the Penpot RPC transport in `extensions/penpot/src/client.ts` and error decoding in `extensions/penpot/src/errors.ts`: authenticated command calls against `<PENPOT_URL>/api/rpc/command/<command>`, with readable errors for both JSON validation failures and opaque Transit-encoded bodies. Unit-tested with `node --test` against a stubbed `fetch`. ## Background **Depends on:** #187 Penpot's RPC API is a command dispatch endpoint: `POST <base>/api/rpc/command/<command>` with header `Authorization: Token <token>` (**not** `Bearer`), and `GET` for read-only commands. The API is internal and unversioned, so the client must be tolerant: it should surface the server's own error payload rather than inventing messages. Confirmed behaviour on Penpot 2.17 (validated against a live instance): - With `Accept: application/json`, a validation failure returns readable JSON: `{"type":"validation","code":"params-validation","explain":"…"}` where `explain` is a precise Malli validation path. **Surface `explain` verbatim** rather than paraphrasing it — it names the exact field that was wrong. - The `binfile` import/export endpoints speak **Transit** for both payloads and errors even when the request and `Accept` are JSON. This produces an opaque string that must never be shown raw. A full minimal Transit *reader* is built in the binfile milestone; in this step it is enough to (a) detect a Transit-looking body and (b) decode the small subset needed to make errors readable, or otherwise wrap it in a clear "unreadable error payload" message that includes the raw bytes. - A missing/invalid token yields a non-2xx response; the message must be human-readable, not a raw dump. This client is the single transport used by every read and write tool in later milestones. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/penpot-api/`** - https://help.penpot.app/technical-guide/integration/ — access-token auth, the `Authorization: Token <token>` header, and the `/api/rpc/command/<command>` URL shape (the canonical `get-profile` curl example). - `<PENPOT_URL>/api/main/doc/openapi.json` — the instance's OpenAPI 3.0 spec: authoritative for which commands exist, whether they are `GET` or `POST`, and their parameter shapes. - https://help.penpot.app/technical-guide/developer/subsystems/authentication/ — background on how Penpot authenticates requests; useful context for interpreting 401/403 responses. **`docs/reference/transit-format/`** - https://github.com/cognitect/transit-format — the Transit format specification: why JSON-with-tags (`{"~#uri": …}`, `"~u<uuid>"`, `"~:keyword"`) is produced, and what a decoder must handle. Needed to recognise and (partially) decode Transit error bodies here and fully in the binfile milestone. **`docs/reference/nodejs/`** - https://nodejs.org/api/globals.html#fetch — the global `fetch` used for requests, and `Response.ok`/`status`/`text()`. - https://nodejs.org/api/errors.html — `AbortSignal`/timeout semantics for socket-level failures. ## Implementation Details ### `extensions/penpot/src/errors.ts` - Define a `PenpotError` (or `Result`-based) shape with a small, closed set of categories, e.g. `config | auth | validation | not-found | transport | server | unexpected` — mirroring the categorization style of `extensions/forgejo/src/errors.ts` and `extensions/mongodb/src/errors.ts` (same idea, no shared import). - `formatPenpotError(...)`: category prefix plus a message that always includes the command name, the HTTP status when there was one, and — for `validation` — the server's `explain` **verbatim**. - `decodeErrorBody(status, contentType, rawText): { category, message }`: - Parses JSON when the body looks like JSON; reads `type`, `code`, `explain`, `message` fields when present. - Detects Transit-shaped bodies (leading `["^ `, `~#`, `~:`, or a `~`-tagged map) and either decodes the tags needed to extract a readable message or returns an "unreadable Transit error payload" message that includes the raw body — never render raw Transit as if it were prose. - Falls back to a truncated raw body when nothing else matches (cap the excerpt, e.g. 500 chars, so a giant HTML error page cannot flood context). - Encode the known non-obvious statuses: `401`/`403` → auth/permission guidance (mention that the token needs **edit** rights for writes), `404` → hint that the command or file id is wrong and that `PENPOT_URL` must not carry an `/api` suffix, `400` with `validation` → the server's `explain`. ### `extensions/penpot/src/client.ts` - `penpotRequest(command, options)` where `options` carries `method` (`GET` default for reads, `POST` for writes), an optional JSON `body`, and optional `query` params. - Builds the URL from `resolveConfig()` (import from `./env.ts`), appending `/api/rpc/command/<command>`; never double-appends `/api`. - Sends `Authorization: Token <token>`, `Accept: application/json`, and `Content-Type: application/json` when there is a body. - Cancels/aborts on a caller-supplied `AbortSignal` and on a bounded timeout; maps network failures to the `transport` category with an actionable message (unreachable host, TLS error, timeout). - Returns parsed JSON on success (empty body → `undefined`), and a categorised error result on failure. Do not throw for expected API failures — callers decide how to present them. - Keeps the raw `Response`/status available where a caller needs it (the commit path inspects `revn-conflict` specifically). - **No logging of the token.** Never interpolate the token into an error message. Write co-located tests next to the module (keep the same test location convention as `src/env.ts`), stubbing `globalThis.fetch` — the repo's `extensions/postgres`/`extensions/mongodb` tests show the fake-client style. Tests must not require network access or credentials. ## Acceptance Criteria - [ ] `penpotRequest` targets `<base>/api/rpc/command/<command>` and sends `Authorization: Token <token>` (asserted on the captured request). - [ ] A base URL already ending in `/api` is not double-appended. - [ ] A JSON validation error with an `explain` field is surfaced with the `explain` text **verbatim** and a `validation` category. - [ ] A Transit-encoded error body produces a readable message that includes the raw payload excerpt, and never renders raw Transit as prose. - [ ] 401/403 produce auth/permission guidance mentioning edit rights for writes; 404 mentions the `/api`-suffix and id/command causes. - [ ] Network failure/timeout produces a `transport` error naming the host, not an unhandled exception. - [ ] The token never appears in any error message or returned error object. - [ ] Unit tests for the client and error decoding are green with no network access. ## Test Plan ```bash # no credentials or network required — fetch is stubbed in the tests node --test extensions/penpot/src/client.test.ts extensions/penpot/src/errors.test.ts ``` Manual smoke check against a real instance (requires `PENPOT_URL`/`PENPOT_TOKEN`): ```bash PENPOT_URL="$PENPOT_URL" PENPOT_TOKEN="$PENPOT_TOKEN" node --input-type=module -e " import { penpotRequest } from './extensions/penpot/src/client.ts'; console.log(await penpotRequest('get-profile')); " ``` Then repeat with a deliberately invalid token and confirm the printed error is human-readable and contains no raw Transit dump.
david closed this issue 2026-09-14 23:37:53 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #221

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/221
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#188
No description provided.