PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
measurement_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
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);
50 }
51}
52
56std::string generate_uuid() {
57 static std::random_device rd;
58 static std::mt19937 gen(rd());
59 static std::uniform_int_distribution<> dis(0, 15);
60 static const char *hex = "0123456789abcdef";
61
62 std::string uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
63 for (char &c : uuid) {
64 if (c == 'x') {
65 c = hex[dis(gen)];
66 } else if (c == 'y') {
67 c = hex[(dis(gen) & 0x3) | 0x8];
68 }
69 }
70 return uuid;
71}
72
76std::string format_timestamp(
77 const std::chrono::system_clock::time_point &tp) {
78 auto time = std::chrono::system_clock::to_time_t(tp);
79 std::ostringstream oss;
80 oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%SZ");
81 return oss.str();
82}
83
87std::string measurement_to_json(const storage::measurement_record &meas) {
88 std::ostringstream oss;
89 oss << R"({"measurement_id":")" << json_escape(meas.measurement_id)
90 << R"(","sop_instance_uid":")" << json_escape(meas.sop_instance_uid)
91 << R"(","frame_number":)";
92 if (meas.frame_number.has_value()) {
93 oss << meas.frame_number.value();
94 } else {
95 oss << "null";
96 }
97 oss << R"(,"user_id":")" << json_escape(meas.user_id)
98 << R"(","measurement_type":")" << json_escape(to_string(meas.type))
99 << R"(","geometry":)" << meas.geometry_json
100 << R"(,"value":)" << meas.value
101 << R"(,"unit":")" << json_escape(meas.unit)
102 << R"(","label":")" << json_escape(meas.label)
103 << R"(","created_at":")" << format_timestamp(meas.created_at) << R"("})";
104 return oss.str();
105}
106
110std::string measurements_to_json(
111 const std::vector<storage::measurement_record> &measurements,
112 size_t total_count) {
113 std::ostringstream oss;
114 oss << R"({"data":[)";
115 for (size_t i = 0; i < measurements.size(); ++i) {
116 if (i > 0) {
117 oss << ",";
118 }
119 oss << measurement_to_json(measurements[i]);
120 }
121 oss << R"(],"pagination":{"total":)" << total_count << R"(,"count":)"
122 << measurements.size() << "}}";
123 return oss.str();
124}
125
129std::pair<size_t, size_t> parse_pagination(const crow::request &req) {
130 size_t limit = 20;
131 size_t offset = 0;
132
133 auto limit_param = req.url_params.get("limit");
134 if (limit_param) {
135 try {
136 limit = std::stoul(limit_param);
137 if (limit > 100) {
138 limit = 100;
139 }
140 } catch (...) {
141 // Use default
142 }
143 }
144
145 auto offset_param = req.url_params.get("offset");
146 if (offset_param) {
147 try {
148 offset = std::stoul(offset_param);
149 } catch (...) {
150 // Use default
151 }
152 }
153
154 return {limit, offset};
155}
156
160std::string parse_json_string(const std::string &json, const std::string &key) {
161 std::string search = "\"" + key + "\":\"";
162 auto pos = json.find(search);
163 if (pos == std::string::npos) {
164 return "";
165 }
166 pos += search.length();
167 auto end = json.find("\"", pos);
168 if (end == std::string::npos) {
169 return "";
170 }
171 return json.substr(pos, end - pos);
172}
173
177std::optional<int> parse_json_int(const std::string &json,
178 const std::string &key) {
179 std::string search = "\"" + key + "\":";
180 auto pos = json.find(search);
181 if (pos == std::string::npos) {
182 return std::nullopt;
183 }
184 pos += search.length();
185 while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) {
186 ++pos;
187 }
188 if (pos >= json.size()) {
189 return std::nullopt;
190 }
191 if (json.substr(pos, 4) == "null") {
192 return std::nullopt;
193 }
194 try {
195 return std::stoi(json.substr(pos));
196 } catch (...) {
197 return std::nullopt;
198 }
199}
200
204double parse_json_double(const std::string &json, const std::string &key) {
205 std::string search = "\"" + key + "\":";
206 auto pos = json.find(search);
207 if (pos == std::string::npos) {
208 return 0.0;
209 }
210 pos += search.length();
211 while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) {
212 ++pos;
213 }
214 if (pos >= json.size()) {
215 return 0.0;
216 }
217 try {
218 return std::stod(json.substr(pos));
219 } catch (...) {
220 return 0.0;
221 }
222}
223
227std::string parse_json_object(const std::string &json, const std::string &key) {
228 std::string search = "\"" + key + "\":";
229 auto pos = json.find(search);
230 if (pos == std::string::npos) {
231 return "{}";
232 }
233 pos += search.length();
234 while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) {
235 ++pos;
236 }
237 if (pos >= json.size() || json[pos] != '{') {
238 return "{}";
239 }
240
241 int depth = 0;
242 size_t start = pos;
243 for (; pos < json.size(); ++pos) {
244 if (json[pos] == '{') {
245 ++depth;
246 } else if (json[pos] == '}') {
247 --depth;
248 if (depth == 0) {
249 return json.substr(start, pos - start + 1);
250 }
251 }
252 }
253 return "{}";
254}
255
256} // namespace
257
258// Internal implementation function called from rest_server.cpp
259void register_measurement_endpoints_impl(crow::SimpleApp &app,
260 std::shared_ptr<rest_server_context> ctx) {
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
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}
553
554} // namespace kcenon::pacs::web::endpoints
Repository for measurement persistence (legacy SQLite interface)
auto find_by_id(std::string_view measurement_id) const -> std::optional< measurement_record >
auto find_by_instance(std::string_view sop_instance_uid) const -> std::vector< measurement_record >
auto remove(std::string_view measurement_id) -> VoidResult
auto search(const measurement_query &query) const -> std::vector< measurement_record >
auto save(const measurement_record &record) -> VoidResult
auto exists(std::string_view measurement_id) const -> bool
PACS index database for metadata storage and retrieval.
Measurement API endpoints for REST server.
Measurement record data structures for database operations.
Repository for measurement persistence using base_repository pattern.
auto measurement_type_from_string(std::string_view str) -> std::optional< measurement_type >
Parse string to measurement_type.
void register_measurement_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
Configuration for REST API server.
Common types and utilities for REST API.
size_t limit
Maximum number of results to return (0 = unlimited)
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.
System API endpoints for REST server.