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

16 KiB

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

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)

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:

auth:
  enabled: true           # Stage 1: false, Stage 2: true
  api_key: "changeme"     # Shared key — keep secret

Middleware (~10 lines, stdlib only):

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:

# 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):

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:

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)

// 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_hispacs_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:

# 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;
}