PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
annotation_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
16// IMPORTANT: Include Crow FIRST before any PACS headers to avoid forward
17// declaration conflicts
18#include "crow.h"
19
20// Workaround for Windows: DELETE is defined as a macro in <winnt.h>
21// which conflicts with crow::HTTPMethod::DELETE
22#ifdef DELETE
23#undef DELETE
24#endif
25
33
34#include <chrono>
35#include <iomanip>
36#include <random>
37#include <sstream>
38
40
41namespace {
42
43#ifdef PACS_WITH_DATABASE_SYSTEM
48inline auto make_annotation_repo(storage::index_database* db) {
49 return storage::annotation_repository(db->db_adapter());
50}
51
55inline std::vector<storage::annotation_record> search_annotations(
56 storage::annotation_repository& repo,
57 const storage::annotation_query& query) {
58 auto result = repo.search(query);
59 return result.is_ok() ? std::move(result.value()) : std::vector<storage::annotation_record>{};
60}
61
65inline size_t count_annotations(
66 storage::annotation_repository& repo,
67 const storage::annotation_query& query) {
68 auto result = repo.count_matching(query);
69 return result.is_ok() ? result.value() : 0;
70}
71
75inline std::optional<storage::annotation_record> find_annotation(
76 storage::annotation_repository& repo,
77 const std::string& annotation_id) {
78 auto result = repo.find_by_id(annotation_id);
79 return result.is_ok() ? std::make_optional(std::move(result.value())) : std::nullopt;
80}
81
85inline bool annotation_exists(
86 storage::annotation_repository& repo,
87 const std::string& annotation_id) {
88 auto result = repo.exists(annotation_id);
89 return result.is_ok() && result.value();
90}
91
95inline std::vector<storage::annotation_record> find_by_instance(
96 storage::annotation_repository& repo,
97 const std::string& sop_instance_uid) {
98 auto result = repo.find_by_instance(sop_instance_uid);
99 return result.is_ok() ? std::move(result.value()) : std::vector<storage::annotation_record>{};
100}
101#else
106inline auto make_annotation_repo(storage::index_database* db) {
107 return storage::annotation_repository(db->native_handle());
108}
109
113inline std::vector<storage::annotation_record> search_annotations(
114 storage::annotation_repository& repo,
115 const storage::annotation_query& query) {
116 return repo.search(query);
117}
118
122inline size_t count_annotations(
123 storage::annotation_repository& repo,
124 const storage::annotation_query& query) {
125 return repo.count(query);
126}
127
131inline std::optional<storage::annotation_record> find_annotation(
132 storage::annotation_repository& repo,
133 const std::string& annotation_id) {
134 return repo.find_by_id(annotation_id);
135}
136
140inline bool annotation_exists(
141 storage::annotation_repository& repo,
142 const std::string& annotation_id) {
143 return repo.exists(annotation_id);
144}
145
149inline std::vector<storage::annotation_record> find_by_instance(
150 storage::annotation_repository& repo,
151 const std::string& sop_instance_uid) {
152 return repo.find_by_instance(sop_instance_uid);
153}
154#endif
155
159void add_cors_headers(crow::response &res, const rest_server_context &ctx) {
160 if (ctx.config && !ctx.config->cors_allowed_origins.empty()) {
161 res.add_header("Access-Control-Allow-Origin",
162 ctx.config->cors_allowed_origins);
163 }
164}
165
169std::string generate_uuid() {
170 static std::random_device rd;
171 static std::mt19937 gen(rd());
172 static std::uniform_int_distribution<> dis(0, 15);
173 static const char *hex = "0123456789abcdef";
174
175 std::string uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
176 for (char &c : uuid) {
177 if (c == 'x') {
178 c = hex[dis(gen)];
179 } else if (c == 'y') {
180 c = hex[(dis(gen) & 0x3) | 0x8];
181 }
182 }
183 return uuid;
184}
185
189std::string format_timestamp(
190 const std::chrono::system_clock::time_point &tp) {
191 auto time = std::chrono::system_clock::to_time_t(tp);
192 std::ostringstream oss;
193 oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%SZ");
194 return oss.str();
195}
196
200std::string style_to_json(const storage::annotation_style &style) {
201 std::ostringstream oss;
202 oss << R"({"color":")" << json_escape(style.color)
203 << R"(","line_width":)" << style.line_width
204 << R"(,"fill_color":")" << json_escape(style.fill_color)
205 << R"(","fill_opacity":)" << style.fill_opacity
206 << R"(,"font_family":")" << json_escape(style.font_family)
207 << R"(","font_size":)" << style.font_size << "}";
208 return oss.str();
209}
210
214std::string annotation_to_json(const storage::annotation_record &ann) {
215 std::ostringstream oss;
216 oss << R"({"annotation_id":")" << json_escape(ann.annotation_id)
217 << R"(","study_uid":")" << json_escape(ann.study_uid)
218 << R"(","series_uid":")" << json_escape(ann.series_uid)
219 << R"(","sop_instance_uid":")" << json_escape(ann.sop_instance_uid)
220 << R"(","frame_number":)";
221 if (ann.frame_number.has_value()) {
222 oss << ann.frame_number.value();
223 } else {
224 oss << "null";
225 }
226 oss << R"(,"user_id":")" << json_escape(ann.user_id)
227 << R"(","annotation_type":")" << json_escape(to_string(ann.type))
228 << R"(","geometry":)" << ann.geometry_json
229 << R"(,"text":")" << json_escape(ann.text)
230 << R"(","style":)" << style_to_json(ann.style)
231 << R"(,"created_at":")" << format_timestamp(ann.created_at)
232 << R"(","updated_at":")" << format_timestamp(ann.updated_at) << R"("})";
233 return oss.str();
234}
235
239std::string annotations_to_json(
240 const std::vector<storage::annotation_record> &annotations,
241 size_t total_count) {
242 std::ostringstream oss;
243 oss << R"({"data":[)";
244 for (size_t i = 0; i < annotations.size(); ++i) {
245 if (i > 0) {
246 oss << ",";
247 }
248 oss << annotation_to_json(annotations[i]);
249 }
250 oss << R"(],"pagination":{"total":)" << total_count << R"(,"count":)"
251 << annotations.size() << "}}";
252 return oss.str();
253}
254
258std::pair<size_t, size_t> parse_pagination(const crow::request &req) {
259 size_t limit = 20;
260 size_t offset = 0;
261
262 auto limit_param = req.url_params.get("limit");
263 if (limit_param) {
264 try {
265 limit = std::stoul(limit_param);
266 if (limit > 100) {
267 limit = 100;
268 }
269 } catch (...) {
270 // Use default
271 }
272 }
273
274 auto offset_param = req.url_params.get("offset");
275 if (offset_param) {
276 try {
277 offset = std::stoul(offset_param);
278 } catch (...) {
279 // Use default
280 }
281 }
282
283 return {limit, offset};
284}
285
289std::string parse_json_string(const std::string &json, const std::string &key) {
290 std::string search = "\"" + key + "\":\"";
291 auto pos = json.find(search);
292 if (pos == std::string::npos) {
293 return "";
294 }
295 pos += search.length();
296 auto end = json.find("\"", pos);
297 if (end == std::string::npos) {
298 return "";
299 }
300 return json.substr(pos, end - pos);
301}
302
306std::optional<int> parse_json_int(const std::string &json,
307 const std::string &key) {
308 std::string search = "\"" + key + "\":";
309 auto pos = json.find(search);
310 if (pos == std::string::npos) {
311 return std::nullopt;
312 }
313 pos += search.length();
314 while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) {
315 ++pos;
316 }
317 if (pos >= json.size()) {
318 return std::nullopt;
319 }
320 if (json.substr(pos, 4) == "null") {
321 return std::nullopt;
322 }
323 try {
324 return std::stoi(json.substr(pos));
325 } catch (...) {
326 return std::nullopt;
327 }
328}
329
333std::string parse_json_object(const std::string &json, const std::string &key) {
334 std::string search = "\"" + key + "\":";
335 auto pos = json.find(search);
336 if (pos == std::string::npos) {
337 return "{}";
338 }
339 pos += search.length();
340 while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) {
341 ++pos;
342 }
343 if (pos >= json.size() || json[pos] != '{') {
344 return "{}";
345 }
346
347 int depth = 0;
348 size_t start = pos;
349 for (; pos < json.size(); ++pos) {
350 if (json[pos] == '{') {
351 ++depth;
352 } else if (json[pos] == '}') {
353 --depth;
354 if (depth == 0) {
355 return json.substr(start, pos - start + 1);
356 }
357 }
358 }
359 return "{}";
360}
361
365storage::annotation_style parse_style(const std::string &style_json) {
366 storage::annotation_style style;
367 auto color = parse_json_string(style_json, "color");
368 if (!color.empty()) {
369 style.color = color;
370 }
371 auto line_width = parse_json_int(style_json, "line_width");
372 if (line_width.has_value()) {
373 style.line_width = line_width.value();
374 }
375 auto fill_color = parse_json_string(style_json, "fill_color");
376 style.fill_color = fill_color;
377
378 std::string search = "\"fill_opacity\":";
379 auto pos = style_json.find(search);
380 if (pos != std::string::npos) {
381 pos += search.length();
382 try {
383 style.fill_opacity = std::stof(style_json.substr(pos));
384 } catch (...) {
385 // Use default
386 }
387 }
388
389 auto font_family = parse_json_string(style_json, "font_family");
390 if (!font_family.empty()) {
391 style.font_family = font_family;
392 }
393 auto font_size = parse_json_int(style_json, "font_size");
394 if (font_size.has_value()) {
395 style.font_size = font_size.value();
396 }
397 return style;
398}
399
400} // namespace
401
402// Internal implementation function called from rest_server.cpp
403void register_annotation_endpoints_impl(crow::SimpleApp &app,
404 std::shared_ptr<rest_server_context> ctx) {
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
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}
685
686} // namespace kcenon::pacs::web::endpoints
Annotation API endpoints for REST server.
Annotation record data structures for database operations.
return style
style line_width
auto font_family
style fill_color
style font_size
Repository for annotation persistence using base_repository pattern.
PACS index database for metadata storage and retrieval.
void register_annotation_endpoints_impl(crow::SimpleApp &app, std::shared_ptr< rest_server_context > ctx)
Configuration for REST API server.
Common types and utilities for REST API.
size_t limit
Maximum number of results to return (0 = unlimited)
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.
System API endpoints for REST server.