@@ -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