Files
dicom-iso/docs/go-mkiso-design.md
2026-06-05 08:11:44 +07:00

431 lines
16 KiB
Markdown

# Go mkiso Server — Architecture & Design
## Overview
A Go HTTP server (stdlib only, no frameworks) that replaces the PHP mkiso scripts. It handles DICOM retrieval from PACS (via dcmtk CLI), ISO creation (via genisoimage), and streaming download — all behind JWT authentication.
---
## Project Structure
```
mkiso-server/
├── config.yaml # Configuration (PACS, AE, binaries, JWT)
├── config.example.yaml # Template config without secrets
├── go.mod
├── main.go # Entrypoint: load config, wire deps, start server
├── internal/
│ ├── config/
│ │ └── config.go # Config struct, YAML loading
│ ├── route/
│ │ └── route.go # Route registration, middleware chain
│ ├── middleware/
│ │ └── auth.go # JWT middleware (slog, context injection)
│ ├── handler/
│ │ ├── auth.go # POST /api/auth/login
│ │ ├── iso.go # ISO download handlers
│ │ ├── print.go # Print ISO (stub)
│ │ └── health.go # GET /api/health
│ ├── service/
│ │ ├── dicom.go # DICOM operations (storescp + movescu orchestration)
│ │ ├── iso.go # ISO creation + cleanup
│ │ └── auth.go # JWT generation + validation
│ └── repo/
│ └── patient.go # HTTP client to patient-data API
├── pkg/
│ └── dicom/
│ └── command.go # Low-level dcmtk binary execution wrapper
└── testdata/
└── ... # Test fixtures
```
---
## External Dependencies
| Binary | Package | Config Key |
|--------|---------|------------|
| `storescp` | dcmtk | `dcmtk.storescp` |
| `movescu` | dcmtk | `dcmtk.movescu` |
| `genisoimage` | `genisoimage` (apt) | `tools.genisoimage` |
**All binary paths are in `config.yaml`.** No hardcoded paths.
---
## config.yaml Design
```yaml
server:
port: 8080
read_timeout: 300s # ISO generation can take minutes
write_timeout: 600s
auth:
enabled: false # Stage 1: false (no auth). Stage 2: true
api_key: "" # Stage 2: shared key, checked via X-API-Key header
dcmtk:
storescp: "/usr/local/bin/storescp"
movescu: "/usr/local/bin/movescu"
storescu: "/usr/local/bin/storescu"
tools:
genisoimage: "/usr/bin/genisoimage"
pacs:
ae_title: "ABPACS"
host: "localhost"
port: 11112
our_ae:
ae_title: "CDRECORD"
base_port: 10104 # storescp listen port range start
port_range: 100 # 10104-10203 (100 unique ports for concurrent requests)
patient_api:
base_url: "http://his-server/api" # External patient data API
timeout: 10s
cd_publisher:
host: "172.16.0.120" # CD Publisher server (for print/relay)
port: 104 # DICOM port on CD Publisher
iso:
microdicom_path: "/var/www/html/microdicom" # DICOM viewer bootstrap files
temp_dir: "/tmp" # Working directory for dicomdir_*
```
---
## API Endpoints
### Stage 1 — All public (auth.enabled = false)
| Method | Path | Description | Replaces |
|--------|------|-------------|----------|
| `GET` | `/api/health` | Health check + dependency status | — |
| `GET` | `/api/iso/download?accession_number=X` | Single accession → ISO download | `mkiso.php` |
| `GET` | `/api/iso/download-multiple?accession_numbers=X,Y,Z` | Multi-accession → ISO download with patient-name filename | `mkiso_multiple.php` |
| `GET` | `/api/iso/print?accession_number=X` | DICOM relay → CD Publisher (auto-detects comma → multi) | `send_rimage_multiple.php` |
| `GET` | `/api/iso/print?accession_number=X,Y,Z` | (same endpoint — comma triggers multi) | `send_rimage_multiple.php` |
> **Note:** `print` and `print-multiple` are merged into one endpoint. The handler checks for commas in `accession_number` to decide single vs multi mode. This matches the PHP behavior where `send_rimage_multiple.php` handles both via `explode(",", ...)`.
### Stage 2 — Authenticated (auth.enabled = true)
Same endpoints, but `/api/iso/*` requires `X-API-Key` header. `/api/health` stays public.
```
GET /api/iso/download?accession_number=X
X-API-Key: <shared-key-from-config>
GET /api/iso/print?accession_number=X,Y,Z
X-API-Key: <shared-key-from-config>
```
---
## Authentication (Stage 2)
### Recommended: API Key via nginx proxy injection
**Why:** Zero changes to the HIS `pacs_downloadiso` module. The existing `window.open()` calls continue to work because nginx injects the auth header before proxying to Go.
```
Browser (HIS) nginx (PACS server) Go Server
──────────── ─────────────────── ─────────
window.open("/mkiso.php?..")
────── GET ──────→ add X-API-Key header
───── GET + key ──────→ validate key → 200 OK
←─── ISO stream ───────
←── ISO download ──
```
**Config:**
```yaml
auth:
enabled: true # Stage 1: false, Stage 2: true
api_key: "changeme" # Shared key — keep secret
```
**Middleware (~10 lines, stdlib only):**
```go
func APIKey(expectedKey string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-Key") != expectedKey {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
return
}
next.ServeHTTP(w, r)
})
}
}
```
**nginx reverse proxy config:**
```nginx
# Map old PHP paths to Go API + inject auth
location = /mkiso.php {
proxy_set_header X-API-Key "${MKISO_API_KEY}";
proxy_pass http://127.0.0.1:8080/api/iso/download$is_args$args;
}
location = /mkiso_multiple.php {
proxy_set_header X-API-Key "${MKISO_API_KEY}";
proxy_pass http://127.0.0.1:8080/api/iso/download-multiple$is_args$args;
}
location = /send_rimage_multiple.php {
proxy_set_header X-API-Key "${MKISO_API_KEY}";
proxy_pass http://127.0.0.1:8080/api/iso/print$is_args$args;
}
```
**Route wiring (Stage 2):**
```go
mux.HandleFunc("GET /api/health", handler.Health(cfg)) // always public
apiKey := middleware.APIKey(cfg.Auth.APIKey)
mux.Handle("GET /api/iso/download", apiKey(handler.DownloadSingle(isoSvc)))
mux.Handle("GET /api/iso/download-multiple", apiKey(handler.DownloadMultiple(isoSvc)))
// ... same for print endpoints
```
---
## DICOM Flow (Go — replaces Java dcmqr)
### Single Accession (`/api/iso/download`)
```
1. handler.iso.go: DownloadSingle()
├─ Parse accession_number from query
├─ Create temp dir: /tmp/dicomdir_<uuid>/
├─ service.iso.go: GenerateISO()
│ ├─ service.dicom.go: FetchDICOM()
│ │ ├─ pkg/dicom: StartStoresCP() → storescp as child process
│ │ │ storescp <port> -aet CDRECORD -od <dir> +xa
│ │ ├─ Wait for storescp ready (poll / retry)
│ │ ├─ pkg/dicom: RunMoveSCU() → movescu as child process
│ │ │ movescu -aet CDRECORD -aec ABPACS -aem CDRECORD
│ │ │ localhost 11112 -S -k 0008,0050=<acc>
│ │ │ -k 0010,0020= --no-port
│ │ ├─ Wait for movescu (with timeout)
│ │ └─ pkg/dicom: StopStoresCP() → SIGTERM
│ ├─ Copy microdicom viewer files into temp dir
│ ├─ Run genisoimage
│ │ genisoimage -iso-level 4 -r -V DICOM -o <acc>.iso <dir>
│ └─ Return ISO path
├─ Stream ISO to response (Content-Type: application/octet-stream)
├─ Cleanup temp dir
└─ Return
```
### Multiple Accessions (`/api/iso/download-multiple`)
Same as single but:
1. Start `storescp` ONCE for all accessions
2. Run `movescu` in a loop for each accession number
3. Call **patient API** to get `Nama` for filename
4. ISO filename: `{PATIENT_NAME}-{ACC_LIST}.iso`
5. Cleanup after everything
### Print / DICOM Relay (`/api/iso/print`, `/api/iso/print-multiple`)
Replaces `send_rimage_multiple.php`. Same DICOM fetch as download, then C-STORE relay instead of ISO. Uses patient API for validation (same as download-multiple).
```
1. handler.print.go: PrintISO()
├─ Parse accession_number(s) from query
├─ Call patient API → validate accession, get patient_name
├─ Create temp dir: /tmp/dicomdir_<uuid>/
├─ service.relay.go: RelayToCDPublisher()
│ ├─ Copy microdicom viewer files into temp dir
│ ├─ service.dicom.go: FetchDICOM() → storescp + movescu loop
│ ├─ pkg/dicom: RunStoreSCU() → storescu to CD Publisher
│ │ storescu -aet <our_ae> +sd +r <cd_host> <cd_port> <dir>
│ ├─ Count files sent
│ └─ Return relay result with patient_name
├─ Respond JSON: { status, accessions_sent, patient_name, destination, files_sent }
├─ Cleanup temp dir
└─ Return
```
**Key difference from download:** No genisoimage, no streaming. Instead `storescu +sd +r` (scan directory + recurse) sends all DICOM files to the CD Publisher server. Response is JSON (success/failure), not an ISO file. Patient API is used for accession validation, not just filename.
### Concurrent Safety
```
Port allocation: base_port + hash(request_id) % port_range
Temp dir: /tmp/dicomdir_<uuid>/
```
Multiple incoming requests each get their own `storescp` port and temp directory. No collisions.
---
## Patient Data API Specification
The Go server does NOT access the database directly. It calls an external API for patient data. This is defined in `docs/patient-api-spec.md`.
### Endpoint: `GET /patient/by-accession`
```
Request:
GET {patient_api.base_url}/patient/by-accession?accession_number=MR.2024.001
Authorization: Bearer <service-token> (or API key header)
Response 200:
{
"accession_number": "MR.2024.001",
"medrec_id": "00012345",
"reg_id": "REG-2024-00123",
"patient_name": "JOHN DOE",
"modality": "MR",
"study_description": "MRI Brain"
}
Response 404:
{ "error": "accession_number not found" }
```
This API must be provided by the HIS team (or deploy a thin proxy). The Go server calls it with HTTP GET.
---
## Error Handling
| Scenario | HTTP Status | Response |
|----------|-------------|----------|
| Invalid/missing accession_number | `400` | `{"error": "invalid accession_number"}` |
| PACS unreachable | `502` | `{"error": "PACS unreachable", "detail": "..."}` |
| movescu failed (accession not found) | `404` | `{"error": "no DICOM data for accession_number"}` |
| Patient API unavailable | `502` | `{"error": "patient API unavailable"}` |
| genisoimage failed | `500` | `{"error": "ISO creation failed"}` |
| Print ISO — CD Publisher unreachable | `502` | `{"error": "CD Publisher unreachable", "destination": "..."}` |
| Concurrent port exhaustion | `503` | `{"error": "too many concurrent requests"}` |
| Timeout (C-MOVE) | `504` | `{"error": "PACS move timed out"}` |
**Stage 2 additions:**
| Scenario | HTTP Status | Response |
|----------|-------------|----------|
| Missing/invalid API key | `401` | `{"error": "unauthorized"}` |
---
## Logging (slog)
All components use `log/slog`. Key log points:
```go
slog.Info("starting storescp", "port", port, "dir", dir, "pid", pid)
slog.Info("running movescu", "accession", acc, "pacs", pacs)
slog.Info("iso created", "path", isoPath, "size_bytes", size, "duration_ms", d)
slog.Error("movescu failed", "accession", acc, "exit_code", code, "stderr", err)
slog.Warn("patient API slow", "latency_ms", lat)
```
---
## Route Wiring (stdlib net/http)
```go
// internal/route/route.go
// Stage 1 — no auth
func Setup(cfg *config.Config, isoSvc service.ISOService, relaySvc service.RelayService) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", handler.Health(cfg))
mux.HandleFunc("GET /api/iso/download", handler.DownloadSingle(isoSvc))
mux.HandleFunc("GET /api/iso/download-multiple", handler.DownloadMultiple(isoSvc))
mux.HandleFunc("GET /api/iso/print", handler.PrintISO(relaySvc)) // auto-detects single vs multi
return middleware.Recovery(middleware.RequestID(mux))
}
// Stage 2 — API key auth on /api/iso/*
func SetupSecure(cfg *config.Config, isoSvc service.ISOService, relaySvc service.RelayService) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", handler.Health(cfg)) // always public
apiKey := middleware.APIKey(cfg.Auth.APIKey)
mux.Handle("GET /api/iso/download", apiKey(http.HandlerFunc(handler.DownloadSingle(isoSvc))))
mux.Handle("GET /api/iso/download-multiple", apiKey(http.HandlerFunc(handler.DownloadMultiple(isoSvc))))
mux.Handle("GET /api/iso/print", apiKey(http.HandlerFunc(handler.PrintISO(relaySvc))))
return middleware.Recovery(middleware.RequestID(mux))
}
```
---
## Repository Pattern Boundaries
```
┌─────────────────────────────────────────────────────────┐
│ handler/ HTTP layer │
│ Parse request, call service, write response │
│ No business logic │
├─────────────────────────────────────────────────────────┤
│ service/ Business logic │
│ Orchestrate DICOM fetch + ISO creation + cleanup │
│ Calls repo for external data, pkg/dicom for commands │
│ Uses slog for logging │
├─────────────────────────────────────────────────────────┤
│ repo/ External data access │
│ patient.go: HTTP client to patient-data API │
│ Uses net/http with timeout + retry │
├─────────────────────────────────────────────────────────┤
│ pkg/dicom/ DICOM command execution │
│ Low-level: spawn storescp/movescu, monitor, kill │
│ Returns (filesRetrieved int, error) │
│ Pure os/exec, no business logic │
└─────────────────────────────────────────────────────────┘
```
---
## Replacement Mapping
| Old (PHP on PACS server) | New (Go on PACS server) |
|--------------------------|-------------------------|
| `mkiso.php?accession_number=X` | `GET /api/iso/download?accession_number=X` |
| `mkiso_multiple.php?accession_number=X,Y,Z` | `GET /api/iso/download-multiple?accession_numbers=X,Y,Z` |
| `send_rimage_multiple.php?accession_number=X` | `GET /api/iso/print?accession_number=X` |
| `send_rimage_multiple.php?accession_number=X,Y,Z` | `GET /api/iso/print?accession_number=X,Y,Z` (same endpoint, comma auto-detect) |
| Direct DB: `pacsdb_his``pacs_result_series`, `medrec` | HTTP: patient API |
### HIS Module Impact
The `pacs_downloadiso` module JS opens `window.open("http://{PACS_HOST}/mkiso.php?accession_number=X")`.
**Recommended: nginx reverse proxy** — zero HIS code changes, works in both stages:
```nginx
# Map old PHP paths to new Go API paths
# mkiso.php → /api/iso/download
location = /mkiso.php {
proxy_set_header X-API-Key "${MKISO_API_KEY}"; # Stage 2 only
proxy_pass http://127.0.0.1:8080/api/iso/download$is_args$args;
}
# mkiso_multiple.php → /api/iso/download-multiple
location = /mkiso_multiple.php {
proxy_set_header X-API-Key "${MKISO_API_KEY}";
proxy_pass http://127.0.0.1:8080/api/iso/download-multiple$is_args$args;
}
# send_rimage_multiple.php → /api/iso/print
# (single handler auto-detects commas for multi mode)
location = /send_rimage_multiple.php {
proxy_set_header X-API-Key "${MKISO_API_KEY}";
proxy_pass http://127.0.0.1:8080/api/iso/print$is_args$args;
}
```