Skip to content

Commit df643d0

Browse files
authored
Merge pull request #14 from ImagingDataCommons/docs/api-endpoint-descriptions
docs(api): add OpenAPI summaries/descriptions to every REST route
2 parents 606f039 + a283f38 commit df643d0

2 files changed

Lines changed: 182 additions & 21 deletions

File tree

src/idc_api/mcp/server.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@
4545
get_table_schema before SQL. Do not guess values or column names.
4646
2. build_cohort for attribute filters; run_sql for relational/aggregate questions (read-only
4747
DuckDB; `index` plus specialized indices joined on SeriesInstanceUID and named for the
48-
DICOM Modality they detail — seg_index: what anatomy a SEG segments; ct/mr/pt_index:
49-
acquisition; sm/ann: microscopy; …). If a property isn't in list_attributes (e.g.
48+
DICOM Modality they detail — seg_index: what anatomy a SEG segments; ct_index, mr_index,
49+
pt_index: acquisition; sm_index, ann_index: microscopy; …). If a property isn't in
50+
list_attributes (e.g.
5051
segmented anatomy), check list_tables before concluding it's unavailable. For clinical
5152
(non-imaging) attributes — staging, demographics, therapy — use list_clinical_tables, then
5253
get_clinical_table_schema / get_clinical_table to read the rows (or run_sql against
@@ -58,6 +59,7 @@
5859
respect get_licenses (CC-BY vs CC-BY-NC). See `idc://guide` for the data model, the full tool
5960
list, and join examples."""
6061

62+
6163
# stateless_http=True / json_response=True make the hosted (streamable-http) transport
6264
# horizontally scalable on Cloud Run.
6365
#
@@ -251,7 +253,10 @@ def list_analysis_results() -> list[dict]:
251253
@guard
252254
def list_attributes() -> list[dict]:
253255
"""List the attributes you can filter a cohort by (name, type, whether categorical).
254-
Call this before build_cohort to learn valid filter attribute names."""
256+
Call this before build_cohort to learn valid filter attribute names. These are a curated
257+
subset of the `index` table chosen for cohort filtering — run_sql can query or filter on any
258+
column in any table from list_tables, including `index` columns that are not filter
259+
attributes."""
255260
return [a.model_dump(mode="json") for a in ctx.discovery.list_attributes()]
256261

257262

@@ -276,8 +281,9 @@ def get_attribute_values(attribute: str, limit: int = 50) -> dict:
276281
def list_tables() -> dict:
277282
"""List the tables available to run_sql: the main `index`, collection/analysis/version
278283
metadata tables, and the specialized indices — named `<modality>_index` after the DICOM
279-
Modality they describe (seg_index: segmented anatomy of SEG series; ct/mr/pt_index:
280-
acquisition parameters; sm/ann: microscopy) plus contrast/volume_geometry/clinical.
284+
Modality they describe (seg_index: segmented anatomy of SEG series; ct_index, mr_index,
285+
pt_index: acquisition parameters; sm_index, ann_index: microscopy), plus contrast_index,
286+
volume_geometry_index, and clinical_index.
281287
Call this before writing SQL, and whenever a property you need (e.g. what a segmentation
282288
contains) is not a filterable attribute — it may live in a specialized index. Per-collection
283289
clinical data tables are listed separately by list_clinical_tables (queried as
@@ -355,8 +361,11 @@ def build_cohort(
355361
def run_sql(sql: str, max_rows: int = 100) -> dict:
356362
"""Run a read-only SQL SELECT against the IDC index using DuckDB and return the rows.
357363
Use for anything build_cohort can't express (GROUP BY, joins across tables, custom
358-
aggregations, filters on columns that exist only in a specialized index — e.g. segmented
359-
anatomy in seg_index). Only a single read-only SELECT/WITH statement is allowed; the
364+
aggregations, or filtering on columns that are not filter attributes: the attributes from
365+
list_attributes are a curated subset of `index`, so run_sql is how you reach the rest —
366+
other `index` columns like SeriesDescription or PatientAge, and columns that exist only in a
367+
specialized index such as segmented anatomy in seg_index). Only a single read-only SELECT/WITH
368+
statement is allowed; the
360369
connection is sandboxed (no writes, no file/network access). Call list_tables /
361370
get_table_schema first to use correct table and column names. The main table is `index`.
362371
Per-collection clinical tables are in the `clinical` schema (query as `clinical.<table>`,

src/idc_api/rest/app.py

Lines changed: 166 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,10 @@ def C() -> AppContext:
217217
return app.state.ctx
218218

219219
# --- meta ---
220-
@app.get(API_PREFIX, tags=["meta"])
220+
@app.get(API_PREFIX, tags=["meta"], summary="API root")
221221
def root():
222+
"""Entry point for the API: returns the server and build version, and links to the
223+
interactive docs, the OpenAPI schema, and the version endpoint."""
222224
return {
223225
"name": "IDC API",
224226
"server_version": server_version(), # this server's software/build version
@@ -227,15 +229,18 @@ def root():
227229
"version_endpoint": f"{API_PREFIX}/version",
228230
}
229231

230-
@app.get(f"{API_PREFIX}/health", tags=["meta"])
232+
@app.get(f"{API_PREFIX}/health", tags=["meta"], summary="Health check")
231233
def health():
234+
"""Liveness probe for the load balancer and uptime checks. Returns `{"status": "ok"}`
235+
once the service is up."""
232236
return {"status": "ok"}
233237

234238
# --- discovery ---
235239
@app.get(
236240
f"{API_PREFIX}/version",
237241
response_model=VersionInfo,
238242
tags=["discovery"],
243+
summary="IDC and server version",
239244
responses={
240245
200: {
241246
"content": {
@@ -257,12 +262,17 @@ def health():
257262
},
258263
)
259264
def version():
265+
"""Report the IDC data release served (e.g. `v24`) and the pinned idc-index-data
266+
version, plus this server's own software `api_version` (and `build` stamp, if the deploy
267+
set one). Use it to confirm which IDC version — and which build of this server —
268+
produced a given result."""
260269
return C().discovery.version()
261270

262271
@app.get(
263272
f"{API_PREFIX}/stats",
264273
response_model=Stats,
265274
tags=["discovery"],
275+
summary="Headline totals",
266276
responses={
267277
200: {
268278
"content": {
@@ -288,109 +298,224 @@ def version():
288298
},
289299
)
290300
def stats():
301+
"""Headline totals for all of IDC: the number of collections, analysis results,
302+
patients, studies, series, and instances, plus the total size in TB."""
291303
return C().discovery.stats()
292304

293305
@app.get(
294306
f"{API_PREFIX}/collections",
295307
response_model=list[CollectionSummary],
296308
tags=["discovery"],
309+
summary="List collections",
297310
)
298311
def collections():
312+
"""List all IDC collections (original imaging datasets) with cancer types, tumor
313+
locations, species, and subject counts. Use it to find a `collection_id` to filter
314+
on."""
299315
return C().discovery.list_collections()
300316

301317
@app.get(
302318
f"{API_PREFIX}/collections/{{collection_id}}",
303319
response_model=CollectionDetail,
304320
tags=["discovery"],
321+
summary="Collection detail",
305322
)
306323
def collection(collection_id: str = Path(..., examples=["nlst"])):
324+
"""Detailed metadata for one collection: description, subject/series/instance counts,
325+
total size, the modalities present, and the license breakdown."""
307326
return C().discovery.get_collection(collection_id)
308327

309328
@app.get(
310329
f"{API_PREFIX}/analysis_results",
311330
response_model=list[AnalysisResult],
312331
tags=["discovery"],
332+
summary="List analysis results",
313333
)
314334
def analysis_results():
335+
"""List IDC analysis results — derived datasets (AI or expert segmentations,
336+
annotations, radiomics) layered on the original collections. Use it to find an
337+
`analysis_result_id`."""
315338
return C().discovery.list_analysis_results()
316339

317340
@app.get(
318-
f"{API_PREFIX}/attributes", response_model=list[AttributeInfo], tags=["discovery"]
341+
f"{API_PREFIX}/attributes",
342+
response_model=list[AttributeInfo],
343+
tags=["discovery"],
344+
summary="List filter attributes",
319345
)
320346
def attributes():
347+
"""List the attributes a cohort can be filtered by (name, type, whether categorical).
348+
Use it to learn valid filter attribute names before building a cohort. These are a
349+
curated subset of the `index` table chosen for cohort filtering — `/sql` can query or
350+
filter on any column in any table listed by `/tables`, including `index` columns that
351+
aren't filter attributes."""
321352
return C().discovery.list_attributes()
322353

323354
@app.get(
324355
f"{API_PREFIX}/attributes/{{attribute}}/values",
325356
response_model=AttributeValues,
326357
tags=["discovery"],
358+
summary="Distinct attribute values",
327359
)
328360
def attribute_values(
329361
attribute: str = Path(..., examples=["Modality"]),
330362
limit: int = Query(100, ge=1, le=10000, examples=[10]),
331363
):
364+
"""Return the distinct values (with counts) of a categorical attribute on the `index`
365+
table, e.g. `Modality` or `BodyPartExamined`. Query this before filtering by an attribute
366+
so you use real values with the correct casing rather than guessing. The response carries
367+
a `truncated` flag: when `false` the list is complete; when `true`, raise `limit` (capped
368+
server-side) and re-check."""
332369
return C().discovery.get_attribute_values(attribute, limit=limit)
333370

334371
# --- schema discovery ---
335-
@app.get(f"{API_PREFIX}/tables", response_model=TableList, tags=["query"])
372+
@app.get(
373+
f"{API_PREFIX}/tables",
374+
response_model=TableList,
375+
tags=["query"],
376+
summary="List queryable tables",
377+
)
336378
def tables():
379+
"""List the tables available to the SQL endpoint: the main `index`, the collection,
380+
analysis, and version metadata tables, and the specialized indices — each named
381+
`<modality>_index` after the DICOM Modality it describes (`seg_index`: segmented anatomy
382+
of SEG series; `ct_index`, `mr_index`, `pt_index`: acquisition parameters; `sm_index`,
383+
`ann_index`: microscopy), plus `contrast_index`, `volume_geometry_index`, and
384+
`clinical_index`. Consult it before writing SQL, and whenever a property you need (e.g.
385+
what a segmentation contains) is not a filterable attribute — it may live in a
386+
specialized index. Per-collection clinical tables are listed separately by
387+
`/clinical/tables`."""
337388
return C().query.list_tables()
338389

339-
@app.get(f"{API_PREFIX}/tables/{{table}}", response_model=TableSchema, tags=["query"])
390+
@app.get(
391+
f"{API_PREFIX}/tables/{{table}}",
392+
response_model=TableSchema,
393+
tags=["query"],
394+
summary="Table schema",
395+
)
340396
def table_schema(table: str = Path(..., examples=["index"])):
397+
"""Return the columns (name, type, description) of a table. Use it to get correct column
398+
names before querying `/sql`. Pass `index` for the main series-level table."""
341399
return C().query.get_table_schema(table)
342400

343401
# --- clinical data ---
344402
@app.get(
345-
f"{API_PREFIX}/clinical/tables", response_model=ClinicalTableList, tags=["clinical"]
403+
f"{API_PREFIX}/clinical/tables",
404+
response_model=ClinicalTableList,
405+
tags=["clinical"],
406+
summary="List clinical tables",
346407
)
347408
def clinical_tables(collection_id: str | None = Query(None, examples=["nlst"])):
409+
"""Discover the per-collection clinical (non-imaging) data tables — demographics,
410+
diagnoses, cancer staging, therapies, labs, outcomes. Clinical data is not a filterable
411+
attribute and is not harmonized across collections, so table and column names vary per
412+
collection. Pass `collection_id` to narrow to one collection. Each table is queryable via
413+
`/sql` as `clinical.<table_name>` and joins to `index` on
414+
`dicom_patient_id = index.PatientID`."""
348415
return C().clinical.list_clinical_tables(collection_id=collection_id)
349416

350417
@app.get(
351418
f"{API_PREFIX}/clinical/tables/{{table}}",
352419
response_model=TableSchema,
353420
tags=["clinical"],
421+
summary="Clinical table schema",
354422
)
355423
def clinical_table_schema(table: str = Path(..., examples=["nlst_canc"])):
424+
"""Return the columns of a clinical table (name, DuckDB type, and a human-readable label
425+
from `clinical_index`, since clinical column names are often cryptic). Get the table name
426+
from `/clinical/tables`."""
356427
return C().clinical.get_clinical_table_schema(table)
357428

358429
@app.get(
359430
f"{API_PREFIX}/clinical/tables/{{table}}/rows",
360431
response_model=SqlResult,
361432
tags=["clinical"],
433+
summary="Read clinical table rows",
362434
)
363435
def clinical_table_rows(
364436
table: str = Path(..., examples=["nlst_canc"]),
365437
max_rows: int | None = Query(None, ge=1, le=100000, examples=[100]),
366438
):
439+
"""Return the rows of a clinical table (capped at `max_rows`). Use it to inspect a small
440+
clinical table directly; for filtering by clinical attributes or joining to imaging,
441+
query `/sql` against `clinical.<table>` instead. Get the table name from
442+
`/clinical/tables`."""
367443
return C().clinical.get_clinical_table(table, max_rows=max_rows)
368444

369445
# --- cohort / manifest ---
370-
@app.post(f"{API_PREFIX}/cohort/counts", response_model=CohortCounts, tags=["cohort"])
446+
@app.post(
447+
f"{API_PREFIX}/cohort/counts",
448+
response_model=CohortCounts,
449+
tags=["cohort"],
450+
summary="Cohort counts",
451+
)
371452
def cohort_counts(filters: CohortFilters):
453+
"""Return distinct counts for a filtered cohort — patients, studies, series, instances,
454+
and total `size_TB` — without the sample rows or download payload. Use it as a fast size
455+
check before building a full manifest or downloading. `terms` is `{attribute: [values]}`
456+
for equality/IN; `ranges` is `{attribute: {"gte": x, "lte": y}}` for numeric or date
457+
ranges."""
372458
return C().cohort.counts(filters)
373459

374460
@app.post(
375-
f"{API_PREFIX}/cohort/manifest", response_model=ManifestResponse, tags=["cohort"]
461+
f"{API_PREFIX}/cohort/manifest",
462+
response_model=ManifestResponse,
463+
tags=["cohort"],
464+
summary="Build cohort manifest",
376465
)
377466
def cohort_manifest(req: ManifestRequest):
467+
"""Build a cohort from structured filters and get back distinct counts (patients,
468+
studies, series, instances, size_TB), a page of matching series, and a download payload
469+
(idc commands plus a manifest preview). `filters.terms` is `{attribute: [values]}` for
470+
equality/IN (e.g. `{"Modality": ["MR"]}`); `filters.ranges` is
471+
`{attribute: {"gte": x, "lte": y}}` for numeric or date ranges. Discover valid attributes
472+
via `/attributes` and valid values via `/attributes/{attribute}/values`. For anything
473+
these structured filters can't express, use `/sql`."""
378474
return C().cohort.build_manifest(
379475
req.filters, page=req.page, page_size=req.page_size, include_rows=req.include_rows
380476
)
381477

382-
@app.post(f"{API_PREFIX}/cohort/manifest.txt", tags=["cohort"])
478+
@app.post(
479+
f"{API_PREFIX}/cohort/manifest.txt",
480+
tags=["cohort"],
481+
summary="Cohort manifest (plain text)",
482+
)
383483
def cohort_manifest_text(req: ManifestTextRequest):
484+
"""Return a plain-text manifest of public download URLs (one `s3://` or `gs://` per
485+
series) for a filtered cohort, up to `limit` lines. `source` is `aws` or `gcs`. These are
486+
anonymous public URLs — fetch them with s5cmd/gsutil or the `idc` CLI. The response is
487+
`text/plain`, one URL per line."""
384488
text = C().manifest.manifest_text(req.filters, source=req.source, limit=req.limit)
385489
return PlainTextResponse(text)
386490

387491
# --- guarded SQL ---
388-
@app.post(f"{API_PREFIX}/sql", response_model=SqlResult, tags=["query"])
492+
@app.post(
493+
f"{API_PREFIX}/sql",
494+
response_model=SqlResult,
495+
tags=["query"],
496+
summary="Run read-only SQL",
497+
)
389498
def sql(req: SqlRequest):
499+
"""Run a single read-only SQL `SELECT`/`WITH` against the IDC index (DuckDB) and return
500+
the rows. Use it for anything the cohort filters can't express — GROUP BY, joins across
501+
tables, custom aggregations, or filtering on columns that aren't filter attributes. The
502+
attributes from `/attributes` are a curated subset of `index`, so `/sql` is how you reach
503+
the rest: other `index` columns (e.g. `SeriesDescription`, `PatientAge`) and columns that
504+
live only in a specialized index (e.g. segmented anatomy in `seg_index`). The connection
505+
is sandboxed: no writes, no file or network access, one statement only. Get correct table
506+
and column names from `/tables` and `/tables/{table}` first; the main table is `index`,
507+
and per-collection clinical tables are in the `clinical` schema. The result carries a
508+
`truncated` flag — when `true` you did not get every row, so narrow or aggregate the
509+
query, or raise `max_rows` (clamped to a server ceiling) and re-check."""
390510
return C().query.run_sql(req.sql, max_rows=req.max_rows)
391511

392512
# --- viewer / citations / licenses ---
393-
@app.get(f"{API_PREFIX}/viewer-url", response_model=ViewerURL, tags=["tools"])
513+
@app.get(
514+
f"{API_PREFIX}/viewer-url",
515+
response_model=ViewerURL,
516+
tags=["tools"],
517+
summary="Viewer URL",
518+
)
394519
def viewer_url(
395520
series_instance_uid: str | None = Query(
396521
None,
@@ -402,23 +527,50 @@ def viewer_url(
402527
),
403528
viewer: str | None = Query(None, examples=["ohif_v3"]),
404529
):
530+
"""Return a browser viewer URL (OHIF for radiology, Slim for slide microscopy) for a
531+
series or study, so images can be viewed without downloading. Provide a
532+
`series_instance_uid` or `study_instance_uid` (obtain one from a cohort manifest or
533+
`/sql`)."""
405534
return C().viewer.get_viewer_url(
406535
series_instance_uid=series_instance_uid,
407536
study_instance_uid=study_instance_uid,
408537
viewer=viewer,
409538
)
410539

411-
@app.post(f"{API_PREFIX}/citations", response_model=CitationsResult, tags=["tools"])
540+
@app.post(
541+
f"{API_PREFIX}/citations",
542+
response_model=CitationsResult,
543+
tags=["tools"],
544+
summary="Cohort citations",
545+
)
412546
def citations(req: CitationsRequest):
547+
"""Return the publications to cite for a cohort: per-dataset citations (from the cohort's
548+
source DOIs) in `citations`, plus the IDC paper in `idc_acknowledgment`.
549+
`citation_format` is one of `apa`, `bibtex`, `csl-json`, `turtle`. When publishing
550+
results that use IDC data, include the per-dataset citations and acknowledge IDC itself
551+
(see the `recommendation` field)."""
413552
return C().citations.get_citations(req.filters, citation_format=req.citation_format)
414553

415-
@app.post(f"{API_PREFIX}/licenses", response_model=LicensesResult, tags=["tools"])
554+
@app.post(
555+
f"{API_PREFIX}/licenses",
556+
response_model=LicensesResult,
557+
tags=["tools"],
558+
summary="Cohort license breakdown",
559+
)
416560
def licenses(filters: CohortFilters):
561+
"""Return the license breakdown (series count and size per license) for a cohort. Use it
562+
to check whether the data is commercial-friendly (CC BY) or non-commercial only
563+
(CC BY-NC) before reuse."""
417564
return C().licenses.get_licenses(filters)
418565

419566
# --- download (local mode only) ---
420-
@app.post(f"{API_PREFIX}/download", tags=["tools"])
567+
@app.post(f"{API_PREFIX}/download", tags=["tools"], summary="Download cohort (local only)")
421568
def download(req: DownloadRequest):
569+
"""Download DICOM files for a selection to a local directory (via idc-index/s5cmd). This
570+
works only when the API runs locally on the caller's machine; a hosted deployment returns
571+
a clear error, in which case use `/cohort/manifest.txt` or the idc commands instead.
572+
Start with `dry_run=true` to report the download size, then set `dry_run=false` to
573+
transfer."""
422574
return C().download.download(
423575
download_dir=req.download_dir,
424576
collection_id=req.collection_id,

0 commit comments

Comments
 (0)