PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
s3_storage.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
16
21
22#include <algorithm>
23#include <set>
24#include <sstream>
25
26#if defined(PACS_WITH_AWS_SDK) && !defined(PACS_USE_MOCK_S3)
27#include <aws/core/Aws.h>
28#include <aws/core/auth/AWSCredentials.h>
29#include <aws/s3/S3Client.h>
30#include <aws/s3/model/DeleteObjectRequest.h>
31#include <aws/s3/model/GetObjectRequest.h>
32#include <aws/s3/model/HeadObjectRequest.h>
33#include <aws/s3/model/ListObjectsV2Request.h>
34#include <aws/s3/model/PutObjectRequest.h>
35
36#include <aws/s3/model/AbortMultipartUploadRequest.h>
37#include <aws/s3/model/CompleteMultipartUploadRequest.h>
38#include <aws/s3/model/CreateMultipartUploadRequest.h>
39#include <aws/s3/model/UploadPartRequest.h>
40
41#include <mutex>
42#endif
43
44namespace kcenon::pacs::storage {
45
46using kcenon::common::make_error;
47using kcenon::common::ok;
48
49namespace {
50
52constexpr int kMissingRequiredUid = -1;
53constexpr int kObjectNotFound = -2;
54constexpr int kUploadError = -3;
55constexpr int kDownloadError = -4;
56constexpr int kConnectionError = -6;
57constexpr int kIntegrityError = -7;
58constexpr int kSerializationError = -8;
59
60} // namespace
61
62// ============================================================================
63// S3 Client Interface
64// ============================================================================
65
73public:
74 virtual ~s3_client_interface() = default;
75
76 [[nodiscard]] virtual auto put_object(const std::string &key,
77 const std::vector<std::uint8_t> &data)
78 -> VoidResult = 0;
79
80 [[nodiscard]] virtual auto get_object(const std::string &key)
82
83 [[nodiscard]] virtual auto delete_object(const std::string &key)
84 -> VoidResult = 0;
85
86 [[nodiscard]] virtual auto head_object(const std::string &key) const
87 -> bool = 0;
88
89 [[nodiscard]] virtual auto get_object_size(const std::string &key) const
90 -> std::size_t = 0;
91
92 [[nodiscard]] virtual auto list_objects() const
93 -> std::vector<std::string> = 0;
94
95 [[nodiscard]] virtual auto is_connected() const -> bool = 0;
96
97 [[nodiscard]] virtual auto multipart_upload(
98 const std::string &key, const std::vector<std::uint8_t> &data,
99 std::size_t part_size, progress_callback callback) -> VoidResult = 0;
100};
101
102// ============================================================================
103// Mock S3 Client Implementation
104// ============================================================================
105
112class mock_s3_client : public s3_storage::s3_client_interface {
113public:
114 explicit mock_s3_client(const cloud_storage_config & /*config*/)
115 : connected_(true) {}
116
117 [[nodiscard]] auto put_object(const std::string &key,
118 const std::vector<std::uint8_t> &data)
119 -> VoidResult override {
120 if (!connected_) {
121 return make_error<std::monostate>(
122 kConnectionError, "S3 client not connected", "s3_storage");
123 }
124 objects_[key] = data;
125 return ok();
126 }
127
128 [[nodiscard]] auto get_object(const std::string &key)
130 if (!connected_) {
131 return make_error<std::vector<std::uint8_t>>(
132 kConnectionError, "S3 client not connected", "s3_storage");
133 }
134 auto it = objects_.find(key);
135 if (it == objects_.end()) {
136 return make_error<std::vector<std::uint8_t>>(
137 kObjectNotFound, "Object not found: " + key, "s3_storage");
138 }
139 return it->second;
140 }
141
142 [[nodiscard]] auto delete_object(const std::string &key)
143 -> VoidResult override {
144 if (!connected_) {
145 return make_error<std::monostate>(
146 kConnectionError, "S3 client not connected", "s3_storage");
147 }
148 objects_.erase(key);
149 return ok();
150 }
151
152 [[nodiscard]] auto head_object(const std::string &key) const
153 -> bool override {
154 if (!connected_) {
155 return false;
156 }
157 return objects_.contains(key);
158 }
159
160 [[nodiscard]] auto get_object_size(const std::string &key) const
161 -> std::size_t override {
162 auto it = objects_.find(key);
163 if (it != objects_.end()) {
164 return it->second.size();
165 }
166 return 0;
167 }
168
169 [[nodiscard]] auto list_objects() const
170 -> std::vector<std::string> override {
171 std::vector<std::string> keys;
172 keys.reserve(objects_.size());
173 for (const auto &[key, data] : objects_) {
174 keys.push_back(key);
175 }
176 return keys;
177 }
178
179 [[nodiscard]] auto is_connected() const -> bool override {
180 return connected_;
181 }
182
183 [[nodiscard]] auto multipart_upload(const std::string &key,
184 const std::vector<std::uint8_t> &data,
185 std::size_t part_size,
186 progress_callback callback)
187 -> VoidResult override {
188 std::size_t total_bytes = data.size();
189 std::size_t bytes_uploaded = 0;
190
191 while (bytes_uploaded < total_bytes) {
192 std::size_t chunk = (std::min)(part_size, total_bytes - bytes_uploaded);
193 bytes_uploaded += chunk;
194
195 if (callback && !callback(bytes_uploaded, total_bytes)) {
196 return make_error<std::monostate>(
197 kUploadError, "Upload cancelled by user", "s3_storage");
198 }
199 }
200
201 return put_object(key, data);
202 }
203
204private:
205 std::unordered_map<std::string, std::vector<std::uint8_t>> objects_;
207};
208
209// ============================================================================
210// AWS SDK S3 Client Implementation
211// ============================================================================
212
213#if defined(PACS_WITH_AWS_SDK) && !defined(PACS_USE_MOCK_S3)
214
215namespace {
216
218class aws_sdk_guard {
219public:
220 static void ensure_initialized() {
221 std::call_once(init_flag_, [] {
222 Aws::InitAPI(options_);
223 std::atexit([] { Aws::ShutdownAPI(options_); });
224 });
225 }
226
227private:
228 static inline std::once_flag init_flag_;
229 static inline Aws::SDKOptions options_;
230};
231
232} // namespace
233
240class aws_s3_client : public s3_storage::s3_client_interface {
241public:
242 explicit aws_s3_client(const cloud_storage_config &config)
243 : bucket_(config.bucket_name) {
244 aws_sdk_guard::ensure_initialized();
245
246 Aws::Client::ClientConfiguration client_config;
247 client_config.region = config.region;
248 client_config.connectTimeoutMs = config.connect_timeout_ms;
249 client_config.requestTimeoutMs = config.request_timeout_ms;
250 client_config.maxConnections = static_cast<unsigned>(config.max_connections);
251
252 if (config.endpoint_url.has_value()) {
253 client_config.endpointOverride = config.endpoint_url.value();
254 // For S3-compatible services (MinIO), use path-style addressing
255 use_path_style_ = true;
256 }
257
258 Aws::Auth::AWSCredentials credentials(config.access_key_id,
259 config.secret_access_key);
260
261 Aws::S3::S3ClientConfiguration s3_config(client_config);
262 s3_config.useVirtualAddressing = !use_path_style_;
263 client_ = std::make_unique<Aws::S3::S3Client>(credentials, nullptr,
264 s3_config);
265 }
266
267 [[nodiscard]] auto put_object(const std::string &key,
268 const std::vector<std::uint8_t> &data)
269 -> VoidResult override {
270 Aws::S3::Model::PutObjectRequest request;
271 request.SetBucket(bucket_);
272 request.SetKey(key);
273 request.SetContentType("application/dicom");
274
275 auto stream = Aws::MakeShared<Aws::StringStream>("PutObjectStream");
276 stream->write(reinterpret_cast<const char *>(data.data()),
277 static_cast<std::streamsize>(data.size()));
278 request.SetBody(stream);
279
280 auto outcome = client_->PutObject(request);
281 if (!outcome.IsSuccess()) {
282 return make_error<std::monostate>(
283 kUploadError,
284 "S3 PutObject failed: " +
285 std::string(outcome.GetError().GetMessage()),
286 "s3_storage");
287 }
288
289 return ok();
290 }
291
292 [[nodiscard]] auto get_object(const std::string &key)
293 -> Result<std::vector<std::uint8_t>> override {
294 Aws::S3::Model::GetObjectRequest request;
295 request.SetBucket(bucket_);
296 request.SetKey(key);
297
298 auto outcome = client_->GetObject(request);
299 if (!outcome.IsSuccess()) {
300 const auto &error = outcome.GetError();
301 if (error.GetErrorType() ==
302 Aws::S3::S3Errors::NO_SUCH_KEY) {
303 return make_error<std::vector<std::uint8_t>>(
304 kObjectNotFound, "Object not found: " + key, "s3_storage");
305 }
306 return make_error<std::vector<std::uint8_t>>(
307 kDownloadError,
308 "S3 GetObject failed: " + std::string(error.GetMessage()),
309 "s3_storage");
310 }
311
312 auto &body = outcome.GetResult().GetBody();
313 std::vector<std::uint8_t> result(
314 (std::istreambuf_iterator<char>(body)),
315 std::istreambuf_iterator<char>());
316 return result;
317 }
318
319 [[nodiscard]] auto delete_object(const std::string &key)
320 -> VoidResult override {
321 Aws::S3::Model::DeleteObjectRequest request;
322 request.SetBucket(bucket_);
323 request.SetKey(key);
324
325 auto outcome = client_->DeleteObject(request);
326 if (!outcome.IsSuccess()) {
327 return make_error<std::monostate>(
328 kUploadError,
329 "S3 DeleteObject failed: " +
330 std::string(outcome.GetError().GetMessage()),
331 "s3_storage");
332 }
333
334 return ok();
335 }
336
337 [[nodiscard]] auto head_object(const std::string &key) const
338 -> bool override {
339 Aws::S3::Model::HeadObjectRequest request;
340 request.SetBucket(bucket_);
341 request.SetKey(key);
342
343 auto outcome = client_->HeadObject(request);
344 return outcome.IsSuccess();
345 }
346
347 [[nodiscard]] auto get_object_size(const std::string &key) const
348 -> std::size_t override {
349 Aws::S3::Model::HeadObjectRequest request;
350 request.SetBucket(bucket_);
351 request.SetKey(key);
352
353 auto outcome = client_->HeadObject(request);
354 if (outcome.IsSuccess()) {
355 return static_cast<std::size_t>(
356 outcome.GetResult().GetContentLength());
357 }
358 return 0;
359 }
360
361 [[nodiscard]] auto list_objects() const
362 -> std::vector<std::string> override {
363 std::vector<std::string> keys;
364
365 Aws::S3::Model::ListObjectsV2Request request;
366 request.SetBucket(bucket_);
367
368 bool has_more = true;
369 while (has_more) {
370 auto outcome = client_->ListObjectsV2(request);
371 if (!outcome.IsSuccess()) {
372 break;
373 }
374
375 const auto &result = outcome.GetResult();
376 for (const auto &object : result.GetContents()) {
377 keys.push_back(object.GetKey());
378 }
379
380 has_more = result.GetIsTruncated();
381 if (has_more) {
382 request.SetContinuationToken(result.GetNextContinuationToken());
383 }
384 }
385
386 return keys;
387 }
388
389 [[nodiscard]] auto is_connected() const -> bool override {
390 return client_ != nullptr;
391 }
392
393 [[nodiscard]] auto multipart_upload(const std::string &key,
394 const std::vector<std::uint8_t> &data,
395 std::size_t part_size,
396 progress_callback callback)
397 -> VoidResult override {
398 // Initiate multipart upload
399 Aws::S3::Model::CreateMultipartUploadRequest create_request;
400 create_request.SetBucket(bucket_);
401 create_request.SetKey(key);
402 create_request.SetContentType("application/dicom");
403
404 auto create_outcome = client_->CreateMultipartUpload(create_request);
405 if (!create_outcome.IsSuccess()) {
406 return make_error<std::monostate>(
407 kUploadError,
408 "Failed to initiate multipart upload: " +
409 std::string(create_outcome.GetError().GetMessage()),
410 "s3_storage");
411 }
412
413 auto upload_id = create_outcome.GetResult().GetUploadId();
414 Aws::S3::Model::CompletedMultipartUpload completed_upload;
415
416 std::size_t total_bytes = data.size();
417 std::size_t bytes_uploaded = 0;
418 int part_number = 1;
419
420 while (bytes_uploaded < total_bytes) {
421 std::size_t chunk =
422 (std::min)(part_size, total_bytes - bytes_uploaded);
423
424 auto stream = Aws::MakeShared<Aws::StringStream>("UploadPartStream");
425 stream->write(
426 reinterpret_cast<const char *>(data.data() + bytes_uploaded),
427 static_cast<std::streamsize>(chunk));
428
429 Aws::S3::Model::UploadPartRequest part_request;
430 part_request.SetBucket(bucket_);
431 part_request.SetKey(key);
432 part_request.SetUploadId(upload_id);
433 part_request.SetPartNumber(part_number);
434 part_request.SetBody(stream);
435 part_request.SetContentLength(static_cast<long long>(chunk));
436
437 auto part_outcome = client_->UploadPart(part_request);
438 if (!part_outcome.IsSuccess()) {
439 // Abort the multipart upload on failure
440 Aws::S3::Model::AbortMultipartUploadRequest abort_request;
441 abort_request.SetBucket(bucket_);
442 abort_request.SetKey(key);
443 abort_request.SetUploadId(upload_id);
444 client_->AbortMultipartUpload(abort_request);
445
446 return make_error<std::monostate>(
447 kUploadError,
448 "Failed to upload part " + std::to_string(part_number) + ": " +
449 std::string(part_outcome.GetError().GetMessage()),
450 "s3_storage");
451 }
452
453 Aws::S3::Model::CompletedPart completed_part;
454 completed_part.SetPartNumber(part_number);
455 completed_part.SetETag(part_outcome.GetResult().GetETag());
456 completed_upload.AddParts(std::move(completed_part));
457
458 bytes_uploaded += chunk;
459 part_number++;
460
461 if (callback && !callback(bytes_uploaded, total_bytes)) {
462 // Abort on user cancellation
463 Aws::S3::Model::AbortMultipartUploadRequest abort_request;
464 abort_request.SetBucket(bucket_);
465 abort_request.SetKey(key);
466 abort_request.SetUploadId(upload_id);
467 client_->AbortMultipartUpload(abort_request);
468
469 return make_error<std::monostate>(
470 kUploadError, "Upload cancelled by user", "s3_storage");
471 }
472 }
473
474 // Complete multipart upload
475 Aws::S3::Model::CompleteMultipartUploadRequest complete_request;
476 complete_request.SetBucket(bucket_);
477 complete_request.SetKey(key);
478 complete_request.SetUploadId(upload_id);
479 complete_request.SetMultipartUpload(completed_upload);
480
481 auto complete_outcome = client_->CompleteMultipartUpload(complete_request);
482 if (!complete_outcome.IsSuccess()) {
483 return make_error<std::monostate>(
484 kUploadError,
485 "Failed to complete multipart upload: " +
486 std::string(complete_outcome.GetError().GetMessage()),
487 "s3_storage");
488 }
489
490 return ok();
491 }
492
493private:
494 std::string bucket_;
495 std::unique_ptr<Aws::S3::S3Client> client_;
496 bool use_path_style_{false};
497};
498
499#endif // PACS_WITH_AWS_SDK && !PACS_USE_MOCK_S3
500
501// ============================================================================
502// Construction
503// ============================================================================
504
506 : config_(config),
507#if defined(PACS_WITH_AWS_SDK) && !defined(PACS_USE_MOCK_S3)
508 client_(std::make_unique<aws_s3_client>(config))
509#else
510 client_(std::make_unique<mock_s3_client>(config))
511#endif
512{
513}
514
515s3_storage::~s3_storage() = default;
516
517// ============================================================================
518// storage_interface Implementation
519// ============================================================================
520
521auto s3_storage::store(const core::dicom_dataset &dataset) -> VoidResult {
522 return store_with_progress(dataset, nullptr);
523}
524
526 progress_callback callback) -> VoidResult {
527 // Extract required UIDs
528 auto study_uid = dataset.get_string(core::tags::study_instance_uid);
529 auto series_uid = dataset.get_string(core::tags::series_instance_uid);
530 auto sop_uid = dataset.get_string(core::tags::sop_instance_uid);
531
532 if (study_uid.empty() || series_uid.empty() || sop_uid.empty()) {
533 return make_error<std::monostate>(
534 kMissingRequiredUid,
535 "Missing required UID (Study, Series, or SOP Instance UID)",
536 "s3_storage");
537 }
538
539 // Build S3 object key
540 auto object_key = build_object_key(study_uid, series_uid, sop_uid);
541
542 // Create DICOM file and serialize to bytes
543 auto dicom_file = core::dicom_file::create(
545
546 auto data = dicom_file.to_bytes();
547 if (data.empty()) {
548 return make_error<std::monostate>(
549 kSerializationError, "Failed to serialize DICOM dataset", "s3_storage");
550 }
551
552 // Report initial progress
553 if (callback && !callback(0, data.size())) {
554 return make_error<std::monostate>(kUploadError, "Upload cancelled by user",
555 "s3_storage");
556 }
557
558 // Upload to S3 (use multipart for large files)
559 VoidResult upload_result = ok();
560 if (data.size() > config_.multipart_threshold) {
561 upload_result =
562 client_->multipart_upload(object_key, data, config_.part_size, callback);
563 } else {
564 upload_result = client_->put_object(object_key, data);
565
566 // Report completion progress
567 if (callback) {
568 callback(data.size(), data.size());
569 }
570 }
571
572 if (upload_result.is_err()) {
573 return upload_result;
574 }
575
576 // Update local index
577 {
578 std::unique_lock lock(mutex_);
579 s3_object_info info;
580 info.key = object_key;
581 info.sop_instance_uid = sop_uid;
582 info.study_instance_uid = study_uid;
583 info.series_instance_uid = series_uid;
584 info.size_bytes = data.size();
585 index_[sop_uid] = std::move(info);
586 }
587
588 return ok();
589}
590
591auto s3_storage::retrieve(std::string_view sop_instance_uid)
593 return retrieve_with_progress(sop_instance_uid, nullptr);
594}
595
596auto s3_storage::retrieve_with_progress(std::string_view sop_instance_uid,
597 progress_callback callback)
599 std::string object_key;
600
601 {
602 std::shared_lock lock(mutex_);
603 auto it = index_.find(std::string{sop_instance_uid});
604 if (it == index_.end()) {
605 return make_error<core::dicom_dataset>(
606 kObjectNotFound,
607 "Instance not found: " + std::string{sop_instance_uid}, "s3_storage");
608 }
609 object_key = it->second.key;
610 }
611
612 // Download from S3
613 auto download_result = client_->get_object(object_key);
614 if (download_result.is_err()) {
615 return make_error<core::dicom_dataset>(
616 kDownloadError, "Failed to download from S3", "s3_storage");
617 }
618
619 const auto &data = download_result.value();
620
621 // Report progress (download complete)
622 if (callback) {
623 callback(data.size(), data.size());
624 }
625
626 // Deserialize DICOM data
627 auto parse_result = core::dicom_file::from_bytes(data);
628 if (parse_result.is_err()) {
629 return make_error<core::dicom_dataset>(
630 kSerializationError,
631 "Failed to parse DICOM data: " + parse_result.error().message,
632 "s3_storage");
633 }
634
635 return parse_result.value().dataset();
636}
637
638auto s3_storage::remove(std::string_view sop_instance_uid) -> VoidResult {
639 std::string object_key;
640
641 {
642 std::unique_lock lock(mutex_);
643 auto it = index_.find(std::string{sop_instance_uid});
644 if (it == index_.end()) {
645 // Not found is not an error for remove
646 return ok();
647 }
648 object_key = it->second.key;
649 index_.erase(it);
650 }
651
652 // Delete from S3
653 auto delete_result = client_->delete_object(object_key);
654 // Ignore delete errors - object might have been deleted externally
655
656 return ok();
657}
658
659auto s3_storage::exists(std::string_view sop_instance_uid) const -> bool {
660 std::shared_lock lock(mutex_);
661 return index_.contains(std::string{sop_instance_uid});
662}
663
666 std::vector<core::dicom_dataset> results;
667
668 std::vector<std::string> keys_to_retrieve;
669 {
670 std::shared_lock lock(mutex_);
671 keys_to_retrieve.reserve(index_.size());
672 for (const auto &[uid, info] : index_) {
673 keys_to_retrieve.push_back(info.key);
674 }
675 }
676
677 for (const auto &key : keys_to_retrieve) {
678 auto download_result = client_->get_object(key);
679 if (download_result.is_err()) {
680 continue; // Skip objects that can't be downloaded
681 }
682
683 auto parse_result = core::dicom_file::from_bytes(download_result.value());
684 if (parse_result.is_err()) {
685 continue; // Skip invalid DICOM files
686 }
687
688 const auto &dataset = parse_result.value().dataset();
689 if (matches_query(dataset, query)) {
690 results.push_back(dataset);
691 }
692 }
693
694 return results;
695}
696
698 storage_statistics stats;
699
700 std::set<std::string> studies;
701 std::set<std::string> series;
702 std::set<std::string> patients;
703
704 {
705 std::shared_lock lock(mutex_);
706 stats.total_instances = index_.size();
707
708 for (const auto &[uid, info] : index_) {
709 stats.total_bytes += info.size_bytes;
710
711 if (!info.study_instance_uid.empty()) {
712 studies.insert(info.study_instance_uid);
713 }
714 if (!info.series_instance_uid.empty()) {
715 series.insert(info.series_instance_uid);
716 }
717 }
718 }
719
720 stats.studies_count = studies.size();
721 stats.series_count = series.size();
722 // Note: patient_count requires downloading datasets to extract PatientID
723
724 return stats;
725}
726
727auto s3_storage::verify_integrity() -> VoidResult {
728 std::vector<std::pair<std::string, std::string>> entries;
729 {
730 std::shared_lock lock(mutex_);
731 entries.reserve(index_.size());
732 for (const auto &[uid, info] : index_) {
733 entries.emplace_back(uid, info.key);
734 }
735 }
736
737 std::vector<std::string> invalid_entries;
738
739 for (const auto &[uid, key] : entries) {
740 if (!client_->head_object(key)) {
741 invalid_entries.push_back(uid + " (object missing)");
742 }
743 }
744
745 if (!invalid_entries.empty()) {
746 std::string message = "Integrity check failed for " +
747 std::to_string(invalid_entries.size()) + " entries";
748 return make_error<std::monostate>(kIntegrityError, message, "s3_storage");
749 }
750
751 return ok();
752}
753
754// ============================================================================
755// S3-specific Operations
756// ============================================================================
757
758auto s3_storage::get_object_key(std::string_view sop_instance_uid) const
759 -> std::string {
760 std::shared_lock lock(mutex_);
761 auto it = index_.find(std::string{sop_instance_uid});
762 if (it != index_.end()) {
763 return it->second.key;
764 }
765 return {};
766}
767
768auto s3_storage::bucket_name() const -> const std::string & {
769 return config_.bucket_name;
770}
771
772auto s3_storage::rebuild_index() -> VoidResult {
773 std::unique_lock lock(mutex_);
774 index_.clear();
775
776 // List all objects from S3
777 auto keys = client_->list_objects();
778
779 for (const auto &key : keys) {
780 // Download and parse each object to rebuild index
781 auto download_result = client_->get_object(key);
782 if (download_result.is_err()) {
783 continue;
784 }
785
786 auto parse_result = core::dicom_file::from_bytes(download_result.value());
787 if (parse_result.is_err()) {
788 continue;
789 }
790
791 const auto &dataset = parse_result.value().dataset();
792 auto sop_uid = dataset.get_string(core::tags::sop_instance_uid);
793 auto study_uid = dataset.get_string(core::tags::study_instance_uid);
794 auto series_uid = dataset.get_string(core::tags::series_instance_uid);
795
796 if (!sop_uid.empty()) {
797 s3_object_info info;
798 info.key = key;
799 info.sop_instance_uid = sop_uid;
800 info.study_instance_uid = study_uid;
801 info.series_instance_uid = series_uid;
802 info.size_bytes = client_->get_object_size(key);
803 index_[sop_uid] = std::move(info);
804 }
805 }
806
807 return ok();
808}
809
810auto s3_storage::is_connected() const -> bool {
811 return client_ && client_->is_connected();
812}
813
814// ============================================================================
815// Internal Helper Methods
816// ============================================================================
817
818auto s3_storage::build_object_key(std::string_view study_uid,
819 std::string_view series_uid,
820 std::string_view sop_uid) const
821 -> std::string {
822 std::ostringstream oss;
823 oss << sanitize_uid(study_uid) << "/" << sanitize_uid(series_uid) << "/"
824 << sanitize_uid(sop_uid) << ".dcm";
825 return oss.str();
826}
827
828auto s3_storage::sanitize_uid(std::string_view uid) -> std::string {
829 std::string result;
830 result.reserve(uid.length());
831
832 for (char c : uid) {
833 // UIDs contain digits and dots, which are safe for S3 keys
834 // Replace any other characters with underscore
835 if (std::isalnum(static_cast<unsigned char>(c)) || c == '.') {
836 result += c;
837 } else {
838 result += '_';
839 }
840 }
841
842 return result;
843}
844
845auto s3_storage::upload_multipart(const std::string &key,
846 const std::vector<std::uint8_t> &data,
847 progress_callback callback) -> VoidResult {
848 return client_->multipart_upload(key, data, config_.part_size, callback);
849}
850
852 const core::dicom_dataset &query) -> bool {
853 // If query is empty, match all
854 if (query.empty()) {
855 return true;
856 }
857
858 // Check each query element
859 for (const auto &[tag, element] : query) {
860 auto query_value = element.as_string().unwrap_or("");
861 if (query_value.empty()) {
862 continue; // Empty value acts as wildcard
863 }
864
865 auto dataset_value = dataset.get_string(tag);
866
867 // Support basic wildcard matching (* and ?)
868 if (query_value.find('*') != std::string::npos ||
869 query_value.find('?') != std::string::npos) {
870 // Simple pattern matching
871 if (query_value.front() == '*' && query_value.back() == '*') {
872 // Contains
873 auto inner = query_value.substr(1, query_value.length() - 2);
874 if (dataset_value.find(inner) == std::string::npos) {
875 return false;
876 }
877 } else if (query_value.front() == '*') {
878 // Ends with
879 auto suffix = query_value.substr(1);
880 if (dataset_value.length() < suffix.length() ||
881 dataset_value.substr(dataset_value.length() - suffix.length()) !=
882 suffix) {
883 return false;
884 }
885 } else if (query_value.back() == '*') {
886 // Starts with
887 auto prefix = query_value.substr(0, query_value.length() - 1);
888 if (dataset_value.substr(0, prefix.length()) != prefix) {
889 return false;
890 }
891 }
892 } else {
893 // Exact match
894 if (dataset_value != query_value) {
895 return false;
896 }
897 }
898 }
899
900 return true;
901}
902
903} // namespace kcenon::pacs::storage
if(!color.empty()) style.color
static auto from_bytes(std::span< const uint8_t > data) -> kcenon::pacs::Result< dicom_file >
Parse a DICOM file from raw bytes.
static auto create(dicom_dataset dataset, const encoding::transfer_syntax &ts) -> dicom_file
Create a new DICOM file from a dataset.
static const transfer_syntax explicit_vr_little_endian
Explicit VR Little Endian (1.2.840.10008.1.2.1)
Mock S3 client for testing without AWS SDK dependency.
auto put_object(const std::string &key, const std::vector< std::uint8_t > &data) -> VoidResult override
auto head_object(const std::string &key) const -> bool override
auto list_objects() const -> std::vector< std::string > override
auto is_connected() const -> bool override
std::unordered_map< std::string, std::vector< std::uint8_t > > objects_
auto delete_object(const std::string &key) -> VoidResult override
auto multipart_upload(const std::string &key, const std::vector< std::uint8_t > &data, std::size_t part_size, progress_callback callback) -> VoidResult override
auto get_object(const std::string &key) -> Result< std::vector< std::uint8_t > > override
auto get_object_size(const std::string &key) const -> std::size_t override
mock_s3_client(const cloud_storage_config &)
Abstract interface for S3 client operations.
virtual auto delete_object(const std::string &key) -> VoidResult=0
virtual auto get_object_size(const std::string &key) const -> std::size_t=0
virtual auto head_object(const std::string &key) const -> bool=0
virtual auto put_object(const std::string &key, const std::vector< std::uint8_t > &data) -> VoidResult=0
virtual auto get_object(const std::string &key) -> Result< std::vector< std::uint8_t > >=0
virtual auto multipart_upload(const std::string &key, const std::vector< std::uint8_t > &data, std::size_t part_size, progress_callback callback) -> VoidResult=0
virtual auto list_objects() const -> std::vector< std::string >=0
auto build_object_key(std::string_view study_uid, std::string_view series_uid, std::string_view sop_uid) const -> std::string
Build S3 object key for a dataset.
auto rebuild_index() -> VoidResult
Rebuild the local index from S3.
auto get_object_key(std::string_view sop_instance_uid) const -> std::string
Get the S3 object key for a SOP Instance UID.
auto store(const core::dicom_dataset &dataset) -> VoidResult override
Store a DICOM dataset to S3.
static auto sanitize_uid(std::string_view uid) -> std::string
Sanitize UID for use in S3 object key.
cloud_storage_config config_
Storage configuration.
Definition s3_storage.h:389
auto retrieve(std::string_view sop_instance_uid) -> Result< core::dicom_dataset > override
Retrieve a DICOM dataset by SOP Instance UID.
auto remove(std::string_view sop_instance_uid) -> VoidResult override
Remove a DICOM object from S3.
auto get_statistics() const -> storage_statistics override
Get storage statistics.
std::unique_ptr< s3_client_interface > client_
S3 client (mock for testing, AWS SDK for production)
Definition s3_storage.h:392
auto verify_integrity() -> VoidResult override
Verify storage integrity.
std::shared_mutex mutex_
Mutex for thread-safe access.
Definition s3_storage.h:398
s3_storage(const cloud_storage_config &config)
Construct S3 storage with configuration.
auto is_connected() const -> bool
Check S3 connectivity.
auto exists(std::string_view sop_instance_uid) const -> bool override
Check if a DICOM instance exists in S3.
auto retrieve_with_progress(std::string_view sop_instance_uid, progress_callback callback) -> Result< core::dicom_dataset >
Retrieve with progress tracking.
auto store_with_progress(const core::dicom_dataset &dataset, progress_callback callback) -> VoidResult
Store with progress tracking.
auto find(const core::dicom_dataset &query) -> Result< std::vector< core::dicom_dataset > > override
Find DICOM datasets matching query criteria.
auto upload_multipart(const std::string &key, const std::vector< std::uint8_t > &data, progress_callback callback) -> VoidResult
Execute multipart upload for large files.
std::unordered_map< std::string, s3_object_info > index_
Mapping from SOP Instance UID to S3 object info.
Definition s3_storage.h:395
~s3_storage() override
Destructor.
static auto matches_query(const core::dicom_dataset &dataset, const core::dicom_dataset &query) -> bool
Check if dataset matches query criteria.
auto bucket_name() const -> const std::string &
Get the bucket name.
DICOM Part 10 file handling for reading/writing DICOM files.
Compile-time constants for commonly used DICOM tags.
@ error
Node returned an error.
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
constexpr dicom_tag study_instance_uid
Study Instance UID.
constexpr dicom_tag series_instance_uid
Series Instance UID.
std::function< bool(std::size_t bytes_transferred, std::size_t total_bytes)> progress_callback
Callback type for upload/download progress tracking.
Definition s3_storage.h:115
S3-compatible DICOM storage backend for cloud storage support.
Configuration for S3-compatible cloud storage.
Definition s3_storage.h:41
std::string bucket_name
S3 bucket name for storing DICOM files.
Definition s3_storage.h:43
Information about an S3 object.
Definition s3_storage.h:84
std::string_view uid