PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
storage_commitment_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
29
30#include <chrono>
31#include <random>
32#include <sstream>
33#include <string>
34
35namespace kcenon::pacs::web {
36
37namespace storage_commitment {
38
39// ============================================================================
40// transaction_state helpers
41// ============================================================================
42
43std::string_view to_string(transaction_state state) noexcept {
44 switch (state) {
45 case transaction_state::pending: return "PENDING";
46 case transaction_state::success: return "SUCCESS";
47 case transaction_state::partial: return "PARTIAL";
48 case transaction_state::failure: return "FAILURE";
49 }
50 return "UNKNOWN";
51}
52
53std::optional<transaction_state> parse_state(std::string_view str) noexcept {
54 if (str == "PENDING") return transaction_state::pending;
55 if (str == "SUCCESS") return transaction_state::success;
56 if (str == "PARTIAL") return transaction_state::partial;
57 if (str == "FAILURE") return transaction_state::failure;
58 return std::nullopt;
59}
60
61// ============================================================================
62// transaction_store
63// ============================================================================
64
66 std::lock_guard lock(mutex_);
67 auto uid = txn.transaction_uid;
68 transactions_.emplace(std::move(uid), std::move(txn));
69}
70
71std::optional<commitment_transaction> transaction_store::find(
72 std::string_view transaction_uid) const {
73 std::lock_guard lock(mutex_);
74 auto it = transactions_.find(transaction_uid);
75 if (it != transactions_.end()) {
76 return it->second;
77 }
78 return std::nullopt;
79}
80
82 std::lock_guard lock(mutex_);
83 auto it = transactions_.find(txn.transaction_uid);
84 if (it != transactions_.end()) {
85 it->second = txn;
86 return true;
87 }
88 return false;
89}
90
91std::vector<commitment_transaction> transaction_store::list(size_t limit) const {
92 std::lock_guard lock(mutex_);
93 std::vector<commitment_transaction> result;
94 result.reserve(limit == 0 ? transactions_.size()
95 : std::min(limit, transactions_.size()));
96
97 for (auto it = transactions_.rbegin(); it != transactions_.rend(); ++it) {
98 if (limit > 0 && result.size() >= limit) break;
99 result.push_back(it->second);
100 }
101 return result;
102}
103
105 std::lock_guard lock(mutex_);
106 return transactions_.size();
107}
108
109// ============================================================================
110// JSON serialization
111// ============================================================================
112
113namespace {
114
115std::string format_timestamp(std::chrono::system_clock::time_point tp) {
116 auto time_t = std::chrono::system_clock::to_time_t(tp);
117 std::tm tm{};
118#ifdef _WIN32
119 gmtime_s(&tm, &time_t);
120#else
121 gmtime_r(&time_t, &tm);
122#endif
123 char buf[32];
124 std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
125 return buf;
126}
127
128std::string sop_reference_to_json(const services::sop_reference& ref) {
129 return R"({"sopClassUID":")" + json_escape(ref.sop_class_uid) +
130 R"(","sopInstanceUID":")" + json_escape(ref.sop_instance_uid) + R"("})";
131}
132
133std::string failed_reference_to_json(
134 const std::pair<services::sop_reference,
136 return R"({"sopClassUID":")" + json_escape(entry.first.sop_class_uid) +
137 R"(","sopInstanceUID":")" + json_escape(entry.first.sop_instance_uid) +
138 R"(","failureReason":")" +
139 std::string(services::to_string(entry.second)) + R"("})";
140}
141
142} // namespace
143
145 std::ostringstream ss;
146 ss << R"({"transactionUID":")" << json_escape(txn.transaction_uid) << R"(",)";
147 ss << R"("state":")" << to_string(txn.state) << R"(",)";
148
149 if (!txn.study_instance_uid.empty()) {
150 ss << R"("studyInstanceUID":")" << json_escape(txn.study_instance_uid) << R"(",)";
151 }
152
153 ss << R"("createdAt":")" << format_timestamp(txn.created_at) << R"(",)";
154
155 if (txn.completed_at.has_value()) {
156 ss << R"("completedAt":")" << format_timestamp(*txn.completed_at) << R"(",)";
157 }
158
159 // Requested references
160 ss << R"("requestedReferences":[)";
161 for (size_t i = 0; i < txn.requested_references.size(); ++i) {
162 if (i > 0) ss << ",";
163 ss << sop_reference_to_json(txn.requested_references[i]);
164 }
165 ss << "],";
166
167 // Success references
168 ss << R"("successReferences":[)";
169 for (size_t i = 0; i < txn.success_references.size(); ++i) {
170 if (i > 0) ss << ",";
171 ss << sop_reference_to_json(txn.success_references[i]);
172 }
173 ss << "],";
174
175 // Failed references
176 ss << R"("failedReferences":[)";
177 for (size_t i = 0; i < txn.failed_references.size(); ++i) {
178 if (i > 0) ss << ",";
179 ss << failed_reference_to_json(txn.failed_references[i]);
180 }
181 ss << "]}";
182
183 return ss.str();
184}
185
187 const std::vector<commitment_transaction>& transactions) {
188 std::ostringstream ss;
189 ss << "[";
190 for (size_t i = 0; i < transactions.size(); ++i) {
191 if (i > 0) ss << ",";
192 ss << transaction_to_json(transactions[i]);
193 }
194 ss << "]";
195 return ss.str();
196}
197
198// ============================================================================
199// Request parsing
200// ============================================================================
201
202namespace {
203
204// Simple JSON string extraction (finds "key":"value" pairs)
205std::string extract_json_string(std::string_view json, std::string_view key) {
206 auto key_pattern = std::string("\"") + std::string(key) + "\"";
207 auto pos = json.find(key_pattern);
208 if (pos == std::string_view::npos) return {};
209
210 pos = json.find('"', pos + key_pattern.size());
211 if (pos == std::string_view::npos) return {};
212 pos++; // skip opening quote
213
214 auto end = json.find('"', pos);
215 if (end == std::string_view::npos) return {};
216
217 return std::string(json.substr(pos, end - pos));
218}
219
220} // namespace
221
223 std::string_view json_body,
224 std::string_view study_uid) {
225
226 parse_result result;
227
228 if (json_body.empty()) {
229 result.error_message = "Request body is empty";
230 return result;
231 }
232
233 // Look for referencedSOPSequence array
234 auto seq_pos = json_body.find("\"referencedSOPSequence\"");
235 if (seq_pos == std::string_view::npos) {
236 result.error_message = "Missing referencedSOPSequence in request body";
237 return result;
238 }
239
240 // Find the array start
241 auto arr_start = json_body.find('[', seq_pos);
242 if (arr_start == std::string_view::npos) {
243 result.error_message = "Invalid referencedSOPSequence format";
244 return result;
245 }
246
247 // Find matching array end
248 size_t depth = 0;
249 size_t arr_end = std::string_view::npos;
250 for (size_t i = arr_start; i < json_body.size(); ++i) {
251 if (json_body[i] == '[') ++depth;
252 else if (json_body[i] == ']') {
253 --depth;
254 if (depth == 0) {
255 arr_end = i;
256 break;
257 }
258 }
259 }
260
261 if (arr_end == std::string_view::npos) {
262 result.error_message = "Unterminated referencedSOPSequence array";
263 return result;
264 }
265
266 // Parse individual items in the array
267 auto array_content = json_body.substr(arr_start + 1, arr_end - arr_start - 1);
268
269 // Find each object in the array
270 size_t pos = 0;
271 while (pos < array_content.size()) {
272 auto obj_start = array_content.find('{', pos);
273 if (obj_start == std::string_view::npos) break;
274
275 size_t obj_depth = 0;
276 size_t obj_end = std::string_view::npos;
277 for (size_t i = obj_start; i < array_content.size(); ++i) {
278 if (array_content[i] == '{') ++obj_depth;
279 else if (array_content[i] == '}') {
280 --obj_depth;
281 if (obj_depth == 0) {
282 obj_end = i;
283 break;
284 }
285 }
286 }
287
288 if (obj_end == std::string_view::npos) break;
289
290 auto obj = array_content.substr(obj_start, obj_end - obj_start + 1);
291 auto sop_class = extract_json_string(obj, "sopClassUID");
292 auto sop_instance = extract_json_string(obj, "sopInstanceUID");
293
294 if (sop_instance.empty()) {
295 result.error_message = "Missing sopInstanceUID in reference";
296 return result;
297 }
298
299 result.references.push_back({std::move(sop_class), std::move(sop_instance)});
300 pos = obj_end + 1;
301 }
302
303 if (result.references.empty()) {
304 result.error_message = "No valid SOP references found in request";
305 return result;
306 }
307
308 // Ignore study_uid parameter (used for context only)
309 (void)study_uid;
310
311 result.valid = true;
312 return result;
313}
314
315} // namespace storage_commitment
316
317// ============================================================================
318// Endpoint Registration
319// ============================================================================
320
321namespace endpoints {
322
323namespace {
324
325void add_cors_headers(crow::response& res, const rest_server_context& ctx) {
326 if (ctx.config != nullptr && !ctx.config->cors_allowed_origins.empty()) {
327 res.add_header("Access-Control-Allow-Origin",
328 ctx.config->cors_allowed_origins);
329 }
330}
331
332bool check_storage_commitment_auth(
333 const std::shared_ptr<rest_server_context>& ctx,
334 const crow::request& req,
335 crow::response& res,
336 const std::vector<std::string>& required_scopes) {
337 if (ctx->oauth2 && ctx->oauth2->enabled()) {
338 auto auth = ctx->oauth2->authenticate(req, res);
339 if (!auth) return false;
340
341 if (!required_scopes.empty()) {
342 return ctx->oauth2->require_any_scope(
343 auth->claims, res, required_scopes);
344 }
345 return true;
346 }
347 return true;
348}
349
350std::string generate_transaction_uid() {
351 // Generate a unique transaction UID using timestamp + random
352 auto now = std::chrono::system_clock::now();
353 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
354 now.time_since_epoch())
355 .count();
356
357 static thread_local std::mt19937 gen(std::random_device{}());
358 std::uniform_int_distribution<uint32_t> dist(1, 999999);
359
360 std::ostringstream ss;
361 ss << "2.25." << ms << "." << dist(gen);
362 return ss.str();
363}
364
365void verify_instances_async(
366 std::shared_ptr<rest_server_context> ctx,
367 std::shared_ptr<storage_commitment::transaction_store> store,
368 std::string transaction_uid) {
369
370 auto txn_opt = store->find(transaction_uid);
371 if (!txn_opt) return;
372
373 auto txn = *txn_opt;
374
375 for (const auto& ref : txn.requested_references) {
376 if (!ctx->database) {
377 txn.failed_references.emplace_back(
379 continue;
380 }
381
382 auto instance = ctx->database->find_instance(ref.sop_instance_uid);
383 if (instance.has_value()) {
384 txn.success_references.push_back(ref);
385 } else {
386 txn.failed_references.emplace_back(
388 }
389 }
390
391 txn.completed_at = std::chrono::system_clock::now();
392
393 if (txn.failed_references.empty()) {
395 } else if (txn.success_references.empty()) {
397 } else {
399 }
400
401 store->update(txn);
402}
403
404} // namespace
405
407 crow::SimpleApp& app,
408 std::shared_ptr<rest_server_context> ctx) {
409
410 // Shared transaction store for all requests
411 auto store = std::make_shared<storage_commitment::transaction_store>();
412
413 // POST /dicomweb/storage-commitments
414 // Request storage commitment for a list of SOP Instances
415 CROW_ROUTE(app, "/dicomweb/storage-commitments")
416 .methods(crow::HTTPMethod::POST)(
417 [ctx, store](const crow::request& req) {
418 crow::response res;
419 res.add_header("Content-Type", "application/dicom+json");
420 add_cors_headers(res, *ctx);
421
422 if (!check_storage_commitment_auth(
423 ctx, req, res, {"dicomweb.write"})) {
424 return res;
425 }
426
427 if (!ctx->database) {
428 res.code = 503;
429 res.body = make_error_json(
430 "DATABASE_UNAVAILABLE",
431 "Storage service is not available");
432 return res;
433 }
434
436 req.body);
437
438 if (!parsed.valid) {
439 res.code = 400;
440 res.body = make_error_json(
441 "INVALID_REQUEST", parsed.error_message);
442 return res;
443 }
444
445 // Create transaction
447 txn.transaction_uid = generate_transaction_uid();
449 txn.requested_references = std::move(parsed.references);
450 txn.created_at = std::chrono::system_clock::now();
451
452 store->add(txn);
453
454 // Verify instances synchronously for simplicity
455 // (async with thread pool in production)
456 verify_instances_async(ctx, store, txn.transaction_uid);
457
458 auto result = store->find(txn.transaction_uid);
459 res.code = 202; // Accepted
460 res.add_header("Content-Location",
461 "/dicomweb/storage-commitments/" +
462 txn.transaction_uid);
463 res.body = storage_commitment::transaction_to_json(*result);
464 return res;
465 });
466
467 // POST /dicomweb/studies/{studyUID}/commitment
468 // Request storage commitment for a study
469 CROW_ROUTE(app, "/dicomweb/studies/<string>/commitment")
470 .methods(crow::HTTPMethod::POST)(
471 [ctx, store](const crow::request& req,
472 const std::string& study_uid) {
473 crow::response res;
474 res.add_header("Content-Type", "application/dicom+json");
475 add_cors_headers(res, *ctx);
476
477 if (!check_storage_commitment_auth(
478 ctx, req, res, {"dicomweb.write"})) {
479 return res;
480 }
481
482 if (!ctx->database) {
483 res.code = 503;
484 res.body = make_error_json(
485 "DATABASE_UNAVAILABLE",
486 "Storage service is not available");
487 return res;
488 }
489
491 req.body, study_uid);
492
493 if (!parsed.valid) {
494 res.code = 400;
495 res.body = make_error_json(
496 "INVALID_REQUEST", parsed.error_message);
497 return res;
498 }
499
501 txn.transaction_uid = generate_transaction_uid();
503 txn.requested_references = std::move(parsed.references);
504 txn.study_instance_uid = study_uid;
505 txn.created_at = std::chrono::system_clock::now();
506
507 store->add(txn);
508 verify_instances_async(ctx, store, txn.transaction_uid);
509
510 auto result = store->find(txn.transaction_uid);
511 res.code = 202;
512 res.add_header("Content-Location",
513 "/dicomweb/storage-commitments/" +
514 txn.transaction_uid);
515 res.body = storage_commitment::transaction_to_json(*result);
516 return res;
517 });
518
519 // GET /dicomweb/storage-commitments/{transactionUID}
520 // Check commitment status
521 CROW_ROUTE(app, "/dicomweb/storage-commitments/<string>")
522 .methods(crow::HTTPMethod::GET)(
523 [ctx, store](const crow::request& req,
524 const std::string& transaction_uid) {
525 crow::response res;
526 res.add_header("Content-Type", "application/dicom+json");
527 add_cors_headers(res, *ctx);
528
529 if (!check_storage_commitment_auth(
530 ctx, req, res, {"dicomweb.read"})) {
531 return res;
532 }
533
534 auto txn = store->find(transaction_uid);
535 if (!txn) {
536 res.code = 404;
537 res.body = make_error_json(
538 "NOT_FOUND",
539 "Transaction not found: " + transaction_uid);
540 return res;
541 }
542
543 res.code = 200;
545 return res;
546 });
547
548 // GET /dicomweb/storage-commitments
549 // List all commitment transactions
550 CROW_ROUTE(app, "/dicomweb/storage-commitments")
551 .methods(crow::HTTPMethod::GET)(
552 [ctx, store](const crow::request& req) {
553 crow::response res;
554 res.add_header("Content-Type", "application/dicom+json");
555 add_cors_headers(res, *ctx);
556
557 if (!check_storage_commitment_auth(
558 ctx, req, res, {"dicomweb.read"})) {
559 return res;
560 }
561
562 size_t limit = 100;
563 auto limit_param = req.url_params.get("limit");
564 if (limit_param != nullptr) {
565 try {
566 int val = std::stoi(limit_param);
567 if (val > 0) {
568 limit = static_cast<size_t>(val);
569 }
570 } catch (...) {
571 // Use default
572 }
573 }
574
575 auto transactions = store->list(limit);
576 res.code = 200;
577 res.body = storage_commitment::transactions_to_json(transactions);
578 return res;
579 });
580}
581
582} // namespace endpoints
583
584} // namespace kcenon::pacs::web
std::map< std::string, commitment_transaction, std::less<> > transactions_
std::optional< commitment_transaction > find(std::string_view transaction_uid) const
Find a transaction by its UID.
void add(commitment_transaction txn)
Add a new transaction.
bool update(const commitment_transaction &txn)
Update an existing transaction.
size_t size() const
Get the number of stored transactions.
std::vector< commitment_transaction > list(size_t limit=0) const
List all transactions (newest first)
PACS index database for metadata storage and retrieval.
@ store
C-STORE operation.
commitment_failure_reason
Failure reason codes for Storage Commitment.
@ no_such_object_instance
Referenced SOP Instance not found in storage.
@ processing_failure
General processing failure.
auto to_string(mpps_status status) -> std::string_view
Convert mpps_status to DICOM string representation.
Definition mpps_scp.h:60
@ storage_commitment
Storage Commitment Push Model Service Class.
void register_storage_commitment_endpoints_impl(crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
transaction_state
Transaction state for DICOMweb Storage Commitment.
@ partial
Some instances verified, others failed.
@ success
All referenced instances verified present and intact.
@ pending
Commitment request received, verification in progress.
parse_result parse_commitment_request(std::string_view json_body, std::string_view study_uid="")
std::string transactions_to_json(const std::vector< commitment_transaction > &transactions)
Serialize a list of transactions to DICOM JSON array.
std::optional< transaction_state > parse_state(std::string_view str) noexcept
Parse transaction state from string.
std::string_view to_string(transaction_state state) noexcept
Convert transaction state to string.
std::string transaction_to_json(const commitment_transaction &txn)
Serialize a commitment transaction to DICOM JSON.
std::string make_error_json(std::string_view code, std::string_view message)
Create JSON error response body with details.
Definition rest_types.h:79
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.
Configuration for REST API server.
Common types and utilities for REST API.
DICOMweb Storage Commitment REST endpoints (Supplement 234)
Reference to a SOP Instance in a commitment request.
std::string sop_class_uid
Referenced SOP Class UID (0008,1150)
std::string sop_instance_uid
Referenced SOP Instance UID (0008,1155)
std::vector< std::pair< services::sop_reference, services::commitment_failure_reason > > failed_references
Failed references with reasons (populated when verified)
std::string transaction_uid
Unique transaction identifier (DICOM UID format)
std::vector< services::sop_reference > success_references
Successfully committed references (populated when verified)
std::optional< std::chrono::system_clock::time_point > completed_at
Timestamp when verification completed.
std::string study_instance_uid
Study Instance UID (optional, for study-level commitment)
std::vector< services::sop_reference > requested_references
SOP Instance references requested for commitment.
std::chrono::system_clock::time_point created_at
Timestamp when the commitment was requested.
System API endpoints for REST server.
std::string_view uid