PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
dicomweb_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
13// IMPORTANT: Include Crow FIRST before any PACS headers to avoid forward
14// declaration conflicts
15#include "crow.h"
16
17// Workaround for Windows: DELETE is defined as a macro in <winnt.h>
18// which conflicts with crow::HTTPMethod::DELETE
19#ifdef DELETE
20#undef DELETE
21#endif
22
42
43#include <algorithm>
44#include <chrono>
45#include <cstdint>
46#include <filesystem>
47#include <fstream>
48#include <iomanip>
49#include <random>
50#include <sstream>
51
53
54namespace {
55
59std::string trim(std::string_view s) {
60 auto start = s.find_first_not_of(" \t\r\n");
61 if (start == std::string_view::npos) {
62 return "";
63 }
64 auto end = s.find_last_not_of(" \t\r\n");
65 return std::string(s.substr(start, end - start + 1));
66}
67
71std::vector<std::string> split(std::string_view s, char delimiter) {
72 std::vector<std::string> result;
73 size_t start = 0;
74 size_t end = s.find(delimiter);
75
76 while (end != std::string_view::npos) {
77 result.push_back(std::string(s.substr(start, end - start)));
78 start = end + 1;
79 end = s.find(delimiter, start);
80 }
81 result.push_back(std::string(s.substr(start)));
82 return result;
83}
84
88std::string vr_enum_to_string(kcenon::pacs::encoding::vr_type vr) {
89 using vr_type = kcenon::pacs::encoding::vr_type;
90 switch (vr) {
91 case vr_type::AE: return "AE";
92 case vr_type::AS: return "AS";
93 case vr_type::AT: return "AT";
94 case vr_type::CS: return "CS";
95 case vr_type::DA: return "DA";
96 case vr_type::DS: return "DS";
97 case vr_type::DT: return "DT";
98 case vr_type::FD: return "FD";
99 case vr_type::FL: return "FL";
100 case vr_type::IS: return "IS";
101 case vr_type::LO: return "LO";
102 case vr_type::LT: return "LT";
103 case vr_type::OB: return "OB";
104 case vr_type::OD: return "OD";
105 case vr_type::OF: return "OF";
106 case vr_type::OL: return "OL";
107 case vr_type::OW: return "OW";
108 case vr_type::PN: return "PN";
109 case vr_type::SH: return "SH";
110 case vr_type::SL: return "SL";
111 case vr_type::SQ: return "SQ";
112 case vr_type::SS: return "SS";
113 case vr_type::ST: return "ST";
114 case vr_type::TM: return "TM";
115 case vr_type::UC: return "UC";
116 case vr_type::UI: return "UI";
117 case vr_type::UL: return "UL";
118 case vr_type::UN: return "UN";
119 case vr_type::UR: return "UR";
120 case vr_type::US: return "US";
121 case vr_type::UT: return "UT";
122 default: return "UN";
123 }
124}
125
126} // anonymous namespace
127
128// ============================================================================
129// Accept Header Parsing
130// ============================================================================
131
132auto parse_accept_header(std::string_view accept_header)
133 -> std::vector<accept_info> {
134 std::vector<accept_info> result;
135
136 if (accept_header.empty()) {
137 // Default to application/dicom
138 result.push_back({std::string(media_type::dicom), "", 1.0f});
139 return result;
140 }
141
142 auto parts = split(accept_header, ',');
143 for (const auto& part : parts) {
144 accept_info info;
145 auto params = split(part, ';');
146
147 if (params.empty()) {
148 continue;
149 }
150
151 info.media_type = trim(params[0]);
152
153 for (size_t i = 1; i < params.size(); ++i) {
154 auto param = trim(params[i]);
155 if (param.starts_with("q=")) {
156 try {
157 info.quality = std::stof(param.substr(2));
158 } catch (...) {
159 info.quality = 1.0f;
160 }
161 } else if (param.starts_with("transfer-syntax=")) {
162 info.transfer_syntax = param.substr(16);
163 // Remove quotes if present
164 if (!info.transfer_syntax.empty() &&
165 info.transfer_syntax.front() == '"') {
166 info.transfer_syntax = info.transfer_syntax.substr(
167 1, info.transfer_syntax.size() - 2);
168 }
169 }
170 }
171
172 result.push_back(std::move(info));
173 }
174
175 // Sort by quality (descending)
176 std::sort(result.begin(), result.end(),
177 [](const accept_info& a, const accept_info& b) {
178 return a.quality > b.quality;
179 });
180
181 return result;
182}
183
184auto is_acceptable(const std::vector<accept_info>& accept_infos,
185 std::string_view media_type) -> bool {
186 if (accept_infos.empty()) {
187 return true; // Accept all if no Accept header
188 }
189
190 for (const auto& info : accept_infos) {
191 if (info.media_type == "*/*" ||
192 info.media_type == media_type ||
193 (info.media_type.ends_with("/*") &&
194 media_type.starts_with(
195 info.media_type.substr(0, info.media_type.size() - 1)))) {
196 return true;
197 }
198 }
199 return false;
200}
201
202// ============================================================================
203// Multipart Builder Implementation
204// ============================================================================
205
206multipart_builder::multipart_builder(std::string_view content_type)
207 : boundary_(generate_boundary()),
208 default_content_type_(content_type) {}
209
211 std::vector<uint8_t> data,
212 std::optional<std::string_view> content_type) {
213 part p;
214 p.data = std::move(data);
215 p.content_type = content_type.has_value()
216 ? std::string(*content_type)
218 parts_.push_back(std::move(p));
219}
220
222 std::vector<uint8_t> data,
223 std::string_view location,
224 std::optional<std::string_view> content_type) {
225 part p;
226 p.data = std::move(data);
227 p.content_type = content_type.has_value()
228 ? std::string(*content_type)
230 p.location = std::string(location);
231 parts_.push_back(std::move(p));
232}
233
234auto multipart_builder::build() const -> std::string {
235 std::ostringstream oss;
236
237 for (const auto& p : parts_) {
238 oss << "--" << boundary_ << "\r\n";
239 oss << "Content-Type: " << p.content_type << "\r\n";
240 if (!p.location.empty()) {
241 oss << "Content-Location: " << p.location << "\r\n";
242 }
243 oss << "\r\n";
244 oss.write(reinterpret_cast<const char*>(p.data.data()),
245 static_cast<std::streamsize>(p.data.size()));
246 oss << "\r\n";
247 }
248
249 if (!parts_.empty()) {
250 oss << "--" << boundary_ << "--\r\n";
251 }
252
253 return oss.str();
254}
255
256auto multipart_builder::content_type_header() const -> std::string {
257 std::ostringstream oss;
258 oss << "multipart/related; type=\"" << default_content_type_
259 << "\"; boundary=" << boundary_;
260 return oss.str();
261}
262
263auto multipart_builder::boundary() const -> std::string_view {
264 return boundary_;
265}
266
267auto multipart_builder::empty() const noexcept -> bool {
268 return parts_.empty();
269}
270
271auto multipart_builder::size() const noexcept -> size_t {
272 return parts_.size();
273}
274
276 // Generate a unique boundary using timestamp and random number
277 auto now = std::chrono::system_clock::now();
278 auto timestamp = std::chrono::duration_cast<std::chrono::microseconds>(
279 now.time_since_epoch())
280 .count();
281
282 std::random_device rd;
283 std::mt19937 gen(rd());
284 std::uniform_int_distribution<> dis(0, 999999);
285
286 std::ostringstream oss;
287 oss << "----=_Part_" << timestamp << "_" << dis(gen);
288 return oss.str();
289}
290
291// ============================================================================
292// DicomJSON Conversion
293// ============================================================================
294
295auto is_bulk_data_tag(uint32_t tag) -> bool {
296 // Pixel Data and related bulk data tags
297 uint16_t group = static_cast<uint16_t>(tag >> 16);
298
299 // Audio Sample Data is in group 0x50xx (curve data)
300 if (group >= 0x5000 && group <= 0x50FF) {
301 uint16_t element = static_cast<uint16_t>(tag & 0xFFFF);
302 if (element == 0x3000) {
303 return true; // Audio Sample Data
304 }
305 }
306
307 return tag == 0x7FE00010 || // Pixel Data
308 tag == 0x7FE00008 || // Float Pixel Data
309 tag == 0x7FE00009 || // Double Float Pixel Data
310 tag == 0x00420011 || // Encapsulated Document
311 tag == 0x00660023 || // Triangle Point Index List
312 tag == 0x00660024 || // Edge Point Index List
313 tag == 0x00660025 || // Vertex Point Index List
314 tag == 0x00660026 || // Triangle Strip Sequence
315 tag == 0x00660027 || // Triangle Fan Sequence
316 tag == 0x00660028 || // Line Sequence
317 tag == 0x00660029; // Primitive Point Index List
318}
319
320auto vr_to_string(uint16_t vr_code) -> std::string {
321 // Convert 2-byte VR code to string
322 char vr[3] = {static_cast<char>(vr_code & 0xFF),
323 static_cast<char>((vr_code >> 8) & 0xFF),
324 '\0'};
325 return std::string(vr);
326}
327
328// ============================================================================
329// Multipart Parser Implementation (STOW-RS)
330// ============================================================================
331
332auto multipart_parser::extract_boundary(std::string_view content_type)
333 -> std::optional<std::string> {
334 // Find boundary parameter in Content-Type header
335 // Example: multipart/related; type="application/dicom"; boundary=----=_Part_123
336 auto boundary_pos = content_type.find("boundary=");
337 if (boundary_pos == std::string_view::npos) {
338 return std::nullopt;
339 }
340
341 auto value_start = boundary_pos + 9; // length of "boundary="
342 if (value_start >= content_type.size()) {
343 return std::nullopt;
344 }
345
346 // Check if boundary is quoted
347 if (content_type[value_start] == '"') {
348 auto end_quote = content_type.find('"', value_start + 1);
349 if (end_quote == std::string_view::npos) {
350 return std::nullopt;
351 }
352 return std::string(content_type.substr(value_start + 1,
353 end_quote - value_start - 1));
354 }
355
356 // Unquoted boundary - find end (semicolon, space, or end of string)
357 auto end_pos = content_type.find_first_of("; \t", value_start);
358 if (end_pos == std::string_view::npos) {
359 end_pos = content_type.size();
360 }
361 return std::string(content_type.substr(value_start, end_pos - value_start));
362}
363
364auto multipart_parser::extract_type(std::string_view content_type)
365 -> std::optional<std::string> {
366 // Find type parameter in Content-Type header
367 // Example: multipart/related; type="application/dicom"
368 auto type_pos = content_type.find("type=");
369 if (type_pos == std::string_view::npos) {
370 return std::nullopt;
371 }
372
373 auto value_start = type_pos + 5; // length of "type="
374 if (value_start >= content_type.size()) {
375 return std::nullopt;
376 }
377
378 // Check if type is quoted
379 if (content_type[value_start] == '"') {
380 auto end_quote = content_type.find('"', value_start + 1);
381 if (end_quote == std::string_view::npos) {
382 return std::nullopt;
383 }
384 return std::string(content_type.substr(value_start + 1,
385 end_quote - value_start - 1));
386 }
387
388 // Unquoted type
389 auto end_pos = content_type.find_first_of("; \t", value_start);
390 if (end_pos == std::string_view::npos) {
391 end_pos = content_type.size();
392 }
393 return std::string(content_type.substr(value_start, end_pos - value_start));
394}
395
396auto multipart_parser::parse_part_headers(std::string_view header_section)
397 -> std::vector<std::pair<std::string, std::string>> {
398 std::vector<std::pair<std::string, std::string>> headers;
399
400 size_t pos = 0;
401 while (pos < header_section.size()) {
402 // Find end of line
403 auto line_end = header_section.find("\r\n", pos);
404 if (line_end == std::string_view::npos) {
405 line_end = header_section.size();
406 }
407
408 auto line = header_section.substr(pos, line_end - pos);
409 if (line.empty()) {
410 break;
411 }
412
413 // Parse header: name: value
414 auto colon_pos = line.find(':');
415 if (colon_pos != std::string_view::npos) {
416 auto name = trim(line.substr(0, colon_pos));
417 auto value = trim(line.substr(colon_pos + 1));
418
419 // Convert header name to lowercase for case-insensitive matching
420 std::string name_lower;
421 name_lower.reserve(name.size());
422 for (char c : name) {
423 name_lower += static_cast<char>(std::tolower(
424 static_cast<unsigned char>(c)));
425 }
426
427 headers.emplace_back(std::move(name_lower), std::string(value));
428 }
429
430 pos = line_end + 2; // Skip \r\n
431 }
432
433 return headers;
434}
435
436auto multipart_parser::parse(std::string_view content_type,
437 std::string_view body) -> parse_result {
438 parse_result result;
439
440 // Extract boundary from Content-Type
441 auto boundary_opt = extract_boundary(content_type);
442 if (!boundary_opt) {
443 result.error = parse_error{
444 "INVALID_BOUNDARY",
445 "Missing or invalid boundary in Content-Type header"};
446 return result;
447 }
448
449 const std::string& boundary = *boundary_opt;
450 std::string delimiter = "--" + boundary;
451 std::string end_delimiter = "--" + boundary + "--";
452
453 // Find first boundary
454 auto pos = body.find(delimiter);
455 if (pos == std::string_view::npos) {
456 result.error = parse_error{
457 "NO_PARTS",
458 "No parts found in multipart body"};
459 return result;
460 }
461
462 // Skip to after first boundary and CRLF
463 pos += delimiter.size();
464 if (pos < body.size() && body.substr(pos, 2) == "\r\n") {
465 pos += 2;
466 }
467
468 // Parse each part
469 while (pos < body.size()) {
470 // Check for end delimiter
471 if (body.substr(pos).starts_with("--")) {
472 break; // End of multipart
473 }
474
475 // Find next boundary
476 auto next_boundary = body.find(delimiter, pos);
477 if (next_boundary == std::string_view::npos) {
478 // No more parts
479 break;
480 }
481
482 // Extract part content (excluding trailing CRLF before boundary)
483 auto part_content = body.substr(pos, next_boundary - pos);
484 if (part_content.size() >= 2 &&
485 part_content.substr(part_content.size() - 2) == "\r\n") {
486 part_content = part_content.substr(0, part_content.size() - 2);
487 }
488
489 // Split part into headers and body
490 auto header_end = part_content.find("\r\n\r\n");
491 if (header_end == std::string_view::npos) {
492 // Malformed part - skip
493 pos = next_boundary + delimiter.size();
494 if (pos < body.size() && body.substr(pos, 2) == "\r\n") {
495 pos += 2;
496 }
497 continue;
498 }
499
500 auto header_section = part_content.substr(0, header_end);
501 auto body_section = part_content.substr(header_end + 4);
502
503 // Parse headers
504 auto headers = parse_part_headers(header_section);
505
506 // Create multipart_part
507 multipart_part part;
508 for (const auto& [name, value] : headers) {
509 if (name == "content-type") {
510 part.content_type = value;
511 } else if (name == "content-location") {
512 part.content_location = value;
513 } else if (name == "content-id") {
514 part.content_id = value;
515 }
516 }
517
518 // Default content type if not specified
519 if (part.content_type.empty()) {
520 part.content_type = std::string(media_type::dicom);
521 }
522
523 // Copy body data
524 part.data.assign(
525 reinterpret_cast<const uint8_t*>(body_section.data()),
526 reinterpret_cast<const uint8_t*>(body_section.data() +
527 body_section.size()));
528
529 result.parts.push_back(std::move(part));
530
531 // Move to next part
532 pos = next_boundary + delimiter.size();
533 if (pos < body.size() && body.substr(pos, 2) == "\r\n") {
534 pos += 2;
535 }
536 }
537
538 if (result.parts.empty()) {
539 result.error = parse_error{
540 "NO_VALID_PARTS",
541 "No valid parts found in multipart body"};
542 }
543
544 return result;
545}
546
547// ============================================================================
548// STOW-RS Validation
549// ============================================================================
550
552 const core::dicom_dataset& dataset,
553 std::optional<std::string_view> target_study_uid) -> validation_result {
554
555 // Check required DICOM tags
556 auto sop_class = dataset.get(core::tags::sop_class_uid);
557 if (!sop_class || sop_class->as_string().unwrap_or("").empty()) {
559 "MISSING_SOP_CLASS",
560 "SOP Class UID (0008,0016) is required");
561 }
562
563 auto sop_instance = dataset.get(core::tags::sop_instance_uid);
564 if (!sop_instance || sop_instance->as_string().unwrap_or("").empty()) {
566 "MISSING_SOP_INSTANCE",
567 "SOP Instance UID (0008,0018) is required");
568 }
569
570 auto study_uid = dataset.get(core::tags::study_instance_uid);
571 if (!study_uid || study_uid->as_string().unwrap_or("").empty()) {
573 "MISSING_STUDY_UID",
574 "Study Instance UID (0020,000D) is required");
575 }
576
577 auto series_uid = dataset.get(core::tags::series_instance_uid);
578 if (!series_uid || series_uid->as_string().unwrap_or("").empty()) {
580 "MISSING_SERIES_UID",
581 "Series Instance UID (0020,000E) is required");
582 }
583
584 // Validate study UID matches target if specified
585 if (target_study_uid.has_value()) {
586 auto instance_study_uid = study_uid->as_string().unwrap_or("");
587 if (instance_study_uid != *target_study_uid) {
589 "STUDY_UID_MISMATCH",
590 "Instance Study UID does not match target study");
591 }
592 }
593
594 return validation_result::ok();
595}
596
597// ============================================================================
598// STOW-RS Response Building
599// ============================================================================
600
602 const store_response& response,
603 std::string_view base_url) -> std::string {
604 std::ostringstream oss;
605 oss << "{";
606
607 // Referenced SOP Sequence (0008,1199) - successfully stored instances
608 if (!response.referenced_instances.empty()) {
609 oss << "\"00081199\":{\"vr\":\"SQ\",\"Value\":[";
610 bool first = true;
611 for (const auto& inst : response.referenced_instances) {
612 if (!first) oss << ",";
613 first = false;
614
615 oss << "{";
616 // Referenced SOP Class UID (0008,1150)
617 oss << "\"00081150\":{\"vr\":\"UI\",\"Value\":[\""
618 << inst.sop_class_uid << "\"]},";
619 // Referenced SOP Instance UID (0008,1155)
620 oss << "\"00081155\":{\"vr\":\"UI\",\"Value\":[\""
621 << inst.sop_instance_uid << "\"]},";
622 // Retrieve URL (0008,1190)
623 oss << "\"00081190\":{\"vr\":\"UR\",\"Value\":[\""
624 << base_url << inst.retrieve_url << "\"]}";
625 oss << "}";
626 }
627 oss << "]}";
628 }
629
630 // Failed SOP Sequence (0008,1198) - failed instances
631 if (!response.failed_instances.empty()) {
632 if (!response.referenced_instances.empty()) {
633 oss << ",";
634 }
635 oss << "\"00081198\":{\"vr\":\"SQ\",\"Value\":[";
636 bool first = true;
637 for (const auto& inst : response.failed_instances) {
638 if (!first) oss << ",";
639 first = false;
640
641 oss << "{";
642 // Referenced SOP Class UID (0008,1150)
643 if (!inst.sop_class_uid.empty()) {
644 oss << "\"00081150\":{\"vr\":\"UI\",\"Value\":[\""
645 << inst.sop_class_uid << "\"]},";
646 }
647 // Referenced SOP Instance UID (0008,1155)
648 if (!inst.sop_instance_uid.empty()) {
649 oss << "\"00081155\":{\"vr\":\"UI\",\"Value\":[\""
650 << inst.sop_instance_uid << "\"]},";
651 }
652 // Failure Reason (0008,1197)
653 uint16_t failure_reason = 272; // Processing failure
654 if (inst.error_code && *inst.error_code == "DUPLICATE") {
655 failure_reason = 273; // Duplicate SOP Instance
656 } else if (inst.error_code && *inst.error_code == "INVALID_DATA") {
657 failure_reason = 272; // Processing failure
658 }
659 oss << "\"00081197\":{\"vr\":\"US\",\"Value\":["
660 << failure_reason << "]}";
661 oss << "}";
662 }
663 oss << "]}";
664 }
665
666 oss << "}";
667 return oss.str();
668}
669
671 const core::dicom_dataset& dataset,
672 bool include_bulk_data,
673 std::string_view bulk_data_uri_prefix) -> std::string {
674 std::ostringstream oss;
675 oss << "{";
676
677 bool first = true;
678 // Iterate using begin()/end() - dataset is iterable as map<dicom_tag, dicom_element>
679 for (const auto& [tag_key, elem] : dataset) {
680 if (!first) {
681 oss << ",";
682 }
683 first = false;
684
685 // Format tag as 8-digit hex
686 uint32_t tag = tag_key.combined();
687 oss << "\"";
688 oss << std::hex << std::setfill('0') << std::setw(8) << tag;
689 oss << std::dec << "\":{";
690
691 // Add VR
692 std::string vr_str = vr_enum_to_string(elem.vr());
693 oss << "\"vr\":\"" << vr_str << "\"";
694
695 // Check if bulk data
696 if (is_bulk_data_tag(tag) && !include_bulk_data) {
697 if (!bulk_data_uri_prefix.empty()) {
698 oss << ",\"BulkDataURI\":\"" << bulk_data_uri_prefix
699 << std::hex << std::setfill('0') << std::setw(8) << tag
700 << std::dec << "\"";
701 }
702 } else {
703 // Add Value based on VR type
704 auto value_str = elem.as_string().unwrap_or("");
705 if (!value_str.empty()) {
706 oss << ",\"Value\":[";
707
708 // Handle different VR types
709 using vr_type = kcenon::pacs::encoding::vr_type;
710 switch (elem.vr()) {
711 case vr_type::PN:
712 // Person Name - object with Alphabetic component
713 oss << "{\"Alphabetic\":\"" << json_escape(value_str) << "\"}";
714 break;
715
716 case vr_type::IS:
717 case vr_type::SL:
718 case vr_type::SS:
719 case vr_type::UL:
720 case vr_type::US:
721 // Integer types - output as number
722 oss << value_str;
723 break;
724
725 case vr_type::DS:
726 case vr_type::FL:
727 case vr_type::FD:
728 // Decimal types - output as number
729 oss << value_str;
730 break;
731
732 default:
733 // String types
734 oss << "\"" << json_escape(value_str) << "\"";
735 break;
736 }
737
738 oss << "]";
739 }
740 }
741
742 oss << "}";
743 }
744
745 oss << "}";
746 return oss.str();
747}
748
749// ============================================================================
750// QIDO-RS Response Building
751// ============================================================================
752
754 const storage::study_record& record,
755 std::string_view patient_id,
756 std::string_view patient_name) -> std::string {
757 std::ostringstream oss;
758 oss << "{";
759
760 // Study Instance UID (0020,000D)
761 oss << "\"0020000D\":{\"vr\":\"UI\",\"Value\":[\""
762 << json_escape(record.study_uid) << "\"]}";
763
764 // Study Date (0008,0020)
765 if (!record.study_date.empty()) {
766 oss << ",\"00080020\":{\"vr\":\"DA\",\"Value\":[\""
767 << json_escape(record.study_date) << "\"]}";
768 }
769
770 // Study Time (0008,0030)
771 if (!record.study_time.empty()) {
772 oss << ",\"00080030\":{\"vr\":\"TM\",\"Value\":[\""
773 << json_escape(record.study_time) << "\"]}";
774 }
775
776 // Accession Number (0008,0050)
777 if (!record.accession_number.empty()) {
778 oss << ",\"00080050\":{\"vr\":\"SH\",\"Value\":[\""
779 << json_escape(record.accession_number) << "\"]}";
780 }
781
782 // Modalities in Study (0008,0061)
783 if (!record.modalities_in_study.empty()) {
784 oss << ",\"00080061\":{\"vr\":\"CS\",\"Value\":[";
785 auto modalities = split(record.modalities_in_study, '\\');
786 bool first = true;
787 for (const auto& mod : modalities) {
788 if (!first) oss << ",";
789 first = false;
790 oss << "\"" << json_escape(mod) << "\"";
791 }
792 oss << "]}";
793 }
794
795 // Referring Physician's Name (0008,0090)
796 if (!record.referring_physician.empty()) {
797 oss << ",\"00080090\":{\"vr\":\"PN\",\"Value\":[{\"Alphabetic\":\""
798 << json_escape(record.referring_physician) << "\"}]}";
799 }
800
801 // Patient's Name (0010,0010)
802 if (!patient_name.empty()) {
803 oss << ",\"00100010\":{\"vr\":\"PN\",\"Value\":[{\"Alphabetic\":\""
804 << json_escape(std::string(patient_name)) << "\"}]}";
805 }
806
807 // Patient ID (0010,0020)
808 if (!patient_id.empty()) {
809 oss << ",\"00100020\":{\"vr\":\"LO\",\"Value\":[\""
810 << json_escape(std::string(patient_id)) << "\"]}";
811 }
812
813 // Study ID (0020,0010)
814 if (!record.study_id.empty()) {
815 oss << ",\"00200010\":{\"vr\":\"SH\",\"Value\":[\""
816 << json_escape(record.study_id) << "\"]}";
817 }
818
819 // Study Description (0008,1030)
820 if (!record.study_description.empty()) {
821 oss << ",\"00081030\":{\"vr\":\"LO\",\"Value\":[\""
822 << json_escape(record.study_description) << "\"]}";
823 }
824
825 // Number of Study Related Series (0020,1206)
826 oss << ",\"00201206\":{\"vr\":\"IS\",\"Value\":[" << record.num_series << "]}";
827
828 // Number of Study Related Instances (0020,1208)
829 oss << ",\"00201208\":{\"vr\":\"IS\",\"Value\":[" << record.num_instances << "]}";
830
831 oss << "}";
832 return oss.str();
833}
834
836 const storage::series_record& record,
837 std::string_view study_uid) -> std::string {
838 std::ostringstream oss;
839 oss << "{";
840
841 // Series Instance UID (0020,000E)
842 oss << "\"0020000E\":{\"vr\":\"UI\",\"Value\":[\""
843 << json_escape(record.series_uid) << "\"]}";
844
845 // Study Instance UID (0020,000D)
846 if (!study_uid.empty()) {
847 oss << ",\"0020000D\":{\"vr\":\"UI\",\"Value\":[\""
848 << json_escape(std::string(study_uid)) << "\"]}";
849 }
850
851 // Modality (0008,0060)
852 if (!record.modality.empty()) {
853 oss << ",\"00080060\":{\"vr\":\"CS\",\"Value\":[\""
854 << json_escape(record.modality) << "\"]}";
855 }
856
857 // Series Number (0020,0011)
858 if (record.series_number.has_value()) {
859 oss << ",\"00200011\":{\"vr\":\"IS\",\"Value\":["
860 << *record.series_number << "]}";
861 }
862
863 // Series Description (0008,103E)
864 if (!record.series_description.empty()) {
865 oss << ",\"0008103E\":{\"vr\":\"LO\",\"Value\":[\""
866 << json_escape(record.series_description) << "\"]}";
867 }
868
869 // Body Part Examined (0018,0015)
870 if (!record.body_part_examined.empty()) {
871 oss << ",\"00180015\":{\"vr\":\"CS\",\"Value\":[\""
872 << json_escape(record.body_part_examined) << "\"]}";
873 }
874
875 // Number of Series Related Instances (0020,1209)
876 oss << ",\"00201209\":{\"vr\":\"IS\",\"Value\":[" << record.num_instances << "]}";
877
878 oss << "}";
879 return oss.str();
880}
881
883 const storage::instance_record& record,
884 std::string_view series_uid,
885 std::string_view study_uid) -> std::string {
886 std::ostringstream oss;
887 oss << "{";
888
889 // SOP Class UID (0008,0016)
890 if (!record.sop_class_uid.empty()) {
891 oss << "\"00080016\":{\"vr\":\"UI\",\"Value\":[\""
892 << json_escape(record.sop_class_uid) << "\"]}";
893 }
894
895 // SOP Instance UID (0008,0018)
896 oss << ",\"00080018\":{\"vr\":\"UI\",\"Value\":[\""
897 << json_escape(record.sop_uid) << "\"]}";
898
899 // Study Instance UID (0020,000D)
900 if (!study_uid.empty()) {
901 oss << ",\"0020000D\":{\"vr\":\"UI\",\"Value\":[\""
902 << json_escape(std::string(study_uid)) << "\"]}";
903 }
904
905 // Series Instance UID (0020,000E)
906 if (!series_uid.empty()) {
907 oss << ",\"0020000E\":{\"vr\":\"UI\",\"Value\":[\""
908 << json_escape(std::string(series_uid)) << "\"]}";
909 }
910
911 // Instance Number (0020,0013)
912 if (record.instance_number.has_value()) {
913 oss << ",\"00200013\":{\"vr\":\"IS\",\"Value\":["
914 << *record.instance_number << "]}";
915 }
916
917 // Rows (0028,0010)
918 if (record.rows.has_value()) {
919 oss << ",\"00280010\":{\"vr\":\"US\",\"Value\":["
920 << *record.rows << "]}";
921 }
922
923 // Columns (0028,0011)
924 if (record.columns.has_value()) {
925 oss << ",\"00280011\":{\"vr\":\"US\",\"Value\":["
926 << *record.columns << "]}";
927 }
928
929 // Number of Frames (0028,0008)
930 if (record.number_of_frames.has_value()) {
931 oss << ",\"00280008\":{\"vr\":\"IS\",\"Value\":["
932 << *record.number_of_frames << "]}";
933 }
934
935 oss << "}";
936 return oss.str();
937}
938
939// ============================================================================
940// QIDO-RS Query Parameter Parsing
941// ============================================================================
942
943namespace {
944
948std::string url_decode(std::string_view str) {
949 std::string result;
950 result.reserve(str.size());
951
952 for (size_t i = 0; i < str.size(); ++i) {
953 if (str[i] == '%' && i + 2 < str.size()) {
954 int value = 0;
955 std::string hex_str(str.substr(i + 1, 2));
956 try {
957 value = std::stoi(hex_str, nullptr, 16);
958 result += static_cast<char>(value);
959 i += 2;
960 } catch (...) {
961 result += str[i];
962 }
963 } else if (str[i] == '+') {
964 result += ' ';
965 } else {
966 result += str[i];
967 }
968 }
969 return result;
970}
971
975std::vector<std::pair<std::string, std::string>> parse_query_string(
976 const std::string& query_string) {
977 std::vector<std::pair<std::string, std::string>> result;
978
979 if (query_string.empty()) {
980 return result;
981 }
982
983 // Remove leading '?' if present
984 std::string_view qs = query_string;
985 if (!qs.empty() && qs[0] == '?') {
986 qs = qs.substr(1);
987 }
988
989 auto params = split(qs, '&');
990 for (const auto& param : params) {
991 auto eq_pos = param.find('=');
992 if (eq_pos != std::string::npos) {
993 auto key = url_decode(param.substr(0, eq_pos));
994 auto value = url_decode(param.substr(eq_pos + 1));
995 result.emplace_back(std::move(key), std::move(value));
996 }
997 }
998
999 return result;
1000}
1001
1002} // anonymous namespace
1003
1004auto parse_study_query_params(const std::string& url_params) -> storage::study_query {
1006
1007 auto params = parse_query_string(url_params);
1008 for (const auto& [key, value] : params) {
1009 if (key == "PatientID" || key == "00100020") {
1010 query.patient_id = value;
1011 } else if (key == "PatientName" || key == "00100010") {
1012 query.patient_name = value;
1013 } else if (key == "StudyInstanceUID" || key == "0020000D") {
1014 query.study_uid = value;
1015 } else if (key == "StudyID" || key == "00200010") {
1016 query.study_id = value;
1017 } else if (key == "StudyDate" || key == "00080020") {
1018 // Handle date range (YYYYMMDD-YYYYMMDD)
1019 auto dash_pos = value.find('-');
1020 if (dash_pos != std::string::npos && dash_pos > 0 &&
1021 dash_pos < value.size() - 1) {
1022 query.study_date_from = value.substr(0, dash_pos);
1023 query.study_date_to = value.substr(dash_pos + 1);
1024 } else if (dash_pos == 0) {
1025 // -YYYYMMDD (up to date)
1026 query.study_date_to = value.substr(1);
1027 } else if (dash_pos == value.size() - 1) {
1028 // YYYYMMDD- (from date)
1029 query.study_date_from = value.substr(0, dash_pos);
1030 } else {
1031 query.study_date = value;
1032 }
1033 } else if (key == "AccessionNumber" || key == "00080050") {
1034 query.accession_number = value;
1035 } else if (key == "ModalitiesInStudy" || key == "00080061") {
1036 query.modality = value;
1037 } else if (key == "ReferringPhysicianName" || key == "00080090") {
1038 query.referring_physician = value;
1039 } else if (key == "StudyDescription" || key == "00081030") {
1040 query.study_description = value;
1041 } else if (key == "limit") {
1042 try {
1043 query.limit = std::stoull(value);
1044 } catch (...) {}
1045 } else if (key == "offset") {
1046 try {
1047 query.offset = std::stoull(value);
1048 } catch (...) {}
1049 }
1050 }
1051
1052 return query;
1053}
1054
1055auto parse_series_query_params(const std::string& url_params) -> storage::series_query {
1057
1058 auto params = parse_query_string(url_params);
1059 for (const auto& [key, value] : params) {
1060 if (key == "StudyInstanceUID" || key == "0020000D") {
1061 query.study_uid = value;
1062 } else if (key == "SeriesInstanceUID" || key == "0020000E") {
1063 query.series_uid = value;
1064 } else if (key == "Modality" || key == "00080060") {
1065 query.modality = value;
1066 } else if (key == "SeriesNumber" || key == "00200011") {
1067 try {
1068 query.series_number = std::stoi(value);
1069 } catch (...) {}
1070 } else if (key == "SeriesDescription" || key == "0008103E") {
1071 query.series_description = value;
1072 } else if (key == "BodyPartExamined" || key == "00180015") {
1073 query.body_part_examined = value;
1074 } else if (key == "limit") {
1075 try {
1076 query.limit = std::stoull(value);
1077 } catch (...) {}
1078 } else if (key == "offset") {
1079 try {
1080 query.offset = std::stoull(value);
1081 } catch (...) {}
1082 }
1083 }
1084
1085 return query;
1086}
1087
1088auto parse_instance_query_params(const std::string& url_params) -> storage::instance_query {
1090
1091 auto params = parse_query_string(url_params);
1092 for (const auto& [key, value] : params) {
1093 if (key == "SeriesInstanceUID" || key == "0020000E") {
1094 query.series_uid = value;
1095 } else if (key == "SOPInstanceUID" || key == "00080018") {
1096 query.sop_uid = value;
1097 } else if (key == "SOPClassUID" || key == "00080016") {
1098 query.sop_class_uid = value;
1099 } else if (key == "InstanceNumber" || key == "00200013") {
1100 try {
1101 query.instance_number = std::stoi(value);
1102 } catch (...) {}
1103 } else if (key == "limit") {
1104 try {
1105 query.limit = std::stoull(value);
1106 } catch (...) {}
1107 } else if (key == "offset") {
1108 try {
1109 query.offset = std::stoull(value);
1110 } catch (...) {}
1111 }
1112 }
1113
1114 return query;
1115}
1116
1117// ============================================================================
1118// Frame Retrieval
1119// ============================================================================
1120
1121auto parse_frame_numbers(std::string_view frame_list) -> std::vector<uint32_t> {
1122 std::vector<uint32_t> frames;
1123
1124 if (frame_list.empty()) {
1125 return frames;
1126 }
1127
1128 // Split by comma
1129 auto parts = split(frame_list, ',');
1130 for (const auto& part : parts) {
1131 auto trimmed = trim(part);
1132 if (trimmed.empty()) {
1133 continue;
1134 }
1135
1136 // Check for range (e.g., "1-5")
1137 auto dash_pos = trimmed.find('-');
1138 if (dash_pos != std::string::npos && dash_pos > 0 &&
1139 dash_pos < trimmed.size() - 1) {
1140 try {
1141 uint32_t start = std::stoul(trimmed.substr(0, dash_pos));
1142 uint32_t end = std::stoul(trimmed.substr(dash_pos + 1));
1143 if (start > 0 && end >= start) {
1144 for (uint32_t i = start; i <= end; ++i) {
1145 frames.push_back(i);
1146 }
1147 }
1148 } catch (...) {
1149 // Invalid range, skip
1150 }
1151 } else {
1152 // Single frame number
1153 try {
1154 uint32_t frame = std::stoul(trimmed);
1155 if (frame > 0) {
1156 frames.push_back(frame);
1157 }
1158 } catch (...) {
1159 // Invalid number, skip
1160 }
1161 }
1162 }
1163
1164 // Remove duplicates while preserving order
1165 std::vector<uint32_t> unique_frames;
1166 for (auto f : frames) {
1167 if (std::find(unique_frames.begin(), unique_frames.end(), f) ==
1168 unique_frames.end()) {
1169 unique_frames.push_back(f);
1170 }
1171 }
1172
1173 return unique_frames;
1174}
1175
1177 std::span<const uint8_t> pixel_data,
1178 uint32_t frame_number,
1179 size_t frame_size) -> std::vector<uint8_t> {
1180
1181 if (frame_number == 0 || frame_size == 0) {
1182 return {};
1183 }
1184
1185 // Frame numbers are 1-based
1186 size_t offset = (frame_number - 1) * frame_size;
1187
1188 if (offset + frame_size > pixel_data.size()) {
1189 return {}; // Frame doesn't exist
1190 }
1191
1192 return std::vector<uint8_t>(
1193 pixel_data.begin() + static_cast<ptrdiff_t>(offset),
1194 pixel_data.begin() + static_cast<ptrdiff_t>(offset + frame_size));
1195}
1196
1197// ============================================================================
1198// Rendered Images
1199// ============================================================================
1200
1202 std::string_view query_string,
1203 std::string_view accept_header) -> rendered_params {
1204
1205 rendered_params params;
1206
1207 // Determine format from Accept header
1208 if (accept_header.find("image/jphc") != std::string_view::npos) {
1210 } else if (accept_header.find("image/png") != std::string_view::npos) {
1212 } else {
1213 params.format = rendered_format::jpeg; // Default to JPEG
1214 }
1215
1216 // Parse query parameters
1217 auto query_params = parse_query_string(std::string(query_string));
1218 for (const auto& [key, value] : query_params) {
1219 if (key == "quality") {
1220 try {
1221 params.quality = std::stoi(value);
1222 params.quality = std::clamp(params.quality, 1, 100);
1223 } catch (...) {}
1224 } else if (key == "windowcenter" || key == "window-center") {
1225 try {
1226 params.window_center = std::stod(value);
1227 } catch (...) {}
1228 } else if (key == "windowwidth" || key == "window-width") {
1229 try {
1230 params.window_width = std::stod(value);
1231 } catch (...) {}
1232 } else if (key == "viewport") {
1233 // Format: WxH or W,H
1234 auto sep = value.find_first_of("x,");
1235 if (sep != std::string::npos) {
1236 try {
1237 params.viewport_width =
1238 static_cast<uint16_t>(std::stoul(value.substr(0, sep)));
1239 params.viewport_height =
1240 static_cast<uint16_t>(std::stoul(value.substr(sep + 1)));
1241 } catch (...) {}
1242 }
1243 } else if (key == "rows") {
1244 try {
1245 params.viewport_height = static_cast<uint16_t>(std::stoul(value));
1246 } catch (...) {}
1247 } else if (key == "columns") {
1248 try {
1249 params.viewport_width = static_cast<uint16_t>(std::stoul(value));
1250 } catch (...) {}
1251 } else if (key == "frame") {
1252 try {
1253 params.frame = std::stoul(value);
1254 if (params.frame == 0) params.frame = 1;
1255 } catch (...) {}
1256 } else if (key == "annotation") {
1257 params.burn_annotations = (value == "true" || value == "1");
1258 }
1259 }
1260
1261 return params;
1262}
1263
1265 std::span<const uint8_t> pixel_data,
1266 uint16_t width,
1267 uint16_t height,
1268 uint16_t bits_stored,
1269 bool is_signed,
1270 double window_center,
1271 double window_width,
1272 double rescale_slope,
1273 double rescale_intercept) -> std::vector<uint8_t> {
1274
1275 std::vector<uint8_t> output(static_cast<size_t>(width) * height);
1276
1277 // Calculate window boundaries
1278 double window_min = window_center - window_width / 2.0;
1279 double window_max = window_center + window_width / 2.0;
1280
1281 size_t pixel_count = static_cast<size_t>(width) * height;
1282 bool is_16bit = (bits_stored > 8);
1283
1284 for (size_t i = 0; i < pixel_count; ++i) {
1285 double value;
1286
1287 if (is_16bit) {
1288 // 16-bit pixel data
1289 size_t byte_offset = i * 2;
1290 if (byte_offset + 1 >= pixel_data.size()) break;
1291
1292 if (is_signed) {
1293 int16_t raw_value = static_cast<int16_t>(
1294 pixel_data[byte_offset] |
1295 (pixel_data[byte_offset + 1] << 8));
1296 value = raw_value * rescale_slope + rescale_intercept;
1297 } else {
1298 uint16_t raw_value = static_cast<uint16_t>(
1299 pixel_data[byte_offset] |
1300 (pixel_data[byte_offset + 1] << 8));
1301 value = raw_value * rescale_slope + rescale_intercept;
1302 }
1303 } else {
1304 // 8-bit pixel data
1305 if (i >= pixel_data.size()) break;
1306
1307 if (is_signed) {
1308 value = static_cast<int8_t>(pixel_data[i]) * rescale_slope +
1309 rescale_intercept;
1310 } else {
1311 value = pixel_data[i] * rescale_slope + rescale_intercept;
1312 }
1313 }
1314
1315 // Apply window/level
1316 uint8_t output_value;
1317 if (value <= window_min) {
1318 output_value = 0;
1319 } else if (value >= window_max) {
1320 output_value = 255;
1321 } else {
1322 output_value = static_cast<uint8_t>(
1323 (value - window_min) / window_width * 255.0);
1324 }
1325
1326 output[i] = output_value;
1327 }
1328
1329 return output;
1330}
1331
1333 std::string_view file_path,
1334 const rendered_params& params) -> rendered_result {
1335
1336 // Load DICOM file
1337 std::ifstream file(std::string(file_path), std::ios::binary);
1338 if (!file) {
1339 return rendered_result::error("Failed to open DICOM file");
1340 }
1341
1342 std::vector<uint8_t> file_data(
1343 (std::istreambuf_iterator<char>(file)),
1344 std::istreambuf_iterator<char>());
1345 file.close();
1346
1347 auto dicom_result = core::dicom_file::from_bytes(
1348 std::span<const uint8_t>(file_data.data(), file_data.size()));
1349
1350 if (dicom_result.is_err()) {
1351 return rendered_result::error("Failed to parse DICOM file");
1352 }
1353
1354 const auto& dataset = dicom_result.value().dataset();
1355
1356 // Get image parameters
1357 auto rows_elem = dataset.get(core::tags::rows);
1358 auto cols_elem = dataset.get(core::tags::columns);
1359 auto bits_stored_elem = dataset.get(core::tags::bits_stored);
1360 auto bits_allocated_elem = dataset.get(core::tags::bits_allocated);
1361 auto pixel_rep_elem = dataset.get(core::tags::pixel_representation);
1362 auto samples_elem = dataset.get(core::tags::samples_per_pixel);
1363 auto pixel_data_elem = dataset.get(core::tags::pixel_data);
1364
1365 if (!rows_elem || !cols_elem || !pixel_data_elem) {
1366 return rendered_result::error("Missing required image attributes");
1367 }
1368
1369 uint16_t rows = rows_elem->as_numeric<uint16_t>().unwrap_or(0);
1370 uint16_t cols = cols_elem->as_numeric<uint16_t>().unwrap_or(0);
1371 uint16_t bits_stored = bits_stored_elem ?
1372 bits_stored_elem->as_numeric<uint16_t>().unwrap_or(8) : 8;
1373 uint16_t bits_allocated = bits_allocated_elem ?
1374 bits_allocated_elem->as_numeric<uint16_t>().unwrap_or(8) : 8;
1375 uint16_t pixel_rep = pixel_rep_elem ?
1376 pixel_rep_elem->as_numeric<uint16_t>().unwrap_or(0) : 0;
1377 uint16_t samples_per_pixel = samples_elem ?
1378 samples_elem->as_numeric<uint16_t>().unwrap_or(1) : 1;
1379
1380 bool is_signed = (pixel_rep == 1);
1381
1382 // Get window/level from DICOM or use provided parameters
1383 double window_center = 128.0;
1384 double window_width = 256.0;
1385
1386 if (params.window_center.has_value()) {
1387 window_center = *params.window_center;
1388 } else if (auto wc = dataset.get(core::tags::window_center)) {
1389 try {
1390 auto values_result = wc->as_string_list();
1391 if (values_result.is_ok() && !values_result.value().empty()) {
1392 window_center = std::stod(values_result.value()[0]);
1393 }
1394 } catch (...) {}
1395 }
1396
1397 if (params.window_width.has_value()) {
1398 window_width = *params.window_width;
1399 } else if (auto ww = dataset.get(core::tags::window_width)) {
1400 try {
1401 auto values_result = ww->as_string_list();
1402 if (values_result.is_ok() && !values_result.value().empty()) {
1403 window_width = std::stod(values_result.value()[0]);
1404 }
1405 } catch (...) {}
1406 }
1407
1408 // Get rescale parameters
1409 double rescale_slope = 1.0;
1410 double rescale_intercept = 0.0;
1411
1412 if (auto rs = dataset.get(core::tags::rescale_slope)) {
1413 try {
1414 rescale_slope = std::stod(rs->as_string().unwrap_or("1.0"));
1415 } catch (...) {}
1416 }
1417 if (auto ri = dataset.get(core::tags::rescale_intercept)) {
1418 try {
1419 rescale_intercept = std::stod(ri->as_string().unwrap_or("0.0"));
1420 } catch (...) {}
1421 }
1422
1423 // Get pixel data
1424 auto pixel_data = pixel_data_elem->raw_data();
1425
1426 // Calculate frame size for multi-frame images
1427 size_t frame_size = static_cast<size_t>(rows) * cols * samples_per_pixel *
1428 ((bits_allocated + 7) / 8);
1429
1430 // Extract specific frame if multi-frame
1431 std::span<const uint8_t> frame_data = pixel_data;
1432 if (params.frame > 1) {
1433 auto extracted = extract_frame(pixel_data, params.frame, frame_size);
1434 if (extracted.empty()) {
1435 return rendered_result::error("Requested frame does not exist");
1436 }
1437 // For simplicity, we'll just use first frame for now
1438 // Full implementation would handle multi-frame properly
1439 }
1440
1441 // Apply window/level for grayscale images
1442 std::vector<uint8_t> output_pixels;
1443 if (samples_per_pixel == 1) {
1444 output_pixels = apply_window_level(
1445 frame_data, cols, rows, bits_stored, is_signed,
1446 window_center, window_width, rescale_slope, rescale_intercept);
1447 } else {
1448 // For color images, just use raw data (convert to 8-bit if needed)
1449 output_pixels.resize(static_cast<size_t>(rows) * cols * samples_per_pixel);
1450 if (bits_allocated == 8) {
1451 std::copy(frame_data.begin(),
1452 frame_data.begin() + (std::min)(frame_data.size(),
1453 output_pixels.size()),
1454 output_pixels.begin());
1455 } else {
1456 // Convert 16-bit to 8-bit
1457 for (size_t i = 0; i < output_pixels.size() && i * 2 + 1 < frame_data.size(); ++i) {
1458 uint16_t val = static_cast<uint16_t>(
1459 frame_data[i * 2] | (frame_data[i * 2 + 1] << 8));
1460 output_pixels[i] = static_cast<uint8_t>(val >> (bits_stored - 8));
1461 }
1462 }
1463 }
1464
1465 // Encode to requested format
1467 img_params.width = cols;
1468 img_params.height = rows;
1469 img_params.bits_allocated = 8;
1470 img_params.bits_stored = 8;
1471 img_params.high_bit = 7;
1472 img_params.samples_per_pixel = samples_per_pixel;
1473 img_params.photometric =
1474 (samples_per_pixel == 1) ?
1477
1478 if (params.format == rendered_format::jphc) {
1480 /*lossless=*/false, /*use_rpcl=*/false);
1481
1482 auto result = codec.encode(output_pixels, img_params);
1483 if (result.is_err()) {
1484 return rendered_result::error("HTJ2K encoding failed: " +
1485 result.error().message);
1486 }
1487
1488 return rendered_result::ok(std::move(result.value().data), media_type::jphc);
1489 } else if (params.format == rendered_format::jpeg) {
1491
1493 opts.quality = params.quality;
1494
1495 auto result = codec.encode(output_pixels, img_params, opts);
1496 if (result.is_err()) {
1497 return rendered_result::error("JPEG encoding failed: " +
1498 result.error().message);
1499 }
1500
1501 return rendered_result::ok(std::move(result.value().data), media_type::jpeg);
1502 } else {
1503 // PNG encoding - not implemented yet
1504 return rendered_result::error("PNG encoding not yet implemented");
1505 }
1506}
1507
1508} // namespace kcenon::pacs::web::dicomweb
1509
1511
1512namespace {
1513
1520bool check_dicomweb_auth(const std::shared_ptr<rest_server_context>& ctx,
1521 const crow::request& req,
1522 crow::response& res,
1523 const std::vector<std::string>& required_scopes) {
1524 if (ctx->oauth2 && ctx->oauth2->enabled()) {
1525 auto auth = ctx->oauth2->authenticate(req, res);
1526 if (!auth) return false;
1527
1528 if (!required_scopes.empty()) {
1529 return ctx->oauth2->require_any_scope(
1530 auth->claims, res, required_scopes);
1531 }
1532 return true;
1533 }
1534
1535 // Legacy X-User-ID fallback (no scope checking)
1536 return true;
1537}
1538
1542void add_cors_headers(crow::response& res, const rest_server_context& ctx) {
1543 if (ctx.config && !ctx.config->cors_allowed_origins.empty()) {
1544 res.add_header("Access-Control-Allow-Origin",
1545 ctx.config->cors_allowed_origins);
1546 }
1547}
1548
1552std::vector<uint8_t> read_file_bytes(const std::filesystem::path& path) {
1553 std::ifstream file(path, std::ios::binary | std::ios::ate);
1554 if (!file) {
1555 return {};
1556 }
1557
1558 auto size = file.tellg();
1559 file.seekg(0, std::ios::beg);
1560
1561 std::vector<uint8_t> buffer(static_cast<size_t>(size));
1562 if (!file.read(reinterpret_cast<char*>(buffer.data()), size)) {
1563 return {};
1564 }
1565
1566 return buffer;
1567}
1568
1572crow::response build_multipart_dicom_response(
1573 const std::vector<std::string>& file_paths,
1574 const rest_server_context& ctx,
1575 std::string_view base_uri = "") {
1576
1577 crow::response res;
1578 add_cors_headers(res, ctx);
1579
1580 if (file_paths.empty()) {
1581 res.code = 404;
1582 res.add_header("Content-Type", "application/json");
1583 res.body = make_error_json("NOT_FOUND", "No instances found");
1584 return res;
1585 }
1586
1587 // Single file - return directly
1588 if (file_paths.size() == 1) {
1589 auto data = read_file_bytes(file_paths[0]);
1590 if (data.empty()) {
1591 res.code = 500;
1592 res.add_header("Content-Type", "application/json");
1593 res.body = make_error_json("READ_ERROR", "Failed to read DICOM file");
1594 return res;
1595 }
1596
1597 res.code = 200;
1598 res.add_header("Content-Type", std::string(dicomweb::media_type::dicom));
1599 res.body = std::string(reinterpret_cast<char*>(data.data()), data.size());
1600 return res;
1601 }
1602
1603 // Multiple files - use multipart response
1604 dicomweb::multipart_builder builder(dicomweb::media_type::dicom);
1605
1606 for (size_t i = 0; i < file_paths.size(); ++i) {
1607 auto data = read_file_bytes(file_paths[i]);
1608 if (data.empty()) {
1609 continue; // Skip files that can't be read
1610 }
1611
1612 if (base_uri.empty()) {
1613 builder.add_part(std::move(data));
1614 } else {
1615 std::ostringstream location;
1616 location << base_uri << "/" << i;
1617 builder.add_part_with_location(std::move(data), location.str());
1618 }
1619 }
1620
1621 if (builder.empty()) {
1622 res.code = 500;
1623 res.add_header("Content-Type", "application/json");
1624 res.body = make_error_json("READ_ERROR", "Failed to read DICOM files");
1625 return res;
1626 }
1627
1628 res.code = 200;
1629 res.add_header("Content-Type", builder.content_type_header());
1630 res.body = builder.build();
1631 return res;
1632}
1633
1637crow::response build_metadata_response(
1638 const std::vector<std::string>& file_paths,
1639 const rest_server_context& ctx,
1640 std::string_view bulk_data_uri_prefix = "") {
1641
1642 crow::response res;
1643 add_cors_headers(res, ctx);
1644 res.add_header("Content-Type", std::string(dicomweb::media_type::dicom_json));
1645
1646 if (file_paths.empty()) {
1647 res.code = 404;
1648 res.body = make_error_json("NOT_FOUND", "No instances found");
1649 return res;
1650 }
1651
1652 std::ostringstream oss;
1653 oss << "[";
1654
1655 bool first = true;
1656 for (const auto& path : file_paths) {
1657 auto result = core::dicom_file::open(path);
1658 if (result.is_err()) {
1659 continue;
1660 }
1661
1662 if (!first) {
1663 oss << ",";
1664 }
1665 first = false;
1666
1667 oss << dicomweb::dataset_to_dicom_json(
1668 result.value().dataset(), false, bulk_data_uri_prefix);
1669 }
1670
1671 oss << "]";
1672
1673 res.code = 200;
1674 res.body = oss.str();
1675 return res;
1676}
1677
1690bool store_instance_to_storage(
1691 const std::shared_ptr<rest_server_context>& ctx,
1692 const core::dicom_dataset& dataset,
1693 dicomweb::store_instance_result& result) {
1694
1695 auto sop_uid = dataset.get_string(core::tags::sop_instance_uid);
1696
1697 // Step 1: Store to filesystem
1698 auto store_result = ctx->file_storage->store(dataset);
1699 if (store_result.is_err()) {
1700 result.success = false;
1701 result.error_code = "STORAGE_ERROR";
1702 result.error_message = store_result.error().message;
1703 return false;
1704 }
1705
1706 // Step 2: Get stored file path and size
1707 auto file_path = ctx->file_storage->get_file_path(sop_uid);
1708 std::error_code ec;
1709 auto file_size = static_cast<int64_t>(
1710 std::filesystem::file_size(file_path, ec));
1711 if (ec) {
1712 file_size = 0;
1713 }
1714
1715 // Step 3: Upsert patient record
1716 auto patient_id = dataset.get_string(core::tags::patient_id);
1717 auto patient_name = dataset.get_string(core::tags::patient_name);
1718 auto birth_date = dataset.get_string(core::tags::patient_birth_date);
1719 auto sex = dataset.get_string(core::tags::patient_sex);
1720
1721 auto patient_pk_result = ctx->database->upsert_patient(
1722 patient_id, patient_name, birth_date, sex);
1723 if (!patient_pk_result.is_ok()) {
1724 (void)ctx->file_storage->remove(sop_uid);
1725 result.success = false;
1726 result.error_code = "DATABASE_ERROR";
1727 result.error_message = "Failed to upsert patient record";
1728 return false;
1729 }
1730
1731 // Step 4: Upsert study record
1732 auto study_uid = dataset.get_string(core::tags::study_instance_uid);
1733 auto study_id = dataset.get_string(core::tags::study_id);
1734 auto study_date = dataset.get_string(core::tags::study_date);
1735 auto study_time = dataset.get_string(core::tags::study_time);
1736 auto accession = dataset.get_string(core::tags::accession_number);
1737 auto referring = dataset.get_string(core::tags::referring_physician_name);
1738 auto study_desc = dataset.get_string(core::tags::study_description);
1739
1740 auto study_pk_result = ctx->database->upsert_study(
1741 patient_pk_result.value(), study_uid, study_id, study_date,
1742 study_time, accession, referring, study_desc);
1743 if (!study_pk_result.is_ok()) {
1744 (void)ctx->file_storage->remove(sop_uid);
1745 result.success = false;
1746 result.error_code = "DATABASE_ERROR";
1747 result.error_message = "Failed to upsert study record";
1748 return false;
1749 }
1750
1751 // Step 5: Upsert series record
1752 auto series_uid = dataset.get_string(core::tags::series_instance_uid);
1753 auto modality_str = dataset.get_string(core::tags::modality);
1754 auto series_number = dataset.get_numeric<int>(core::tags::series_number);
1755 auto series_desc = dataset.get_string(core::tags::series_description);
1756 auto station = dataset.get_string(core::tags::station_name);
1757
1758 auto series_pk_result = ctx->database->upsert_series(
1759 study_pk_result.value(), series_uid, modality_str, series_number,
1760 series_desc, "", station);
1761 if (!series_pk_result.is_ok()) {
1762 (void)ctx->file_storage->remove(sop_uid);
1763 result.success = false;
1764 result.error_code = "DATABASE_ERROR";
1765 result.error_message = "Failed to upsert series record";
1766 return false;
1767 }
1768
1769 // Step 6: Upsert instance record
1770 auto sop_class_uid = dataset.get_string(core::tags::sop_class_uid);
1771 auto transfer_syntax = std::string(
1772 encoding::transfer_syntax::explicit_vr_little_endian.uid());
1773 auto instance_number = dataset.get_numeric<int>(
1774 core::tags::instance_number);
1775
1776 auto instance_pk_result = ctx->database->upsert_instance(
1777 series_pk_result.value(), sop_uid, sop_class_uid,
1778 file_path.string(), file_size, transfer_syntax, instance_number);
1779 if (!instance_pk_result.is_ok()) {
1780 (void)ctx->file_storage->remove(sop_uid);
1781 result.success = false;
1782 result.error_code = "DATABASE_ERROR";
1783 result.error_message = "Failed to upsert instance record";
1784 return false;
1785 }
1786
1787 return true;
1788}
1789
1790} // anonymous namespace
1791
1792void register_dicomweb_endpoints_impl(crow::SimpleApp& app,
1793 std::shared_ptr<rest_server_context> ctx) {
1794 // ========================================================================
1795 // Study Retrieval
1796 // ========================================================================
1797
1798 // GET /dicomweb/studies/{studyUID} - Retrieve entire study
1799 CROW_ROUTE(app, "/dicomweb/studies/<string>")
1800 .methods(crow::HTTPMethod::GET)(
1801 [ctx](const crow::request& req, const std::string& study_uid) {
1802 crow::response res;
1803 add_cors_headers(res, *ctx);
1804
1805 // OAuth 2.0 / legacy auth check
1806 if (!check_dicomweb_auth(ctx, req, res,
1807 {"dicomweb.read"})) {
1808 return res;
1809 }
1810
1811 if (!ctx->database) {
1812 res.code = 503;
1813 res.add_header("Content-Type", "application/json");
1814 res.body = make_error_json("DATABASE_UNAVAILABLE",
1815 "Database not configured");
1816 return res;
1817 }
1818
1819 // Check Accept header
1820 auto accept = req.get_header_value("Accept");
1821 auto accept_infos = dicomweb::parse_accept_header(accept);
1822
1823 // Check if metadata is requested
1824 if (dicomweb::is_acceptable(accept_infos,
1825 dicomweb::media_type::dicom_json)) {
1826 auto files_result = ctx->database->get_study_files(study_uid);
1827 if (!files_result.is_ok()) {
1828 res.code = 500;
1829 res.add_header("Content-Type", "application/json");
1830 res.body = make_error_json("QUERY_ERROR",
1831 files_result.error().message);
1832 return res;
1833 }
1834 std::string bulk_uri = "/dicomweb/studies/" + study_uid +
1835 "/bulkdata/";
1836 return build_metadata_response(files_result.value(), *ctx, bulk_uri);
1837 }
1838
1839 // Return DICOM instances
1840 auto files_result = ctx->database->get_study_files(study_uid);
1841 if (!files_result.is_ok()) {
1842 res.code = 500;
1843 res.add_header("Content-Type", "application/json");
1844 res.body = make_error_json("QUERY_ERROR",
1845 files_result.error().message);
1846 return res;
1847 }
1848 std::string base_uri = "/dicomweb/studies/" + study_uid;
1849 return build_multipart_dicom_response(files_result.value(), *ctx, base_uri);
1850 });
1851
1852 // GET /dicomweb/studies/{studyUID}/metadata - Study metadata
1853 CROW_ROUTE(app, "/dicomweb/studies/<string>/metadata")
1854 .methods(crow::HTTPMethod::GET)(
1855 [ctx](const crow::request& req, const std::string& study_uid) {
1856 crow::response res;
1857 add_cors_headers(res, *ctx);
1858
1859 // OAuth 2.0 / legacy auth check
1860 if (!check_dicomweb_auth(ctx, req, res,
1861 {"dicomweb.read"})) {
1862 return res;
1863 }
1864
1865 if (!ctx->database) {
1866 res.code = 503;
1867 res.add_header("Content-Type", "application/json");
1868 res.body = make_error_json("DATABASE_UNAVAILABLE",
1869 "Database not configured");
1870 return res;
1871 }
1872
1873 auto files_result = ctx->database->get_study_files(study_uid);
1874 if (!files_result.is_ok()) {
1875 res.code = 500;
1876 res.add_header("Content-Type", "application/json");
1877 res.body = make_error_json("QUERY_ERROR",
1878 files_result.error().message);
1879 return res;
1880 }
1881 std::string bulk_uri = "/dicomweb/studies/" + study_uid +
1882 "/bulkdata/";
1883 return build_metadata_response(files_result.value(), *ctx, bulk_uri);
1884 });
1885
1886 // ========================================================================
1887 // Series Retrieval
1888 // ========================================================================
1889
1890 // GET /dicomweb/studies/{studyUID}/series/{seriesUID} - Retrieve series
1891 CROW_ROUTE(app, "/dicomweb/studies/<string>/series/<string>")
1892 .methods(crow::HTTPMethod::GET)(
1893 [ctx](const crow::request& req,
1894 const std::string& study_uid,
1895 const std::string& series_uid) {
1896 crow::response res;
1897 add_cors_headers(res, *ctx);
1898
1899 if (!ctx->database) {
1900 res.code = 503;
1901 res.add_header("Content-Type", "application/json");
1902 res.body = make_error_json("DATABASE_UNAVAILABLE",
1903 "Database not configured");
1904 return res;
1905 }
1906
1907 // Verify study exists
1908 auto study = ctx->database->find_study(study_uid);
1909 if (!study) {
1910 res.code = 404;
1911 res.add_header("Content-Type", "application/json");
1912 res.body = make_error_json("NOT_FOUND", "Study not found");
1913 return res;
1914 }
1915
1916 // Check Accept header
1917 auto accept = req.get_header_value("Accept");
1918 auto accept_infos = dicomweb::parse_accept_header(accept);
1919
1920 // Check if metadata is requested
1921 if (dicomweb::is_acceptable(accept_infos,
1922 dicomweb::media_type::dicom_json)) {
1923 auto files_result = ctx->database->get_series_files(series_uid);
1924 if (!files_result.is_ok()) {
1925 res.code = 500;
1926 res.add_header("Content-Type", "application/json");
1927 res.body = make_error_json("QUERY_ERROR",
1928 files_result.error().message);
1929 return res;
1930 }
1931 std::string bulk_uri = "/dicomweb/studies/" + study_uid +
1932 "/series/" + series_uid + "/bulkdata/";
1933 return build_metadata_response(files_result.value(), *ctx, bulk_uri);
1934 }
1935
1936 auto files_result = ctx->database->get_series_files(series_uid);
1937 if (!files_result.is_ok()) {
1938 res.code = 500;
1939 res.add_header("Content-Type", "application/json");
1940 res.body = make_error_json("QUERY_ERROR",
1941 files_result.error().message);
1942 return res;
1943 }
1944 std::string base_uri = "/dicomweb/studies/" + study_uid +
1945 "/series/" + series_uid;
1946 return build_multipart_dicom_response(files_result.value(), *ctx, base_uri);
1947 });
1948
1949 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/metadata
1950 CROW_ROUTE(app, "/dicomweb/studies/<string>/series/<string>/metadata")
1951 .methods(crow::HTTPMethod::GET)(
1952 [ctx](const crow::request& /*req*/,
1953 const std::string& study_uid,
1954 const std::string& series_uid) {
1955 crow::response res;
1956 add_cors_headers(res, *ctx);
1957
1958 if (!ctx->database) {
1959 res.code = 503;
1960 res.add_header("Content-Type", "application/json");
1961 res.body = make_error_json("DATABASE_UNAVAILABLE",
1962 "Database not configured");
1963 return res;
1964 }
1965
1966 auto files_result = ctx->database->get_series_files(series_uid);
1967 if (!files_result.is_ok()) {
1968 res.code = 500;
1969 res.add_header("Content-Type", "application/json");
1970 res.body = make_error_json("QUERY_ERROR",
1971 files_result.error().message);
1972 return res;
1973 }
1974 std::string bulk_uri = "/dicomweb/studies/" + study_uid +
1975 "/series/" + series_uid + "/bulkdata/";
1976 return build_metadata_response(files_result.value(), *ctx, bulk_uri);
1977 });
1978
1979 // ========================================================================
1980 // Instance Retrieval
1981 // ========================================================================
1982
1983 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/instances/{sopUID}
1984 CROW_ROUTE(app,
1985 "/dicomweb/studies/<string>/series/<string>/instances/<string>")
1986 .methods(crow::HTTPMethod::GET)(
1987 [ctx](const crow::request& req,
1988 const std::string& study_uid,
1989 const std::string& series_uid,
1990 const std::string& sop_uid) {
1991 crow::response res;
1992 add_cors_headers(res, *ctx);
1993
1994 if (!ctx->database) {
1995 res.code = 503;
1996 res.add_header("Content-Type", "application/json");
1997 res.body = make_error_json("DATABASE_UNAVAILABLE",
1998 "Database not configured");
1999 return res;
2000 }
2001
2002 auto file_path_result = ctx->database->get_file_path(sop_uid);
2003 if (!file_path_result.is_ok()) {
2004 res.code = 500;
2005 res.add_header("Content-Type", "application/json");
2006 res.body = make_error_json("QUERY_ERROR",
2007 file_path_result.error().message);
2008 return res;
2009 }
2010 const auto& file_path = file_path_result.value();
2011 if (!file_path) {
2012 res.code = 404;
2013 res.add_header("Content-Type", "application/json");
2014 res.body = make_error_json("NOT_FOUND", "Instance not found");
2015 return res;
2016 }
2017
2018 // Check Accept header
2019 auto accept = req.get_header_value("Accept");
2020 auto accept_infos = dicomweb::parse_accept_header(accept);
2021
2022 // Check if metadata is requested
2023 if (dicomweb::is_acceptable(accept_infos,
2024 dicomweb::media_type::dicom_json)) {
2025 std::vector<std::string> files = {*file_path};
2026 std::string bulk_uri = "/dicomweb/studies/" + study_uid +
2027 "/series/" + series_uid +
2028 "/instances/" + sop_uid + "/bulkdata/";
2029 return build_metadata_response(files, *ctx, bulk_uri);
2030 }
2031
2032 // Return DICOM instance
2033 auto data = read_file_bytes(*file_path);
2034 if (data.empty()) {
2035 res.code = 500;
2036 res.add_header("Content-Type", "application/json");
2037 res.body = make_error_json("READ_ERROR",
2038 "Failed to read DICOM file");
2039 return res;
2040 }
2041
2042 res.code = 200;
2043 res.add_header("Content-Type",
2044 std::string(dicomweb::media_type::dicom));
2045 res.body = std::string(reinterpret_cast<char*>(data.data()),
2046 data.size());
2047 return res;
2048 });
2049
2050 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/instances/{sopUID}/metadata
2051 CROW_ROUTE(app,
2052 "/dicomweb/studies/<string>/series/<string>/instances/<string>/metadata")
2053 .methods(crow::HTTPMethod::GET)(
2054 [ctx](const crow::request& /*req*/,
2055 const std::string& study_uid,
2056 const std::string& series_uid,
2057 const std::string& sop_uid) {
2058 crow::response res;
2059 add_cors_headers(res, *ctx);
2060
2061 if (!ctx->database) {
2062 res.code = 503;
2063 res.add_header("Content-Type", "application/json");
2064 res.body = make_error_json("DATABASE_UNAVAILABLE",
2065 "Database not configured");
2066 return res;
2067 }
2068
2069 auto file_path_result = ctx->database->get_file_path(sop_uid);
2070 if (!file_path_result.is_ok()) {
2071 res.code = 500;
2072 res.add_header("Content-Type", "application/json");
2073 res.body = make_error_json("QUERY_ERROR",
2074 file_path_result.error().message);
2075 return res;
2076 }
2077 const auto& file_path = file_path_result.value();
2078 if (!file_path) {
2079 res.code = 404;
2080 res.add_header("Content-Type", "application/json");
2081 res.body = make_error_json("NOT_FOUND", "Instance not found");
2082 return res;
2083 }
2084
2085 std::vector<std::string> files = {*file_path};
2086 std::string bulk_uri = "/dicomweb/studies/" + study_uid +
2087 "/series/" + series_uid +
2088 "/instances/" + sop_uid + "/bulkdata/";
2089 return build_metadata_response(files, *ctx, bulk_uri);
2090 });
2091
2092 // ========================================================================
2093 // Frame Retrieval (WADO-RS)
2094 // ========================================================================
2095
2096 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/instances/{sopUID}/frames/{frameNumbers}
2097 CROW_ROUTE(app,
2098 "/dicomweb/studies/<string>/series/<string>/instances/<string>/frames/<string>")
2099 .methods(crow::HTTPMethod::GET)(
2100 [ctx](const crow::request& req,
2101 const std::string& study_uid,
2102 const std::string& series_uid,
2103 const std::string& sop_uid,
2104 const std::string& frame_list) {
2105 crow::response res;
2106 add_cors_headers(res, *ctx);
2107
2108 if (!ctx->database) {
2109 res.code = 503;
2110 res.add_header("Content-Type", "application/json");
2111 res.body = make_error_json("DATABASE_UNAVAILABLE",
2112 "Database not configured");
2113 return res;
2114 }
2115
2116 auto file_path_result = ctx->database->get_file_path(sop_uid);
2117 if (!file_path_result.is_ok()) {
2118 res.code = 500;
2119 res.add_header("Content-Type", "application/json");
2120 res.body = make_error_json("QUERY_ERROR",
2121 file_path_result.error().message);
2122 return res;
2123 }
2124 const auto& file_path = file_path_result.value();
2125 if (!file_path) {
2126 res.code = 404;
2127 res.add_header("Content-Type", "application/json");
2128 res.body = make_error_json("NOT_FOUND", "Instance not found");
2129 return res;
2130 }
2131
2132 // Parse frame numbers
2133 auto frames = dicomweb::parse_frame_numbers(frame_list);
2134 if (frames.empty()) {
2135 res.code = 400;
2136 res.add_header("Content-Type", "application/json");
2137 res.body = make_error_json("INVALID_FRAME_LIST",
2138 "No valid frame numbers specified");
2139 return res;
2140 }
2141
2142 // Load DICOM file
2143 auto data = read_file_bytes(*file_path);
2144 if (data.empty()) {
2145 res.code = 500;
2146 res.add_header("Content-Type", "application/json");
2147 res.body = make_error_json("READ_ERROR",
2148 "Failed to read DICOM file");
2149 return res;
2150 }
2151
2152 auto dicom_result = core::dicom_file::from_bytes(
2153 std::span<const uint8_t>(data.data(), data.size()));
2154 if (dicom_result.is_err()) {
2155 res.code = 500;
2156 res.add_header("Content-Type", "application/json");
2157 res.body = make_error_json("PARSE_ERROR",
2158 "Failed to parse DICOM file");
2159 return res;
2160 }
2161
2162 const auto& dataset = dicom_result.value().dataset();
2163
2164 // Get image parameters
2165 auto rows_elem = dataset.get(core::tags::rows);
2166 auto cols_elem = dataset.get(core::tags::columns);
2167 auto bits_alloc_elem = dataset.get(core::tags::bits_allocated);
2168 auto samples_elem = dataset.get(core::tags::samples_per_pixel);
2169 // Number of Frames (0028,0008)
2170 constexpr core::dicom_tag number_of_frames_tag{0x0028, 0x0008};
2171 auto num_frames_elem = dataset.get(number_of_frames_tag);
2172 auto pixel_data_elem = dataset.get(core::tags::pixel_data);
2173
2174 if (!rows_elem || !cols_elem || !pixel_data_elem) {
2175 res.code = 400;
2176 res.add_header("Content-Type", "application/json");
2177 res.body = make_error_json("NOT_IMAGE",
2178 "Instance does not contain image data");
2179 return res;
2180 }
2181
2182 uint16_t rows = rows_elem->as_numeric<uint16_t>().unwrap_or(0);
2183 uint16_t cols = cols_elem->as_numeric<uint16_t>().unwrap_or(0);
2184 uint16_t bits_allocated = bits_alloc_elem ?
2185 bits_alloc_elem->as_numeric<uint16_t>().unwrap_or(16) : 16;
2186 uint16_t samples_per_pixel = samples_elem ?
2187 samples_elem->as_numeric<uint16_t>().unwrap_or(1) : 1;
2188 uint32_t num_frames = 1;
2189 if (num_frames_elem) {
2190 try {
2191 num_frames = std::stoul(num_frames_elem->as_string().unwrap_or("1"));
2192 } catch (...) {}
2193 }
2194
2195 // Calculate frame size
2196 size_t frame_size = static_cast<size_t>(rows) * cols *
2197 samples_per_pixel * ((bits_allocated + 7) / 8);
2198
2199 auto pixel_data = pixel_data_elem->raw_data();
2200
2201 // Check Accept header
2202 auto accept = req.get_header_value("Accept");
2203
2204 // Build multipart response for multiple frames
2206 dicomweb::media_type::octet_stream);
2207
2208 for (uint32_t frame_num : frames) {
2209 if (frame_num > num_frames) {
2210 // Skip invalid frame numbers
2211 continue;
2212 }
2213
2214 auto frame_data = dicomweb::extract_frame(
2215 pixel_data, frame_num, frame_size);
2216
2217 if (!frame_data.empty()) {
2218 std::string location = "/dicomweb/studies/" + study_uid +
2219 "/series/" + series_uid +
2220 "/instances/" + sop_uid +
2221 "/frames/" + std::to_string(frame_num);
2222 builder.add_part_with_location(std::move(frame_data), location);
2223 }
2224 }
2225
2226 if (builder.empty()) {
2227 res.code = 404;
2228 res.add_header("Content-Type", "application/json");
2229 res.body = make_error_json("NOT_FOUND",
2230 "No valid frames found");
2231 return res;
2232 }
2233
2234 // Return single part or multipart
2235 if (builder.size() == 1) {
2236 // Single frame - return directly
2237 auto body = builder.build();
2238 res.code = 200;
2239 res.add_header("Content-Type",
2240 std::string(dicomweb::media_type::octet_stream));
2241 // Extract just the data part from multipart
2242 auto frame_data = dicomweb::extract_frame(
2243 pixel_data, frames[0], frame_size);
2244 res.body = std::string(
2245 reinterpret_cast<char*>(frame_data.data()),
2246 frame_data.size());
2247 } else {
2248 // Multiple frames - return multipart
2249 res.code = 200;
2250 res.add_header("Content-Type", builder.content_type_header());
2251 res.body = builder.build();
2252 }
2253
2254 return res;
2255 });
2256
2257 // ========================================================================
2258 // Rendered Images (WADO-RS)
2259 // ========================================================================
2260
2261 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/instances/{sopUID}/rendered
2262 CROW_ROUTE(app,
2263 "/dicomweb/studies/<string>/series/<string>/instances/<string>/rendered")
2264 .methods(crow::HTTPMethod::GET)(
2265 [ctx](const crow::request& req,
2266 const std::string& study_uid,
2267 const std::string& series_uid,
2268 const std::string& sop_uid) {
2269 crow::response res;
2270 add_cors_headers(res, *ctx);
2271
2272 if (!ctx->database) {
2273 res.code = 503;
2274 res.add_header("Content-Type", "application/json");
2275 res.body = make_error_json("DATABASE_UNAVAILABLE",
2276 "Database not configured");
2277 return res;
2278 }
2279
2280 auto file_path_result = ctx->database->get_file_path(sop_uid);
2281 if (!file_path_result.is_ok()) {
2282 res.code = 500;
2283 res.add_header("Content-Type", "application/json");
2284 res.body = make_error_json("QUERY_ERROR",
2285 file_path_result.error().message);
2286 return res;
2287 }
2288 const auto& file_path = file_path_result.value();
2289 if (!file_path) {
2290 res.code = 404;
2291 res.add_header("Content-Type", "application/json");
2292 res.body = make_error_json("NOT_FOUND", "Instance not found");
2293 return res;
2294 }
2295
2296 // Parse rendering parameters
2297 auto accept = req.get_header_value("Accept");
2298 auto params = dicomweb::parse_rendered_params(req.raw_url, accept);
2299
2300 // Render image
2301 auto result = dicomweb::render_dicom_image(*file_path, params);
2302
2303 if (!result.success) {
2304 res.code = 400;
2305 res.add_header("Content-Type", "application/json");
2306 res.body = make_error_json("RENDER_ERROR", result.error_message);
2307 return res;
2308 }
2309
2310 res.code = 200;
2311 res.add_header("Content-Type", result.content_type);
2312 res.body = std::string(
2313 reinterpret_cast<char*>(result.data.data()),
2314 result.data.size());
2315 return res;
2316 });
2317
2318 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/instances/{sopUID}/frames/{frameNumber}/rendered
2319 CROW_ROUTE(app,
2320 "/dicomweb/studies/<string>/series/<string>/instances/<string>/frames/<string>/rendered")
2321 .methods(crow::HTTPMethod::GET)(
2322 [ctx](const crow::request& req,
2323 const std::string& study_uid,
2324 const std::string& series_uid,
2325 const std::string& sop_uid,
2326 const std::string& frame_str) {
2327 crow::response res;
2328 add_cors_headers(res, *ctx);
2329
2330 if (!ctx->database) {
2331 res.code = 503;
2332 res.add_header("Content-Type", "application/json");
2333 res.body = make_error_json("DATABASE_UNAVAILABLE",
2334 "Database not configured");
2335 return res;
2336 }
2337
2338 auto file_path_result = ctx->database->get_file_path(sop_uid);
2339 if (!file_path_result.is_ok()) {
2340 res.code = 500;
2341 res.add_header("Content-Type", "application/json");
2342 res.body = make_error_json("QUERY_ERROR",
2343 file_path_result.error().message);
2344 return res;
2345 }
2346 const auto& file_path = file_path_result.value();
2347 if (!file_path) {
2348 res.code = 404;
2349 res.add_header("Content-Type", "application/json");
2350 res.body = make_error_json("NOT_FOUND", "Instance not found");
2351 return res;
2352 }
2353
2354 // Parse frame number
2355 uint32_t frame_num = 1;
2356 try {
2357 frame_num = std::stoul(frame_str);
2358 if (frame_num == 0) frame_num = 1;
2359 } catch (...) {
2360 res.code = 400;
2361 res.add_header("Content-Type", "application/json");
2362 res.body = make_error_json("INVALID_FRAME",
2363 "Invalid frame number");
2364 return res;
2365 }
2366
2367 // Parse rendering parameters and set frame
2368 auto accept = req.get_header_value("Accept");
2369 auto params = dicomweb::parse_rendered_params(req.raw_url, accept);
2370 params.frame = frame_num;
2371
2372 // Render image
2373 auto result = dicomweb::render_dicom_image(*file_path, params);
2374
2375 if (!result.success) {
2376 res.code = 400;
2377 res.add_header("Content-Type", "application/json");
2378 res.body = make_error_json("RENDER_ERROR", result.error_message);
2379 return res;
2380 }
2381
2382 res.code = 200;
2383 res.add_header("Content-Type", result.content_type);
2384 res.body = std::string(
2385 reinterpret_cast<char*>(result.data.data()),
2386 result.data.size());
2387 return res;
2388 });
2389
2390 // ========================================================================
2391 // STOW-RS (Store Over the Web)
2392 // ========================================================================
2393
2394 // POST /dicomweb/studies - Store instances (new study)
2395 CROW_ROUTE(app, "/dicomweb/studies")
2396 .methods(crow::HTTPMethod::POST)(
2397 [ctx](const crow::request& req) {
2398 crow::response res;
2399 add_cors_headers(res, *ctx);
2400
2401 // OAuth 2.0 / legacy auth check (write scope)
2402 if (!check_dicomweb_auth(ctx, req, res,
2403 {"dicomweb.write"})) {
2404 return res;
2405 }
2406
2407 if (!ctx->database || !ctx->file_storage) {
2408 res.code = 503;
2409 res.add_header("Content-Type", "application/json");
2410 res.body = make_error_json("SERVICE_UNAVAILABLE",
2411 !ctx->database
2412 ? "Database not configured"
2413 : "File storage not configured");
2414 return res;
2415 }
2416
2417 // Parse Content-Type header
2418 auto content_type = req.get_header_value("Content-Type");
2419 if (content_type.empty() ||
2420 content_type.find("multipart/related") == std::string::npos) {
2421 res.code = 415; // Unsupported Media Type
2422 res.add_header("Content-Type", "application/json");
2423 res.body = make_error_json(
2424 "UNSUPPORTED_MEDIA_TYPE",
2425 "Content-Type must be multipart/related");
2426 return res;
2427 }
2428
2429 // Parse multipart body
2430 auto parse_result = dicomweb::multipart_parser::parse(
2431 content_type, req.body);
2432
2433 if (!parse_result) {
2434 res.code = 400;
2435 res.add_header("Content-Type", "application/json");
2436 res.body = make_error_json(
2437 parse_result.error->code,
2438 parse_result.error->message);
2439 return res;
2440 }
2441
2442 if (parse_result.parts.empty()) {
2443 res.code = 400;
2444 res.add_header("Content-Type", "application/json");
2445 res.body = make_error_json(
2446 "NO_INSTANCES",
2447 "No DICOM instances in request body");
2448 return res;
2449 }
2450
2451 // Process each part
2452 dicomweb::store_response store_response;
2453
2454 for (const auto& part : parse_result.parts) {
2456
2457 // Only process application/dicom parts
2458 if (part.content_type.find("application/dicom") ==
2459 std::string::npos) {
2460 continue;
2461 }
2462
2463 // Parse DICOM from memory
2464 auto dicom_result = core::dicom_file::from_bytes(
2465 std::span<const uint8_t>(part.data.data(), part.data.size()));
2466
2467 if (dicom_result.is_err()) {
2468 result.success = false;
2469 result.error_code = "INVALID_DATA";
2470 result.error_message = "Failed to parse DICOM data";
2471 store_response.failed_instances.push_back(
2472 std::move(result));
2473 continue;
2474 }
2475
2476 const auto& dataset = dicom_result.value().dataset();
2477
2478 // Validate instance
2479 auto validation = dicomweb::validate_instance(dataset);
2480 if (!validation) {
2481 result.success = false;
2482 result.error_code = validation.error_code;
2483 result.error_message = validation.error_message;
2484
2485 // Try to extract UIDs for error reporting
2486 if (auto elem = dataset.get(core::tags::sop_class_uid)) {
2487 result.sop_class_uid = elem->as_string().unwrap_or("");
2488 }
2489 if (auto elem = dataset.get(core::tags::sop_instance_uid)) {
2490 result.sop_instance_uid = elem->as_string().unwrap_or("");
2491 }
2492
2493 store_response.failed_instances.push_back(
2494 std::move(result));
2495 continue;
2496 }
2497
2498 // Extract UIDs
2499 auto sop_class_elem = dataset.get(core::tags::sop_class_uid);
2500 auto sop_instance_elem = dataset.get(core::tags::sop_instance_uid);
2501 auto study_uid_elem = dataset.get(core::tags::study_instance_uid);
2502 auto series_uid_elem = dataset.get(core::tags::series_instance_uid);
2503
2504 result.sop_class_uid = sop_class_elem->as_string().unwrap_or("");
2505 result.sop_instance_uid = sop_instance_elem->as_string().unwrap_or("");
2506
2507 std::string study_uid = study_uid_elem->as_string().unwrap_or("");
2508 std::string series_uid = series_uid_elem->as_string().unwrap_or("");
2509
2510 // Check for duplicate
2511 auto existing_result = ctx->database->get_file_path(
2512 result.sop_instance_uid);
2513 if (existing_result.is_ok() && existing_result.value()) {
2514 result.success = false;
2515 result.error_code = "DUPLICATE";
2516 result.error_message = "Instance already exists";
2517 store_response.failed_instances.push_back(
2518 std::move(result));
2519 continue;
2520 }
2521
2522 // Store instance via file_storage and index in database
2523 if (!store_instance_to_storage(ctx, dataset, result)) {
2524 store_response.failed_instances.push_back(
2525 std::move(result));
2526 continue;
2527 }
2528
2529 result.success = true;
2530 result.retrieve_url = "/dicomweb/studies/" + study_uid +
2531 "/series/" + series_uid +
2532 "/instances/" + result.sop_instance_uid;
2533 store_response.referenced_instances.push_back(
2534 std::move(result));
2535 }
2536
2537 // Build response
2538 std::string base_url; // Empty base URL, client should use relative URLs
2539
2540 res.add_header("Content-Type",
2541 std::string(dicomweb::media_type::dicom_json));
2542
2543 if (store_response.all_failed()) {
2544 res.code = 409; // Conflict
2545 } else if (store_response.partial_success()) {
2546 res.code = 202; // Accepted with warnings
2547 } else {
2548 res.code = 200; // OK
2549 }
2550
2551 res.body = dicomweb::build_store_response_json(
2552 store_response, base_url);
2553 return res;
2554 });
2555
2556 // POST /dicomweb/studies/{studyUID} - Store instances to existing study
2557 CROW_ROUTE(app, "/dicomweb/studies/<string>")
2558 .methods(crow::HTTPMethod::POST)(
2559 [ctx](const crow::request& req, const std::string& target_study_uid) {
2560 crow::response res;
2561 add_cors_headers(res, *ctx);
2562
2563 // OAuth 2.0 / legacy auth check (write scope)
2564 if (!check_dicomweb_auth(ctx, req, res,
2565 {"dicomweb.write"})) {
2566 return res;
2567 }
2568
2569 if (!ctx->database || !ctx->file_storage) {
2570 res.code = 503;
2571 res.add_header("Content-Type", "application/json");
2572 res.body = make_error_json("SERVICE_UNAVAILABLE",
2573 !ctx->database
2574 ? "Database not configured"
2575 : "File storage not configured");
2576 return res;
2577 }
2578
2579 // Parse Content-Type header
2580 auto content_type = req.get_header_value("Content-Type");
2581 if (content_type.empty() ||
2582 content_type.find("multipart/related") == std::string::npos) {
2583 res.code = 415;
2584 res.add_header("Content-Type", "application/json");
2585 res.body = make_error_json(
2586 "UNSUPPORTED_MEDIA_TYPE",
2587 "Content-Type must be multipart/related");
2588 return res;
2589 }
2590
2591 // Parse multipart body
2592 auto parse_result = dicomweb::multipart_parser::parse(
2593 content_type, req.body);
2594
2595 if (!parse_result) {
2596 res.code = 400;
2597 res.add_header("Content-Type", "application/json");
2598 res.body = make_error_json(
2599 parse_result.error->code,
2600 parse_result.error->message);
2601 return res;
2602 }
2603
2604 if (parse_result.parts.empty()) {
2605 res.code = 400;
2606 res.add_header("Content-Type", "application/json");
2607 res.body = make_error_json(
2608 "NO_INSTANCES",
2609 "No DICOM instances in request body");
2610 return res;
2611 }
2612
2613 // Process each part
2614 dicomweb::store_response store_response;
2615
2616 for (const auto& part : parse_result.parts) {
2618
2619 // Only process application/dicom parts
2620 if (part.content_type.find("application/dicom") ==
2621 std::string::npos) {
2622 continue;
2623 }
2624
2625 // Parse DICOM from memory
2626 auto dicom_result = core::dicom_file::from_bytes(
2627 std::span<const uint8_t>(part.data.data(), part.data.size()));
2628
2629 if (dicom_result.is_err()) {
2630 result.success = false;
2631 result.error_code = "INVALID_DATA";
2632 result.error_message = "Failed to parse DICOM data";
2633 store_response.failed_instances.push_back(
2634 std::move(result));
2635 continue;
2636 }
2637
2638 const auto& dataset = dicom_result.value().dataset();
2639
2640 // Validate instance with target study UID check
2641 auto validation = dicomweb::validate_instance(
2642 dataset, target_study_uid);
2643 if (!validation) {
2644 result.success = false;
2645 result.error_code = validation.error_code;
2646 result.error_message = validation.error_message;
2647
2648 if (auto elem = dataset.get(core::tags::sop_class_uid)) {
2649 result.sop_class_uid = elem->as_string().unwrap_or("");
2650 }
2651 if (auto elem = dataset.get(core::tags::sop_instance_uid)) {
2652 result.sop_instance_uid = elem->as_string().unwrap_or("");
2653 }
2654
2655 store_response.failed_instances.push_back(
2656 std::move(result));
2657 continue;
2658 }
2659
2660 // Extract UIDs
2661 auto sop_class_elem = dataset.get(core::tags::sop_class_uid);
2662 auto sop_instance_elem = dataset.get(core::tags::sop_instance_uid);
2663 auto series_uid_elem = dataset.get(core::tags::series_instance_uid);
2664
2665 result.sop_class_uid = sop_class_elem->as_string().unwrap_or("");
2666 result.sop_instance_uid = sop_instance_elem->as_string().unwrap_or("");
2667 std::string series_uid = series_uid_elem->as_string().unwrap_or("");
2668
2669 // Check for duplicate
2670 auto existing_result = ctx->database->get_file_path(
2671 result.sop_instance_uid);
2672 if (existing_result.is_ok() && existing_result.value()) {
2673 result.success = false;
2674 result.error_code = "DUPLICATE";
2675 result.error_message = "Instance already exists";
2676 store_response.failed_instances.push_back(
2677 std::move(result));
2678 continue;
2679 }
2680
2681 // Store instance via file_storage and index in database
2682 if (!store_instance_to_storage(ctx, dataset, result)) {
2683 store_response.failed_instances.push_back(
2684 std::move(result));
2685 continue;
2686 }
2687
2688 result.success = true;
2689 result.retrieve_url = "/dicomweb/studies/" + target_study_uid +
2690 "/series/" + series_uid +
2691 "/instances/" + result.sop_instance_uid;
2692 store_response.referenced_instances.push_back(
2693 std::move(result));
2694 }
2695
2696 // Build response
2697 std::string base_url; // Empty base URL, client should use relative URLs
2698
2699 res.add_header("Content-Type",
2700 std::string(dicomweb::media_type::dicom_json));
2701
2702 if (store_response.all_failed()) {
2703 res.code = 409; // Conflict
2704 } else if (store_response.partial_success()) {
2705 res.code = 202; // Accepted with warnings
2706 } else {
2707 res.code = 200; // OK
2708 }
2709
2710 res.body = dicomweb::build_store_response_json(
2711 store_response, base_url);
2712 return res;
2713 });
2714
2715 // ========================================================================
2716 // QIDO-RS (Query based on ID for DICOM Objects)
2717 // ========================================================================
2718
2719 // GET /dicomweb/studies - Search for studies
2720 CROW_ROUTE(app, "/dicomweb/studies")
2721 .methods(crow::HTTPMethod::GET)(
2722 [ctx](const crow::request& req) {
2723 crow::response res;
2724 add_cors_headers(res, *ctx);
2725 res.add_header("Content-Type",
2726 std::string(dicomweb::media_type::dicom_json));
2727
2728 // OAuth 2.0 / legacy auth check (read or search scope)
2729 if (!check_dicomweb_auth(ctx, req, res,
2730 {"dicomweb.read", "dicomweb.search"})) {
2731 return res;
2732 }
2733
2734 if (!ctx->database) {
2735 res.code = 503;
2736 res.body = make_error_json("DATABASE_UNAVAILABLE",
2737 "Database not configured");
2738 return res;
2739 }
2740
2741 // Parse query parameters
2742 auto query = dicomweb::parse_study_query_params(req.raw_url);
2743
2744 // Set default limit if not specified (prevent unlimited queries)
2745 if (query.limit == 0) {
2746 query.limit = 100;
2747 }
2748
2749 // Execute search
2750 auto studies_result = ctx->database->search_studies(query);
2751 if (!studies_result.is_ok()) {
2752 res.code = 500;
2753 res.body = make_error_json("QUERY_ERROR",
2754 studies_result.error().message);
2755 return res;
2756 }
2757
2758 // Build response
2759 std::ostringstream oss;
2760 oss << "[";
2761
2762 bool first = true;
2763 for (const auto& study : studies_result.value()) {
2764 if (!first) oss << ",";
2765 first = false;
2766
2767 // Get patient info for this study
2768 std::string patient_id;
2769 std::string patient_name;
2770 if (auto patient = ctx->database->find_patient_by_pk(study.patient_pk)) {
2771 patient_id = patient->patient_id;
2772 patient_name = patient->patient_name;
2773 }
2774
2775 oss << dicomweb::study_record_to_dicom_json(
2776 study, patient_id, patient_name);
2777 }
2778
2779 oss << "]";
2780
2781 res.code = 200;
2782 res.body = oss.str();
2783 return res;
2784 });
2785
2786 // GET /dicomweb/series - Search for all series
2787 CROW_ROUTE(app, "/dicomweb/series")
2788 .methods(crow::HTTPMethod::GET)(
2789 [ctx](const crow::request& req) {
2790 crow::response res;
2791 add_cors_headers(res, *ctx);
2792 res.add_header("Content-Type",
2793 std::string(dicomweb::media_type::dicom_json));
2794
2795 if (!ctx->database) {
2796 res.code = 503;
2797 res.body = make_error_json("DATABASE_UNAVAILABLE",
2798 "Database not configured");
2799 return res;
2800 }
2801
2802 // Parse query parameters
2803 auto query = dicomweb::parse_series_query_params(req.raw_url);
2804
2805 // Set default limit if not specified
2806 if (query.limit == 0) {
2807 query.limit = 100;
2808 }
2809
2810 // Execute search
2811 auto series_list_result = ctx->database->search_series(query);
2812 if (!series_list_result.is_ok()) {
2813 res.code = 500;
2814 res.body = make_error_json("QUERY_ERROR",
2815 series_list_result.error().message);
2816 return res;
2817 }
2818
2819 // Build response
2820 std::ostringstream oss;
2821 oss << "[";
2822
2823 bool first = true;
2824 for (const auto& series : series_list_result.value()) {
2825 if (!first) oss << ",";
2826 first = false;
2827
2828 // Get study UID for this series
2829 std::string study_uid;
2830 if (auto study = ctx->database->find_study_by_pk(series.study_pk)) {
2831 study_uid = study->study_uid;
2832 }
2833
2834 oss << dicomweb::series_record_to_dicom_json(series, study_uid);
2835 }
2836
2837 oss << "]";
2838
2839 res.code = 200;
2840 res.body = oss.str();
2841 return res;
2842 });
2843
2844 // GET /dicomweb/instances - Search for all instances
2845 CROW_ROUTE(app, "/dicomweb/instances")
2846 .methods(crow::HTTPMethod::GET)(
2847 [ctx](const crow::request& req) {
2848 crow::response res;
2849 add_cors_headers(res, *ctx);
2850 res.add_header("Content-Type",
2851 std::string(dicomweb::media_type::dicom_json));
2852
2853 if (!ctx->database) {
2854 res.code = 503;
2855 res.body = make_error_json("DATABASE_UNAVAILABLE",
2856 "Database not configured");
2857 return res;
2858 }
2859
2860 // Parse query parameters
2861 auto query = dicomweb::parse_instance_query_params(req.raw_url);
2862
2863 // Set default limit if not specified
2864 if (query.limit == 0) {
2865 query.limit = 100;
2866 }
2867
2868 // Execute search
2869 auto instances_result = ctx->database->search_instances(query);
2870 if (!instances_result.is_ok()) {
2871 res.code = 500;
2872 res.body = make_error_json("QUERY_ERROR",
2873 instances_result.error().message);
2874 return res;
2875 }
2876
2877 // Build response
2878 std::ostringstream oss;
2879 oss << "[";
2880
2881 bool first = true;
2882 for (const auto& instance : instances_result.value()) {
2883 if (!first) oss << ",";
2884 first = false;
2885
2886 // Get series and study UIDs
2887 std::string series_uid;
2888 std::string study_uid;
2889 if (auto series = ctx->database->find_series_by_pk(instance.series_pk)) {
2890 series_uid = series->series_uid;
2891 if (auto study = ctx->database->find_study_by_pk(series->study_pk)) {
2892 study_uid = study->study_uid;
2893 }
2894 }
2895
2896 oss << dicomweb::instance_record_to_dicom_json(
2897 instance, series_uid, study_uid);
2898 }
2899
2900 oss << "]";
2901
2902 res.code = 200;
2903 res.body = oss.str();
2904 return res;
2905 });
2906
2907 // GET /dicomweb/studies/{studyUID}/series - Search series in a study (QIDO-RS)
2908 CROW_ROUTE(app, "/dicomweb/studies/<string>/series")
2909 .methods(crow::HTTPMethod::GET)(
2910 [ctx](const crow::request& req, const std::string& study_uid) {
2911 crow::response res;
2912 add_cors_headers(res, *ctx);
2913 res.add_header("Content-Type",
2914 std::string(dicomweb::media_type::dicom_json));
2915
2916 // OAuth 2.0 / legacy auth check
2917 if (!check_dicomweb_auth(ctx, req, res,
2918 {"dicomweb.read", "dicomweb.search"})) {
2919 return res;
2920 }
2921
2922 if (!ctx->database) {
2923 res.code = 503;
2924 res.body = make_error_json("DATABASE_UNAVAILABLE",
2925 "Database not configured");
2926 return res;
2927 }
2928
2929 // Verify study exists
2930 auto study = ctx->database->find_study(study_uid);
2931 if (!study) {
2932 res.code = 404;
2933 res.body = make_error_json("NOT_FOUND", "Study not found");
2934 return res;
2935 }
2936
2937 // Parse query parameters and add study filter
2938 auto query = dicomweb::parse_series_query_params(req.raw_url);
2939 query.study_uid = study_uid;
2940
2941 // Set default limit if not specified
2942 if (query.limit == 0) {
2943 query.limit = 100;
2944 }
2945
2946 // Execute search
2947 auto series_list_result = ctx->database->search_series(query);
2948 if (!series_list_result.is_ok()) {
2949 res.code = 500;
2950 res.body = make_error_json("QUERY_ERROR",
2951 series_list_result.error().message);
2952 return res;
2953 }
2954
2955 // Build response
2956 std::ostringstream oss;
2957 oss << "[";
2958
2959 bool first = true;
2960 for (const auto& series : series_list_result.value()) {
2961 if (!first) oss << ",";
2962 first = false;
2963
2964 oss << dicomweb::series_record_to_dicom_json(series, study_uid);
2965 }
2966
2967 oss << "]";
2968
2969 res.code = 200;
2970 res.body = oss.str();
2971 return res;
2972 });
2973
2974 // GET /dicomweb/studies/{studyUID}/instances - Search instances in a study (QIDO-RS)
2975 CROW_ROUTE(app, "/dicomweb/studies/<string>/instances")
2976 .methods(crow::HTTPMethod::GET)(
2977 [ctx](const crow::request& req, const std::string& study_uid) {
2978 crow::response res;
2979 add_cors_headers(res, *ctx);
2980 res.add_header("Content-Type",
2981 std::string(dicomweb::media_type::dicom_json));
2982
2983 // OAuth 2.0 / legacy auth check
2984 if (!check_dicomweb_auth(ctx, req, res,
2985 {"dicomweb.read", "dicomweb.search"})) {
2986 return res;
2987 }
2988
2989 if (!ctx->database) {
2990 res.code = 503;
2991 res.body = make_error_json("DATABASE_UNAVAILABLE",
2992 "Database not configured");
2993 return res;
2994 }
2995
2996 // Verify study exists
2997 auto study = ctx->database->find_study(study_uid);
2998 if (!study) {
2999 res.code = 404;
3000 res.body = make_error_json("NOT_FOUND", "Study not found");
3001 return res;
3002 }
3003
3004 // Get all series in this study and then instances
3005 storage::series_query series_query;
3006 series_query.study_uid = study_uid;
3007 auto series_list_result = ctx->database->search_series(series_query);
3008 if (!series_list_result.is_ok()) {
3009 res.code = 500;
3010 res.body = make_error_json("QUERY_ERROR",
3011 series_list_result.error().message);
3012 return res;
3013 }
3014
3015 // Parse query parameters for additional filters
3016 auto inst_query = dicomweb::parse_instance_query_params(req.raw_url);
3017 if (inst_query.limit == 0) {
3018 inst_query.limit = 100;
3019 }
3020
3021 // Collect instances from all series
3022 std::ostringstream oss;
3023 oss << "[";
3024
3025 bool first = true;
3026 size_t count = 0;
3027 size_t skipped = 0;
3028
3029 for (const auto& series : series_list_result.value()) {
3030 if (count >= inst_query.limit) break;
3031
3032 // Search instances in this series
3034 query.series_uid = series.series_uid;
3035 if (inst_query.sop_uid.has_value()) {
3036 query.sop_uid = inst_query.sop_uid;
3037 }
3038 if (inst_query.sop_class_uid.has_value()) {
3039 query.sop_class_uid = inst_query.sop_class_uid;
3040 }
3041 if (inst_query.instance_number.has_value()) {
3042 query.instance_number = inst_query.instance_number;
3043 }
3044 query.limit = inst_query.limit - count;
3045
3046 auto instances_result = ctx->database->search_instances(query);
3047 if (!instances_result.is_ok()) {
3048 continue;
3049 }
3050 for (const auto& instance : instances_result.value()) {
3051 // Handle offset
3052 if (skipped < inst_query.offset) {
3053 ++skipped;
3054 continue;
3055 }
3056
3057 if (count >= inst_query.limit) break;
3058
3059 if (!first) oss << ",";
3060 first = false;
3061
3062 oss << dicomweb::instance_record_to_dicom_json(
3063 instance, series.series_uid, study_uid);
3064 ++count;
3065 }
3066 }
3067
3068 oss << "]";
3069
3070 res.code = 200;
3071 res.body = oss.str();
3072 return res;
3073 });
3074
3075 // GET /dicomweb/studies/{studyUID}/series/{seriesUID}/instances - Search instances in a series (QIDO-RS)
3076 CROW_ROUTE(app, "/dicomweb/studies/<string>/series/<string>/instances")
3077 .methods(crow::HTTPMethod::GET)(
3078 [ctx](const crow::request& req,
3079 const std::string& study_uid,
3080 const std::string& series_uid) {
3081 crow::response res;
3082 add_cors_headers(res, *ctx);
3083 res.add_header("Content-Type",
3084 std::string(dicomweb::media_type::dicom_json));
3085
3086 // OAuth 2.0 / legacy auth check
3087 if (!check_dicomweb_auth(ctx, req, res,
3088 {"dicomweb.read", "dicomweb.search"})) {
3089 return res;
3090 }
3091
3092 if (!ctx->database) {
3093 res.code = 503;
3094 res.body = make_error_json("DATABASE_UNAVAILABLE",
3095 "Database not configured");
3096 return res;
3097 }
3098
3099 // Verify study exists
3100 auto study = ctx->database->find_study(study_uid);
3101 if (!study) {
3102 res.code = 404;
3103 res.body = make_error_json("NOT_FOUND", "Study not found");
3104 return res;
3105 }
3106
3107 // Verify series exists
3108 auto series = ctx->database->find_series(series_uid);
3109 if (!series) {
3110 res.code = 404;
3111 res.body = make_error_json("NOT_FOUND", "Series not found");
3112 return res;
3113 }
3114
3115 // Parse query parameters and add series filter
3116 auto query = dicomweb::parse_instance_query_params(req.raw_url);
3117 query.series_uid = series_uid;
3118
3119 // Set default limit if not specified
3120 if (query.limit == 0) {
3121 query.limit = 100;
3122 }
3123
3124 // Execute search
3125 auto instances_result = ctx->database->search_instances(query);
3126 if (!instances_result.is_ok()) {
3127 res.code = 500;
3128 res.body = make_error_json("QUERY_ERROR",
3129 instances_result.error().message);
3130 return res;
3131 }
3132
3133 // Build response
3134 std::ostringstream oss;
3135 oss << "[";
3136
3137 bool first = true;
3138 for (const auto& instance : instances_result.value()) {
3139 if (!first) oss << ",";
3140 first = false;
3141
3142 oss << dicomweb::instance_record_to_dicom_json(
3143 instance, series_uid, study_uid);
3144 }
3145
3146 oss << "]";
3147
3148 res.code = 200;
3149 res.body = oss.str();
3150 return res;
3151 });
3152
3153 // ========================================================================
3154 // CORS Preflight Handler for DICOMweb
3155 // ========================================================================
3156
3157 CROW_ROUTE(app, "/dicomweb/<path>")
3158 .methods(crow::HTTPMethod::OPTIONS)(
3159 [ctx](const crow::request& /*req*/, const std::string& /*path*/) {
3160 crow::response res(204);
3161 if (ctx->config) {
3162 res.add_header("Access-Control-Allow-Origin",
3163 ctx->config->cors_allowed_origins);
3164 }
3165 res.add_header("Access-Control-Allow-Methods",
3166 "GET, POST, OPTIONS");
3167 res.add_header("Access-Control-Allow-Headers",
3168 "Content-Type, Accept, Authorization");
3169 res.add_header("Access-Control-Max-Age", "86400");
3170 return res;
3171 });
3172}
3173
3174} // namespace kcenon::pacs::web::endpoints
static auto from_bytes(std::span< const uint8_t > data) -> kcenon::pacs::Result< dicom_file >
Parse a DICOM file from raw bytes.
High-Throughput JPEG 2000 (HTJ2K) codec implementation.
Definition htj2k_codec.h:47
codec_result encode(std::span< const uint8_t > pixel_data, const image_params &params, const compression_options &options={}) const override
Compresses pixel data to HTJ2K format.
JPEG Baseline (Process 1) codec implementation.
codec_result encode(std::span< const uint8_t > pixel_data, const image_params &params, const compression_options &options={}) const override
Compresses pixel data to JPEG Baseline format.
Builder for multipart MIME responses.
auto build() const -> std::string
Build the complete multipart response body.
auto boundary() const -> std::string_view
Get the boundary string.
auto content_type_header() const -> std::string
Get the Content-Type header value for this multipart response.
void add_part(std::vector< uint8_t > data, std::optional< std::string_view > content_type=std::nullopt)
Add a part to the multipart response.
static auto generate_boundary() -> std::string
Generate a unique boundary string.
auto empty() const noexcept -> bool
Check if any parts have been added.
void add_part_with_location(std::vector< uint8_t > data, std::string_view location, std::optional< std::string_view > content_type=std::nullopt)
Add a part with location header.
multipart_builder(std::string_view content_type=media_type::dicom)
Construct a multipart builder.
auto size() const noexcept -> size_t
Get the number of parts.
static auto extract_type(std::string_view content_type) -> std::optional< std::string >
Extract type parameter from Content-Type header.
static auto extract_boundary(std::string_view content_type) -> std::optional< std::string >
Extract boundary from Content-Type header.
static auto parse_part_headers(std::string_view header_section) -> std::vector< std::pair< std::string, std::string > >
Parse headers from a part's header section.
static auto parse(std::string_view content_type, std::string_view body) -> parse_result
Parse a multipart/related request body.
DICOM Dataset - ordered collection of Data Elements.
DICOM Data Element representation (Tag, VR, Value)
DICOM Part 10 file handling for reading/writing DICOM files.
Compile-time constants for commonly used DICOM tags.
DICOMweb (WADO-RS) API endpoints for REST server.
Filesystem-based DICOM storage with hierarchical organization.
PACS index database for metadata storage and retrieval.
Instance record data structures for database operations.
constexpr dicom_tag window_width
Window Width.
constexpr dicom_tag window_center
Window Center.
constexpr dicom_tag patient_id
Patient ID.
constexpr dicom_tag bits_allocated
Bits Allocated.
constexpr dicom_tag rows
Rows.
constexpr dicom_tag rescale_intercept
Rescale Intercept.
constexpr dicom_tag columns
Columns.
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
constexpr dicom_tag bits_stored
Bits Stored.
constexpr dicom_tag pixel_data
Pixel Data.
constexpr dicom_tag pixel_representation
Pixel Representation.
constexpr dicom_tag rescale_slope
Rescale Slope.
constexpr dicom_tag study_time
Study Time.
constexpr dicom_tag samples_per_pixel
Samples per Pixel.
constexpr dicom_tag study_instance_uid
Study Instance UID.
constexpr dicom_tag series_number
Series Number.
constexpr dicom_tag sop_class_uid
SOP Class UID.
constexpr dicom_tag study_id
Study ID.
constexpr dicom_tag patient_name
Patient's Name.
constexpr dicom_tag study_date
Study Date.
constexpr dicom_tag series_instance_uid
Series Instance UID.
constexpr dicom_tag instance_number
Instance Number.
@ monochrome2
Minimum pixel value displayed as black.
vr_type
DICOM Value Representation (VR) types.
Definition vr_type.h:29
auto study_record_to_dicom_json(const storage::study_record &record, std::string_view patient_id, std::string_view patient_name) -> std::string
Convert a study record to DicomJSON format for QIDO-RS response.
auto extract_frame(std::span< const uint8_t > pixel_data, uint32_t frame_number, size_t frame_size) -> std::vector< uint8_t >
Extract a single frame from pixel data.
auto parse_accept_header(std::string_view accept_header) -> std::vector< accept_info >
Parse Accept header into structured format.
auto series_record_to_dicom_json(const storage::series_record &record, std::string_view study_uid) -> std::string
Convert a series record to DicomJSON format for QIDO-RS response.
auto is_acceptable(const std::vector< accept_info > &accept_infos, std::string_view media_type) -> bool
Check if a media type is acceptable based on Accept header.
auto parse_rendered_params(std::string_view query_string, std::string_view accept_header) -> rendered_params
Parse rendered image parameters from HTTP request.
auto build_store_response_json(const store_response &response, std::string_view base_url) -> std::string
Build STOW-RS response in DicomJSON format.
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 validate_instance(const core::dicom_dataset &dataset, std::optional< std::string_view > target_study_uid=std::nullopt) -> validation_result
Validate a DICOM instance for STOW-RS storage.
auto is_bulk_data_tag(uint32_t tag) -> bool
Check if a DICOM tag contains bulk data.
auto parse_frame_numbers(std::string_view frame_list) -> std::vector< uint32_t >
Parse frame numbers from URL path.
auto parse_instance_query_params(const std::string &url_params) -> storage::instance_query
Parse QIDO-RS instance query parameters from HTTP request.
auto instance_record_to_dicom_json(const storage::instance_record &record, std::string_view series_uid, std::string_view study_uid) -> std::string
Convert an instance record to DicomJSON format for QIDO-RS response.
auto apply_window_level(std::span< const uint8_t > pixel_data, uint16_t width, uint16_t height, uint16_t bits_stored, bool is_signed, double window_center, double window_width, double rescale_slope=1.0, double rescale_intercept=0.0) -> std::vector< uint8_t >
Apply window/level transformation to pixel data.
auto vr_to_string(uint16_t vr_code) -> std::string
Convert a VR type code to DicomJSON VR string.
auto parse_series_query_params(const std::string &url_params) -> storage::series_query
Parse QIDO-RS series query parameters from HTTP request.
auto render_dicom_image(std::string_view file_path, const rendered_params &params) -> rendered_result
Render a DICOM image to JPEG or PNG.
auto parse_study_query_params(const std::string &url_params) -> storage::study_query
Parse QIDO-RS query parameters from HTTP request.
void register_dicomweb_endpoints_impl(crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
std::string json_escape(std::string_view s)
Escape a string for JSON.
Definition rest_types.h:101
OAuth 2.0 middleware for DICOMweb endpoint authorization.
Patient record data structures for database operations.
Configuration for REST API server.
Common types and utilities for REST API.
Series record data structures for database operations.
Compression quality settings for lossy codecs.
Parameters describing image pixel data.
uint16_t samples_per_pixel
Number of samples per pixel (0028,0002) 1 for grayscale, 3 for color.
uint16_t bits_allocated
Bits allocated per pixel sample (0028,0100) Valid values: 8, 16.
uint16_t height
Image height in pixels (Rows - 0028,0010)
photometric_interpretation photometric
Photometric interpretation (0028,0004)
uint16_t width
Image width in pixels (Columns - 0028,0011)
uint16_t bits_stored
Bits stored per pixel sample (0028,0101) Must be <= bits_allocated.
uint16_t high_bit
High bit position (0028,0102) Typically bits_stored - 1.
Instance record from the database.
std::optional< std::string > study_uid
Study Instance UID for filtering by study (exact match)
Series record from the database.
Study record from the database.
Parsed Accept header information.
Media types supported by WADO-RS.
static constexpr std::string_view dicom
static constexpr std::string_view jphc
static constexpr std::string_view jpeg
std::optional< parse_error > error
Error if parsing failed.
std::vector< multipart_part > parts
Parsed parts (empty on error)
Parsed part from multipart request.
std::string content_id
Content-ID header (optional)
std::string content_type
Content-Type of this part.
std::vector< uint8_t > data
Binary data of this part.
std::string content_location
Content-Location header (optional)
Parameters for rendered image requests.
uint16_t viewport_width
Output viewport width (0 = original size)
std::optional< double > window_center
Window center (default: auto from DICOM or calculated)
std::optional< double > window_width
Window width (default: auto from DICOM or calculated)
uint32_t frame
Frame number for multi-frame images (1-based, default 1)
rendered_format format
Output format (jpeg or png)
int quality
JPEG quality (1-100, default 75)
bool burn_annotations
Annotation (burned-in or removed)
uint16_t viewport_height
Output viewport height (0 = original size)
Result of rendered image operation.
static rendered_result ok(std::vector< uint8_t > d, std::string_view mime_type)
static rendered_result error(std::string msg)
STOW-RS store result for a single instance.
std::optional< std::string > error_code
Error code if failed.
std::string sop_class_uid
SOP Class UID of the instance.
std::optional< std::string > error_message
Error message if failed.
std::string retrieve_url
URL to retrieve the stored instance.
std::string sop_instance_uid
SOP Instance UID of the instance.
Validation result for DICOM instance.
static validation_result error(std::string code, std::string message)
Study record data structures for database operations.
System API endpoints for REST server.
vr_encoding vr
std::string_view uid
std::string_view name