[med-svn] [Git][med-team/orthanc-dicomweb][upstream] New upstream version 1.0+dfsg
Sebastien Jodogne
gitlab at salsa.debian.org
Wed Jul 31 17:15:05 BST 2019
Sebastien Jodogne pushed to branch upstream at Debian Med / orthanc-dicomweb
Commits:
fd1245b9 by jodogne-guest at 2019-07-31T13:20:42Z
New upstream version 1.0+dfsg
- - - - -
26 changed files:
- .hg_archival.txt
- CMakeLists.txt
- NEWS
- Plugin/Configuration.cpp
- Plugin/Configuration.h
- Plugin/DicomWebClient.cpp
- Plugin/DicomWebClient.h
- Plugin/DicomWebServers.cpp
- Plugin/DicomWebServers.h
- + Plugin/OrthancExplorer.js
- Plugin/Plugin.cpp
- Plugin/QidoRs.cpp
- Plugin/StowRs.cpp
- Plugin/StowRs.h
- Plugin/WadoRs.cpp
- + Resources/CMake/JavaScriptLibraries.cmake
- Resources/Orthanc/DownloadOrthancFramework.cmake
- + Resources/Orthanc/Sdk-1.5.7/orthanc/OrthancCPlugin.h
- + Resources/Samples/Proxy/NOTES.txt
- + Resources/Samples/Proxy/nginx.local.conf
- Resources/Samples/Python/SendStow.py
- Resources/SyncOrthancFolder.py
- Status.txt
- UnitTestsSources/UnitTestsMain.cpp
- + WebApplication/app.js
- + WebApplication/index.html
Changes:
=====================================
.hg_archival.txt
=====================================
@@ -1,6 +1,6 @@
repo: d5f45924411123cfd02d035fd50b8e37536eadef
-node: ce4e5f0769c81584a8851cff3d1b0fd56283574f
-branch: OrthancDicomWeb-0.6
+node: 1e052095bb1d3e3ac5d0c3a5253699867d784579
+branch: OrthancDicomWeb-1.0
latesttag: null
-latesttagdistance: 250
-changessincelatesttag: 264
+latesttagdistance: 323
+changessincelatesttag: 339
=====================================
CMakeLists.txt
=====================================
@@ -21,13 +21,13 @@ cmake_minimum_required(VERSION 2.8)
project(OrthancDicomWeb)
-set(ORTHANC_DICOM_WEB_VERSION "0.6")
+set(ORTHANC_DICOM_WEB_VERSION "1.0")
if (ORTHANC_DICOM_WEB_VERSION STREQUAL "mainline")
set(ORTHANC_FRAMEWORK_VERSION "mainline")
set(ORTHANC_FRAMEWORK_DEFAULT_SOURCE "hg")
else()
- set(ORTHANC_FRAMEWORK_VERSION "1.5.5")
+ set(ORTHANC_FRAMEWORK_VERSION "1.5.7")
set(ORTHANC_FRAMEWORK_DEFAULT_SOURCE "web")
endif()
@@ -42,7 +42,11 @@ set(ORTHANC_FRAMEWORK_ROOT "" CACHE STRING "Path to the Orthanc source directory
# Advanced parameters to fine-tune linking against system libraries
set(USE_SYSTEM_GDCM ON CACHE BOOL "Use the system version of Grassroot DICOM (GDCM)")
set(USE_SYSTEM_ORTHANC_SDK ON CACHE BOOL "Use the system version of the Orthanc plugin SDK")
-set(ORTHANC_SDK_VERSION "1.5.4" CACHE STRING "Version of the Orthanc plugin SDK to use, if not using the system version (can be \"1.5.4\", or \"framework\")")
+set(ORTHANC_SDK_VERSION "1.5.7" CACHE STRING "Version of the Orthanc plugin SDK to use, if not using the system version (can be \"1.5.4\", \"1.5.7\", or \"framework\")")
+
+
+set(BUILD_BOOTSTRAP_VUE OFF CACHE BOOL "Compile Bootstrap-Vue from sources")
+set(BUILD_BABEL_POLYFILL OFF CACHE BOOL "Retrieve babel-polyfill from npm")
@@ -60,13 +64,15 @@ set(USE_BOOST_ICONV ON)
include(${ORTHANC_ROOT}/Resources/CMake/OrthancFrameworkConfiguration.cmake)
include_directories(${ORTHANC_ROOT})
-
include(${CMAKE_SOURCE_DIR}/Resources/CMake/GdcmConfiguration.cmake)
+include(${CMAKE_SOURCE_DIR}/Resources/CMake/JavaScriptLibraries.cmake)
if (STATIC_BUILD OR NOT USE_SYSTEM_ORTHANC_SDK)
if (ORTHANC_SDK_VERSION STREQUAL "1.5.4")
include_directories(${CMAKE_SOURCE_DIR}/Resources/Orthanc/Sdk-1.5.4)
+ elseif (ORTHANC_SDK_VERSION STREQUAL "1.5.7")
+ include_directories(${CMAKE_SOURCE_DIR}/Resources/Orthanc/Sdk-1.5.7)
elseif (ORTHANC_SDK_VERSION STREQUAL "framework")
include_directories(${ORTHANC_ROOT}/Plugins/Include)
else()
@@ -110,11 +116,30 @@ if (APPLE)
endif()
+
+if (STANDALONE_BUILD)
+ add_definitions(-DORTHANC_STANDALONE=1)
+ set(ADDITIONAL_RESOURCES
+ ORTHANC_EXPLORER ${CMAKE_SOURCE_DIR}/Plugin/OrthancExplorer.js
+ WEB_APPLICATION ${CMAKE_SOURCE_DIR}/WebApplication/
+ )
+else()
+ add_definitions(-DORTHANC_STANDALONE=0)
+endif()
+
+EmbedResources(
+ --no-upcase-check
+ ${ADDITIONAL_RESOURCES}
+ JAVASCRIPT_LIBS ${JAVASCRIPT_LIBS_DIR}
+ )
+
+
include_directories(${ORTHANC_ROOT}/Core) # To access "OrthancException.h"
add_definitions(
-DHAS_ORTHANC_EXCEPTION=1
-DORTHANC_ENABLE_LOGGING_PLUGIN=1
+ -DDICOMWEB_CLIENT_PATH="${CMAKE_SOURCE_DIR}/WebApplication/"
)
set(CORE_SOURCES
=====================================
NEWS
=====================================
@@ -2,6 +2,23 @@ Pending changes in the mainline
===============================
+Version 1.0 (2019-06-26)
+========================
+
+=> Recommended SDK version: 1.5.7 <=
+=> Minimum SDK version: 1.5.4 <=
+
+* Web user interface to QIDO-RS, WADO-RS and STOW-RS client
+* First implementation of WADO-RS "Retrieve Rendered Transaction"
+* WADO-RS and STOW-RS client now create Orthanc jobs
+* Support "Transfer-Encoding: chunked" to reduce memory consumption in STOW-RS
+ (provided the SDK version is above 1.5.7)
+* New URI: /dicom-web/servers/.../qido
+* New URI: /dicom-web/servers/.../delete
+* Handling of the HTTP header "Forwarded" for WADO-RS
+* Full refactoring of multipart parsing
+
+
Version 0.6 (2019-02-27)
========================
=====================================
Plugin/Configuration.cpp
=====================================
@@ -21,15 +21,17 @@
#include "Configuration.h"
+#include "DicomWebServers.h"
+
+#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+#include <Core/Toolbox.h>
+
#include <fstream>
#include <json/reader.h>
#include <boost/regex.hpp>
#include <boost/lexical_cast.hpp>
+#include <boost/algorithm/string/predicate.hpp>
-#include "DicomWebServers.h"
-
-#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
-#include <Core/Toolbox.h>
namespace OrthancPlugins
{
@@ -85,220 +87,200 @@ namespace OrthancPlugins
}
- static const boost::regex MULTIPART_HEADERS_ENDING("(.*?\r\n)\r\n(.*)");
- static const boost::regex MULTIPART_HEADERS_LINE(".*?\r\n");
-
- static void ParseMultipartHeaders(bool& hasLength /* out */,
- size_t& length /* out */,
- std::string& contentType /* out */,
- const char* startHeaders,
- const char* endHeaders)
+ void ParseAssociativeArray(std::map<std::string, std::string>& target,
+ const Json::Value& value)
{
- hasLength = false;
- contentType = "application/octet-stream";
+ if (value.type() != Json::objectValue)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "The JSON object is not a JSON associative array as expected");
+ }
- // Loop over the HTTP headers of this multipart item
- boost::cregex_token_iterator it(startHeaders, endHeaders, MULTIPART_HEADERS_LINE, 0);
- boost::cregex_token_iterator iteratorEnd;
+ Json::Value::Members names = value.getMemberNames();
- for (; it != iteratorEnd; ++it)
+ for (size_t i = 0; i < names.size(); i++)
{
- const std::string line(*it);
- size_t colon = line.find(':');
- size_t eol = line.find('\r');
-
- if (colon != std::string::npos &&
- eol != std::string::npos &&
- colon < eol &&
- eol + 2 == line.length())
+ if (value[names[i]].type() != Json::stringValue)
{
- std::string key = Orthanc::Toolbox::StripSpaces(line.substr(0, colon));
- Orthanc::Toolbox::ToLowerCase(key);
-
- const std::string value = Orthanc::Toolbox::StripSpaces(line.substr(colon + 1, eol - colon - 1));
-
- if (key == "content-length")
- {
- try
- {
- int tmp = boost::lexical_cast<int>(value);
- if (tmp >= 0)
- {
- hasLength = true;
- length = tmp;
- }
- }
- catch (boost::bad_lexical_cast&)
- {
- LogWarning("Unable to parse the Content-Length of a multipart item");
- }
- }
- else if (key == "content-type")
- {
- contentType = value;
- }
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "Value \"" + names[i] + "\" in the associative array "
+ "is not a string as expected");
+ }
+ else
+ {
+ target[names[i]] = value[names[i]].asString();
}
}
}
+
-
- static const char* ParseMultipartItem(std::vector<MultipartItem>& result,
- const char* start,
- const char* end,
- const boost::regex& nextSeparator)
+ void ParseAssociativeArray(std::map<std::string, std::string>& target,
+ const Json::Value& value,
+ const std::string& key)
{
- // Just before "start", it is guaranteed that "--[BOUNDARY]\r\n" is present
-
- boost::cmatch what;
- if (!boost::regex_match(start, end, what, MULTIPART_HEADERS_ENDING, boost::match_perl))
+ if (value.type() != Json::objectValue)
{
- // Cannot find the HTTP headers of this multipart item
- throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "This is not a JSON object");
}
- // Some aliases for more clarity
- assert(what[1].first == start);
- const char* startHeaders = what[1].first;
- const char* endHeaders = what[1].second;
- const char* startBody = what[2].first;
-
- bool hasLength;
- size_t length;
- std::string contentType;
- ParseMultipartHeaders(hasLength, length, contentType, startHeaders, endHeaders);
+ if (value.isMember(key))
+ {
+ ParseAssociativeArray(target, value[key]);
+ }
+ else
+ {
+ target.clear();
+ }
+ }
- boost::cmatch separator;
- if (hasLength)
+ bool ParseTag(Orthanc::DicomTag& target,
+ const std::string& name)
+ {
+ OrthancPluginDictionaryEntry entry;
+
+ if (OrthancPluginLookupDictionary(OrthancPlugins::GetGlobalContext(), &entry, name.c_str()) == OrthancPluginErrorCode_Success)
{
- if (!boost::regex_match(startBody + length, end, separator, nextSeparator, boost::match_perl) ||
- startBody + length != separator[1].first)
- {
- // Cannot find the separator after skipping the "Content-Length" bytes
- throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
- }
+ target = Orthanc::DicomTag(entry.group, entry.element);
+ return true;
}
else
{
- if (!boost::regex_match(startBody, end, separator, nextSeparator, boost::match_perl))
- {
- // No more occurrence of the boundary separator
- throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
- }
+ return false;
}
+ }
- MultipartItem item;
- item.data_ = startBody;
- item.size_ = separator[1].first - startBody;
- item.contentType_ = contentType;
- result.push_back(item);
- return separator[1].second; // Return the end of the separator
+ void ParseJsonBody(Json::Value& target,
+ const OrthancPluginHttpRequest* request)
+ {
+ Json::Reader reader;
+ if (!reader.parse(reinterpret_cast<const char*>(request->body),
+ reinterpret_cast<const char*>(request->body) + request->bodySize, target))
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "A JSON file was expected");
+ }
}
- void ParseMultipartBody(std::vector<MultipartItem>& result,
- const char* body,
- const uint64_t bodySize,
- const std::string& boundary)
+ std::string RemoveMultipleSlashes(const std::string& source)
{
- // Reference:
- // https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
+ std::string target;
+ target.reserve(source.size());
- result.clear();
-
- // Look for the first boundary separator in the body (note the "?"
- // to request non-greedy search)
- const boost::regex firstSeparator1("--" + boundary + "(--|\r\n).*");
- const boost::regex firstSeparator2(".*?\r\n--" + boundary + "(--|\r\n).*");
+ size_t prefix = 0;
+
+ if (boost::starts_with(source, "https://"))
+ {
+ prefix = 8;
+ }
+ else if (boost::starts_with(source, "http://"))
+ {
+ prefix = 7;
+ }
- // Look for the next boundary separator in the body (note the "?"
- // to request non-greedy search)
- const boost::regex nextSeparator(".*?(\r\n--" + boundary + ").*");
+ for (size_t i = 0; i < prefix; i++)
+ {
+ target.push_back(source[i]);
+ }
- const char* end = body + bodySize;
+ bool isLastSlash = false;
- boost::cmatch what;
- if (boost::regex_match(body, end, what, firstSeparator1, boost::match_perl | boost::match_single_line) ||
- boost::regex_match(body, end, what, firstSeparator2, boost::match_perl | boost::match_single_line))
+ for (size_t i = prefix; i < source.size(); i++)
{
- const char* current = what[1].first;
-
- while (current != NULL &&
- current + 2 < end)
+ if (source[i] == '/')
{
- if (current[0] != '\r' ||
- current[1] != '\n')
- {
- // We reached a separator with a trailing "--", which
- // means that reading the multipart body is done
- break;
- }
- else
+ if (!isLastSlash)
{
- current = ParseMultipartItem(result, current + 2, end, nextSeparator);
+ target.push_back('/');
+ isLastSlash = true;
}
}
+ else
+ {
+ target.push_back(source[i]);
+ isLastSlash = false;
+ }
}
+
+ return target;
}
- void ParseAssociativeArray(std::map<std::string, std::string>& target,
- const Json::Value& value,
- const std::string& key)
+ bool LookupStringValue(std::string& target,
+ const Json::Value& json,
+ const std::string& key)
{
- if (value.type() != Json::objectValue)
+ if (json.type() != Json::objectValue)
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
- "This is not a JSON object");
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
}
-
- if (!value.isMember(key))
+ else if (!json.isMember(key))
{
- return;
+ return false;
}
-
- const Json::Value& tmp = value[key];
-
- if (tmp.type() != Json::objectValue)
+ else if (json[key].type() != Json::stringValue)
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
- "The field \"" + key + "\" of a JSON object is "
- "not a JSON associative array as expected");
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_BadFileFormat,
+ "The field \"" + key + "\" in a JSON object should be a string");
}
+ else
+ {
+ target = json[key].asString();
+ return true;
+ }
+ }
- Json::Value::Members names = tmp.getMemberNames();
- for (size_t i = 0; i < names.size(); i++)
+ bool LookupIntegerValue(int& target,
+ const Json::Value& json,
+ const std::string& key)
+ {
+ if (json.type() != Json::objectValue)
{
- if (tmp[names[i]].type() != Json::stringValue)
- {
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
- "Some value in the associative array \"" + key +
- "\" is not a string as expected");
- }
- else
- {
- target[names[i]] = tmp[names[i]].asString();
- }
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+ }
+ else if (!json.isMember(key))
+ {
+ return false;
+ }
+ else if (json[key].type() != Json::intValue &&
+ json[key].type() != Json::uintValue)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+ }
+ else
+ {
+ target = json[key].asInt();
+ return true;
}
}
- bool ParseTag(Orthanc::DicomTag& target,
- const std::string& name)
+ bool LookupBooleanValue(bool& target,
+ const Json::Value& json,
+ const std::string& key)
{
- OrthancPluginDictionaryEntry entry;
-
- if (OrthancPluginLookupDictionary(OrthancPlugins::GetGlobalContext(), &entry, name.c_str()) == OrthancPluginErrorCode_Success)
+ if (json.type() != Json::objectValue)
{
- target = Orthanc::DicomTag(entry.group, entry.element);
- return true;
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
}
- else
+ else if (!json.isMember(key))
{
return false;
}
+ else if (json[key].type() != Json::booleanValue)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+ }
+ else
+ {
+ target = json[key].asBool();
+ return true;
+ }
}
@@ -352,7 +334,7 @@ namespace OrthancPlugins
}
- std::string GetRoot()
+ std::string GetDicomWebRoot()
{
assert(configuration_.get() != NULL);
std::string root = configuration_->GetStringValue("Root", "/dicom-web/");
@@ -372,6 +354,40 @@ namespace OrthancPlugins
return root;
}
+
+ std::string GetOrthancApiRoot()
+ {
+ std::string root = OrthancPlugins::Configuration::GetDicomWebRoot();
+ std::vector<std::string> tokens;
+ Orthanc::Toolbox::TokenizeString(tokens, root, '/');
+
+ int depth = 0;
+ for (size_t i = 0; i < tokens.size(); i++)
+ {
+ if (tokens[i].empty() ||
+ tokens[i] == ".")
+ {
+ // Don't change the depth
+ }
+ else if (tokens[i] == "..")
+ {
+ depth--;
+ }
+ else
+ {
+ depth++;
+ }
+ }
+
+ std::string orthancRoot = "./";
+ for (int i = 0; i < depth; i++)
+ {
+ orthancRoot += "../";
+ }
+
+ return orthancRoot;
+ }
+
std::string GetWadoRoot()
{
@@ -395,21 +411,116 @@ namespace OrthancPlugins
}
- std::string GetBaseUrl(const OrthancPluginHttpRequest* request)
+ static bool IsHttpsProto(const std::string& proto,
+ bool defaultValue)
+ {
+ if (proto == "http")
+ {
+ return false;
+ }
+ else if (proto == "https")
+ {
+ return true;
+ }
+ else
+ {
+ return defaultValue;
+ }
+ }
+
+
+ static bool LookupHttpHeader2(std::string& value,
+ const OrthancPlugins::HttpClient::HttpHeaders& headers,
+ const std::string& name)
+ {
+ for (OrthancPlugins::HttpClient::HttpHeaders::const_iterator
+ it = headers.begin(); it != headers.end(); ++it)
+ {
+ if (boost::iequals(it->first, name))
+ {
+ value = it->second;
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+
+ std::string GetBaseUrl(const OrthancPlugins::HttpClient::HttpHeaders& headers)
{
assert(configuration_.get() != NULL);
std::string host = configuration_->GetStringValue("Host", "");
- bool ssl = configuration_->GetBooleanValue("Ssl", false);
+ bool https = configuration_->GetBooleanValue("Ssl", false);
+
+ std::string forwarded;
+ if (host.empty() &&
+ LookupHttpHeader2(forwarded, headers, "forwarded"))
+ {
+ // There is a "Forwarded" HTTP header in the query
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
+
+ std::vector<std::string> forwarders;
+ Orthanc::Toolbox::TokenizeString(forwarders, forwarded, ',');
+
+ // Only consider the first forwarder, if any
+ if (!forwarders.empty())
+ {
+ std::vector<std::string> tokens;
+ Orthanc::Toolbox::TokenizeString(tokens, forwarders[0], ';');
+
+ for (size_t j = 0; j < tokens.size(); j++)
+ {
+ std::vector<std::string> args;
+ Orthanc::Toolbox::TokenizeString(args, tokens[j], '=');
+
+ if (args.size() == 2)
+ {
+ std::string key = Orthanc::Toolbox::StripSpaces(args[0]);
+ std::string value = Orthanc::Toolbox::StripSpaces(args[1]);
+
+ Orthanc::Toolbox::ToLowerCase(key);
+ if (key == "host")
+ {
+ host = value;
+ }
+ else if (key == "proto")
+ {
+ https = IsHttpsProto(value, https);
+ }
+ }
+ }
+ }
+ }
if (host.empty() &&
- !LookupHttpHeader(host, request, "host"))
+ !LookupHttpHeader2(host, headers, "host"))
{
// Should never happen: The "host" header should always be present
// in HTTP requests. Provide a default value anyway.
host = "localhost:8042";
}
- return (ssl ? "https://" : "http://") + host + GetRoot();
+ return (https ? "https://" : "http://") + host + GetDicomWebRoot();
+ }
+
+
+ std::string GetBaseUrl(const OrthancPluginHttpRequest* request)
+ {
+ OrthancPlugins::HttpClient::HttpHeaders headers;
+
+ std::string value;
+ if (LookupHttpHeader(value, request, "forwarded"))
+ {
+ headers["Forwarded"] = value;
+ }
+
+ if (LookupHttpHeader(value, request, "host"))
+ {
+ headers["Host"] = value;
+ }
+
+ return GetBaseUrl(headers);
}
@@ -438,5 +549,61 @@ namespace OrthancPlugins
{
return defaultEncoding_;
}
+
+
+ static bool IsXmlExpected(const std::string& acceptHeader)
+ {
+ std::string accept;
+ Orthanc::Toolbox::ToLowerCase(accept, acceptHeader);
+
+ if (accept == "application/dicom+json" ||
+ accept == "application/json" ||
+ accept == "*/*")
+ {
+ return false;
+ }
+ else if (accept == "application/dicom+xml" ||
+ accept == "application/xml" ||
+ accept == "text/xml")
+ {
+ return true;
+ }
+ else
+ {
+ OrthancPlugins::LogError("Unsupported return MIME type: " + accept +
+ ", will return DICOM+JSON");
+ return false;
+ }
+ }
+
+
+ bool IsXmlExpected(const std::map<std::string, std::string>& headers)
+ {
+ std::map<std::string, std::string>::const_iterator found = headers.find("accept");
+
+ if (found == headers.end())
+ {
+ return false; // By default, return DICOM+JSON
+ }
+ else
+ {
+ return IsXmlExpected(found->second);
+ }
+ }
+
+
+ bool IsXmlExpected(const OrthancPluginHttpRequest* request)
+ {
+ std::string accept;
+
+ if (OrthancPlugins::LookupHttpHeader(accept, request, "accept"))
+ {
+ return IsXmlExpected(accept);
+ }
+ else
+ {
+ return false; // By default, return DICOM+JSON
+ }
+ }
}
}
=====================================
Plugin/Configuration.h
=====================================
@@ -45,13 +45,6 @@ namespace OrthancPlugins
static const Orthanc::DicomTag DICOM_TAG_REFERENCED_SOP_CLASS_UID(0x0008, 0x1150);
static const Orthanc::DicomTag DICOM_TAG_REFERENCED_SOP_INSTANCE_UID(0x0008, 0x1155);
- struct MultipartItem
- {
- const char* data_;
- size_t size_;
- std::string contentType_;
- };
-
bool LookupHttpHeader(std::string& value,
const OrthancPluginHttpRequest* request,
const std::string& header);
@@ -60,18 +53,33 @@ namespace OrthancPlugins
std::map<std::string, std::string>& attributes,
const std::string& header);
- void ParseMultipartBody(std::vector<MultipartItem>& result,
- const char* body,
- const uint64_t bodySize,
- const std::string& boundary);
-
void ParseAssociativeArray(std::map<std::string, std::string>& target,
const Json::Value& value,
const std::string& key);
+ void ParseAssociativeArray(std::map<std::string, std::string>& target,
+ const Json::Value& value);
+
bool ParseTag(Orthanc::DicomTag& target,
const std::string& name);
+ void ParseJsonBody(Json::Value& target,
+ const OrthancPluginHttpRequest* request);
+
+ std::string RemoveMultipleSlashes(const std::string& source);
+
+ bool LookupStringValue(std::string& target,
+ const Json::Value& json,
+ const std::string& key);
+
+ bool LookupIntegerValue(int& target,
+ const Json::Value& json,
+ const std::string& key);
+
+ bool LookupBooleanValue(bool& target,
+ const Json::Value& json,
+ const std::string& key);
+
namespace Configuration
{
void Initialize();
@@ -85,10 +93,15 @@ namespace OrthancPlugins
unsigned int GetUnsignedIntegerValue(const std::string& key,
unsigned int defaultValue);
- std::string GetRoot();
+ std::string GetDicomWebRoot();
+
+ std::string GetOrthancApiRoot();
std::string GetWadoRoot();
+ std::string GetBaseUrl(const std::map<std::string, std::string>& headers);
+
+ // TODO => REMOVE
std::string GetBaseUrl(const OrthancPluginHttpRequest* request);
std::string GetWadoUrl(const std::string& wadoBase,
@@ -97,5 +110,10 @@ namespace OrthancPlugins
const std::string& sopInstanceUid);
Orthanc::Encoding GetDefaultEncoding();
+
+ bool IsXmlExpected(const std::map<std::string, std::string>& headers);
+
+ // TODO => REMOVE
+ bool IsXmlExpected(const OrthancPluginHttpRequest* request);
}
}
=====================================
Plugin/DicomWebClient.cpp
=====================================
@@ -28,22 +28,363 @@
#include <set>
#include <boost/lexical_cast.hpp>
+#include <Core/HttpServer/MultipartStreamReader.h>
#include <Core/ChunkedBuffer.h>
#include <Core/Toolbox.h>
+#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+
+
+#include <boost/thread.hpp>
+#include <boost/algorithm/string/predicate.hpp>
+
+
+
+
+class SingleFunctionJob : public OrthancPlugins::OrthancJob
+{
+public:
+ class JobContext : public boost::noncopyable
+ {
+ private:
+ SingleFunctionJob& that_;
+
+ public:
+ explicit JobContext(SingleFunctionJob& that) :
+ that_(that)
+ {
+ }
+
+ void SetContent(const std::string& key,
+ const std::string& value)
+ {
+ that_.SetContent(key, value);
+ }
+
+ void SetProgress(unsigned int position,
+ unsigned int maxPosition)
+ {
+ boost::mutex::scoped_lock lock(that_.mutex_);
+
+ if (maxPosition == 0 ||
+ position > maxPosition)
+ {
+ that_.UpdateProgress(1);
+ }
+ else
+ {
+ that_.UpdateProgress(static_cast<float>(position) / static_cast<float>(maxPosition));
+ }
+ }
+ };
+
+
+ class IFunction : public boost::noncopyable
+ {
+ public:
+ virtual ~IFunction()
+ {
+ }
+
+ virtual void Execute(JobContext& context) = 0;
+ };
+
+
+ class IFunctionFactory : public boost::noncopyable
+ {
+ public:
+ virtual ~IFunctionFactory()
+ {
+ }
+
+ // Called when the job is paused or canceled. WARNING:
+ // "CancelFunction()" will be invoked while "Execute()" is
+ // running. Mutex is probably necessary.
+ virtual void CancelFunction() = 0;
+
+ virtual void PauseFunction() = 0;
+
+ virtual IFunction* CreateFunction() = 0;
+ };
+
+
+protected:
+ void SetFactory(IFunctionFactory& factory)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+
+ if (factory_ != NULL)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+ }
+ else
+ {
+ factory_ = &factory;
+ }
+ }
+
+
+private:
+ enum FunctionResult
+ {
+ FunctionResult_Running,
+ FunctionResult_Done,
+ FunctionResult_Failure
+ };
+
+ boost::mutex mutex_;
+ FunctionResult functionResult_; // Can only be modified by the "Worker()" function
+ std::auto_ptr<boost::thread> worker_;
+ Json::Value content_;
+ IFunctionFactory* factory_;
+ bool stopping_;
+
+ void JoinWorker()
+ {
+ assert(factory_ != NULL);
+
+ if (worker_.get() != NULL)
+ {
+ if (worker_->joinable())
+ {
+ worker_->join();
+ }
+
+ worker_.reset();
+ }
+ }
+
+ void StartWorker()
+ {
+ assert(factory_ != NULL);
+
+ if (worker_.get() == NULL)
+ {
+ stopping_ = false;
+ worker_.reset(new boost::thread(Worker, this, factory_));
+ }
+ }
+
+ void SetContent(const std::string& key,
+ const std::string& value)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+ content_[key] = value;
+ UpdateContent(content_);
+ }
+
+ static void Worker(SingleFunctionJob* job,
+ IFunctionFactory* factory)
+ {
+ assert(job != NULL && factory != NULL);
+
+ JobContext context(*job);
+
+ try
+ {
+ std::auto_ptr<IFunction> function(factory->CreateFunction());
+ function->Execute(context);
+
+ {
+ boost::mutex::scoped_lock lock(job->mutex_);
+ job->functionResult_ = FunctionResult_Done;
+ }
+ }
+ catch (Orthanc::OrthancException& e)
+ {
+ LOG(ERROR) << "Error in a job: " << e.What();
+
+ {
+ boost::mutex::scoped_lock lock(job->mutex_);
+
+ job->functionResult_ = FunctionResult_Failure;
+
+ if (!job->stopping_)
+ {
+ // Don't report exceptions that are a consequence of stopping the function
+ job->content_["FunctionErrorCode"] = e.GetErrorCode();
+ job->content_["FunctionErrorDescription"] = e.What();
+ if (e.HasDetails())
+ {
+ job->content_["FunctionErrorDetails"] = e.GetDetails();
+ }
+ job->UpdateContent(job->content_);
+ }
+ }
+ }
+ }
+
+public:
+ explicit SingleFunctionJob(const std::string& jobName) :
+ OrthancJob(jobName),
+ functionResult_(FunctionResult_Running),
+ content_(Json::objectValue),
+ factory_(NULL),
+ stopping_(false)
+ {
+ }
+
+ virtual ~SingleFunctionJob()
+ {
+ if (worker_.get() != NULL)
+ {
+ LOG(ERROR) << "Classes deriving from SingleFunctionJob must "
+ << "explicitly call Finalize() in their destructor";
+ Finalize();
+ }
+ }
+
+ void Finalize()
+ {
+ try
+ {
+ Stop(OrthancPluginJobStopReason_Canceled);
+ }
+ catch (Orthanc::OrthancException&)
+ {
+ }
+ }
+
+ virtual OrthancPluginJobStepStatus Step()
+ {
+ if (factory_ == NULL)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+ }
+
+ FunctionResult result;
+
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+ result = functionResult_;
+ }
+
+ switch (result)
+ {
+ case FunctionResult_Running:
+ StartWorker();
+ boost::this_thread::sleep(boost::posix_time::milliseconds(500));
+ return OrthancPluginJobStepStatus_Continue;
+
+ case FunctionResult_Done:
+ JoinWorker();
+ return OrthancPluginJobStepStatus_Success;
+
+ case FunctionResult_Failure:
+ JoinWorker();
+ return OrthancPluginJobStepStatus_Failure;
+
+ default:
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+ }
+ }
+
+ virtual void Stop(OrthancPluginJobStopReason reason)
+ {
+ if (factory_ == NULL)
+ {
+ return;
+ }
+ else if (reason == OrthancPluginJobStopReason_Paused ||
+ reason == OrthancPluginJobStopReason_Canceled)
+ {
+ stopping_ = true;
+
+ if (reason == OrthancPluginJobStopReason_Paused)
+ {
+ factory_->PauseFunction();
+ }
+ else
+ {
+ factory_->CancelFunction();
+ }
+
+ JoinWorker();
+
+ // Be ready for the next possible call to "Step()" that will resume the function
+ functionResult_ = FunctionResult_Running;
+ }
+ }
+
+ virtual void Reset()
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+
+ assert(worker_.get() == NULL);
+ functionResult_ = FunctionResult_Running;
+ content_ = Json::objectValue;
+ ClearContent();
+ }
+};
+
+
+
+
+static const std::string MULTIPART_RELATED = "multipart/related";
+
+
+
+static void SubmitJob(OrthancPluginRestOutput* output,
+ OrthancPlugins::OrthancJob* job,
+ const Json::Value& body,
+ bool defaultSynchronous)
+{
+ std::auto_ptr<OrthancPlugins::OrthancJob> protection(job);
+
+ bool synchronous;
+
+ bool b;
+ if (OrthancPlugins::LookupBooleanValue(b, body, "Synchronous"))
+ {
+ synchronous = b;
+ }
+ else if (OrthancPlugins::LookupBooleanValue(b, body, "Asynchronous"))
+ {
+ synchronous = !b;
+ }
+ else
+ {
+ synchronous = defaultSynchronous;
+ }
+
+ int priority;
+ if (!OrthancPlugins::LookupIntegerValue(priority, body, "Priority"))
+ {
+ priority = 0;
+ }
+
+ Json::Value answer;
+
+ if (synchronous)
+ {
+ OrthancPlugins::OrthancJob::SubmitAndWait(answer, protection.release(), priority);
+ }
+ else
+ {
+ std::string jobId = OrthancPlugins::OrthancJob::Submit(protection.release(), priority);
+
+ answer = Json::objectValue;
+ answer["ID"] = jobId;
+ answer["Path"] = OrthancPlugins::RemoveMultipleSlashes
+ ("../" + OrthancPlugins::Configuration::GetOrthancApiRoot() + "/jobs/" + jobId);
+ }
+
+ std::string s = answer.toStyledString();
+ OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(),
+ output, s.c_str(), s.size(), "application/json");
+}
static void AddInstance(std::list<std::string>& target,
const Json::Value& instance)
{
- if (instance.type() != Json::objectValue ||
- !instance.isMember("ID") ||
- instance["ID"].type() != Json::stringValue)
+ std::string id;
+ if (OrthancPlugins::LookupStringValue(id, instance, "ID"))
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+ target.push_back(id);
}
else
{
- target.push_back(instance["ID"].asString());
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
}
}
@@ -103,19 +444,59 @@ static bool GetSequenceSize(size_t& result,
+static void CheckStowAnswer(const Json::Value& response,
+ const std::string& serverName,
+ size_t instancesCount)
+{
+ if (response.type() != Json::objectValue ||
+ !response.isMember("00081199"))
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_NetworkProtocol,
+ "Unable to parse STOW-RS JSON response from DICOMweb server " + serverName);
+ }
+
+ size_t size;
+ if (!GetSequenceSize(size, response, "00081199", true, serverName) ||
+ size != instancesCount)
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_NetworkProtocol,
+ "The STOW-RS server was only able to receive " +
+ boost::lexical_cast<std::string>(size) + " instances out of " +
+ boost::lexical_cast<std::string>(instancesCount));
+ }
+
+ if (GetSequenceSize(size, response, "00081198", false, serverName) &&
+ size != 0)
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_NetworkProtocol,
+ "The response from the STOW-RS server contains " +
+ boost::lexical_cast<std::string>(size) +
+ " items in its Failed SOP Sequence (0008,1198) tag");
+ }
+
+ if (GetSequenceSize(size, response, "0008119A", false, serverName) &&
+ size != 0)
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_NetworkProtocol,
+ "The response from the STOW-RS server contains " +
+ boost::lexical_cast<std::string>(size) +
+ " items in its Other Failures Sequence (0008,119A) tag");
+ }
+}
+
+
static void ParseStowRequest(std::list<std::string>& instances /* out */,
std::map<std::string, std::string>& httpHeaders /* out */,
- std::map<std::string, std::string>& queryArguments /* out */,
- const OrthancPluginHttpRequest* request /* in */)
+ const Json::Value& body /* in */)
{
static const char* RESOURCES = "Resources";
static const char* HTTP_HEADERS = "HttpHeaders";
- static const char* QUERY_ARGUMENTS = "Arguments";
- Json::Value body;
- Json::Reader reader;
- if (!reader.parse(request->body, request->body + request->bodySize, body) ||
- body.type() != Json::objectValue ||
+ if (body.type() != Json::objectValue ||
!body.isMember(RESOURCES) ||
body[RESOURCES].type() != Json::arrayValue)
{
@@ -126,10 +507,9 @@ static void ParseStowRequest(std::list<std::string>& instances /* out */,
"\" containing an array of resources to be sent");
}
- OrthancPlugins::ParseAssociativeArray(queryArguments, body, QUERY_ARGUMENTS);
OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS);
- Json::Value& resources = body[RESOURCES];
+ const Json::Value& resources = body[RESOURCES];
// Extract information about all the child instances
for (Json::Value::ArrayIndex i = 0; i < resources.size(); i++)
@@ -177,197 +557,254 @@ static void ParseStowRequest(std::list<std::string>& instances /* out */,
}
-static void SendStowChunks(const Orthanc::WebServiceParameters& server,
- const std::map<std::string, std::string>& httpHeaders,
- const std::map<std::string, std::string>& queryArguments,
- const std::string& boundary,
- Orthanc::ChunkedBuffer& chunks,
- size_t& countInstances,
- bool force)
+class StowClientJob :
+ public SingleFunctionJob,
+ private SingleFunctionJob::IFunctionFactory
{
- unsigned int maxInstances = OrthancPlugins::Configuration::GetUnsignedIntegerValue("StowMaxInstances", 10);
- size_t maxSize = static_cast<size_t>(OrthancPlugins::Configuration::GetUnsignedIntegerValue("StowMaxSize", 10)) * 1024 * 1024;
-
- if ((force && countInstances > 0) ||
- (maxInstances != 0 && countInstances >= maxInstances) ||
- (maxSize != 0 && chunks.GetNumBytes() >= maxSize))
+private:
+ enum Action
{
- chunks.AddChunk("\r\n--" + boundary + "--\r\n");
-
- std::string body;
- chunks.Flatten(body);
+ Action_None,
+ Action_Pause,
+ Action_Cancel
+ };
+
+ boost::mutex mutex_;
+ std::string serverName_;
+ std::vector<std::string> instances_;
+ OrthancPlugins::HttpClient::HttpHeaders headers_;
+ std::string boundary_;
+ size_t position_;
+ Action action_;
+ size_t networkSize_;
+ bool debug_;
+
+ bool ReadNextInstance(std::string& dicom,
+ JobContext& context)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
- OrthancPlugins::MemoryBuffer answerBody;
- std::map<std::string, std::string> answerHeaders;
+ if (action_ != Action_None)
+ {
+ return false;
+ }
- std::string uri;
- OrthancPlugins::UriEncode(uri, "studies", queryArguments);
+ while (position_ < instances_.size())
+ {
+ context.SetProgress(position_, instances_.size());
+
+ size_t i = position_++;
- OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Post,
- httpHeaders, uri, body);
+ if (debug_)
+ {
+ boost::this_thread::sleep(boost::posix_time::milliseconds(100));
+ }
- Json::Value response;
- Json::Reader reader;
- bool success = reader.parse(reinterpret_cast<const char*>((*answerBody)->data),
- reinterpret_cast<const char*>((*answerBody)->data) + (*answerBody)->size, response);
- answerBody.Clear();
+ if (OrthancPlugins::RestApiGetString(dicom, "/instances/" + instances_[i] + "/file", false))
+ {
+ networkSize_ += dicom.size();
+ context.SetContent("NetworkSizeMB", boost::lexical_cast<std::string>
+ (networkSize_ / static_cast<uint64_t>(1024 * 1024)));
- if (!success ||
- response.type() != Json::objectValue ||
- !response.isMember("00081199"))
- {
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "Unable to parse STOW-RS JSON response from DICOMweb server " + server.GetUrl());
+ return true;
+ }
}
- size_t size;
- if (!GetSequenceSize(size, response, "00081199", true, server.GetUrl()) ||
- size != countInstances)
- {
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The STOW-RS server was only able to receive " +
- boost::lexical_cast<std::string>(size) + " instances out of " +
- boost::lexical_cast<std::string>(countInstances));
- }
+ return false;
+ }
+
- if (GetSequenceSize(size, response, "00081198", false, server.GetUrl()) &&
- size != 0)
+ class RequestBody : public OrthancPlugins::HttpClient::IRequestBody
+ {
+ private:
+ StowClientJob& that_;
+ JobContext& context_;
+ std::string boundary_;
+ bool done_;
+
+ public:
+ RequestBody(StowClientJob& that,
+ JobContext& context) :
+ that_(that),
+ context_(context),
+ boundary_(that.boundary_),
+ done_(false)
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The response from the STOW-RS server contains " +
- boost::lexical_cast<std::string>(size) +
- " items in its Failed SOP Sequence (0008,1198) tag");
}
- if (GetSequenceSize(size, response, "0008119A", false, server.GetUrl()) &&
- size != 0)
+ virtual bool ReadNextChunk(std::string& chunk)
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The response from the STOW-RS server contains " +
- boost::lexical_cast<std::string>(size) +
- " items in its Other Failures Sequence (0008,119A) tag");
- }
+ if (done_)
+ {
+ context_.SetProgress(1, 1);
+ return false;
+ }
+ else
+ {
+ std::string dicom;
- countInstances = 0;
- }
-}
+ if (that_.ReadNextInstance(dicom, context_))
+ {
+ chunk = ("--" + boundary_ + "\r\n" +
+ "Content-Type: application/dicom\r\n" +
+ "Content-Length: " + boost::lexical_cast<std::string>(dicom.size()) +
+ "\r\n\r\n" + dicom + "\r\n");
+ }
+ else
+ {
+ done_ = true;
+ chunk = ("--" + boundary_ + "--");
+ }
+ //boost::this_thread::sleep(boost::posix_time::seconds(1));
-void StowClient(OrthancPluginRestOutput* output,
- const char* /*url*/,
- const OrthancPluginHttpRequest* request)
-{
- OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+ return true;
+ }
+ }
+ };
- if (request->groupsCount != 1)
- {
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
- }
- if (request->method != OrthancPluginHttpMethod_Post)
+ class F : public IFunction
{
- OrthancPluginSendMethodNotAllowed(context, output, "POST");
- return;
- }
-
- Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]));
-
- std::string boundary;
+ private:
+ StowClientJob& that_;
- {
- char* uuid = OrthancPluginGenerateUuid(context);
- try
- {
- boundary.assign(uuid);
- }
- catch (...)
+ public:
+ explicit F(StowClientJob& that) :
+ that_(that)
{
- OrthancPluginFreeString(context, uuid);
- throw Orthanc::OrthancException(Orthanc::ErrorCode_NotEnoughMemory);
}
- OrthancPluginFreeString(context, uuid);
- }
+ virtual void Execute(JobContext& context)
+ {
+ std::string serverName;
+ size_t startPosition;
- std::string mime = "multipart/related; type=\"application/dicom\"; boundary=" + boundary;
+ // The lifetime of "body" should be larger than "client"
+ std::auto_ptr<RequestBody> body;
+ std::auto_ptr<OrthancPlugins::HttpClient> client;
- std::map<std::string, std::string> queryArguments;
- std::map<std::string, std::string> httpHeaders;
- httpHeaders["Accept"] = "application/dicom+json";
- httpHeaders["Expect"] = "";
- httpHeaders["Content-Type"] = mime;
+ {
+ boost::mutex::scoped_lock lock(that_.mutex_);
+ context.SetContent("InstancesCount", boost::lexical_cast<std::string>(that_.instances_.size()));
+ serverName = that_.serverName_;
+
+ startPosition = that_.position_;
+ body.reset(new RequestBody(that_, context));
- std::list<std::string> instances;
- ParseStowRequest(instances, httpHeaders, queryArguments, request);
+ client.reset(new OrthancPlugins::HttpClient);
+ OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(*client, serverName, "/studies");
+ client->SetMethod(OrthancPluginHttpMethod_Post);
+ client->AddHeaders(that_.headers_);
+ }
- OrthancPlugins::LogInfo("Sending " + boost::lexical_cast<std::string>(instances.size()) +
- " instances using STOW-RS to DICOMweb server: " + server.GetUrl());
+ OrthancPlugins::HttpClient::HttpHeaders answerHeaders;
+ Json::Value answerBody;
- Orthanc::ChunkedBuffer chunks;
- size_t countInstances = 0;
+ client->SetBody(*body);
- for (std::list<std::string>::const_iterator it = instances.begin(); it != instances.end(); ++it)
- {
- OrthancPlugins::MemoryBuffer dicom;
- if (dicom.RestApiGet("/instances/" + *it + "/file", false))
- {
- chunks.AddChunk("\r\n--" + boundary + "\r\n" +
- "Content-Type: application/dicom\r\n" +
- "Content-Length: " + boost::lexical_cast<std::string>(dicom.GetSize()) +
- "\r\n\r\n");
- chunks.AddChunk(dicom.GetData(), dicom.GetSize());
- countInstances ++;
+ try
+ {
+ client->Execute(answerHeaders, answerBody);
+ }
+ catch (Orthanc::OrthancException&)
+ {
+ if (client->GetHttpStatus() == 411)
+ {
+ /**
+ * "Length required" error. This might indicate an older
+ * version of Orthanc (<= 1.5.6) that does not support
+ * chunked transfers.
+ **/
+ LOG(ERROR) << "The remote DICOMweb server \"" << serverName << "\" does not support chunked transfers, "
+ << "set configuration option \"ChunkedTransfers\" to \"0\" in the configuration";
+ }
- SendStowChunks(server, httpHeaders, queryArguments, boundary, chunks, countInstances, false);
- }
- }
+ throw;
+ }
- SendStowChunks(server, httpHeaders, queryArguments, boundary, chunks, countInstances, true);
+ {
+ boost::mutex::scoped_lock lock(that_.mutex_);
+ size_t endPosition = that_.position_;
+ CheckStowAnswer(answerBody, serverName, endPosition - startPosition);
- std::string answer = "{}\n";
- OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
-}
+ if (that_.action_ == Action_Cancel)
+ {
+ that_.position_ = 0;
+ }
+ }
+ }
+ };
+
+ virtual void CancelFunction()
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+ action_ = Action_Cancel;
+ }
+
-static bool GetStringValue(std::string& target,
- const Json::Value& json,
- const std::string& key)
-{
- if (json.type() != Json::objectValue)
+ virtual void PauseFunction()
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+ boost::mutex::scoped_lock lock(mutex_);
+ action_ = Action_Pause;
}
- else if (!json.isMember(key))
+
+
+ virtual IFunction* CreateFunction()
{
- target.clear();
- return false;
+ action_ = Action_None;
+ return new F(*this);
}
- else if (json[key].type() != Json::stringValue)
+
+
+public:
+ StowClientJob(const std::string& serverName,
+ const std::list<std::string>& instances,
+ const OrthancPlugins::HttpClient::HttpHeaders& headers) :
+ SingleFunctionJob("DicomWebStowClient"),
+ serverName_(serverName),
+ headers_(headers),
+ position_(0),
+ action_(Action_None),
+ networkSize_(0),
+ debug_(false)
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_BadFileFormat,
- "The field \"" + key + "\" in a JSON object should be a string");
+ SetFactory(*this);
+
+ instances_.reserve(instances.size());
+
+ for (std::list<std::string>::const_iterator
+ it = instances.begin(); it != instances.end(); ++it)
+ {
+ instances_.push_back(*it);
+ }
+
+ {
+ OrthancPlugins::OrthancString tmp;
+ tmp.Assign(OrthancPluginGenerateUuid(OrthancPlugins::GetGlobalContext()));
+ tmp.ToString(boundary_);
+ }
+
+ boundary_ = (boundary_ + "-" + boundary_); // Make the boundary longer
+
+ headers_["Accept"] = "application/dicom+json";
+ headers_["Expect"] = "";
+ headers_["Content-Type"] = "multipart/related; type=\"application/dicom\"; boundary=" + boundary_;
}
- else
+
+ void SetDebug(bool debug)
{
- target = json[key].asString();
- return true;
+ debug_ = debug;
}
-}
+};
-void GetFromServer(OrthancPluginRestOutput* output,
- const char* /*url*/,
- const OrthancPluginHttpRequest* request)
-{
- static const char* URI = "Uri";
- static const char* HTTP_HEADERS = "HttpHeaders";
- static const char* GET_ARGUMENTS = "Arguments";
+void StowClient(OrthancPluginRestOutput* output,
+ const char* /*url*/,
+ const OrthancPluginHttpRequest* request)
+{
OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
if (request->method != OrthancPluginHttpMethod_Post)
@@ -376,14 +813,49 @@ void GetFromServer(OrthancPluginRestOutput* output,
return;
}
- Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]));
+ if (request->groupsCount != 1)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
+ }
+
+ std::string serverName(request->groups[0]);
- std::string tmp;
Json::Value body;
- Json::Reader reader;
- if (!reader.parse(request->body, request->body + request->bodySize, body) ||
- body.type() != Json::objectValue ||
- !GetStringValue(tmp, body, URI))
+ OrthancPlugins::ParseJsonBody(body, request);
+
+ std::list<std::string> instances;
+ std::map<std::string, std::string> httpHeaders;
+ ParseStowRequest(instances, httpHeaders, body);
+
+ OrthancPlugins::LogInfo("Sending " + boost::lexical_cast<std::string>(instances.size()) +
+ " instances using STOW-RS to DICOMweb server: " + serverName);
+
+ std::auto_ptr<StowClientJob> job(new StowClientJob(serverName, instances, httpHeaders));
+
+ bool debug;
+ if (OrthancPlugins::LookupBooleanValue(debug, body, "Debug"))
+ {
+ job->SetDebug(debug);
+ }
+
+ Json::Value answer;
+ SubmitJob(output, job.release(), body,
+ true /* synchronous by default, for compatibility with <= 0.6 */);
+}
+
+
+
+static void ParseGetFromServer(std::string& uri,
+ std::map<std::string, std::string>& additionalHeaders,
+ const Json::Value& resource)
+{
+ static const char* URI = "Uri";
+ static const char* HTTP_HEADERS = "HttpHeaders";
+ static const char* GET_ARGUMENTS = "Arguments";
+
+ std::string tmp;
+ if (resource.type() != Json::objectValue ||
+ !OrthancPlugins::LookupStringValue(tmp, resource, URI))
{
throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
"A request to the DICOMweb client must provide a JSON object "
@@ -391,17 +863,53 @@ void GetFromServer(OrthancPluginRestOutput* output,
}
std::map<std::string, std::string> getArguments;
- OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS);
+ OrthancPlugins::ParseAssociativeArray(getArguments, resource, GET_ARGUMENTS);
+ OrthancPlugins::DicomWebServers::UriEncode(uri, tmp, getArguments);
+
+ OrthancPlugins::ParseAssociativeArray(additionalHeaders, resource, HTTP_HEADERS);
+}
+
+
+
+static void ConfigureGetFromServer(OrthancPlugins::HttpClient& client,
+ const OrthancPluginHttpRequest* request)
+{
+ if (request->method != OrthancPluginHttpMethod_Post)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+ }
+
+ Json::Value body;
+ OrthancPlugins::ParseJsonBody(body, request);
std::string uri;
- OrthancPlugins::UriEncode(uri, tmp, getArguments);
+ std::map<std::string, std::string> additionalHeaders;
+ ParseGetFromServer(uri, additionalHeaders, body);
- std::map<std::string, std::string> httpHeaders;
- OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS);
+ OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(client, request->groups[0], uri);
+ client.AddHeaders(additionalHeaders);
+}
+
+
+
+void GetFromServer(OrthancPluginRestOutput* output,
+ const char* /*url*/,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Post)
+ {
+ OrthancPluginSendMethodNotAllowed(context, output, "POST");
+ return;
+ }
- OrthancPlugins::MemoryBuffer answerBody;
+ OrthancPlugins::HttpClient client;
+ ConfigureGetFromServer(client, request);
+
std::map<std::string, std::string> answerHeaders;
- OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Get, httpHeaders, uri, "");
+ std::string answer;
+ client.Execute(answerHeaders, answer);
std::string contentType = "application/octet-stream";
@@ -415,9 +923,11 @@ void GetFromServer(OrthancPluginRestOutput* output,
{
contentType = it->second;
}
- else if (key == "transfer-encoding")
+ else if (key == "transfer-encoding" ||
+ key == "content-length" ||
+ key == "connection")
{
- // Do not forward this header
+ // Do not forward these headers
}
else
{
@@ -425,266 +935,553 @@ void GetFromServer(OrthancPluginRestOutput* output,
}
}
- OrthancPluginAnswerBuffer(context, output,
- reinterpret_cast<const char*>(answerBody.GetData()),
- answerBody.GetSize(), contentType.c_str());
+ OrthancPluginAnswerBuffer(context, output, answer.empty() ? NULL : answer.c_str(),
+ answer.size(), contentType.c_str());
}
-
-static void RetrieveFromServerInternal(std::set<std::string>& instances,
- const Orthanc::WebServiceParameters& server,
- const std::map<std::string, std::string>& httpHeaders,
- const std::map<std::string, std::string>& getArguments,
- const Json::Value& resource)
+void GetFromServer(Json::Value& result,
+ const OrthancPluginHttpRequest* request)
{
- static const std::string STUDY = "Study";
- static const std::string SERIES = "Series";
- static const std::string INSTANCE = "Instance";
- static const std::string MULTIPART_RELATED = "multipart/related";
- static const std::string APPLICATION_DICOM = "application/dicom";
+ OrthancPlugins::HttpClient client;
+ ConfigureGetFromServer(client, request);
+
+ std::map<std::string, std::string> answerHeaders;
+ client.Execute(answerHeaders, result);
+}
- if (resource.type() != Json::objectValue)
+
+
+
+
+class WadoRetrieveAnswer :
+ public OrthancPlugins::HttpClient::IAnswer,
+ private Orthanc::MultipartStreamReader::IHandler
+{
+private:
+ enum State
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
- "Resources of interest for the DICOMweb WADO-RS Retrieve client "
- "must be provided as a JSON object");
+ State_Headers,
+ State_Body,
+ State_Canceled
+ };
+
+ bool debug_;
+ boost::mutex mutex_;
+ State state_;
+ std::list<std::string> instances_;
+ std::auto_ptr<Orthanc::MultipartStreamReader> reader_;
+ uint64_t networkSize_;
+
+ virtual void HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers,
+ const void* part,
+ size_t size)
+ {
+ std::string contentType;
+ if (!Orthanc::MultipartStreamReader::GetMainContentType(contentType, headers))
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+ "Missing Content-Type for a part of WADO-RS answer");
+ }
+
+ size_t pos = contentType.find(';');
+ if (pos != std::string::npos)
+ {
+ contentType = contentType.substr(0, pos);
+ }
+
+ contentType = Orthanc::Toolbox::StripSpaces(contentType);
+ if (!boost::iequals(contentType, "application/dicom"))
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_NetworkProtocol,
+ "Parts of a WADO-RS retrieve should have \"application/dicom\" type, but received: " + contentType);
+ }
+
+ OrthancPlugins::MemoryBuffer tmp;
+ tmp.RestApiPost("/instances", part, size, false);
+
+ Json::Value result;
+ tmp.ToJson(result);
+
+ std::string id;
+ if (OrthancPlugins::LookupStringValue(id, result, "ID"))
+ {
+ instances_.push_back(id);
+ }
+ else
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+ }
+
+ if (debug_)
+ {
+ boost::this_thread::sleep(boost::posix_time::milliseconds(50));
+ }
}
- std::string study, series, instance;
- if (!GetStringValue(study, resource, STUDY) ||
- study.empty())
+public:
+ WadoRetrieveAnswer() :
+ debug_(false),
+ state_(State_Headers),
+ networkSize_(0)
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
- "A non-empty \"" + STUDY + "\" field is mandatory for the "
- "DICOMweb WADO-RS Retrieve client");
}
- GetStringValue(series, resource, SERIES);
- GetStringValue(instance, resource, INSTANCE);
+ virtual ~WadoRetrieveAnswer()
+ {
+ }
- if (series.empty() &&
- !instance.empty())
+ void SetDebug(bool debug)
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_BadFileFormat,
- "When specifying a \"" + INSTANCE + "\" field in a call to DICOMweb "
- "WADO-RS Retrieve client, the \"" + SERIES + "\" field is mandatory");
+ debug_ = debug;
}
- std::string tmpUri = "studies/" + study;
- if (!series.empty())
+ void Close()
{
- tmpUri += "/series/" + series;
- if (!instance.empty())
+ boost::mutex::scoped_lock lock(mutex_);
+
+ if (state_ != State_Canceled &&
+ reader_.get() != NULL)
{
- tmpUri += "/instances/" + instance;
+ reader_->CloseStream();
}
}
- std::string uri;
- OrthancPlugins::UriEncode(uri, tmpUri, getArguments);
+ virtual void AddHeader(const std::string& key,
+ const std::string& value)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
- OrthancPlugins::MemoryBuffer answerBody;
- std::map<std::string, std::string> answerHeaders;
- OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Get, httpHeaders, uri, "");
+ if (state_ == State_Canceled)
+ {
+ return;
+ }
+ else if (state_ != State_Headers)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+ }
- std::string contentTypeFull;
- std::vector<std::string> contentType;
- for (std::map<std::string, std::string>::const_iterator
- it = answerHeaders.begin(); it != answerHeaders.end(); ++it)
+ if (boost::iequals(key, "Content-Type"))
+ {
+ if (reader_.get() != NULL)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+ "Received twice a Content-Type header in WADO-RS");
+ }
+
+ std::string contentType, subType, boundary;
+
+ if (!Orthanc::MultipartStreamReader::ParseMultipartContentType
+ (contentType, subType, boundary, value))
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+ "Cannot parse the Content-Type for WADO-RS: " + value);
+ }
+
+ if (!boost::iequals(contentType, MULTIPART_RELATED))
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_NetworkProtocol,
+ "The remote WADO-RS server answers with a \"" + contentType +
+ "\" Content-Type, but \"" + MULTIPART_RELATED + "\" is expected");
+ }
+
+ reader_.reset(new Orthanc::MultipartStreamReader(boundary));
+ reader_->SetHandler(*this);
+
+ if (debug_)
+ {
+ reader_->SetBlockSize(1024 * 64);
+ }
+ }
+ }
+
+ virtual void AddChunk(const void* data,
+ size_t size)
{
- std::string s = Orthanc::Toolbox::StripSpaces(it->first);
- Orthanc::Toolbox::ToLowerCase(s);
- if (s == "content-type")
+ boost::mutex::scoped_lock lock(mutex_);
+
+ if (state_ == State_Canceled)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_CanceledJob);
+ }
+ else if (reader_.get() == NULL)
{
- contentTypeFull = it->second;
- Orthanc::Toolbox::TokenizeString(contentType, it->second, ';');
- break;
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+ "No Content-Type provided by the remote WADO-RS server");
+ }
+ else
+ {
+ state_ = State_Body;
+ networkSize_ += size;
+ reader_->AddChunk(data, size);
}
}
- OrthancPlugins::LogInfo("Got " + boost::lexical_cast<std::string>(answerBody.GetSize()) +
- " bytes from a WADO-RS query with content type: " + contentTypeFull);
-
- if (contentType.empty())
+ void GetReceivedInstances(std::list<std::string>& target)
{
- throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
- "No Content-Type provided by the remote WADO-RS server");
+ boost::mutex::scoped_lock lock(mutex_);
+ target = instances_;
}
- Orthanc::Toolbox::ToLowerCase(contentType[0]);
- if (Orthanc::Toolbox::StripSpaces(contentType[0]) != MULTIPART_RELATED)
+ void Cancel()
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The remote WADO-RS server answers with a \"" + contentType[0] +
- "\" Content-Type, but \"" + MULTIPART_RELATED + "\" is expected");
+ boost::mutex::scoped_lock lock(mutex_);
+ LOG(ERROR) << "A WADO-RS retrieve job has been canceled, expect \"Error in the network protocol\" errors";
+ state_ = State_Canceled;
}
- std::string type, boundary;
- for (size_t i = 1; i < contentType.size(); i++)
+ uint64_t GetNetworkSize()
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+ return networkSize_;
+ }
+};
+
+
+
+
+
+class WadoRetrieveJob :
+ public SingleFunctionJob,
+ private SingleFunctionJob::IFunctionFactory
+{
+private:
+ class Resource : public boost::noncopyable
{
- std::vector<std::string> tokens;
- Orthanc::Toolbox::TokenizeString(tokens, contentType[i], '=');
+ private:
+ std::string uri_;
+ std::map<std::string, std::string> additionalHeaders_;
- if (tokens.size() == 2)
+ public:
+ explicit Resource(const std::string& uri) :
+ uri_(uri)
{
- std::string s = Orthanc::Toolbox::StripSpaces(tokens[0]);
- Orthanc::Toolbox::ToLowerCase(s);
+ }
+
+ Resource(const std::string& uri,
+ const std::map<std::string, std::string>& additionalHeaders) :
+ uri_(uri),
+ additionalHeaders_(additionalHeaders)
+ {
+ }
+
+ const std::string& GetUri() const
+ {
+ return uri_;
+ }
- if (s == "type")
+ const std::map<std::string, std::string>& GetAdditionalHeaders() const
+ {
+ return additionalHeaders_;
+ }
+ };
+
+
+ class F : public IFunction
+ {
+ private:
+ WadoRetrieveJob& that_;
+
+ public:
+ explicit F(WadoRetrieveJob& that) :
+ that_(that)
+ {
+ }
+
+ virtual void Execute(JobContext& context)
+ {
+ for (;;)
{
- type = Orthanc::Toolbox::StripSpaces(tokens[1]);
-
- // This covers the case where the content-type is quoted,
- // which COULD be the case
- // cf. https://tools.ietf.org/html/rfc7231#section-3.1.1.1
- size_t len = type.length();
- if (len >= 2 &&
- type[0] == '"' &&
- type[len - 1] == '"')
+ OrthancPlugins::HttpClient client;
+
+ if (that_.SetupNextResource(client, context))
{
- type = type.substr(1, len - 2);
+ client.Execute(*that_.answer_);
+ that_.CloseResource(context);
+ }
+ else
+ {
+ return; // We're done
}
-
- Orthanc::Toolbox::ToLowerCase(type);
}
- else if (s == "boundary")
+ }
+ };
+
+
+ boost::mutex mutex_;
+ std::string serverName_;
+ size_t position_;
+ std::vector<Resource*> resources_;
+ bool stopped_;
+ std::list<std::string> retrievedInstances_;
+ std::auto_ptr<WadoRetrieveAnswer> answer_;
+ uint64_t networkSize_;
+ bool debug_;
+
+ bool SetupNextResource(OrthancPlugins::HttpClient& client,
+ JobContext& context)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+
+ if (stopped_ ||
+ position_ == resources_.size())
+ {
+ return false;
+ }
+ else
+ {
+ context.SetProgress(position_, resources_.size());
+
+ answer_.reset(new WadoRetrieveAnswer);
+ answer_->SetDebug(debug_);
+
+ const Resource* resource = resources_[position_++];
+ if (resource == NULL)
{
- boundary = Orthanc::Toolbox::StripSpaces(tokens[1]);
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
}
+
+ OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient
+ (client, serverName_, resource->GetUri());
+ client.AddHeaders(resource->GetAdditionalHeaders());
+
+ return true;
}
}
- // Strip the trailing and heading quotes if present
- if (boundary.length() > 2 &&
- boundary[0] == '"' &&
- boundary[boundary.size() - 1] == '"')
+
+ void CloseResource(JobContext& context)
{
- boundary = boundary.substr(1, boundary.size() - 2);
+ boost::mutex::scoped_lock lock(mutex_);
+ answer_->Close();
+
+ std::list<std::string> instances;
+ answer_->GetReceivedInstances(instances);
+ networkSize_ += answer_->GetNetworkSize();
+
+ answer_.reset();
+
+ retrievedInstances_.splice(retrievedInstances_.end(), instances);
+
+ context.SetProgress(position_, resources_.size());
+ context.SetContent("NetworkUsageMB", boost::lexical_cast<std::string>
+ (networkSize_ / static_cast<uint64_t>(1024 * 1024)));
+ context.SetContent("ReceivedInstancesCount", boost::lexical_cast<std::string>(retrievedInstances_.size()));
}
- OrthancPlugins::LogInfo(" Parsing the multipart content type: " + type +
- " with boundary: " + boundary);
- if (type != APPLICATION_DICOM)
+ virtual void CancelFunction()
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The remote WADO-RS server answers with a \"" + type +
- "\" multipart Content-Type, but \"" + APPLICATION_DICOM + "\" is expected");
+ boost::mutex::scoped_lock lock(mutex_);
+
+ stopped_ = true;
+ if (answer_.get() != NULL)
+ {
+ answer_->Cancel();
+ }
}
- if (boundary.empty())
+ virtual void PauseFunction()
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The remote WADO-RS server does not provide a boundary for its multipart answer");
+ // This type of job cannot be paused
+ CancelFunction();
}
- std::vector<OrthancPlugins::MultipartItem> parts;
- OrthancPlugins::ParseMultipartBody(parts,
- reinterpret_cast<const char*>(answerBody.GetData()),
- answerBody.GetSize(), boundary);
+ virtual IFunction* CreateFunction()
+ {
+ // This type of job cannot be paused: If restarting, always go
+ // back to the beginning
- OrthancPlugins::LogInfo("The remote WADO-RS server has provided " +
- boost::lexical_cast<std::string>(parts.size()) +
- " DICOM instances");
+ stopped_ = false;
+ position_ = 0;
+ retrievedInstances_.clear();
- for (size_t i = 0; i < parts.size(); i++)
+ return new F(*this);
+ }
+
+public:
+ explicit WadoRetrieveJob(const std::string& serverName) :
+ SingleFunctionJob("DicomWebWadoRetrieveClient"),
+ serverName_(serverName),
+ position_(0),
+ stopped_(false),
+ networkSize_(0),
+ debug_(false)
{
- std::vector<std::string> tokens;
- Orthanc::Toolbox::TokenizeString(tokens, parts[i].contentType_, ';');
+ SetFactory(*this);
+ }
- std::string partType;
- if (tokens.size() > 0)
- {
- partType = Orthanc::Toolbox::StripSpaces(tokens[0]);
- }
+ virtual ~WadoRetrieveJob()
+ {
+ SingleFunctionJob::Finalize();
- if (partType != APPLICATION_DICOM)
+ for (size_t i = 0; i < resources_.size(); i++)
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_NetworkProtocol,
- "The remote WADO-RS server has provided a non-DICOM file in its multipart answer"
- " (content type: " + parts[i].contentType_ + ")");
+ assert(resources_[i] != NULL);
+ delete resources_[i];
}
+ }
- OrthancPlugins::MemoryBuffer tmp;
- tmp.RestApiPost("/instances", parts[i].data_, parts[i].size_, false);
+ void SetDebug(bool debug)
+ {
+ debug_ = debug;
+ }
- Json::Value result;
- tmp.ToJson(result);
+ void AddResource(const std::string& uri)
+ {
+ resources_.push_back(new Resource(uri));
+ }
- if (result.type() != Json::objectValue ||
- !result.isMember("ID") ||
- result["ID"].type() != Json::stringValue)
- {
- throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
- }
- else
- {
- instances.insert(result["ID"].asString());
- }
+ void AddResource(const std::string& uri,
+ const std::map<std::string, std::string>& additionalHeaders)
+ {
+ resources_.push_back(new Resource(uri, additionalHeaders));
+ }
+
+ void AddResourceFromRequest(const Json::Value& resource)
+ {
+ std::string uri;
+ std::map<std::string, std::string> additionalHeaders;
+ ParseGetFromServer(uri, additionalHeaders, resource);
+
+ resources_.push_back(new Resource(uri, additionalHeaders));
+ }
+};
+
+
+void WadoRetrieveClient(OrthancPluginRestOutput* output,
+ const char* url,
+ const OrthancPluginHttpRequest* request)
+{
+ if (request->method != OrthancPluginHttpMethod_Post)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
}
+
+ if (request->groupsCount != 1)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
+ }
+
+ std::string serverName(request->groups[0]);
+
+ Json::Value body;
+ OrthancPlugins::ParseJsonBody(body, request);
+
+ std::auto_ptr<WadoRetrieveJob> job(new WadoRetrieveJob(serverName));
+ job->AddResourceFromRequest(body);
+
+ bool debug;
+ if (OrthancPlugins::LookupBooleanValue(debug, body, "Debug"))
+ {
+ job->SetDebug(debug);
+ }
+
+ SubmitJob(output, job.release(), body, false /* asynchronous by default */);
}
void RetrieveFromServer(OrthancPluginRestOutput* output,
- const char* /*url*/,
+ const char* url,
const OrthancPluginHttpRequest* request)
{
- static const std::string RESOURCES("Resources");
- static const char* HTTP_HEADERS = "HttpHeaders";
- static const std::string GET_ARGUMENTS = "Arguments";
-
- OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+ static const char* const GET_ARGUMENTS = "GetArguments";
+ static const char* const HTTP_HEADERS = "HttpHeaders";
+ static const char* const RESOURCES = "Resources";
+ static const char* const STUDY = "Study";
+ static const char* const SERIES = "Series";
+ static const char* const INSTANCE = "Instance";
if (request->method != OrthancPluginHttpMethod_Post)
{
- OrthancPluginSendMethodNotAllowed(context, output, "POST");
- return;
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
}
- Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]));
+ if (request->groupsCount != 1)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
+ }
+
+ std::string serverName(request->groups[0]);
Json::Value body;
- Json::Reader reader;
- if (!reader.parse(request->body, request->body + request->bodySize, body) ||
- body.type() != Json::objectValue ||
+ OrthancPlugins::ParseJsonBody(body, request);
+
+ std::map<std::string, std::string> getArguments;
+ OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS);
+
+ std::map<std::string, std::string> additionalHeaders;
+ OrthancPlugins::ParseAssociativeArray(additionalHeaders, body, HTTP_HEADERS);
+
+ std::auto_ptr<WadoRetrieveJob> job(new WadoRetrieveJob(serverName));
+
+ if (body.type() != Json::objectValue ||
!body.isMember(RESOURCES) ||
body[RESOURCES].type() != Json::arrayValue)
{
- throw Orthanc::OrthancException(
- Orthanc::ErrorCode_BadFileFormat,
- "A request to the DICOMweb WADO-RS Retrieve client must provide a JSON object "
- "with the field \"" + RESOURCES + "\" containing an array of resources");
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "The body must be a JSON object containing an array \"" +
+ std::string(RESOURCES) + "\"");
}
- std::map<std::string, std::string> httpHeaders;
- OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS);
+ const Json::Value& resources = body[RESOURCES];
- std::map<std::string, std::string> getArguments;
- OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS);
+ for (Json::Value::ArrayIndex i = 0; i < resources.size(); i++)
+ {
+ std::string study;
+ if (!OrthancPlugins::LookupStringValue(study, resources[i], STUDY))
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "Missing \"Study\" field in the body");
+ }
+ std::string series;
+ if (!OrthancPlugins::LookupStringValue(series, resources[i], SERIES))
+ {
+ series.clear();
+ }
- std::set<std::string> instances;
- for (Json::Value::ArrayIndex i = 0; i < body[RESOURCES].size(); i++)
- {
- RetrieveFromServerInternal(instances, server, httpHeaders, getArguments, body[RESOURCES][i]);
+ std::string instance;
+ if (!OrthancPlugins::LookupStringValue(instance, resources[i], INSTANCE))
+ {
+ instance.clear();
+ }
+
+ if (series.empty() &&
+ !instance.empty())
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+ "Missing \"Series\" field in the body, as \"Instance\" is present");
+ }
+
+ std::string tmp = "/studies/" + study;
+
+ if (!series.empty())
+ {
+ tmp += "/series/" + series;
+ }
+
+ if (!instance.empty())
+ {
+ tmp += "/instances/" + instance;
+ }
+
+ std::string uri;
+ OrthancPlugins::DicomWebServers::UriEncode(uri, tmp, getArguments);
+
+ job->AddResource(uri, additionalHeaders);
}
- Json::Value status = Json::objectValue;
- status["Instances"] = Json::arrayValue;
-
- for (std::set<std::string>::const_iterator
- it = instances.begin(); it != instances.end(); ++it)
+ bool debug;
+ if (OrthancPlugins::LookupBooleanValue(debug, body, "Debug"))
{
- status["Instances"].append(*it);
+ job->SetDebug(debug);
}
- std::string s = status.toStyledString();
- OrthancPluginAnswerBuffer(context, output, s.c_str(), s.size(), "application/json");
+ SubmitJob(output, job.release(), body,
+ true /* synchronous by default, for compatibility with <= 0.6 */);
}
+
=====================================
Plugin/DicomWebClient.h
=====================================
@@ -32,6 +32,14 @@ void GetFromServer(OrthancPluginRestOutput* output,
const char* /*url*/,
const OrthancPluginHttpRequest* request);
+void GetFromServer(Json::Value& result,
+ const OrthancPluginHttpRequest* request);
+
+// TODO => Mark as deprecated
void RetrieveFromServer(OrthancPluginRestOutput* output,
const char* /*url*/,
const OrthancPluginHttpRequest* request);
+
+void WadoRetrieveClient(OrthancPluginRestOutput* output,
+ const char* url,
+ const OrthancPluginHttpRequest* request);
=====================================
Plugin/DicomWebServers.cpp
=====================================
@@ -25,6 +25,8 @@
#include <Core/Toolbox.h>
+#include <boost/algorithm/string/predicate.hpp>
+
namespace OrthancPlugins
{
void DicomWebServers::Clear()
@@ -116,6 +118,67 @@ namespace OrthancPlugins
}
+ void DicomWebServers::ConfigureHttpClient(HttpClient& client,
+ const std::string& name,
+ const std::string& uri)
+ {
+ static const char* HAS_CHUNKED_TRANSFERS = "ChunkedTransfers";
+
+ const Orthanc::WebServiceParameters parameters = GetServer(name);
+
+ client.SetUrl(RemoveMultipleSlashes(parameters.GetUrl() + "/" + uri));
+ client.SetHeaders(parameters.GetHttpHeaders());
+
+ if (!parameters.GetUsername().empty())
+ {
+ client.SetCredentials(parameters.GetUsername(), parameters.GetPassword());
+ }
+
+ // By default, enable chunked transfers
+ client.SetChunkedTransfersAllowed(
+ parameters.GetBooleanUserProperty(HAS_CHUNKED_TRANSFERS, true));
+ }
+
+
+ void DicomWebServers::DeleteServer(const std::string& name)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+
+ Servers::iterator found = servers_.find(name);
+
+ if (found == servers_.end())
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange,
+ "Unknown DICOMweb server: " + name);
+ }
+ else
+ {
+ assert(found->second != NULL);
+ delete found->second;
+ servers_.erase(found);
+ }
+ }
+
+
+ void DicomWebServers::SetServer(const std::string& name,
+ const Orthanc::WebServiceParameters& parameters)
+ {
+ boost::mutex::scoped_lock lock(mutex_);
+
+ Servers::iterator found = servers_.find(name);
+
+ if (found != servers_.end())
+ {
+ assert(found->second != NULL);
+ delete found->second;
+ servers_.erase(found);
+ }
+
+ servers_[name] = new Orthanc::WebServiceParameters(parameters);
+ }
+
+
+
static const char* ConvertToCString(const std::string& s)
{
if (s.empty())
@@ -252,9 +315,9 @@ namespace OrthancPlugins
}
- void UriEncode(std::string& uri,
- const std::string& resource,
- const std::map<std::string, std::string>& getArguments)
+ void DicomWebServers::UriEncode(std::string& uri,
+ const std::string& resource,
+ const std::map<std::string, std::string>& getArguments)
{
if (resource.find('?') != std::string::npos)
{
=====================================
Plugin/DicomWebServers.h
=====================================
@@ -45,6 +45,10 @@ namespace OrthancPlugins
}
public:
+ static void UriEncode(std::string& uri,
+ const std::string& resource,
+ const std::map<std::string, std::string>& getArguments);
+
void Load(const Json::Value& configuration);
~DicomWebServers()
@@ -57,6 +61,15 @@ namespace OrthancPlugins
Orthanc::WebServiceParameters GetServer(const std::string& name);
void ListServers(std::list<std::string>& servers);
+
+ void ConfigureHttpClient(HttpClient& client,
+ const std::string& name,
+ const std::string& uri);
+
+ void DeleteServer(const std::string& name);
+
+ void SetServer(const std::string& name,
+ const Orthanc::WebServiceParameters& parameters);
};
@@ -67,8 +80,4 @@ namespace OrthancPlugins
const std::map<std::string, std::string>& httpHeaders,
const std::string& uri,
const std::string& body);
-
- void UriEncode(std::string& uri,
- const std::string& resource,
- const std::map<std::string, std::string>& getArguments);
}
=====================================
Plugin/OrthancExplorer.js
=====================================
@@ -0,0 +1,142 @@
+function ChooseDicomWebServer(callback)
+{
+ var clickedModality = '';
+ var clickedPeer = '';
+ var items = $('<ul>')
+ .attr('data-divider-theme', 'd')
+ .attr('data-role', 'listview');
+
+ $.ajax({
+ url: '../${DICOMWEB_ROOT}/servers',
+ type: 'GET',
+ dataType: 'json',
+ async: false,
+ cache: false,
+ success: function(servers) {
+ var name, item;
+
+ if (servers.length > 0)
+ {
+ items.append('<li data-role="list-divider">DICOMweb servers</li>');
+
+ for (var i = 0; i < servers.length; i++) {
+ name = servers[i];
+ item = $('<li>')
+ .html('<a href="#" rel="close">' + name + '</a>')
+ .attr('name', name)
+ .click(function() {
+ clickedModality = $(this).attr('name');
+ });
+ items.append(item);
+ }
+ }
+
+ // Launch the dialog
+ $(document).simpledialog2({
+ mode: 'blank',
+ animate: false,
+ headerText: 'Choose target',
+ headerClose: true,
+ forceInput: false,
+ width: '100%',
+ blankContent: items,
+ callbackClose: function() {
+ var timer;
+ function WaitForDialogToClose() {
+ if (!$('#dialog').is(':visible')) {
+ clearInterval(timer);
+ callback(clickedModality, clickedPeer);
+ }
+ }
+ timer = setInterval(WaitForDialogToClose, 100);
+ }
+ });
+ }
+ });
+}
+
+
+function ConfigureDicomWebStowClient(resourceId, buttonId, positionOnPage)
+{
+ $('#' + buttonId).remove();
+
+ var b = $('<a>')
+ .attr('id', buttonId)
+ .attr('data-role', 'button')
+ .attr('href', '#')
+ .attr('data-icon', 'forward')
+ .attr('data-theme', 'e')
+ .text('Send to DICOMweb server')
+ .button();
+
+ b.insertAfter($('#' + positionOnPage));
+
+ b.click(function() {
+ if ($.mobile.pageData) {
+ ChooseDicomWebServer(function(server) {
+ if (server != '' && resourceId != '') {
+ var query = {
+ 'Resources' : [ resourceId ],
+ 'Synchronous' : false
+ };
+
+ $.ajax({
+ url: '../${DICOMWEB_ROOT}/servers/' + server + '/stow',
+ type: 'POST',
+ dataType: 'json',
+ data: JSON.stringify(query),
+ async: false,
+ error: function() {
+ alert('Cannot submit job');
+ },
+ success: function(job) {
+ }
+ });
+ }
+ });
+ }
+ });
+}
+
+
+$('#patient').live('pagebeforeshow', function() {
+ ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-patient', 'patient-info');
+});
+
+$('#study').live('pagebeforeshow', function() {
+ ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-study', 'study-info');
+});
+
+$('#series').live('pagebeforeshow', function() {
+ ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-series', 'series-info');
+});
+
+$('#instance').live('pagebeforeshow', function() {
+ ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-instance', 'instance-info');
+});
+
+$('#lookup').live('pagebeforeshow', function() {
+ $('#open-dicomweb-client').remove();
+
+ var b = $('<fieldset>')
+ .attr('id', 'open-dicomweb-client')
+ .addClass('ui-grid-b')
+ .append($('<div>')
+ .addClass('ui-block-a'))
+ .append($('<div>')
+ .addClass('ui-block-b')
+ .append($('<a>')
+ .attr('id', 'coucou')
+ .attr('data-role', 'button')
+ .attr('href', '#')
+ .attr('data-icon', 'forward')
+ .attr('data-theme', 'a')
+ .text('Open DICOMweb client')
+ .button()
+ .click(function(e) {
+ window.open('../${DICOMWEB_ROOT}/app/client/index.html');
+ })));
+
+ b.insertAfter($('#lookup-result'));
+});
+
=====================================
Plugin/Plugin.cpp
=====================================
@@ -18,7 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
**/
-
#include "DicomWebClient.h"
#include "DicomWebServers.h"
#include "GdcmParsedDicomFile.h"
@@ -28,53 +27,17 @@
#include "WadoUri.h"
#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+#include <Core/SystemToolbox.h>
#include <Core/Toolbox.h>
+#include <EmbeddedResources.h>
-void SwitchStudies(OrthancPluginRestOutput* output,
- const char* url,
- const OrthancPluginHttpRequest* request)
-{
- switch (request->method)
- {
- case OrthancPluginHttpMethod_Get:
- // This is QIDO-RS
- SearchForStudies(output, url, request);
- break;
+#include <boost/algorithm/string/predicate.hpp>
- case OrthancPluginHttpMethod_Post:
- // This is STOW-RS
- StowCallback(output, url, request);
- break;
- default:
- OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST");
- break;
- }
-}
+static const char* const HAS_DELETE = "HasDelete";
-void SwitchStudy(OrthancPluginRestOutput* output,
- const char* url,
- const OrthancPluginHttpRequest* request)
-{
- switch (request->method)
- {
- case OrthancPluginHttpMethod_Get:
- // This is WADO-RS
- RetrieveDicomStudy(output, url, request);
- break;
-
- case OrthancPluginHttpMethod_Post:
- // This is STOW-RS
- StowCallback(output, url, request);
- break;
-
- default:
- OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST");
- break;
- }
-}
bool RequestHasKey(const OrthancPluginHttpRequest* request, const char* key)
{
@@ -110,11 +73,7 @@ void ListServers(OrthancPluginRestOutput* output,
Orthanc::WebServiceParameters server = OrthancPlugins::DicomWebServers::GetInstance().GetServer(*it);
Json::Value jsonServer;
// only return the minimum information to identify the destination, do not include "security" information like passwords
- jsonServer["Url"] = server.GetUrl();
- if (!server.GetUsername().empty())
- {
- jsonServer["Username"] = server.GetUsername();
- }
+ server.FormatPublic(jsonServer);
result[*it] = jsonServer;
}
@@ -141,26 +100,374 @@ void ListServerOperations(OrthancPluginRestOutput* output,
{
OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+ switch (request->method)
+ {
+ case OrthancPluginHttpMethod_Get:
+ {
+ // Make sure the server does exist
+ const Orthanc::WebServiceParameters& server =
+ OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]);
+
+ Json::Value json = Json::arrayValue;
+ json.append("get");
+ json.append("retrieve");
+ json.append("stow");
+ json.append("wado");
+ json.append("qido");
+
+ if (server.GetBooleanUserProperty(HAS_DELETE, false))
+ {
+ json.append("delete");
+ }
+
+ std::string answer = json.toStyledString();
+ OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
+ break;
+ }
+
+ case OrthancPluginHttpMethod_Delete:
+ {
+ OrthancPlugins::DicomWebServers::GetInstance().DeleteServer(request->groups[0]);
+ std::string answer = "{}";
+ OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
+ break;
+ }
+
+ case OrthancPluginHttpMethod_Put:
+ {
+ Json::Value body;
+ OrthancPlugins::ParseJsonBody(body, request);
+
+ Orthanc::WebServiceParameters parameters(body);
+
+ OrthancPlugins::DicomWebServers::GetInstance().SetServer(request->groups[0], parameters);
+ std::string answer = "{}";
+ OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
+ break;
+ }
+
+ default:
+ OrthancPluginSendMethodNotAllowed(context, output, "GET,PUT,DELETE");
+ break;
+ }
+}
+
+
+
+void GetClientInformation(OrthancPluginRestOutput* output,
+ const char* /*url*/,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
if (request->method != OrthancPluginHttpMethod_Get)
{
OrthancPluginSendMethodNotAllowed(context, output, "GET");
}
else
{
- // Make sure the server does exist
- OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]);
-
- Json::Value json = Json::arrayValue;
- json.append("get");
- json.append("retrieve");
- json.append("stow");
+ Json::Value info = Json::objectValue;
+ info["DicomWebRoot"] = OrthancPlugins::Configuration::GetDicomWebRoot();
+ info["OrthancApiRoot"] = OrthancPlugins::Configuration::GetOrthancApiRoot();
- std::string answer = json.toStyledString();
+ std::string answer = info.toStyledString();
OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
}
}
+
+void QidoClient(OrthancPluginRestOutput* output,
+ const char* /*url*/,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Post)
+ {
+ OrthancPluginSendMethodNotAllowed(context, output, "POST");
+ }
+ else
+ {
+ Json::Value answer;
+ GetFromServer(answer, request);
+
+ if (answer.type() != Json::arrayValue)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+ }
+
+ Json::Value result = Json::arrayValue;
+ for (Json::Value::ArrayIndex i = 0; i < answer.size(); i++)
+ {
+ if (answer[i].type() != Json::objectValue)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+ }
+
+ Json::Value::Members tags = answer[i].getMemberNames();
+
+ Json::Value item = Json::objectValue;
+
+ for (size_t j = 0; j < tags.size(); j++)
+ {
+ Orthanc::DicomTag tag(0, 0);
+ if (Orthanc::DicomTag::ParseHexadecimal(tag, tags[j].c_str()))
+ {
+ Json::Value value = Json::objectValue;
+ value["Group"] = tag.GetGroup();
+ value["Element"] = tag.GetElement();
+
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 5, 7)
+ OrthancPlugins::OrthancString name;
+
+ name.Assign(OrthancPluginGetTagName(context, tag.GetGroup(), tag.GetElement(), NULL));
+ if (name.GetContent() != NULL)
+ {
+ value["Name"] = std::string(name.GetContent());
+ }
+#endif
+
+ const Json::Value& source = answer[i][tags[j]];
+ if (source.type() != Json::objectValue ||
+ !source.isMember("vr") ||
+ source["vr"].type() != Json::stringValue)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+ }
+
+ value["vr"] = source["vr"].asString();
+
+ if (source.isMember("Value") &&
+ source["Value"].type() == Json::arrayValue &&
+ source["Value"].size() >= 1)
+ {
+ const Json::Value& content = source["Value"][0];
+
+ switch (content.type())
+ {
+ case Json::stringValue:
+ value["Value"] = content.asString();
+ break;
+
+ case Json::objectValue:
+ if (content.isMember("Alphabetic") &&
+ content["Alphabetic"].type() == Json::stringValue)
+ {
+ value["Value"] = content["Alphabetic"].asString();
+ }
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ item[tags[j]] = value;
+ }
+ }
+
+ result.append(item);
+ }
+
+ std::string tmp = result.toStyledString();
+ OrthancPluginAnswerBuffer(context, output, tmp.c_str(), tmp.size(), "application/json");
+ }
+}
+
+
+void DeleteClient(OrthancPluginRestOutput* output,
+ const char* /*url*/,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Post)
+ {
+ OrthancPluginSendMethodNotAllowed(context, output, "POST");
+ }
+ else
+ {
+ static const char* const LEVEL = "Level";
+ static const char* const SERIES_INSTANCE_UID = "SeriesInstanceUID";
+ static const char* const STUDY_INSTANCE_UID = "StudyInstanceUID";
+ static const char* const SOP_INSTANCE_UID = "SOPInstanceUID";
+
+ const std::string serverName = request->groups[0];
+
+ const Orthanc::WebServiceParameters& server =
+ OrthancPlugins::DicomWebServers::GetInstance().GetServer(serverName);
+
+ if (!server.GetBooleanUserProperty(HAS_DELETE, false))
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_BadFileFormat,
+ "Cannot delete on DICOMweb server, check out property \"" + std::string(HAS_DELETE) + "\": " + serverName);
+ }
+
+ Json::Value body;
+ OrthancPlugins::ParseJsonBody(body, request);
+
+ if (body.type() != Json::objectValue ||
+ !body.isMember(LEVEL) ||
+ !body.isMember(STUDY_INSTANCE_UID) ||
+ body[LEVEL].type() != Json::stringValue ||
+ body[STUDY_INSTANCE_UID].type() != Json::stringValue)
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_BadFileFormat,
+ "The request body must contain a JSON object with fields \"Level\" and \"StudyInstanceUID\"");
+ }
+
+ Orthanc::ResourceType level = Orthanc::StringToResourceType(body[LEVEL].asCString());
+
+ const std::string study = body[STUDY_INSTANCE_UID].asString();
+
+ std::string series;
+ if (level == Orthanc::ResourceType_Series ||
+ level == Orthanc::ResourceType_Instance)
+ {
+ if (!body.isMember(SERIES_INSTANCE_UID) ||
+ body[SERIES_INSTANCE_UID].type() != Json::stringValue)
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_BadFileFormat,
+ "The request body must contain the field \"SeriesInstanceUID\"");
+ }
+ else
+ {
+ series = body[SERIES_INSTANCE_UID].asString();
+ }
+ }
+
+ std::string instance;
+ if (level == Orthanc::ResourceType_Instance)
+ {
+ if (!body.isMember(SOP_INSTANCE_UID) ||
+ body[SOP_INSTANCE_UID].type() != Json::stringValue)
+ {
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_BadFileFormat,
+ "The request body must contain the field \"SOPInstanceUID\"");
+ }
+ else
+ {
+ instance = body[SOP_INSTANCE_UID].asString();
+ }
+ }
+
+ std::string uri;
+ switch (level)
+ {
+ case Orthanc::ResourceType_Study:
+ uri = "/studies/" + study;
+ break;
+
+ case Orthanc::ResourceType_Series:
+ uri = "/studies/" + study + "/series/" + series;
+ break;
+
+ case Orthanc::ResourceType_Instance:
+ uri = "/studies/" + study + "/series/" + series + "/instances/" + instance;
+ break;
+
+ default:
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+ }
+
+ OrthancPlugins::HttpClient client;
+ OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(client, serverName, uri);
+ client.SetMethod(OrthancPluginHttpMethod_Delete);
+ client.Execute();
+
+ std::string tmp = "{}";
+ OrthancPluginAnswerBuffer(context, output, tmp.c_str(), tmp.size(), "application/json");
+ }
+}
+
+
+
+
+static void AnswerFrameRendered(OrthancPluginRestOutput* output,
+ int frame,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Get)
+ {
+ OrthancPluginSendMethodNotAllowed(context, output, "GET");
+ }
+ else
+ {
+ std::string instanceId;
+ if (LocateInstance(output, instanceId, request))
+ {
+ Orthanc::MimeType mime = Orthanc::MimeType_Jpeg; // This is the default in DICOMweb
+
+ for (uint32_t i = 0; i < request->headersCount; i++)
+ {
+ if (boost::iequals(request->headersKeys[i], "Accept") &&
+ !boost::iequals(request->headersValues[i], "*/*"))
+ {
+ try
+ {
+ // TODO - Support conversion to GIF
+
+ mime = Orthanc::StringToMimeType(request->headersValues[i]);
+ if (mime != Orthanc::MimeType_Png &&
+ mime != Orthanc::MimeType_Jpeg)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+ }
+ }
+ catch (Orthanc::OrthancException&)
+ {
+ LOG(ERROR) << "Unsupported MIME type in WADO-RS rendered frame: " << request->headersValues[i];
+ throw;
+ }
+ }
+ }
+
+ std::map<std::string, std::string> headers;
+ headers["Accept"] = Orthanc::EnumerationToString(mime);
+
+ // NB: In DICOMweb, the "frame" parameter is in the range [1..N], whereas
+ // Orthanc uses range [0..N-1], hence the "-1" below
+ OrthancPlugins::MemoryBuffer buffer;
+ if (buffer.RestApiGet("/instances/" + instanceId + "/frames/" +
+ boost::lexical_cast<std::string>(frame - 1) + "/preview", headers, false))
+ {
+ OrthancPluginAnswerBuffer(context, output, buffer.GetData(),
+ buffer.GetSize(), Orthanc::EnumerationToString(mime));
+ }
+ }
+ }
+}
+
+
+void RetrieveInstanceRendered(OrthancPluginRestOutput* output,
+ const char* url,
+ const OrthancPluginHttpRequest* request)
+{
+ AnswerFrameRendered(output, 1 /* first frame */, request);
+}
+
+
+void RetrieveFrameRendered(OrthancPluginRestOutput* output,
+ const char* url,
+ const OrthancPluginHttpRequest* request)
+{
+ assert(request->groupsCount == 4);
+ const char* frame = request->groups[3];
+
+ AnswerFrameRendered(output, boost::lexical_cast<int>(frame), request);
+}
+
+
+
+
+
static bool DisplayPerformanceWarning(OrthancPluginContext* context)
{
(void) DisplayPerformanceWarning; // Disable warning about unused function
@@ -170,11 +477,62 @@ static bool DisplayPerformanceWarning(OrthancPluginContext* context)
}
+template <enum Orthanc::EmbeddedResources::DirectoryResourceId folder>
+void ServeEmbeddedFolder(OrthancPluginRestOutput* output,
+ const char* url,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Get)
+ {
+ OrthancPluginSendMethodNotAllowed(context, output, "GET");
+ }
+ else
+ {
+ std::string path = "/" + std::string(request->groups[0]);
+ const char* mime = Orthanc::EnumerationToString(Orthanc::SystemToolbox::AutodetectMimeType(path));
+
+ std::string s;
+ Orthanc::EmbeddedResources::GetDirectoryResource(s, folder, path.c_str());
+
+ const char* resource = s.size() ? s.c_str() : NULL;
+ OrthancPluginAnswerBuffer(context, output, resource, s.size(), mime);
+ }
+}
+
+
+#if ORTHANC_STANDALONE == 0
+void ServeDicomWebClient(OrthancPluginRestOutput* output,
+ const char* url,
+ const OrthancPluginHttpRequest* request)
+{
+ OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Get)
+ {
+ OrthancPluginSendMethodNotAllowed(context, output, "GET");
+ }
+ else
+ {
+ const std::string path = std::string(DICOMWEB_CLIENT_PATH) + std::string(request->groups[0]);
+ const char* mime = Orthanc::EnumerationToString(Orthanc::SystemToolbox::AutodetectMimeType(path));
+
+ OrthancPlugins::MemoryBuffer f;
+ f.ReadFile(path);
+
+ OrthancPluginAnswerBuffer(context, output, f.GetData(), f.GetSize(), mime);
+ }
+}
+#endif
+
+
extern "C"
{
ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context)
{
assert(DisplayPerformanceWarning(context));
+
OrthancPlugins::SetGlobalContext(context);
Orthanc::Logging::Initialize(context);
@@ -191,6 +549,11 @@ extern "C"
return -1;
}
+#if HAS_ORTHANC_PLUGIN_CHUNKED_HTTP_CLIENT == 0
+ LOG(WARNING) << "Performance warning in DICOMweb: The plugin was compiled against "
+ << "Orthanc SDK <= 1.5.6. STOW and WADO chunked transfers will be entirely stored in RAM.";
+#endif
+
OrthancPluginSetDescription(context, "Implementation of DICOMweb (QIDO-RS, STOW-RS and WADO-RS) and WADO-URI.");
try
@@ -204,15 +567,21 @@ extern "C"
// Configure the DICOMweb callbacks
if (OrthancPlugins::Configuration::GetBooleanValue("Enable", true))
{
- std::string root = OrthancPlugins::Configuration::GetRoot();
+ std::string root = OrthancPlugins::Configuration::GetDicomWebRoot();
assert(!root.empty() && root[root.size() - 1] == '/');
OrthancPlugins::LogWarning("URI to the DICOMweb REST API: " + root);
+ OrthancPlugins::ChunkedRestRegistration<
+ SearchForStudies /* TODO => Rename as QIDO-RS */,
+ OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies");
+
+ OrthancPlugins::ChunkedRestRegistration<
+ RetrieveDicomStudy /* TODO => Rename as WADO-RS */,
+ OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies/([^/]*)");
+
OrthancPlugins::RegisterRestCallback<SearchForInstances>(root + "instances", true);
OrthancPlugins::RegisterRestCallback<SearchForSeries>(root + "series", true);
- OrthancPlugins::RegisterRestCallback<SwitchStudies>(root + "studies", true);
- OrthancPlugins::RegisterRestCallback<SwitchStudy>(root + "studies/([^/]*)", true);
OrthancPlugins::RegisterRestCallback<SearchForInstances>(root + "studies/([^/]*)/instances", true);
OrthancPlugins::RegisterRestCallback<RetrieveStudyMetadata>(root + "studies/([^/]*)/metadata", true);
OrthancPlugins::RegisterRestCallback<SearchForSeries>(root + "studies/([^/]*)/series", true);
@@ -228,8 +597,53 @@ extern "C"
OrthancPlugins::RegisterRestCallback<ListServers>(root + "servers", true);
OrthancPlugins::RegisterRestCallback<ListServerOperations>(root + "servers/([^/]*)", true);
OrthancPlugins::RegisterRestCallback<StowClient>(root + "servers/([^/]*)/stow", true);
+ OrthancPlugins::RegisterRestCallback<WadoRetrieveClient>(root + "servers/([^/]*)/wado", true);
OrthancPlugins::RegisterRestCallback<GetFromServer>(root + "servers/([^/]*)/get", true);
OrthancPlugins::RegisterRestCallback<RetrieveFromServer>(root + "servers/([^/]*)/retrieve", true);
+ OrthancPlugins::RegisterRestCallback<QidoClient>(root + "servers/([^/]*)/qido", true);
+ OrthancPlugins::RegisterRestCallback<DeleteClient>(root + "servers/([^/]*)/delete", true);
+
+ OrthancPlugins::RegisterRestCallback
+ <ServeEmbeddedFolder<Orthanc::EmbeddedResources::JAVASCRIPT_LIBS> >
+ (root + "app/libs/(.*)", true);
+
+ OrthancPlugins::RegisterRestCallback<GetClientInformation>(root + "info", true);
+
+ OrthancPlugins::RegisterRestCallback<RetrieveInstanceRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/rendered", true);
+ OrthancPlugins::RegisterRestCallback<RetrieveFrameRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/frames/([^/]*)/rendered", true);
+
+
+ // Extend the default Orthanc Explorer with custom JavaScript for STOW client
+ std::string explorer;
+
+#if ORTHANC_STANDALONE == 1
+ Orthanc::EmbeddedResources::GetFileResource(explorer, Orthanc::EmbeddedResources::ORTHANC_EXPLORER);
+ OrthancPlugins::RegisterRestCallback
+ <ServeEmbeddedFolder<Orthanc::EmbeddedResources::WEB_APPLICATION> >
+ (root + "app/client/(.*)", true);
+#else
+ Orthanc::SystemToolbox::ReadFile(explorer, std::string(DICOMWEB_CLIENT_PATH) + "../Plugin/OrthancExplorer.js");
+ OrthancPlugins::RegisterRestCallback<ServeDicomWebClient>(root + "app/client/(.*)", true);
+#endif
+
+ {
+ if (root.size() < 2 ||
+ root[0] != '/' ||
+ root[root.size() - 1] != '/')
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+ }
+
+ std::map<std::string, std::string> dictionary;
+ dictionary["DICOMWEB_ROOT"] = root.substr(1, root.size() - 2); // Remove heading and trailing slashes
+ std::string configured = Orthanc::Toolbox::SubstituteVariables(explorer, dictionary);
+
+ OrthancPluginExtendOrthancExplorer(OrthancPlugins::GetGlobalContext(), configured.c_str());
+ }
+
+
+ std::string uri = root + "app/client/index.html";
+ OrthancPluginSetRootUri(context, uri.c_str());
}
else
{
=====================================
Plugin/QidoRs.cpp
=====================================
@@ -21,7 +21,6 @@
#include "QidoRs.h"
-#include "StowRs.h" // For IsXmlExpected()
#include "Configuration.h"
#include "DicomWebFormatter.h"
@@ -494,7 +493,8 @@ static void ApplyMatcher(OrthancPluginRestOutput* output,
std::string wadoBase = OrthancPlugins::Configuration::GetBaseUrl(request);
- OrthancPlugins::DicomWebFormatter::HttpWriter writer(output, IsXmlExpected(request));
+ OrthancPlugins::DicomWebFormatter::HttpWriter writer(
+ output, OrthancPlugins::Configuration::IsXmlExpected(request));
// Fix of issue #13
for (ResourcesAndInstances::const_iterator
=====================================
Plugin/StowRs.cpp
=====================================
@@ -24,135 +24,67 @@
#include "Configuration.h"
#include "DicomWebFormatter.h"
-#include <Core/Toolbox.h>
-#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
-bool IsXmlExpected(const OrthancPluginHttpRequest* request)
+namespace OrthancPlugins
{
- std::string accept;
-
- if (!OrthancPlugins::LookupHttpHeader(accept, request, "accept"))
- {
- return false; // By default, return DICOM+JSON
- }
-
- Orthanc::Toolbox::ToLowerCase(accept);
- if (accept == "application/dicom+json" ||
- accept == "application/json" ||
- accept == "*/*")
- {
- return false;
- }
- else if (accept == "application/dicom+xml" ||
- accept == "application/xml" ||
- accept == "text/xml")
- {
- return true;
- }
- else
- {
- OrthancPlugins::LogError("Unsupported return MIME type: " + accept +
- ", will return DICOM+JSON");
- return false;
- }
-}
-
-
-
-void StowCallback(OrthancPluginRestOutput* output,
- const char* url,
- const OrthancPluginHttpRequest* request)
-{
- OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
-
- const std::string wadoBase = OrthancPlugins::Configuration::GetBaseUrl(request);
-
- if (request->method != OrthancPluginHttpMethod_Post)
- {
- OrthancPluginSendMethodNotAllowed(context, output, "POST");
- return;
- }
-
- std::string expectedStudy;
- if (request->groupsCount == 1)
- {
- expectedStudy = request->groups[0];
- }
-
- if (expectedStudy.empty())
- {
- OrthancPlugins::LogInfo("STOW-RS request without study");
- }
- else
- {
- OrthancPlugins::LogInfo("STOW-RS request restricted to study UID " + expectedStudy);
- }
-
- std::string header;
- if (!OrthancPlugins::LookupHttpHeader(header, request, "content-type"))
- {
- OrthancPlugins::LogError("No content type in the HTTP header of a STOW-RS request");
- OrthancPluginSendHttpStatusCode(context, output, 400 /* Bad request */);
- return;
- }
-
- std::string application;
- std::map<std::string, std::string> attributes;
- OrthancPlugins::ParseContentType(application, attributes, header);
-
- if (application != "multipart/related" ||
- attributes.find("type") == attributes.end() ||
- attributes.find("boundary") == attributes.end())
- {
- OrthancPlugins::LogError("Unable to parse the content type of a STOW-RS request (" + application + ")");
- OrthancPluginSendHttpStatusCode(context, output, 400 /* Bad request */);
- return;
- }
+ StowServer::StowServer(OrthancPluginContext* context,
+ const std::map<std::string, std::string>& headers,
+ const std::string& expectedStudy) :
+ context_(context),
+ xml_(Configuration::IsXmlExpected(headers)),
+ wadoBase_(Configuration::GetBaseUrl(headers)),
+ expectedStudy_(expectedStudy),
+ isFirst_(true),
+ result_(Json::objectValue),
+ success_(Json::arrayValue),
+ failed_(Json::arrayValue)
+ {
+ std::string tmp, contentType, subType, boundary;
+ if (!Orthanc::MultipartStreamReader::GetMainContentType(tmp, headers) ||
+ !Orthanc::MultipartStreamReader::ParseMultipartContentType(contentType, subType, boundary, tmp))
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType,
+ "The STOW-RS server expects a multipart body in its request");
+ }
+ if (contentType != "multipart/related")
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType,
+ "The Content-Type of a STOW-RS request must be \"multipart/related\"");
+ }
- std::string boundary = attributes["boundary"];
+ if (subType != "application/dicom")
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType,
+ "The STOW-RS plugin currently only supports \"application/dicom\" subtype");
+ }
- if (attributes["type"] != "application/dicom")
- {
- OrthancPlugins::LogError("The STOW-RS plugin currently only supports application/dicom");
- OrthancPluginSendHttpStatusCode(context, output, 415 /* Unsupported media type */);
- return;
+ parser_.reset(new Orthanc::MultipartStreamReader(boundary));
+ parser_->SetHandler(*this);
}
- bool isFirst = true;
-
- Json::Value result = Json::objectValue;
- Json::Value success = Json::arrayValue;
- Json::Value failed = Json::arrayValue;
-
- std::vector<OrthancPlugins::MultipartItem> items;
- OrthancPlugins::ParseMultipartBody(items, request->body, request->bodySize, boundary);
-
- for (size_t i = 0; i < items.size(); i++)
+ void StowServer::HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers,
+ const void* part,
+ size_t size)
{
- OrthancPlugins::LogInfo("Detected multipart item with content type \"" +
- items[i].contentType_ + "\" of size " +
- boost::lexical_cast<std::string>(items[i].size_));
- }
+ std::string contentType;
- for (size_t i = 0; i < items.size(); i++)
- {
- if (!items[i].contentType_.empty() &&
- items[i].contentType_ != "application/dicom")
+ if (!Orthanc::MultipartStreamReader::GetMainContentType(contentType, headers) ||
+ contentType != "application/dicom")
{
- OrthancPlugins::LogError("The STOW-RS request contains a part that is not "
- "\"application/dicom\" (it is: \"" + items[i].contentType_ + "\")");
- OrthancPluginSendHttpStatusCode(context, output, 415 /* Unsupported media type */);
- return;
+ throw Orthanc::OrthancException(
+ Orthanc::ErrorCode_UnsupportedMediaType,
+ "The STOW-RS request contains a part that is not "
+ "\"application/dicom\" (it is: \"" + contentType + "\")");
}
Json::Value dicom;
try
{
- OrthancPlugins::OrthancString s;
- s.Assign(OrthancPluginDicomBufferToJson(context, items[i].data_, items[i].size_,
+ OrthancString s;
+ s.Assign(OrthancPluginDicomBufferToJson(context_, part, size,
OrthancPluginDicomToJsonFormat_Short,
OrthancPluginDicomToJsonFlags_None, 256));
s.ToJson(dicom);
@@ -160,8 +92,8 @@ void StowCallback(OrthancPluginRestOutput* output,
catch (Orthanc::OrthancException&)
{
// Bad DICOM file => TODO add to error
- OrthancPlugins::LogWarning("STOW-RS cannot parse an incoming DICOM file");
- continue;
+ LogWarning("STOW-RS cannot parse an incoming DICOM file");
+ return;
}
if (dicom.type() != Json::objectValue ||
@@ -174,8 +106,8 @@ void StowCallback(OrthancPluginRestOutput* output,
dicom[Orthanc::DICOM_TAG_SOP_INSTANCE_UID.Format()].type() != Json::stringValue ||
dicom[Orthanc::DICOM_TAG_STUDY_INSTANCE_UID.Format()].type() != Json::stringValue)
{
- OrthancPlugins::LogWarning("STOW-RS: Missing a mandatory tag in incoming DICOM file");
- continue;
+ LogWarning("STOW-RS: Missing a mandatory tag in incoming DICOM file");
+ return;
}
const std::string seriesInstanceUid = dicom[Orthanc::DICOM_TAG_SERIES_INSTANCE_UID.Format()].asString();
@@ -184,63 +116,112 @@ void StowCallback(OrthancPluginRestOutput* output,
const std::string studyInstanceUid = dicom[Orthanc::DICOM_TAG_STUDY_INSTANCE_UID.Format()].asString();
Json::Value item = Json::objectValue;
- item[OrthancPlugins::DICOM_TAG_REFERENCED_SOP_CLASS_UID.Format()] = sopClassUid;
- item[OrthancPlugins::DICOM_TAG_REFERENCED_SOP_INSTANCE_UID.Format()] = sopInstanceUid;
-
- if (!expectedStudy.empty() &&
- studyInstanceUid != expectedStudy)
+ item[DICOM_TAG_REFERENCED_SOP_CLASS_UID.Format()] = sopClassUid;
+ item[DICOM_TAG_REFERENCED_SOP_INSTANCE_UID.Format()] = sopInstanceUid;
+
+ if (!expectedStudy_.empty() &&
+ studyInstanceUid != expectedStudy_)
{
- OrthancPlugins::LogInfo("STOW-RS request restricted to study [" + expectedStudy +
- "]: Ignoring instance from study [" + studyInstanceUid + "]");
+ LogInfo("STOW-RS request restricted to study [" + expectedStudy_ +
+ "]: Ignoring instance from study [" + studyInstanceUid + "]");
- /*item[OrthancPlugins::DICOM_TAG_WARNING_REASON.Format()] =
+ /*item[DICOM_TAG_WARNING_REASON.Format()] =
boost::lexical_cast<std::string>(0xB006); // Elements discarded
success.append(item);*/
}
else
{
- if (isFirst)
+ if (isFirst_)
{
- std::string url = wadoBase + "studies/" + studyInstanceUid;
- result[OrthancPlugins::DICOM_TAG_RETRIEVE_URL.Format()] = url;
- isFirst = false;
+ std::string url = wadoBase_ + "studies/" + studyInstanceUid;
+ result_[DICOM_TAG_RETRIEVE_URL.Format()] = url;
+ isFirst_ = false;
}
- OrthancPlugins::MemoryBuffer tmp;
- bool ok = tmp.RestApiPost("/instances", items[i].data_, items[i].size_, false);
+ MemoryBuffer tmp;
+ bool ok = tmp.RestApiPost("/instances", part, size, false);
tmp.Clear();
if (ok)
{
- std::string url = (wadoBase +
+ std::string url = (wadoBase_ +
"studies/" + studyInstanceUid +
"/series/" + seriesInstanceUid +
"/instances/" + sopInstanceUid);
- item[OrthancPlugins::DICOM_TAG_RETRIEVE_URL.Format()] = url;
- success.append(item);
+ item[DICOM_TAG_RETRIEVE_URL.Format()] = url;
+ success_.append(item);
}
else
{
- OrthancPlugins::LogError("Orthanc was unable to store instance through STOW-RS request");
- item[OrthancPlugins::DICOM_TAG_FAILURE_REASON.Format()] =
+ LogError("Orthanc was unable to store one instance in a STOW-RS request");
+ item[DICOM_TAG_FAILURE_REASON.Format()] =
boost::lexical_cast<std::string>(0x0110); // Processing failure
- failed.append(item);
+ failed_.append(item);
}
}
}
- result[OrthancPlugins::DICOM_TAG_FAILED_SOP_SEQUENCE.Format()] = failed;
- result[OrthancPlugins::DICOM_TAG_REFERENCED_SOP_SEQUENCE.Format()] = success;
- const bool isXml = IsXmlExpected(request);
- std::string answer;
-
+ void StowServer::AddChunk(const void* data,
+ size_t size)
{
- OrthancPlugins::DicomWebFormatter::Locker locker(OrthancPluginDicomWebBinaryMode_Ignore, "");
- locker.Apply(answer, context, result, isXml);
+ assert(parser_.get() != NULL);
+ parser_->AddChunk(data, size);
}
- OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(),
- isXml ? "application/dicom+xml" : "application/dicom+json");
+
+ void StowServer::Execute(OrthancPluginRestOutput* output)
+ {
+ assert(parser_.get() != NULL);
+ parser_->CloseStream();
+
+ result_[DICOM_TAG_FAILED_SOP_SEQUENCE.Format()] = failed_;
+ result_[DICOM_TAG_REFERENCED_SOP_SEQUENCE.Format()] = success_;
+
+ std::string answer;
+
+ {
+ DicomWebFormatter::Locker locker(OrthancPluginDicomWebBinaryMode_Ignore, "");
+ locker.Apply(answer, context_, result_, xml_);
+ }
+
+ OrthancPluginAnswerBuffer(context_, output, answer.c_str(), answer.size(),
+ xml_ ? "application/dicom+xml" : "application/dicom+json");
+ };
+
+
+ IChunkedRequestReader* StowServer::PostCallback(const char* url,
+ const OrthancPluginHttpRequest* request)
+ {
+ OrthancPluginContext* context = GetGlobalContext();
+
+ if (request->method != OrthancPluginHttpMethod_Post)
+ {
+ throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+ }
+
+ std::map<std::string, std::string> headers;
+ for (uint32_t i = 0; i < request->headersCount; i++)
+ {
+ headers[request->headersKeys[i]] = request->headersValues[i];
+ }
+
+ std::string expectedStudy;
+ if (request->groupsCount == 1)
+ {
+ expectedStudy = request->groups[0];
+ }
+
+ if (expectedStudy.empty())
+ {
+ LogInfo("STOW-RS request without study");
+ }
+ else
+ {
+ LogInfo("STOW-RS request restricted to study UID " + expectedStudy);
+ }
+
+ return new StowServer(context, headers, expectedStudy);
+ }
}
=====================================
Plugin/StowRs.h
=====================================
@@ -21,10 +21,42 @@
#pragma once
-#include "Configuration.h"
+#include <Core/HttpServer/MultipartStreamReader.h>
+#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
-bool IsXmlExpected(const OrthancPluginHttpRequest* request);
+namespace OrthancPlugins
+{
+ class StowServer :
+ public IChunkedRequestReader,
+ private Orthanc::MultipartStreamReader::IHandler
+ {
+ private:
+ OrthancPluginContext* context_;
+ bool xml_;
+ std::string wadoBase_;
+ std::string expectedStudy_;
+ bool isFirst_;
+ Json::Value result_;
+ Json::Value success_;
+ Json::Value failed_;
-void StowCallback(OrthancPluginRestOutput* output,
- const char* url,
- const OrthancPluginHttpRequest* request);
+ std::auto_ptr<Orthanc::MultipartStreamReader> parser_;
+
+ virtual void HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers,
+ const void* part,
+ size_t size);
+
+ public:
+ StowServer(OrthancPluginContext* context,
+ const std::map<std::string, std::string>& headers,
+ const std::string& expectedStudy);
+
+ virtual void AddChunk(const void* data,
+ size_t size);
+
+ virtual void Execute(OrthancPluginRestOutput* output);
+
+ static IChunkedRequestReader* PostCallback(const char* url,
+ const OrthancPluginHttpRequest* request);
+ };
+}
=====================================
Plugin/WadoRs.cpp
=====================================
@@ -439,9 +439,9 @@ bool LocateInstance(OrthancPluginRestOutput* output,
series["MainDicomTags"]["SeriesInstanceUID"].asString() != std::string(request->groups[1]))
{
throw Orthanc::OrthancException(Orthanc::ErrorCode_InexistentItem,
- "No instance " + std::string(request->groups[2]) +
- " in study " + std::string(request->groups[0]) +
- " or in series " + std::string(request->groups[1]));
+ "Instance " + std::string(request->groups[2]) +
+ " is not both in study " + std::string(request->groups[0]) +
+ " and in series " + std::string(request->groups[1]));
}
else
{
=====================================
Resources/CMake/JavaScriptLibraries.cmake
=====================================
@@ -0,0 +1,177 @@
+# Orthanc - A Lightweight, RESTful DICOM Store
+# Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+# Department, University Hospital of Liege, Belgium
+# Copyright (C) 2017-2019 Osimis S.A., Belgium
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+set(BASE_URL "http://orthanc.osimis.io/ThirdPartyDownloads/dicom-web")
+
+DownloadPackage(
+ "da0189f7c33bf9f652ea65401e0a3dc9"
+ "${BASE_URL}/bootstrap-4.3.1.zip"
+ "${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1")
+
+DownloadPackage(
+ "8242afdc5bd44105d9dc9e6535315484"
+ "${BASE_URL}/vuejs-2.6.10.tar.gz"
+ "${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10")
+
+DownloadPackage(
+ "3e2b4e1522661f7fcf8ad49cb933296c"
+ "${BASE_URL}/axios-0.19.0.tar.gz"
+ "${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0")
+
+DownloadPackage(
+ "a6145901f233f7d54165d8ade779082e"
+ "${BASE_URL}/Font-Awesome-4.7.0.tar.gz"
+ "${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0")
+
+
+set(BOOTSTRAP_VUE_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-vue-2.0.0-rc.24)
+
+if (BUILD_BOOTSTRAP_VUE OR
+ BUILD_BABEL_POLYFILL)
+ find_program(NPM_EXECUTABLE npm)
+ if (${NPM_EXECUTABLE} MATCHES "NPM_EXECUTABLE-NOTFOUND")
+ message(FATAL_ERROR "Please install the 'npm' standard command-line tool")
+ endif()
+endif()
+
+if (BUILD_BOOTSTRAP_VUE)
+ DownloadPackage(
+ "36ab31495ab94162e159619532e8def5"
+ "${BASE_URL}/bootstrap-vue-2.0.0-rc.24.tar.gz"
+ "${BOOTSTRAP_VUE_SOURCES_DIR}")
+
+ if (NOT IS_DIRECTORY "${BOOTSTRAP_VUE_SOURCES_DIR}/node_modules")
+ execute_process(
+ COMMAND ${NPM_EXECUTABLE} install
+ WORKING_DIRECTORY ${BOOTSTRAP_VUE_SOURCES_DIR}
+ RESULT_VARIABLE Failure
+ OUTPUT_QUIET
+ )
+
+ if (Failure)
+ message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
+ endif()
+ endif()
+
+ if (NOT IS_DIRECTORY "${BOOTSTRAP_VUE_SOURCES_DIR}/dist")
+ execute_process(
+ COMMAND ${NPM_EXECUTABLE} run build
+ WORKING_DIRECTORY ${BOOTSTRAP_VUE_SOURCES_DIR}
+ RESULT_VARIABLE Failure
+ OUTPUT_QUIET
+ )
+
+ if (Failure)
+ message(FATAL_ERROR "Error while running 'npm build' on Bootstrap-Vue")
+ endif()
+ endif()
+
+else()
+
+ ##
+ ## Generation of the precompiled Bootstrap-Vue package:
+ ##
+ ## Possibility 1 (build from sources):
+ ## $ cmake -DBUILD_BOOTSTRAP_VUE=ON .
+ ## $ tar cvfz bootstrap-vue-2.0.0-rc.24-dist.tar.gz bootstrap-vue-2.0.0-rc.24/dist/
+ ##
+ ## Possibility 2 (download from CDN):
+ ## $ mkdir /tmp/i && cd /tmp/i
+ ## $ wget -r --no-parent https://unpkg.com/bootstrap-vue@2.0.0-rc.24/dist/
+ ## $ mv unpkg.com/bootstrap-vue at 2.0.0-rc.24/ bootstrap-vue-2.0.0-rc.24
+ ## $ rm bootstrap-vue-2.0.0-rc.24/dist/index.html
+ ## $ tar cvfz bootstrap-vue-2.0.0-rc.24-dist.tar.gz bootstrap-vue-2.0.0-rc.24/dist/
+
+ DownloadPackage(
+ "ba0e67b1f0b4ce64e072b42b17f6c578"
+ "${BASE_URL}/bootstrap-vue-2.0.0-rc.24-dist.tar.gz"
+ "${BOOTSTRAP_VUE_SOURCES_DIR}")
+
+endif()
+
+
+if (BUILD_BABEL_POLYFILL)
+ set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR}/node_modules/babel-polyfill/dist)
+
+ if (NOT IS_DIRECTORY "${BABEL_POLYFILL_SOURCES_DIR}")
+ execute_process(
+ COMMAND ${NPM_EXECUTABLE} install babel-polyfill
+ WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
+ RESULT_VARIABLE Failure
+ OUTPUT_QUIET
+ )
+
+ if (Failure)
+ message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
+ endif()
+ endif()
+else()
+
+ ## curl -L https://unpkg.com/babel-polyfill@6.26.0/dist/polyfill.min.js | gzip > babel-polyfill-6.26.0.min.js.gz
+
+ set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR})
+ DownloadCompressedFile(
+ "49f7bad4176d715ce145e75c903988ef"
+ "${BASE_URL}/babel-polyfill-6.26.0.min.js.gz"
+ "${CMAKE_CURRENT_BINARY_DIR}/polyfill.min.js")
+
+endif()
+
+
+set(JAVASCRIPT_LIBS_DIR ${CMAKE_CURRENT_BINARY_DIR}/javascript-libs)
+file(MAKE_DIRECTORY ${JAVASCRIPT_LIBS_DIR})
+
+file(COPY
+ ${BABEL_POLYFILL_SOURCES_DIR}/polyfill.min.js
+ ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js
+ ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js.map
+ ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.js
+ ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.map
+ ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/js/bootstrap.min.js
+ ${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10/dist/vue.min.js
+ DESTINATION
+ ${JAVASCRIPT_LIBS_DIR}/js
+ )
+
+file(COPY
+ ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.css
+ ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.css.map
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/css/font-awesome.min.css
+ ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/css/bootstrap.min.css
+ ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/css/bootstrap.min.css.map
+ DESTINATION
+ ${JAVASCRIPT_LIBS_DIR}/css
+ )
+
+file(COPY
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/FontAwesome.otf
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.eot
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.svg
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.ttf
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.woff
+ ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.woff2
+ DESTINATION
+ ${JAVASCRIPT_LIBS_DIR}/fonts
+ )
+
+file(COPY
+ ${ORTHANC_ROOT}/Resources/OrthancLogo.png
+ DESTINATION
+ ${JAVASCRIPT_LIBS_DIR}/img
+ )
=====================================
Resources/Orthanc/DownloadOrthancFramework.cmake
=====================================
@@ -66,6 +66,9 @@ if (ORTHANC_FRAMEWORK_SOURCE STREQUAL "hg" OR
if (NOT DEFINED ORTHANC_FRAMEWORK_BRANCH)
if (ORTHANC_FRAMEWORK_VERSION STREQUAL "mainline")
set(ORTHANC_FRAMEWORK_BRANCH "default")
+ set(ORTHANC_FRAMEWORK_MAJOR 999)
+ set(ORTHANC_FRAMEWORK_MINOR 999)
+ set(ORTHANC_FRAMEWORK_REVISION 999)
else()
set(ORTHANC_FRAMEWORK_BRANCH "Orthanc-${ORTHANC_FRAMEWORK_VERSION}")
@@ -103,9 +106,18 @@ if (ORTHANC_FRAMEWORK_SOURCE STREQUAL "hg" OR
set(ORTHANC_FRAMEWORK_MD5 "404baef5d4c43e7c5d9410edda8ef5a5")
elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.5.5")
set(ORTHANC_FRAMEWORK_MD5 "cfc437e0687ae4bd725fd93dc1f08bc4")
+ elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.5.6")
+ set(ORTHANC_FRAMEWORK_MD5 "3c29de1e289b5472342947168f0105c0")
+ elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.5.7")
+ set(ORTHANC_FRAMEWORK_MD5 "e1b76f01116d9b5d4ac8cc39980560e3")
endif()
endif()
endif()
+else()
+ message("Using the Orthanc framework from a path of the filesystem. Assuming mainline version.")
+ set(ORTHANC_FRAMEWORK_MAJOR 999)
+ set(ORTHANC_FRAMEWORK_MINOR 999)
+ set(ORTHANC_FRAMEWORK_REVISION 999)
endif()
=====================================
Resources/Orthanc/Sdk-1.5.7/orthanc/OrthancCPlugin.h
=====================================
The diff for this file was not included because it is too large.
=====================================
Resources/Samples/Proxy/NOTES.txt
=====================================
@@ -0,0 +1,9 @@
+This is a sample configuration file for nginx to test the DICOMweb
+plugin behind a HTTP proxy. To start the proxy as a regular user:
+
+$ nginx -c ./nginx.local.conf -p $PWD
+
+
+References about "Forwarded" header in nginx:
+https://onefeed.xyz/posts/x-forwarded-for-vs-x-real-ip.html
+https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/
=====================================
Resources/Samples/Proxy/nginx.local.conf
=====================================
@@ -0,0 +1,39 @@
+worker_processes 1;
+error_log stderr;
+daemon off;
+pid nginx.pid;
+
+# `events` section is mandatory
+events {
+ worker_connections 1024; # Default: 1024
+}
+
+http {
+ # prevent nginx sync issues on OSX
+ proxy_buffering off;
+ access_log off;
+
+ server {
+ listen 9977 default_server;
+ client_max_body_size 4G;
+
+ # location may have to be adjusted depending on your OS and nginx install
+ include /etc/nginx/mime.types;
+
+ # if not in your system mime.types, add this line to support WASM:
+ # types {
+ # application/wasm wasm;
+ # }
+
+ # reverse proxy orthanc
+ location /orthanc/ {
+ rewrite /orthanc(.*) $1 break;
+ proxy_pass http://127.0.0.1:8042;
+ proxy_set_header Host $http_host;
+ proxy_set_header my-auth-header good-token;
+ #proxy_request_buffering off;
+ #proxy_max_temp_file_size 0;
+ #client_max_body_size 0;
+ }
+ }
+}
=====================================
Resources/Samples/Python/SendStow.py
=====================================
@@ -31,8 +31,7 @@ import sys
import json
import uuid
-#if len(sys.argv) < 2:
-if len(sys.argv) < 1:
+if len(sys.argv) < 2:
print('Usage: %s <StowUri> <file>...' % sys.argv[0])
print('')
print('Example: %s http://localhost:8042/dicom-web/studies hello.dcm world.dcm' % sys.argv[0])
@@ -59,11 +58,31 @@ for i in range(2, len(sys.argv)):
# Closing boundary
body += bytearray('--%s--' % boundary, 'ascii')
-# Do the HTTP POST request to the STOW-RS server
-r = requests.post(URL, data=body, headers= {
+headers = {
'Content-Type' : 'multipart/related; type=application/dicom; boundary=%s' % boundary,
'Accept' : 'application/json',
-})
+ }
+
+# Do the HTTP POST request to the STOW-RS server
+if False:
+ # Don't use chunked transfer (this code was in use in DICOMweb plugin <= 0.6)
+ r = requests.post(URL, data=body, headers=headers)
+else:
+ # Use chunked transfer
+ # https://2.python-requests.org/en/master/user/advanced/#chunk-encoded-requests
+ def gen():
+ chunkSize = 1024 * 1024
+
+ l = len(body) / chunkSize
+ for i in range(l):
+ pos = i * chunkSize
+ yield body[pos : pos + chunkSize]
+
+ if len(body) % chunkSize != 0:
+ yield body[l * chunkSize :]
+
+ r = requests.post(URL, data=gen(), headers=headers)
+
j = json.loads(r.text)
=====================================
Resources/SyncOrthancFolder.py
=====================================
@@ -11,7 +11,7 @@ import stat
import urllib2
TARGET = os.path.join(os.path.dirname(__file__), 'Orthanc')
-PLUGIN_SDK_VERSION = '1.5.4'
+PLUGIN_SDK_VERSION = '1.5.7'
REPOSITORY = 'https://bitbucket.org/sjodogne/orthanc/raw'
FILES = [
=====================================
Status.txt
=====================================
@@ -1,4 +1,4 @@
-Reference: http://medical.nema.org/medical/dicom/current/output/html/part18.html
+Reference: http://dicom.nema.org/MEDICAL/dicom/2019a/output/html/part18.html
@@ -86,7 +86,19 @@ Supported.
6.5.8 WADO-RS / RetrieveRenderedTransaction
===========================================
-Not supported yet.
+http://dicom.nema.org/MEDICAL/dicom/2019a/output/chtml/part18/sect_6.5.8.html
+
+Supported
+---------
+
+* Single-frame and multi-frame retrieval
+* JPEG and PNG output
+
+Not supported
+-------------
+
+* GIF output
+* None of the "Retrieve Rendered Query Parameters" (table 6.5.8-2)
=====================================
UnitTestsSources/UnitTestsMain.cpp
=====================================
@@ -21,6 +21,7 @@
#include <gtest/gtest.h>
#include <boost/lexical_cast.hpp>
+#include <boost/algorithm/string/predicate.hpp>
#include "../Plugin/Configuration.h"
@@ -29,6 +30,7 @@ using namespace OrthancPlugins;
OrthancPluginContext* context_ = NULL;
+// TODO => Remove this test (now in Orthanc core)
TEST(ContentType, Parse)
{
std::string c;
=====================================
WebApplication/app.js
=====================================
@@ -0,0 +1,398 @@
+var DICOM_TAG_ACCESSION_NUMBER = '00080050';
+var DICOM_TAG_MODALITY = '00080060';
+var DICOM_TAG_PATIENT_ID = '00100020';
+var DICOM_TAG_PATIENT_NAME = '00100010';
+var DICOM_TAG_SERIES_DESCRIPTION = '0008103E';
+var DICOM_TAG_SERIES_INSTANCE_UID = '0020000E';
+var DICOM_TAG_SOP_INSTANCE_UID = '00080018';
+var DICOM_TAG_STUDY_DATE = '00080020';
+var DICOM_TAG_STUDY_ID = '00200010';
+var DICOM_TAG_STUDY_INSTANCE_UID = '0020000D';
+var MAX_RESULTS = 100;
+
+/**
+ * This is a minimal 1x1 PNG image with white background, as generated by:
+ * $ convert -size 1x1 -define png:include-chunk=none xc:white png:- | base64 -w 0
+ **/
+var DEFAULT_PREVIEW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg==';
+
+var app = new Vue({
+ el: '#app',
+ computed: {
+ studiesCount() {
+ return this.studies.length
+ },
+ seriesCount() {
+ return this.series.length
+ }
+ },
+ data: {
+ orthancApiRoot: '../../../',
+ previewFailure: true,
+ preview: DEFAULT_PREVIEW,
+ showTruncatedStudies: false,
+ showNoServer: false,
+ showStudies: false,
+ showSeries: false,
+ maxResults: MAX_RESULTS,
+ currentPage: 0,
+ perPage: 10,
+ servers: [ ],
+ serversInfo: { },
+ activeServer: '',
+ lookup: { },
+ studies: [ ],
+ currentStudy: null,
+ jobId: '',
+ jobLevel: '',
+ jobUri: '',
+ jobDetails: '',
+ studiesFields: [
+ {
+ key: DICOM_TAG_PATIENT_ID + '.Value',
+ label: 'Patient ID',
+ sortable: true
+ },
+ {
+ key: DICOM_TAG_PATIENT_NAME + '.Value',
+ label: 'Patient name',
+ sortable: true
+ },
+ {
+ key: DICOM_TAG_ACCESSION_NUMBER + '.Value',
+ label: 'Accession number',
+ sortable: true
+ },
+ {
+ key: DICOM_TAG_STUDY_DATE + '.Value',
+ label: 'Study date',
+ sortable: true
+ },
+ {
+ key: 'operations',
+ label: ''
+ }
+ ],
+ studyToDelete: null,
+ studyTags: [ ],
+ studyTagsFields: [
+ {
+ key: 'Tag',
+ sortable: true
+ },
+ {
+ key: 'Name',
+ label: 'Description',
+ sortable: true
+ },
+ {
+ key: 'Value',
+ sortable: true
+ }
+ ],
+ series: [ ],
+ seriesFields: [
+ {
+ key: DICOM_TAG_SERIES_DESCRIPTION + '.Value',
+ label: 'Series description',
+ sortable: true
+ },
+ {
+ key: DICOM_TAG_MODALITY + '.Value',
+ label: 'Modality',
+ sortable: true
+ },
+ {
+ key: 'operations',
+ label: ''
+ }
+ ],
+ seriesToDelete: null,
+ seriesTags: [ ],
+ seriesTagsFields: [
+ {
+ key: 'Tag',
+ sortable: true
+ },
+ {
+ key: 'Name',
+ label: 'Description',
+ sortable: true
+ },
+ {
+ key: 'Value',
+ sortable: true
+ }
+ ],
+ scrollToSeries: false,
+ scrollToStudies: false
+ },
+ mounted: () => {
+ axios
+ .get('../../servers?expand')
+ .then(response => {
+ app.serversInfo = response.data;
+ app.servers = Object.keys(response.data).map(i => i);
+ app.Clear();
+ });
+ axios
+ .get('../../info')
+ .then(response => {
+ app.orthancApiRoot = response.data.OrthancApiRoot;
+ if (!app.orthancApiRoot.endsWith('/')) {
+ app.orthancApiRoot += '/';
+ }
+ app.orthancApiRoot += '../../'; // To be at the same level as "info"
+ });
+ },
+ methods: {
+ /**
+ * Toolbox
+ **/
+
+ ScrollToRef: function(refName) {
+ var element = app.$refs[refName];
+ window.scrollTo(0, element.offsetTop);
+ },
+ ShowErrorModal: function() {
+ app.$refs['modal-error'].show();
+ },
+ RefreshJobDetails: function() {
+ axios
+ .get(app.jobUri)
+ .then(response => {
+ app.jobDetails = response.data;
+ })
+ .catch(response => {
+ app.jobDetails = 'Job details are not available';
+ })
+ },
+
+
+ /**
+ * Studies
+ **/
+
+ SetStudies: function(response) {
+ if (response.data.length > app.maxResults) {
+ app.showTruncatedStudies = true;
+ app.studies = response.data.splice(0, app.maxResults);
+ } else {
+ app.showTruncatedStudies = false;
+ app.studies = response.data;
+ }
+ app.showStudies = true;
+ app.showSeries = false;
+ app.studyToDelete = null;
+ app.scrollToStudies = true;
+ },
+ ExecuteLookup: function() {
+ var args = {
+ 'fuzzymatching' : 'true',
+ 'limit' : (app.maxResults + 1).toString()
+ };
+
+ if ('patientName' in app.lookup) {
+ args[DICOM_TAG_PATIENT_NAME] = app.lookup.patientName;
+ }
+
+ if ('patientID' in app.lookup) {
+ args[DICOM_TAG_PATIENT_ID] = app.lookup.patientID;
+ }
+
+ if ('studyDate' in app.lookup) {
+ args[DICOM_TAG_STUDY_DATE] = app.lookup.studyDate;
+ }
+
+ if ('accessionNumber' in app.lookup) {
+ args[DICOM_TAG_ACCESSION_NUMBER] = app.lookup.accessionNumber;
+ }
+
+ app.activeServer = app.lookup.server;
+ axios
+ .post('../../servers/' + app.activeServer + '/qido', {
+ 'Uri' : '/studies',
+ 'Arguments' : args,
+ })
+ .then(app.SetStudies)
+ .catch(response => {
+ app.showStudies = false;
+ app.showSeries = false;
+ app.ShowErrorModal();
+ });
+ },
+ Clear: function() {
+ app.lookup = {};
+ currentStudy = null;
+ app.showSeries = false;
+ app.showStudies = false;
+ if (app.servers.length == 0) {
+ app.showNoServer = true;
+ } else {
+ app.showNoServer = false;
+ app.lookup.server = app.servers[0];
+ }
+ },
+ OnLookup: function(event) {
+ event.preventDefault();
+ app.ExecuteLookup();
+ },
+ OnReset: function(event) {
+ event.preventDefault();
+ app.Clear();
+ },
+ OpenStudyDetails: function(study) {
+ app.studyTags = Object.keys(study).map(i => {
+ var item = study[i];
+ item['Tag'] = i;
+ return item;
+ });
+
+ app.$refs['study-details'].show();
+ },
+ RetrieveStudy: function(study) {
+ var base = '../../servers/';
+ axios
+ .post(base + app.activeServer + '/wado', {
+ 'Uri' : '/studies/' + study[DICOM_TAG_STUDY_INSTANCE_UID].Value
+ })
+ .then(response => {
+ app.jobLevel = 'study';
+ app.jobId = response.data.ID;
+ app.jobUri = base + response.data.Path;
+ app.$refs['retrieve-job'].show();
+ app.RefreshJobDetails();
+ });
+ },
+ ConfirmDeleteStudy: function(study) {
+ app.studyToDelete = study;
+ app.$bvModal.show('study-delete-confirm');
+ },
+ ExecuteDeleteStudy: function(study) {
+ axios
+ .post('../../servers/' + app.activeServer + '/delete', {
+ 'Level': 'Study',
+ 'StudyInstanceUID': app.studyToDelete[DICOM_TAG_STUDY_INSTANCE_UID].Value
+ })
+ .then(app.ExecuteLookup)
+ .catch(app.ShowErrorModal)
+ },
+
+
+ /**
+ * Series
+ **/
+
+ LoadSeriesOfCurrentStudy: function() {
+ axios
+ .post('../../servers/' + app.activeServer + '/qido', {
+ 'Uri' : '/studies/' + app.currentStudy + '/series'
+ })
+ .then(response => {
+ if (response.data.length > 0) {
+ app.series = response.data;
+ app.showSeries = true;
+ app.seriesToDelete = null;
+ app.scrollToSeries = true;
+ } else {
+ // No more series, so no more study, so re-lookup
+ app.ExecuteLookup();
+ }
+ })
+ .catch(app.ShowErrorModal);
+ },
+ OpenSeries: function(series) {
+ app.currentStudy = series[DICOM_TAG_STUDY_INSTANCE_UID].Value;
+ app.LoadSeriesOfCurrentStudy();
+ },
+ OpenSeriesDetails: function(series) {
+ app.seriesTags = Object.keys(series).map(i => {
+ var item = series[i];
+ item['Tag'] = i;
+ return item;
+ });
+
+ app.$refs['series-details'].show();
+ },
+ RetrieveSeries: function(series) {
+ var base = '../../servers/';
+ axios
+ .post(base + app.activeServer + '/wado', {
+ 'Uri' : ('/studies/' + app.currentStudy +
+ '/series/' + series[DICOM_TAG_SERIES_INSTANCE_UID].Value)
+ })
+ .then(response => {
+ app.jobLevel = 'series';
+ app.jobId = response.data.ID;
+ app.jobUri = base + response.data.Path;
+ app.$refs['retrieve-job'].show();
+ app.RefreshJobDetails();
+ });
+ },
+ OpenSeriesPreview: function(series) {
+ axios
+ .post('../../servers/' + app.activeServer + '/get', {
+ 'Uri' : ('/studies/' + app.currentStudy + '/series/' +
+ series[DICOM_TAG_SERIES_INSTANCE_UID].Value + '/instances')
+ })
+ .then(response => {
+ var instance = response.data[Math.floor(response.data.length / 2)];
+
+ axios
+ .post('../../servers/' + app.activeServer + '/get', {
+ 'Uri' : ('/studies/' + app.currentStudy + '/series/' +
+ series[DICOM_TAG_SERIES_INSTANCE_UID].Value + '/instances/' +
+ instance[DICOM_TAG_SOP_INSTANCE_UID].Value + '/rendered')
+ }, {
+ responseType: 'arraybuffer'
+ })
+ .then(response => {
+ // https://github.com/axios/axios/issues/513
+ var image = btoa(new Uint8Array(response.data)
+ .reduce((data, byte) => data + String.fromCharCode(byte), ''));
+ app.preview = ("data:" +
+ response.headers['content-type'].toLowerCase() +
+ ";base64," + image);
+ app.previewFailure = false;
+ })
+ .catch(response => {
+ app.previewFailure = true;
+ })
+ .finally(function() {
+ app.$refs['series-preview'].show();
+ })
+ })
+ },
+ ConfirmDeleteSeries: function(series) {
+ app.seriesToDelete = series;
+ app.$bvModal.show('series-delete-confirm');
+ },
+ ExecuteDeleteSeries: function(series) {
+ axios
+ .post('../../servers/' + app.activeServer + '/delete', {
+ 'Level': 'Series',
+ 'StudyInstanceUID': app.currentStudy,
+ 'SeriesInstanceUID': app.seriesToDelete[DICOM_TAG_SERIES_INSTANCE_UID].Value
+ })
+ .then(app.LoadSeriesOfCurrentStudy)
+ .catch(app.ShowErrorModal)
+ }
+ },
+
+ updated: function () {
+ this.$nextTick(function () {
+ // Code that will run only after the
+ // entire view has been re-rendered
+
+ if (app.scrollToStudies) {
+ app.scrollToStudies = false;
+ app.ScrollToRef('studies-top');
+ }
+
+ if (app.scrollToSeries) {
+ app.scrollToSeries = false;
+ app.ScrollToRef('series-top');
+ }
+ })
+ }
+});
=====================================
WebApplication/index.html
=====================================
@@ -0,0 +1,261 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+ <title>Orthanc - DICOMweb client</title>
+
+ <!-- Add Bootstrap and Bootstrap-Vue CSS to the <head> section -->
+ <link type="text/css" rel="stylesheet" href="../libs/css/bootstrap.min.css"/>
+ <link type="text/css" rel="stylesheet" href="../libs/css/bootstrap-vue.min.css"/>
+ <link type="text/css" rel="stylesheet" href="../libs/css/font-awesome.min.css"/>
+
+ <script src="../libs/js/polyfill.min.js"></script>
+
+ <!-- CSS style to truncate long text in tables, provided they have
+ class "table-layout:fixed;" or attribute ":fixed=true" -->
+ <style>
+ table td {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ </style>
+
+ </head>
+ <body>
+ <div class="container" id="app">
+ <p style="height:1em"></p>
+
+ <div class="jumbotron">
+ <div class="row">
+ <div class="col-sm-8">
+ <h1 class="display-4">DICOMweb client</h1>
+ <p class="lead">
+ This is a simple client interface to the DICOMweb
+ servers that are configured in Orthanc. From this page,
+ you can search the content of remote DICOMweb servers
+ (QIDO-RS), then locally retrieve the DICOM
+ studies/series of interest
+ (WADO-RS). <a :href="orthancApiRoot"
+ target="_blank">Orthanc Explorer</a> can be used to send
+ DICOM resources to remote DICOMweb servers (STOW-RS).
+ </p>
+ <p>
+ <a class="btn btn-primary btn-lg"
+ href="https://book.orthanc-server.com/plugins/dicomweb.html"
+ target="_blank" role="button">Open documentation</a>
+ <a class="btn btn-primary btn-lg"
+ :href="orthancApiRoot"
+ target="_blank" role="button">Open Orthanc Explorer</a>
+ </p>
+ </div>
+ <div class="col-sm-4">
+ <a href="http://www.orthanc-server.com/" target="_blank">
+ <img class="img-fluid" alt="Orthanc" src="../libs/img/OrthancLogo.png" />
+ </a>
+ </div>
+ </div>
+ </div>
+
+
+ <b-modal ref="modal-error" size="xl" ok-only="true">
+ <template slot="modal-title">
+ Connection error
+ </template>
+ <div class="d-block">
+ <p>
+ There was an error connecting to "{{ activeServer }}" server.
+ </p>
+ </div>
+ </b-modal>
+
+
+ <!-- LOOKUP -->
+
+ <div class="row">
+ <b-alert variant="danger" dismissible v-model="showNoServer">
+ No DICOMweb server is configured!
+ </b-alert>
+ <b-form style="width:100%;padding:5px;">
+ <b-form-group label="DICOMweb server:" label-cols-sm="4" label-cols-lg="3">
+ <b-form-select v-model="lookup.server" :options="servers"></b-form-select>
+ </b-form-group>
+ <b-form-group label="Patient ID:" label-cols-sm="4" label-cols-lg="3">
+ <b-form-input v-model="lookup.patientID"></b-form-input>
+ </b-form-group>
+ <b-form-group label="Patient name:" label-cols-sm="4" label-cols-lg="3">
+ <b-form-input v-model="lookup.patientName"></b-form-input>
+ </b-form-group>
+ <b-form-group label="Accession number:" label-cols-sm="4" label-cols-lg="3">
+ <b-form-input v-model="lookup.accessionNumber"></b-form-input>
+ </b-form-group>
+ <b-form-group label="Study date:" label-cols-sm="4" label-cols-lg="3">
+ <b-form-input v-model="lookup.studyDate"></b-form-input>
+ </b-form-group>
+ <p class="pull-right">
+ <b-button type="submit" variant="success" @click="OnLookup"
+ size="lg">Do lookup</b-button>
+ <b-button type="reset" variant="outline-danger" @click="OnReset"
+ size="lg">Reset</b-button>
+ </p>
+ </b-form>
+ </div>
+
+
+ <!-- STUDIES -->
+
+ <hr v-show="showStudies" ref="studies-top" />
+ <div class="row" v-show="showStudies">
+ <h1>Studies</h1>
+ </div>
+ <div class="row" v-show="showStudies">
+ <b-alert variant="warning" dismissible v-model="showTruncatedStudies">
+ More than {{ maxResults }} matching studies, results have been truncated!
+ </b-alert>
+ </div>
+ <div class="row" v-show="showStudies">
+ <b-pagination v-model="currentPage" :per-page="perPage" :total-rows="studiesCount"></b-pagination>
+ <b-table striped hover :current-page="currentPage" :per-page="perPage"
+ :items="studies" :fields="studiesFields" :fixed="false">
+ <template slot="operations" slot-scope="data">
+ <b-button @click="OpenSeries(data.item)" title="Open series">
+ <i class="fa fa-folder-open"></i>
+ </b-button>
+ <b-button @click="OpenStudyDetails(data.item)" title="Open tags">
+ <i class="fa fa-address-card"></i>
+ </b-button>
+ <b-button @click="RetrieveStudy(data.item)" title="Retrieve study using WADO-RS">
+ <i class="fa fa-cloud-download"></i>
+ </b-button>
+ <b-button @click="ConfirmDeleteStudy(data.item)"
+ v-if="serversInfo[activeServer].HasDelete == '1'" title="Delete remote study">
+ <i class="fa fa-trash"></i>
+ </b-button>
+ </template>
+ </b-table>
+
+ <b-modal ref="study-details" size="xl" ok-only="true">
+ <template slot="modal-title">
+ Details of study
+ </template>
+ <div class="d-block text-center">
+ <b-table striped :items="studyTags" :fields="studyTagsFields" :fixed="true">
+ </b-table>
+ </div>
+ </b-modal>
+
+ <b-modal id="study-delete-confirm" size="xl" @ok="ExecuteDeleteStudy">
+ <template slot="modal-title">
+ Confirm deletion
+ </template>
+ <div class="d-block">
+ <p>
+ Are you sure you want to remove this study from the remote server?
+ </p>
+ <p>
+ Patient name: {{ studyToDelete && studyToDelete['00100010'] && studyToDelete['00100010'].Value }}
+ </p>
+ </div>
+ </b-modal>
+ </div>
+
+
+ <!-- SERIES -->
+
+ <hr v-show="showSeries" ref="series-top" />
+ <div class="row" v-show="showSeries">
+ <h1>Series</h1>
+ </div>
+ <div class="row" v-show="showSeries">
+ <b-table striped hover :items="series" :fields="seriesFields" :fixed="false">
+ <template slot="operations" slot-scope="data">
+ <b-button @click="OpenSeriesPreview(data.item)" title="Preview">
+ <i class="fa fa-eye"></i>
+ </b-button>
+ <b-button @click="OpenSeriesDetails(data.item)" title="Open tags">
+ <i class="fa fa-address-card"></i>
+ </b-button>
+ <b-button @click="RetrieveSeries(data.item)" title="Retrieve series using WADO-RS">
+ <i class="fa fa-cloud-download"></i>
+ </b-button>
+ <b-button @click="ConfirmDeleteSeries(data.item)"
+ v-if="serversInfo[activeServer].HasDelete" title="Delete remote series">
+ <i class="fa fa-trash"></i>
+ </b-button>
+ </template>
+ </b-table>
+
+ <b-modal ref="series-details" size="xl" ok-only="true">
+ <template slot="modal-title">
+ Details of series
+ </template>
+ <div class="d-block text-center">
+ <b-table striped :items="seriesTags" :fields="seriesTagsFields" :fixed="true">
+ </b-table>
+ </div>
+ </b-modal>
+
+ <b-modal ref="series-preview" size="xl" ok-only="true">
+ <template slot="modal-title">
+ Preview of series
+ </template>
+ <div class="d-block text-center">
+ <b-alert variant="danger" v-model="previewFailure">
+ The remote DICOMweb server cannot generate a preview for this image.
+ </b-alert>
+ <b-img v-if="!previewFailure" :src="preview" fluid alt=""></b-img>
+ </div>
+ </b-modal>
+
+ <b-modal id="series-delete-confirm" size="xl" @ok="ExecuteDeleteSeries">
+ <template slot="modal-title">
+ Confirm deletion
+ </template>
+ <div class="d-block">
+ <p>
+ Are you sure you want to remove this series from the remote server?
+ </p>
+ <p>
+ Series description: {{ seriesToDelete && seriesToDelete['0008103E'] && seriesToDelete['0008103E'].Value }}
+ </p>
+ </div>
+ </b-modal>
+ </div>
+
+
+ <b-modal ref="retrieve-job" size="xl" ok-only="true">
+ <template slot="modal-title">
+ Retrieving {{ jobLevel }}
+ </template>
+ <div class="d-block">
+ <p>
+ Orthanc is now running a background job to retrieve the
+ {{ jobLevel }} from remote server "{{ activeServer }}" using
+ WADO-RS.
+ </p>
+ <p>
+ Job ID: <tt>{{ jobId }}</tt>
+ </p>
+ <p>
+ Job details:
+ </p>
+ <pre>{{ jobDetails }}</pre>
+ <p>
+ <b-button variant="success" @click="RefreshJobDetails()">Refresh job details</b-button>
+ </p>
+ </div>
+ </b-modal>
+
+
+ <p style="height:5em"></p>
+ </div>
+
+ <!-- Add Vue and Bootstrap-Vue JS just before the closing </body> tag -->
+ <script src="../libs/js/vue.min.js"></script>
+ <script src="../libs/js/bootstrap-vue.min.js"></script>
+ <script src="../libs/js/axios.min.js"></script>
+ <script type="text/javascript" src="app.js"></script>
+ </body>
+</html>
View it on GitLab: https://salsa.debian.org/med-team/orthanc-dicomweb/commit/fd1245b9b49e4270b47c106441aacd0381261d8f
--
View it on GitLab: https://salsa.debian.org/med-team/orthanc-dicomweb/commit/fd1245b9b49e4270b47c106441aacd0381261d8f
You're receiving this email because of your account on salsa.debian.org.
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20190731/ec0215af/attachment-0001.html>
More information about the debian-med-commit
mailing list