PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
routing_endpoints.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
17// IMPORTANT: Include Crow FIRST before any PACS headers to avoid forward
18// declaration conflicts
19#include "crow.h"
20
21// Workaround for Windows: DELETE is defined as a macro in <winnt.h>
22// which conflicts with crow::HTTPMethod::DELETE
23#ifdef DELETE
24#undef DELETE
25#endif
26
36
37#include <chrono>
38#include <sstream>
39#include <unordered_map>
40#include <vector>
41
43
44namespace {
45
49void add_cors_headers(crow::response& res, const rest_server_context& ctx) {
50 if (ctx.config && !ctx.config->cors_allowed_origins.empty()) {
51 res.add_header("Access-Control-Allow-Origin",
52 ctx.config->cors_allowed_origins);
53 }
54}
55
59std::string format_timestamp(std::chrono::system_clock::time_point tp) {
60 if (tp == std::chrono::system_clock::time_point{}) {
61 return "";
62 }
63 auto time_t_val = std::chrono::system_clock::to_time_t(tp);
64 std::tm tm_val{};
65#ifdef _WIN32
66 gmtime_s(&tm_val, &time_t_val);
67#else
68 gmtime_r(&time_t_val, &tm_val);
69#endif
70 char buf[32];
71 std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_val);
72 return buf;
73}
74
78std::string priority_to_string(client::job_priority priority) {
79 switch (priority) {
80 case client::job_priority::low: return "low";
81 case client::job_priority::normal: return "normal";
82 case client::job_priority::high: return "high";
83 case client::job_priority::urgent: return "urgent";
84 default: return "normal";
85 }
86}
87
91client::job_priority priority_from_string(const std::string& str) {
92 if (str == "low") return client::job_priority::low;
93 if (str == "high") return client::job_priority::high;
94 if (str == "urgent") return client::job_priority::urgent;
96}
97
101std::string condition_to_json(const client::routing_condition& condition) {
102 std::ostringstream oss;
103 oss << R"({"field":")" << client::to_string(condition.match_field)
104 << R"(","pattern":")" << json_escape(condition.pattern)
105 << R"(","case_sensitive":)" << (condition.case_sensitive ? "true" : "false")
106 << R"(,"negate":)" << (condition.negate ? "true" : "false")
107 << '}';
108 return oss.str();
109}
110
114std::string action_to_json(const client::routing_action& action) {
115 std::ostringstream oss;
116 oss << R"({"destination_node_id":")" << json_escape(action.destination_node_id)
117 << R"(","priority":")" << priority_to_string(action.priority)
118 << R"(","delay_minutes":)" << action.delay.count()
119 << R"(,"delete_after_send":)" << (action.delete_after_send ? "true" : "false")
120 << R"(,"notify_on_failure":)" << (action.notify_on_failure ? "true" : "false")
121 << '}';
122 return oss.str();
123}
124
128std::string rule_to_json(const client::routing_rule& rule) {
129 std::ostringstream oss;
130 oss << R"({"rule_id":")" << json_escape(rule.rule_id)
131 << R"(","name":")" << json_escape(rule.name)
132 << R"(","description":")" << json_escape(rule.description)
133 << R"(","enabled":)" << (rule.enabled ? "true" : "false")
134 << R"(,"priority":)" << rule.priority;
135
136 // Conditions
137 oss << R"(,"conditions":[)";
138 for (size_t i = 0; i < rule.conditions.size(); ++i) {
139 if (i > 0) oss << ',';
140 oss << condition_to_json(rule.conditions[i]);
141 }
142 oss << ']';
143
144 // Actions
145 oss << R"(,"actions":[)";
146 for (size_t i = 0; i < rule.actions.size(); ++i) {
147 if (i > 0) oss << ',';
148 oss << action_to_json(rule.actions[i]);
149 }
150 oss << ']';
151
152 // Schedule (optional)
153 if (rule.schedule_cron.has_value()) {
154 oss << R"(,"schedule_cron":")" << json_escape(*rule.schedule_cron) << '"';
155 }
156
157 // Statistics
158 oss << R"(,"triggered_count":)" << rule.triggered_count
159 << R"(,"success_count":)" << rule.success_count
160 << R"(,"failure_count":)" << rule.failure_count;
161
162 auto last_triggered = format_timestamp(rule.last_triggered);
163 if (!last_triggered.empty()) {
164 oss << R"(,"last_triggered":")" << last_triggered << '"';
165 }
166
167 auto created_at = format_timestamp(rule.created_at);
168 if (!created_at.empty()) {
169 oss << R"(,"created_at":")" << created_at << '"';
170 }
171
172 auto updated_at = format_timestamp(rule.updated_at);
173 if (!updated_at.empty()) {
174 oss << R"(,"updated_at":")" << updated_at << '"';
175 }
176
177 oss << '}';
178 return oss.str();
179}
180
184std::string rules_to_json(const std::vector<client::routing_rule>& rules,
185 size_t total_count) {
186 std::ostringstream oss;
187 oss << R"({"rules":[)";
188 for (size_t i = 0; i < rules.size(); ++i) {
189 if (i > 0) oss << ',';
190 oss << rule_to_json(rules[i]);
191 }
192 oss << R"(],"total":)" << total_count << '}';
193 return oss.str();
194}
195
199std::string get_json_string(const std::string& body, const std::string& key) {
200 std::string search = "\"" + key + "\":\"";
201 auto pos = body.find(search);
202 if (pos == std::string::npos) {
203 return "";
204 }
205 pos += search.length();
206 auto end_pos = body.find('"', pos);
207 if (end_pos == std::string::npos) {
208 return "";
209 }
210 return body.substr(pos, end_pos - pos);
211}
212
216std::optional<int64_t> get_json_int(const std::string& body, const std::string& key) {
217 std::string search = "\"" + key + "\":";
218 auto pos = body.find(search);
219 if (pos == std::string::npos) {
220 return std::nullopt;
221 }
222 pos += search.length();
223 while (pos < body.length() && (body[pos] == ' ' || body[pos] == '\t')) {
224 ++pos;
225 }
226 if (pos >= body.length()) {
227 return std::nullopt;
228 }
229 try {
230 size_t idx = 0;
231 auto val = std::stoll(body.substr(pos), &idx);
232 return val;
233 } catch (...) {
234 return std::nullopt;
235 }
236}
237
241bool get_json_bool(const std::string& body, const std::string& key, bool default_val) {
242 std::string search = "\"" + key + "\":";
243 auto pos = body.find(search);
244 if (pos == std::string::npos) {
245 return default_val;
246 }
247 pos += search.length();
248 while (pos < body.length() && (body[pos] == ' ' || body[pos] == '\t')) {
249 ++pos;
250 }
251 if (pos >= body.length()) {
252 return default_val;
253 }
254 if (body.substr(pos, 4) == "true") {
255 return true;
256 }
257 if (body.substr(pos, 5) == "false") {
258 return false;
259 }
260 return default_val;
261}
262
266std::vector<client::routing_condition> parse_conditions(const std::string& body) {
267 std::vector<client::routing_condition> conditions;
268
269 // Find conditions array
270 auto start = body.find("\"conditions\":[");
271 if (start == std::string::npos) {
272 return conditions;
273 }
274 start += 14; // skip "conditions":[
275
276 auto end = body.find(']', start);
277 if (end == std::string::npos) {
278 return conditions;
279 }
280
281 std::string arr = body.substr(start, end - start);
282
283 // Parse each condition object
284 size_t obj_start = 0;
285 while ((obj_start = arr.find('{', obj_start)) != std::string::npos) {
286 auto obj_end = arr.find('}', obj_start);
287 if (obj_end == std::string::npos) break;
288
289 std::string obj = arr.substr(obj_start, obj_end - obj_start + 1);
290
291 client::routing_condition cond;
292 auto field_str = get_json_string(obj, "field");
293 cond.match_field = client::routing_field_from_string(field_str);
294 cond.pattern = get_json_string(obj, "pattern");
295 cond.case_sensitive = get_json_bool(obj, "case_sensitive", false);
296 cond.negate = get_json_bool(obj, "negate", false);
297
298 if (!cond.pattern.empty()) {
299 conditions.push_back(std::move(cond));
300 }
301
302 obj_start = obj_end + 1;
303 }
304
305 return conditions;
306}
307
311std::vector<client::routing_action> parse_actions(const std::string& body) {
312 std::vector<client::routing_action> actions;
313
314 // Find actions array
315 auto start = body.find("\"actions\":[");
316 if (start == std::string::npos) {
317 return actions;
318 }
319 start += 11; // skip "actions":[
320
321 auto end = body.find(']', start);
322 if (end == std::string::npos) {
323 return actions;
324 }
325
326 std::string arr = body.substr(start, end - start);
327
328 // Parse each action object
329 size_t obj_start = 0;
330 while ((obj_start = arr.find('{', obj_start)) != std::string::npos) {
331 auto obj_end = arr.find('}', obj_start);
332 if (obj_end == std::string::npos) break;
333
334 std::string obj = arr.substr(obj_start, obj_end - obj_start + 1);
335
336 client::routing_action action;
337 action.destination_node_id = get_json_string(obj, "destination_node_id");
338 auto priority_str = get_json_string(obj, "priority");
339 action.priority = priority_from_string(priority_str);
340
341 auto delay = get_json_int(obj, "delay_minutes");
342 if (delay) {
343 action.delay = std::chrono::minutes(*delay);
344 }
345
346 action.delete_after_send = get_json_bool(obj, "delete_after_send", false);
347 action.notify_on_failure = get_json_bool(obj, "notify_on_failure", true);
348
349 if (!action.destination_node_id.empty()) {
350 actions.push_back(std::move(action));
351 }
352
353 obj_start = obj_end + 1;
354 }
355
356 return actions;
357}
358
362std::optional<client::routing_rule> parse_rule_from_json(const std::string& body,
363 std::string& error_message) {
364 client::routing_rule rule;
365
366 // Required: name
367 rule.name = get_json_string(body, "name");
368 if (rule.name.empty()) {
369 error_message = "name is required";
370 return std::nullopt;
371 }
372
373 // Optional fields
374 rule.description = get_json_string(body, "description");
375 rule.enabled = get_json_bool(body, "enabled", true);
376
377 auto priority = get_json_int(body, "priority");
378 if (priority) {
379 rule.priority = static_cast<int>(*priority);
380 }
381
382 // Parse conditions
383 rule.conditions = parse_conditions(body);
384 if (rule.conditions.empty()) {
385 error_message = "at least one condition is required";
386 return std::nullopt;
387 }
388
389 // Parse actions
390 rule.actions = parse_actions(body);
391 if (rule.actions.empty()) {
392 error_message = "at least one action is required";
393 return std::nullopt;
394 }
395
396 // Optional schedule
397 auto schedule = get_json_string(body, "schedule_cron");
398 if (!schedule.empty()) {
399 rule.schedule_cron = schedule;
400 }
401
402 return rule;
403}
404
408std::vector<std::string> parse_rule_ids(const std::string& body) {
409 std::vector<std::string> ids;
410
411 // Find rule_ids array
412 auto start = body.find("\"rule_ids\":[");
413 if (start == std::string::npos) {
414 return ids;
415 }
416 start += 12; // skip "rule_ids":[
417
418 auto end = body.find(']', start);
419 if (end == std::string::npos) {
420 return ids;
421 }
422
423 std::string arr = body.substr(start, end - start);
424
425 // Parse each string
426 size_t str_start = 0;
427 while ((str_start = arr.find('"', str_start)) != std::string::npos) {
428 ++str_start;
429 auto str_end = arr.find('"', str_start);
430 if (str_end == std::string::npos) break;
431
432 std::string id = arr.substr(str_start, str_end - str_start);
433 if (!id.empty()) {
434 ids.push_back(std::move(id));
435 }
436
437 str_start = str_end + 1;
438 }
439
440 return ids;
441}
442
446std::pair<size_t, size_t> parse_pagination(const crow::request& req) {
447 size_t limit = 20;
448 size_t offset = 0;
449
450 auto limit_param = req.url_params.get("limit");
451 if (limit_param) {
452 try {
453 limit = std::stoul(limit_param);
454 if (limit > 100) {
455 limit = 100;
456 }
457 } catch (...) {
458 // Use default
459 }
460 }
461
462 auto offset_param = req.url_params.get("offset");
463 if (offset_param) {
464 try {
465 offset = std::stoul(offset_param);
466 } catch (...) {
467 // Use default
468 }
469 }
470
471 return {limit, offset};
472}
473
480core::dicom_dataset parse_test_dataset(const std::string& body) {
481 core::dicom_dataset dataset;
482
483 // Find dataset object
484 auto start = body.find("\"dataset\":{");
485 if (start == std::string::npos) {
486 start = body.find("\"dataset\": {");
487 }
488 if (start == std::string::npos) {
489 return dataset;
490 }
491
492 // Find the opening brace of dataset
493 auto brace_start = body.find('{', start);
494 if (brace_start == std::string::npos) {
495 return dataset;
496 }
497
498 // Find matching closing brace (handle nested objects)
499 int brace_count = 1;
500 size_t brace_end = brace_start + 1;
501 while (brace_end < body.length() && brace_count > 0) {
502 if (body[brace_end] == '{') {
503 ++brace_count;
504 } else if (body[brace_end] == '}') {
505 --brace_count;
506 }
507 ++brace_end;
508 }
509
510 if (brace_count != 0) {
511 return dataset;
512 }
513
514 std::string dataset_json = body.substr(brace_start, brace_end - brace_start);
515
516 // Map of JSON field names to DICOM tags
517 // These match the routing_field enum values in routing_types.hpp
518 struct FieldMapping {
519 const char* json_key;
520 core::dicom_tag tag;
521 };
522
523 static const FieldMapping field_mappings[] = {
524 {"modality", core::tags::modality},
525 {"station_ae", core::tags::station_name},
526 {"institution", core::tags::institution_name},
527 {"department", core::dicom_tag{0x0008, 0x1040}}, // Institutional Department
528 {"referring_physician", core::tags::referring_physician_name},
529 {"study_description", core::tags::study_description},
530 {"series_description", core::tags::series_description},
531 {"body_part", core::dicom_tag{0x0018, 0x0015}}, // Body Part Examined
532 {"patient_id_pattern", core::tags::patient_id},
533 {"patient_id", core::tags::patient_id},
534 {"sop_class_uid", core::tags::sop_class_uid},
535 };
536
537 for (const auto& mapping : field_mappings) {
538 std::string value = get_json_string(dataset_json, mapping.json_key);
539 if (!value.empty()) {
540 dataset.set_string(mapping.tag, encoding::vr_type::LO, value);
541 }
542 }
543
544 return dataset;
545}
546
550std::string test_result_to_json(
551 bool matched,
552 const std::vector<std::pair<std::string, std::vector<client::routing_action>>>& matches,
553 const std::shared_ptr<client::routing_manager>& routing_manager) {
554
555 std::ostringstream oss;
556 oss << R"({"matched":)" << (matched ? "true" : "false");
557
558 oss << R"(,"matched_rules":[)";
559 bool first = true;
560 for (const auto& [rule_id, actions] : matches) {
561 if (!first) oss << ',';
562 first = false;
563
564 // Get rule name
565 std::string rule_name;
566 auto rule = routing_manager->get_rule(rule_id);
567 if (rule) {
568 rule_name = rule->name;
569 }
570
571 oss << R"({"rule_id":")" << json_escape(rule_id)
572 << R"(","rule_name":")" << json_escape(rule_name)
573 << R"(","actions":[)";
574
575 bool first_action = true;
576 for (const auto& action : actions) {
577 if (!first_action) oss << ',';
578 first_action = false;
579 oss << action_to_json(action);
580 }
581
582 oss << "]}";
583 }
584 oss << "]}";
585
586 return oss.str();
587}
588
592std::string single_rule_test_to_json(bool matched,
593 const std::vector<client::routing_action>& actions) {
594 std::ostringstream oss;
595 oss << R"({"matched":)" << (matched ? "true" : "false")
596 << R"(,"actions":[)";
597
598 bool first = true;
599 for (const auto& action : actions) {
600 if (!first) oss << ',';
601 first = false;
602 oss << action_to_json(action);
603 }
604
605 oss << "]}";
606 return oss.str();
607}
608
609} // namespace
610
611// Internal implementation function called from rest_server.cpp
612void register_routing_endpoints_impl(crow::SimpleApp& app,
613 std::shared_ptr<rest_server_context> ctx) {
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) {
1013 value = dataset.get_string(core::tags::modality);
1014 break;
1016 value = dataset.get_string(core::tags::station_name);
1017 break;
1019 value = dataset.get_string(core::tags::institution_name);
1020 break;
1022 value = dataset.get_string(core::dicom_tag{0x0008, 0x1040});
1023 break;
1025 value = dataset.get_string(core::tags::referring_physician_name);
1026 break;
1028 value = dataset.get_string(core::tags::study_description);
1029 break;
1031 value = dataset.get_string(core::tags::series_description);
1032 break;
1034 value = dataset.get_string(core::dicom_tag{0x0018, 0x0015});
1035 break;
1037 value = dataset.get_string(core::tags::patient_id);
1038 break;
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}
1113
1114} // namespace kcenon::pacs::web::endpoints
DICOM Dataset - ordered collection of Data Elements.
Compile-time constants for commonly used DICOM tags.
@ series_description
(0008,103E) Series Description
@ department
(0008,1040) Institutional Department Name
@ body_part
(0018,0015) Body Part Examined
@ study_description
(0008,1030) Study Description
@ modality
(0008,0060) Modality - CT, MR, US, etc.
@ sop_class_uid
(0008,0016) SOP Class UID
@ institution
(0008,0080) Institution Name
@ station_ae
(0008,1010) Station Name or calling AE
@ patient_id_pattern
(0010,0020) Patient ID (pattern matching)
@ referring_physician
(0008,0090) Referring Physician's Name
job_priority
Priority level for job execution.
Definition job_types.h:154
@ low
Background operations.
@ high
User-requested operations.
@ urgent
Critical operations.
constexpr const char * to_string(job_type type) noexcept
Convert job_type to string representation.
Definition job_types.h:54
routing_field routing_field_from_string(std::string_view str) noexcept
Parse routing_field from string.
constexpr dicom_tag referring_physician_name
Referring Physician's Name.
constexpr dicom_tag institution_name
Institution Name.
constexpr dicom_tag study_description
Study Description.
constexpr dicom_tag patient_id
Patient ID.
constexpr dicom_tag priority
Priority.
constexpr dicom_tag modality
Modality.
constexpr dicom_tag station_name
Station Name.
constexpr dicom_tag series_description
Series Description.
constexpr dicom_tag sop_class_uid
SOP Class UID.
@ LO
Long String (64 chars max)
@ empty
Z - Replace with zero-length value.
void register_routing_endpoints_impl(crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
std::string make_error_json(std::string_view code, std::string_view message)
Create JSON error response body with details.
Definition rest_types.h:79
std::string json_escape(std::string_view s)
Escape a string for JSON.
Definition rest_types.h:101
std::variant< interval_schedule, cron_schedule, one_time_schedule > schedule
Combined schedule type.
Configuration for REST API server.
Common types and utilities for REST API.
Routing rule management REST API endpoints.
Routing manager for automatic DICOM image forwarding.
Routing types and structures for auto-forwarding DICOM images.
System API endpoints for REST server.