A small, opinionated REST gateway for DICOM Query/Retrieve (C-FIND, C-MOVE) operations against PACS systems. Built with FastAPI and pynetdicom.
The gateway translates HTTP+JSON requests into DICOM network operations, so applications can integrate with PACS systems without needing to embed a DICOM toolkit themselves.
POST /q— Query (C-FIND) studies for a patient, with optional modality / age / result-count filtering.POST /qr— Query then retrieve matching studies via C-MOVE to a destination AE.GET /health,GET /version— Liveness + version endpoints.- Auto-generated OpenAPI docs at
/docs(Swagger UI) and/redoc. - Structured JSON errors with correlation IDs; exception details are never leaked to clients.
- Pydantic request validation; sensible DICOM-aware rules (AE title charset, IP, port range, modality codes).
- Single-association, multi-modality C-FIND (one round-trip per query).
- Native pynetdicom timeouts (ACSE / DIMSE / network) — no thread- based cancellation hacks.
- Runs as a non-root user inside a slim Docker image, with a built-in healthcheck.
pip install -e ".[dev]"
make run
# → http://localhost:6000/docsdocker build -t dicom-qr-gateway:local .
docker run --rm -p 8888:6000 dicom-qr-gateway:local
# → http://localhost:8888/docsSee SECURITY.md for a reverse-proxy template
(nginx, traefik, …) to place in front of the gateway in production.
Request body (JSON):
| Field | Type | Required | Description |
|---|---|---|---|
pacs_ip |
string | yes | IPv4/IPv6 address or hostname of the PACS. |
pacs_port |
int (1-65535) | yes | TCP port. |
pacs_ae_title |
string | yes | AE title of the PACS (1-16 chars). |
c_find_ae_title |
string | yes | AE title used as the C-FIND SCU. |
patient_id |
string (1-64) | yes | Patient ID (no *, ?, \). |
modalities |
string[] | no | Restrict by modality (e.g. ["CT","MR"]). |
max_years_old |
int (1-200) | no | Drop studies older than N years. |
max_results |
int (1-1000) | no | Return at most N most-recent studies. |
Example:
curl -sS http://localhost:6000/q \
-H 'content-type: application/json' \
-d '{
"pacs_ip": "127.0.0.1",
"pacs_port": 4242,
"pacs_ae_title": "ORTHANCA",
"c_find_ae_title": "SCU",
"patient_id": "1112223333",
"modalities": ["CT","MR"],
"max_years_old": 5,
"max_results": 10
}'Response (200):
{
"operation": "C-FIND",
"status": "ok",
"patient_id": "1112223333",
"study_details": [
{
"PatientName": "DOE^JOHN",
"PatientID": "1112223333",
"DateOfBirth": "19700101",
"StudyInstanceUID": "1.2.3.4",
"StudyDescription": "CT ABDOMEN",
"ModalitiesInStudy": ["CT"],
"StudyDate": "20240101",
"StudyTime": "120000",
"AccessionNumber": "ACC-1",
"ReferringPhysicianName": "REF^MD",
"PerformingPhysicianName": "PERF^MD",
"InstitutionName": "Hospital"
}
]
}Same fields as /q, plus one additional required field:
| Field | Type | Required | Description |
|---|---|---|---|
c_move_ae_title |
string | yes | AE title of the C-MOVE destination (1-16 chars). |
Response on full success (200) or partial success (207):
{
"operation": "C-MOVE",
"status": "ok",
"message": "Operation completed successfully",
"success_count": 2,
"total_count": 2,
"patient_id": "1112223333",
"study_details": [ /* ... */ ]
}| Code | Meaning |
|---|---|
| 200 | Success. |
| 207 | C-MOVE partially successful (status: "partial"). |
| 400 | Invalid request payload. |
| 404 | No studies matched the query. |
| 500 | Unexpected server error. |
| 502 | DICOM operation failed (e.g. association rejected, network error). |
Error responses (400/500/502) include a correlation_id in the body and
an X-Correlation-ID response header so clients can cross-reference
issues with server-side logs. The 404 response does not include
correlation_id but the header is still present.
All settings are environment variables:
| Variable | Default | Description |
|---|---|---|
DICOM_DEBUG |
false |
Verbose logging (sets app + pynetdicom to DEBUG). |
LOG_TO_CONSOLE |
true |
Attach a stdout log handler. |
ACSE_TIMEOUT |
10 |
Association negotiation timeout (s). |
DIMSE_TIMEOUT |
10 |
DIMSE message timeout (s). |
NETWORK_TIMEOUT |
10 |
Low-level network timeout (s). |
CONNECTION_TIMEOUT |
10 |
TCP connection timeout (s). |
make dev # install with dev extras
make lint # ruff check + ruff format --check + mypy
make format # ruff format + ruff --fix
make test # pytest
make cov # pytest with HTML coverage reportCoverage target: ≥ 90 % (enforced by CI).
.
├── app.py # FastAPI app factory
├── config.py # Env-driven config
├── __version__.py
├── api/
│ ├── schemas.py # Pydantic request/response models
│ └── endpoints.py # FastAPI routers
├── services/
│ ├── dicom_client.py # pynetdicom wrapper (C-FIND, C-MOVE)
│ ├── qr_service.py # Orchestration + filtering
│ └── study_mapper.py # Dataset → DTO mapping
├── tests/ # pytest suite
├── samples/pacs/ # Two-Orthanc test harness (see its README)
└── Dockerfile
- The gateway does not authenticate HTTP clients by default. Deploy behind a reverse proxy (nginx, traefik) or API gateway that enforces authN/authZ appropriate to your environment.
- PACS credentials (AE titles, IPs, ports) are supplied per request; the gateway stores no state.
- Exception text is never returned to clients; use the
correlation_idto look up server-side logs. - Run the container as the provided
appuser (default). SeeSECURITY.md.
# bump version + tag + push
echo '__version__ = "X.Y.Z"' > __version__.py
git commit -am "chore: release vX.Y.Z"
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push && git push --tagsCI runs on every tag push.