# cBioPortal — API: Every cBioPortal route, every role: the CoreModels HTTP surface for study governance

Ask the API what it can do for cBioPortal before you write a line of client code:

# Every cBioPortal route, every role: the CoreModels HTTP surface for study governance

Ask the API what it can do for cBioPortal before you write a line of client code:

```bash
curl -sS "https://coremodels.example.com/graph/integrations/vendors" \
  -H "Authorization: Bearer $TOKEN" | jq '.vendors[] | select(.key == "cbioportal")'
```

```json
{
  "key": "cbioportal",
  "displayName": "cBioPortal",
  "capabilities": "Import, Audit, Generate",
  "artifacts": {
    "clinical_patient": "data_clinical_patient.txt - at least one clinical file is required (data rows may be omitted)",
    "clinical_sample": "data_clinical_sample.txt - at least one clinical file is required (data rows may be omitted)",
    "meta": "optional - meta_study.txt (key: value lines; study id/name/cancer type)"
  }
}
```

That `capabilities` string is the contract for this article. cBioPortal declares Import, Audit, and
Generate, so all three verbs below are real operations rather than stubs. It does not declare
LiveSync, which is why nothing in this API ever asks you for portal credentials.

Two surfaces carry the routes. The interactive surface under `graph/integrations/...` takes your
normal CoreModels login token and carries every verb. The machine-to-machine surface under `v1/...`
accepts revocable user API keys and carries the two verbs automation needs: audit and badge. Both
are `[Authorize]`d, and every project-scoped route enforces a role.

| Verb | Route | Role |
|---|---|---|
| discovery | `GET graph/integrations/vendors` | any authenticated caller |
| import | `POST graph/integrations/cbioportal/import/{projectId}` | Admin |
| audit | `POST graph/integrations/cbioportal/audit/{projectId}` | Viewer |
| audit (API key) | `POST v1/{projectId}/integrations/cbioportal/audit` | Viewer |
| re-audit | `POST graph/integrations/cbioportal/reaudit/{projectId}` | Viewer |
| history | `GET graph/integrations/cbioportal/history/{projectId}` | Viewer |
| badge | `GET graph/integrations/cbioportal/badge/{projectId}` | Viewer |
| badge (API key) | `GET v1/{projectId}/integrations/cbioportal/badge` | Viewer |
| generate | `POST graph/integrations/cbioportal/generate/{projectId}` | Viewer |
| status | `GET graph/integrations/cbioportal/status/{projectId}` | Viewer |

Below, `$PROJECT_ID` is the 32-character hex id of the governing CoreModels project and `$TOKEN` is
a bearer token - a login token on the interactive surface, a user API key on `v1`.

## Import (Admin)

Import is the only cBioPortal verb that writes model shape, and it writes additively: existing
governed nodes are never mutated or deleted.

```http
POST /graph/integrations/cbioportal/import/$PROJECT_ID
Authorization: Bearer $TOKEN
Content-Type: application/json

{
  "artifacts": {
    "clinical_patient": "#Patient Identifier\tAge\n#Unique patient identifier\tAge at diagnosis\n#STRING\tNUMBER\n#1\t1\nPATIENT_ID\tAGE\n",
    "clinical_sample": "…",
    "meta": "type_of_cancer: brca\ncancer_study_identifier: example_brca_2026\nname: Example BRCA Cohort 2026\n"
  },
  "spaces": []
}
```

The body is the shared `ArtifactsRequest`: `artifacts` maps artifact name to raw content and is
required; `spaces` is an optional array of space ids, where empty means the project's main space.
Artifact keys are matched case-insensitively, and an entry whose value is blank is dropped before
parsing - so sending `"meta": ""` is the same as not sending `meta` at all.

The response reports counts plus the two honesty channels, `lossiness` and `errors`:

```json
{
  "success": true,
  "vendor": "cbioportal",
  "projectName": "example_brca_2026",
  "datasetsAdded": 2,
  "datasetsSkippedExisting": 0,
  "fieldsAdded": 0,
  "lineageEdgesAdded": 0,
  "lineageEdgesSkipped": 0,
  "nodesEnriched": 10,
  "snapshotStored": true,
  "lossiness": [],
  "errors": []
}
```

`projectName` comes from `cancer_study_identifier` in the meta file; without a meta file it falls
back to `cbioportal-study`. `lineageEdgesAdded` is always `0` here - clinical staging files describe
no build dependencies, so the patient/sample join is written as a governed reference rather than a
lineage edge. `fieldsAdded` counts only attributes added to instruments that were already governed,
so a first import reports zero while a later import that introduces a column reports one per column.

Two failure shapes are worth handling explicitly. An unknown vendor segment returns
`{"success": false, "errors": [{"path": "vendor", "message": "Unknown vendor 'cbio'. Registered: …"}]}`
with the full registered list, and a missing artifact map returns the same envelope with
`Body must include 'artifacts': { "<name>": "<content>" } (e.g. manifest for dbt).` Parse failures
are more specific: a file without four `#` header rows fails with
`Not a clinical staging file (needs 4 '#' header rows above the attribute-ID row).` and sending
neither clinical file fails with
`At least one clinical staging file ('clinical_patient' or 'clinical_sample') is required.`

## Audit (Viewer)

Audit compares fresh artifacts against the governed graph and writes nothing - unless you pass
`recordHistory`, which is opt-in bookkeeping and defaults to `false`.

```http
POST /graph/integrations/cbioportal/audit/$PROJECT_ID
Authorization: Bearer $TOKEN
Content-Type: application/json

{
  "artifacts": { "clinical_patient": "…", "clinical_sample": "…" },
  "recordHistory": true
}
```

The report shape is stable across every audit route:

```json
{
  "success": true,
  "vendor": "cbioportal",
  "projectName": "example_brca_2026",
  "errorCount": 1,
  "warningCount": 1,
  "infoCount": 1,
  "codes": {
    "field-type-drift": 1,
    "attribute-id-not-upper": 1,
    "attribute-no-description": 1
  },
  "driftedObjects": ["patient.AGE"],
  "fingerprint": "c4a71f2e9b380d56",
  "metrics": {
    "Datasets (estate)": "2",
    "Datasets governed": "2 / 2",
    "Fields governed": "8 / 8",
    "Governed nodes with canonical mappings": "0 / 10 (0%)",
    "Last import": "2026-08-03T09:14:22.7431180+00:00"
  },
  "findings": [
    {
      "section": "Drift",
      "severity": "Error",
      "code": "field-type-drift",
      "subject": "patient.AGE",
      "message": "Field type changed since the last import.",
      "detail": "governed: NUMBER, estate: STRING"
    },
    {
      "section": "Conformance",
      "severity": "Info",
      "code": "attribute-no-description",
      "subject": "patient",
      "message": "1 clinical attribute(s) carry no description row - curators downstream will guess.",
      "detail": "OS_STATUS"
    },
    {
      "section": "Conformance",
      "severity": "Warning",
      "code": "attribute-id-not-upper",
      "subject": "sample.cancerType",
      "message": "Attribute IDs must be UPPER_CASE for cBioPortal validation to pass.",
      "detail": null
    }
  ],
  "markdown": "# cbioportal Schema Audit - example_brca_2026\n\n🔴 **1 errors · 1 warnings · 1 info**\n…",
  "historyRecorded": true,
  "lossiness": []
}
```

Parse `errorCount` for gating, `codes` for policy (a map of finding code to occurrence count),
`driftedObjects` for "what exactly moved", and `fingerprint` - a 16-character content hash of the
audited artifacts - to tell one submission from the next. `markdown` renders the whole report with
the verdict line first and each section folded into a collapsible block.

The `v1` twin takes the identical body and wraps the same payload in the standard API envelope, so
every field moves one level down:

```bash
curl -sS -X POST \
  "https://coremodels.example.com/v1/$PROJECT_ID/integrations/cbioportal/audit" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  --data-binary @audit-request.json | jq '.data.errorCount'
```

Runs recorded from `v1` carry the trigger `ci`; runs recorded from the interactive route carry
`audit`.

## Re-audit (Viewer)

Audit asks whether fresh files still conform to the governed model. Re-audit asks the mirror
question: does the governed model still match the last-known study? It runs the same engine over the
snapshot stored at import time against the *current* governed view.

```http
POST /graph/integrations/cbioportal/reaudit/$PROJECT_ID
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "projectName": null, "spaces": null }
```

`projectName: null` re-audits the most recently stored snapshot; naming a study targets that one.
The response is the same report shape with an extra metric, `Snapshot stored`, and
`historyRecorded: true` - unlike audit, re-audit always records its run. With no stored snapshot
you get a precise refusal: `No stored estate snapshot for vendor 'cbioportal' - import the vendor
project first (imports persist the parsed snapshot).`

## History (Viewer)

```http
GET /graph/integrations/cbioportal/history/$PROJECT_ID
Authorization: Bearer $TOKEN
```

```json
{
  "success": true,
  "vendor": "cbioportal",
  "projects": [
    {
      "projectName": "example_brca_2026",
      "runs": [
        {
          "at": "2026-08-03T11:02:47.1180930+00:00",
          "trigger": "ci",
          "errorCount": 1,
          "warningCount": 1,
          "infoCount": 1,
          "codes": { "field-type-drift": 1, "attribute-id-not-upper": 1, "attribute-no-description": 1 },
          "fingerprint": "c4a71f2e9b380d56"
        }
      ]
    }
  ]
}
```

Runs are newest first, one trail per vendor-side study, capped at the 50 most recent runs. Triggers
are `audit`, `ci`, `reaudit`, and `scheduled`. Records are deliberately compact - counts, codes, and
fingerprint, never full findings - because the trail answers "is this study drifting over time?"
while a live audit answers "what exactly is wrong now?"

## Badge (Viewer)

```http
GET /graph/integrations/cbioportal/badge/$PROJECT_ID
GET /v1/$PROJECT_ID/integrations/cbioportal/badge
```

Both return `image/svg+xml`: a self-contained shields-style SVG labeled `cbioportal audit`, green
when the latest recorded run was clean, yellow when it had warnings only, red with the error count
when it had errors, and gray when nothing has been recorded yet. Both routes are authenticated like
everything else, so send the Authorization header rather than expecting an anonymous image URL.

## Generate (Viewer)

Generate closes the loop back to staging files. It reads the governed model and emits one
upload-ready `data_clinical_{instrument}.txt` scaffold per governed Type.

```http
POST /graph/integrations/cbioportal/generate/$PROJECT_ID
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "typeNames": ["patient", "sample"] }
```

The `GenerateRequest` body also accepts `targetVersion`, an open `extra` string map, and `spaces`;
the cBioPortal connector reads none of those dialect options, so `typeNames` is the one that
matters. It matters more than it looks: generation walks every governed Type in the project, not
only the ones cBioPortal imported. In a project that also governs other estates, scope it.

```json
{
  "success": true,
  "artifacts": [
    {
      "name": "data_clinical_patient.txt",
      "kind": "tsv",
      "content": "#PATIENT_ID\tAGE\tSEX\tOS_STATUS\n#Unique patient identifier\tAge at diagnosis in years\tSex at birth\tOS_STATUS\n#STRING\tNUMBER\tSTRING\tSTRING\n#1\t1\t1\t1\nPATIENT_ID\tAGE\tSEX\tOS_STATUS\n"
    }
  ],
  "lossiness": [],
  "errors": []
}
```

Decoded, that artifact is a valid five-line header block:

```text
#PATIENT_ID	AGE	SEX	OS_STATUS
#Unique patient identifier	Age at diagnosis in years	Sex at birth	OS_STATUS
#STRING	NUMBER	STRING	STRING
#1	1	1	1
PATIENT_ID	AGE	SEX	OS_STATUS
```

Read the rows honestly. The display-name row is the governed Element label, not the original
`cbio.displayName` the import captured - so a round trip through CoreModels writes attribute IDs
into row one unless someone relabels the Elements with human-readable names. The description row
falls back to the label when no governed description exists (`OS_STATUS` above). Datatypes derive
from the governed value types: integer and floating-point become `NUMBER`, boolean becomes
`BOOLEAN`, everything else becomes `STRING`. Priorities are emitted as `1` across the board, and
attribute IDs are minted upper case from the label, so a governed label of `Age at dx` becomes
`AGE_AT_DX`. A governed Type with no elements is skipped with a declared `StructuralDrop` in
`lossiness`; when nothing at all is eligible, the call fails with `No eligible governed types found
to emit as staging-file scaffolds.`

## Status (Viewer)

```http
GET /graph/integrations/cbioportal/status/$PROJECT_ID
Authorization: Bearer $TOKEN
```

The response is `{ "success": true, "vendor": "cbioportal", "imported": true, "state": { … },
"governedDatasets": 2 }`. `imported` is simply whether a state record exists, and `governedDatasets`
counts the vendor identities in this project that resolve to a governed Type - `2` for a study with
both instruments.

The nested `state` record is what the last import wrote to the integration state node:

| State field | Value for our study |
|---|---|
| Vendor | `cbioportal` |
| Project Name | `example_brca_2026` |
| Imported At | `2026-08-03T09:14:22.7431180+00:00` |
| Fingerprint | `c4a71f2e9b380d56` |
| Counts | `fieldsAdded=0, lineageAdded=0, lineageSkipped=0, nodesEnriched=10` |
| Facts | `{"instruments":"2","attributes":"8","cancerType":"brca","studyId":"example_brca_2026","studyName":"Example BRCA Cohort 2026"}` |
| Tool Version / Artifact Version / Generated At | absent |

The three absent fields are absent on purpose: clinical staging files declare no tool version and no
generation stamp, and the connector does not invent one. `Facts` is the parser's own key-value
record, carrying the instrument and attribute counts alongside the three keys read from the meta
file.

## What this API deliberately does not offer

There is no live-portal route, because there is no credential store to make one from. There is also
no cBioPortal-specific reconciliation: the project-wide `POST graph/integrations/reconcile/{projectId}`
route (Admin) matches datasets two vendors govern by their physical relation name, and clinical
instruments have no `database.schema.table` coordinates to match on. And `reaudit` plus `history`
live on the interactive surface only - the API-key surface carries audit and badge.

For the artifact contract and a copy-paste extraction recipe, see the cBioPortal quickstart in the
CoreModels integration docs.
