PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
wado_uri_endpoints.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
15// IMPORTANT: Include Crow FIRST before any PACS headers to avoid forward
16// declaration conflicts
17#include "crow.h"
18
19// Workaround for Windows: DELETE is defined as a macro in <winnt.h>
20// which conflicts with crow::HTTPMethod::DELETE
21#ifdef DELETE
22#undef DELETE
23#endif
24
33
34#include <cstdlib>
35#include <filesystem>
36#include <fstream>
37#include <sstream>
38#include <string>
39#include <vector>
40
41namespace kcenon::pacs::web {
42
43namespace wado_uri {
44
46 const char* study_uid,
47 const char* series_uid,
48 const char* object_uid,
49 const char* content_type,
50 const char* transfer_syntax,
51 const char* anonymize,
52 const char* rows,
53 const char* columns,
54 const char* window_center,
55 const char* window_width,
56 const char* frame_number) {
57
58 wado_uri_request request;
59
60 if (study_uid != nullptr) {
61 request.study_uid = study_uid;
62 }
63 if (series_uid != nullptr) {
64 request.series_uid = series_uid;
65 }
66 if (object_uid != nullptr) {
67 request.object_uid = object_uid;
68 }
69 if (content_type != nullptr && content_type[0] != '\0') {
70 request.content_type = content_type;
71 }
72 if (transfer_syntax != nullptr && transfer_syntax[0] != '\0') {
73 request.transfer_syntax = std::string(transfer_syntax);
74 }
75 if (anonymize != nullptr) {
76 std::string anon_str(anonymize);
77 request.anonymize = (anon_str == "yes" || anon_str == "true"
78 || anon_str == "1");
79 }
80 if (rows != nullptr) {
81 try {
82 int val = std::stoi(rows);
83 if (val > 0 && val <= 65535) {
84 request.rows = static_cast<uint16_t>(val);
85 }
86 } catch (...) {
87 // Ignore invalid values
88 }
89 }
90 if (columns != nullptr) {
91 try {
92 int val = std::stoi(columns);
93 if (val > 0 && val <= 65535) {
94 request.columns = static_cast<uint16_t>(val);
95 }
96 } catch (...) {
97 // Ignore invalid values
98 }
99 }
100 if (window_center != nullptr) {
101 try {
102 request.window_center = std::stod(window_center);
103 } catch (...) {
104 // Ignore invalid values
105 }
106 }
107 if (window_width != nullptr) {
108 try {
109 request.window_width = std::stod(window_width);
110 } catch (...) {
111 // Ignore invalid values
112 }
113 }
114 if (frame_number != nullptr) {
115 try {
116 int val = std::stoi(frame_number);
117 if (val > 0) {
118 request.frame_number = static_cast<uint32_t>(val);
119 }
120 } catch (...) {
121 // Ignore invalid values
122 }
123 }
124
125 return request;
126}
127
129 if (request.study_uid.empty()) {
131 400, "MISSING_PARAMETER", "studyUID parameter is required");
132 }
133 if (request.series_uid.empty()) {
135 400, "MISSING_PARAMETER", "seriesUID parameter is required");
136 }
137 if (request.object_uid.empty()) {
139 400, "MISSING_PARAMETER", "objectUID parameter is required");
140 }
143 406, "UNSUPPORTED_MEDIA_TYPE",
144 "Unsupported contentType: " + request.content_type);
145 }
146 return validation_result::ok();
147}
148
149bool is_supported_content_type(std::string_view content_type) {
150 return content_type == "application/dicom"
151 || content_type == "image/jpeg"
152 || content_type == "image/png"
153 || content_type == "application/dicom+json";
154}
155
156} // namespace wado_uri
157
158namespace endpoints {
159
160namespace {
161
165void add_cors_headers(crow::response& res, const rest_server_context& ctx) {
166 if (ctx.config != nullptr && !ctx.config->cors_allowed_origins.empty()) {
167 res.add_header("Access-Control-Allow-Origin",
168 ctx.config->cors_allowed_origins);
169 }
170}
171
175std::vector<uint8_t> read_file_bytes(const std::filesystem::path& path) {
176 std::ifstream file(path, std::ios::binary | std::ios::ate);
177 if (!file) {
178 return {};
179 }
180
181 auto size = file.tellg();
182 file.seekg(0, std::ios::beg);
183
184 std::vector<uint8_t> buffer(static_cast<size_t>(size));
185 if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
186 return {};
187 }
188
189 return buffer;
190}
191
195crow::response handle_dicom_response(
196 const std::string& file_path,
197 const rest_server_context& ctx) {
198 crow::response res;
199 add_cors_headers(res, ctx);
200
201 auto data = read_file_bytes(file_path);
202 if (data.empty()) {
203 res.code = 500;
204 res.add_header("Content-Type", "application/json");
205 res.body = make_error_json("READ_ERROR", "Failed to read DICOM file");
206 return res;
207 }
208
209 res.code = 200;
210 res.add_header("Content-Type", "application/dicom");
211 res.body = std::string(reinterpret_cast<char*>(data.data()), data.size());
212 return res;
213}
214
218crow::response handle_rendered_response(
219 const std::string& file_path,
220 const wado_uri::wado_uri_request& request,
221 const rest_server_context& ctx) {
222 crow::response res;
223 add_cors_headers(res, ctx);
224
225 dicomweb::rendered_params params;
226
227 if (request.content_type == "image/png") {
228 params.format = dicomweb::rendered_format::png;
229 } else {
230 params.format = dicomweb::rendered_format::jpeg;
231 }
232
233 params.window_center = request.window_center;
234 params.window_width = request.window_width;
235
236 if (request.rows.has_value()) {
237 params.viewport_height = request.rows.value();
238 }
239 if (request.columns.has_value()) {
240 params.viewport_width = request.columns.value();
241 }
242 if (request.frame_number.has_value()) {
243 params.frame = request.frame_number.value();
244 }
245
246 auto result = dicomweb::render_dicom_image(file_path, params);
247
248 if (!result.success) {
249 res.code = 400;
250 res.add_header("Content-Type", "application/json");
251 res.body = make_error_json("RENDER_ERROR", result.error_message);
252 return res;
253 }
254
255 res.code = 200;
256 res.add_header("Content-Type", result.content_type);
257 res.body = std::string(
258 reinterpret_cast<char*>(result.data.data()),
259 result.data.size());
260 return res;
261}
262
266crow::response handle_dicom_json_response(
267 const std::string& file_path,
268 const rest_server_context& ctx) {
269 crow::response res;
270 add_cors_headers(res, ctx);
271
272 auto data = read_file_bytes(file_path);
273 if (data.empty()) {
274 res.code = 500;
275 res.add_header("Content-Type", "application/json");
276 res.body = make_error_json("READ_ERROR", "Failed to read DICOM file");
277 return res;
278 }
279
280 auto dicom_result = core::dicom_file::from_bytes(
281 std::span<const uint8_t>(data.data(), data.size()));
282 if (dicom_result.is_err()) {
283 res.code = 500;
284 res.add_header("Content-Type", "application/json");
285 res.body = make_error_json("PARSE_ERROR", "Failed to parse DICOM file");
286 return res;
287 }
288
290 dicom_result.value().dataset(), false, "");
291
292 res.code = 200;
293 res.add_header("Content-Type", "application/dicom+json");
294 res.body = "[" + json + "]";
295 return res;
296}
297
298} // anonymous namespace
299
301 crow::SimpleApp& app,
302 std::shared_ptr<rest_server_context> ctx) {
303
304 // GET /wado?requestType=WADO&studyUID=...&seriesUID=...&objectUID=...
305 CROW_ROUTE(app, "/wado")
306 .methods(crow::HTTPMethod::GET)(
307 [ctx](const crow::request& req) {
308 crow::response res;
309 add_cors_headers(res, *ctx);
310
311 // OAuth 2.0 / legacy auth check (read scope for WADO-URI)
312 if (ctx->oauth2 && ctx->oauth2->enabled()) {
313 auto auth = ctx->oauth2->authenticate(req, res);
314 if (!auth) return res;
315 if (!ctx->oauth2->require_any_scope(
316 auth->claims, res,
317 {"dicomweb.read"})) {
318 return res;
319 }
320 }
321
322 // Validate requestType parameter
323 auto request_type = req.url_params.get("requestType");
324 if (request_type == nullptr
325 || std::string(request_type) != "WADO") {
326 res.code = 400;
327 res.add_header("Content-Type", "application/json");
328 res.body = make_error_json(
329 "INVALID_REQUEST_TYPE",
330 "requestType must be 'WADO'");
331 return res;
332 }
333
334 // Check database availability
335 if (!ctx->database) {
336 res.code = 503;
337 res.add_header("Content-Type", "application/json");
338 res.body = make_error_json(
339 "DATABASE_UNAVAILABLE", "Database not configured");
340 return res;
341 }
342
343 // Parse and validate parameters
344 auto wado_request = wado_uri::parse_wado_uri_params(
345 req.url_params.get("studyUID"),
346 req.url_params.get("seriesUID"),
347 req.url_params.get("objectUID"),
348 req.url_params.get("contentType"),
349 req.url_params.get("transferSyntax"),
350 req.url_params.get("anonymize"),
351 req.url_params.get("rows"),
352 req.url_params.get("columns"),
353 req.url_params.get("windowCenter"),
354 req.url_params.get("windowWidth"),
355 req.url_params.get("frameNumber"));
356
357 auto validation = wado_uri::validate_wado_uri_request(
358 wado_request);
359 if (!validation.valid) {
360 res.code = validation.http_status;
361 res.add_header("Content-Type", "application/json");
362 res.body = make_error_json(
363 validation.error_code, validation.error_message);
364 return res;
365 }
366
367 // Look up the DICOM instance
368 auto file_path_result = ctx->database->get_file_path(
369 wado_request.object_uid);
370 if (!file_path_result.is_ok()) {
371 res.code = 500;
372 res.add_header("Content-Type", "application/json");
373 res.body = make_error_json(
374 "QUERY_ERROR", file_path_result.error().message);
375 return res;
376 }
377 const auto& file_path = file_path_result.value();
378 if (!file_path) {
379 res.code = 404;
380 res.add_header("Content-Type", "application/json");
381 res.body = make_error_json(
382 "NOT_FOUND", "DICOM instance not found");
383 return res;
384 }
385
386 // Route to appropriate handler based on content type
387 if (wado_request.content_type == "application/dicom") {
388 return handle_dicom_response(*file_path, *ctx);
389 }
390 if (wado_request.content_type == "image/jpeg"
391 || wado_request.content_type == "image/png") {
392 return handle_rendered_response(
393 *file_path, wado_request, *ctx);
394 }
395 if (wado_request.content_type == "application/dicom+json") {
396 return handle_dicom_json_response(*file_path, *ctx);
397 }
398
399 // Should not reach here due to validation, but handle defensively
400 res.code = 406;
401 res.add_header("Content-Type", "application/json");
402 res.body = make_error_json(
403 "UNSUPPORTED_MEDIA_TYPE",
404 "Unsupported contentType: " + wado_request.content_type);
405 return res;
406 });
407}
408
409} // namespace endpoints
410
411} // namespace kcenon::pacs::web
static auto from_bytes(std::span< const uint8_t > data) -> kcenon::pacs::Result< dicom_file >
Parse a DICOM file from raw bytes.
DICOM Part 10 file handling for reading/writing DICOM files.
DICOMweb (WADO-RS) API endpoints for REST server.
PACS index database for metadata storage and retrieval.
auto dataset_to_dicom_json(const core::dicom_dataset &dataset, bool include_bulk_data=false, std::string_view bulk_data_uri_prefix="") -> std::string
Convert a DICOM dataset to DicomJSON format.
auto render_dicom_image(std::string_view file_path, const rendered_params &params) -> rendered_result
Render a DICOM image to JPEG or PNG.
void register_wado_uri_endpoints_impl(crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
validation_result validate_wado_uri_request(const wado_uri_request &request)
Validate a WADO-URI request.
wado_uri_request parse_wado_uri_params(const char *study_uid, const char *series_uid, const char *object_uid, const char *content_type, const char *transfer_syntax, const char *anonymize, const char *rows, const char *columns, const char *window_center, const char *window_width, const char *frame_number)
Parse WADO-URI query parameters from an HTTP request.
bool is_supported_content_type(std::string_view content_type)
Check if a content type is supported by WADO-URI.
std::string make_error_json(std::string_view code, std::string_view message)
Create JSON error response body with details.
Definition rest_types.h:79
OAuth 2.0 middleware for DICOMweb endpoint authorization.
Configuration for REST API server.
Common types and utilities for REST API.
Result of WADO-URI request validation.
static validation_result error(int status, std::string code, std::string message)
Parsed WADO-URI request parameters.
std::optional< double > window_center
Window center for rendered images (optional)
std::optional< uint16_t > rows
Output viewport rows (optional, for rendered images)
std::optional< uint16_t > columns
Output viewport columns (optional, for rendered images)
std::string series_uid
Series Instance UID (required)
std::string content_type
Requested content type (default: application/dicom)
bool anonymize
Whether to anonymize the response (optional)
std::string object_uid
SOP Instance UID (required)
std::optional< uint32_t > frame_number
Frame number for multi-frame images (1-based, optional)
std::optional< double > window_width
Window width for rendered images (optional)
std::optional< std::string > transfer_syntax
Transfer Syntax UID for transcoding (optional)
std::string study_uid
Study Instance UID (required)
System API endpoints for REST server.
WADO-URI (Web Access to DICOM Objects — URI-based) API endpoints.