PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
kcenon::pacs::web::endpoints Namespace Reference

Functions

void register_security_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 Register security endpoints with the Crow app.
 
void register_annotation_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_association_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_audit_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_dicomweb_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_jobs_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_key_image_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_measurement_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_metadata_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_patient_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_remote_nodes_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_routing_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_series_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_storage_commitment_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_study_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_system_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_thumbnail_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_viewer_state_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_wado_uri_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 
void register_worklist_endpoints_impl (crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
 

Function Documentation

◆ register_annotation_endpoints_impl()

void kcenon::pacs::web::endpoints::register_annotation_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 403 of file annotation_endpoints.cpp.

404 {
405 // POST /api/v1/annotations - Create annotation
406 CROW_ROUTE(app, "/api/v1/annotations")
407 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req) {
408 crow::response res;
409 res.add_header("Content-Type", "application/json");
410 add_cors_headers(res, *ctx);
411
412 if (!ctx->database) {
413 res.code = 503;
414 res.body =
415 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
416 return res;
417 }
418
419 std::string body = req.body;
420 if (body.empty()) {
421 res.code = 400;
422 res.body = make_error_json("INVALID_REQUEST", "Request body is empty");
423 return res;
424 }
425
427 ann.annotation_id = generate_uuid();
428 ann.study_uid = parse_json_string(body, "study_uid");
429 ann.series_uid = parse_json_string(body, "series_uid");
430 ann.sop_instance_uid = parse_json_string(body, "sop_instance_uid");
431 ann.frame_number = parse_json_int(body, "frame_number");
432 ann.user_id = parse_json_string(body, "user_id");
433
434 auto type_str = parse_json_string(body, "annotation_type");
435 auto type_opt = storage::annotation_type_from_string(type_str);
436 if (!type_opt.has_value()) {
437 res.code = 400;
438 res.body = make_error_json("INVALID_TYPE", "Invalid annotation type");
439 return res;
440 }
441 ann.type = type_opt.value();
442
443 ann.geometry_json = parse_json_object(body, "geometry");
444 ann.text = parse_json_string(body, "text");
445 ann.style = parse_style(parse_json_object(body, "style"));
446 ann.created_at = std::chrono::system_clock::now();
447 ann.updated_at = ann.created_at;
448
449 if (ann.study_uid.empty()) {
450 res.code = 400;
451 res.body = make_error_json("MISSING_FIELD", "study_uid is required");
452 return res;
453 }
454
455 auto repo = make_annotation_repo(ctx->database.get());
456 auto save_result = repo.save(ann);
457 if (!save_result.is_ok()) {
458 res.code = 500;
459 res.body =
460 make_error_json("SAVE_ERROR", save_result.error().message);
461 return res;
462 }
463
464 res.code = 201;
465 std::ostringstream oss;
466 oss << R"({"annotation_id":")" << json_escape(ann.annotation_id)
467 << R"(","created_at":")" << format_timestamp(ann.created_at)
468 << R"("})";
469 res.body = oss.str();
470 return res;
471 });
472
473 // GET /api/v1/annotations - List annotations
474 CROW_ROUTE(app, "/api/v1/annotations")
475 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
476 crow::response res;
477 res.add_header("Content-Type", "application/json");
478 add_cors_headers(res, *ctx);
479
480 if (!ctx->database) {
481 res.code = 503;
482 res.body =
483 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
484 return res;
485 }
486
487 auto [limit, offset] = parse_pagination(req);
488
489 storage::annotation_query query;
490 query.limit = limit;
491 query.offset = offset;
492
493 auto study_uid = req.url_params.get("study_uid");
494 if (study_uid) {
495 query.study_uid = study_uid;
496 }
497 auto series_uid = req.url_params.get("series_uid");
498 if (series_uid) {
499 query.series_uid = series_uid;
500 }
501 auto sop_instance_uid = req.url_params.get("sop_instance_uid");
502 if (sop_instance_uid) {
503 query.sop_instance_uid = sop_instance_uid;
504 }
505 auto user_id = req.url_params.get("user_id");
506 if (user_id) {
507 query.user_id = user_id;
508 }
509
510 auto repo = make_annotation_repo(ctx->database.get());
511
512 storage::annotation_query count_query = query;
513 count_query.limit = 0;
514 count_query.offset = 0;
515 size_t total_count = count_annotations(repo, count_query);
516
517 auto annotations = search_annotations(repo, query);
518
519 res.code = 200;
520 res.body = annotations_to_json(annotations, total_count);
521 return res;
522 });
523
524 // GET /api/v1/annotations/<annotationId> - Get annotation by ID
525 CROW_ROUTE(app, "/api/v1/annotations/<string>")
526 .methods(crow::HTTPMethod::GET)(
527 [ctx](const crow::request & /*req*/, const std::string &annotation_id) {
528 crow::response res;
529 res.add_header("Content-Type", "application/json");
530 add_cors_headers(res, *ctx);
531
532 if (!ctx->database) {
533 res.code = 503;
534 res.body = make_error_json("DATABASE_UNAVAILABLE",
535 "Database not configured");
536 return res;
537 }
538
539 auto repo = make_annotation_repo(ctx->database.get());
540 auto ann = find_annotation(repo, annotation_id);
541 if (!ann.has_value()) {
542 res.code = 404;
543 res.body = make_error_json("NOT_FOUND", "Annotation not found");
544 return res;
545 }
546
547 res.code = 200;
548 res.body = annotation_to_json(ann.value());
549 return res;
550 });
551
552 // PUT /api/v1/annotations/<annotationId> - Update annotation
553 CROW_ROUTE(app, "/api/v1/annotations/<string>")
554 .methods(crow::HTTPMethod::PUT)(
555 [ctx](const crow::request &req, const std::string &annotation_id) {
556 crow::response res;
557 res.add_header("Content-Type", "application/json");
558 add_cors_headers(res, *ctx);
559
560 if (!ctx->database) {
561 res.code = 503;
562 res.body = make_error_json("DATABASE_UNAVAILABLE",
563 "Database not configured");
564 return res;
565 }
566
567 auto repo = make_annotation_repo(ctx->database.get());
568 auto existing = find_annotation(repo, annotation_id);
569 if (!existing.has_value()) {
570 res.code = 404;
571 res.body = make_error_json("NOT_FOUND", "Annotation not found");
572 return res;
573 }
574
575 std::string body = req.body;
576 if (body.empty()) {
577 res.code = 400;
578 res.body =
579 make_error_json("INVALID_REQUEST", "Request body is empty");
580 return res;
581 }
582
583 storage::annotation_record ann = existing.value();
584 auto geometry = parse_json_object(body, "geometry");
585 if (geometry != "{}") {
586 ann.geometry_json = geometry;
587 }
588 auto text = parse_json_string(body, "text");
589 if (!text.empty() || body.find("\"text\":\"\"") != std::string::npos) {
590 ann.text = text;
591 }
592 auto style_json = parse_json_object(body, "style");
593 if (style_json != "{}") {
594 ann.style = parse_style(style_json);
595 }
596 ann.updated_at = std::chrono::system_clock::now();
597
598 auto update_result = repo.update(ann);
599 if (!update_result.is_ok()) {
600 res.code = 500;
601 res.body =
602 make_error_json("UPDATE_ERROR", update_result.error().message);
603 return res;
604 }
605
606 res.code = 200;
607 std::ostringstream oss;
608 oss << R"({"annotation_id":")" << json_escape(ann.annotation_id)
609 << R"(","updated_at":")" << format_timestamp(ann.updated_at)
610 << R"("})";
611 res.body = oss.str();
612 return res;
613 });
614
615 // DELETE /api/v1/annotations/<annotationId> - Delete annotation
616 CROW_ROUTE(app, "/api/v1/annotations/<string>")
617 .methods(crow::HTTPMethod::DELETE)(
618 [ctx](const crow::request & /*req*/, const std::string &annotation_id) {
619 crow::response res;
620 add_cors_headers(res, *ctx);
621
622 if (!ctx->database) {
623 res.code = 503;
624 res.add_header("Content-Type", "application/json");
625 res.body = make_error_json("DATABASE_UNAVAILABLE",
626 "Database not configured");
627 return res;
628 }
629
630 auto repo = make_annotation_repo(ctx->database.get());
631 if (!annotation_exists(repo, annotation_id)) {
632 res.code = 404;
633 res.add_header("Content-Type", "application/json");
634 res.body = make_error_json("NOT_FOUND", "Annotation not found");
635 return res;
636 }
637
638 auto remove_result = repo.remove(annotation_id);
639 if (!remove_result.is_ok()) {
640 res.code = 500;
641 res.add_header("Content-Type", "application/json");
642 res.body =
643 make_error_json("DELETE_ERROR", remove_result.error().message);
644 return res;
645 }
646
647 res.code = 204;
648 return res;
649 });
650
651 // GET /api/v1/instances/<sopInstanceUid>/annotations - Get annotations for instance
652 CROW_ROUTE(app, "/api/v1/instances/<string>/annotations")
653 .methods(crow::HTTPMethod::GET)(
654 [ctx](const crow::request & /*req*/,
655 const std::string &sop_instance_uid) {
656 crow::response res;
657 res.add_header("Content-Type", "application/json");
658 add_cors_headers(res, *ctx);
659
660 if (!ctx->database) {
661 res.code = 503;
662 res.body = make_error_json("DATABASE_UNAVAILABLE",
663 "Database not configured");
664 return res;
665 }
666
667 auto repo = make_annotation_repo(ctx->database.get());
668 auto annotations = find_by_instance(repo, sop_instance_uid);
669
670 std::ostringstream oss;
671 oss << R"({"data":[)";
672 for (size_t i = 0; i < annotations.size(); ++i) {
673 if (i > 0) {
674 oss << ",";
675 }
676 oss << annotation_to_json(annotations[i]);
677 }
678 oss << "]}";
679
680 res.code = 200;
681 res.body = oss.str();
682 return res;
683 });
684}
constexpr dicom_tag sop_instance_uid
SOP Instance UID.
const atna_coded_value query
Query (110112)
Annotation record from the database.
std::string annotation_id
Unique annotation identifier (UUID)
annotation_type type
Type of annotation.
std::optional< int > frame_number
Frame number for multi-frame images (1-based)
std::string text
Text content for text annotations or labels.
std::string series_uid
Series Instance UID - DICOM tag (0020,000E)
std::string study_uid
Study Instance UID - DICOM tag (0020,000D)
annotation_style style
Visual style of the annotation.
std::chrono::system_clock::time_point created_at
Record creation timestamp.
std::chrono::system_clock::time_point updated_at
Record last update timestamp.
std::string sop_instance_uid
SOP Instance UID - DICOM tag (0008,0018)
std::string geometry_json
Geometry data as JSON string (type-specific coordinates)
std::string user_id
User who created the annotation.

References kcenon::pacs::storage::annotation_record::annotation_id, kcenon::pacs::storage::annotation_record::created_at, kcenon::pacs::storage::annotation_record::frame_number, kcenon::pacs::storage::annotation_record::geometry_json, kcenon::pacs::storage::annotation_query::limit, kcenon::pacs::storage::annotation_query::offset, register_annotation_endpoints_impl(), kcenon::pacs::storage::annotation_record::series_uid, kcenon::pacs::storage::annotation_record::sop_instance_uid, kcenon::pacs::storage::annotation_record::study_uid, kcenon::pacs::storage::annotation_record::style, kcenon::pacs::storage::annotation_record::text, kcenon::pacs::storage::annotation_record::type, kcenon::pacs::storage::annotation_record::updated_at, and kcenon::pacs::storage::annotation_record::user_id.

Referenced by register_annotation_endpoints_impl(), and kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_association_endpoints_impl()

void kcenon::pacs::web::endpoints::register_association_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 49 of file association_endpoints.cpp.

51 {
52 // GET /api/v1/associations/active - List active DICOM associations
53 CROW_ROUTE(app, "/api/v1/associations/active")
54 .methods(crow::HTTPMethod::GET)([ctx](const crow::request & /*req*/) {
55 crow::response res;
56 res.add_header("Content-Type", "application/json");
57 add_cors_headers(res, *ctx);
58
59 if (!ctx->dicom_server) {
60 res.code = 503;
61 res.body = make_error_json("SERVICE_UNAVAILABLE",
62 "DICOM server not configured");
63 return res;
64 }
65
66 auto stats = ctx->dicom_server->get_statistics();
67 auto active_count = ctx->dicom_server->active_associations();
68 auto uptime_sec = stats.uptime().count();
69
70 std::ostringstream oss;
71 oss << R"({"active_count":)" << active_count
72 << R"(,"total_associations":)" << stats.total_associations
73 << R"(,"rejected_associations":)" << stats.rejected_associations
74 << R"(,"messages_processed":)" << stats.messages_processed
75 << R"(,"bytes_received":)" << stats.bytes_received
76 << R"(,"bytes_sent":)" << stats.bytes_sent
77 << R"(,"uptime_seconds":)" << uptime_sec
78 << R"(,"server_running":)"
79 << (ctx->dicom_server->is_running() ? "true" : "false")
80 << '}';
81
82 res.code = 200;
83 res.body = oss.str();
84 return res;
85 });
86
87 // DELETE /api/v1/associations/:id - Terminate a DICOM association
88 CROW_ROUTE(app, "/api/v1/associations/<string>")
89 .methods(crow::HTTPMethod::DELETE)(
90 [ctx](const crow::request & /*req*/,
91 const std::string &association_id) {
92 crow::response res;
93 res.add_header("Content-Type", "application/json");
94 add_cors_headers(res, *ctx);
95
96 if (association_id.empty()) {
97 res.code = 400;
98 res.body = make_error_json("INVALID_REQUEST",
99 "Association ID is required");
100 return res;
101 }
102
103 if (!ctx->dicom_server) {
104 res.code = 503;
105 res.body = make_error_json("SERVICE_UNAVAILABLE",
106 "DICOM server not configured");
107 return res;
108 }
109
110 // Individual association termination is not supported via the
111 // dicom_server public API. The server manages association
112 // lifecycles internally through idle timeouts and graceful
113 // shutdown.
114 res.code = 501;
115 res.body = make_error_json(
116 "NOT_IMPLEMENTED",
117 "Individual association termination is not supported. "
118 "Associations are managed by the DICOM server via idle "
119 "timeouts and graceful shutdown.");
120 return res;
121 });
122
123 // GET /api/v1/associations/:id - Get specific association details
124 CROW_ROUTE(app, "/api/v1/associations/<string>")
125 .methods(crow::HTTPMethod::GET)(
126 [ctx](const crow::request & /*req*/,
127 const std::string &association_id) {
128 crow::response res;
129 res.add_header("Content-Type", "application/json");
130 add_cors_headers(res, *ctx);
131
132 if (association_id.empty()) {
133 res.code = 400;
134 res.body = make_error_json("INVALID_REQUEST",
135 "Association ID is required");
136 return res;
137 }
138
139 if (!ctx->dicom_server) {
140 res.code = 503;
141 res.body = make_error_json("SERVICE_UNAVAILABLE",
142 "DICOM server not configured");
143 return res;
144 }
145
146 // Individual association lookup is not supported via the
147 // dicom_server public API. Use GET /associations/active for
148 // aggregate statistics.
149 res.code = 501;
150 res.body = make_error_json(
151 "NOT_IMPLEMENTED",
152 "Individual association lookup is not supported. "
153 "Use GET /api/v1/associations/active for aggregate "
154 "statistics.");
155 return res;
156 });
157}

References kcenon::pacs::web::make_error_json().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_audit_endpoints_impl()

void kcenon::pacs::web::endpoints::register_audit_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 159 of file audit_endpoints.cpp.

161 {
162 // GET /api/v1/audit/logs - List audit log entries (paginated)
163 CROW_ROUTE(app, "/api/v1/audit/logs")
164 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
165 crow::response res;
166 res.add_header("Content-Type", "application/json");
167 add_cors_headers(res, *ctx);
168
169 if (!ctx->database) {
170 res.code = 503;
171 res.body =
172 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
173 return res;
174 }
175
176 // Parse pagination
177 auto [limit, offset] = parse_pagination(req);
178
179 // Build query from URL parameters
181 query.limit = limit;
182 query.offset = offset;
183
184 auto event_type = req.url_params.get("event_type");
185 if (event_type) {
186 query.event_type = event_type;
187 }
188
189 auto outcome = req.url_params.get("outcome");
190 if (outcome) {
191 query.outcome = outcome;
192 }
193
194 auto user_id = req.url_params.get("user_id");
195 if (user_id) {
196 query.user_id = user_id;
197 }
198
199 auto source_ae = req.url_params.get("source_ae");
200 if (source_ae) {
201 query.source_ae = source_ae;
202 }
203
204 auto patient_id = req.url_params.get("patient_id");
205 if (patient_id) {
206 query.patient_id = patient_id;
207 }
208
209 auto study_uid = req.url_params.get("study_uid");
210 if (study_uid) {
211 query.study_uid = study_uid;
212 }
213
214 auto date_from = req.url_params.get("date_from");
215 if (date_from) {
216 query.date_from = date_from;
217 }
218
219 auto date_to = req.url_params.get("date_to");
220 if (date_to) {
221 query.date_to = date_to;
222 }
223
224 // Check export format
225 auto format_param = req.url_params.get("format");
226 bool export_csv = format_param && std::string(format_param) == "csv";
227
228 // Get total count (without pagination)
229 storage::audit_query count_query = query;
230 count_query.limit = 0;
231 count_query.offset = 0;
232 auto all_records_result = ctx->database->query_audit_log(count_query);
233 if (!all_records_result.is_ok()) {
234 res.code = 500;
235 res.body = make_error_json("QUERY_ERROR",
236 all_records_result.error().message);
237 return res;
238 }
239 size_t total_count = all_records_result.value().size();
240
241 // Get paginated results
242 auto records_result = ctx->database->query_audit_log(query);
243 if (!records_result.is_ok()) {
244 res.code = 500;
245 res.body = make_error_json("QUERY_ERROR",
246 records_result.error().message);
247 return res;
248 }
249 auto records = std::move(records_result.value());
250
251 if (export_csv) {
252 res.add_header("Content-Type", "text/csv");
253 res.add_header("Content-Disposition",
254 "attachment; filename=\"audit_logs.csv\"");
255 res.code = 200;
256 res.body = audit_records_to_csv(records);
257 } else {
258 res.code = 200;
259 res.body = audit_records_to_json(records, total_count);
260 }
261 return res;
262 });
263
264 // GET /api/v1/audit/logs/:id - Get specific audit log entry
265 CROW_ROUTE(app, "/api/v1/audit/logs/<int>")
266 .methods(crow::HTTPMethod::GET)(
267 [ctx](const crow::request & /*req*/, int pk) {
268 crow::response res;
269 res.add_header("Content-Type", "application/json");
270 add_cors_headers(res, *ctx);
271
272 if (!ctx->database) {
273 res.code = 503;
274 res.body = make_error_json("DATABASE_UNAVAILABLE",
275 "Database not configured");
276 return res;
277 }
278
279 auto record = ctx->database->find_audit_by_pk(pk);
280 if (!record) {
281 res.code = 404;
282 res.body = make_error_json("NOT_FOUND", "Audit log entry not found");
283 return res;
284 }
285
286 res.code = 200;
287 res.body = audit_record_to_json(*record);
288 return res;
289 });
290
291 // GET /api/v1/audit/export - Export audit logs (CSV or JSON)
292 CROW_ROUTE(app, "/api/v1/audit/export")
293 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
294 crow::response res;
295 add_cors_headers(res, *ctx);
296
297 if (!ctx->database) {
298 res.add_header("Content-Type", "application/json");
299 res.code = 503;
300 res.body =
301 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
302 return res;
303 }
304
305 // Build query from URL parameters (no pagination for export)
306 storage::audit_query query;
307
308 auto event_type = req.url_params.get("event_type");
309 if (event_type) {
310 query.event_type = event_type;
311 }
312
313 auto outcome = req.url_params.get("outcome");
314 if (outcome) {
315 query.outcome = outcome;
316 }
317
318 auto user_id = req.url_params.get("user_id");
319 if (user_id) {
320 query.user_id = user_id;
321 }
322
323 auto date_from = req.url_params.get("date_from");
324 if (date_from) {
325 query.date_from = date_from;
326 }
327
328 auto date_to = req.url_params.get("date_to");
329 if (date_to) {
330 query.date_to = date_to;
331 }
332
333 auto records_result = ctx->database->query_audit_log(query);
334 if (!records_result.is_ok()) {
335 res.add_header("Content-Type", "application/json");
336 res.code = 500;
337 res.body = make_error_json("QUERY_ERROR",
338 records_result.error().message);
339 return res;
340 }
341 auto records = std::move(records_result.value());
342
343 // Check export format
344 auto format_param = req.url_params.get("format");
345 std::string format = format_param ? format_param : "json";
346
347 if (format == "csv") {
348 res.add_header("Content-Type", "text/csv");
349 res.add_header("Content-Disposition",
350 "attachment; filename=\"audit_logs.csv\"");
351 res.code = 200;
352 res.body = audit_records_to_csv(records);
353 } else {
354 res.add_header("Content-Type", "application/json");
355 res.add_header("Content-Disposition",
356 "attachment; filename=\"audit_logs.json\"");
357 res.code = 200;
358 res.body = audit_records_to_json(records, records.size());
359 }
360 return res;
361 });
362}
constexpr dicom_tag patient_id
Patient ID.
Query parameters for audit log search.

References kcenon::pacs::storage::audit_query::limit, kcenon::pacs::web::make_error_json(), and kcenon::pacs::storage::audit_query::offset.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_dicomweb_endpoints_impl()

void kcenon::pacs::web::endpoints::register_dicomweb_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 1792 of file dicomweb_endpoints.cpp.

1793 {
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
2205 dicomweb::multipart_builder builder(
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) {
2455 dicomweb::store_instance_result result;
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) {
2617 dicomweb::store_instance_result result;
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
3033 storage::instance_query query;
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}
@ accept
Clinician accepts AI result as-is.
constexpr dicom_tag bits_allocated
Bits Allocated.
constexpr dicom_tag rows
Rows.
constexpr dicom_tag pixel_data
Pixel Data.
constexpr dicom_tag samples_per_pixel
Samples per Pixel.
constexpr dicom_tag patient_name
Patient's Name.

References kcenon::pacs::web::dicomweb::multipart_builder::add_part_with_location(), kcenon::pacs::web::dicomweb::multipart_builder::build(), kcenon::pacs::web::dicomweb::multipart_builder::content_type_header(), kcenon::pacs::web::dicomweb::multipart_builder::empty(), kcenon::pacs::web::dicomweb::store_instance_result::error_code, kcenon::pacs::web::dicomweb::store_instance_result::error_message, register_dicomweb_endpoints_impl(), kcenon::pacs::web::dicomweb::store_instance_result::retrieve_url, kcenon::pacs::web::dicomweb::multipart_builder::size(), kcenon::pacs::web::dicomweb::store_instance_result::sop_class_uid, kcenon::pacs::web::dicomweb::store_instance_result::sop_instance_uid, kcenon::pacs::storage::series_query::study_uid, and kcenon::pacs::web::dicomweb::store_instance_result::success.

Referenced by register_dicomweb_endpoints_impl(), and kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_jobs_endpoints_impl()

void kcenon::pacs::web::endpoints::register_jobs_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 444 of file jobs_endpoints.cpp.

445 {
446 // GET /api/v1/jobs - List jobs (paginated with filters)
447 CROW_ROUTE(app, "/api/v1/jobs")
448 .methods(crow::HTTPMethod::GET)([ctx](const crow::request& req) {
449 crow::response res;
450 res.add_header("Content-Type", "application/json");
451 add_cors_headers(res, *ctx);
452
453 if (!ctx->job_manager) {
454 res.code = 503;
455 res.body = make_error_json("SERVICE_UNAVAILABLE",
456 "Job manager not configured");
457 return res;
458 }
459
460 // Parse pagination
461 auto [limit, offset] = parse_pagination(req);
462
463 // Parse filters
464 std::optional<client::job_status> status_filter;
465 std::optional<client::job_type> type_filter;
466
467 auto status_param = req.url_params.get("status");
468 if (status_param) {
469 status_filter = client::job_status_from_string(status_param);
470 }
471
472 auto type_param = req.url_params.get("type");
473 if (type_param) {
474 type_filter = client::job_type_from_string(type_param);
475 }
476
477 // Get jobs with filters
478 auto jobs = ctx->job_manager->list_jobs(status_filter, type_filter,
479 limit, offset);
480
481 // Get total count (without pagination for now, we use jobs.size())
482 // TODO: Add count method to job_manager for accurate total
483 size_t total_count = jobs.size();
484
485 res.code = 200;
486 res.body = jobs_to_json(jobs, total_count);
487 return res;
488 });
489
490 // POST /api/v1/jobs - Create a new job
491 CROW_ROUTE(app, "/api/v1/jobs")
492 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& req) {
493 crow::response res;
494 res.add_header("Content-Type", "application/json");
495 add_cors_headers(res, *ctx);
496
497 if (!ctx->job_manager) {
498 res.code = 503;
499 res.body = make_error_json("SERVICE_UNAVAILABLE",
500 "Job manager not configured");
501 return res;
502 }
503
504 // Parse job type
505 auto type_str = get_json_string_value(req.body, "type");
506 if (type_str.empty()) {
507 res.code = 400;
508 res.body = make_error_json("INVALID_REQUEST", "type is required");
509 return res;
510 }
511
512 auto type = client::job_type_from_string(type_str);
513
514 // Parse priority (optional, defaults to normal)
515 auto priority_str = get_json_string_value(req.body, "priority");
516 auto priority = priority_str.empty()
517 ? client::job_priority::normal
518 : client::job_priority_from_string(priority_str);
519
520 std::string job_id;
521
522 // Create job based on type
523 switch (type) {
524 case client::job_type::retrieve: {
525 auto source_node_id = get_json_string_value(req.body, "source_node_id");
526 auto study_uid = get_json_string_value(req.body, "study_uid");
527
528 if (source_node_id.empty()) {
529 res.code = 400;
530 res.body = make_error_json("INVALID_REQUEST",
531 "source_node_id is required for retrieve job");
532 return res;
533 }
534
535 if (study_uid.empty()) {
536 res.code = 400;
537 res.body = make_error_json("INVALID_REQUEST",
538 "study_uid is required for retrieve job");
539 return res;
540 }
541
542 auto series_uid = get_json_string_value(req.body, "series_uid");
543 std::optional<std::string_view> series_opt;
544 if (!series_uid.empty()) {
545 series_opt = series_uid;
546 }
547
548 job_id = ctx->job_manager->create_retrieve_job(
549 source_node_id, study_uid, series_opt, priority);
550 break;
551 }
552
553 case client::job_type::store: {
554 auto destination_node_id = get_json_string_value(req.body, "destination_node_id");
555 auto instance_uids = get_json_string_array(req.body, "instance_uids");
556
557 if (destination_node_id.empty()) {
558 res.code = 400;
559 res.body = make_error_json("INVALID_REQUEST",
560 "destination_node_id is required for store job");
561 return res;
562 }
563
564 if (instance_uids.empty()) {
565 res.code = 400;
566 res.body = make_error_json("INVALID_REQUEST",
567 "instance_uids is required for store job");
568 return res;
569 }
570
571 job_id = ctx->job_manager->create_store_job(
572 destination_node_id, instance_uids, priority);
573 break;
574 }
575
576 case client::job_type::query: {
577 auto node_id = get_json_string_value(req.body, "node_id");
578 auto query_level = get_json_string_value(req.body, "query_level");
579
580 if (node_id.empty()) {
581 res.code = 400;
582 res.body = make_error_json("INVALID_REQUEST",
583 "node_id is required for query job");
584 return res;
585 }
586
587 if (query_level.empty()) {
588 query_level = "STUDY"; // Default to study level
589 }
590
591 // Parse query keys (simplified - in production use proper JSON parser)
592 std::unordered_map<std::string, std::string> query_keys;
593 auto patient_id = get_json_string_value(req.body, "patient_id");
594 if (!patient_id.empty()) {
595 query_keys["PatientID"] = patient_id;
596 }
597 auto patient_name = get_json_string_value(req.body, "patient_name");
598 if (!patient_name.empty()) {
599 query_keys["PatientName"] = patient_name;
600 }
601
602 job_id = ctx->job_manager->create_query_job(
603 node_id, query_level, query_keys, priority);
604 break;
605 }
606
607 case client::job_type::sync: {
608 auto source_node_id = get_json_string_value(req.body, "source_node_id");
609
610 if (source_node_id.empty()) {
611 res.code = 400;
612 res.body = make_error_json("INVALID_REQUEST",
613 "source_node_id is required for sync job");
614 return res;
615 }
616
617 auto patient_id = get_json_string_value(req.body, "patient_id");
618 std::optional<std::string_view> patient_opt;
619 if (!patient_id.empty()) {
620 patient_opt = patient_id;
621 }
622
623 job_id = ctx->job_manager->create_sync_job(
624 source_node_id, patient_opt, priority);
625 break;
626 }
627
628 case client::job_type::prefetch: {
629 auto source_node_id = get_json_string_value(req.body, "source_node_id");
630 auto patient_id = get_json_string_value(req.body, "patient_id");
631
632 if (source_node_id.empty()) {
633 res.code = 400;
634 res.body = make_error_json("INVALID_REQUEST",
635 "source_node_id is required for prefetch job");
636 return res;
637 }
638
639 if (patient_id.empty()) {
640 res.code = 400;
641 res.body = make_error_json("INVALID_REQUEST",
642 "patient_id is required for prefetch job");
643 return res;
644 }
645
646 job_id = ctx->job_manager->create_prefetch_job(
647 source_node_id, patient_id, priority);
648 break;
649 }
650
651 default: {
652 res.code = 400;
653 res.body = make_error_json("INVALID_REQUEST",
654 "Unsupported job type: " + type_str);
655 return res;
656 }
657 }
658
659 // Retrieve and return the created job
660 auto created_job = ctx->job_manager->get_job(job_id);
661 if (!created_job) {
662 res.code = 201;
663 res.body = R"({"job_id":")" + json_escape(job_id) + R"(","status":"pending"})";
664 return res;
665 }
666
667 res.code = 201;
668 res.body = job_to_json(*created_job);
669 return res;
670 });
671
672 // GET /api/v1/jobs/<jobId> - Get a specific job
673 CROW_ROUTE(app, "/api/v1/jobs/<string>")
674 .methods(crow::HTTPMethod::GET)(
675 [ctx](const crow::request& /*req*/, const std::string& job_id) {
676 crow::response res;
677 res.add_header("Content-Type", "application/json");
678 add_cors_headers(res, *ctx);
679
680 if (!ctx->job_manager) {
681 res.code = 503;
682 res.body = make_error_json("SERVICE_UNAVAILABLE",
683 "Job manager not configured");
684 return res;
685 }
686
687 auto job = ctx->job_manager->get_job(job_id);
688 if (!job) {
689 res.code = 404;
690 res.body = make_error_json("NOT_FOUND", "Job not found");
691 return res;
692 }
693
694 res.code = 200;
695 res.body = job_to_json(*job);
696 return res;
697 });
698
699 // DELETE /api/v1/jobs/<jobId> - Delete a job
700 CROW_ROUTE(app, "/api/v1/jobs/<string>")
701 .methods(crow::HTTPMethod::DELETE)(
702 [ctx](const crow::request& /*req*/, const std::string& job_id) {
703 crow::response res;
704 add_cors_headers(res, *ctx);
705
706 if (!ctx->job_manager) {
707 res.add_header("Content-Type", "application/json");
708 res.code = 503;
709 res.body = make_error_json("SERVICE_UNAVAILABLE",
710 "Job manager not configured");
711 return res;
712 }
713
714 // Check if job exists
715 auto job = ctx->job_manager->get_job(job_id);
716 if (!job) {
717 res.add_header("Content-Type", "application/json");
718 res.code = 404;
719 res.body = make_error_json("NOT_FOUND", "Job not found");
720 return res;
721 }
722
723 auto result = ctx->job_manager->delete_job(job_id);
724 if (!result.is_ok()) {
725 res.add_header("Content-Type", "application/json");
726 res.code = 500;
727 res.body = make_error_json("DELETE_FAILED", result.error().message);
728 return res;
729 }
730
731 res.code = 204;
732 return res;
733 });
734
735 // GET /api/v1/jobs/<jobId>/progress - Get job progress
736 CROW_ROUTE(app, "/api/v1/jobs/<string>/progress")
737 .methods(crow::HTTPMethod::GET)(
738 [ctx](const crow::request& /*req*/, const std::string& job_id) {
739 crow::response res;
740 res.add_header("Content-Type", "application/json");
741 add_cors_headers(res, *ctx);
742
743 if (!ctx->job_manager) {
744 res.code = 503;
745 res.body = make_error_json("SERVICE_UNAVAILABLE",
746 "Job manager not configured");
747 return res;
748 }
749
750 // Check if job exists
751 auto job = ctx->job_manager->get_job(job_id);
752 if (!job) {
753 res.code = 404;
754 res.body = make_error_json("NOT_FOUND", "Job not found");
755 return res;
756 }
757
758 auto progress = ctx->job_manager->get_progress(job_id);
759
760 res.code = 200;
761 res.body = progress_to_json(progress);
762 return res;
763 });
764
765 // =========================================================================
766 // Job Control Endpoints (Issue #559)
767 // =========================================================================
768
769 // POST /api/v1/jobs/<jobId>/start - Start a pending job
770 CROW_ROUTE(app, "/api/v1/jobs/<string>/start")
771 .methods(crow::HTTPMethod::POST)(
772 [ctx](const crow::request& /*req*/, const std::string& job_id) {
773 crow::response res;
774 res.add_header("Content-Type", "application/json");
775 add_cors_headers(res, *ctx);
776
777 if (!ctx->job_manager) {
778 res.code = 503;
779 res.body = make_error_json("SERVICE_UNAVAILABLE",
780 "Job manager not configured");
781 return res;
782 }
783
784 // Check if job exists
785 auto job = ctx->job_manager->get_job(job_id);
786 if (!job) {
787 res.code = 404;
788 res.body = make_error_json("NOT_FOUND", "Job not found");
789 return res;
790 }
791
792 auto result = ctx->job_manager->start_job(job_id);
793 if (!result.is_ok()) {
794 res.code = 409;
795 res.body = make_error_json("INVALID_STATE_TRANSITION",
796 result.error().message);
797 return res;
798 }
799
800 // Return updated job
801 auto updated_job = ctx->job_manager->get_job(job_id);
802 if (updated_job) {
803 res.code = 200;
804 res.body = job_to_json(*updated_job);
805 } else {
806 res.code = 200;
807 res.body = R"({"job_id":")" + json_escape(job_id) +
808 R"(","message":"Job started"})";
809 }
810 return res;
811 });
812
813 // POST /api/v1/jobs/<jobId>/pause - Pause a running job
814 CROW_ROUTE(app, "/api/v1/jobs/<string>/pause")
815 .methods(crow::HTTPMethod::POST)(
816 [ctx](const crow::request& /*req*/, const std::string& job_id) {
817 crow::response res;
818 res.add_header("Content-Type", "application/json");
819 add_cors_headers(res, *ctx);
820
821 if (!ctx->job_manager) {
822 res.code = 503;
823 res.body = make_error_json("SERVICE_UNAVAILABLE",
824 "Job manager not configured");
825 return res;
826 }
827
828 // Check if job exists
829 auto job = ctx->job_manager->get_job(job_id);
830 if (!job) {
831 res.code = 404;
832 res.body = make_error_json("NOT_FOUND", "Job not found");
833 return res;
834 }
835
836 auto result = ctx->job_manager->pause_job(job_id);
837 if (!result.is_ok()) {
838 res.code = 409;
839 res.body = make_error_json("INVALID_STATE_TRANSITION",
840 result.error().message);
841 return res;
842 }
843
844 // Return updated job
845 auto updated_job = ctx->job_manager->get_job(job_id);
846 if (updated_job) {
847 res.code = 200;
848 res.body = job_to_json(*updated_job);
849 } else {
850 res.code = 200;
851 res.body = R"({"job_id":")" + json_escape(job_id) +
852 R"(","message":"Job paused"})";
853 }
854 return res;
855 });
856
857 // POST /api/v1/jobs/<jobId>/resume - Resume a paused job
858 CROW_ROUTE(app, "/api/v1/jobs/<string>/resume")
859 .methods(crow::HTTPMethod::POST)(
860 [ctx](const crow::request& /*req*/, const std::string& job_id) {
861 crow::response res;
862 res.add_header("Content-Type", "application/json");
863 add_cors_headers(res, *ctx);
864
865 if (!ctx->job_manager) {
866 res.code = 503;
867 res.body = make_error_json("SERVICE_UNAVAILABLE",
868 "Job manager not configured");
869 return res;
870 }
871
872 // Check if job exists
873 auto job = ctx->job_manager->get_job(job_id);
874 if (!job) {
875 res.code = 404;
876 res.body = make_error_json("NOT_FOUND", "Job not found");
877 return res;
878 }
879
880 auto result = ctx->job_manager->resume_job(job_id);
881 if (!result.is_ok()) {
882 res.code = 409;
883 res.body = make_error_json("INVALID_STATE_TRANSITION",
884 result.error().message);
885 return res;
886 }
887
888 // Return updated job
889 auto updated_job = ctx->job_manager->get_job(job_id);
890 if (updated_job) {
891 res.code = 200;
892 res.body = job_to_json(*updated_job);
893 } else {
894 res.code = 200;
895 res.body = R"({"job_id":")" + json_escape(job_id) +
896 R"(","message":"Job resumed"})";
897 }
898 return res;
899 });
900
901 // POST /api/v1/jobs/<jobId>/cancel - Cancel a job
902 CROW_ROUTE(app, "/api/v1/jobs/<string>/cancel")
903 .methods(crow::HTTPMethod::POST)(
904 [ctx](const crow::request& /*req*/, const std::string& job_id) {
905 crow::response res;
906 res.add_header("Content-Type", "application/json");
907 add_cors_headers(res, *ctx);
908
909 if (!ctx->job_manager) {
910 res.code = 503;
911 res.body = make_error_json("SERVICE_UNAVAILABLE",
912 "Job manager not configured");
913 return res;
914 }
915
916 // Check if job exists
917 auto job = ctx->job_manager->get_job(job_id);
918 if (!job) {
919 res.code = 404;
920 res.body = make_error_json("NOT_FOUND", "Job not found");
921 return res;
922 }
923
924 auto result = ctx->job_manager->cancel_job(job_id);
925 if (!result.is_ok()) {
926 res.code = 409;
927 res.body = make_error_json("INVALID_STATE_TRANSITION",
928 result.error().message);
929 return res;
930 }
931
932 // Return updated job
933 auto updated_job = ctx->job_manager->get_job(job_id);
934 if (updated_job) {
935 res.code = 200;
936 res.body = job_to_json(*updated_job);
937 } else {
938 res.code = 200;
939 res.body = R"({"job_id":")" + json_escape(job_id) +
940 R"(","message":"Job cancelled"})";
941 }
942 return res;
943 });
944
945 // POST /api/v1/jobs/<jobId>/retry - Retry a failed job
946 CROW_ROUTE(app, "/api/v1/jobs/<string>/retry")
947 .methods(crow::HTTPMethod::POST)(
948 [ctx](const crow::request& /*req*/, const std::string& job_id) {
949 crow::response res;
950 res.add_header("Content-Type", "application/json");
951 add_cors_headers(res, *ctx);
952
953 if (!ctx->job_manager) {
954 res.code = 503;
955 res.body = make_error_json("SERVICE_UNAVAILABLE",
956 "Job manager not configured");
957 return res;
958 }
959
960 // Check if job exists
961 auto job = ctx->job_manager->get_job(job_id);
962 if (!job) {
963 res.code = 404;
964 res.body = make_error_json("NOT_FOUND", "Job not found");
965 return res;
966 }
967
968 auto result = ctx->job_manager->retry_job(job_id);
969 if (!result.is_ok()) {
970 res.code = 409;
971 res.body = make_error_json("INVALID_STATE_TRANSITION",
972 result.error().message);
973 return res;
974 }
975
976 // Return updated job
977 auto updated_job = ctx->job_manager->get_job(job_id);
978 if (updated_job) {
979 res.code = 200;
980 res.body = job_to_json(*updated_job);
981 } else {
982 res.code = 200;
983 res.body = R"({"job_id":")" + json_escape(job_id) +
984 R"(","message":"Job retry queued"})";
985 }
986 return res;
987 });
988
989 // =========================================================================
990 // WebSocket Endpoints (Issue #560)
991 // =========================================================================
992
993 // Register job_manager callbacks for broadcasting (only once)
994 if (ctx->job_manager && !g_callbacks_registered.exchange(true)) {
995 // Progress callback - broadcasts to all subscribers
996 ctx->job_manager->set_progress_callback(
997 [](const std::string& job_id, const client::job_progress& progress) {
998 auto message = make_progress_message(job_id, progress);
999 ws_subscriber_state::instance().broadcast_progress(job_id, message);
1000 });
1001
1002 // Completion callback - broadcasts final status
1003 ctx->job_manager->set_completion_callback(
1004 [](const std::string& job_id, const client::job_record& record) {
1005 auto message = make_completion_message(job_id, record);
1006 ws_subscriber_state::instance().broadcast_progress(job_id, message);
1007 });
1008 }
1009
1010 // WS /api/v1/jobs/<jobId>/progress/stream - Stream progress for specific job
1011 // Note: URL parameters in WebSocket routes require using onaccept to extract
1012 // the job_id from the request URL and store it in userdata
1013 CROW_WEBSOCKET_ROUTE(app, "/api/v1/jobs/<string>/progress/stream")
1014 .onaccept([ctx](const crow::request& req, void** userdata) -> bool {
1015 // Extract job_id from URL: /api/v1/jobs/{job_id}/progress/stream
1016 std::string url = req.url;
1017 // Find the job_id between /jobs/ and /progress
1018 const std::string prefix = "/api/v1/jobs/";
1019 const std::string suffix = "/progress/stream";
1020
1021 if (url.find(prefix) != 0) {
1022 return false;
1023 }
1024
1025 auto suffix_pos = url.rfind(suffix);
1026 if (suffix_pos == std::string::npos) {
1027 return false;
1028 }
1029
1030 std::string job_id = url.substr(prefix.length(),
1031 suffix_pos - prefix.length());
1032
1033 if (job_id.empty()) {
1034 return false;
1035 }
1036
1037 // Verify job manager is available
1038 if (!ctx->job_manager) {
1039 return false;
1040 }
1041
1042 // Verify job exists
1043 auto job = ctx->job_manager->get_job(job_id);
1044 if (!job) {
1045 return false;
1046 }
1047
1048 // Store job_id in userdata (allocate on heap)
1049 *userdata = new std::string(job_id);
1050 return true;
1051 })
1052 .onopen([ctx](crow::websocket::connection& conn) {
1053 auto* job_id_ptr = static_cast<std::string*>(conn.userdata());
1054 if (!job_id_ptr || !ctx->job_manager) {
1055 conn.send_text(R"({"error":"Invalid connection state"})");
1056 conn.close("Invalid state");
1057 return;
1058 }
1059
1060 const std::string& job_id = *job_id_ptr;
1061
1062 // Subscribe to job updates
1063 ws_subscriber_state::instance().add_job_subscriber(job_id, &conn);
1064
1065 // Send initial progress
1066 auto progress = ctx->job_manager->get_progress(job_id);
1067 conn.send_text(make_progress_message(job_id, progress));
1068 })
1069 .onclose([](crow::websocket::connection& conn,
1070 const std::string& /*reason*/, uint16_t /*code*/) {
1071 auto* job_id_ptr = static_cast<std::string*>(conn.userdata());
1072 if (job_id_ptr) {
1073 ws_subscriber_state::instance().remove_job_subscriber(*job_id_ptr, &conn);
1074 delete job_id_ptr;
1075 conn.userdata(nullptr);
1076 }
1077 })
1078 .onmessage([](crow::websocket::connection& /*conn*/,
1079 const std::string& /*data*/, bool /*is_binary*/) {
1080 // Client messages not expected, but handle gracefully
1081 });
1082
1083 // WS /api/v1/jobs/stream - Stream all job updates
1084 CROW_WEBSOCKET_ROUTE(app, "/api/v1/jobs/stream")
1085 .onaccept([ctx](const crow::request& /*req*/, void** /*userdata*/) -> bool {
1086 // Only accept if job_manager is available
1087 return ctx->job_manager != nullptr;
1088 })
1089 .onopen([](crow::websocket::connection& conn) {
1090 // Subscribe to all job updates
1091 ws_subscriber_state::instance().add_all_jobs_subscriber(&conn);
1092
1093 // Send initial connected message
1094 conn.send_text(R"({"type":"connected","message":"Subscribed to all job updates"})");
1095 })
1096 .onclose([](crow::websocket::connection& conn,
1097 const std::string& /*reason*/, uint16_t /*code*/) {
1098 ws_subscriber_state::instance().remove_all_jobs_subscriber(&conn);
1099 })
1100 .onmessage([](crow::websocket::connection& /*conn*/,
1101 const std::string& /*data*/, bool /*is_binary*/) {
1102 // Client messages not expected, but handle gracefully
1103 });
1104}
constexpr dicom_tag priority
Priority.

References kcenon::pacs::client::job_priority_from_string(), kcenon::pacs::client::job_status_from_string(), kcenon::pacs::client::job_type_from_string(), kcenon::pacs::web::json_escape(), kcenon::pacs::web::make_error_json(), kcenon::pacs::client::normal, kcenon::pacs::client::prefetch, kcenon::pacs::client::query, kcenon::pacs::client::retrieve, kcenon::pacs::client::store, and kcenon::pacs::client::sync.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_key_image_endpoints_impl()

void kcenon::pacs::web::endpoints::register_key_image_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 169 of file key_image_endpoints.cpp.

170 {
171 // POST /api/v1/studies/<studyUid>/key-images - Create key image
172 CROW_ROUTE(app, "/api/v1/studies/<string>/key-images")
173 .methods(crow::HTTPMethod::POST)(
174 [ctx](const crow::request &req, const std::string &study_uid) {
175 crow::response res;
176 res.add_header("Content-Type", "application/json");
177 add_cors_headers(res, *ctx);
178
179 if (!ctx->database) {
180 res.code = 503;
181 res.body =
182 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
183 return res;
184 }
185
186 std::string body = req.body;
187 if (body.empty()) {
188 res.code = 400;
189 res.body = make_error_json("INVALID_REQUEST", "Request body is empty");
190 return res;
191 }
192
194 ki.key_image_id = generate_uuid();
195 ki.study_uid = study_uid;
196 ki.sop_instance_uid = parse_json_string(body, "sop_instance_uid");
197 ki.frame_number = parse_json_int(body, "frame_number");
198 ki.user_id = parse_json_string(body, "user_id");
199 ki.reason = parse_json_string(body, "reason");
200 ki.document_title = parse_json_string(body, "document_title");
201 ki.created_at = std::chrono::system_clock::now();
202
203 if (ki.sop_instance_uid.empty()) {
204 res.code = 400;
205 res.body = make_error_json("MISSING_FIELD", "sop_instance_uid is required");
206 return res;
207 }
208
209#ifdef PACS_WITH_DATABASE_SYSTEM
210 storage::key_image_repository repo(ctx->database->db_adapter());
211#else
212 storage::key_image_repository repo(ctx->database->native_handle());
213#endif
214 auto save_result = repo.save(ki);
215 if (!save_result.is_ok()) {
216 res.code = 500;
217 res.body =
218 make_error_json("SAVE_ERROR", save_result.error().message);
219 return res;
220 }
221
222 res.code = 201;
223 std::ostringstream oss;
224 oss << R"({"key_image_id":")" << json_escape(ki.key_image_id)
225 << R"(","created_at":")" << format_timestamp(ki.created_at)
226 << R"("})";
227 res.body = oss.str();
228 return res;
229 });
230
231 // GET /api/v1/studies/<studyUid>/key-images - List key images for study
232 CROW_ROUTE(app, "/api/v1/studies/<string>/key-images")
233 .methods(crow::HTTPMethod::GET)(
234 [ctx](const crow::request & /*req*/, const std::string &study_uid) {
235 crow::response res;
236 res.add_header("Content-Type", "application/json");
237 add_cors_headers(res, *ctx);
238
239 if (!ctx->database) {
240 res.code = 503;
241 res.body =
242 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
243 return res;
244 }
245
246#ifdef PACS_WITH_DATABASE_SYSTEM
247 storage::key_image_repository repo(ctx->database->db_adapter());
248 auto key_images_result = repo.find_by_study(study_uid);
249 if (!key_images_result.is_ok()) {
250 res.code = 500;
251 res.body = make_error_json("QUERY_ERROR", key_images_result.error().message);
252 return res;
253 }
254 res.code = 200;
255 res.body = key_images_to_json(key_images_result.value());
256#else
257 storage::key_image_repository repo(ctx->database->native_handle());
258 auto key_images = repo.find_by_study(study_uid);
259 res.code = 200;
260 res.body = key_images_to_json(key_images);
261#endif
262 return res;
263 });
264
265 // DELETE /api/v1/key-images/<keyImageId> - Delete key image
266 CROW_ROUTE(app, "/api/v1/key-images/<string>")
267 .methods(crow::HTTPMethod::DELETE)(
268 [ctx](const crow::request & /*req*/, const std::string &key_image_id) {
269 crow::response res;
270 add_cors_headers(res, *ctx);
271
272 if (!ctx->database) {
273 res.code = 503;
274 res.add_header("Content-Type", "application/json");
275 res.body = make_error_json("DATABASE_UNAVAILABLE",
276 "Database not configured");
277 return res;
278 }
279
280#ifdef PACS_WITH_DATABASE_SYSTEM
281 storage::key_image_repository repo(ctx->database->db_adapter());
282 auto exists_result = repo.exists(key_image_id);
283 if (!exists_result.is_ok()) {
284 res.code = 500;
285 res.add_header("Content-Type", "application/json");
286 res.body = make_error_json("QUERY_ERROR", exists_result.error().message);
287 return res;
288 }
289 if (!exists_result.value()) {
290 res.code = 404;
291 res.add_header("Content-Type", "application/json");
292 res.body = make_error_json("NOT_FOUND", "Key image not found");
293 return res;
294 }
295#else
296 storage::key_image_repository repo(ctx->database->native_handle());
297 if (!repo.exists(key_image_id)) {
298 res.code = 404;
299 res.add_header("Content-Type", "application/json");
300 res.body = make_error_json("NOT_FOUND", "Key image not found");
301 return res;
302 }
303#endif
304
305 auto remove_result = repo.remove(key_image_id);
306 if (!remove_result.is_ok()) {
307 res.code = 500;
308 res.add_header("Content-Type", "application/json");
309 res.body =
310 make_error_json("DELETE_ERROR", remove_result.error().message);
311 return res;
312 }
313
314 res.code = 204;
315 return res;
316 });
317
318 // POST /api/v1/studies/<studyUid>/key-images/export-sr - Export as Key Object Selection SR
319 CROW_ROUTE(app, "/api/v1/studies/<string>/key-images/export-sr")
320 .methods(crow::HTTPMethod::POST)(
321 [ctx](const crow::request & /*req*/, const std::string &study_uid) {
322 crow::response res;
323 add_cors_headers(res, *ctx);
324
325 if (!ctx->database) {
326 res.code = 503;
327 res.add_header("Content-Type", "application/json");
328 res.body = make_error_json("DATABASE_UNAVAILABLE",
329 "Database not configured");
330 return res;
331 }
332
333#ifdef PACS_WITH_DATABASE_SYSTEM
334 storage::key_image_repository repo(ctx->database->db_adapter());
335 auto key_images_result = repo.find_by_study(study_uid);
336 if (!key_images_result.is_ok()) {
337 res.code = 500;
338 res.add_header("Content-Type", "application/json");
339 res.body = make_error_json("QUERY_ERROR", key_images_result.error().message);
340 return res;
341 }
342 const auto& key_images = key_images_result.value();
343#else
344 storage::key_image_repository repo(ctx->database->native_handle());
345 auto key_images = repo.find_by_study(study_uid);
346#endif
347 if (key_images.empty()) {
348 res.code = 404;
349 res.add_header("Content-Type", "application/json");
350 res.body = make_error_json("NO_KEY_IMAGES",
351 "No key images found for study");
352 return res;
353 }
354
355 // Build Key Object Selection Document as JSON
356 // Note: Full DICOM SR encoding requires dcmtk/dicom_dataset integration
357 // This endpoint returns a structured JSON that can be converted to DICOM SR
358 std::ostringstream oss;
359 oss << R"({"document_type":"Key Object Selection",)";
360 oss << R"("study_uid":")" << json_escape(study_uid) << R"(",)";
361 oss << R"("document_title":"Key Images",)";
362 oss << R"("referenced_instances":[)";
363
364 for (size_t i = 0; i < key_images.size(); ++i) {
365 if (i > 0) {
366 oss << ",";
367 }
368 const auto &ki = key_images[i];
369 oss << R"({"sop_instance_uid":")" << json_escape(ki.sop_instance_uid) << R"(",)";
370 oss << R"("frame_number":)";
371 if (ki.frame_number.has_value()) {
372 oss << ki.frame_number.value();
373 } else {
374 oss << "null";
375 }
376 oss << R"(,"reason":")" << json_escape(ki.reason) << R"("})";
377 }
378
379 oss << R"(],"created_at":")" << format_timestamp(std::chrono::system_clock::now()) << R"("})";
380
381 res.code = 200;
382 res.add_header("Content-Type", "application/json");
383 res.body = oss.str();
384 return res;
385 });
386}
Key image record from the database.
std::chrono::system_clock::time_point created_at
Record creation timestamp.
std::optional< int > frame_number
Frame number for multi-frame images (1-based)
std::string user_id
User who marked the key image.
std::string study_uid
Study Instance UID - DICOM tag (0020,000D)
std::string sop_instance_uid
SOP Instance UID - DICOM tag (0008,0018)
std::string key_image_id
Unique key image identifier (UUID)
std::string document_title
Document title for Key Object Selection.
std::string reason
Reason for marking as key image.

References kcenon::pacs::storage::key_image_record::created_at, kcenon::pacs::storage::key_image_record::document_title, kcenon::pacs::storage::key_image_repository::exists(), kcenon::pacs::storage::key_image_repository::find_by_study(), kcenon::pacs::storage::key_image_record::frame_number, kcenon::pacs::web::json_escape(), kcenon::pacs::storage::key_image_record::key_image_id, kcenon::pacs::web::make_error_json(), kcenon::pacs::storage::key_image_record::reason, kcenon::pacs::storage::key_image_repository::remove(), kcenon::pacs::storage::key_image_repository::save(), kcenon::pacs::storage::key_image_record::sop_instance_uid, kcenon::pacs::storage::key_image_record::study_uid, and kcenon::pacs::storage::key_image_record::user_id.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_measurement_endpoints_impl()

void kcenon::pacs::web::endpoints::register_measurement_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 259 of file measurement_endpoints.cpp.

260 {
261 // POST /api/v1/measurements - Create measurement
262 CROW_ROUTE(app, "/api/v1/measurements")
263 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req) {
264 crow::response res;
265 res.add_header("Content-Type", "application/json");
266 add_cors_headers(res, *ctx);
267
268 if (!ctx->database) {
269 res.code = 503;
270 res.body =
271 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
272 return res;
273 }
274
275 std::string body = req.body;
276 if (body.empty()) {
277 res.code = 400;
278 res.body = make_error_json("INVALID_REQUEST", "Request body is empty");
279 return res;
280 }
281
283 meas.measurement_id = generate_uuid();
284 meas.sop_instance_uid = parse_json_string(body, "sop_instance_uid");
285 meas.frame_number = parse_json_int(body, "frame_number");
286 meas.user_id = parse_json_string(body, "user_id");
287
288 auto type_str = parse_json_string(body, "measurement_type");
289 auto type_opt = storage::measurement_type_from_string(type_str);
290 if (!type_opt.has_value()) {
291 res.code = 400;
292 res.body = make_error_json("INVALID_TYPE", "Invalid measurement type");
293 return res;
294 }
295 meas.type = type_opt.value();
296
297 meas.geometry_json = parse_json_object(body, "geometry");
298 meas.value = parse_json_double(body, "value");
299 meas.unit = parse_json_string(body, "unit");
300 meas.label = parse_json_string(body, "label");
301 meas.created_at = std::chrono::system_clock::now();
302
303 if (meas.sop_instance_uid.empty()) {
304 res.code = 400;
305 res.body =
306 make_error_json("MISSING_FIELD", "sop_instance_uid is required");
307 return res;
308 }
309
310#ifdef PACS_WITH_DATABASE_SYSTEM
311 storage::measurement_repository repo(ctx->database->db_adapter());
312#else
313 storage::measurement_repository repo(ctx->database->native_handle());
314#endif
315 auto save_result = repo.save(meas);
316 if (!save_result.is_ok()) {
317 res.code = 500;
318 res.body =
319 make_error_json("SAVE_ERROR", save_result.error().message);
320 return res;
321 }
322
323 res.code = 201;
324 std::ostringstream oss;
325 oss << R"({"measurement_id":")" << json_escape(meas.measurement_id)
326 << R"(","value":)" << meas.value << R"(,"unit":")"
327 << json_escape(meas.unit) << R"("})";
328 res.body = oss.str();
329 return res;
330 });
331
332 // GET /api/v1/measurements - List measurements
333 CROW_ROUTE(app, "/api/v1/measurements")
334 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
335 crow::response res;
336 res.add_header("Content-Type", "application/json");
337 add_cors_headers(res, *ctx);
338
339 if (!ctx->database) {
340 res.code = 503;
341 res.body =
342 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
343 return res;
344 }
345
346 auto [limit, offset] = parse_pagination(req);
347
348 storage::measurement_query query;
349 query.limit = limit;
350 query.offset = offset;
351
352 auto sop_instance_uid = req.url_params.get("sop_instance_uid");
353 if (sop_instance_uid) {
354 query.sop_instance_uid = sop_instance_uid;
355 }
356 auto study_uid = req.url_params.get("study_uid");
357 if (study_uid) {
358 query.study_uid = study_uid;
359 }
360 auto user_id = req.url_params.get("user_id");
361 if (user_id) {
362 query.user_id = user_id;
363 }
364 auto measurement_type = req.url_params.get("measurement_type");
365 if (measurement_type) {
366 auto type_opt = storage::measurement_type_from_string(measurement_type);
367 if (type_opt.has_value()) {
368 query.type = type_opt.value();
369 }
370 }
371
372#ifdef PACS_WITH_DATABASE_SYSTEM
373 storage::measurement_repository repo(ctx->database->db_adapter());
374
375 storage::measurement_query count_query = query;
376 count_query.limit = 0;
377 count_query.offset = 0;
378 auto count_result = repo.count(count_query);
379 if (!count_result.is_ok()) {
380 res.code = 500;
381 res.body = make_error_json("COUNT_ERROR", count_result.error().message);
382 return res;
383 }
384 size_t total_count = count_result.value();
385
386 auto measurements_result = repo.search(query);
387 if (!measurements_result.is_ok()) {
388 res.code = 500;
389 res.body = make_error_json("QUERY_ERROR", measurements_result.error().message);
390 return res;
391 }
392 res.code = 200;
393 res.body = measurements_to_json(measurements_result.value(), total_count);
394#else
395 storage::measurement_repository repo(ctx->database->native_handle());
396
397 storage::measurement_query count_query = query;
398 count_query.limit = 0;
399 count_query.offset = 0;
400 size_t total_count = repo.count(count_query);
401
402 auto measurements = repo.search(query);
403 res.code = 200;
404 res.body = measurements_to_json(measurements, total_count);
405#endif
406 return res;
407 });
408
409 // GET /api/v1/measurements/<measurementId> - Get measurement by ID
410 CROW_ROUTE(app, "/api/v1/measurements/<string>")
411 .methods(crow::HTTPMethod::GET)(
412 [ctx](const crow::request & /*req*/, const std::string &measurement_id) {
413 crow::response res;
414 res.add_header("Content-Type", "application/json");
415 add_cors_headers(res, *ctx);
416
417 if (!ctx->database) {
418 res.code = 503;
419 res.body = make_error_json("DATABASE_UNAVAILABLE",
420 "Database not configured");
421 return res;
422 }
423
424#ifdef PACS_WITH_DATABASE_SYSTEM
425 storage::measurement_repository repo(ctx->database->db_adapter());
426 auto meas_result = repo.find_by_id(measurement_id);
427 if (!meas_result.is_ok()) {
428 // Check if it's a NOT_FOUND error
429 if (meas_result.error().message.find("not found") != std::string::npos ||
430 meas_result.error().message.find("NOT_FOUND") != std::string::npos) {
431 res.code = 404;
432 res.body = make_error_json("NOT_FOUND", "Measurement not found");
433 } else {
434 res.code = 500;
435 res.body = make_error_json("QUERY_ERROR", meas_result.error().message);
436 }
437 return res;
438 }
439 res.code = 200;
440 res.body = measurement_to_json(meas_result.value());
441#else
442 storage::measurement_repository repo(ctx->database->native_handle());
443 auto meas_result = repo.find_by_id(measurement_id);
444 if (!meas_result.has_value()) {
445 res.code = 404;
446 res.body = make_error_json("NOT_FOUND", "Measurement not found");
447 return res;
448 }
449 res.code = 200;
450 res.body = measurement_to_json(meas_result.value());
451#endif
452 return res;
453 });
454
455 // DELETE /api/v1/measurements/<measurementId> - Delete measurement
456 CROW_ROUTE(app, "/api/v1/measurements/<string>")
457 .methods(crow::HTTPMethod::DELETE)(
458 [ctx](const crow::request & /*req*/, const std::string &measurement_id) {
459 crow::response res;
460 add_cors_headers(res, *ctx);
461
462 if (!ctx->database) {
463 res.code = 503;
464 res.add_header("Content-Type", "application/json");
465 res.body = make_error_json("DATABASE_UNAVAILABLE",
466 "Database not configured");
467 return res;
468 }
469
470#ifdef PACS_WITH_DATABASE_SYSTEM
471 storage::measurement_repository repo(ctx->database->db_adapter());
472 auto exists_result = repo.exists(measurement_id);
473 if (!exists_result.is_ok()) {
474 res.code = 500;
475 res.add_header("Content-Type", "application/json");
476 res.body = make_error_json("QUERY_ERROR", exists_result.error().message);
477 return res;
478 }
479 if (!exists_result.value()) {
480 res.code = 404;
481 res.add_header("Content-Type", "application/json");
482 res.body = make_error_json("NOT_FOUND", "Measurement not found");
483 return res;
484 }
485#else
486 storage::measurement_repository repo(ctx->database->native_handle());
487 if (!repo.exists(measurement_id)) {
488 res.code = 404;
489 res.add_header("Content-Type", "application/json");
490 res.body = make_error_json("NOT_FOUND", "Measurement not found");
491 return res;
492 }
493#endif
494
495 auto remove_result = repo.remove(measurement_id);
496 if (!remove_result.is_ok()) {
497 res.code = 500;
498 res.add_header("Content-Type", "application/json");
499 res.body =
500 make_error_json("DELETE_ERROR", remove_result.error().message);
501 return res;
502 }
503
504 res.code = 204;
505 return res;
506 });
507
508 // GET /api/v1/instances/<sopInstanceUid>/measurements - Get measurements for instance
509 CROW_ROUTE(app, "/api/v1/instances/<string>/measurements")
510 .methods(crow::HTTPMethod::GET)(
511 [ctx](const crow::request & /*req*/,
512 const std::string &sop_instance_uid) {
513 crow::response res;
514 res.add_header("Content-Type", "application/json");
515 add_cors_headers(res, *ctx);
516
517 if (!ctx->database) {
518 res.code = 503;
519 res.body = make_error_json("DATABASE_UNAVAILABLE",
520 "Database not configured");
521 return res;
522 }
523
524#ifdef PACS_WITH_DATABASE_SYSTEM
525 storage::measurement_repository repo(ctx->database->db_adapter());
526 auto measurements_result = repo.find_by_instance(sop_instance_uid);
527 if (!measurements_result.is_ok()) {
528 res.code = 500;
529 res.body = make_error_json("QUERY_ERROR", measurements_result.error().message);
530 return res;
531 }
532 const auto& measurements = measurements_result.value();
533#else
534 storage::measurement_repository repo(ctx->database->native_handle());
535 auto measurements = repo.find_by_instance(sop_instance_uid);
536#endif
537
538 std::ostringstream oss;
539 oss << R"({"data":[)";
540 for (size_t i = 0; i < measurements.size(); ++i) {
541 if (i > 0) {
542 oss << ",";
543 }
544 oss << measurement_to_json(measurements[i]);
545 }
546 oss << "]}";
547
548 res.code = 200;
549 res.body = oss.str();
550 return res;
551 });
552}
measurement_type
Measurement types supported by the system.
std::string code
Code value (e.g., "110114")
Measurement record from the database.
std::string unit
Unit of measurement (mm, cm2, degrees, HU, g/ml, etc.)
std::string measurement_id
Unique measurement identifier (UUID)
std::string geometry_json
Geometry data as JSON string (coordinates)
std::chrono::system_clock::time_point created_at
Record creation timestamp.
std::string sop_instance_uid
SOP Instance UID - DICOM tag (0008,0018)
measurement_type type
Type of measurement.
std::string label
Optional label/description.
std::optional< int > frame_number
Frame number for multi-frame images (1-based)
double value
Calculated measurement value.
std::string user_id
User who created the measurement.

References kcenon::pacs::storage::measurement_repository::count(), kcenon::pacs::storage::measurement_record::created_at, kcenon::pacs::storage::measurement_repository::exists(), kcenon::pacs::storage::measurement_repository::find_by_id(), kcenon::pacs::storage::measurement_repository::find_by_instance(), kcenon::pacs::storage::measurement_record::frame_number, kcenon::pacs::storage::measurement_record::geometry_json, kcenon::pacs::web::json_escape(), kcenon::pacs::storage::measurement_record::label, kcenon::pacs::storage::measurement_query::limit, kcenon::pacs::web::make_error_json(), kcenon::pacs::storage::measurement_record::measurement_id, kcenon::pacs::storage::measurement_type_from_string(), kcenon::pacs::storage::measurement_query::offset, kcenon::pacs::storage::measurement_repository::remove(), kcenon::pacs::storage::measurement_repository::save(), kcenon::pacs::storage::measurement_repository::search(), kcenon::pacs::storage::measurement_record::sop_instance_uid, kcenon::pacs::storage::measurement_record::type, kcenon::pacs::storage::measurement_record::unit, kcenon::pacs::storage::measurement_record::user_id, and kcenon::pacs::storage::measurement_record::value.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_metadata_endpoints_impl()

void kcenon::pacs::web::endpoints::register_metadata_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 308 of file metadata_endpoints.cpp.

309 {
310 // Initialize metadata service if database is available
311 if (ctx->database != nullptr && g_metadata_service == nullptr) {
312 g_metadata_service = std::make_shared<metadata_service>(ctx->database);
313 }
314
315 // =========================================================================
316 // Selective Metadata
317 // =========================================================================
318
319 // GET /api/v1/instances/{sopInstanceUid}/metadata
320 CROW_ROUTE(app, "/api/v1/instances/<string>/metadata")
321 .methods(crow::HTTPMethod::GET)(
322 [ctx](const crow::request& req, const std::string& sop_uid) {
323 crow::response res;
324 res.add_header("Content-Type", "application/json");
325 add_cors_headers(res, *ctx);
326
327 if (g_metadata_service == nullptr) {
328 res.code = 503;
329 res.body = make_error_json("SERVICE_UNAVAILABLE",
330 "Metadata service not configured");
331 return res;
332 }
333
334 auto request = parse_metadata_request(req);
335 auto result = g_metadata_service->get_metadata(sop_uid, request);
336
337 if (!result.success) {
338 res.code = 404;
339 res.body = make_error_json("NOT_FOUND", result.error_message);
340 return res;
341 }
342
343 res.code = 200;
344 res.body = metadata_response_to_json(result);
345 return res;
346 });
347
348 // =========================================================================
349 // Series Navigation
350 // =========================================================================
351
352 // GET /api/v1/series/{seriesUid}/instances/sorted
353 CROW_ROUTE(app, "/api/v1/series/<string>/instances/sorted")
354 .methods(crow::HTTPMethod::GET)(
355 [ctx](const crow::request& req, const std::string& series_uid) {
356 crow::response res;
357 res.add_header("Content-Type", "application/json");
358 add_cors_headers(res, *ctx);
359
360 if (g_metadata_service == nullptr) {
361 res.code = 503;
362 res.body = make_error_json("SERVICE_UNAVAILABLE",
363 "Metadata service not configured");
364 return res;
365 }
366
367 // Parse sort parameters
368 sort_order order = sort_order::position;
369 auto sort_param = req.url_params.get("sort_by");
370 if (sort_param != nullptr) {
371 auto parsed = sort_order_from_string(sort_param);
372 if (parsed.has_value()) {
373 order = parsed.value();
374 }
375 }
376
377 bool ascending = true;
378 auto dir_param = req.url_params.get("direction");
379 if (dir_param != nullptr) {
380 std::string dir = dir_param;
381 ascending = (dir != "desc");
382 }
383
384 auto result = g_metadata_service->get_sorted_instances(
385 series_uid, order, ascending);
386
387 if (!result.success) {
388 res.code = 404;
389 res.body = make_error_json("NOT_FOUND", result.error_message);
390 return res;
391 }
392
393 res.code = 200;
394 res.body = sorted_instances_to_json(result);
395 return res;
396 });
397
398 // GET /api/v1/instances/{sopInstanceUid}/navigation
399 CROW_ROUTE(app, "/api/v1/instances/<string>/navigation")
400 .methods(crow::HTTPMethod::GET)(
401 [ctx](const crow::request& /*req*/, const std::string& sop_uid) {
402 crow::response res;
403 res.add_header("Content-Type", "application/json");
404 add_cors_headers(res, *ctx);
405
406 if (g_metadata_service == nullptr) {
407 res.code = 503;
408 res.body = make_error_json("SERVICE_UNAVAILABLE",
409 "Metadata service not configured");
410 return res;
411 }
412
413 auto result = g_metadata_service->get_navigation(sop_uid);
414
415 if (!result.success) {
416 res.code = 404;
417 res.body = make_error_json("NOT_FOUND", result.error_message);
418 return res;
419 }
420
421 res.code = 200;
422 res.body = navigation_info_to_json(result);
423 return res;
424 });
425
426 // =========================================================================
427 // Window/Level Presets
428 // =========================================================================
429
430 // GET /api/v1/presets/window-level
431 CROW_ROUTE(app, "/api/v1/presets/window-level")
432 .methods(crow::HTTPMethod::GET)([ctx](const crow::request& req) {
433 crow::response res;
434 res.add_header("Content-Type", "application/json");
435 add_cors_headers(res, *ctx);
436
437 std::string modality = "CT"; // Default modality
438 auto modality_param = req.url_params.get("modality");
439 if (modality_param != nullptr) {
440 modality = modality_param;
441 }
442
443 auto presets = metadata_service::get_window_level_presets(modality);
444
445 res.code = 200;
446 res.body = presets_to_json(presets);
447 return res;
448 });
449
450 // GET /api/v1/instances/{sopInstanceUid}/voi-lut
451 CROW_ROUTE(app, "/api/v1/instances/<string>/voi-lut")
452 .methods(crow::HTTPMethod::GET)(
453 [ctx](const crow::request& /*req*/, const std::string& sop_uid) {
454 crow::response res;
455 res.add_header("Content-Type", "application/json");
456 add_cors_headers(res, *ctx);
457
458 if (g_metadata_service == nullptr) {
459 res.code = 503;
460 res.body = make_error_json("SERVICE_UNAVAILABLE",
461 "Metadata service not configured");
462 return res;
463 }
464
465 auto result = g_metadata_service->get_voi_lut(sop_uid);
466
467 if (!result.success) {
468 res.code = 404;
469 res.body = make_error_json("NOT_FOUND", result.error_message);
470 return res;
471 }
472
473 res.code = 200;
474 res.body = voi_lut_to_json(result);
475 return res;
476 });
477
478 // =========================================================================
479 // Multi-frame Support
480 // =========================================================================
481
482 // GET /api/v1/instances/{sopInstanceUid}/frame-info
483 CROW_ROUTE(app, "/api/v1/instances/<string>/frame-info")
484 .methods(crow::HTTPMethod::GET)(
485 [ctx](const crow::request& /*req*/, const std::string& sop_uid) {
486 crow::response res;
487 res.add_header("Content-Type", "application/json");
488 add_cors_headers(res, *ctx);
489
490 if (g_metadata_service == nullptr) {
491 res.code = 503;
492 res.body = make_error_json("SERVICE_UNAVAILABLE",
493 "Metadata service not configured");
494 return res;
495 }
496
497 auto result = g_metadata_service->get_frame_info(sop_uid);
498
499 if (!result.success) {
500 res.code = 404;
501 res.body = make_error_json("NOT_FOUND", result.error_message);
502 return res;
503 }
504
505 res.code = 200;
506 res.body = frame_info_to_json(result);
507 return res;
508 });
509}
constexpr dicom_tag modality
Modality.
sort_order
Sort order for series instances.
std::optional< sort_order > sort_order_from_string(std::string_view str)
Parse sort order from string.

References kcenon::pacs::web::metadata_service::get_window_level_presets(), kcenon::pacs::web::make_error_json(), kcenon::pacs::web::position, and kcenon::pacs::web::sort_order_from_string().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_patient_endpoints_impl()

void kcenon::pacs::web::endpoints::register_patient_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 144 of file patient_endpoints.cpp.

145 {
146 // GET /api/v1/patients - List patients (paginated)
147 CROW_ROUTE(app, "/api/v1/patients")
148 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
149 crow::response res;
150 res.add_header("Content-Type", "application/json");
151 add_cors_headers(res, *ctx);
152
153 if (!ctx->database) {
154 res.code = 503;
155 res.body =
156 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
157 return res;
158 }
159
160 // Parse pagination
161 auto [limit, offset] = parse_pagination(req);
162
163 // Build query from URL parameters
165 query.limit = limit;
166 query.offset = offset;
167
168 auto patient_id = req.url_params.get("patient_id");
169 if (patient_id) {
170 query.patient_id = patient_id;
171 }
172
173 auto patient_name = req.url_params.get("patient_name");
174 if (patient_name) {
175 query.patient_name = patient_name;
176 }
177
178 auto birth_date = req.url_params.get("birth_date");
179 if (birth_date) {
180 query.birth_date = birth_date;
181 }
182
183 auto birth_date_from = req.url_params.get("birth_date_from");
184 if (birth_date_from) {
185 query.birth_date_from = birth_date_from;
186 }
187
188 auto birth_date_to = req.url_params.get("birth_date_to");
189 if (birth_date_to) {
190 query.birth_date_to = birth_date_to;
191 }
192
193 auto sex = req.url_params.get("sex");
194 if (sex) {
195 query.sex = sex;
196 }
197
198 // Get total count (without pagination for accurate count)
199 storage::patient_query count_query = query;
200 count_query.limit = 0;
201 count_query.offset = 0;
202 auto all_patients_result = ctx->database->search_patients(count_query);
203 if (!all_patients_result.is_ok()) {
204 res.code = 500;
205 res.body = make_error_json("QUERY_ERROR",
206 all_patients_result.error().message);
207 return res;
208 }
209 size_t total_count = all_patients_result.value().size();
210
211 // Get paginated results
212 auto patients_result = ctx->database->search_patients(query);
213 if (!patients_result.is_ok()) {
214 res.code = 500;
215 res.body = make_error_json("QUERY_ERROR",
216 patients_result.error().message);
217 return res;
218 }
219
220 res.code = 200;
221 res.body = patients_to_json(patients_result.value(), total_count);
222 return res;
223 });
224
225 // GET /api/v1/patients/:id - Get patient details
226 CROW_ROUTE(app, "/api/v1/patients/<string>")
227 .methods(crow::HTTPMethod::GET)(
228 [ctx](const crow::request & /*req*/, const std::string &patient_id) {
229 crow::response res;
230 res.add_header("Content-Type", "application/json");
231 add_cors_headers(res, *ctx);
232
233 if (!ctx->database) {
234 res.code = 503;
235 res.body = make_error_json("DATABASE_UNAVAILABLE",
236 "Database not configured");
237 return res;
238 }
239
240 auto patient = ctx->database->find_patient(patient_id);
241 if (!patient) {
242 res.code = 404;
243 res.body = make_error_json("NOT_FOUND", "Patient not found");
244 return res;
245 }
246
247 res.code = 200;
248 res.body = patient_to_json(*patient);
249 return res;
250 });
251
252 // GET /api/v1/patients/:id/studies - Get patient's studies
253 CROW_ROUTE(app, "/api/v1/patients/<string>/studies")
254 .methods(crow::HTTPMethod::GET)(
255 [ctx](const crow::request & /*req*/, const std::string &patient_id) {
256 crow::response res;
257 res.add_header("Content-Type", "application/json");
258 add_cors_headers(res, *ctx);
259
260 if (!ctx->database) {
261 res.code = 503;
262 res.body = make_error_json("DATABASE_UNAVAILABLE",
263 "Database not configured");
264 return res;
265 }
266
267 // Verify patient exists
268 auto patient = ctx->database->find_patient(patient_id);
269 if (!patient) {
270 res.code = 404;
271 res.body = make_error_json("NOT_FOUND", "Patient not found");
272 return res;
273 }
274
275 auto studies_result = ctx->database->list_studies(patient_id);
276 if (!studies_result.is_ok()) {
277 res.code = 500;
278 res.body = make_error_json("QUERY_ERROR",
279 studies_result.error().message);
280 return res;
281 }
282
283 res.code = 200;
284 res.body = studies_to_json(studies_result.value());
285 return res;
286 });
287}

References kcenon::pacs::storage::patient_query::limit, kcenon::pacs::web::make_error_json(), and kcenon::pacs::storage::patient_query::offset.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_remote_nodes_endpoints_impl()

void kcenon::pacs::web::endpoints::register_remote_nodes_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 283 of file remote_nodes_endpoints.cpp.

284 {
285 // GET /api/v1/remote-nodes - List remote nodes (paginated)
286 CROW_ROUTE(app, "/api/v1/remote-nodes")
287 .methods(crow::HTTPMethod::GET)([ctx](const crow::request& req) {
288 crow::response res;
289 res.add_header("Content-Type", "application/json");
290 add_cors_headers(res, *ctx);
291
292 if (!ctx->node_manager) {
293 res.code = 503;
294 res.body = make_error_json("SERVICE_UNAVAILABLE",
295 "Remote node manager not configured");
296 return res;
297 }
298
299 // Parse pagination
300 auto [limit, offset] = parse_pagination(req);
301
302 // Filter by status if provided
303 auto status_param = req.url_params.get("status");
304 std::vector<client::remote_node> nodes;
305
306 if (status_param) {
307 auto status = client::node_status_from_string(status_param);
308 nodes = ctx->node_manager->list_nodes_by_status(status);
309 } else {
310 nodes = ctx->node_manager->list_nodes();
311 }
312
313 size_t total_count = nodes.size();
314
315 // Apply pagination
316 std::vector<client::remote_node> paginated;
317 for (size_t i = offset; i < nodes.size() && paginated.size() < limit; ++i) {
318 paginated.push_back(nodes[i]);
319 }
320
321 res.code = 200;
322 res.body = nodes_to_json(paginated, total_count);
323 return res;
324 });
325
326 // POST /api/v1/remote-nodes - Create a new remote node
327 CROW_ROUTE(app, "/api/v1/remote-nodes")
328 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& req) {
329 crow::response res;
330 res.add_header("Content-Type", "application/json");
331 add_cors_headers(res, *ctx);
332
333 if (!ctx->node_manager) {
334 res.code = 503;
335 res.body = make_error_json("SERVICE_UNAVAILABLE",
336 "Remote node manager not configured");
337 return res;
338 }
339
340 std::string error_message;
341 auto node_opt = parse_node_from_json(req.body, error_message);
342 if (!node_opt) {
343 res.code = 400;
344 res.body = make_error_json("INVALID_REQUEST", error_message);
345 return res;
346 }
347
348 auto& node = *node_opt;
349
350 // Generate node_id if not provided
351 if (node.node_id.empty()) {
352 // Use ae_title + timestamp as default node_id
353 auto now = std::chrono::system_clock::now();
354 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
355 now.time_since_epoch()).count();
356 node.node_id = node.ae_title + "_" + std::to_string(ms);
357 }
358
359 auto result = ctx->node_manager->add_node(node);
360 if (!result.is_ok()) {
361 res.code = 409;
362 res.body = make_error_json("CONFLICT", result.error().message);
363 return res;
364 }
365
366 // Retrieve the created node to get full details
367 auto created_node = ctx->node_manager->get_node(node.node_id);
368 if (!created_node) {
369 res.code = 201;
370 res.body = node_to_json(node);
371 return res;
372 }
373
374 res.code = 201;
375 res.body = node_to_json(*created_node);
376 return res;
377 });
378
379 // GET /api/v1/remote-nodes/<nodeId> - Get a specific remote node
380 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>")
381 .methods(crow::HTTPMethod::GET)(
382 [ctx](const crow::request& /*req*/, const std::string& node_id) {
383 crow::response res;
384 res.add_header("Content-Type", "application/json");
385 add_cors_headers(res, *ctx);
386
387 if (!ctx->node_manager) {
388 res.code = 503;
389 res.body = make_error_json("SERVICE_UNAVAILABLE",
390 "Remote node manager not configured");
391 return res;
392 }
393
394 auto node = ctx->node_manager->get_node(node_id);
395 if (!node) {
396 res.code = 404;
397 res.body = make_error_json("NOT_FOUND", "Remote node not found");
398 return res;
399 }
400
401 res.code = 200;
402 res.body = node_to_json(*node);
403 return res;
404 });
405
406 // PUT /api/v1/remote-nodes/<nodeId> - Update a remote node
407 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>")
408 .methods(crow::HTTPMethod::PUT)(
409 [ctx](const crow::request& req, const std::string& node_id) {
410 crow::response res;
411 res.add_header("Content-Type", "application/json");
412 add_cors_headers(res, *ctx);
413
414 if (!ctx->node_manager) {
415 res.code = 503;
416 res.body = make_error_json("SERVICE_UNAVAILABLE",
417 "Remote node manager not configured");
418 return res;
419 }
420
421 // Check if node exists
422 auto existing = ctx->node_manager->get_node(node_id);
423 if (!existing) {
424 res.code = 404;
425 res.body = make_error_json("NOT_FOUND", "Remote node not found");
426 return res;
427 }
428
429 std::string error_message;
430 auto node_opt = parse_node_from_json(req.body, error_message);
431 if (!node_opt) {
432 res.code = 400;
433 res.body = make_error_json("INVALID_REQUEST", error_message);
434 return res;
435 }
436
437 auto& node = *node_opt;
438 node.node_id = node_id; // Preserve the node_id from URL
439
440 auto result = ctx->node_manager->update_node(node);
441 if (!result.is_ok()) {
442 res.code = 500;
443 res.body = make_error_json("UPDATE_FAILED", result.error().message);
444 return res;
445 }
446
447 // Retrieve the updated node
448 auto updated_node = ctx->node_manager->get_node(node_id);
449 if (!updated_node) {
450 res.code = 200;
451 res.body = node_to_json(node);
452 return res;
453 }
454
455 res.code = 200;
456 res.body = node_to_json(*updated_node);
457 return res;
458 });
459
460 // DELETE /api/v1/remote-nodes/<nodeId> - Delete a remote node
461 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>")
462 .methods(crow::HTTPMethod::DELETE)(
463 [ctx](const crow::request& /*req*/, const std::string& node_id) {
464 crow::response res;
465 add_cors_headers(res, *ctx);
466
467 if (!ctx->node_manager) {
468 res.add_header("Content-Type", "application/json");
469 res.code = 503;
470 res.body = make_error_json("SERVICE_UNAVAILABLE",
471 "Remote node manager not configured");
472 return res;
473 }
474
475 // Check if node exists
476 auto existing = ctx->node_manager->get_node(node_id);
477 if (!existing) {
478 res.add_header("Content-Type", "application/json");
479 res.code = 404;
480 res.body = make_error_json("NOT_FOUND", "Remote node not found");
481 return res;
482 }
483
484 auto result = ctx->node_manager->remove_node(node_id);
485 if (!result.is_ok()) {
486 res.add_header("Content-Type", "application/json");
487 res.code = 500;
488 res.body = make_error_json("DELETE_FAILED", result.error().message);
489 return res;
490 }
491
492 res.code = 204;
493 return res;
494 });
495
496 // POST /api/v1/remote-nodes/<nodeId>/verify - Verify node connectivity
497 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>/verify")
498 .methods(crow::HTTPMethod::POST)(
499 [ctx](const crow::request& /*req*/, const std::string& node_id) {
500 crow::response res;
501 res.add_header("Content-Type", "application/json");
502 add_cors_headers(res, *ctx);
503
504 if (!ctx->node_manager) {
505 res.code = 503;
506 res.body = make_error_json("SERVICE_UNAVAILABLE",
507 "Remote node manager not configured");
508 return res;
509 }
510
511 // Check if node exists
512 auto existing = ctx->node_manager->get_node(node_id);
513 if (!existing) {
514 res.code = 404;
515 res.body = make_error_json("NOT_FOUND", "Remote node not found");
516 return res;
517 }
518
519 auto start = std::chrono::steady_clock::now();
520 auto result = ctx->node_manager->verify_node(node_id);
521 auto end = std::chrono::steady_clock::now();
522 auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
523 end - start).count();
524
525 if (result.is_ok()) {
526 std::ostringstream oss;
527 oss << R"({"success":true,"response_time_ms":)" << elapsed_ms << '}';
528 res.code = 200;
529 res.body = oss.str();
530 } else {
531 std::ostringstream oss;
532 oss << R"({"success":false,"error":")"
533 << json_escape(result.error().message)
534 << R"(","response_time_ms":)" << elapsed_ms << '}';
535 res.code = 200;
536 res.body = oss.str();
537 }
538 return res;
539 });
540
541 // GET /api/v1/remote-nodes/<nodeId>/status - Get node status
542 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>/status")
543 .methods(crow::HTTPMethod::GET)(
544 [ctx](const crow::request& /*req*/, const std::string& node_id) {
545 crow::response res;
546 res.add_header("Content-Type", "application/json");
547 add_cors_headers(res, *ctx);
548
549 if (!ctx->node_manager) {
550 res.code = 503;
551 res.body = make_error_json("SERVICE_UNAVAILABLE",
552 "Remote node manager not configured");
553 return res;
554 }
555
556 auto node = ctx->node_manager->get_node(node_id);
557 if (!node) {
558 res.code = 404;
559 res.body = make_error_json("NOT_FOUND", "Remote node not found");
560 return res;
561 }
562
563 auto stats = ctx->node_manager->get_statistics(node_id);
564
565 std::ostringstream oss;
566 oss << R"({"status":")" << client::to_string(node->status) << '"';
567
568 auto last_verified = format_timestamp(node->last_verified);
569 if (!last_verified.empty()) {
570 oss << R"(,"last_verified":")" << last_verified << '"';
571 }
572
573 if (!node->last_error_message.empty()) {
574 oss << R"(,"last_error_message":")"
575 << json_escape(node->last_error_message) << '"';
576 }
577
578 oss << R"(,"total_connections":)" << stats.total_connections
579 << R"(,"active_connections":)" << stats.active_connections
580 << R"(,"successful_operations":)" << stats.successful_operations
581 << R"(,"failed_operations":)" << stats.failed_operations;
582
583 if (stats.avg_response_time.count() > 0) {
584 oss << R"(,"avg_response_time_ms":)" << stats.avg_response_time.count();
585 }
586
587 oss << '}';
588
589 res.code = 200;
590 res.body = oss.str();
591 return res;
592 });
593
594 // POST /api/v1/remote-nodes/<nodeId>/query - Query remote PACS
595 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>/query")
596 .methods(crow::HTTPMethod::POST)(
597 [ctx](const crow::request& req, const std::string& node_id) {
598 crow::response res;
599 res.add_header("Content-Type", "application/json");
600 add_cors_headers(res, *ctx);
601
602 if (!ctx->node_manager) {
603 res.code = 503;
604 res.body = make_error_json("SERVICE_UNAVAILABLE",
605 "Remote node manager not configured");
606 return res;
607 }
608
609 auto node = ctx->node_manager->get_node(node_id);
610 if (!node) {
611 res.code = 404;
612 res.body = make_error_json("NOT_FOUND", "Remote node not found");
613 return res;
614 }
615
616 if (!node->supports_find) {
617 res.code = 400;
618 res.body = make_error_json("UNSUPPORTED",
619 "Remote node does not support C-FIND queries");
620 return res;
621 }
622
623 // Parse query parameters from request body
624 auto get_str = [&req](const std::string& key) -> std::string {
625 std::string search = "\"" + key + "\":\"";
626 auto pos = req.body.find(search);
627 if (pos == std::string::npos) return "";
628 pos += search.length();
629 auto end_pos = req.body.find('"', pos);
630 if (end_pos == std::string::npos) return "";
631 return req.body.substr(pos, end_pos - pos);
632 };
633
634 auto level = get_str("level");
635 if (level.empty()) level = "STUDY";
636
637 // Determine calling AE title from dicom_server config
638 std::string calling_ae = "PACS_SCU";
639 if (ctx->dicom_server) {
640 calling_ae = ctx->dicom_server->config().ae_title;
641 }
642
643 // Build association config for C-FIND
644 network::association_config assoc_config;
645 assoc_config.calling_ae_title = calling_ae;
646 assoc_config.called_ae_title = node->ae_title;
647 assoc_config.proposed_contexts.push_back({
648 1,
649 std::string(services::study_root_find_sop_class_uid),
650 {"1.2.840.10008.1.2.1"} // Explicit VR Little Endian
651 });
652
653 auto timeout = std::chrono::milliseconds(
654 node->connection_timeout.count() * 1000);
655 auto assoc_result = network::association::connect(
656 node->host,
657 static_cast<uint16_t>(node->port),
658 assoc_config,
659 timeout);
660
661 if (assoc_result.is_err()) {
662 res.code = 502;
663 res.body = make_error_json("CONNECTION_FAILED",
664 "Failed to connect to remote PACS: " +
665 assoc_result.error().message);
666 return res;
667 }
668
669 auto& assoc = assoc_result.value();
670 services::query_scu scu;
671
672 // Helper to format query results as JSON
673 auto format_query_response = [](
674 const services::query_result& qr) -> std::string {
675 std::ostringstream oss;
676 oss << R"({"matches":[)";
677 for (size_t i = 0; i < qr.matches.size(); ++i) {
678 if (i > 0) oss << ',';
679 oss << '{';
680 bool first_field = true;
681 for (const auto& [tag, element] : qr.matches[i]) {
682 auto val = element.as_string();
683 if (val.is_err()) continue;
684 if (!first_field) oss << ',';
685 first_field = false;
686 oss << '"' << json_escape(tag.to_string())
687 << R"(":")" << json_escape(val.value()) << '"';
688 }
689 oss << '}';
690 }
691 oss << R"(],"total_matches":)" << qr.matches.size()
692 << R"(,"elapsed_ms":)" << qr.elapsed.count()
693 << R"(,"status":")"
694 << (qr.is_success() ? "success" : "error")
695 << R"("})";
696 return oss.str();
697 };
698
699 // Helper to handle query result and build response
700 auto handle_result = [&](
701 network::Result<services::query_result>& result) {
702 (void)assoc.release();
703 if (result.is_err()) {
704 res.code = 502;
705 res.body = make_error_json("QUERY_FAILED",
706 "C-FIND query failed: " + result.error().message);
707 } else {
708 res.code = 200;
709 res.body = format_query_response(result.value());
710 }
711 };
712
713 // Execute query based on level
714 if (level == "PATIENT") {
715 services::patient_query_keys keys;
716 keys.patient_name = get_str("patient_name");
717 keys.patient_id = get_str("patient_id");
718 keys.birth_date = get_str("birth_date");
719 keys.sex = get_str("sex");
720 auto result = scu.find_patients(assoc, keys);
721 handle_result(result);
722 } else if (level == "STUDY") {
723 services::study_query_keys keys;
724 keys.patient_id = get_str("patient_id");
725 keys.study_uid = get_str("study_uid");
726 keys.study_date = get_str("study_date");
727 keys.accession_number = get_str("accession_number");
728 keys.modality = get_str("modality");
729 keys.study_description = get_str("study_description");
730 auto result = scu.find_studies(assoc, keys);
731 handle_result(result);
732 } else if (level == "SERIES") {
733 services::series_query_keys keys;
734 keys.study_uid = get_str("study_uid");
735 keys.series_uid = get_str("series_uid");
736 keys.modality = get_str("modality");
737 keys.series_number = get_str("series_number");
738 if (keys.study_uid.empty()) {
739 (void)assoc.release();
740 res.code = 400;
741 res.body = make_error_json("INVALID_REQUEST",
742 "study_uid is required for SERIES level queries");
743 return res;
744 }
745 auto result = scu.find_series(assoc, keys);
746 handle_result(result);
747 } else if (level == "IMAGE") {
748 services::instance_query_keys keys;
749 keys.series_uid = get_str("series_uid");
750 keys.sop_instance_uid = get_str("sop_instance_uid");
751 keys.instance_number = get_str("instance_number");
752 if (keys.series_uid.empty()) {
753 (void)assoc.release();
754 res.code = 400;
755 res.body = make_error_json("INVALID_REQUEST",
756 "series_uid is required for IMAGE level queries");
757 return res;
758 }
759 auto result = scu.find_instances(assoc, keys);
760 handle_result(result);
761 } else {
762 (void)assoc.release();
763 res.code = 400;
764 res.body = make_error_json("INVALID_REQUEST",
765 "Invalid query level. Use PATIENT, STUDY, SERIES, "
766 "or IMAGE.");
767 return res;
768 }
769
770 return res;
771 });
772
773 // POST /api/v1/remote-nodes/<nodeId>/retrieve - Retrieve from remote PACS
774 CROW_ROUTE(app, "/api/v1/remote-nodes/<string>/retrieve")
775 .methods(crow::HTTPMethod::POST)(
776 [ctx](const crow::request& req, const std::string& node_id) {
777 crow::response res;
778 res.add_header("Content-Type", "application/json");
779 add_cors_headers(res, *ctx);
780
781 if (!ctx->node_manager) {
782 res.code = 503;
783 res.body = make_error_json("SERVICE_UNAVAILABLE",
784 "Remote node manager not configured");
785 return res;
786 }
787
788 if (!ctx->job_manager) {
789 res.code = 503;
790 res.body = make_error_json("SERVICE_UNAVAILABLE",
791 "Job manager not configured");
792 return res;
793 }
794
795 auto node = ctx->node_manager->get_node(node_id);
796 if (!node) {
797 res.code = 404;
798 res.body = make_error_json("NOT_FOUND", "Remote node not found");
799 return res;
800 }
801
802 if (!node->supports_move && !node->supports_get) {
803 res.code = 400;
804 res.body = make_error_json("UNSUPPORTED",
805 "Remote node does not support C-MOVE or C-GET");
806 return res;
807 }
808
809 // Parse retrieve parameters from request body
810 auto get_str = [&req](const std::string& key) -> std::string {
811 std::string search = "\"" + key + "\":\"";
812 auto pos = req.body.find(search);
813 if (pos == std::string::npos) return "";
814 pos += search.length();
815 auto end_pos = req.body.find('"', pos);
816 if (end_pos == std::string::npos) return "";
817 return req.body.substr(pos, end_pos - pos);
818 };
819
820 auto study_uid = get_str("study_uid");
821 if (study_uid.empty()) {
822 res.code = 400;
823 res.body = make_error_json("INVALID_REQUEST",
824 "study_uid is required");
825 return res;
826 }
827
828 auto series_uid = get_str("series_uid");
829 auto priority_str = get_str("priority");
830
831 // Map priority string to enum
832 auto priority = client::job_priority::normal;
833 if (priority_str == "low") {
834 priority = client::job_priority::low;
835 } else if (priority_str == "high") {
836 priority = client::job_priority::high;
837 } else if (priority_str == "urgent") {
838 priority = client::job_priority::urgent;
839 }
840
841 // Create retrieve job
842 std::optional<std::string_view> series_opt;
843 if (!series_uid.empty()) {
844 series_opt = series_uid;
845 }
846
847 auto job_id = ctx->job_manager->create_retrieve_job(
848 node_id, study_uid, series_opt, priority);
849
850 // Start the job
851 auto start_result = ctx->job_manager->start_job(job_id);
852 if (start_result.is_err()) {
853 res.code = 500;
854 res.body = make_error_json("JOB_START_FAILED",
855 "Failed to start retrieve job: " +
856 start_result.error().message);
857 return res;
858 }
859
860 std::ostringstream oss;
861 oss << R"({"job_id":")" << json_escape(job_id)
862 << R"(","status":"pending"})";
863
864 res.code = 202;
865 res.body = oss.str();
866 return res;
867 });
868}
constexpr int timeout
Lock timeout exceeded.

References kcenon::pacs::services::study_query_keys::accession_number, kcenon::pacs::services::patient_query_keys::birth_date, kcenon::pacs::network::association_config::called_ae_title, kcenon::pacs::network::association_config::calling_ae_title, kcenon::pacs::network::association::connect(), kcenon::pacs::services::query_scu::find_instances(), kcenon::pacs::services::query_scu::find_patients(), kcenon::pacs::services::query_scu::find_series(), kcenon::pacs::services::query_scu::find_studies(), kcenon::pacs::client::high, kcenon::pacs::services::instance_query_keys::instance_number, kcenon::pacs::web::json_escape(), kcenon::pacs::client::low, kcenon::pacs::web::make_error_json(), kcenon::pacs::services::series_query_keys::modality, kcenon::pacs::services::study_query_keys::modality, kcenon::pacs::client::node_status_from_string(), kcenon::pacs::client::normal, kcenon::pacs::services::patient_query_keys::patient_id, kcenon::pacs::services::study_query_keys::patient_id, kcenon::pacs::services::patient_query_keys::patient_name, kcenon::pacs::network::association_config::proposed_contexts, kcenon::pacs::services::series_query_keys::series_number, kcenon::pacs::services::instance_query_keys::series_uid, kcenon::pacs::services::series_query_keys::series_uid, kcenon::pacs::services::patient_query_keys::sex, kcenon::pacs::services::instance_query_keys::sop_instance_uid, kcenon::pacs::services::study_query_keys::study_date, kcenon::pacs::services::study_query_keys::study_description, kcenon::pacs::services::study_root_find_sop_class_uid, kcenon::pacs::services::series_query_keys::study_uid, kcenon::pacs::services::study_query_keys::study_uid, kcenon::pacs::client::to_string(), and kcenon::pacs::client::urgent.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_routing_endpoints_impl()

void kcenon::pacs::web::endpoints::register_routing_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 612 of file routing_endpoints.cpp.

613 {
614 // GET /api/v1/routing/rules - List routing rules (paginated)
615 CROW_ROUTE(app, "/api/v1/routing/rules")
616 .methods(crow::HTTPMethod::GET)([ctx](const crow::request& req) {
617 crow::response res;
618 res.add_header("Content-Type", "application/json");
619 add_cors_headers(res, *ctx);
620
621 if (!ctx->routing_manager) {
622 res.code = 503;
623 res.body = make_error_json("SERVICE_UNAVAILABLE",
624 "Routing manager not configured");
625 return res;
626 }
627
628 // Parse pagination
629 auto [limit, offset] = parse_pagination(req);
630
631 // Filter by enabled if provided
632 auto enabled_param = req.url_params.get("enabled");
633 std::vector<client::routing_rule> rules;
634
635 if (enabled_param) {
636 std::string enabled_str(enabled_param);
637 if (enabled_str == "true") {
638 rules = ctx->routing_manager->list_enabled_rules();
639 } else {
640 rules = ctx->routing_manager->list_rules();
641 }
642 } else {
643 rules = ctx->routing_manager->list_rules();
644 }
645
646 size_t total_count = rules.size();
647
648 // Apply pagination
649 std::vector<client::routing_rule> paginated;
650 for (size_t i = offset; i < rules.size() && paginated.size() < limit; ++i) {
651 paginated.push_back(rules[i]);
652 }
653
654 res.code = 200;
655 res.body = rules_to_json(paginated, total_count);
656 return res;
657 });
658
659 // POST /api/v1/routing/rules - Create a new routing rule
660 CROW_ROUTE(app, "/api/v1/routing/rules")
661 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& req) {
662 crow::response res;
663 res.add_header("Content-Type", "application/json");
664 add_cors_headers(res, *ctx);
665
666 if (!ctx->routing_manager) {
667 res.code = 503;
668 res.body = make_error_json("SERVICE_UNAVAILABLE",
669 "Routing manager not configured");
670 return res;
671 }
672
673 std::string error_message;
674 auto rule_opt = parse_rule_from_json(req.body, error_message);
675 if (!rule_opt) {
676 res.code = 400;
677 res.body = make_error_json("INVALID_REQUEST", error_message);
678 return res;
679 }
680
681 auto& rule = *rule_opt;
682
683 // Generate rule_id if not provided
684 if (rule.rule_id.empty()) {
685 auto now = std::chrono::system_clock::now();
686 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
687 now.time_since_epoch()).count();
688 rule.rule_id = "rule_" + std::to_string(ms);
689 }
690
691 auto result = ctx->routing_manager->add_rule(rule);
692 if (!result.is_ok()) {
693 res.code = 409;
694 res.body = make_error_json("CONFLICT", result.error().message);
695 return res;
696 }
697
698 // Retrieve the created rule to get full details
699 auto created_rule = ctx->routing_manager->get_rule(rule.rule_id);
700 if (!created_rule) {
701 res.code = 201;
702 res.body = rule_to_json(rule);
703 return res;
704 }
705
706 res.code = 201;
707 res.body = rule_to_json(*created_rule);
708 return res;
709 });
710
711 // GET /api/v1/routing/rules/<ruleId> - Get a specific routing rule
712 CROW_ROUTE(app, "/api/v1/routing/rules/<string>")
713 .methods(crow::HTTPMethod::GET)(
714 [ctx](const crow::request& /*req*/, const std::string& rule_id) {
715 crow::response res;
716 res.add_header("Content-Type", "application/json");
717 add_cors_headers(res, *ctx);
718
719 if (!ctx->routing_manager) {
720 res.code = 503;
721 res.body = make_error_json("SERVICE_UNAVAILABLE",
722 "Routing manager not configured");
723 return res;
724 }
725
726 auto rule = ctx->routing_manager->get_rule(rule_id);
727 if (!rule) {
728 res.code = 404;
729 res.body = make_error_json("NOT_FOUND", "Routing rule not found");
730 return res;
731 }
732
733 res.code = 200;
734 res.body = rule_to_json(*rule);
735 return res;
736 });
737
738 // PUT /api/v1/routing/rules/<ruleId> - Update a routing rule
739 CROW_ROUTE(app, "/api/v1/routing/rules/<string>")
740 .methods(crow::HTTPMethod::PUT)(
741 [ctx](const crow::request& req, const std::string& rule_id) {
742 crow::response res;
743 res.add_header("Content-Type", "application/json");
744 add_cors_headers(res, *ctx);
745
746 if (!ctx->routing_manager) {
747 res.code = 503;
748 res.body = make_error_json("SERVICE_UNAVAILABLE",
749 "Routing manager not configured");
750 return res;
751 }
752
753 // Check if rule exists
754 auto existing = ctx->routing_manager->get_rule(rule_id);
755 if (!existing) {
756 res.code = 404;
757 res.body = make_error_json("NOT_FOUND", "Routing rule not found");
758 return res;
759 }
760
761 std::string error_message;
762 auto rule_opt = parse_rule_from_json(req.body, error_message);
763 if (!rule_opt) {
764 res.code = 400;
765 res.body = make_error_json("INVALID_REQUEST", error_message);
766 return res;
767 }
768
769 auto& rule = *rule_opt;
770 rule.rule_id = rule_id; // Preserve the rule_id from URL
771
772 auto result = ctx->routing_manager->update_rule(rule);
773 if (!result.is_ok()) {
774 res.code = 500;
775 res.body = make_error_json("UPDATE_FAILED", result.error().message);
776 return res;
777 }
778
779 // Retrieve the updated rule
780 auto updated_rule = ctx->routing_manager->get_rule(rule_id);
781 if (!updated_rule) {
782 res.code = 200;
783 res.body = rule_to_json(rule);
784 return res;
785 }
786
787 res.code = 200;
788 res.body = rule_to_json(*updated_rule);
789 return res;
790 });
791
792 // DELETE /api/v1/routing/rules/<ruleId> - Delete a routing rule
793 CROW_ROUTE(app, "/api/v1/routing/rules/<string>")
794 .methods(crow::HTTPMethod::DELETE)(
795 [ctx](const crow::request& /*req*/, const std::string& rule_id) {
796 crow::response res;
797 add_cors_headers(res, *ctx);
798
799 if (!ctx->routing_manager) {
800 res.add_header("Content-Type", "application/json");
801 res.code = 503;
802 res.body = make_error_json("SERVICE_UNAVAILABLE",
803 "Routing manager not configured");
804 return res;
805 }
806
807 // Check if rule exists
808 auto existing = ctx->routing_manager->get_rule(rule_id);
809 if (!existing) {
810 res.add_header("Content-Type", "application/json");
811 res.code = 404;
812 res.body = make_error_json("NOT_FOUND", "Routing rule not found");
813 return res;
814 }
815
816 auto result = ctx->routing_manager->remove_rule(rule_id);
817 if (!result.is_ok()) {
818 res.add_header("Content-Type", "application/json");
819 res.code = 500;
820 res.body = make_error_json("DELETE_FAILED", result.error().message);
821 return res;
822 }
823
824 res.code = 204;
825 return res;
826 });
827
828 // POST /api/v1/routing/rules/reorder - Reorder routing rules
829 CROW_ROUTE(app, "/api/v1/routing/rules/reorder")
830 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& req) {
831 crow::response res;
832 res.add_header("Content-Type", "application/json");
833 add_cors_headers(res, *ctx);
834
835 if (!ctx->routing_manager) {
836 res.code = 503;
837 res.body = make_error_json("SERVICE_UNAVAILABLE",
838 "Routing manager not configured");
839 return res;
840 }
841
842 auto rule_ids = parse_rule_ids(req.body);
843 if (rule_ids.empty()) {
844 res.code = 400;
845 res.body = make_error_json("INVALID_REQUEST",
846 "rule_ids array is required");
847 return res;
848 }
849
850 auto result = ctx->routing_manager->reorder_rules(rule_ids);
851 if (!result.is_ok()) {
852 res.code = 400;
853 res.body = make_error_json("REORDER_FAILED", result.error().message);
854 return res;
855 }
856
857 res.code = 200;
858 res.body = R"({"status":"success"})";
859 return res;
860 });
861
862 // POST /api/v1/routing/enable - Enable routing globally
863 CROW_ROUTE(app, "/api/v1/routing/enable")
864 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& /*req*/) {
865 crow::response res;
866 res.add_header("Content-Type", "application/json");
867 add_cors_headers(res, *ctx);
868
869 if (!ctx->routing_manager) {
870 res.code = 503;
871 res.body = make_error_json("SERVICE_UNAVAILABLE",
872 "Routing manager not configured");
873 return res;
874 }
875
876 ctx->routing_manager->enable();
877
878 res.code = 200;
879 res.body = R"({"enabled":true})";
880 return res;
881 });
882
883 // POST /api/v1/routing/disable - Disable routing globally
884 CROW_ROUTE(app, "/api/v1/routing/disable")
885 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& /*req*/) {
886 crow::response res;
887 res.add_header("Content-Type", "application/json");
888 add_cors_headers(res, *ctx);
889
890 if (!ctx->routing_manager) {
891 res.code = 503;
892 res.body = make_error_json("SERVICE_UNAVAILABLE",
893 "Routing manager not configured");
894 return res;
895 }
896
897 ctx->routing_manager->disable();
898
899 res.code = 200;
900 res.body = R"({"enabled":false})";
901 return res;
902 });
903
904 // GET /api/v1/routing/status - Get routing status and statistics
905 CROW_ROUTE(app, "/api/v1/routing/status")
906 .methods(crow::HTTPMethod::GET)([ctx](const crow::request& /*req*/) {
907 crow::response res;
908 res.add_header("Content-Type", "application/json");
909 add_cors_headers(res, *ctx);
910
911 if (!ctx->routing_manager) {
912 res.code = 503;
913 res.body = make_error_json("SERVICE_UNAVAILABLE",
914 "Routing manager not configured");
915 return res;
916 }
917
918 bool enabled = ctx->routing_manager->is_enabled();
919 auto all_rules = ctx->routing_manager->list_rules();
920 auto enabled_rules = ctx->routing_manager->list_enabled_rules();
921 auto stats = ctx->routing_manager->get_statistics();
922
923 std::ostringstream oss;
924 oss << R"({"enabled":)" << (enabled ? "true" : "false")
925 << R"(,"rules_count":)" << all_rules.size()
926 << R"(,"enabled_rules_count":)" << enabled_rules.size()
927 << R"(,"statistics":{)"
928 << R"("total_evaluated":)" << stats.total_evaluated
929 << R"(,"total_matched":)" << stats.total_matched
930 << R"(,"total_forwarded":)" << stats.total_forwarded
931 << R"(,"total_failed":)" << stats.total_failed
932 << R"(}})";
933
934 res.code = 200;
935 res.body = oss.str();
936 return res;
937 });
938
939 // POST /api/v1/routing/test - Test all rules against a dataset (dry run)
940 CROW_ROUTE(app, "/api/v1/routing/test")
941 .methods(crow::HTTPMethod::POST)([ctx](const crow::request& req) {
942 crow::response res;
943 res.add_header("Content-Type", "application/json");
944 add_cors_headers(res, *ctx);
945
946 if (!ctx->routing_manager) {
947 res.code = 503;
948 res.body = make_error_json("SERVICE_UNAVAILABLE",
949 "Routing manager not configured");
950 return res;
951 }
952
953 // Parse dataset from request body
954 auto dataset = parse_test_dataset(req.body);
955 if (dataset.empty()) {
956 res.code = 400;
957 res.body = make_error_json("INVALID_REQUEST",
958 "dataset object is required");
959 return res;
960 }
961
962 // Evaluate rules against the dataset
963 // Note: evaluate_with_rule_ids requires routing to be enabled,
964 // so we use a direct evaluation approach for testing
965 auto matches = ctx->routing_manager->evaluate_with_rule_ids(dataset);
966
967 bool matched = !matches.empty();
968 res.code = 200;
969 res.body = test_result_to_json(matched, matches, ctx->routing_manager);
970 return res;
971 });
972
973 // POST /api/v1/routing/rules/<ruleId>/test - Test specific rule against a dataset
974 CROW_ROUTE(app, "/api/v1/routing/rules/<string>/test")
975 .methods(crow::HTTPMethod::POST)(
976 [ctx](const crow::request& req, const std::string& rule_id) {
977 crow::response res;
978 res.add_header("Content-Type", "application/json");
979 add_cors_headers(res, *ctx);
980
981 if (!ctx->routing_manager) {
982 res.code = 503;
983 res.body = make_error_json("SERVICE_UNAVAILABLE",
984 "Routing manager not configured");
985 return res;
986 }
987
988 // Check if rule exists
989 auto rule = ctx->routing_manager->get_rule(rule_id);
990 if (!rule) {
991 res.code = 404;
992 res.body = make_error_json("NOT_FOUND", "Routing rule not found");
993 return res;
994 }
995
996 // Parse dataset from request body
997 auto dataset = parse_test_dataset(req.body);
998 if (dataset.empty()) {
999 res.code = 400;
1000 res.body = make_error_json("INVALID_REQUEST",
1001 "dataset object is required");
1002 return res;
1003 }
1004
1005 // Test only this specific rule
1006 // We need to manually check if all conditions match
1007 bool all_match = !rule->conditions.empty();
1008 for (const auto& condition : rule->conditions) {
1009 // Get the field value from dataset
1010 std::string value;
1011 switch (condition.match_field) {
1012 case client::routing_field::modality:
1013 value = dataset.get_string(core::tags::modality);
1014 break;
1015 case client::routing_field::station_ae:
1016 value = dataset.get_string(core::tags::station_name);
1017 break;
1018 case client::routing_field::institution:
1019 value = dataset.get_string(core::tags::institution_name);
1020 break;
1021 case client::routing_field::department:
1022 value = dataset.get_string(core::dicom_tag{0x0008, 0x1040});
1023 break;
1024 case client::routing_field::referring_physician:
1025 value = dataset.get_string(core::tags::referring_physician_name);
1026 break;
1027 case client::routing_field::study_description:
1028 value = dataset.get_string(core::tags::study_description);
1029 break;
1030 case client::routing_field::series_description:
1031 value = dataset.get_string(core::tags::series_description);
1032 break;
1033 case client::routing_field::body_part:
1034 value = dataset.get_string(core::dicom_tag{0x0018, 0x0015});
1035 break;
1036 case client::routing_field::patient_id_pattern:
1037 value = dataset.get_string(core::tags::patient_id);
1038 break;
1039 case client::routing_field::sop_class_uid:
1040 value = dataset.get_string(core::tags::sop_class_uid);
1041 break;
1042 default:
1043 break;
1044 }
1045
1046 // Simple wildcard matching
1047 auto match_wildcard = [](std::string_view pattern,
1048 std::string_view val,
1049 bool case_sensitive) -> bool {
1050 auto to_lower = [](std::string_view s) {
1051 std::string result;
1052 result.reserve(s.size());
1053 for (char c : s) {
1054 result += static_cast<char>(std::tolower(
1055 static_cast<unsigned char>(c)));
1056 }
1057 return result;
1058 };
1059
1060 std::string pat_str = case_sensitive ?
1061 std::string(pattern) : to_lower(pattern);
1062 std::string val_str = case_sensitive ?
1063 std::string(val) : to_lower(val);
1064
1065 const char* p = pat_str.c_str();
1066 const char* v = val_str.c_str();
1067 const char* star_p = nullptr;
1068 const char* star_v = nullptr;
1069
1070 while (*v) {
1071 if (*p == '*') {
1072 star_p = p++;
1073 star_v = v;
1074 } else if (*p == '?' || *p == *v) {
1075 ++p;
1076 ++v;
1077 } else if (star_p) {
1078 p = star_p + 1;
1079 v = ++star_v;
1080 } else {
1081 return false;
1082 }
1083 }
1084
1085 while (*p == '*') {
1086 ++p;
1087 }
1088
1089 return *p == '\0';
1090 };
1091
1092 bool matched = match_wildcard(condition.pattern, value,
1093 condition.case_sensitive);
1094 if (condition.negate) {
1095 matched = !matched;
1096 }
1097
1098 if (!matched) {
1099 all_match = false;
1100 break;
1101 }
1102 }
1103
1104 res.code = 200;
1105 if (all_match) {
1106 res.body = single_rule_test_to_json(true, rule->actions);
1107 } else {
1108 res.body = single_rule_test_to_json(false, {});
1109 }
1110 return res;
1111 });
1112}

References kcenon::pacs::client::body_part, kcenon::pacs::client::department, kcenon::pacs::client::institution, kcenon::pacs::core::tags::institution_name, kcenon::pacs::web::make_error_json(), kcenon::pacs::client::modality, kcenon::pacs::core::tags::modality, kcenon::pacs::core::tags::patient_id, kcenon::pacs::client::patient_id_pattern, kcenon::pacs::client::referring_physician, kcenon::pacs::core::tags::referring_physician_name, kcenon::pacs::client::series_description, kcenon::pacs::core::tags::series_description, kcenon::pacs::client::sop_class_uid, kcenon::pacs::core::tags::sop_class_uid, kcenon::pacs::client::station_ae, kcenon::pacs::core::tags::station_name, kcenon::pacs::client::study_description, and kcenon::pacs::core::tags::study_description.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_security_endpoints_impl()

void kcenon::pacs::web::endpoints::register_security_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Register security endpoints with the Crow app.

Parameters
appCrow application instance
ctxShared server context

Definition at line 21 of file security_endpoints.cpp.

22 {
23
24 // POST /api/v1/security/users - Create a new user
25 CROW_ROUTE(app, "/api/v1/security/users")
26 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req) {
27 crow::response res;
28 res.add_header("Content-Type", "application/json");
29
30 if (!ctx->security_manager) {
31 res.body = make_error_json("SECURITY_UNAVAILABLE",
32 "Security manager not configured");
33 res.code = 503;
34 return res;
35 }
36
37 auto x = crow::json::load(req.body);
38 if (!x) {
39 res.body = make_error_json("INVALID_JSON", "Invalid JSON body");
40 res.code = 400;
41 return res;
42 }
43
44 if (!x.has("username") || !x.has("id")) {
45 res.body =
46 make_error_json("MISSING_FIELDS", "Username and ID are required");
47 res.code = 400;
48 return res;
49 }
50
52 user.id = x["id"].s();
53 user.username = x["username"].s();
54 user.active = true; // Default to active
55
56 auto result = ctx->security_manager->create_user(user);
57 if (result.is_err()) {
58 res.body = make_error_json(
59 "CREATE_FAILED", "Failed to create user"); // In real app, expose
60 // inner error safely
61 res.code = 500;
62 } else {
63 res.body = make_success_json("User created");
64 res.code = 201;
65 }
66 return res;
67 });
68
69 // POST /api/v1/security/users/<id>/roles - Assign role to user
70 CROW_ROUTE(app, "/api/v1/security/users/<string>/roles")
71 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req,
72 std::string user_id) {
73 crow::response res;
74 res.add_header("Content-Type", "application/json");
75
76 if (!ctx->security_manager) {
77 res.body = make_error_json("SECURITY_UNAVAILABLE",
78 "Security manager not configured");
79 res.code = 503;
80 return res;
81 }
82
83 auto x = crow::json::load(req.body);
84 if (!x || !x.has("role")) {
85 res.body = make_error_json("INVALID_REQUEST", "Role is required");
86 res.code = 400;
87 return res;
88 }
89
90 std::string role_str = x["role"].s();
91 auto role_opt = kcenon::pacs::security::parse_role(role_str);
92 if (!role_opt) {
93 res.body = make_error_json("INVALID_ROLE", "Invalid role specified");
94 res.code = 400;
95 return res;
96 }
97
98 auto result = ctx->security_manager->assign_role(user_id, *role_opt);
99 if (result.is_err()) {
100 // Could distinguish user not found vs other errors
101 res.body = make_error_json("ASSIGN_FAILED", "Failed to assign role");
102 res.code = 500;
103 } else {
104 res.body = make_success_json("Role assigned");
105 res.code = 200;
106 }
107 return res;
108 });
109}
std::optional< Role > parse_role(std::string_view str)
Parse Role from string.
Definition role.h:50
Represents a user in the system.
Definition user.h:26

References kcenon::pacs::security::User::id, kcenon::pacs::web::make_error_json(), kcenon::pacs::web::make_success_json(), and kcenon::pacs::security::parse_role().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_series_endpoints_impl()

void kcenon::pacs::web::endpoints::register_series_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 100 of file series_endpoints.cpp.

101 {
102 // GET /api/v1/series/:uid - Get series details
103 CROW_ROUTE(app, "/api/v1/series/<string>")
104 .methods(crow::HTTPMethod::GET)(
105 [ctx](const crow::request & /*req*/, const std::string &series_uid) {
106 crow::response res;
107 res.add_header("Content-Type", "application/json");
108 add_cors_headers(res, *ctx);
109
110 if (!ctx->database) {
111 res.code = 503;
112 res.body = make_error_json("DATABASE_UNAVAILABLE",
113 "Database not configured");
114 return res;
115 }
116
117 auto series = ctx->database->find_series(series_uid);
118 if (!series) {
119 res.code = 404;
120 res.body = make_error_json("NOT_FOUND", "Series not found");
121 return res;
122 }
123
124 res.code = 200;
125 res.body = series_to_json(*series);
126 return res;
127 });
128
129 // GET /api/v1/series/:uid/instances - Get series instances
130 CROW_ROUTE(app, "/api/v1/series/<string>/instances")
131 .methods(crow::HTTPMethod::GET)(
132 [ctx](const crow::request & /*req*/, const std::string &series_uid) {
133 crow::response res;
134 res.add_header("Content-Type", "application/json");
135 add_cors_headers(res, *ctx);
136
137 if (!ctx->database) {
138 res.code = 503;
139 res.body = make_error_json("DATABASE_UNAVAILABLE",
140 "Database not configured");
141 return res;
142 }
143
144 // Verify series exists
145 auto series = ctx->database->find_series(series_uid);
146 if (!series) {
147 res.code = 404;
148 res.body = make_error_json("NOT_FOUND", "Series not found");
149 return res;
150 }
151
152 auto instances_result = ctx->database->list_instances(series_uid);
153 if (!instances_result.is_ok()) {
154 res.code = 500;
155 res.body = make_error_json("QUERY_ERROR",
156 instances_result.error().message);
157 return res;
158 }
159
160 res.code = 200;
161 res.body = instances_to_json(instances_result.value());
162 return res;
163 });
164}

References kcenon::pacs::web::make_error_json().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_storage_commitment_endpoints_impl()

void kcenon::pacs::web::endpoints::register_storage_commitment_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 406 of file storage_commitment_endpoints.cpp.

408 {
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
435 auto parsed = storage_commitment::parse_commitment_request(
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
446 storage_commitment::commitment_transaction txn;
447 txn.transaction_uid = generate_transaction_uid();
448 txn.state = storage_commitment::transaction_state::pending;
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
490 auto parsed = storage_commitment::parse_commitment_request(
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
500 storage_commitment::commitment_transaction txn;
501 txn.transaction_uid = generate_transaction_uid();
502 txn.state = storage_commitment::transaction_state::pending;
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;
544 res.body = storage_commitment::transaction_to_json(*txn);
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}
@ store
C-STORE operation.
constexpr dicom_tag transaction_uid
Transaction UID — identifies a Storage Commitment transaction (PS3.4 J.3)

References kcenon::pacs::web::storage_commitment::commitment_transaction::created_at, kcenon::pacs::web::make_error_json(), kcenon::pacs::web::storage_commitment::parse_commitment_request(), kcenon::pacs::web::storage_commitment::pending, kcenon::pacs::web::storage_commitment::commitment_transaction::requested_references, kcenon::pacs::web::storage_commitment::commitment_transaction::state, kcenon::pacs::web::storage_commitment::commitment_transaction::study_instance_uid, kcenon::pacs::web::storage_commitment::transaction_to_json(), kcenon::pacs::web::storage_commitment::commitment_transaction::transaction_uid, and kcenon::pacs::web::storage_commitment::transactions_to_json().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_study_endpoints_impl()

void kcenon::pacs::web::endpoints::register_study_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 192 of file study_endpoints.cpp.

193 {
194 // GET /api/v1/studies - List studies (paginated)
195 CROW_ROUTE(app, "/api/v1/studies")
196 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
197 crow::response res;
198 res.add_header("Content-Type", "application/json");
199 add_cors_headers(res, *ctx);
200
201 if (!ctx->database) {
202 res.code = 503;
203 res.body =
204 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
205 return res;
206 }
207
208 // Parse pagination
209 auto [limit, offset] = parse_pagination(req);
210
211 // Build query from URL parameters
213 query.limit = limit;
214 query.offset = offset;
215
216 auto patient_id = req.url_params.get("patient_id");
217 if (patient_id) {
218 query.patient_id = patient_id;
219 }
220
221 auto patient_name = req.url_params.get("patient_name");
222 if (patient_name) {
223 query.patient_name = patient_name;
224 }
225
226 auto study_uid = req.url_params.get("study_uid");
227 if (study_uid) {
228 query.study_uid = study_uid;
229 }
230
231 auto study_id = req.url_params.get("study_id");
232 if (study_id) {
233 query.study_id = study_id;
234 }
235
236 auto study_date = req.url_params.get("study_date");
237 if (study_date) {
238 query.study_date = study_date;
239 }
240
241 auto study_date_from = req.url_params.get("study_date_from");
242 if (study_date_from) {
243 query.study_date_from = study_date_from;
244 }
245
246 auto study_date_to = req.url_params.get("study_date_to");
247 if (study_date_to) {
248 query.study_date_to = study_date_to;
249 }
250
251 auto accession_number = req.url_params.get("accession_number");
252 if (accession_number) {
253 query.accession_number = accession_number;
254 }
255
256 auto modality = req.url_params.get("modality");
257 if (modality) {
258 query.modality = modality;
259 }
260
261 auto referring_physician = req.url_params.get("referring_physician");
262 if (referring_physician) {
263 query.referring_physician = referring_physician;
264 }
265
266 auto study_description = req.url_params.get("study_description");
267 if (study_description) {
268 query.study_description = study_description;
269 }
270
271 // Get total count (without pagination)
272 storage::study_query count_query = query;
273 count_query.limit = 0;
274 count_query.offset = 0;
275 auto all_studies_result = ctx->database->search_studies(count_query);
276 if (!all_studies_result.is_ok()) {
277 res.code = 500;
278 res.body = make_error_json("QUERY_ERROR",
279 all_studies_result.error().message);
280 return res;
281 }
282 size_t total_count = all_studies_result.value().size();
283
284 // Get paginated results
285 auto studies_result = ctx->database->search_studies(query);
286 if (!studies_result.is_ok()) {
287 res.code = 500;
288 res.body = make_error_json("QUERY_ERROR",
289 studies_result.error().message);
290 return res;
291 }
292
293 res.code = 200;
294 res.body = studies_to_json(studies_result.value(), total_count);
295 return res;
296 });
297
298 // GET /api/v1/studies/:uid - Get study details
299 CROW_ROUTE(app, "/api/v1/studies/<string>")
300 .methods(crow::HTTPMethod::GET)(
301 [ctx](const crow::request & /*req*/, const std::string &study_uid) {
302 crow::response res;
303 res.add_header("Content-Type", "application/json");
304 add_cors_headers(res, *ctx);
305
306 if (!ctx->database) {
307 res.code = 503;
308 res.body = make_error_json("DATABASE_UNAVAILABLE",
309 "Database not configured");
310 return res;
311 }
312
313 auto study = ctx->database->find_study(study_uid);
314 if (!study) {
315 res.code = 404;
316 res.body = make_error_json("NOT_FOUND", "Study not found");
317 return res;
318 }
319
320 res.code = 200;
321 res.body = study_to_json(*study);
322 return res;
323 });
324
325 // GET /api/v1/studies/:uid/series - Get study's series
326 CROW_ROUTE(app, "/api/v1/studies/<string>/series")
327 .methods(crow::HTTPMethod::GET)(
328 [ctx](const crow::request & /*req*/, const std::string &study_uid) {
329 crow::response res;
330 res.add_header("Content-Type", "application/json");
331 add_cors_headers(res, *ctx);
332
333 if (!ctx->database) {
334 res.code = 503;
335 res.body = make_error_json("DATABASE_UNAVAILABLE",
336 "Database not configured");
337 return res;
338 }
339
340 // Verify study exists
341 auto study = ctx->database->find_study(study_uid);
342 if (!study) {
343 res.code = 404;
344 res.body = make_error_json("NOT_FOUND", "Study not found");
345 return res;
346 }
347
348 auto series_list_result = ctx->database->list_series(study_uid);
349 if (!series_list_result.is_ok()) {
350 res.code = 500;
351 res.body = make_error_json("QUERY_ERROR",
352 series_list_result.error().message);
353 return res;
354 }
355
356 res.code = 200;
357 res.body = series_list_to_json(series_list_result.value());
358 return res;
359 });
360
361 // GET /api/v1/studies/:uid/instances - Get study's instances (all series)
362 CROW_ROUTE(app, "/api/v1/studies/<string>/instances")
363 .methods(crow::HTTPMethod::GET)(
364 [ctx](const crow::request & /*req*/, const std::string &study_uid) {
365 crow::response res;
366 res.add_header("Content-Type", "application/json");
367 add_cors_headers(res, *ctx);
368
369 if (!ctx->database) {
370 res.code = 503;
371 res.body = make_error_json("DATABASE_UNAVAILABLE",
372 "Database not configured");
373 return res;
374 }
375
376 // Verify study exists
377 auto study = ctx->database->find_study(study_uid);
378 if (!study) {
379 res.code = 404;
380 res.body = make_error_json("NOT_FOUND", "Study not found");
381 return res;
382 }
383
384 // Get all series and their instances
385 std::vector<storage::instance_record> all_instances;
386 auto series_list_result = ctx->database->list_series(study_uid);
387 if (!series_list_result.is_ok()) {
388 res.code = 500;
389 res.body = make_error_json("QUERY_ERROR",
390 series_list_result.error().message);
391 return res;
392 }
393 for (const auto &series : series_list_result.value()) {
394 auto instances_result = ctx->database->list_instances(series.series_uid);
395 if (!instances_result.is_ok()) {
396 res.code = 500;
397 res.body = make_error_json("QUERY_ERROR",
398 instances_result.error().message);
399 return res;
400 }
401 const auto& instances = instances_result.value();
402 all_instances.insert(all_instances.end(), instances.begin(),
403 instances.end());
404 }
405
406 res.code = 200;
407 res.body = instances_to_json(all_instances);
408 return res;
409 });
410
411 // DELETE /api/v1/studies/:uid - Delete study
412 CROW_ROUTE(app, "/api/v1/studies/<string>")
413 .methods(crow::HTTPMethod::DELETE)(
414 [ctx](const crow::request & /*req*/, const std::string &study_uid) {
415 crow::response res;
416 res.add_header("Content-Type", "application/json");
417 add_cors_headers(res, *ctx);
418
419 if (!ctx->database) {
420 res.code = 503;
421 res.body = make_error_json("DATABASE_UNAVAILABLE",
422 "Database not configured");
423 return res;
424 }
425
426 // Verify study exists
427 auto study = ctx->database->find_study(study_uid);
428 if (!study) {
429 res.code = 404;
430 res.body = make_error_json("NOT_FOUND", "Study not found");
431 return res;
432 }
433
434 auto result = ctx->database->delete_study(study_uid);
435 if (result.is_err()) {
436 res.code = 500;
437 res.body = make_error_json("DELETE_FAILED",
438 result.error().message);
439 return res;
440 }
441
442 res.code = 200;
443 res.body = make_success_json("Study deleted successfully");
444 return res;
445 });
446}
@ referring_physician
(0008,0090) Referring Physician's Name
constexpr dicom_tag study_description
Study Description.
constexpr dicom_tag accession_number
Accession Number.
constexpr dicom_tag study_date
Study Date.

References kcenon::pacs::storage::study_query::limit, kcenon::pacs::web::make_error_json(), kcenon::pacs::web::make_success_json(), and kcenon::pacs::storage::study_query::offset.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_system_endpoints_impl()

void kcenon::pacs::web::endpoints::register_system_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 106 of file system_endpoints.cpp.

107 {
108 // GET /api/v1/system/status - System health status
109 CROW_ROUTE(app, "/api/v1/system/status")
110 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
111 crow::response res;
112 res.add_header("Content-Type", "application/json");
113 add_cors_headers(res, *ctx);
114
115 // Permission Check: System Read
116 if (!check_permission(ctx, req, res, security::ResourceType::System,
117 security::Action::Read)) {
118 return res;
119 }
120
121 crow::json::wvalue status_json;
122 status_json["status"] = "unknown"; // Default
123 status_json["message"] = "Health checker not configured"; // Default
124 status_json["version"] = "1.0.0"; // Default
125
126#ifdef PACS_WITH_MONITORING
127 if (ctx->health_checker) {
128 auto status = ctx->health_checker->get_status();
129 // to_json is a free function in kcenon::pacs::monitoring namespace
130 status_json = crow::json::load(
131 monitoring::to_json(status)); // Parse existing JSON into wvalue
132 status_json["version"] = "1.0.0"; // Add version
133 } else {
134 status_json["status"] = "unknown";
135 status_json["message"] = "Health checker not configured";
136 }
137#else
138 // Basic status without monitoring module
139 res.code = 200;
140#endif
141 return res;
142 });
143
144 // GET /api/v1/system/metrics - Performance metrics
145 CROW_ROUTE(app, "/api/v1/system/metrics").methods(crow::HTTPMethod::GET)([ctx]() {
146 crow::response res;
147 res.add_header("Content-Type", "application/json");
148 add_cors_headers(res, *ctx);
149
150#ifdef PACS_WITH_MONITORING
151 if (ctx->metrics) {
152 // pacs_metrics provides individual counters, build simple JSON
153 std::ostringstream oss;
154 oss << R"({"message":"Metrics available via pacs_metrics API"})";
155 res.body = oss.str();
156 res.code = 200;
157 } else {
158 res.body =
159 R"({"error":{"code":"METRICS_UNAVAILABLE","message":"Metrics provider not configured"}})";
160 res.code = 503;
161 }
162#else
163 // Basic metrics without monitoring module
164 res.body =
165 R"({"uptime_seconds":0,"requests_total":0,"message":"Metrics module not available"})";
166 res.code = 200;
167#endif
168 return res;
169 });
170
171 // GET /api/v1/system/config - Current configuration
172 CROW_ROUTE(app, "/api/v1/system/config")
173 .methods(crow::HTTPMethod::GET)([ctx]() {
174 crow::response res;
175 res.add_header("Content-Type", "application/json");
176 add_cors_headers(res, *ctx);
177
178 if (ctx->config) {
179 res.body = config_to_json(*ctx->config);
180 res.code = 200;
181 } else {
182 res.body = make_error_json("CONFIG_UNAVAILABLE",
183 "Configuration not available");
184 res.code = 500;
185 }
186 return res;
187 });
188
189 // PUT /api/v1/system/config - Update configuration
190 CROW_ROUTE(app, "/api/v1/system/config")
191 .methods(crow::HTTPMethod::PUT)([ctx](const crow::request &req) {
192 crow::response res;
193 res.add_header("Content-Type", "application/json");
194 add_cors_headers(res, *ctx);
195
196 // Validate content type
197 auto content_type = req.get_header_value("Content-Type");
198 if (content_type.find("application/json") == std::string::npos) {
199 res.body = make_error_json("INVALID_CONTENT_TYPE",
200 "Content-Type must be application/json");
201 res.code = 415;
202 return res;
203 }
204
205 // Validate body is not empty
206 if (req.body.empty()) {
207 res.body = make_error_json("EMPTY_BODY", "Request body is required");
208 res.code = 400;
209 return res;
210 }
211
212 // Note: Full configuration update would require parsing JSON and
213 // updating the config. For now, we return a placeholder success
214 // response. Actual implementation would need a callback or direct
215 // config reference.
216 res.body = make_success_json("Configuration update acknowledged");
217 res.code = 200;
218 return res;
219 });
220
221 // GET /api/v1/system/version - API version info
222 CROW_ROUTE(app, "/api/v1/system/version").methods(crow::HTTPMethod::GET)([ctx]() {
223 crow::response res;
224 res.add_header("Content-Type", "application/json");
225 add_cors_headers(res, *ctx);
226
227 res.body =
228 R"({"api_version":"v1","pacs_version":"1.2.0","crow_version":"1.2.0"})";
229 res.code = 200;
230 return res;
231 });
232}

References kcenon::pacs::web::make_error_json(), kcenon::pacs::web::make_success_json(), kcenon::pacs::security::Action::Read, and kcenon::pacs::security::System.

Referenced by kcenon::pacs::web::rest_server::start(), and kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_thumbnail_endpoints_impl()

void kcenon::pacs::web::endpoints::register_thumbnail_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 113 of file thumbnail_endpoints.cpp.

114 {
115 // Initialize thumbnail service if database is available
116 if (ctx->database != nullptr && g_thumbnail_service == nullptr) {
117 g_thumbnail_service =
118 std::make_shared<thumbnail_service>(ctx->database);
119 }
120
121 // GET /api/v1/thumbnails/instances/{sopInstanceUid}
122 CROW_ROUTE(app, "/api/v1/thumbnails/instances/<string>")
123 .methods(crow::HTTPMethod::GET)(
124 [ctx](const crow::request& req, const std::string& sop_uid) {
125 crow::response res;
126 add_cors_headers(res, *ctx);
127
128 if (g_thumbnail_service == nullptr) {
129 res.code = 503;
130 res.add_header("Content-Type", "application/json");
131 res.body = make_error_json("SERVICE_UNAVAILABLE",
132 "Thumbnail service not configured");
133 return res;
134 }
135
136 auto params = parse_thumbnail_params(req);
137 auto result = g_thumbnail_service->get_thumbnail(sop_uid, params);
138
139 if (!result.success) {
140 res.code = 404;
141 res.add_header("Content-Type", "application/json");
142 res.body =
143 make_error_json("NOT_FOUND", result.error_message);
144 return res;
145 }
146
147 res.code = 200;
148 res.add_header("Content-Type", result.entry.content_type);
149 res.add_header("Cache-Control", "max-age=3600");
150 res.body = std::string(
151 reinterpret_cast<const char*>(result.entry.data.data()),
152 result.entry.data.size());
153 return res;
154 });
155
156 // GET /api/v1/thumbnails/series/{seriesUid}
157 CROW_ROUTE(app, "/api/v1/thumbnails/series/<string>")
158 .methods(crow::HTTPMethod::GET)(
159 [ctx](const crow::request& req, const std::string& series_uid) {
160 crow::response res;
161 add_cors_headers(res, *ctx);
162
163 if (g_thumbnail_service == nullptr) {
164 res.code = 503;
165 res.add_header("Content-Type", "application/json");
166 res.body = make_error_json("SERVICE_UNAVAILABLE",
167 "Thumbnail service not configured");
168 return res;
169 }
170
171 auto params = parse_thumbnail_params(req);
172 auto result =
173 g_thumbnail_service->get_series_thumbnail(series_uid, params);
174
175 if (!result.success) {
176 res.code = 404;
177 res.add_header("Content-Type", "application/json");
178 res.body =
179 make_error_json("NOT_FOUND", result.error_message);
180 return res;
181 }
182
183 res.code = 200;
184 res.add_header("Content-Type", result.entry.content_type);
185 res.add_header("Cache-Control", "max-age=3600");
186 res.body = std::string(
187 reinterpret_cast<const char*>(result.entry.data.data()),
188 result.entry.data.size());
189 return res;
190 });
191
192 // GET /api/v1/thumbnails/studies/{studyUid}
193 CROW_ROUTE(app, "/api/v1/thumbnails/studies/<string>")
194 .methods(crow::HTTPMethod::GET)(
195 [ctx](const crow::request& req, const std::string& study_uid) {
196 crow::response res;
197 add_cors_headers(res, *ctx);
198
199 if (g_thumbnail_service == nullptr) {
200 res.code = 503;
201 res.add_header("Content-Type", "application/json");
202 res.body = make_error_json("SERVICE_UNAVAILABLE",
203 "Thumbnail service not configured");
204 return res;
205 }
206
207 auto params = parse_thumbnail_params(req);
208 auto result =
209 g_thumbnail_service->get_study_thumbnail(study_uid, params);
210
211 if (!result.success) {
212 res.code = 404;
213 res.add_header("Content-Type", "application/json");
214 res.body =
215 make_error_json("NOT_FOUND", result.error_message);
216 return res;
217 }
218
219 res.code = 200;
220 res.add_header("Content-Type", result.entry.content_type);
221 res.add_header("Cache-Control", "max-age=3600");
222 res.body = std::string(
223 reinterpret_cast<const char*>(result.entry.data.data()),
224 result.entry.data.size());
225 return res;
226 });
227
228 // DELETE /api/v1/thumbnails/cache - Clear all cached thumbnails
229 CROW_ROUTE(app, "/api/v1/thumbnails/cache")
230 .methods(crow::HTTPMethod::DELETE)([ctx](const crow::request& /*req*/) {
231 crow::response res;
232 res.add_header("Content-Type", "application/json");
233 add_cors_headers(res, *ctx);
234
235 if (g_thumbnail_service == nullptr) {
236 res.code = 503;
237 res.body = make_error_json("SERVICE_UNAVAILABLE",
238 "Thumbnail service not configured");
239 return res;
240 }
241
242 g_thumbnail_service->clear_cache();
243
244 res.code = 200;
245 res.body = make_success_json("Cache cleared successfully");
246 return res;
247 });
248
249 // GET /api/v1/thumbnails/cache/stats - Get cache statistics
250 CROW_ROUTE(app, "/api/v1/thumbnails/cache/stats")
251 .methods(crow::HTTPMethod::GET)([ctx](const crow::request& /*req*/) {
252 crow::response res;
253 res.add_header("Content-Type", "application/json");
254 add_cors_headers(res, *ctx);
255
256 if (g_thumbnail_service == nullptr) {
257 res.code = 503;
258 res.body = make_error_json("SERVICE_UNAVAILABLE",
259 "Thumbnail service not configured");
260 return res;
261 }
262
263 std::ostringstream oss;
264 oss << R"({"cache_size":)" << g_thumbnail_service->cache_size()
265 << R"(,"entry_count":)" << g_thumbnail_service->cache_entry_count()
266 << R"(,"max_size":)" << g_thumbnail_service->max_cache_size()
267 << "}";
268
269 res.code = 200;
270 res.body = oss.str();
271 return res;
272 });
273}

References kcenon::pacs::web::make_error_json(), and kcenon::pacs::web::make_success_json().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_viewer_state_endpoints_impl()

void kcenon::pacs::web::endpoints::register_viewer_state_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 244 of file viewer_state_endpoints.cpp.

245 {
246 // POST /api/v1/viewer-states - Create viewer state
247 CROW_ROUTE(app, "/api/v1/viewer-states")
248 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req) {
249 crow::response res;
250 res.add_header("Content-Type", "application/json");
251 add_cors_headers(res, *ctx);
252
253 if (!ctx->database) {
254 res.code = 503;
255 res.body =
256 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
257 return res;
258 }
259
260 std::string body = req.body;
261 if (body.empty()) {
262 res.code = 400;
263 res.body = make_error_json("INVALID_REQUEST", "Request body is empty");
264 return res;
265 }
266
268 state.state_id = generate_uuid();
269 state.study_uid = parse_json_string(body, "study_uid");
270 state.user_id = parse_json_string(body, "user_id");
271 state.state_json = build_state_json(body);
272 state.created_at = std::chrono::system_clock::now();
273 state.updated_at = state.created_at;
274
275 if (state.study_uid.empty()) {
276 res.code = 400;
277 res.body = make_error_json("MISSING_FIELD", "study_uid is required");
278 return res;
279 }
280
281 #ifdef PACS_WITH_DATABASE_SYSTEM
282 storage::viewer_state_record_repository repo(ctx->database->db_adapter());
283 storage::recent_study_repository recent_repo(ctx->database->db_adapter());
284#else
285 storage::viewer_state_repository repo(ctx->database->native_handle());
286#endif
287 auto save_result =
288#ifdef PACS_WITH_DATABASE_SYSTEM
289 repo.save(state);
290#else
291 repo.save_state(state);
292#endif
293 if (!save_result.is_ok()) {
294 res.code = 500;
295 res.body =
296 make_error_json("SAVE_ERROR", save_result.error().message);
297 return res;
298 }
299
300 // Also record study access if user_id is provided
301 if (!state.user_id.empty()) {
302 #ifdef PACS_WITH_DATABASE_SYSTEM
303 (void)recent_repo.record_access(state.user_id, state.study_uid);
304 #else
305 (void)repo.record_study_access(state.user_id, state.study_uid);
306 #endif
307 }
308
309 res.code = 201;
310 std::ostringstream oss;
311 oss << R"({"state_id":")" << json_escape(state.state_id)
312 << R"(","created_at":")" << format_timestamp(state.created_at)
313 << R"("})";
314 res.body = oss.str();
315 return res;
316 });
317
318 // GET /api/v1/viewer-states - List viewer states
319 CROW_ROUTE(app, "/api/v1/viewer-states")
320 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
321 crow::response res;
322 res.add_header("Content-Type", "application/json");
323 add_cors_headers(res, *ctx);
324
325 if (!ctx->database) {
326 res.code = 503;
327 res.body =
328 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
329 return res;
330 }
331
332 storage::viewer_state_query query;
333
334 auto study_uid = req.url_params.get("study_uid");
335 if (study_uid) {
336 query.study_uid = study_uid;
337 }
338 auto user_id = req.url_params.get("user_id");
339 if (user_id) {
340 query.user_id = user_id;
341 }
342 auto limit_param = req.url_params.get("limit");
343 if (limit_param) {
344 try {
345 query.limit = std::stoul(limit_param);
346 if (query.limit > 100) {
347 query.limit = 100;
348 }
349 } catch (...) {
350 // Use default
351 }
352 }
353
354 #ifdef PACS_WITH_DATABASE_SYSTEM
355 storage::viewer_state_record_repository repo(ctx->database->db_adapter());
356 #else
357 storage::viewer_state_repository repo(ctx->database->native_handle());
358#endif
359 #ifdef PACS_WITH_DATABASE_SYSTEM
360 auto states_result = repo.search(query);
361 if (states_result.is_err()) {
362 res.code = 500;
363 res.body =
364 make_error_json("SEARCH_ERROR", states_result.error().message);
365 return res;
366 }
367 auto states = std::move(states_result.value());
368 #else
369 auto states = repo.search_states(query);
370 #endif
371
372 res.code = 200;
373 res.body = viewer_states_to_json(states);
374 return res;
375 });
376
377 // GET /api/v1/viewer-states/<stateId> - Get viewer state by ID
378 CROW_ROUTE(app, "/api/v1/viewer-states/<string>")
379 .methods(crow::HTTPMethod::GET)(
380 [ctx](const crow::request & /*req*/, const std::string &state_id) {
381 crow::response res;
382 res.add_header("Content-Type", "application/json");
383 add_cors_headers(res, *ctx);
384
385 if (!ctx->database) {
386 res.code = 503;
387 res.body = make_error_json("DATABASE_UNAVAILABLE",
388 "Database not configured");
389 return res;
390 }
391
392 #ifdef PACS_WITH_DATABASE_SYSTEM
393 storage::viewer_state_record_repository repo(ctx->database->db_adapter());
394 #else
395 storage::viewer_state_repository repo(ctx->database->native_handle());
396#endif
397 #ifdef PACS_WITH_DATABASE_SYSTEM
398 auto state_result = repo.find_by_id(state_id);
399 if (state_result.is_err()) {
400 res.code = 404;
401 res.body = make_error_json("NOT_FOUND", "Viewer state not found");
402 return res;
403 }
404 auto state = state_result.value();
405 #else
406 auto state = repo.find_state_by_id(state_id);
407 if (!state.has_value()) {
408 res.code = 404;
409 res.body = make_error_json("NOT_FOUND", "Viewer state not found");
410 return res;
411 }
412 #endif
413
414 res.code = 200;
415 #ifdef PACS_WITH_DATABASE_SYSTEM
416 res.body = viewer_state_to_json(state);
417 #else
418 res.body = viewer_state_to_json(state.value());
419 #endif
420 return res;
421 });
422
423 // DELETE /api/v1/viewer-states/<stateId> - Delete viewer state
424 CROW_ROUTE(app, "/api/v1/viewer-states/<string>")
425 .methods(crow::HTTPMethod::DELETE)(
426 [ctx](const crow::request & /*req*/, const std::string &state_id) {
427 crow::response res;
428 add_cors_headers(res, *ctx);
429
430 if (!ctx->database) {
431 res.code = 503;
432 res.add_header("Content-Type", "application/json");
433 res.body = make_error_json("DATABASE_UNAVAILABLE",
434 "Database not configured");
435 return res;
436 }
437
438 #ifdef PACS_WITH_DATABASE_SYSTEM
439 storage::viewer_state_record_repository repo(ctx->database->db_adapter());
440 #else
441 storage::viewer_state_repository repo(ctx->database->native_handle());
442#endif
443 #ifdef PACS_WITH_DATABASE_SYSTEM
444 auto existing = repo.find_by_id(state_id);
445 if (existing.is_err()) {
446 res.code = 404;
447 res.add_header("Content-Type", "application/json");
448 res.body = make_error_json("NOT_FOUND", "Viewer state not found");
449 return res;
450 }
451 #else
452 auto existing = repo.find_state_by_id(state_id);
453 if (!existing.has_value()) {
454 res.code = 404;
455 res.add_header("Content-Type", "application/json");
456 res.body = make_error_json("NOT_FOUND", "Viewer state not found");
457 return res;
458 }
459 #endif
460
461 auto remove_result =
462 #ifdef PACS_WITH_DATABASE_SYSTEM
463 repo.remove(state_id);
464 #else
465 repo.remove_state(state_id);
466 #endif
467 if (!remove_result.is_ok()) {
468 res.code = 500;
469 res.add_header("Content-Type", "application/json");
470 res.body =
471 make_error_json("DELETE_ERROR", remove_result.error().message);
472 return res;
473 }
474
475 res.code = 204;
476 return res;
477 });
478
479 // GET /api/v1/users/<userId>/recent-studies - Get recent studies for user
480 CROW_ROUTE(app, "/api/v1/users/<string>/recent-studies")
481 .methods(crow::HTTPMethod::GET)(
482 [ctx](const crow::request &req, const std::string &user_id) {
483 crow::response res;
484 res.add_header("Content-Type", "application/json");
485 add_cors_headers(res, *ctx);
486
487 if (!ctx->database) {
488 res.code = 503;
489 res.body = make_error_json("DATABASE_UNAVAILABLE",
490 "Database not configured");
491 return res;
492 }
493
494 size_t limit = 20;
495 auto limit_param = req.url_params.get("limit");
496 if (limit_param) {
497 try {
498 limit = std::stoul(limit_param);
499 if (limit > 100) {
500 limit = 100;
501 }
502 } catch (...) {
503 // Use default
504 }
505 }
506
507 #ifdef PACS_WITH_DATABASE_SYSTEM
508 storage::recent_study_repository repo(ctx->database->db_adapter());
509 #else
510 storage::viewer_state_repository repo(ctx->database->native_handle());
511#endif
512 #ifdef PACS_WITH_DATABASE_SYSTEM
513 auto records_result = repo.find_by_user(user_id, limit);
514 if (records_result.is_err()) {
515 res.code = 500;
516 res.body = make_error_json("SEARCH_ERROR",
517 records_result.error().message);
518 return res;
519 }
520 auto total_result = repo.count_for_user(user_id);
521 if (total_result.is_err()) {
522 res.code = 500;
523 res.body =
524 make_error_json("COUNT_ERROR", total_result.error().message);
525 return res;
526 }
527 auto records = std::move(records_result.value());
528 size_t total = total_result.value();
529 #else
530 auto records = repo.get_recent_studies(user_id, limit);
531 size_t total = repo.count_recent_studies(user_id);
532 #endif
533
534 res.code = 200;
535 res.body = recent_studies_to_json(records, total);
536 return res;
537 });
538}
Viewer state record from the database.
std::chrono::system_clock::time_point updated_at
Record last update timestamp.
std::string state_json
Full viewer state as JSON (layout, viewports, settings)
std::string state_id
Unique state identifier (UUID)
std::chrono::system_clock::time_point created_at
Record creation timestamp.
std::string study_uid
Study Instance UID - DICOM tag (0020,000D)
std::string user_id
User who saved the state.

References kcenon::pacs::storage::viewer_state_record::created_at, kcenon::pacs::web::json_escape(), kcenon::pacs::web::make_error_json(), kcenon::pacs::storage::viewer_state_record::state_id, kcenon::pacs::storage::viewer_state_record::state_json, kcenon::pacs::storage::viewer_state_record::study_uid, kcenon::pacs::storage::viewer_state_record::updated_at, and kcenon::pacs::storage::viewer_state_record::user_id.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_wado_uri_endpoints_impl()

void kcenon::pacs::web::endpoints::register_wado_uri_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 300 of file wado_uri_endpoints.cpp.

302 {
303
304 // GET /wado?requestType=WADO&studyUID=...&seriesUID=...&objectUID=...
305 CROW_ROUTE(app, "/wado")
306 .methods(crow::HTTPMethod::GET)(
307 [ctx](const crow::request& req) {
308 crow::response res;
309 add_cors_headers(res, *ctx);
310
311 // OAuth 2.0 / legacy auth check (read scope for WADO-URI)
312 if (ctx->oauth2 && ctx->oauth2->enabled()) {
313 auto auth = ctx->oauth2->authenticate(req, res);
314 if (!auth) return res;
315 if (!ctx->oauth2->require_any_scope(
316 auth->claims, res,
317 {"dicomweb.read"})) {
318 return res;
319 }
320 }
321
322 // Validate requestType parameter
323 auto request_type = req.url_params.get("requestType");
324 if (request_type == nullptr
325 || std::string(request_type) != "WADO") {
326 res.code = 400;
327 res.add_header("Content-Type", "application/json");
328 res.body = make_error_json(
329 "INVALID_REQUEST_TYPE",
330 "requestType must be 'WADO'");
331 return res;
332 }
333
334 // Check database availability
335 if (!ctx->database) {
336 res.code = 503;
337 res.add_header("Content-Type", "application/json");
338 res.body = make_error_json(
339 "DATABASE_UNAVAILABLE", "Database not configured");
340 return res;
341 }
342
343 // Parse and validate parameters
344 auto wado_request = wado_uri::parse_wado_uri_params(
345 req.url_params.get("studyUID"),
346 req.url_params.get("seriesUID"),
347 req.url_params.get("objectUID"),
348 req.url_params.get("contentType"),
349 req.url_params.get("transferSyntax"),
350 req.url_params.get("anonymize"),
351 req.url_params.get("rows"),
352 req.url_params.get("columns"),
353 req.url_params.get("windowCenter"),
354 req.url_params.get("windowWidth"),
355 req.url_params.get("frameNumber"));
356
357 auto validation = wado_uri::validate_wado_uri_request(
358 wado_request);
359 if (!validation.valid) {
360 res.code = validation.http_status;
361 res.add_header("Content-Type", "application/json");
362 res.body = make_error_json(
363 validation.error_code, validation.error_message);
364 return res;
365 }
366
367 // Look up the DICOM instance
368 auto file_path_result = ctx->database->get_file_path(
369 wado_request.object_uid);
370 if (!file_path_result.is_ok()) {
371 res.code = 500;
372 res.add_header("Content-Type", "application/json");
373 res.body = make_error_json(
374 "QUERY_ERROR", file_path_result.error().message);
375 return res;
376 }
377 const auto& file_path = file_path_result.value();
378 if (!file_path) {
379 res.code = 404;
380 res.add_header("Content-Type", "application/json");
381 res.body = make_error_json(
382 "NOT_FOUND", "DICOM instance not found");
383 return res;
384 }
385
386 // Route to appropriate handler based on content type
387 if (wado_request.content_type == "application/dicom") {
388 return handle_dicom_response(*file_path, *ctx);
389 }
390 if (wado_request.content_type == "image/jpeg"
391 || wado_request.content_type == "image/png") {
392 return handle_rendered_response(
393 *file_path, wado_request, *ctx);
394 }
395 if (wado_request.content_type == "application/dicom+json") {
396 return handle_dicom_json_response(*file_path, *ctx);
397 }
398
399 // Should not reach here due to validation, but handle defensively
400 res.code = 406;
401 res.add_header("Content-Type", "application/json");
402 res.body = make_error_json(
403 "UNSUPPORTED_MEDIA_TYPE",
404 "Unsupported contentType: " + wado_request.content_type);
405 return res;
406 });
407}

References kcenon::pacs::web::make_error_json(), kcenon::pacs::web::wado_uri::parse_wado_uri_params(), and kcenon::pacs::web::wado_uri::validate_wado_uri_request().

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function:

◆ register_worklist_endpoints_impl()

void kcenon::pacs::web::endpoints::register_worklist_endpoints_impl ( crow::SimpleApp & app,
std::shared_ptr< rest_server_context > ctx )

Definition at line 194 of file worklist_endpoints.cpp.

196 {
197 // GET /api/v1/worklist - List scheduled procedures (paginated)
198 CROW_ROUTE(app, "/api/v1/worklist")
199 .methods(crow::HTTPMethod::GET)([ctx](const crow::request &req) {
200 crow::response res;
201 res.add_header("Content-Type", "application/json");
202 add_cors_headers(res, *ctx);
203
204 if (!ctx->database) {
205 res.code = 503;
206 res.body =
207 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
208 return res;
209 }
210
211 // Parse pagination
212 auto [limit, offset] = parse_pagination(req);
213
214 // Build query from URL parameters
216 query.limit = limit;
217 query.offset = offset;
218
219 auto station_ae = req.url_params.get("station_ae");
220 if (station_ae) {
221 query.station_ae = station_ae;
222 }
223
224 auto modality = req.url_params.get("modality");
225 if (modality) {
226 query.modality = modality;
227 }
228
229 auto scheduled_date_from = req.url_params.get("scheduled_date_from");
230 if (scheduled_date_from) {
231 query.scheduled_date_from = scheduled_date_from;
232 }
233
234 auto scheduled_date_to = req.url_params.get("scheduled_date_to");
235 if (scheduled_date_to) {
236 query.scheduled_date_to = scheduled_date_to;
237 }
238
239 auto patient_id = req.url_params.get("patient_id");
240 if (patient_id) {
241 query.patient_id = patient_id;
242 }
243
244 auto patient_name = req.url_params.get("patient_name");
245 if (patient_name) {
246 query.patient_name = patient_name;
247 }
248
249 auto accession_no = req.url_params.get("accession_no");
250 if (accession_no) {
251 query.accession_no = accession_no;
252 }
253
254 auto step_id = req.url_params.get("step_id");
255 if (step_id) {
256 query.step_id = step_id;
257 }
258
259 // Check if we should include all statuses
260 auto include_all = req.url_params.get("include_all_status");
261 if (include_all && std::string(include_all) == "true") {
262 query.include_all_status = true;
263 }
264
265 // Get total count (without pagination)
266 storage::worklist_query count_query = query;
267 count_query.limit = 0;
268 count_query.offset = 0;
269 auto all_items_result = ctx->database->query_worklist(count_query);
270 if (!all_items_result.is_ok()) {
271 res.code = 500;
272 res.body = make_error_json("QUERY_ERROR",
273 all_items_result.error().message);
274 return res;
275 }
276 size_t total_count = all_items_result.value().size();
277
278 // Get paginated results
279 auto items_result = ctx->database->query_worklist(query);
280 if (!items_result.is_ok()) {
281 res.code = 500;
282 res.body = make_error_json("QUERY_ERROR",
283 items_result.error().message);
284 return res;
285 }
286
287 res.code = 200;
288 res.body = worklist_items_to_json(items_result.value(), total_count);
289 return res;
290 });
291
292 // POST /api/v1/worklist - Create worklist item
293 CROW_ROUTE(app, "/api/v1/worklist")
294 .methods(crow::HTTPMethod::POST)([ctx](const crow::request &req) {
295 crow::response res;
296 res.add_header("Content-Type", "application/json");
297 add_cors_headers(res, *ctx);
298
299 if (!ctx->database) {
300 res.code = 503;
301 res.body =
302 make_error_json("DATABASE_UNAVAILABLE", "Database not configured");
303 return res;
304 }
305
306 // Parse request body
307 auto item = parse_worklist_item_json(req.body);
308 if (!item) {
309 res.code = 400;
310 res.body = make_error_json(
311 "INVALID_REQUEST",
312 "Missing required fields: step_id, patient_id, modality, "
313 "scheduled_datetime");
314 return res;
315 }
316
317 // Add worklist item
318 auto result = ctx->database->add_worklist_item(*item);
319 if (result.is_err()) {
320 res.code = 500;
321 res.body = make_error_json("CREATE_FAILED", result.error().message);
322 return res;
323 }
324
325 // Fetch the created item to return
326 auto created_item = ctx->database->find_worklist_by_pk(result.value());
327 if (created_item) {
328 res.code = 201;
329 res.body = worklist_item_to_json(*created_item);
330 } else {
331 res.code = 201;
332 res.body = make_success_json("Worklist item created");
333 }
334 return res;
335 });
336
337 // GET /api/v1/worklist/:id - Get worklist item by pk
338 CROW_ROUTE(app, "/api/v1/worklist/<int>")
339 .methods(crow::HTTPMethod::GET)(
340 [ctx](const crow::request & /*req*/, int pk) {
341 crow::response res;
342 res.add_header("Content-Type", "application/json");
343 add_cors_headers(res, *ctx);
344
345 if (!ctx->database) {
346 res.code = 503;
347 res.body = make_error_json("DATABASE_UNAVAILABLE",
348 "Database not configured");
349 return res;
350 }
351
352 auto item = ctx->database->find_worklist_by_pk(pk);
353 if (!item) {
354 res.code = 404;
355 res.body = make_error_json("NOT_FOUND", "Worklist item not found");
356 return res;
357 }
358
359 res.code = 200;
360 res.body = worklist_item_to_json(*item);
361 return res;
362 });
363
364 // PUT /api/v1/worklist/:id - Update worklist item
365 CROW_ROUTE(app, "/api/v1/worklist/<int>")
366 .methods(crow::HTTPMethod::PUT)(
367 [ctx](const crow::request &req, int pk) {
368 crow::response res;
369 res.add_header("Content-Type", "application/json");
370 add_cors_headers(res, *ctx);
371
372 if (!ctx->database) {
373 res.code = 503;
374 res.body = make_error_json("DATABASE_UNAVAILABLE",
375 "Database not configured");
376 return res;
377 }
378
379 // Verify item exists
380 auto existing_item = ctx->database->find_worklist_by_pk(pk);
381 if (!existing_item) {
382 res.code = 404;
383 res.body = make_error_json("NOT_FOUND", "Worklist item not found");
384 return res;
385 }
386
387 // Parse new status from request body
388 auto get_json_string = [&req](const std::string &key) -> std::string {
389 std::string search_key = "\"" + key + "\":\"";
390 auto pos = req.body.find(search_key);
391 if (pos == std::string::npos) {
392 return "";
393 }
394 pos += search_key.length();
395 auto end_pos = req.body.find("\"", pos);
396 if (end_pos == std::string::npos) {
397 return "";
398 }
399 return req.body.substr(pos, end_pos - pos);
400 };
401
402 std::string new_status = get_json_string("step_status");
403 if (new_status.empty()) {
404 res.code = 400;
405 res.body = make_error_json("INVALID_REQUEST",
406 "step_status is required for update");
407 return res;
408 }
409
410 // Update status
411 auto result = ctx->database->update_worklist_status(
412 existing_item->step_id,
413 existing_item->accession_no,
414 new_status);
415
416 if (result.is_err()) {
417 res.code = 500;
418 res.body = make_error_json("UPDATE_FAILED", result.error().message);
419 return res;
420 }
421
422 // Fetch updated item
423 auto updated_item = ctx->database->find_worklist_by_pk(pk);
424 if (updated_item) {
425 res.code = 200;
426 res.body = worklist_item_to_json(*updated_item);
427 } else {
428 res.code = 200;
429 res.body = make_success_json("Worklist item updated");
430 }
431 return res;
432 });
433
434 // DELETE /api/v1/worklist/:id - Delete worklist item
435 CROW_ROUTE(app, "/api/v1/worklist/<int>")
436 .methods(crow::HTTPMethod::DELETE)(
437 [ctx](const crow::request & /*req*/, int pk) {
438 crow::response res;
439 res.add_header("Content-Type", "application/json");
440 add_cors_headers(res, *ctx);
441
442 if (!ctx->database) {
443 res.code = 503;
444 res.body = make_error_json("DATABASE_UNAVAILABLE",
445 "Database not configured");
446 return res;
447 }
448
449 // Verify item exists
450 auto item = ctx->database->find_worklist_by_pk(pk);
451 if (!item) {
452 res.code = 404;
453 res.body = make_error_json("NOT_FOUND", "Worklist item not found");
454 return res;
455 }
456
457 auto result = ctx->database->delete_worklist_item(
458 item->step_id, item->accession_no);
459 if (result.is_err()) {
460 res.code = 500;
461 res.body = make_error_json("DELETE_FAILED", result.error().message);
462 return res;
463 }
464
465 res.code = 200;
466 res.body = make_success_json("Worklist item deleted successfully");
467 return res;
468 });
469}
constexpr dicom_tag item
Item.

References kcenon::pacs::storage::worklist_query::limit, kcenon::pacs::web::make_error_json(), kcenon::pacs::web::make_success_json(), and kcenon::pacs::storage::worklist_query::offset.

Referenced by kcenon::pacs::web::rest_server::start_async().

Here is the call graph for this function:
Here is the caller graph for this function: