API
Variables
Edit a highlighted value in any command, or fill it in here: every command on this page follows, and Copy copies a command with your values. They stay in this browser, except secrets.
-
YOUR_INSTANCEThe host your instance answers on: its Cloud Run address or your domain. Example:
gcportrait-10000000001.europe-west3.run.appExpected: a host name such as gcportrait.example.com, without https:// or a path.
-
YOUR_ACCESS_TOKENA Google OAuth access token, as gcloud auth print-access-token prints it. Never stored. Example:
ya29.example-tokenExpected: letters, digits and . _ ~ + / -.
-
YOUR_ORG_IDThe number of the organisation to read, as gcloud organizations list shows it. Example:
30000000001Expected: digits only.
-
YOUR_FOLDER_IDThe number of the folder to read, shown next to its name in the console. Example:
20000000001Expected: digits only.
-
YOUR_PROJECT_IDThe id of a project to read; not necessarily the one gcportrait runs in. Example:
demo-web-1Expected: 6 to 30 lowercase letters, digits or hyphens, starting with a letter and not ending with a hyphen.
-
YOUR_QUOTA_PROJECTThe id of a project of yours that your Cloud Asset Inventory requests are charged to. Example:
demo-billing-1Expected: 6 to 30 lowercase letters, digits or hyphens, starting with a letter and not ending with a hyphen.
-
YOUR_BUCKETThe Cloud Storage bucket an export is written to, without gs://. Example:
demo-exports-1Expected: 3 to 63 lowercase letters, digits, hyphens, underscores or dots.
gcportrait renders Google Cloud-style architecture diagrams. It is an independent open-source project and is not affiliated with, endorsed by, or sponsored by Google. Google Cloud and the Google Cloud product icons are trademarks of Google and are used under Google's icon usage terms.
Every route an instance serves under /api, for scripts: health and settings, the scope search and permission
check, the live pull and export read the app streams from, and POST /api/render, which draws a view as SVG,
PNG or a draw.io file on the server, byte for byte what pnpm render and the app's export draw. A demo-only instance has
none of the routes that need a token.
Authentication
The routes that read Google Cloud take your own Google access token and nothing else; the instance stores it
nowhere and logs it never. Send it in X-Gcportrait-Token (the app's header, which Identity-Aware Proxy
leaves alone), or in Authorization: Bearer when there is no X-Gcportrait-Token (ADR
0031). A token from gcloud auth print-access-token works; reading a
scope needs the permissions of permissions.md. Behind Identity-Aware Proxy, IAP takes
Authorization for its own sign-in, so use X-Gcportrait-Token there.
Edit a highlighted value in any command; every command on this page follows.
curl -s https://YOUR_INSTANCE/api/healthz
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' --json '{"query":"demo"}' https://YOUR_INSTANCE/api/scopes/search
Every request is JSON (curl --json, curl 7.82 or later, sets both content headers), except a render's
upload. Answers carry cache-control: no-store and x-request-id; quote the id when you report a problem.
Routes
| Route | Token | Answer |
|---|---|---|
GET /api/healthz |
no | { status: "ok", version }. (/healthz is the same for the container's probes; Cloud Run answers it itself from outside.) |
GET /api/config |
no | The instance's public settings: OAuth client id, service-account mode and account, version, source URL, hostedBy (who runs it, "Hosted by NAME.", or null), demoOnly, which legal pages exist, and render, the render API's limits or null where it is off. |
GET /api/legal |
no | { impressum, privacy }, the Markdown of each legal page or null. |
POST /api/scopes/search |
yes | { query, kinds?, pageSize?, pageToken? } → the organisations, folders and projects your token can see, { scopes, nextPageToken?, problems }. |
POST /api/preflight |
yes | { scope, mode: "user" | "sa", quotaProject? } → the permission checklist for reading scope, with gcloud commands for what is missing. |
POST /api/assets/stream |
yes | { scope, contentType: "RESOURCE", mode, quotaProject?, readTime?, assetTypes? } → the live pull as NDJSON frames (page, progress, then done or error). |
POST /api/export/read |
yes | { gcsUri } → the lines of a Cloud Asset Inventory export in Cloud Storage (one object, or every object under a prefix ending in /), as NDJSON. |
POST /api/render |
yes | A view drawn as SVG, PNG or a draw.io file (below). Absent with RENDER_API=false. |
A scope is organizations/YOUR_ORG_ID, folders/YOUR_FOLDER_ID or projects/YOUR_PROJECT_ID. In user mode
a pull reads with your token and charges quotaProject, a project of yours; in sa mode the instance's
service account reads, where its operator turned that on, after its gate checked you may see the scope
(modes.md). A pull and an export read, each saved as a file the app (and the render API) can read:
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -o pull.ndjson \
--json '{"scope":"organizations/YOUR_ORG_ID","contentType":"RESOURCE","mode":"user","quotaProject":"YOUR_QUOTA_PROJECT"}' \
https://YOUR_INSTANCE/api/assets/stream
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -o export.ndjson \
--json '{"gcsUri":"gs://YOUR_BUCKET/export/"}' https://YOUR_INSTANCE/api/export/read
The two streams answer 200 once they start; what Google says after that arrives as an error frame
({"t":"error","message":…,"status":403,"code":"PERMISSION_DENIED","reason":…}) on the last line.
POST /api/render
Draws one view of one snapshot, with the code pnpm render runs: the same view gives the same bytes, a PNG is
the app's export (2× by default, a 24 px margin, the theme's canvas colour), and a draw.io file is the same drawing
as cells diagrams.net can edit (below). The request is one JSON document:
| Field | Default | |
|---|---|---|
source |
required | Where the data comes from (next table). |
view |
{} |
{ "hash": "…" }, the part of an app link after # (its snapshot, selection and highlight do not change the drawing; a tick is refused), or explicit fields: lens (default overview), detail (auto, folders, projects, networks, all; default auto), focus ({ kind, params }), root (a node id to drill into), filters (any of the view's filters), flips (zone ids to open or close). |
format |
png |
png, svg or drawio (a .drawio file for diagrams.net). |
theme |
light |
light or dark. |
scale / width |
scale 2 |
PNG only, one of them: pixels per drawing unit, above 0 and up to 3, or the PNG's width in pixels. |
compressed |
true |
draw.io only: the diagram compressed as diagrams.net saves it, or false for its XML as it is (readable and diffable, several times larger). |
fontSource |
true |
draw.io only: every label names Google Fonts' stylesheet of Roboto and Roboto Mono, which diagrams.net loads when it opens the file, so the labels keep the drawing's faces; false leaves it out (diagrams.net then uses its default font unless Roboto is installed). |
viewport |
{ "width": 1280, "height": 800 } |
The canvas detail: auto fits, as the app's window would. |
source.kind |
Fields | Data |
|---|---|---|
scope |
scope, mode, quotaProject, readTime?, assetTypes? |
A live pull, exactly as /api/assets/stream makes it (the same rules, gate and limits). readTime draws a past moment within Cloud Asset Inventory's 35 days; assetTypes narrows the pull. |
ndjson |
scope?, readTime? |
The file part: a Cloud Asset Inventory export (gcloud asset export, a file of it) or a saved /api/assets/stream answer, read as the app imports it. The scope comes from the assets unless given, the read time from readTime or else the latest asset change. |
bundle |
none | The file part: a .gcportrait.json snapshot file the app saved; its own day is drawn. |
demo |
size (S, M, L, XL) |
A synthetic demo organisation, as pnpm render --fixture draws it. |
scope and demo send the document as the JSON body. ndjson and bundle send multipart/form-data: the
document as the field request, first, then the file as the part file (curl -F):
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -OJ \
--json '{"source":{"kind":"scope","scope":"folders/YOUR_FOLDER_ID","mode":"user","quotaProject":"YOUR_QUOTA_PROJECT"},"view":{"lens":"networking"}}' \
https://YOUR_INSTANCE/api/render
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -o demo-m.svg \
--json '{"source":{"kind":"demo","size":"M"},"view":{"hash":"lens=networking&detail=networks"},"format":"svg","theme":"dark"}' \
https://YOUR_INSTANCE/api/render
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -OJ \
-F 'request={"source":{"kind":"ndjson"},"scale":3}' -F 'file=@export.ndjson' https://YOUR_INSTANCE/api/render
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -OJ \
-F 'request={"source":{"kind":"bundle"},"format":"svg"}' -F 'file=@snapshot.gcportrait.json' \
https://YOUR_INSTANCE/api/render
curl -s -H 'X-Gcportrait-Token: YOUR_ACCESS_TOKEN' -OJ \
--json '{"source":{"kind":"demo","size":"M"},"format":"drawio"}' https://YOUR_INSTANCE/api/render
-OJ saves the image under the name the instance gives it, the app's export name. A 200 answer's headers:
| Header | |
|---|---|
content-type |
image/png, image/svg+xml; charset=utf-8, or application/vnd.jgraph.mxfile (draw.io) |
content-disposition |
attachment; filename="gcportrait_<scope>_<lens>_<readTime>.<ext>" |
x-gcportrait-view |
The view drawn, as an app link's hash; for a demo it names the snapshot (snapshot=demo:M&lens=networking&detail=networks), and pnpm render --hash with it draws the same bytes. |
x-gcportrait-detail |
The detail level drawn, what auto chose. |
x-gcportrait-scale |
PNG only: the scale used, lower than asked when the PNG would pass RENDER_MAX_PIXELS (pnpm render --scale with it draws the same bytes). |
The drawing is the app's: its title block holds the scope, the lens with the read time (written in UTC) and
the tagline. Renders run one at a time per instance, so a script retries a 429 RENDER_BUSY after its
Retry-After, as it does every answer marked retryable.
draw.io files
"format": "drawio" answers a .drawio file (gcportrait_<scope>_<lens>_<readTime>.drawio) that
diagrams.net (draw.io) opens as an editable diagram looking like the PNG and the SVG.
It is not drawn a second time: the SVG of the view is translated into draw.io's cells, so every zone, card, line
and label is where the picture has it, in the same colours, strokes, dashes and fonts:
- zones are containers, nested as in the drawing, each with its title as its label, so moving a zone moves what it holds; cards, chips and collapsed zones are groups of their body, their icon (Google's icon as an embedded SVG image, unaltered) and one text cell of their lines;
- lines connect their two nodes, with the drawing's bends as waypoints and their heads and dashes;
- three layers: "Diagram" (zones and nodes), "Connections" (the lines) and "Title and legend" (the title block's three lines and the legend);
- the page's background is the theme's canvas colour, and the file turns off diagrams.net's adaptive colours
(
adaptiveColors="none"), so it looks the same in diagrams.net's light or dark interface; the file'smodifiedis the data's read time, and the same view gives the same bytes.
The diagram is compressed as diagrams.net saves it (the XML URI-encoded, deflated, base64); "compressed": false (or pnpm render --drawio-plain) writes the XML itself. With fontSource (the default) each label
names Google Fonts' stylesheet of Roboto and Roboto Mono: the instance fetches nothing, it is your diagrams.net
that loads the faces from Google Fonts when it opens the file.
To open a file in diagrams.net, use File, Open from, Device, or give it a public URL of the file:
https://app.diagrams.net/?url= followed by the URL-encoded address of a .drawio file anyone can read (a
public bucket's object, a raw file in a public repository).
Errors
Every error is JSON, sent before any byte of an image: { statusCode, error, message, requestId, code, retryable?, reason?, upstream? }. message is for people and names a field, never the value sent; code is
for scripts; reason is Google's ErrorInfo reason when Google refused (such as SERVICE_DISABLED). Every
429 and every error with retryable: true carries Retry-After, in seconds: wait that long, then send the
same request again.
| Status | code |
When |
|---|---|---|
| 400 | INVALID_REQUEST |
The body does not fit the route's schema (unknown fields are refused), or a render's upload is not readable multipart (no boundary, cut short). |
| 401 | UNAUTHENTICATED |
No token, a malformed one, or one Google does not accept; with WWW-Authenticate: Bearer. |
| 403 | SA_MODE_OFF, a gate code (SA_GATE_CALLER_DENIED, …), or Google's (PERMISSION_DENIED) |
Service-account mode off or refused, or Google refused the read. |
| 404 | NOT_FOUND |
Google found no such scope, or none at that readTime. A route the instance does not have (RENDER_API=false; every token route on a demo-only instance) answers the JSON 404 of any unknown path, without a code. |
| 408 | REQUEST_TIMEOUT |
The body, a render's upload included, was still arriving after 30 s; the connection is closed. |
| 413 | BODY_TOO_LARGE |
A JSON body over 64 KiB, or an upload over RENDER_MAX_BODY_BYTES. |
| 415 | UNSUPPORTED_MEDIA_TYPE |
A render source sent in the wrong body, or an upload without its file part. |
| 422 | INVALID_FILE, EMPTY_SNAPSHOT, TOO_MANY_ASSETS, VIEW_REFUSED |
A file the app could not read either (its message), no assets, more than RENDER_MAX_ASSETS, a root or focus the snapshot does not have. |
| 429 | RATE_LIMITED, STREAMS_BUSY, RENDER_BUSY |
Too many requests for the token or address (Retry-After: until the minute's window ends), four streams already running for the token (30 s), another render running on the instance (10 s). |
| 501 | SA_MODE_UNAVAILABLE |
Service-account mode asked of a build without the instance's identity. |
| 502, 503, 504 | UPSTREAM_UNREACHABLE, INSTANCE_BUSY, UPSTREAM_TIMEOUT, BAD_UPSTREAM_ANSWER, Google's code |
Google could not be reached or answered with an error after the retries; retryable ones with Retry-After (10 s, 30 s for INSTANCE_BUSY). |
| 503 | SA_GATE_UNAVAILABLE, SA_CREDENTIALS_UNAVAILABLE, TOKEN_CHECK_UNAVAILABLE |
Google did not answer the service-account gate's check, the instance could not get its account's token, or Google's token check did not answer: retryable, Retry-After 10 s. |
| 503 | RENDER_TIMEOUT, RENDER_TOO_LARGE |
The render passed RENDER_TIMEOUT_MS or the render worker's memory; not retryable as it is: narrow the data or the view. |
| 500 | INTERNAL |
A bug: Internal error. and the request id; the details are in the instance's log. |
The streaming routes answer the 400 to 501 rows before they start; the code field is the render route's.
Limits
| Limit | Value |
|---|---|
| Requests per client address | 600 a minute, every route but /healthz |
| Per token | 120 scope searches, 30 preflights, 30 streams per stream route, 10 renders, each a minute; four streams at once |
| Bodies | JSON up to 64 KiB, sent within 30 s; a client must take each 1 MiB piece of an answer within 60 s |
| Live pull | 60 s and 25 MiB per page, 64 MiB of pages in memory per instance |
| Render | one at a time per instance; scale up to 3; 16 384 px a side; RENDER_MAX_ASSETS (60 000), RENDER_MAX_PIXELS (2^26), RENDER_TIMEOUT_MS (300 s), RENDER_MAX_BODY_BYTES (25 MiB), each set by the operator (configuration.md); the pixel limits are a PNG's only, an SVG or a draw.io file has none |
A render keeps the instance's CPU busy and needs memory for its PNG: demo XL at detail: all and scale 3
needs about 1.4 GiB and minutes of CPU even on a fast machine, so on Cloud Run's one vCPU a large render can
approach RENDER_TIMEOUT_MS, and x-gcportrait-scale says when the scale was lowered to fit
RENDER_MAX_PIXELS. RENDER_API=false (deploy.sh --no-render-api) turns the route off where nobody needs
it. Any Google account's token passes the render route's token check for the demo, ndjson and bundle
sources: on a public instance, keep --max-instances low or put Identity-Aware Proxy in front
(self-host-options.md). An upload must pass Cloud Run's 32 MiB request limit and arrive within 30 s
(else 408); read larger scopes with scope.
This page is docs/api.md in the repository, rendered for the website.