Mercurial > hg > orthanc-dicomweb
changeset 320:86e5357039e9 refactoring
integration default->refactoring
author | Sebastien Jodogne <s.jodogne@gmail.com> |
---|---|
date | Thu, 20 Jun 2019 21:16:12 +0200 |
parents | 59182b13a58c (diff) 1d4a5b25992f (current diff) |
children | bae597dc9837 |
files | Plugin/Plugin.cpp |
diffstat | 18 files changed, 3067 insertions(+), 766 deletions(-) [+] |
line wrap: on
line diff
--- a/CMakeLists.txt Wed Jun 12 10:17:55 2019 +0200 +++ b/CMakeLists.txt Thu Jun 20 21:16:12 2019 +0200 @@ -45,6 +45,10 @@ 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(BUILD_BOOTSTRAP_VUE OFF CACHE BOOL "Compile Bootstrap-Vue from sources") +set(BUILD_BABEL_POLYFILL OFF CACHE BOOL "Retrieve babel-polyfill from npm") + + # Download and setup the Orthanc framework include(${CMAKE_SOURCE_DIR}/Resources/Orthanc/DownloadOrthancFramework.cmake) @@ -60,8 +64,8 @@ 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) @@ -110,11 +114,30 @@ 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
--- a/NEWS Wed Jun 12 10:17:55 2019 +0200 +++ b/NEWS Thu Jun 20 21:16:12 2019 +0200 @@ -1,9 +1,12 @@ Pending changes in the mainline =============================== -=> Minimum SDK version: 1.5.7 <= - +* Support "Transfer-Encoding: chunked" to reduce memory consumption in STOW-RS + (provided the SDK version is above 1.5.7) * Handling of the HTTP header "Forwarded" for WADO-RS +* New URI: /dicom-web/servers/.../qido +* New URI: /dicom-web/servers/.../delete +* Basic implementation of WADO-RS "Retrieve rendered instance" Version 0.6 (2019-02-27)
--- a/Plugin/Configuration.cpp Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/Configuration.cpp Thu Jun 20 21:16:12 2019 +0200 @@ -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 "DicomWebServers.h" +#include <boost/algorithm/string/predicate.hpp> -#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h> -#include <Core/Toolbox.h> namespace OrthancPlugins { @@ -85,164 +87,38 @@ } - 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"; - - // 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; - - for (; it != iteratorEnd; ++it) + if (value.type() != Json::objectValue) { - const std::string line(*it); - size_t colon = line.find(':'); - size_t eol = line.find('\r'); + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, + "This is not a JSON object"); + } - if (colon != std::string::npos && - eol != std::string::npos && - colon < eol && - eol + 2 == line.length()) - { - std::string key = Orthanc::Toolbox::StripSpaces(line.substr(0, colon)); - Orthanc::Toolbox::ToLowerCase(key); + if (value.type() != Json::objectValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, + "The JSON object is not a JSON associative array as expected"); + } - const std::string value = Orthanc::Toolbox::StripSpaces(line.substr(colon + 1, eol - colon - 1)); + Json::Value::Members names = value.getMemberNames(); - 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; - } + for (size_t i = 0; i < names.size(); i++) + { + if (value[names[i]].type() != Json::stringValue) + { + 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) - { - // 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)) - { - // Cannot find the HTTP headers of this multipart item - throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol); - } - - // 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); - - boost::cmatch separator; - - if (hasLength) - { - 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); - } - } - 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); - } - } - - 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 ParseMultipartBody(std::vector<MultipartItem>& result, - const void* body, - const uint64_t bodySize, - const std::string& boundary) - { - // Reference: - // https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html - - 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).*"); - - // Look for the next boundary separator in the body (note the "?" - // to request non-greedy search) - const boost::regex nextSeparator(".*?(\r\n--" + boundary + ").*"); - - const char* start = reinterpret_cast<const char*>(body); - const char* end = reinterpret_cast<const char*>(body) + bodySize; - - boost::cmatch what; - if (boost::regex_match(start, end, what, firstSeparator1, boost::match_perl | boost::match_single_line) || - boost::regex_match(start, end, what, firstSeparator2, boost::match_perl | boost::match_single_line)) - { - const char* current = what[1].first; - - while (current != NULL && - current + 2 < end) - { - 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 - { - current = ParseMultipartItem(result, current + 2, end, nextSeparator); - } - } - } - } - + void ParseAssociativeArray(std::map<std::string, std::string>& target, const Json::Value& value, @@ -254,34 +130,13 @@ "This is not a JSON object"); } - if (!value.isMember(key)) + if (value.isMember(key)) { - return; - } - - const Json::Value& tmp = value[key]; - - if (tmp.type() != Json::objectValue) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, - "The field \"" + key + "\" of a JSON object is " - "not a JSON associative array as expected"); + ParseAssociativeArray(target, value[key]); } - - Json::Value::Members names = tmp.getMemberNames(); - - for (size_t i = 0; i < names.size(); i++) + else { - 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(); - } + target.clear(); } } @@ -303,6 +158,139 @@ } + 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"); + } + } + + + std::string RemoveMultipleSlashes(const std::string& source) + { + std::string target; + target.reserve(source.size()); + + size_t prefix = 0; + + if (boost::starts_with(source, "https://")) + { + prefix = 8; + } + else if (boost::starts_with(source, "http://")) + { + prefix = 7; + } + + for (size_t i = 0; i < prefix; i++) + { + target.push_back(source[i]); + } + + bool isLastSlash = false; + + for (size_t i = prefix; i < source.size(); i++) + { + if (source[i] == '/') + { + if (!isLastSlash) + { + target.push_back('/'); + isLastSlash = true; + } + } + else + { + target.push_back(source[i]); + isLastSlash = false; + } + } + + return target; + } + + + bool LookupStringValue(std::string& target, + const Json::Value& json, + const std::string& key) + { + if (json.type() != Json::objectValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat); + } + else if (!json.isMember(key)) + { + return false; + } + else if (json[key].type() != Json::stringValue) + { + throw Orthanc::OrthancException( + Orthanc::ErrorCode_BadFileFormat, + "The field \"" + key + "\" in a JSON object should be a string"); + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat); + } + else + { + target = json[key].asString(); + return true; + } + } + + + bool LookupIntegerValue(int& target, + const Json::Value& json, + const std::string& key) + { + if (json.type() != Json::objectValue) + { + 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 LookupBooleanValue(bool& target, + const Json::Value& json, + const std::string& key) + { + if (json.type() != Json::objectValue) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat); + } + 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; + } + } + + namespace Configuration { // Assume Latin-1 encoding by default (as in the Orthanc core) @@ -353,7 +341,7 @@ } - std::string GetRoot() + std::string GetDicomWebRoot() { assert(configuration_.get() != NULL); std::string root = configuration_->GetStringValue("Root", "/dicom-web/"); @@ -373,6 +361,40 @@ 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() { @@ -414,7 +436,25 @@ } - std::string GetBaseUrl(const OrthancPluginHttpRequest* request) + 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", ""); @@ -422,7 +462,7 @@ std::string forwarded; if (host.empty() && - LookupHttpHeader(forwarded, request, "forwarded")) + LookupHttpHeader2(forwarded, headers, "forwarded")) { // There is a "Forwarded" HTTP header in the query // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded @@ -461,14 +501,33 @@ } 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 (https ? "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); } @@ -497,5 +556,61 @@ { 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 + } + } } }
--- a/Plugin/Configuration.h Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/Configuration.h Thu Jun 20 21:16:12 2019 +0200 @@ -45,34 +45,41 @@ 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); - // TODO => REMOVE (use Orthanc core instead) void ParseContentType(std::string& application, std::map<std::string, std::string>& attributes, const std::string& header); - void ParseMultipartBody(std::vector<MultipartItem>& result, - const void* 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(); @@ -86,10 +93,15 @@ 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, @@ -98,5 +110,10 @@ const std::string& sopInstanceUid); Orthanc::Encoding GetDefaultEncoding(); + + bool IsXmlExpected(const std::map<std::string, std::string>& headers); + + // TODO => REMOVE + bool IsXmlExpected(const OrthancPluginHttpRequest* request); } }
--- a/Plugin/DicomWebClient.cpp Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/DicomWebClient.cpp Thu Jun 20 21:16:12 2019 +0200 @@ -28,22 +28,84 @@ #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> + + + + +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,20 +165,59 @@ +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(reinterpret_cast<const char*>(request->body), - reinterpret_cast<const char*>(request->body) + request->bodySize, body) || - body.type() != Json::objectValue || + if (body.type() != Json::objectValue || !body.isMember(RESOURCES) || body[RESOURCES].type() != Json::arrayValue) { @@ -127,10 +228,9 @@ "\" 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++) @@ -178,84 +278,283 @@ } -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 OrthancPlugins::OrthancJob { - unsigned int maxInstances = OrthancPlugins::Configuration::GetUnsignedIntegerValue("StowMaxInstances", 10); - size_t maxSize = static_cast<size_t>(OrthancPlugins::Configuration::GetUnsignedIntegerValue("StowMaxSize", 10)) * 1024 * 1024; +private: + enum State + { + State_Running, + State_Stopped, + State_Error, + State_Done + }; + - if ((force && countInstances > 0) || - (maxInstances != 0 && countInstances >= maxInstances) || - (maxSize != 0 && chunks.GetNumBytes() >= maxSize)) + boost::mutex mutex_; + std::string serverName_; + std::vector<std::string> instances_; + OrthancPlugins::HttpClient::HttpHeaders headers_; + std::string boundary_; + + + std::auto_ptr<boost::thread> worker_; + + + State state_; + size_t position_; + Json::Value content_; + + + bool ReadNextInstance(std::string& dicom) { - chunks.AddChunk("\r\n--" + boundary + "--\r\n"); - - std::string body; - chunks.Flatten(body); + boost::mutex::scoped_lock lock(mutex_); - OrthancPlugins::MemoryBuffer answerBody; - std::map<std::string, std::string> answerHeaders; + if (state_ != State_Running) + { + return false; + } - std::string uri; - OrthancPlugins::UriEncode(uri, "studies", queryArguments); + while (position_ < instances_.size()) + { + size_t i = position_++; - OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Post, - httpHeaders, uri, body); + if (OrthancPlugins::RestApiGetString(dicom, "/instances/" + instances_[i] + "/file", false)) + { + return true; + } + } - 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(); + return false; + } + - if (!success || - response.type() != Json::objectValue || - !response.isMember("00081199")) + class RequestBody : public OrthancPlugins::HttpClient::IRequestBody + { + private: + StowClientJob& that_; + std::string boundary_; + bool done_; + + public: + RequestBody(StowClientJob& that) : + that_(that), + boundary_(that.boundary_), + done_(false) { - throw Orthanc::OrthancException( - Orthanc::ErrorCode_NetworkProtocol, - "Unable to parse STOW-RS JSON response from DICOMweb server " + server.GetUrl()); } - size_t size; - if (!GetSequenceSize(size, response, "00081199", true, server.GetUrl()) || - size != countInstances) + virtual bool ReadNextChunk(std::string& chunk) + { + if (done_) + { + return false; + } + else + { + std::string dicom; + + if (that_.ReadNextInstance(dicom)) + { + 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)); + + return true; + } + } + }; + + + static void Worker(StowClientJob* that) + { + try { - 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)); + std::string serverName; + size_t startPosition; + + // The lifetime of "body" should be larger than "client" + std::auto_ptr<RequestBody> body; + std::auto_ptr<OrthancPlugins::HttpClient> client; + + { + boost::mutex::scoped_lock lock(that->mutex_); + serverName = that->serverName_; + startPosition = that->position_; + + body.reset(new RequestBody(*that)); + + client.reset(new OrthancPlugins::HttpClient); + OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(*client, that->serverName_, "/studies"); + client->SetMethod(OrthancPluginHttpMethod_Post); + client->AddHeaders(that->headers_); + } + + OrthancPlugins::HttpClient::HttpHeaders answerHeaders; + Json::Value answerBody; + + client->SetBody(*body); + client->Execute(answerHeaders, answerBody); + + size_t endPosition; + + { + boost::mutex::scoped_lock lock(that->mutex_); + endPosition = that->position_; + } + + CheckStowAnswer(answerBody, serverName, endPosition - startPosition); + } + catch (Orthanc::OrthancException& e) + { + { + boost::mutex::scoped_lock lock(that->mutex_); + LOG(ERROR) << "Error in STOW-RS client job to server " << that->serverName_ << ": " << e.What(); + that->state_ = State_Error; + } + + that->SetContent("Error", e.What()); + } + } + + + void SetContent(const std::string& key, + const std::string& value) + { + boost::mutex::scoped_lock lock(mutex_); + content_[key] = value; + UpdateContent(content_); + } + + + void StopWorker() + { + if (worker_.get() != NULL) + { + if (worker_->joinable()) + { + worker_->join(); + } + + worker_.reset(); + } + } + + +public: + StowClientJob(const std::string& serverName, + const std::list<std::string>& instances, + const OrthancPlugins::HttpClient::HttpHeaders& headers) : + OrthancJob("DicomWebStowClient"), + serverName_(serverName), + headers_(headers), + state_(State_Running), + position_(0), + content_(Json::objectValue) + { + instances_.reserve(instances.size()); + + for (std::list<std::string>::const_iterator + it = instances.begin(); it != instances.end(); ++it) + { + instances_.push_back(*it); } - if (GetSequenceSize(size, response, "00081198", false, server.GetUrl()) && - 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"); + 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_; + } + + + virtual OrthancPluginJobStepStatus Step() + { + State state; + + { + boost::mutex::scoped_lock lock(mutex_); + + if (state_ == State_Stopped) + { + state_ = State_Running; + } + + UpdateProgress(instances_.empty() ? 1 : + static_cast<float>(position_) / static_cast<float>(instances_.size())); + + if (position_ == instances_.size() && + state_ == State_Running) + { + state_ = State_Done; + } + + state = state_; } - if (GetSequenceSize(size, response, "0008119A", false, server.GetUrl()) && - size != 0) + switch (state) { - 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"); + case State_Done: + StopWorker(); + return (state_ == State_Done ? + OrthancPluginJobStepStatus_Success : + OrthancPluginJobStepStatus_Failure); + + case State_Error: + StopWorker(); + return OrthancPluginJobStepStatus_Failure; + + case State_Running: + if (worker_.get() == NULL) + { + worker_.reset(new boost::thread(Worker, this)); + } + + boost::this_thread::sleep(boost::posix_time::milliseconds(500)); + + return OrthancPluginJobStepStatus_Continue; + + default: + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + } + + + virtual void Stop(OrthancPluginJobStopReason reason) + { + { + boost::mutex::scoped_lock lock(mutex_); + state_ = State_Stopped; } - countInstances = 0; + StopWorker(); } -} + + + virtual void Reset() + { + boost::mutex::scoped_lock lock(mutex_); + position_ = 0; + state_ = State_Running; + content_ = Json::objectValue; + ClearContent(); + } +}; + void StowClient(OrthancPluginRestOutput* output, @@ -264,111 +563,87 @@ { OrthancPluginContext* context = OrthancPlugins::GetGlobalContext(); - if (request->groupsCount != 1) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest); - } - if (request->method != OrthancPluginHttpMethod_Post) { OrthancPluginSendMethodNotAllowed(context, output, "POST"); return; } - Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0])); - - std::string boundary; - + if (request->groupsCount != 1) { - char* uuid = OrthancPluginGenerateUuid(context); - try - { - boundary.assign(uuid); - } - catch (...) - { - OrthancPluginFreeString(context, uuid); - throw Orthanc::OrthancException(Orthanc::ErrorCode_NotEnoughMemory); - } - - OrthancPluginFreeString(context, uuid); + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest); } - std::string mime = "multipart/related; type=\"application/dicom\"; boundary=" + boundary; + std::string serverName(request->groups[0]); - 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; + Json::Value body; + OrthancPlugins::ParseJsonBody(body, request); std::list<std::string> instances; - ParseStowRequest(instances, httpHeaders, queryArguments, request); + 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: " + server.GetUrl()); - - Orthanc::ChunkedBuffer chunks; - size_t countInstances = 0; + " instances using STOW-RS to DICOMweb server: " + serverName); - 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 ++; - - SendStowChunks(server, httpHeaders, queryArguments, boundary, chunks, countInstances, false); - } - } - - SendStowChunks(server, httpHeaders, queryArguments, boundary, chunks, countInstances, true); - - std::string answer = "{}\n"; - OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json"); + Json::Value answer; + SubmitJob(output, new StowClientJob(serverName, instances, httpHeaders), body, + true /* synchronous by default, for compatibility with <= 0.6 */); } -static bool GetStringValue(std::string& target, - const Json::Value& json, - const std::string& key) + +static void ParseGetFromServer(std::string& uri, + std::map<std::string, std::string>& additionalHeaders, + const Json::Value& resource) { - if (json.type() != Json::objectValue) + 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); - } - else if (!json.isMember(key)) - { - target.clear(); - return false; + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, + "A request to the DICOMweb client must provide a JSON object " + "with the field \"Uri\" containing the URI of interest"); } - else if (json[key].type() != Json::stringValue) + + std::map<std::string, std::string> getArguments; + 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_BadFileFormat, - "The field \"" + key + "\" in a JSON object should be a string"); + throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange); } - else - { - target = json[key].asString(); - return true; - } + + Json::Value body; + OrthancPlugins::ParseJsonBody(body, request); + + std::string uri; + std::map<std::string, std::string> additionalHeaders; + ParseGetFromServer(uri, additionalHeaders, body); + + OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(client, request->groups[0], uri); + client.AddHeaders(additionalHeaders); } + 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"; - OrthancPluginContext* context = OrthancPlugins::GetGlobalContext(); if (request->method != OrthancPluginHttpMethod_Post) @@ -377,33 +652,12 @@ return; } - Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0])); - - std::string tmp; - Json::Value body; - Json::Reader reader; - if (!reader.parse(reinterpret_cast<const char*>(request->body), - reinterpret_cast<const char*>(request->body) + request->bodySize, body) || - body.type() != Json::objectValue || - !GetStringValue(tmp, body, URI)) - { - throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, - "A request to the DICOMweb client must provide a JSON object " - "with the field \"Uri\" containing the URI of interest"); - } - - std::map<std::string, std::string> getArguments; - OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS); - - std::string uri; - OrthancPlugins::UriEncode(uri, tmp, getArguments); - - std::map<std::string, std::string> httpHeaders; - OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS); - - 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"; @@ -417,9 +671,11 @@ { 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 { @@ -427,267 +683,820 @@ } } - 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()); +} + + +void GetFromServer(Json::Value& result, + const OrthancPluginHttpRequest* request) +{ + OrthancPlugins::HttpClient client; + ConfigureGetFromServer(client, request); + + std::map<std::string, std::string> answerHeaders; + client.Execute(answerHeaders, result); } -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) + + +class WadoRetrieveAnswer : + public OrthancPlugins::HttpClient::IAnswer, + private Orthanc::MultipartStreamReader::IHandler { - 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"; +private: + enum State + { + State_Headers, + State_Body, + State_Canceled + }; + + 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); + } - if (resource.type() != Json::objectValue) + 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); + } + } + +public: + WadoRetrieveAnswer() : + state_(State_Headers), + networkSize_(0) { - throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, - "Resources of interest for the DICOMweb WADO-RS Retrieve client " - "must be provided as a JSON object"); + } + + virtual ~WadoRetrieveAnswer() + { } - std::string study, series, instance; - if (!GetStringValue(study, resource, STUDY) || - study.empty()) + void Close() { - throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat, - "A non-empty \"" + STUDY + "\" field is mandatory for the " - "DICOMweb WADO-RS Retrieve client"); + boost::mutex::scoped_lock lock(mutex_); + + if (state_ != State_Canceled && + reader_.get() != NULL) + { + reader_->CloseStream(); + } } - GetStringValue(series, resource, SERIES); - GetStringValue(instance, resource, INSTANCE); + virtual void AddHeader(const std::string& key, + const std::string& value) + { + boost::mutex::scoped_lock lock(mutex_); + + if (state_ == State_Canceled) + { + return; + } + else if (state_ != State_Headers) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } - if (series.empty() && - !instance.empty()) - { - 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"); + 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); + } } - std::string tmpUri = "studies/" + study; - if (!series.empty()) + virtual void AddChunk(const void* data, + size_t size) { - tmpUri += "/series/" + series; - if (!instance.empty()) + boost::mutex::scoped_lock lock(mutex_); + + if (state_ == State_Canceled) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_CanceledJob); + } + else if (reader_.get() == NULL) { - tmpUri += "/instances/" + instance; + 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); } } - std::string uri; - OrthancPlugins::UriEncode(uri, tmpUri, getArguments); + void GetReceivedInstances(std::list<std::string>& target) + { + boost::mutex::scoped_lock lock(mutex_); + target = instances_; + } + + void Cancel() + { + 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; + } + + uint64_t GetNetworkSize() + { + boost::mutex::scoped_lock lock(mutex_); + return networkSize_; + } +}; + + + +class SingleFunctionJob : public OrthancPlugins::OrthancJob +{ +public: + class JobContext : public boost::noncopyable + { + private: + SingleFunctionJob& that_; - OrthancPlugins::MemoryBuffer answerBody; - std::map<std::string, std::string> answerHeaders; - OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Get, httpHeaders, uri, ""); + public: + 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() + { + } - std::string contentTypeFull; - std::vector<std::string> contentType; - for (std::map<std::string, std::string>::const_iterator - it = answerHeaders.begin(); it != answerHeaders.end(); ++it) + // Must return "true" if the job has completed with success, or + // "false" if the job has been canceled. Pausing the job + // corresponds to canceling it. + virtual bool Execute(JobContext& context) = 0; + }; + + + class IFunctionFactory : public boost::noncopyable + { + public: + virtual ~IFunctionFactory() + { + } + + // WARNING: "CancelFunction()" will be invoked while "Execute()" + // is running. Mutex is probably necessary. + virtual void CancelFunction() = 0; + + // Only called when no function is running, to deal with + // "Resubmit()" after job cancelation/failure. + virtual void ResetFunction() = 0; + + virtual IFunction* CreateFunction() = 0; + }; + + +protected: + void SetFactory(IFunctionFactory& factory) { - 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_Setup) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls); + } + else { - contentTypeFull = it->second; - Orthanc::Toolbox::TokenizeString(contentType, it->second, ';'); - break; + factory_ = &factory; + } + } + + +private: + enum State + { + State_Setup, + State_Running, + State_Success, + State_Failure + }; + + boost::mutex mutex_; + State state_; // Can only be modified by the "Worker()" function + std::auto_ptr<boost::thread> worker_; + Json::Value content_; + IFunctionFactory* factory_; + + void JoinWorker() + { + assert(factory_ != NULL); + + if (worker_.get() != NULL) + { + if (worker_->joinable()) + { + worker_->join(); + } + + worker_.reset(); } } - OrthancPlugins::LogInfo("Got " + boost::lexical_cast<std::string>(answerBody.GetSize()) + - " bytes from a WADO-RS query with content type: " + contentTypeFull); - - if (contentType.empty()) + void StartWorker() { - throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol, - "No Content-Type provided by the remote WADO-RS server"); + assert(factory_ != NULL); + + if (worker_.get() == NULL && + factory_ != NULL) + { + worker_.reset(new boost::thread(Worker, this, factory_)); + } } - Orthanc::Toolbox::ToLowerCase(contentType[0]); - if (Orthanc::Toolbox::StripSpaces(contentType[0]) != MULTIPART_RELATED) + void SetContent(const std::string& key, + const std::string& value) { - 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_); + content_[key] = value; + UpdateContent(content_); } - std::string type, boundary; - for (size_t i = 1; i < contentType.size(); i++) + static void Worker(SingleFunctionJob* job, + IFunctionFactory* factory) { - std::vector<std::string> tokens; - Orthanc::Toolbox::TokenizeString(tokens, contentType[i], '='); + assert(job != NULL && factory != NULL); + + JobContext context(*job); + + { + boost::mutex::scoped_lock lock(job->mutex_); + job->state_ = State_Running; + } - if (tokens.size() == 2) + try { - std::string s = Orthanc::Toolbox::StripSpaces(tokens[0]); - Orthanc::Toolbox::ToLowerCase(s); + std::auto_ptr<IFunction> function(factory->CreateFunction()); + bool success = function->Execute(context); - if (s == "type") { - type = Orthanc::Toolbox::StripSpaces(tokens[1]); + boost::mutex::scoped_lock lock(job->mutex_); + job->state_ = (success ? State_Success : State_Failure); + if (success) + { + job->UpdateProgress(1); + } + } + } + catch (Orthanc::OrthancException& e) + { + LOG(ERROR) << "Error in a job: " << e.What(); - // 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] == '"') + { + boost::mutex::scoped_lock lock(job->mutex_); + job->state_ = State_Failure; + job->content_["FunctionErrorCode"] = e.GetErrorCode(); + job->content_["FunctionErrorDescription"] = e.What(); + if (e.HasDetails()) { - type = type.substr(1, len - 2); + job->content_["FunctionErrorDetails"] = e.GetDetails(); } - - Orthanc::Toolbox::ToLowerCase(type); + job->UpdateContent(job->content_); } - else if (s == "boundary") + } + } + +public: + SingleFunctionJob(const std::string& jobName) : + OrthancJob(jobName), + state_(State_Setup), + content_(Json::objectValue), + factory_(NULL) + { + } + + virtual ~SingleFunctionJob() + { + if (worker_.get() != NULL) + { + LOG(ERROR) << "Classes deriving from SingleFunctionJob must " + << "explicitly call Finalize() in their destructor"; + + try { - boundary = Orthanc::Toolbox::StripSpaces(tokens[1]); + JoinWorker(); + } + catch (Orthanc::OrthancException&) + { } } } - // Strip the trailing and heading quotes if present - if (boundary.length() > 2 && - boundary[0] == '"' && - boundary[boundary.size() - 1] == '"') + void Finalize() { - boundary = boundary.substr(1, boundary.size() - 2); + try + { + if (factory_ != NULL) + { + factory_->CancelFunction(); + JoinWorker(); + } + } + catch (Orthanc::OrthancException&) + { + } } - OrthancPlugins::LogInfo(" Parsing the multipart content type: " + type + - " with boundary: " + boundary); - - if (type != APPLICATION_DICOM) + virtual OrthancPluginJobStepStatus Step() { - throw Orthanc::OrthancException( - Orthanc::ErrorCode_NetworkProtocol, - "The remote WADO-RS server answers with a \"" + type + - "\" multipart Content-Type, but \"" + APPLICATION_DICOM + "\" is expected"); + if (factory_ == NULL) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls); + } + + State state; + + { + boost::mutex::scoped_lock lock(mutex_); + state = state_; + } + + switch (state) + { + case State_Setup: + StartWorker(); + break; + + case State_Running: + break; + + case State_Success: + JoinWorker(); + return OrthancPluginJobStepStatus_Success; + + case State_Failure: + JoinWorker(); + return OrthancPluginJobStepStatus_Failure; + + default: + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + + boost::this_thread::sleep(boost::posix_time::milliseconds(500)); + return OrthancPluginJobStepStatus_Continue; } - if (boundary.empty()) + virtual void Stop(OrthancPluginJobStopReason reason) { - throw Orthanc::OrthancException( - Orthanc::ErrorCode_NetworkProtocol, - "The remote WADO-RS server does not provide a boundary for its multipart answer"); + if (factory_ == NULL) + { + return; + } + + if (reason == OrthancPluginJobStopReason_Paused || + reason == OrthancPluginJobStopReason_Canceled) + { + factory_->CancelFunction(); + } + + JoinWorker(); + + if (reason == OrthancPluginJobStopReason_Paused) + { + // This type of job cannot be paused: Reset under the hood + Reset(); + } } - std::vector<OrthancPlugins::MultipartItem> parts; - OrthancPlugins::ParseMultipartBody(parts, - reinterpret_cast<const char*>(answerBody.GetData()), - answerBody.GetSize(), boundary); + virtual void Reset() + { + boost::mutex::scoped_lock lock(mutex_); + + if (factory_ != NULL) + { + factory_->ResetFunction(); + } - OrthancPlugins::LogInfo("The remote WADO-RS server has provided " + - boost::lexical_cast<std::string>(parts.size()) + - " DICOM instances"); + state_ = State_Setup; + + content_ = Json::objectValue; + ClearContent(); + } +}; + + - for (size_t i = 0; i < parts.size(); i++) +class WadoRetrieveJob : + public SingleFunctionJob, + private SingleFunctionJob::IFunctionFactory +{ +private: + class Resource : public boost::noncopyable { - std::vector<std::string> tokens; - Orthanc::Toolbox::TokenizeString(tokens, parts[i].contentType_, ';'); + private: + std::string uri_; + std::map<std::string, std::string> additionalHeaders_; - std::string partType; - if (tokens.size() > 0) + public: + Resource(const std::string& uri) : + uri_(uri) { - partType = Orthanc::Toolbox::StripSpaces(tokens[0]); + } + + 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 (partType != APPLICATION_DICOM) + const std::map<std::string, std::string>& GetAdditionalHeaders() const { - 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_ + ")"); + return additionalHeaders_; + } + }; + + + enum Status + { + Status_Done, + Status_Canceled, + Status_Continue + }; + + + class F : public IFunction + { + private: + WadoRetrieveJob& that_; + + public: + F(WadoRetrieveJob& that) : + that_(that) + { } - OrthancPlugins::MemoryBuffer tmp; - tmp.RestApiPost("/instances", parts[i].data_, parts[i].size_, false); + virtual bool Execute(JobContext& context) + { + for (;;) + { + OrthancPlugins::HttpClient client; - Json::Value result; - tmp.ToJson(result); + switch (that_.SetupNextResource(client)) + { + case Status_Continue: + client.Execute(*that_.answer_); + that_.CloseResource(context); + break; + + case Status_Canceled: + return false; + + case Status_Done: + return true; + } + } + } + }; - if (result.type() != Json::objectValue || - !result.isMember("ID") || - result["ID"].type() != Json::stringValue) + + boost::mutex mutex_; + std::string serverName_; + size_t position_; + std::vector<Resource*> resources_; + bool canceled_; + std::list<std::string> retrievedInstances_; + std::auto_ptr<WadoRetrieveAnswer> answer_; + uint64_t networkSize_; + + + Status SetupNextResource(OrthancPlugins::HttpClient& client) + { + boost::mutex::scoped_lock lock(mutex_); + + if (canceled_) { - throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + return Status_Canceled; + } + else if (position_ == resources_.size()) + { + return Status_Done; } else { - instances.insert(result["ID"].asString()); + answer_.reset(new WadoRetrieveAnswer); + + const Resource* resource = resources_[position_++]; + if (resource == NULL) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError); + } + + OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient + (client, serverName_, resource->GetUri()); + client.AddHeaders(resource->GetAdditionalHeaders()); + + return Status_Continue; + } + } + + + void CloseResource(JobContext& context) + { + 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_ / (1024llu * 1024llu))); + context.SetContent("ReceivedInstancesCount", boost::lexical_cast<std::string>(retrievedInstances_.size())); + } + + + virtual void CancelFunction() + { + boost::mutex::scoped_lock lock(mutex_); + canceled_ = true; + if (answer_.get() != NULL) + { + answer_->Cancel(); } } + + virtual void ResetFunction() + { + boost::mutex::scoped_lock lock(mutex_); + canceled_ = false; + position_ = 0; + retrievedInstances_.clear(); + } + + virtual IFunction* CreateFunction() + { + return new F(*this); + } + +public: + WadoRetrieveJob(const std::string& serverName) : + SingleFunctionJob("DicomWebWadoRetrieveClient"), + serverName_(serverName), + position_(0), + canceled_(false), + networkSize_(0) + { + SetFactory(*this); + } + + virtual ~WadoRetrieveJob() + { + SingleFunctionJob::Finalize(); + + for (size_t i = 0; i < resources_.size(); i++) + { + assert(resources_[i] != NULL); + delete resources_[i]; + } + } + + void AddResource(const std::string uri) + { + resources_.push_back(new Resource(uri)); + } + + 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); + + 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); + } + + if (request->groupsCount != 1) + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest); } - Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0])); + std::string serverName(request->groups[0]); Json::Value body; - Json::Reader reader; - if (!reader.parse(reinterpret_cast<const char*>(request->body), - reinterpret_cast<const char*>(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]; + + 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::map<std::string, std::string> getArguments; - OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS); + std::string series; + if (!OrthancPlugins::LookupStringValue(series, resources[i], SERIES)) + { + series.clear(); + } + + 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::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 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) - { - status["Instances"].append(*it); - } + SubmitJob(output, job.release(), body, + true /* synchronous by default, for compatibility with <= 0.6 */); +} - std::string s = status.toStyledString(); - OrthancPluginAnswerBuffer(context, output, s.c_str(), s.size(), "application/json"); -}
--- a/Plugin/DicomWebClient.h Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/DicomWebClient.h Thu Jun 20 21:16:12 2019 +0200 @@ -32,6 +32,14 @@ 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);
--- a/Plugin/DicomWebServers.cpp Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/DicomWebServers.cpp Thu Jun 20 21:16:12 2019 +0200 @@ -25,6 +25,8 @@ #include <Core/Toolbox.h> +#include <boost/algorithm/string/predicate.hpp> + namespace OrthancPlugins { void DicomWebServers::Clear() @@ -116,6 +118,61 @@ } + void DicomWebServers::ConfigureHttpClient(HttpClient& client, + const std::string& name, + const std::string& uri) + { + 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()); + } + } + + + 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 +309,9 @@ } - 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) {
--- a/Plugin/DicomWebServers.h Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/DicomWebServers.h Thu Jun 20 21:16:12 2019 +0200 @@ -45,6 +45,10 @@ } 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 @@ 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 @@ 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); }
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Plugin/OrthancExplorer.js Thu Jun 20 21:16:12 2019 +0200 @@ -0,0 +1,115 @@ +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 + $('#dialog').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 ] + }; + + $.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'); +});
--- a/Plugin/Plugin.cpp Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/Plugin.cpp Thu Jun 20 21:16:12 2019 +0200 @@ -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,54 +27,12 @@ #include "WadoUri.h" #include <Plugins/Samples/Common/OrthancPluginCppWrapper.h> +#include <Core/SystemToolbox.h> #include <Core/Toolbox.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; - - case OrthancPluginHttpMethod_Post: - // This is STOW-RS - StowCallback(output, url, request); - break; - - default: - OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST"); - break; - } -} +#include <EmbeddedResources.h> -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) { for (uint32_t i = 0; i < request->getCount; i++) @@ -110,11 +67,7 @@ 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 +94,332 @@ { 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"); // TODO => Mark as deprecated + json.append("stow"); + json.append("wado"); + json.append("qido"); + + std::string value; + if (server.LookupUserProperty(value, "HasDelete") && + value == "1") + { + 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 info = Json::objectValue; + info["DicomWebRoot"] = OrthancPlugins::Configuration::GetDicomWebRoot(); + info["OrthancApiRoot"] = OrthancPlugins::Configuration::GetOrthancApiRoot(); - Json::Value json = Json::arrayValue; - json.append("get"); - json.append("retrieve"); - json.append("stow"); - - 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 HAS_DELETE = "HasDelete"; + 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); + + std::string value; + if (server.LookupUserProperty(value, HAS_DELETE) && + value != "1") + { + throw Orthanc::OrthancException( + Orthanc::ErrorCode_BadFileFormat, + "Cannot delete on DICOMweb server, check out property \"HasDelete\": " + 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"); + } +} + + + +void RetrieveInstanceRendered(OrthancPluginRestOutput* output, + const char* url, + const OrthancPluginHttpRequest* request) +{ + OrthancPluginContext* context = OrthancPlugins::GetGlobalContext(); + + if (request->method != OrthancPluginHttpMethod_Get) + { + OrthancPluginSendMethodNotAllowed(context, output, "GET"); + } + else + { + Orthanc::MimeType mime = Orthanc::MimeType_Jpeg; + + std::string publicId; + if (LocateInstance(output, publicId, request)) + { + std::map<std::string, std::string> headers; + headers["Accept"] = Orthanc::EnumerationToString(mime); + + OrthancPlugins::MemoryBuffer buffer; + if (buffer.RestApiGet("/instances/" + publicId + "/preview", headers, false)) + { + OrthancPluginAnswerBuffer(context, output, buffer.GetData(), + buffer.GetSize(), Orthanc::EnumerationToString(mime)); + } + } + } +} + + + + + static bool DisplayPerformanceWarning(OrthancPluginContext* context) { (void) DisplayPerformanceWarning; // Disable warning about unused function @@ -170,6 +429,56 @@ } +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) @@ -204,15 +513,21 @@ // 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 +543,43 @@ 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 + "app/info", true); + + OrthancPlugins::RegisterRestCallback<RetrieveInstanceRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/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 + + std::map<std::string, std::string> dictionary; + dictionary["DICOMWEB_ROOT"] = OrthancPlugins::Configuration::GetDicomWebRoot(); + 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 {
--- a/Plugin/QidoRs.cpp Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/QidoRs.cpp Thu Jun 20 21:16:12 2019 +0200 @@ -21,7 +21,6 @@ #include "QidoRs.h" -#include "StowRs.h" // For IsXmlExpected() #include "Configuration.h" #include "DicomWebFormatter.h" @@ -494,7 +493,8 @@ 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
--- a/Plugin/StowRs.cpp Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/StowRs.cpp Thu Jun 20 21:16:12 2019 +0200 @@ -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) -{ - 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]; - } +namespace OrthancPlugins +{ + 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 (expectedStudy.empty()) - { - OrthancPlugins::LogInfo("STOW-RS request without study"); - } - else - { - OrthancPlugins::LogInfo("STOW-RS request restricted to study UID " + expectedStudy); - } + if (contentType != "multipart/related") + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType, + "The Content-Type of a STOW-RS request must be \"multipart/related\""); + } - 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; - } + if (subType != "application/dicom") + { + throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType, + "The STOW-RS plugin currently only supports \"application/dicom\" subtype"); + } - 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; + parser_.reset(new Orthanc::MultipartStreamReader(boundary)); + parser_->SetHandler(*this); } - std::string boundary = attributes["boundary"]; - - if (attributes["type"] != "application/dicom") + void StowServer::HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers, + const void* part, + size_t size) { - OrthancPlugins::LogError("The STOW-RS plugin currently only supports application/dicom"); - OrthancPluginSendHttpStatusCode(context, output, 415 /* Unsupported media type */); - return; - } - - - bool isFirst = true; + std::string contentType; - 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++) - { - OrthancPlugins::LogInfo("Detected multipart item with content type \"" + - items[i].contentType_ + "\" of size " + - boost::lexical_cast<std::string>(items[i].size_)); - } - - 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 @@ 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 @@ 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 @@ 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; + item[DICOM_TAG_REFERENCED_SOP_CLASS_UID.Format()] = sopClassUid; + item[DICOM_TAG_REFERENCED_SOP_INSTANCE_UID.Format()] = sopInstanceUid; + + if (!expectedStudy_.empty() && + studyInstanceUid != expectedStudy_) + { + LogInfo("STOW-RS request restricted to study [" + expectedStudy_ + + "]: Ignoring instance from study [" + studyInstanceUid + "]"); - if (!expectedStudy.empty() && - studyInstanceUid != expectedStudy) - { - OrthancPlugins::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); + } }
--- a/Plugin/StowRs.h Wed Jun 12 10:17:55 2019 +0200 +++ b/Plugin/StowRs.h Thu Jun 20 21:16:12 2019 +0200 @@ -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); + }; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/Resources/CMake/JavaScriptLibraries.cmake Thu Jun 20 21:16:12 2019 +0200 @@ -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@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 + )
--- a/Resources/Orthanc/DownloadOrthancFramework.cmake Wed Jun 12 10:17:55 2019 +0200 +++ b/Resources/Orthanc/DownloadOrthancFramework.cmake Thu Jun 20 21:16:12 2019 +0200 @@ -66,6 +66,9 @@ 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}") @@ -108,6 +111,11 @@ 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()
--- a/Resources/Samples/Python/SendStow.py Wed Jun 12 10:17:55 2019 +0200 +++ b/Resources/Samples/Python/SendStow.py Thu Jun 20 21:16:12 2019 +0200 @@ -58,11 +58,31 @@ # 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)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebApplication/app.js Thu Jun 20 21:16:12 2019 +0200 @@ -0,0 +1,351 @@ +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: { + orthancExplorerUri: '../../../', + 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, + 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.orthancExplorerUri = response.data.OrthancApiRoot + '../../'; + }); + }, + methods: { + /** + * Toolbox + **/ + + ScrollToRef: function(refName) { + var element = app.$refs[refName]; + window.scrollTo(0, element.offsetTop); + }, + ShowErrorModal: function() { + app.$refs['modal-error'].show(); + }, + + + /** + * 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(); + }, + 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(); + }, + 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'); + } + }) + } +});
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/WebApplication/index.html Thu Jun 20 21:16:12 2019 +0200 @@ -0,0 +1,226 @@ +<!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="orthancExplorerUri" + 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="orthancExplorerUri" + 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 "{{ app.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 title="Retrieve study using WADO-RS"><i class="fa fa-cloud-download"></i></b-button> + <b-button @click="ConfirmDeleteStudy(data.item)" + v-if="serversInfo[lookup.server].HasDelete" 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-pagination v-model="currentPage" :per-page="perPage" :total-rows="seriesCount"></b-pagination> + <b-table striped hover :current-page="currentPage" :per-page="perPage" + :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 title="Retrieve series using WADO-RS"><i class="fa fa-cloud-download"></i></b-button> + <b-button @click="ConfirmDeleteSeries(data.item)" + v-if="serversInfo[lookup.server].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> + + <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>