46void add_cors_headers(crow::response& res,
const rest_server_context& ctx) {
47 if (ctx.config && !ctx.config->cors_allowed_origins.empty()) {
48 res.add_header(
"Access-Control-Allow-Origin",
49 ctx.config->cors_allowed_origins);
56std::string format_timestamp(std::chrono::system_clock::time_point tp) {
57 if (tp == std::chrono::system_clock::time_point{}) {
60 auto time_t_val = std::chrono::system_clock::to_time_t(tp);
63 gmtime_s(&tm_val, &time_t_val);
65 gmtime_r(&time_t_val, &tm_val);
68 std::strftime(buf,
sizeof(buf),
"%Y-%m-%dT%H:%M:%SZ", &tm_val);
75std::string node_to_json(
const client::remote_node& node) {
76 std::ostringstream oss;
77 oss << R
"({"node_id":")" << json_escape(node.node_id)
79 << R"(","ae_title":")" << json_escape(node.ae_title)
81 << R"(","port":)" << node.port
82 << R"(,"supports_find":)" << (node.supports_find ? "true" :
"false")
83 << R
"(,"supports_move":)" << (node.supports_move ? "true" :
"false")
84 << R
"(,"supports_get":)" << (node.supports_get ? "true" :
"false")
85 << R
"(,"supports_store":)" << (node.supports_store ? "true" :
"false")
86 << R
"(,"supports_worklist":)" << (node.supports_worklist ? "true" :
"false")
87 << R
"(,"connection_timeout_sec":)" << node.connection_timeout.count()
88 << R"(,"dimse_timeout_sec":)" << node.dimse_timeout.count()
89 << R"(,"max_associations":)" << node.max_associations
92 auto last_verified = format_timestamp(node.last_verified);
93 if (!last_verified.empty()) {
94 oss << R
"(,"last_verified":")" << last_verified << '"';
97 auto last_error_time = format_timestamp(node.last_error);
98 if (!last_error_time.empty()) {
99 oss << R
"(,"last_error":")" << last_error_time << '"';
102 if (!node.last_error_message.empty()) {
103 oss << R
"(,"last_error_message":")" << json_escape(node.last_error_message) << '"';
106 auto created_at = format_timestamp(node.created_at);
107 if (!created_at.empty()) {
108 oss << R
"(,"created_at":")" << created_at << '"';
111 auto updated_at = format_timestamp(node.updated_at);
112 if (!updated_at.empty()) {
113 oss << R
"(,"updated_at":")" << updated_at << '"';
123std::string nodes_to_json(
const std::vector<client::remote_node>& nodes,
124 size_t total_count) {
125 std::ostringstream oss;
126 oss << R
"({"nodes":[)";
127 for (
size_t i = 0; i < nodes.size(); ++i) {
131 oss << node_to_json(nodes[i]);
133 oss << R
"(],"total":)" << total_count << '}';
140std::optional<client::remote_node> parse_node_from_json(
const std::string& body,
141 std::string& error_message) {
142 client::remote_node node;
145 auto get_string_value = [&body](
const std::string& key) -> std::string {
146 std::string
search =
"\"" + key +
"\":\"";
147 auto pos = body.find(search);
148 if (pos == std::string::npos) {
152 auto end_pos = body.find(
'"', pos);
153 if (end_pos == std::string::npos) {
156 return body.substr(pos, end_pos - pos);
159 auto get_int_value = [&body](
const std::string& key) -> std::optional<int64_t> {
160 std::string
search =
"\"" + key +
"\":";
161 auto pos = body.find(search);
162 if (pos == std::string::npos) {
167 while (pos < body.length() && (body[pos] ==
' ' || body[pos] ==
'\t')) {
170 if (pos >= body.length()) {
175 auto val = std::stoll(body.substr(pos), &idx);
182 auto get_bool_value = [&body](
const std::string& key,
183 bool default_val) ->
bool {
184 std::string
search =
"\"" + key +
"\":";
185 auto pos = body.find(search);
186 if (pos == std::string::npos) {
191 while (pos < body.length() && (body[pos] ==
' ' || body[pos] ==
'\t')) {
194 if (pos >= body.length()) {
197 if (body.substr(pos, 4) ==
"true") {
200 if (body.substr(pos, 5) ==
"false") {
207 node.name = get_string_value(
"name");
208 node.ae_title = get_string_value(
"ae_title");
209 node.host = get_string_value(
"host");
211 if (node.ae_title.empty()) {
212 error_message =
"ae_title is required";
216 if (node.host.empty()) {
217 error_message =
"host is required";
222 auto port_val = get_int_value(
"port");
223 node.port = port_val.value_or(104);
225 node.supports_find = get_bool_value(
"supports_find",
true);
226 node.supports_move = get_bool_value(
"supports_move",
true);
227 node.supports_get = get_bool_value(
"supports_get",
false);
228 node.supports_store = get_bool_value(
"supports_store",
true);
229 node.supports_worklist = get_bool_value(
"supports_worklist",
false);
232 if (connection_timeout) {
233 node.connection_timeout = std::chrono::seconds(*connection_timeout);
236 auto dimse_timeout = get_int_value(
"dimse_timeout_sec");
238 node.dimse_timeout = std::chrono::seconds(*dimse_timeout);
241 auto max_assoc = get_int_value(
"max_associations");
243 node.max_associations =
static_cast<size_t>(*max_assoc);
252std::pair<size_t, size_t> parse_pagination(
const crow::request& req) {
256 auto limit_param = req.url_params.get(
"limit");
259 limit = std::stoul(limit_param);
268 auto offset_param = req.url_params.get(
"offset");
271 offset = std::stoul(offset_param);
277 return {limit, offset};
284 std::shared_ptr<rest_server_context> ctx) {
286 CROW_ROUTE(app,
"/api/v1/remote-nodes")
287 .methods(crow::HTTPMethod::GET)([ctx](
const crow::request& req) {
289 res.add_header(
"Content-Type",
"application/json");
290 add_cors_headers(res, *ctx);
292 if (!ctx->node_manager) {
295 "Remote node manager not configured");
300 auto [limit, offset] = parse_pagination(req);
303 auto status_param = req.url_params.get(
"status");
304 std::vector<client::remote_node> nodes;
308 nodes = ctx->node_manager->list_nodes_by_status(status);
310 nodes = ctx->node_manager->list_nodes();
313 size_t total_count = nodes.size();
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]);
322 res.body = nodes_to_json(paginated, total_count);
327 CROW_ROUTE(app,
"/api/v1/remote-nodes")
328 .methods(crow::HTTPMethod::POST)([ctx](
const crow::request& req) {
330 res.add_header(
"Content-Type",
"application/json");
331 add_cors_headers(res, *ctx);
333 if (!ctx->node_manager) {
336 "Remote node manager not configured");
340 std::string error_message;
341 auto node_opt = parse_node_from_json(req.body, error_message);
348 auto& node = *node_opt;
351 if (node.node_id.empty()) {
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);
359 auto result = ctx->node_manager->add_node(node);
360 if (!result.is_ok()) {
367 auto created_node = ctx->node_manager->get_node(node.node_id);
370 res.body = node_to_json(node);
375 res.body = node_to_json(*created_node);
380 CROW_ROUTE(app,
"/api/v1/remote-nodes/<string>")
381 .methods(crow::HTTPMethod::GET)(
382 [ctx](
const crow::request& ,
const std::string& node_id) {
384 res.add_header(
"Content-Type",
"application/json");
385 add_cors_headers(res, *ctx);
387 if (!ctx->node_manager) {
390 "Remote node manager not configured");
394 auto node = ctx->node_manager->get_node(node_id);
402 res.body = node_to_json(*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) {
411 res.add_header(
"Content-Type",
"application/json");
412 add_cors_headers(res, *ctx);
414 if (!ctx->node_manager) {
417 "Remote node manager not configured");
422 auto existing = ctx->node_manager->get_node(node_id);
429 std::string error_message;
430 auto node_opt = parse_node_from_json(req.body, error_message);
437 auto& node = *node_opt;
438 node.node_id = node_id;
440 auto result = ctx->node_manager->update_node(node);
441 if (!result.is_ok()) {
448 auto updated_node = ctx->node_manager->get_node(node_id);
451 res.body = node_to_json(node);
456 res.body = node_to_json(*updated_node);
461 CROW_ROUTE(app,
"/api/v1/remote-nodes/<string>")
462 .methods(crow::HTTPMethod::DELETE)(
463 [ctx](
const crow::request& ,
const std::string& node_id) {
465 add_cors_headers(res, *ctx);
467 if (!ctx->node_manager) {
468 res.add_header(
"Content-Type",
"application/json");
471 "Remote node manager not configured");
476 auto existing = ctx->node_manager->get_node(node_id);
478 res.add_header(
"Content-Type",
"application/json");
484 auto result = ctx->node_manager->remove_node(node_id);
485 if (!result.is_ok()) {
486 res.add_header(
"Content-Type",
"application/json");
497 CROW_ROUTE(app,
"/api/v1/remote-nodes/<string>/verify")
498 .methods(crow::HTTPMethod::POST)(
499 [ctx](
const crow::request& ,
const std::string& node_id) {
501 res.add_header(
"Content-Type",
"application/json");
502 add_cors_headers(res, *ctx);
504 if (!ctx->node_manager) {
507 "Remote node manager not configured");
512 auto existing = ctx->node_manager->get_node(node_id);
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();
525 if (result.is_ok()) {
526 std::ostringstream oss;
527 oss << R
"({"success":true,"response_time_ms":)" << elapsed_ms << '}';
529 res.body = oss.str();
531 std::ostringstream oss;
532 oss << R
"({"success":false,"error":")"
534 << R"(","response_time_ms":)" << elapsed_ms << '}';
536 res.body = oss.str();
542 CROW_ROUTE(app,
"/api/v1/remote-nodes/<string>/status")
543 .methods(crow::HTTPMethod::GET)(
544 [ctx](
const crow::request& ,
const std::string& node_id) {
546 res.add_header(
"Content-Type",
"application/json");
547 add_cors_headers(res, *ctx);
549 if (!ctx->node_manager) {
552 "Remote node manager not configured");
556 auto node = ctx->node_manager->get_node(node_id);
563 auto stats = ctx->node_manager->get_statistics(node_id);
565 std::ostringstream oss;
568 auto last_verified = format_timestamp(node->last_verified);
569 if (!last_verified.empty()) {
570 oss << R
"(,"last_verified":")" << last_verified << '"';
573 if (!node->last_error_message.empty()) {
574 oss << R
"(,"last_error_message":")"
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;
583 if (stats.avg_response_time.count() > 0) {
584 oss << R
"(,"avg_response_time_ms":)" << stats.avg_response_time.count();
590 res.body = oss.str();
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) {
599 res.add_header(
"Content-Type",
"application/json");
600 add_cors_headers(res, *ctx);
602 if (!ctx->node_manager) {
605 "Remote node manager not configured");
609 auto node = ctx->node_manager->get_node(node_id);
616 if (!node->supports_find) {
619 "Remote node does not support C-FIND queries");
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);
634 auto level = get_str(
"level");
635 if (level.empty()) level =
"STUDY";
638 std::string calling_ae =
"PACS_SCU";
639 if (ctx->dicom_server) {
640 calling_ae = ctx->dicom_server->config().ae_title;
650 {
"1.2.840.10008.1.2.1"}
653 auto timeout = std::chrono::milliseconds(
654 node->connection_timeout.count() * 1000);
657 static_cast<uint16_t
>(node->port),
661 if (assoc_result.is_err()) {
664 "Failed to connect to remote PACS: " +
665 assoc_result.error().message);
669 auto& assoc = assoc_result.value();
673 auto format_query_response = [](
675 std::ostringstream oss;
676 oss << R
"({"matches":[)";
677 for (
size_t i = 0; i < qr.matches.size(); ++i) {
678 if (i > 0) 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 <<
',';
691 oss << R
"(],"total_matches":)" << qr.matches.size()
692 << R"(,"elapsed_ms":)" << qr.elapsed.count()
694 << (qr.is_success() ? "success" :
"error")
700 auto handle_result = [&](
702 (void)assoc.release();
703 if (result.is_err()) {
706 "C-FIND query failed: " + result.error().message);
709 res.body = format_query_response(result.value());
714 if (level ==
"PATIENT") {
719 keys.
sex = get_str(
"sex");
721 handle_result(result);
722 }
else if (level ==
"STUDY") {
728 keys.
modality = get_str(
"modality");
731 handle_result(result);
732 }
else if (level ==
"SERIES") {
736 keys.
modality = get_str(
"modality");
739 (void)assoc.release();
742 "study_uid is required for SERIES level queries");
746 handle_result(result);
747 }
else if (level ==
"IMAGE") {
753 (void)assoc.release();
756 "series_uid is required for IMAGE level queries");
760 handle_result(result);
762 (void)assoc.release();
765 "Invalid query level. Use PATIENT, STUDY, SERIES, "
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) {
778 res.add_header(
"Content-Type",
"application/json");
779 add_cors_headers(res, *ctx);
781 if (!ctx->node_manager) {
784 "Remote node manager not configured");
788 if (!ctx->job_manager) {
791 "Job manager not configured");
795 auto node = ctx->node_manager->get_node(node_id);
802 if (!node->supports_move && !node->supports_get) {
805 "Remote node does not support C-MOVE or C-GET");
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);
820 auto study_uid = get_str(
"study_uid");
821 if (study_uid.empty()) {
824 "study_uid is required");
828 auto series_uid = get_str(
"series_uid");
829 auto priority_str = get_str(
"priority");
833 if (priority_str ==
"low") {
835 }
else if (priority_str ==
"high") {
837 }
else if (priority_str ==
"urgent") {
842 std::optional<std::string_view> series_opt;
843 if (!series_uid.empty()) {
844 series_opt = series_uid;
847 auto job_id = ctx->job_manager->create_retrieve_job(
848 node_id, study_uid, series_opt, priority);
851 auto start_result = ctx->job_manager->start_job(job_id);
852 if (start_result.is_err()) {
855 "Failed to start retrieve job: " +
856 start_result.error().message);
860 std::ostringstream oss;
862 << R"(","status":"pending"})";
865 res.body = oss.str();
DICOM Association management per PS3.8.
static Result< association > connect(const std::string &host, uint16_t port, const association_config &config, duration timeout=default_timeout)
Initiate an SCU association to a remote SCP.
network::Result< query_result > find_patients(network::association &assoc, const patient_query_keys &keys)
Query for patients.
network::Result< query_result > find_instances(network::association &assoc, const instance_query_keys &keys)
Query for instances within a series.
network::Result< query_result > find_studies(network::association &assoc, const study_query_keys &keys)
Query for studies.
network::Result< query_result > find_series(network::association &assoc, const series_query_keys &keys)
Query for series within a study.
Multi-threaded DICOM server for handling multiple associations.
Job manager for asynchronous DICOM operations.
node_status node_status_from_string(std::string_view str) noexcept
Parse node_status from string.
@ low
Background operations.
@ high
User-requested operations.
@ urgent
Critical operations.
@ normal
Standard priority.
constexpr const char * to_string(job_type type) noexcept
Convert job_type to string representation.
constexpr int connection_timeout
constexpr std::string_view study_root_find_sop_class_uid
Study Root Query/Retrieve Information Model - FIND.
constexpr std::string_view search
void register_remote_nodes_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.
std::string json_escape(std::string_view s)
Escape a string for JSON.
DICOM Query SCU service (C-FIND sender)
Remote PACS node data structures for client operations.
Remote PACS node manager for client operations.
Remote PACS node management REST API endpoints.
Configuration for REST API server.
Common types and utilities for REST API.
DICOM Server configuration structures.
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::vector< proposed_presentation_context > proposed_contexts
Query keys for IMAGE (Instance) level queries.
std::string sop_instance_uid
SOP Instance UID (0008,0018)
std::string series_uid
Series Instance UID (0020,000E) - Required.
std::string instance_number
Instance Number (0020,0013)
Query keys for PATIENT level queries.
std::string birth_date
Patient's Birth Date (0010,0030)
std::string sex
Patient's Sex (0010,0040)
std::string patient_id
Patient ID (0010,0020)
std::string patient_name
Patient's Name (0010,0010)
Result of a C-FIND query operation.
Query keys for SERIES level queries.
std::string series_number
Series Number (0020,0011)
std::string series_uid
Series Instance UID (0020,000E)
std::string study_uid
Study Instance UID (0020,000D) - Required.
std::string modality
Modality (0008,0060)
Query keys for STUDY level queries.
std::string accession_number
Accession Number (0008,0050)
std::string study_uid
Study Instance UID (0020,000D)
std::string study_description
Study Description (0008,1030)
std::string modality
Modalities in Study (0008,0061)
std::string patient_id
Patient ID (0010,0020) - for filtering.
std::string study_date
Study Date (0008,0020) - YYYYMMDD or range.
System API endpoints for REST server.