PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
viewer_state_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
28#ifdef PACS_WITH_DATABASE_SYSTEM
31#endif
37
38#include <chrono>
39#include <iomanip>
40#include <random>
41#include <sstream>
42
44
45namespace {
46
50void add_cors_headers(crow::response &res, const rest_server_context &ctx) {
51 if (ctx.config && !ctx.config->cors_allowed_origins.empty()) {
52 res.add_header("Access-Control-Allow-Origin",
53 ctx.config->cors_allowed_origins);
54 }
55}
56
60std::string generate_uuid() {
61 static std::random_device rd;
62 static std::mt19937 gen(rd());
63 static std::uniform_int_distribution<> dis(0, 15);
64 static const char *hex = "0123456789abcdef";
65
66 std::string uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
67 for (char &c : uuid) {
68 if (c == 'x') {
69 c = hex[dis(gen)];
70 } else if (c == 'y') {
71 c = hex[(dis(gen) & 0x3) | 0x8];
72 }
73 }
74 return uuid;
75}
76
80std::string format_timestamp(
81 const std::chrono::system_clock::time_point &tp) {
82 auto time = std::chrono::system_clock::to_time_t(tp);
83 std::ostringstream oss;
84 oss << std::put_time(std::gmtime(&time), "%Y-%m-%dT%H:%M:%SZ");
85 return oss.str();
86}
87
91std::string viewer_state_to_json(const storage::viewer_state_record &state) {
92 std::ostringstream oss;
93 oss << R"({"state_id":")" << json_escape(state.state_id)
94 << R"(","study_uid":")" << json_escape(state.study_uid)
95 << R"(","user_id":")" << json_escape(state.user_id)
96 << R"(","state":)" << state.state_json
97 << R"(,"created_at":")" << format_timestamp(state.created_at)
98 << R"(","updated_at":")" << format_timestamp(state.updated_at) << R"("})";
99 return oss.str();
100}
101
105std::string viewer_states_to_json(
106 const std::vector<storage::viewer_state_record> &states) {
107 std::ostringstream oss;
108 oss << R"({"data":[)";
109 for (size_t i = 0; i < states.size(); ++i) {
110 if (i > 0) {
111 oss << ",";
112 }
113 oss << viewer_state_to_json(states[i]);
114 }
115 oss << "]}";
116 return oss.str();
117}
118
122std::string recent_study_to_json(const storage::recent_study_record &record) {
123 std::ostringstream oss;
124 oss << R"({"user_id":")" << json_escape(record.user_id)
125 << R"(","study_uid":")" << json_escape(record.study_uid)
126 << R"(","accessed_at":")" << format_timestamp(record.accessed_at) << R"("})";
127 return oss.str();
128}
129
133std::string recent_studies_to_json(
134 const std::vector<storage::recent_study_record> &records,
135 size_t total_count) {
136 std::ostringstream oss;
137 oss << R"({"data":[)";
138 for (size_t i = 0; i < records.size(); ++i) {
139 if (i > 0) {
140 oss << ",";
141 }
142 oss << recent_study_to_json(records[i]);
143 }
144 oss << R"(],"total":)" << total_count << "}";
145 return oss.str();
146}
147
151std::string parse_json_string(const std::string &json, const std::string &key) {
152 std::string search = "\"" + key + "\":\"";
153 auto pos = json.find(search);
154 if (pos == std::string::npos) {
155 return "";
156 }
157 pos += search.length();
158 auto end = json.find("\"", pos);
159 if (end == std::string::npos) {
160 return "";
161 }
162 return json.substr(pos, end - pos);
163}
164
168std::string parse_json_object(const std::string &json, const std::string &key) {
169 std::string search = "\"" + key + "\":";
170 auto pos = json.find(search);
171 if (pos == std::string::npos) {
172 return "{}";
173 }
174 pos += search.length();
175 while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) {
176 ++pos;
177 }
178 if (pos >= json.size() || json[pos] != '{') {
179 return "{}";
180 }
181
182 int depth = 0;
183 size_t start = pos;
184 for (; pos < json.size(); ++pos) {
185 if (json[pos] == '{') {
186 ++depth;
187 } else if (json[pos] == '}') {
188 --depth;
189 if (depth == 0) {
190 return json.substr(start, pos - start + 1);
191 }
192 }
193 }
194 return "{}";
195}
196
200std::string build_state_json(const std::string &body) {
201 // Extract individual state components and build combined JSON
202 std::string layout = parse_json_object(body, "layout");
203 std::string viewports = "[]";
204 std::string active_viewport = "";
205
206 // Parse viewports array
207 std::string viewports_search = "\"viewports\":";
208 auto viewports_pos = body.find(viewports_search);
209 if (viewports_pos != std::string::npos) {
210 viewports_pos += viewports_search.length();
211 while (viewports_pos < body.size() &&
212 (body[viewports_pos] == ' ' || body[viewports_pos] == '\t')) {
213 ++viewports_pos;
214 }
215 if (viewports_pos < body.size() && body[viewports_pos] == '[') {
216 int depth = 0;
217 size_t start = viewports_pos;
218 for (; viewports_pos < body.size(); ++viewports_pos) {
219 if (body[viewports_pos] == '[') {
220 ++depth;
221 } else if (body[viewports_pos] == ']') {
222 --depth;
223 if (depth == 0) {
224 viewports = body.substr(start, viewports_pos - start + 1);
225 break;
226 }
227 }
228 }
229 }
230 }
231
232 active_viewport = parse_json_string(body, "active_viewport");
233
234 std::ostringstream oss;
235 oss << R"({"layout":)" << layout
236 << R"(,"viewports":)" << viewports
237 << R"(,"active_viewport":")" << json_escape(active_viewport) << R"("})";
238 return oss.str();
239}
240
241} // namespace
242
243// Internal implementation function called from rest_server.cpp
244void register_viewer_state_endpoints_impl(crow::SimpleApp &app,
245 std::shared_ptr<rest_server_context> ctx) {
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
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}
539
540} // namespace kcenon::pacs::web::endpoints
Repository for viewer state persistence (legacy SQLite interface)
PACS index database for metadata storage and retrieval.
void register_viewer_state_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
Repository for recent study records using base_repository pattern.
Configuration for REST API server.
Common types and utilities for REST API.
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.
System API endpoints for REST server.
Viewer state API endpoints for REST server.
Viewer state record data structures for database operations.
Repository for viewer state records using base_repository pattern.
Repository for viewer state persistence.