[med-svn] [Git][med-team/orthanc-dicomweb][upstream] New upstream version 1.0+dfsg

Sebastien Jodogne gitlab at salsa.debian.org
Wed Jul 31 17:15:05 BST 2019



Sebastien Jodogne pushed to branch upstream at Debian Med / orthanc-dicomweb


Commits:
fd1245b9 by jodogne-guest at 2019-07-31T13:20:42Z
New upstream version 1.0+dfsg
- - - - -


26 changed files:

- .hg_archival.txt
- CMakeLists.txt
- NEWS
- Plugin/Configuration.cpp
- Plugin/Configuration.h
- Plugin/DicomWebClient.cpp
- Plugin/DicomWebClient.h
- Plugin/DicomWebServers.cpp
- Plugin/DicomWebServers.h
- + Plugin/OrthancExplorer.js
- Plugin/Plugin.cpp
- Plugin/QidoRs.cpp
- Plugin/StowRs.cpp
- Plugin/StowRs.h
- Plugin/WadoRs.cpp
- + Resources/CMake/JavaScriptLibraries.cmake
- Resources/Orthanc/DownloadOrthancFramework.cmake
- + Resources/Orthanc/Sdk-1.5.7/orthanc/OrthancCPlugin.h
- + Resources/Samples/Proxy/NOTES.txt
- + Resources/Samples/Proxy/nginx.local.conf
- Resources/Samples/Python/SendStow.py
- Resources/SyncOrthancFolder.py
- Status.txt
- UnitTestsSources/UnitTestsMain.cpp
- + WebApplication/app.js
- + WebApplication/index.html


Changes:

=====================================
.hg_archival.txt
=====================================
@@ -1,6 +1,6 @@
 repo: d5f45924411123cfd02d035fd50b8e37536eadef
-node: ce4e5f0769c81584a8851cff3d1b0fd56283574f
-branch: OrthancDicomWeb-0.6
+node: 1e052095bb1d3e3ac5d0c3a5253699867d784579
+branch: OrthancDicomWeb-1.0
 latesttag: null
-latesttagdistance: 250
-changessincelatesttag: 264
+latesttagdistance: 323
+changessincelatesttag: 339


=====================================
CMakeLists.txt
=====================================
@@ -21,13 +21,13 @@ cmake_minimum_required(VERSION 2.8)
 
 project(OrthancDicomWeb)
 
-set(ORTHANC_DICOM_WEB_VERSION "0.6")
+set(ORTHANC_DICOM_WEB_VERSION "1.0")
 
 if (ORTHANC_DICOM_WEB_VERSION STREQUAL "mainline")
   set(ORTHANC_FRAMEWORK_VERSION "mainline")
   set(ORTHANC_FRAMEWORK_DEFAULT_SOURCE "hg")
 else()
-  set(ORTHANC_FRAMEWORK_VERSION "1.5.5")
+  set(ORTHANC_FRAMEWORK_VERSION "1.5.7")
   set(ORTHANC_FRAMEWORK_DEFAULT_SOURCE "web")
 endif()
 
@@ -42,7 +42,11 @@ set(ORTHANC_FRAMEWORK_ROOT "" CACHE STRING "Path to the Orthanc source directory
 # Advanced parameters to fine-tune linking against system libraries
 set(USE_SYSTEM_GDCM ON CACHE BOOL "Use the system version of Grassroot DICOM (GDCM)")
 set(USE_SYSTEM_ORTHANC_SDK ON CACHE BOOL "Use the system version of the Orthanc plugin SDK")
-set(ORTHANC_SDK_VERSION "1.5.4" CACHE STRING "Version of the Orthanc plugin SDK to use, if not using the system version (can be \"1.5.4\", or \"framework\")")
+set(ORTHANC_SDK_VERSION "1.5.7" CACHE STRING "Version of the Orthanc plugin SDK to use, if not using the system version (can be \"1.5.4\", \"1.5.7\", or \"framework\")")
+
+
+set(BUILD_BOOTSTRAP_VUE OFF CACHE BOOL "Compile Bootstrap-Vue from sources")
+set(BUILD_BABEL_POLYFILL OFF CACHE BOOL "Retrieve babel-polyfill from npm")
 
 
 
@@ -60,13 +64,15 @@ set(USE_BOOST_ICONV ON)
 include(${ORTHANC_ROOT}/Resources/CMake/OrthancFrameworkConfiguration.cmake)
 include_directories(${ORTHANC_ROOT})
 
-
 include(${CMAKE_SOURCE_DIR}/Resources/CMake/GdcmConfiguration.cmake)
+include(${CMAKE_SOURCE_DIR}/Resources/CMake/JavaScriptLibraries.cmake)
 
 
 if (STATIC_BUILD OR NOT USE_SYSTEM_ORTHANC_SDK)
   if (ORTHANC_SDK_VERSION STREQUAL "1.5.4")
     include_directories(${CMAKE_SOURCE_DIR}/Resources/Orthanc/Sdk-1.5.4)
+  elseif (ORTHANC_SDK_VERSION STREQUAL "1.5.7")
+    include_directories(${CMAKE_SOURCE_DIR}/Resources/Orthanc/Sdk-1.5.7)
   elseif (ORTHANC_SDK_VERSION STREQUAL "framework")
     include_directories(${ORTHANC_ROOT}/Plugins/Include)
   else()
@@ -110,11 +116,30 @@ if (APPLE)
 endif()
 
 
+
+if (STANDALONE_BUILD)
+  add_definitions(-DORTHANC_STANDALONE=1)
+  set(ADDITIONAL_RESOURCES
+    ORTHANC_EXPLORER  ${CMAKE_SOURCE_DIR}/Plugin/OrthancExplorer.js
+    WEB_APPLICATION   ${CMAKE_SOURCE_DIR}/WebApplication/
+    )
+else()
+  add_definitions(-DORTHANC_STANDALONE=0)
+endif()
+
+EmbedResources(
+  --no-upcase-check
+  ${ADDITIONAL_RESOURCES}
+  JAVASCRIPT_LIBS   ${JAVASCRIPT_LIBS_DIR}
+  )
+
+
 include_directories(${ORTHANC_ROOT}/Core)  # To access "OrthancException.h"
 
 add_definitions(
   -DHAS_ORTHANC_EXCEPTION=1
   -DORTHANC_ENABLE_LOGGING_PLUGIN=1
+  -DDICOMWEB_CLIENT_PATH="${CMAKE_SOURCE_DIR}/WebApplication/"
   )
 
 set(CORE_SOURCES


=====================================
NEWS
=====================================
@@ -2,6 +2,23 @@ Pending changes in the mainline
 ===============================
 
 
+Version 1.0 (2019-06-26)
+========================
+
+=> Recommended SDK version: 1.5.7 <=
+=> Minimum SDK version: 1.5.4 <=
+
+* Web user interface to QIDO-RS, WADO-RS and STOW-RS client
+* First implementation of WADO-RS "Retrieve Rendered Transaction"
+* WADO-RS and STOW-RS client now create Orthanc jobs
+* Support "Transfer-Encoding: chunked" to reduce memory consumption in STOW-RS
+  (provided the SDK version is above 1.5.7)
+* New URI: /dicom-web/servers/.../qido
+* New URI: /dicom-web/servers/.../delete
+* Handling of the HTTP header "Forwarded" for WADO-RS
+* Full refactoring of multipart parsing
+
+
 Version 0.6 (2019-02-27)
 ========================
 


=====================================
Plugin/Configuration.cpp
=====================================
@@ -21,15 +21,17 @@
 
 #include "Configuration.h"
 
+#include "DicomWebServers.h"
+
+#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+#include <Core/Toolbox.h>
+
 #include <fstream>
 #include <json/reader.h>
 #include <boost/regex.hpp>
 #include <boost/lexical_cast.hpp>
+#include <boost/algorithm/string/predicate.hpp>
 
-#include "DicomWebServers.h"
-
-#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
-#include <Core/Toolbox.h>
 
 namespace OrthancPlugins
 {
@@ -85,220 +87,200 @@ namespace OrthancPlugins
   }
 
 
-  static const boost::regex MULTIPART_HEADERS_ENDING("(.*?\r\n)\r\n(.*)");
-  static const boost::regex MULTIPART_HEADERS_LINE(".*?\r\n");
-
-  static void ParseMultipartHeaders(bool& hasLength /* out */,
-                                    size_t& length /* out */,
-                                    std::string& contentType /* out */,
-                                    const char* startHeaders,
-                                    const char* endHeaders)
+  void ParseAssociativeArray(std::map<std::string, std::string>& target,
+                             const Json::Value& value)
   {
-    hasLength = false;
-    contentType = "application/octet-stream";
+    if (value.type() != Json::objectValue)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                      "The JSON object is not a JSON associative array as expected");
+    }
 
-    // Loop over the HTTP headers of this multipart item
-    boost::cregex_token_iterator it(startHeaders, endHeaders, MULTIPART_HEADERS_LINE, 0);
-    boost::cregex_token_iterator iteratorEnd;
+    Json::Value::Members names = value.getMemberNames();
 
-    for (; it != iteratorEnd; ++it)
+    for (size_t i = 0; i < names.size(); i++)
     {
-      const std::string line(*it);
-      size_t colon = line.find(':');
-      size_t eol = line.find('\r');
-
-      if (colon != std::string::npos &&
-          eol != std::string::npos &&
-          colon < eol &&
-          eol + 2 == line.length())
+      if (value[names[i]].type() != Json::stringValue)
       {
-        std::string key = Orthanc::Toolbox::StripSpaces(line.substr(0, colon));
-        Orthanc::Toolbox::ToLowerCase(key);
-
-        const std::string value = Orthanc::Toolbox::StripSpaces(line.substr(colon + 1, eol - colon - 1));
-
-        if (key == "content-length")
-        {
-          try
-          {
-            int tmp = boost::lexical_cast<int>(value);
-            if (tmp >= 0)
-            {
-              hasLength = true;
-              length = tmp;
-            }
-          }
-          catch (boost::bad_lexical_cast&)
-          {
-            LogWarning("Unable to parse the Content-Length of a multipart item");
-          }
-        }
-        else if (key == "content-type")
-        {
-          contentType = value;
-        }
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                        "Value \"" + names[i] + "\" in the associative array "
+                                        "is not a string as expected");
+      }
+      else
+      {
+        target[names[i]] = value[names[i]].asString();
       }
     }
   }
+  
 
-
-  static const char* ParseMultipartItem(std::vector<MultipartItem>& result,
-                                        const char* start,
-                                        const char* end,
-                                        const boost::regex& nextSeparator)
+  void ParseAssociativeArray(std::map<std::string, std::string>& target,
+                             const Json::Value& value,
+                             const std::string& key)
   {
-    // Just before "start", it is guaranteed that "--[BOUNDARY]\r\n" is present
-
-    boost::cmatch what;
-    if (!boost::regex_match(start, end, what, MULTIPART_HEADERS_ENDING, boost::match_perl))
+    if (value.type() != Json::objectValue)
     {
-      // Cannot find the HTTP headers of this multipart item
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                      "This is not a JSON object");
     }
 
-    // Some aliases for more clarity
-    assert(what[1].first == start);
-    const char* startHeaders = what[1].first;
-    const char* endHeaders = what[1].second;
-    const char* startBody = what[2].first;
-
-    bool hasLength;
-    size_t length;
-    std::string contentType;
-    ParseMultipartHeaders(hasLength, length, contentType, startHeaders, endHeaders);
+    if (value.isMember(key))
+    {
+      ParseAssociativeArray(target, value[key]);
+    }
+    else
+    {
+      target.clear();
+    }
+  }
 
-    boost::cmatch separator;
 
-    if (hasLength)
+  bool ParseTag(Orthanc::DicomTag& target,
+                const std::string& name)
+  {
+    OrthancPluginDictionaryEntry entry;
+    
+    if (OrthancPluginLookupDictionary(OrthancPlugins::GetGlobalContext(), &entry, name.c_str()) == OrthancPluginErrorCode_Success)
     {
-      if (!boost::regex_match(startBody + length, end, separator, nextSeparator, boost::match_perl) ||
-          startBody + length != separator[1].first)
-      {
-        // Cannot find the separator after skipping the "Content-Length" bytes
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-      }
+      target = Orthanc::DicomTag(entry.group, entry.element);
+      return true;
     }
     else
     {
-      if (!boost::regex_match(startBody, end, separator, nextSeparator, boost::match_perl))
-      {
-        // No more occurrence of the boundary separator
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
-      }
+      return false;
     }
+  }
 
-    MultipartItem item;
-    item.data_ = startBody;
-    item.size_ = separator[1].first - startBody;
-    item.contentType_ = contentType;
-    result.push_back(item);
 
-    return separator[1].second;  // Return the end of the separator
+  void ParseJsonBody(Json::Value& target,
+                     const OrthancPluginHttpRequest* request)
+  {
+    Json::Reader reader;
+    if (!reader.parse(reinterpret_cast<const char*>(request->body),
+                      reinterpret_cast<const char*>(request->body) + request->bodySize, target))
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                      "A JSON file was expected");
+    }
   }
 
 
-  void ParseMultipartBody(std::vector<MultipartItem>& result,
-                          const char* body,
-                          const uint64_t bodySize,
-                          const std::string& boundary)
+  std::string RemoveMultipleSlashes(const std::string& source)
   {
-    // Reference:
-    // https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
+    std::string target;
+    target.reserve(source.size());
 
-    result.clear();
-
-    // Look for the first boundary separator in the body (note the "?"
-    // to request non-greedy search)
-    const boost::regex firstSeparator1("--" + boundary + "(--|\r\n).*");
-    const boost::regex firstSeparator2(".*?\r\n--" + boundary + "(--|\r\n).*");
+    size_t prefix = 0;
+  
+    if (boost::starts_with(source, "https://"))
+    {
+      prefix = 8;
+    }
+    else if (boost::starts_with(source, "http://"))
+    {
+      prefix = 7;
+    }
 
-    // Look for the next boundary separator in the body (note the "?"
-    // to request non-greedy search)
-    const boost::regex nextSeparator(".*?(\r\n--" + boundary + ").*");
+    for (size_t i = 0; i < prefix; i++)
+    {
+      target.push_back(source[i]);
+    }
 
-    const char* end = body + bodySize;
+    bool isLastSlash = false;
 
-    boost::cmatch what;
-    if (boost::regex_match(body, end, what, firstSeparator1, boost::match_perl | boost::match_single_line) ||
-        boost::regex_match(body, end, what, firstSeparator2, boost::match_perl | boost::match_single_line))
+    for (size_t i = prefix; i < source.size(); i++)
     {
-      const char* current = what[1].first;
-
-      while (current != NULL &&
-             current + 2 < end)
+      if (source[i] == '/')
       {
-        if (current[0] != '\r' ||
-            current[1] != '\n')
-        {
-          // We reached a separator with a trailing "--", which
-          // means that reading the multipart body is done
-          break;
-        }
-        else
+        if (!isLastSlash)
         {
-          current = ParseMultipartItem(result, current + 2, end, nextSeparator);
+          target.push_back('/');
+          isLastSlash = true;
         }
       }
+      else
+      {
+        target.push_back(source[i]);
+        isLastSlash = false;
+      }
     }
+
+    return target;
   }
 
 
-  void ParseAssociativeArray(std::map<std::string, std::string>& target,
-                             const Json::Value& value,
-                             const std::string& key)
+  bool LookupStringValue(std::string& target,
+                         const Json::Value& json,
+                         const std::string& key)
   {
-    if (value.type() != Json::objectValue)
+    if (json.type() != Json::objectValue)
     {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
-                                      "This is not a JSON object");
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
     }
-
-    if (!value.isMember(key))
+    else if (!json.isMember(key))
     {
-      return;
+      return false;
     }
-
-    const Json::Value& tmp = value[key];
-
-    if (tmp.type() != Json::objectValue)
+    else if (json[key].type() != Json::stringValue)
     {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
-                                      "The field \"" + key + "\" of a JSON object is "
-                                      "not a JSON associative array as expected");
+      throw Orthanc::OrthancException(
+        Orthanc::ErrorCode_BadFileFormat,
+        "The field \"" + key + "\" in a JSON object should be a string");
     }
+    else
+    {
+      target = json[key].asString();
+      return true;
+    }
+  }
 
-    Json::Value::Members names = tmp.getMemberNames();
 
-    for (size_t i = 0; i < names.size(); i++)
+  bool LookupIntegerValue(int& target,
+                          const Json::Value& json,
+                          const std::string& key)
+  {
+    if (json.type() != Json::objectValue)
     {
-      if (tmp[names[i]].type() != Json::stringValue)
-      {
-        throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
-                                        "Some value in the associative array \"" + key + 
-                                        "\" is not a string as expected");
-      }
-      else
-      {
-        target[names[i]] = tmp[names[i]].asString();
-      }
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+    }
+    else if (!json.isMember(key))
+    {
+      return false;
+    }
+    else if (json[key].type() != Json::intValue &&
+             json[key].type() != Json::uintValue)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);      
+    }
+    else
+    {
+      target = json[key].asInt();
+      return true;
     }
   }
 
 
-  bool ParseTag(Orthanc::DicomTag& target,
-                const std::string& name)
+  bool LookupBooleanValue(bool& target,
+                          const Json::Value& json,
+                          const std::string& key)
   {
-    OrthancPluginDictionaryEntry entry;
-    
-    if (OrthancPluginLookupDictionary(OrthancPlugins::GetGlobalContext(), &entry, name.c_str()) == OrthancPluginErrorCode_Success)
+    if (json.type() != Json::objectValue)
     {
-      target = Orthanc::DicomTag(entry.group, entry.element);
-      return true;
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
     }
-    else
+    else if (!json.isMember(key))
     {
       return false;
     }
+    else if (json[key].type() != Json::booleanValue)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);      
+    }
+    else
+    {
+      target = json[key].asBool();
+      return true;
+    }
   }
 
 
@@ -352,7 +334,7 @@ namespace OrthancPlugins
     }
 
 
-    std::string GetRoot()
+    std::string GetDicomWebRoot()
     {
       assert(configuration_.get() != NULL);
       std::string root = configuration_->GetStringValue("Root", "/dicom-web/");
@@ -372,6 +354,40 @@ namespace OrthancPlugins
       return root;
     }
 
+    
+    std::string GetOrthancApiRoot()
+    {
+      std::string root = OrthancPlugins::Configuration::GetDicomWebRoot();
+      std::vector<std::string> tokens;
+      Orthanc::Toolbox::TokenizeString(tokens, root, '/');
+
+      int depth = 0;
+      for (size_t i = 0; i < tokens.size(); i++)
+      {
+        if (tokens[i].empty() ||
+            tokens[i] == ".")
+        {
+          // Don't change the depth
+        }
+        else if (tokens[i] == "..")
+        {
+          depth--;
+        }
+        else
+        {
+          depth++;
+        }
+      }
+
+      std::string orthancRoot = "./";
+      for (int i = 0; i < depth; i++)
+      {
+        orthancRoot += "../";
+      }
+
+      return orthancRoot;
+    }
+
 
     std::string GetWadoRoot()
     {
@@ -395,21 +411,116 @@ namespace OrthancPlugins
     }
 
 
-    std::string  GetBaseUrl(const OrthancPluginHttpRequest* request)
+    static bool IsHttpsProto(const std::string& proto,
+                             bool defaultValue)
+    {
+      if (proto == "http")
+      {
+        return false;
+      }
+      else if (proto == "https")
+      {
+        return true;
+      }
+      else
+      {
+        return defaultValue;
+      }
+    }
+
+
+    static bool LookupHttpHeader2(std::string& value,
+                                  const OrthancPlugins::HttpClient::HttpHeaders& headers,
+                                  const std::string& name)
+    {
+      for (OrthancPlugins::HttpClient::HttpHeaders::const_iterator
+             it = headers.begin(); it != headers.end(); ++it)
+      {
+        if (boost::iequals(it->first, name))
+        {
+          value = it->second;
+          return false;
+        }
+      }
+
+      return false;
+    }
+
+
+    std::string GetBaseUrl(const OrthancPlugins::HttpClient::HttpHeaders& headers)
     {
       assert(configuration_.get() != NULL);
       std::string host = configuration_->GetStringValue("Host", "");
-      bool ssl = configuration_->GetBooleanValue("Ssl", false);
+      bool https = configuration_->GetBooleanValue("Ssl", false);
+
+      std::string forwarded;
+      if (host.empty() &&
+          LookupHttpHeader2(forwarded, headers, "forwarded"))
+      {
+        // There is a "Forwarded" HTTP header in the query
+        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded
+        
+        std::vector<std::string> forwarders;
+        Orthanc::Toolbox::TokenizeString(forwarders, forwarded, ',');
+
+        // Only consider the first forwarder, if any
+        if (!forwarders.empty())
+        {
+          std::vector<std::string> tokens;
+          Orthanc::Toolbox::TokenizeString(tokens, forwarders[0], ';');
+
+          for (size_t j = 0; j < tokens.size(); j++)
+          {
+            std::vector<std::string> args;
+            Orthanc::Toolbox::TokenizeString(args, tokens[j], '=');
+            
+            if (args.size() == 2)
+            {
+              std::string key = Orthanc::Toolbox::StripSpaces(args[0]);
+              std::string value = Orthanc::Toolbox::StripSpaces(args[1]);
+
+              Orthanc::Toolbox::ToLowerCase(key);
+              if (key == "host")
+              {
+                host = value;
+              }
+              else if (key == "proto")
+              {
+                https = IsHttpsProto(value, https);
+              }
+            }
+          }
+        }
+      }
 
       if (host.empty() &&
-          !LookupHttpHeader(host, request, "host"))
+          !LookupHttpHeader2(host, headers, "host"))
       {
         // Should never happen: The "host" header should always be present
         // in HTTP requests. Provide a default value anyway.
         host = "localhost:8042";
       }
 
-      return (ssl ? "https://" : "http://") + host + GetRoot();
+      return (https ? "https://" : "http://") + host + GetDicomWebRoot();
+    }
+
+
+    std::string GetBaseUrl(const OrthancPluginHttpRequest* request)
+    {
+      OrthancPlugins::HttpClient::HttpHeaders headers;
+
+      std::string value;
+      if (LookupHttpHeader(value, request, "forwarded"))
+      {
+        headers["Forwarded"] = value;
+      }
+
+      if (LookupHttpHeader(value, request, "host"))
+      {
+        headers["Host"] = value;
+      }
+
+      return GetBaseUrl(headers);
     }
 
 
@@ -438,5 +549,61 @@ namespace OrthancPlugins
     {
       return defaultEncoding_;
     }
+
+
+    static bool IsXmlExpected(const std::string& acceptHeader)
+    {
+      std::string accept;
+      Orthanc::Toolbox::ToLowerCase(accept, acceptHeader);
+  
+      if (accept == "application/dicom+json" ||
+          accept == "application/json" ||
+          accept == "*/*")
+      {
+        return false;
+      }
+      else if (accept == "application/dicom+xml" ||
+               accept == "application/xml" ||
+               accept == "text/xml")
+      {
+        return true;
+      }
+      else
+      {
+        OrthancPlugins::LogError("Unsupported return MIME type: " + accept +
+                                 ", will return DICOM+JSON");
+        return false;
+      }
+    }
+
+
+    bool IsXmlExpected(const std::map<std::string, std::string>& headers)
+    {
+      std::map<std::string, std::string>::const_iterator found = headers.find("accept");
+
+      if (found == headers.end())
+      {
+        return false;   // By default, return DICOM+JSON
+      }
+      else
+      {
+        return IsXmlExpected(found->second);
+      }
+    }
+
+
+    bool IsXmlExpected(const OrthancPluginHttpRequest* request)
+    {
+      std::string accept;
+
+      if (OrthancPlugins::LookupHttpHeader(accept, request, "accept"))
+      {
+        return IsXmlExpected(accept);
+      }
+      else
+      {
+        return false;   // By default, return DICOM+JSON
+      }
+    }
   }
 }


=====================================
Plugin/Configuration.h
=====================================
@@ -45,13 +45,6 @@ namespace OrthancPlugins
   static const Orthanc::DicomTag DICOM_TAG_REFERENCED_SOP_CLASS_UID(0x0008, 0x1150);
   static const Orthanc::DicomTag DICOM_TAG_REFERENCED_SOP_INSTANCE_UID(0x0008, 0x1155);
 
-  struct MultipartItem
-  {
-    const char*   data_;
-    size_t        size_;
-    std::string   contentType_;
-  };
-
   bool LookupHttpHeader(std::string& value,
                         const OrthancPluginHttpRequest* request,
                         const std::string& header);
@@ -60,18 +53,33 @@ namespace OrthancPlugins
                         std::map<std::string, std::string>& attributes,
                         const std::string& header);
 
-  void ParseMultipartBody(std::vector<MultipartItem>& result,
-                          const char* body,
-                          const uint64_t bodySize,
-                          const std::string& boundary);
-
   void ParseAssociativeArray(std::map<std::string, std::string>& target,
                              const Json::Value& value,
                              const std::string& key);
 
+  void ParseAssociativeArray(std::map<std::string, std::string>& target,
+                             const Json::Value& value);
+
   bool ParseTag(Orthanc::DicomTag& target,
                 const std::string& name);
 
+  void ParseJsonBody(Json::Value& target,
+                     const OrthancPluginHttpRequest* request);
+
+  std::string RemoveMultipleSlashes(const std::string& source);
+
+  bool LookupStringValue(std::string& target,
+                         const Json::Value& json,
+                         const std::string& key);
+
+  bool LookupIntegerValue(int& target,
+                          const Json::Value& json,
+                          const std::string& key);
+
+  bool LookupBooleanValue(bool& target,
+                          const Json::Value& json,
+                          const std::string& key);
+
   namespace Configuration
   {
     void Initialize();
@@ -85,10 +93,15 @@ namespace OrthancPlugins
     unsigned int GetUnsignedIntegerValue(const std::string& key,
                                          unsigned int defaultValue);
 
-    std::string GetRoot();
+    std::string GetDicomWebRoot();
+
+    std::string GetOrthancApiRoot();
 
     std::string GetWadoRoot();
       
+    std::string GetBaseUrl(const std::map<std::string, std::string>& headers);
+
+    // TODO => REMOVE
     std::string GetBaseUrl(const OrthancPluginHttpRequest* request);
 
     std::string GetWadoUrl(const std::string& wadoBase,
@@ -97,5 +110,10 @@ namespace OrthancPlugins
                            const std::string& sopInstanceUid);
 
     Orthanc::Encoding GetDefaultEncoding();
+
+    bool IsXmlExpected(const std::map<std::string, std::string>& headers);
+
+    // TODO => REMOVE
+    bool IsXmlExpected(const OrthancPluginHttpRequest* request);
   }
 }


=====================================
Plugin/DicomWebClient.cpp
=====================================
@@ -28,22 +28,363 @@
 #include <set>
 #include <boost/lexical_cast.hpp>
 
+#include <Core/HttpServer/MultipartStreamReader.h>
 #include <Core/ChunkedBuffer.h>
 #include <Core/Toolbox.h>
+#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+
+
+#include <boost/thread.hpp>
+#include <boost/algorithm/string/predicate.hpp>
+
+
+
+
+class SingleFunctionJob : public OrthancPlugins::OrthancJob
+{
+public:
+  class JobContext : public boost::noncopyable
+  {
+  private:
+    SingleFunctionJob&  that_;
+
+  public:
+    explicit JobContext(SingleFunctionJob& that) :
+      that_(that)
+    {
+    }
+
+    void SetContent(const std::string& key,
+                    const std::string& value)
+    {
+      that_.SetContent(key, value);
+    }
+
+    void SetProgress(unsigned int position,
+                     unsigned int maxPosition)
+    {
+      boost::mutex::scoped_lock lock(that_.mutex_);
+
+      if (maxPosition == 0 || 
+          position > maxPosition)
+      {
+        that_.UpdateProgress(1);
+      }
+      else
+      {
+        that_.UpdateProgress(static_cast<float>(position) / static_cast<float>(maxPosition));
+      }
+    }
+  };
+
+
+  class IFunction : public boost::noncopyable
+  {
+  public:
+    virtual ~IFunction()
+    {
+    }
+
+    virtual void Execute(JobContext& context) = 0;
+  };
+
+
+  class IFunctionFactory : public boost::noncopyable
+  {
+  public:
+    virtual ~IFunctionFactory()
+    {
+    }
+
+    // Called when the job is paused or canceled. WARNING:
+    // "CancelFunction()" will be invoked while "Execute()" is
+    // running. Mutex is probably necessary.
+    virtual void CancelFunction() = 0;
+
+    virtual void PauseFunction() = 0;
+
+    virtual IFunction* CreateFunction() = 0;
+  };
+
+
+protected:
+  void SetFactory(IFunctionFactory& factory)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    if (factory_ != NULL)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      factory_ = &factory;
+    }
+  }
+  
+
+private:
+  enum FunctionResult
+  {
+    FunctionResult_Running,
+    FunctionResult_Done,
+    FunctionResult_Failure
+  };
+
+  boost::mutex                  mutex_;
+  FunctionResult                functionResult_;  // Can only be modified by the "Worker()" function
+  std::auto_ptr<boost::thread>  worker_;
+  Json::Value                   content_;
+  IFunctionFactory*             factory_;
+  bool                          stopping_;
+
+  void JoinWorker()
+  {
+    assert(factory_ != NULL);
+
+    if (worker_.get() != NULL)
+    {
+      if (worker_->joinable())
+      {
+        worker_->join();
+      }
+
+      worker_.reset();
+    }
+  }
+
+  void StartWorker()
+  {
+    assert(factory_ != NULL);
+
+    if (worker_.get() == NULL)
+    {
+      stopping_ = false;
+      worker_.reset(new boost::thread(Worker, this, factory_));
+    }
+  }
+
+  void SetContent(const std::string& key,
+                  const std::string& value)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+    content_[key] = value;
+    UpdateContent(content_);
+  }
+
+  static void Worker(SingleFunctionJob* job,
+                     IFunctionFactory* factory)
+  {
+    assert(job != NULL && factory != NULL);
+
+    JobContext context(*job);
+
+    try
+    {
+      std::auto_ptr<IFunction> function(factory->CreateFunction());
+      function->Execute(context);
+
+      {
+        boost::mutex::scoped_lock lock(job->mutex_);
+        job->functionResult_ = FunctionResult_Done;
+      }
+    }
+    catch (Orthanc::OrthancException& e)
+    {
+      LOG(ERROR) << "Error in a job: " << e.What();
+
+      {
+        boost::mutex::scoped_lock lock(job->mutex_);
+
+        job->functionResult_ = FunctionResult_Failure;
+
+        if (!job->stopping_)
+        {
+          // Don't report exceptions that are a consequence of stopping the function
+          job->content_["FunctionErrorCode"] = e.GetErrorCode();
+          job->content_["FunctionErrorDescription"] = e.What();
+          if (e.HasDetails())
+          {
+            job->content_["FunctionErrorDetails"] = e.GetDetails();
+          }
+          job->UpdateContent(job->content_);
+        }
+      }
+    }
+  }  
+
+public:
+  explicit SingleFunctionJob(const std::string& jobName) :
+    OrthancJob(jobName),
+    functionResult_(FunctionResult_Running),
+    content_(Json::objectValue),
+    factory_(NULL),
+    stopping_(false)
+  {
+  }
+
+  virtual ~SingleFunctionJob()
+  {
+    if (worker_.get() != NULL)
+    {
+      LOG(ERROR) << "Classes deriving from SingleFunctionJob must "
+                 << "explicitly call Finalize() in their destructor";
+      Finalize();
+    }
+  }
+
+  void Finalize()
+  {
+    try
+    {
+      Stop(OrthancPluginJobStopReason_Canceled);
+    }
+    catch (Orthanc::OrthancException&)
+    {
+    }
+  }
+
+  virtual OrthancPluginJobStepStatus Step()
+  {
+    if (factory_ == NULL)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadSequenceOfCalls);
+    }
+
+    FunctionResult result;
+
+    {
+      boost::mutex::scoped_lock lock(mutex_);
+      result = functionResult_;
+    }
+
+    switch (result)
+    {
+      case FunctionResult_Running:
+        StartWorker();
+        boost::this_thread::sleep(boost::posix_time::milliseconds(500));
+        return OrthancPluginJobStepStatus_Continue;
+
+      case FunctionResult_Done:
+        JoinWorker();
+        return OrthancPluginJobStepStatus_Success;
+
+      case FunctionResult_Failure:
+        JoinWorker();
+        return OrthancPluginJobStepStatus_Failure;
+
+      default:
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
+  }
+
+  virtual void Stop(OrthancPluginJobStopReason reason)
+  {
+    if (factory_ == NULL)
+    {
+      return;
+    }
+    else if (reason == OrthancPluginJobStopReason_Paused ||
+             reason == OrthancPluginJobStopReason_Canceled)
+    {
+      stopping_ = true;
+
+      if (reason == OrthancPluginJobStopReason_Paused)
+      {
+        factory_->PauseFunction();
+      }
+      else
+      {
+        factory_->CancelFunction();
+      }
+
+      JoinWorker();
+
+      // Be ready for the next possible call to "Step()" that will resume the function
+      functionResult_ = FunctionResult_Running;
+    }
+  }
+
+  virtual void Reset()
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    assert(worker_.get() == NULL);
+    functionResult_ = FunctionResult_Running;
+    content_ = Json::objectValue;
+    ClearContent();
+  }
+};
+
+
+
+
+static const std::string MULTIPART_RELATED = "multipart/related";
+
+
+
+static void SubmitJob(OrthancPluginRestOutput* output,
+                      OrthancPlugins::OrthancJob* job,
+                      const Json::Value& body,
+                      bool defaultSynchronous)
+{
+  std::auto_ptr<OrthancPlugins::OrthancJob> protection(job);
+
+  bool synchronous;
+
+  bool b;
+  if (OrthancPlugins::LookupBooleanValue(b, body, "Synchronous"))
+  {
+    synchronous = b;
+  }
+  else if (OrthancPlugins::LookupBooleanValue(b, body, "Asynchronous"))
+  {
+    synchronous = !b;
+  }
+  else
+  {
+    synchronous = defaultSynchronous;
+  }
+
+  int priority;
+  if (!OrthancPlugins::LookupIntegerValue(priority, body, "Priority"))
+  {
+    priority = 0;
+  }
+
+  Json::Value answer;
+
+  if (synchronous)
+  {
+    OrthancPlugins::OrthancJob::SubmitAndWait(answer, protection.release(), priority);
+  }
+  else
+  {
+    std::string jobId = OrthancPlugins::OrthancJob::Submit(protection.release(), priority);
+
+    answer = Json::objectValue;
+    answer["ID"] = jobId;
+    answer["Path"] = OrthancPlugins::RemoveMultipleSlashes
+      ("../" + OrthancPlugins::Configuration::GetOrthancApiRoot() + "/jobs/" + jobId);
+  }
+
+  std::string s = answer.toStyledString();
+  OrthancPluginAnswerBuffer(OrthancPlugins::GetGlobalContext(),
+                            output, s.c_str(), s.size(), "application/json");    
+}
 
 
 static void AddInstance(std::list<std::string>& target,
                         const Json::Value& instance)
 {
-  if (instance.type() != Json::objectValue ||
-      !instance.isMember("ID") ||
-      instance["ID"].type() != Json::stringValue)
+  std::string id;
+  if (OrthancPlugins::LookupStringValue(id, instance, "ID"))
   {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    target.push_back(id);
   }
   else
   {
-    target.push_back(instance["ID"].asString());
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
   }
 }
 
@@ -103,19 +444,59 @@ static bool GetSequenceSize(size_t& result,
 
 
 
+static void CheckStowAnswer(const Json::Value& response,
+                            const std::string& serverName,
+                            size_t instancesCount)
+{
+  if (response.type() != Json::objectValue ||
+      !response.isMember("00081199"))
+  {
+    throw Orthanc::OrthancException(
+      Orthanc::ErrorCode_NetworkProtocol,
+      "Unable to parse STOW-RS JSON response from DICOMweb server " + serverName);
+  }
+
+  size_t size;
+  if (!GetSequenceSize(size, response, "00081199", true, serverName) ||
+      size != instancesCount)
+  {
+    throw Orthanc::OrthancException(
+      Orthanc::ErrorCode_NetworkProtocol,
+      "The STOW-RS server was only able to receive " + 
+      boost::lexical_cast<std::string>(size) + " instances out of " +
+      boost::lexical_cast<std::string>(instancesCount));
+  }
+
+  if (GetSequenceSize(size, response, "00081198", false, serverName) &&
+      size != 0)
+  {
+    throw Orthanc::OrthancException(
+      Orthanc::ErrorCode_NetworkProtocol,
+      "The response from the STOW-RS server contains " + 
+      boost::lexical_cast<std::string>(size) + 
+      " items in its Failed SOP Sequence (0008,1198) tag");
+  }
+
+  if (GetSequenceSize(size, response, "0008119A", false, serverName) &&
+      size != 0)
+  {
+    throw Orthanc::OrthancException(
+      Orthanc::ErrorCode_NetworkProtocol,
+      "The response from the STOW-RS server contains " + 
+      boost::lexical_cast<std::string>(size) + 
+      " items in its Other Failures Sequence (0008,119A) tag");
+  }
+}
+
+
 static void ParseStowRequest(std::list<std::string>& instances /* out */,
                              std::map<std::string, std::string>& httpHeaders /* out */,
-                             std::map<std::string, std::string>& queryArguments /* out */,
-                             const OrthancPluginHttpRequest* request /* in */)
+                             const Json::Value& body /* in */)
 {
   static const char* RESOURCES = "Resources";
   static const char* HTTP_HEADERS = "HttpHeaders";
-  static const char* QUERY_ARGUMENTS = "Arguments";
 
-  Json::Value body;
-  Json::Reader reader;
-  if (!reader.parse(request->body, request->body + request->bodySize, body) ||
-      body.type() != Json::objectValue ||
+  if (body.type() != Json::objectValue ||
       !body.isMember(RESOURCES) ||
       body[RESOURCES].type() != Json::arrayValue)
   {
@@ -126,10 +507,9 @@ static void ParseStowRequest(std::list<std::string>& instances /* out */,
       "\" containing an array of resources to be sent");
   }
 
-  OrthancPlugins::ParseAssociativeArray(queryArguments, body, QUERY_ARGUMENTS);
   OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS);
 
-  Json::Value& resources = body[RESOURCES];
+  const Json::Value& resources = body[RESOURCES];
 
   // Extract information about all the child instances
   for (Json::Value::ArrayIndex i = 0; i < resources.size(); i++)
@@ -177,197 +557,254 @@ static void ParseStowRequest(std::list<std::string>& instances /* out */,
 }
 
 
-static void SendStowChunks(const Orthanc::WebServiceParameters& server,
-                           const std::map<std::string, std::string>& httpHeaders,
-                           const std::map<std::string, std::string>& queryArguments,
-                           const std::string& boundary,
-                           Orthanc::ChunkedBuffer& chunks,
-                           size_t& countInstances,
-                           bool force)
+class StowClientJob :
+  public SingleFunctionJob,
+  private SingleFunctionJob::IFunctionFactory
 {
-  unsigned int maxInstances = OrthancPlugins::Configuration::GetUnsignedIntegerValue("StowMaxInstances", 10);
-  size_t maxSize = static_cast<size_t>(OrthancPlugins::Configuration::GetUnsignedIntegerValue("StowMaxSize", 10)) * 1024 * 1024;
-
-  if ((force && countInstances > 0) ||
-      (maxInstances != 0 && countInstances >= maxInstances) ||
-      (maxSize != 0 && chunks.GetNumBytes() >= maxSize))
+private:
+  enum Action
   {
-    chunks.AddChunk("\r\n--" + boundary + "--\r\n");
-
-    std::string body;
-    chunks.Flatten(body);
+    Action_None,
+    Action_Pause,
+    Action_Cancel
+  };
+  
+  boost::mutex                             mutex_;
+  std::string                              serverName_;
+  std::vector<std::string>                 instances_;
+  OrthancPlugins::HttpClient::HttpHeaders  headers_;
+  std::string                              boundary_;
+  size_t                                   position_;
+  Action                                   action_;
+  size_t                                   networkSize_;
+  bool                                     debug_;
+
+  bool ReadNextInstance(std::string& dicom,
+                        JobContext& context)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
 
-    OrthancPlugins::MemoryBuffer answerBody;
-    std::map<std::string, std::string> answerHeaders;
+    if (action_ != Action_None)
+    {
+      return false;
+    }
 
-    std::string uri;
-    OrthancPlugins::UriEncode(uri, "studies", queryArguments);
+    while (position_ < instances_.size())
+    {
+      context.SetProgress(position_, instances_.size());
+      
+      size_t i = position_++;
 
-    OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Post,
-                               httpHeaders, uri, body);
+      if (debug_)
+      {
+        boost::this_thread::sleep(boost::posix_time::milliseconds(100));
+      }
 
-    Json::Value response;
-    Json::Reader reader;
-    bool success = reader.parse(reinterpret_cast<const char*>((*answerBody)->data),
-                                reinterpret_cast<const char*>((*answerBody)->data) + (*answerBody)->size, response);
-    answerBody.Clear();
+      if (OrthancPlugins::RestApiGetString(dicom, "/instances/" + instances_[i] + "/file", false))
+      {
+        networkSize_ += dicom.size();
+        context.SetContent("NetworkSizeMB", boost::lexical_cast<std::string>
+                           (networkSize_ / static_cast<uint64_t>(1024 * 1024)));
 
-    if (!success ||
-        response.type() != Json::objectValue ||
-        !response.isMember("00081199"))
-    {
-      throw Orthanc::OrthancException(
-        Orthanc::ErrorCode_NetworkProtocol,
-        "Unable to parse STOW-RS JSON response from DICOMweb server " + server.GetUrl());
+        return true;
+      }
     }
 
-    size_t size;
-    if (!GetSequenceSize(size, response, "00081199", true, server.GetUrl()) ||
-        size != countInstances)
-    {
-      throw Orthanc::OrthancException(
-        Orthanc::ErrorCode_NetworkProtocol,
-        "The STOW-RS server was only able to receive " + 
-        boost::lexical_cast<std::string>(size) + " instances out of " +
-        boost::lexical_cast<std::string>(countInstances));
-    }
+    return false;
+  }
+
 
-    if (GetSequenceSize(size, response, "00081198", false, server.GetUrl()) &&
-        size != 0)
+  class RequestBody : public OrthancPlugins::HttpClient::IRequestBody
+  {
+  private:
+    StowClientJob&  that_;
+    JobContext&     context_;
+    std::string     boundary_;
+    bool            done_;
+
+  public:
+    RequestBody(StowClientJob& that,
+                JobContext& context) :
+      that_(that),
+      context_(context),
+      boundary_(that.boundary_),
+      done_(false)
     {
-      throw Orthanc::OrthancException(
-        Orthanc::ErrorCode_NetworkProtocol,
-        "The response from the STOW-RS server contains " + 
-        boost::lexical_cast<std::string>(size) + 
-        " items in its Failed SOP Sequence (0008,1198) tag");
     }
 
-    if (GetSequenceSize(size, response, "0008119A", false, server.GetUrl()) &&
-        size != 0)
+    virtual bool ReadNextChunk(std::string& chunk)
     {
-      throw Orthanc::OrthancException(
-        Orthanc::ErrorCode_NetworkProtocol,
-        "The response from the STOW-RS server contains " + 
-        boost::lexical_cast<std::string>(size) + 
-        " items in its Other Failures Sequence (0008,119A) tag");
-    }
+      if (done_)
+      {
+        context_.SetProgress(1, 1);
+        return false;
+      }
+      else
+      {
+        std::string dicom;
 
-    countInstances = 0;
-  }
-}
+        if (that_.ReadNextInstance(dicom, context_))
+        {
+          chunk = ("--" + boundary_ + "\r\n" +
+                   "Content-Type: application/dicom\r\n" +
+                   "Content-Length: " + boost::lexical_cast<std::string>(dicom.size()) + 
+                   "\r\n\r\n" + dicom + "\r\n");
+        }
+        else
+        {
+          done_ = true;
+          chunk = ("--" + boundary_ + "--");
+        }
 
+        //boost::this_thread::sleep(boost::posix_time::seconds(1));
 
-void StowClient(OrthancPluginRestOutput* output,
-                const char* /*url*/,
-                const OrthancPluginHttpRequest* request)
-{
-  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+        return true;
+      }
+    }
+  };
 
-  if (request->groupsCount != 1)
-  {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
-  }
 
-  if (request->method != OrthancPluginHttpMethod_Post)
+  class F : public IFunction
   {
-    OrthancPluginSendMethodNotAllowed(context, output, "POST");
-    return;
-  }
-
-  Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]));
-
-  std::string boundary;
+  private:
+    StowClientJob& that_;
 
-  {
-    char* uuid = OrthancPluginGenerateUuid(context);
-    try
-    {
-      boundary.assign(uuid);
-    }
-    catch (...)
+  public:
+    explicit F(StowClientJob& that) :
+      that_(that)
     {
-      OrthancPluginFreeString(context, uuid);
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_NotEnoughMemory);
     }
 
-    OrthancPluginFreeString(context, uuid);
-  }
+    virtual void Execute(JobContext& context)
+    {
+      std::string serverName;
+      size_t startPosition;
 
-  std::string mime = "multipart/related; type=\"application/dicom\"; boundary=" + boundary;
+      // The lifetime of "body" should be larger than "client"
+      std::auto_ptr<RequestBody> body;
+      std::auto_ptr<OrthancPlugins::HttpClient> client;
 
-  std::map<std::string, std::string> queryArguments;
-  std::map<std::string, std::string> httpHeaders;
-  httpHeaders["Accept"] = "application/dicom+json";
-  httpHeaders["Expect"] = "";
-  httpHeaders["Content-Type"] = mime;
+      {
+        boost::mutex::scoped_lock lock(that_.mutex_);
+        context.SetContent("InstancesCount", boost::lexical_cast<std::string>(that_.instances_.size()));
+        serverName = that_.serverName_;
+        
+        startPosition = that_.position_;        
+        body.reset(new RequestBody(that_, context));
 
-  std::list<std::string> instances;
-  ParseStowRequest(instances, httpHeaders, queryArguments, request);
+        client.reset(new OrthancPlugins::HttpClient);
+        OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(*client, serverName, "/studies");
+        client->SetMethod(OrthancPluginHttpMethod_Post);
+        client->AddHeaders(that_.headers_);
+      }
 
-  OrthancPlugins::LogInfo("Sending " + boost::lexical_cast<std::string>(instances.size()) +
-                          " instances using STOW-RS to DICOMweb server: " + server.GetUrl());
+      OrthancPlugins::HttpClient::HttpHeaders answerHeaders;
+      Json::Value answerBody;
 
-  Orthanc::ChunkedBuffer chunks;
-  size_t countInstances = 0;
+      client->SetBody(*body);
 
-  for (std::list<std::string>::const_iterator it = instances.begin(); it != instances.end(); ++it)
-  {
-    OrthancPlugins::MemoryBuffer dicom;
-    if (dicom.RestApiGet("/instances/" + *it + "/file", false))
-    {
-      chunks.AddChunk("\r\n--" + boundary + "\r\n" +
-                      "Content-Type: application/dicom\r\n" +
-                      "Content-Length: " + boost::lexical_cast<std::string>(dicom.GetSize()) +
-                      "\r\n\r\n");
-      chunks.AddChunk(dicom.GetData(), dicom.GetSize());
-      countInstances ++;
+      try
+      {
+        client->Execute(answerHeaders, answerBody);
+      }
+      catch (Orthanc::OrthancException&)
+      {
+        if (client->GetHttpStatus() == 411)
+        {
+          /**
+           * "Length required" error. This might indicate an older
+           * version of Orthanc (<= 1.5.6) that does not support
+           * chunked transfers.
+           **/
+          LOG(ERROR) << "The remote DICOMweb server \"" << serverName << "\" does not support chunked transfers, "
+                     << "set configuration option \"ChunkedTransfers\" to \"0\" in the configuration";
+        }
 
-      SendStowChunks(server, httpHeaders, queryArguments, boundary, chunks, countInstances, false);
-    }
-  }
+        throw;
+      }
 
-  SendStowChunks(server, httpHeaders, queryArguments, boundary, chunks, countInstances, true);
+      {
+        boost::mutex::scoped_lock lock(that_.mutex_);
+        size_t endPosition = that_.position_;
+        CheckStowAnswer(answerBody, serverName, endPosition - startPosition);
 
-  std::string answer = "{}\n";
-  OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
-}
+        if (that_.action_ == Action_Cancel)
+        {
+          that_.position_ = 0;
+        }
+      }
+    }
+  };
+  
 
+  virtual void CancelFunction()
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+    action_ = Action_Cancel;
+  }
+  
 
-static bool GetStringValue(std::string& target,
-                           const Json::Value& json,
-                           const std::string& key)
-{
-  if (json.type() != Json::objectValue)
+  virtual void PauseFunction()
   {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat);
+    boost::mutex::scoped_lock lock(mutex_);
+    action_ = Action_Pause;
   }
-  else if (!json.isMember(key))
+
+  
+  virtual IFunction* CreateFunction()
   {
-    target.clear();
-    return false;
+    action_ = Action_None;
+    return new F(*this);
   }
-  else if (json[key].type() != Json::stringValue)
+
+  
+public:
+  StowClientJob(const std::string& serverName,
+                const std::list<std::string>& instances,
+                const OrthancPlugins::HttpClient::HttpHeaders& headers) :
+    SingleFunctionJob("DicomWebStowClient"),
+    serverName_(serverName),
+    headers_(headers),
+    position_(0),
+    action_(Action_None),
+    networkSize_(0),
+    debug_(false)
   {
-    throw Orthanc::OrthancException(
-      Orthanc::ErrorCode_BadFileFormat,
-      "The field \"" + key + "\" in a JSON object should be a string");
+    SetFactory(*this);
+
+    instances_.reserve(instances.size());
+
+    for (std::list<std::string>::const_iterator
+           it = instances.begin(); it != instances.end(); ++it)
+    {
+      instances_.push_back(*it);
+    }
+
+    {
+      OrthancPlugins::OrthancString tmp;
+      tmp.Assign(OrthancPluginGenerateUuid(OrthancPlugins::GetGlobalContext()));
+      tmp.ToString(boundary_);
+    }
+
+    boundary_ = (boundary_ + "-" + boundary_);  // Make the boundary longer
+
+    headers_["Accept"] = "application/dicom+json";
+    headers_["Expect"] = "";
+    headers_["Content-Type"] = "multipart/related; type=\"application/dicom\"; boundary=" + boundary_;
   }
-  else
+
+  void SetDebug(bool debug)
   {
-    target = json[key].asString();
-    return true;
+    debug_ = debug;
   }
-}
+};
 
 
-void GetFromServer(OrthancPluginRestOutput* output,
-                   const char* /*url*/,
-                   const OrthancPluginHttpRequest* request)
-{
-  static const char* URI = "Uri";
-  static const char* HTTP_HEADERS = "HttpHeaders";
-  static const char* GET_ARGUMENTS = "Arguments";
 
+void StowClient(OrthancPluginRestOutput* output,
+                const char* /*url*/,
+                const OrthancPluginHttpRequest* request)
+{
   OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
 
   if (request->method != OrthancPluginHttpMethod_Post)
@@ -376,14 +813,49 @@ void GetFromServer(OrthancPluginRestOutput* output,
     return;
   }
 
-  Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]));
+  if (request->groupsCount != 1)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
+  }
+
+  std::string serverName(request->groups[0]);
 
-  std::string tmp;
   Json::Value body;
-  Json::Reader reader;
-  if (!reader.parse(request->body, request->body + request->bodySize, body) ||
-      body.type() != Json::objectValue ||
-      !GetStringValue(tmp, body, URI))
+  OrthancPlugins::ParseJsonBody(body, request);
+
+  std::list<std::string> instances;
+  std::map<std::string, std::string> httpHeaders;
+  ParseStowRequest(instances, httpHeaders, body);
+
+  OrthancPlugins::LogInfo("Sending " + boost::lexical_cast<std::string>(instances.size()) +
+                          " instances using STOW-RS to DICOMweb server: " + serverName);
+
+  std::auto_ptr<StowClientJob> job(new StowClientJob(serverName, instances, httpHeaders));
+
+  bool debug;
+  if (OrthancPlugins::LookupBooleanValue(debug, body, "Debug"))
+  {
+    job->SetDebug(debug);
+  }
+  
+  Json::Value answer;
+  SubmitJob(output, job.release(), body, 
+            true /* synchronous by default, for compatibility with <= 0.6 */);
+}
+
+
+
+static void ParseGetFromServer(std::string& uri,
+                               std::map<std::string, std::string>& additionalHeaders,
+                               const Json::Value& resource)
+{
+  static const char* URI = "Uri";
+  static const char* HTTP_HEADERS = "HttpHeaders";
+  static const char* GET_ARGUMENTS = "Arguments";
+
+  std::string tmp;
+  if (resource.type() != Json::objectValue ||
+      !OrthancPlugins::LookupStringValue(tmp, resource, URI))
   {
     throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
                                     "A request to the DICOMweb client must provide a JSON object "
@@ -391,17 +863,53 @@ void GetFromServer(OrthancPluginRestOutput* output,
   }
 
   std::map<std::string, std::string> getArguments;
-  OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS);
+  OrthancPlugins::ParseAssociativeArray(getArguments, resource, GET_ARGUMENTS); 
+  OrthancPlugins::DicomWebServers::UriEncode(uri, tmp, getArguments);
+
+  OrthancPlugins::ParseAssociativeArray(additionalHeaders, resource, HTTP_HEADERS);
+}
+
+
+
+static void ConfigureGetFromServer(OrthancPlugins::HttpClient& client,
+                                   const OrthancPluginHttpRequest* request)
+{
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+  }
+
+  Json::Value body;
+  OrthancPlugins::ParseJsonBody(body, request);
 
   std::string uri;
-  OrthancPlugins::UriEncode(uri, tmp, getArguments);
+  std::map<std::string, std::string> additionalHeaders;
+  ParseGetFromServer(uri, additionalHeaders, body);
 
-  std::map<std::string, std::string> httpHeaders;
-  OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS);
+  OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(client, request->groups[0], uri);
+  client.AddHeaders(additionalHeaders);
+}
+
+
+
+void GetFromServer(OrthancPluginRestOutput* output,
+                   const char* /*url*/,
+                   const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "POST");
+    return;
+  }
 
-  OrthancPlugins::MemoryBuffer answerBody;
+  OrthancPlugins::HttpClient client;
+  ConfigureGetFromServer(client, request);
+  
   std::map<std::string, std::string> answerHeaders;
-  OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Get, httpHeaders, uri, "");
+  std::string answer;
+  client.Execute(answerHeaders, answer);
 
   std::string contentType = "application/octet-stream";
 
@@ -415,9 +923,11 @@ void GetFromServer(OrthancPluginRestOutput* output,
     {
       contentType = it->second;
     }
-    else if (key == "transfer-encoding")
+    else if (key == "transfer-encoding" ||
+             key == "content-length" ||
+             key == "connection")
     {
-      // Do not forward this header
+      // Do not forward these headers
     }
     else
     {
@@ -425,266 +935,553 @@ void GetFromServer(OrthancPluginRestOutput* output,
     }
   }
 
-  OrthancPluginAnswerBuffer(context, output, 
-                            reinterpret_cast<const char*>(answerBody.GetData()),
-                            answerBody.GetSize(), contentType.c_str());
+  OrthancPluginAnswerBuffer(context, output, answer.empty() ? NULL : answer.c_str(), 
+                            answer.size(), contentType.c_str());
 }
 
 
-
-static void RetrieveFromServerInternal(std::set<std::string>& instances,
-                                       const Orthanc::WebServiceParameters& server,
-                                       const std::map<std::string, std::string>& httpHeaders,
-                                       const std::map<std::string, std::string>& getArguments,
-                                       const Json::Value& resource)
+void GetFromServer(Json::Value& result,
+                   const OrthancPluginHttpRequest* request)
 {
-  static const std::string STUDY = "Study";
-  static const std::string SERIES = "Series";
-  static const std::string INSTANCE = "Instance";
-  static const std::string MULTIPART_RELATED = "multipart/related";
-  static const std::string APPLICATION_DICOM = "application/dicom";
+  OrthancPlugins::HttpClient client;
+  ConfigureGetFromServer(client, request);
+
+  std::map<std::string, std::string> answerHeaders;
+  client.Execute(answerHeaders, result);
+}
 
-  if (resource.type() != Json::objectValue)
+
+
+
+
+class WadoRetrieveAnswer : 
+  public OrthancPlugins::HttpClient::IAnswer,
+  private Orthanc::MultipartStreamReader::IHandler
+{
+private:
+  enum State
   {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
-                                    "Resources of interest for the DICOMweb WADO-RS Retrieve client "
-                                    "must be provided as a JSON object");
+    State_Headers,
+    State_Body,
+    State_Canceled
+  };
+
+  bool                                           debug_;
+  boost::mutex                                   mutex_;
+  State                                          state_;
+  std::list<std::string>                         instances_;
+  std::auto_ptr<Orthanc::MultipartStreamReader>  reader_;
+  uint64_t                                       networkSize_;
+
+  virtual void HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers,
+                          const void* part,
+                          size_t size)
+  {
+    std::string contentType;
+    if (!Orthanc::MultipartStreamReader::GetMainContentType(contentType, headers))
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+                                      "Missing Content-Type for a part of WADO-RS answer");
+    }
+
+    size_t pos = contentType.find(';');
+    if (pos != std::string::npos)
+    {
+      contentType = contentType.substr(0, pos);
+    }
+
+    contentType = Orthanc::Toolbox::StripSpaces(contentType);
+    if (!boost::iequals(contentType, "application/dicom"))
+    {
+      throw Orthanc::OrthancException(
+        Orthanc::ErrorCode_NetworkProtocol,
+        "Parts of a WADO-RS retrieve should have \"application/dicom\" type, but received: " + contentType);
+    }
+
+    OrthancPlugins::MemoryBuffer tmp;
+    tmp.RestApiPost("/instances", part, size, false);
+
+    Json::Value result;
+    tmp.ToJson(result);
+
+    std::string id;
+    if (OrthancPlugins::LookupStringValue(id, result, "ID"))
+    {
+      instances_.push_back(id);
+    }
+    else
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);      
+    }
+
+    if (debug_)
+    {
+      boost::this_thread::sleep(boost::posix_time::milliseconds(50));
+    }
   }
 
-  std::string study, series, instance;
-  if (!GetStringValue(study, resource, STUDY) ||
-      study.empty())
+public:
+  WadoRetrieveAnswer() :
+    debug_(false),
+    state_(State_Headers),
+    networkSize_(0)
   {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
-                                    "A non-empty \"" + STUDY + "\" field is mandatory for the "
-                                    "DICOMweb WADO-RS Retrieve client");
   }
 
-  GetStringValue(series, resource, SERIES);
-  GetStringValue(instance, resource, INSTANCE);
+  virtual ~WadoRetrieveAnswer()
+  {
+  }
 
-  if (series.empty() && 
-      !instance.empty())
+  void SetDebug(bool debug)
   {
-    throw Orthanc::OrthancException(
-      Orthanc::ErrorCode_BadFileFormat,
-      "When specifying a \"" + INSTANCE + "\" field in a call to DICOMweb "
-      "WADO-RS Retrieve client, the \"" + SERIES + "\" field is mandatory");
+    debug_ = debug;
   }
 
-  std::string tmpUri = "studies/" + study;
-  if (!series.empty())
+  void Close()
   {
-    tmpUri += "/series/" + series;
-    if (!instance.empty())
+    boost::mutex::scoped_lock lock(mutex_);
+
+    if (state_ != State_Canceled &&
+        reader_.get() != NULL)
     {
-      tmpUri += "/instances/" + instance;
+      reader_->CloseStream();
     }
   }
 
-  std::string uri;
-  OrthancPlugins::UriEncode(uri, tmpUri, getArguments);
+  virtual void AddHeader(const std::string& key,
+                         const std::string& value)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
 
-  OrthancPlugins::MemoryBuffer answerBody;
-  std::map<std::string, std::string> answerHeaders;
-  OrthancPlugins::CallServer(answerBody, answerHeaders, server, OrthancPluginHttpMethod_Get, httpHeaders, uri, "");
+    if (state_ == State_Canceled)
+    {
+      return;
+    }
+    else if (state_ != State_Headers)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
 
-  std::string contentTypeFull;
-  std::vector<std::string> contentType;
-  for (std::map<std::string, std::string>::const_iterator 
-         it = answerHeaders.begin(); it != answerHeaders.end(); ++it)
+    if (boost::iequals(key, "Content-Type"))
+    {
+      if (reader_.get() != NULL)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+                                        "Received twice a Content-Type header in WADO-RS");
+      }
+
+      std::string contentType, subType, boundary;
+
+      if (!Orthanc::MultipartStreamReader::ParseMultipartContentType
+          (contentType, subType, boundary, value))
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+                                        "Cannot parse the Content-Type for WADO-RS: " + value);
+      }
+
+      if (!boost::iequals(contentType, MULTIPART_RELATED))
+      {
+        throw Orthanc::OrthancException(
+          Orthanc::ErrorCode_NetworkProtocol,
+          "The remote WADO-RS server answers with a \"" + contentType +
+          "\" Content-Type, but \"" + MULTIPART_RELATED + "\" is expected");
+      }
+
+      reader_.reset(new Orthanc::MultipartStreamReader(boundary));
+      reader_->SetHandler(*this);
+
+      if (debug_)
+      {
+        reader_->SetBlockSize(1024 * 64);
+      }
+    }
+  }
+
+  virtual void AddChunk(const void* data,
+                        size_t size)
   {
-    std::string s = Orthanc::Toolbox::StripSpaces(it->first);
-    Orthanc::Toolbox::ToLowerCase(s);
-    if (s == "content-type")
+    boost::mutex::scoped_lock lock(mutex_);
+
+    if (state_ == State_Canceled)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_CanceledJob);
+    }
+    else if (reader_.get() == NULL)
     {
-      contentTypeFull = it->second;
-      Orthanc::Toolbox::TokenizeString(contentType, it->second, ';');
-      break;
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
+                                      "No Content-Type provided by the remote WADO-RS server");
+    }
+    else
+    {
+      state_ = State_Body;
+      networkSize_ += size;
+      reader_->AddChunk(data, size);
     }
   }
 
-  OrthancPlugins::LogInfo("Got " + boost::lexical_cast<std::string>(answerBody.GetSize()) +
-                          " bytes from a WADO-RS query with content type: " + contentTypeFull);
-  
-  if (contentType.empty())
+  void GetReceivedInstances(std::list<std::string>& target)
   {
-    throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol,
-                                    "No Content-Type provided by the remote WADO-RS server");
+    boost::mutex::scoped_lock lock(mutex_);
+    target = instances_;
   }
 
-  Orthanc::Toolbox::ToLowerCase(contentType[0]);
-  if (Orthanc::Toolbox::StripSpaces(contentType[0]) != MULTIPART_RELATED)
+  void Cancel()
   {
-    throw Orthanc::OrthancException(
-      Orthanc::ErrorCode_NetworkProtocol,
-      "The remote WADO-RS server answers with a \"" + contentType[0] +
-      "\" Content-Type, but \"" + MULTIPART_RELATED + "\" is expected");
+    boost::mutex::scoped_lock lock(mutex_);
+    LOG(ERROR) << "A WADO-RS retrieve job has been canceled, expect \"Error in the network protocol\" errors";
+    state_ = State_Canceled;
   }
 
-  std::string type, boundary;
-  for (size_t i = 1; i < contentType.size(); i++)
+  uint64_t GetNetworkSize()
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+    return networkSize_;
+  }
+};
+
+
+
+
+
+class WadoRetrieveJob : 
+  public SingleFunctionJob,
+  private SingleFunctionJob::IFunctionFactory
+{
+private:
+  class Resource : public boost::noncopyable
   {
-    std::vector<std::string> tokens;
-    Orthanc::Toolbox::TokenizeString(tokens, contentType[i], '=');
+  private:
+    std::string                        uri_;
+    std::map<std::string, std::string> additionalHeaders_;
 
-    if (tokens.size() == 2)
+  public:
+    explicit Resource(const std::string& uri) :
+      uri_(uri)
     {
-      std::string s = Orthanc::Toolbox::StripSpaces(tokens[0]);
-      Orthanc::Toolbox::ToLowerCase(s);
+    }
+
+    Resource(const std::string& uri,
+             const std::map<std::string, std::string>& additionalHeaders) :
+      uri_(uri),
+      additionalHeaders_(additionalHeaders)
+    {
+    }
+
+    const std::string& GetUri() const
+    {
+      return uri_;
+    }
 
-      if (s == "type")
+    const std::map<std::string, std::string>& GetAdditionalHeaders() const
+    {
+      return additionalHeaders_;
+    }
+  };
+
+
+  class F : public IFunction
+  {
+  private:
+    WadoRetrieveJob&   that_;
+
+  public:
+    explicit F(WadoRetrieveJob& that) :
+      that_(that)
+    {
+    }
+
+    virtual void Execute(JobContext& context)
+    {
+      for (;;)
       {
-        type = Orthanc::Toolbox::StripSpaces(tokens[1]);
-
-        // This covers the case where the content-type is quoted,
-        // which COULD be the case 
-        // cf. https://tools.ietf.org/html/rfc7231#section-3.1.1.1
-        size_t len = type.length();
-        if (len >= 2 &&
-            type[0] == '"' &&
-            type[len - 1] == '"')
+        OrthancPlugins::HttpClient client;
+
+        if (that_.SetupNextResource(client, context))
         {
-          type = type.substr(1, len - 2);
+          client.Execute(*that_.answer_);
+          that_.CloseResource(context);
+        }
+        else
+        {
+          return;   // We're done
         }
-        
-        Orthanc::Toolbox::ToLowerCase(type);
       }
-      else if (s == "boundary")
+    }
+  };
+
+
+  boost::mutex            mutex_;
+  std::string             serverName_;
+  size_t                  position_;
+  std::vector<Resource*>  resources_;
+  bool                    stopped_;
+  std::list<std::string>  retrievedInstances_;
+  std::auto_ptr<WadoRetrieveAnswer>  answer_;
+  uint64_t                networkSize_;
+  bool                    debug_;
+
+  bool SetupNextResource(OrthancPlugins::HttpClient& client,
+                         JobContext& context)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    if (stopped_ ||
+        position_ == resources_.size())
+    {
+      return false;
+    }
+    else
+    {
+      context.SetProgress(position_, resources_.size());
+
+      answer_.reset(new WadoRetrieveAnswer);
+      answer_->SetDebug(debug_);
+
+      const Resource* resource = resources_[position_++];
+      if (resource == NULL)
       {
-        boundary = Orthanc::Toolbox::StripSpaces(tokens[1]);
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
       }
+
+      OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient
+        (client, serverName_, resource->GetUri());
+      client.AddHeaders(resource->GetAdditionalHeaders());
+
+      return true;
     }
   }
 
-  // Strip the trailing and heading quotes if present
-  if (boundary.length() > 2 &&
-      boundary[0] == '"' &&
-      boundary[boundary.size() - 1] == '"')
+
+  void CloseResource(JobContext& context)
   {
-    boundary = boundary.substr(1, boundary.size() - 2);
+    boost::mutex::scoped_lock lock(mutex_);
+    answer_->Close();
+
+    std::list<std::string> instances;
+    answer_->GetReceivedInstances(instances);
+    networkSize_ += answer_->GetNetworkSize();
+
+    answer_.reset();
+
+    retrievedInstances_.splice(retrievedInstances_.end(), instances);
+
+    context.SetProgress(position_, resources_.size());
+    context.SetContent("NetworkUsageMB", boost::lexical_cast<std::string>
+                       (networkSize_ / static_cast<uint64_t>(1024 * 1024)));
+    context.SetContent("ReceivedInstancesCount", boost::lexical_cast<std::string>(retrievedInstances_.size()));
   }
 
-  OrthancPlugins::LogInfo("  Parsing the multipart content type: " + type +
-                          " with boundary: " + boundary);
 
-  if (type != APPLICATION_DICOM)
+  virtual void CancelFunction()
   {
-    throw Orthanc::OrthancException(
-      Orthanc::ErrorCode_NetworkProtocol,
-      "The remote WADO-RS server answers with a \"" + type +
-      "\" multipart Content-Type, but \"" + APPLICATION_DICOM + "\" is expected");
+    boost::mutex::scoped_lock lock(mutex_);
+
+    stopped_ = true;
+    if (answer_.get() != NULL)
+    {      
+      answer_->Cancel();
+    }
   }
 
-  if (boundary.empty())
+  virtual void PauseFunction()
   {
-    throw Orthanc::OrthancException(
-      Orthanc::ErrorCode_NetworkProtocol,
-      "The remote WADO-RS server does not provide a boundary for its multipart answer");
+    // This type of job cannot be paused
+    CancelFunction();
   }
 
-  std::vector<OrthancPlugins::MultipartItem> parts;
-  OrthancPlugins::ParseMultipartBody(parts, 
-                                     reinterpret_cast<const char*>(answerBody.GetData()),
-                                     answerBody.GetSize(), boundary);
+  virtual IFunction* CreateFunction()
+  {
+    // This type of job cannot be paused: If restarting, always go
+    // back to the beginning
 
-  OrthancPlugins::LogInfo("The remote WADO-RS server has provided " +
-                          boost::lexical_cast<std::string>(parts.size()) + 
-                          " DICOM instances");
+    stopped_ = false;
+    position_ = 0;
+    retrievedInstances_.clear();
 
-  for (size_t i = 0; i < parts.size(); i++)
+    return new F(*this);
+  }
+
+public:
+  explicit WadoRetrieveJob(const std::string& serverName) :
+    SingleFunctionJob("DicomWebWadoRetrieveClient"),
+    serverName_(serverName),
+    position_(0),
+    stopped_(false),
+    networkSize_(0),
+    debug_(false)
   {
-    std::vector<std::string> tokens;
-    Orthanc::Toolbox::TokenizeString(tokens, parts[i].contentType_, ';');
+    SetFactory(*this);
+  }
 
-    std::string partType;
-    if (tokens.size() > 0)
-    {
-      partType = Orthanc::Toolbox::StripSpaces(tokens[0]);
-    }
+  virtual ~WadoRetrieveJob()
+  {
+    SingleFunctionJob::Finalize();
 
-    if (partType != APPLICATION_DICOM)
+    for (size_t i = 0; i < resources_.size(); i++)
     {
-      throw Orthanc::OrthancException(
-        Orthanc::ErrorCode_NetworkProtocol,
-        "The remote WADO-RS server has provided a non-DICOM file in its multipart answer"
-        " (content type: " + parts[i].contentType_ + ")");
+      assert(resources_[i] != NULL);
+      delete resources_[i];
     }
+  }
 
-    OrthancPlugins::MemoryBuffer tmp;
-    tmp.RestApiPost("/instances", parts[i].data_, parts[i].size_, false);
+  void SetDebug(bool debug)
+  {
+    debug_ = debug;
+  }
 
-    Json::Value result;
-    tmp.ToJson(result);
+  void AddResource(const std::string& uri)
+  {
+    resources_.push_back(new Resource(uri));
+  }
 
-    if (result.type() != Json::objectValue ||
-        !result.isMember("ID") ||
-        result["ID"].type() != Json::stringValue)
-    {
-      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);      
-    }
-    else
-    {
-      instances.insert(result["ID"].asString());
-    }
+  void AddResource(const std::string& uri,
+                   const std::map<std::string, std::string>& additionalHeaders)
+  {
+    resources_.push_back(new Resource(uri, additionalHeaders));
+  }
+
+  void AddResourceFromRequest(const Json::Value& resource)
+  {
+    std::string uri;
+    std::map<std::string, std::string> additionalHeaders;
+    ParseGetFromServer(uri, additionalHeaders, resource);
+
+    resources_.push_back(new Resource(uri, additionalHeaders));
+  }
+};
+
+
+void WadoRetrieveClient(OrthancPluginRestOutput* output,
+                        const char* url,
+                        const OrthancPluginHttpRequest* request)
+{
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
   }
+
+  if (request->groupsCount != 1)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
+  }
+
+  std::string serverName(request->groups[0]);
+
+  Json::Value body;
+  OrthancPlugins::ParseJsonBody(body, request);
+
+  std::auto_ptr<WadoRetrieveJob>  job(new WadoRetrieveJob(serverName));
+  job->AddResourceFromRequest(body);
+
+  bool debug;
+  if (OrthancPlugins::LookupBooleanValue(debug, body, "Debug"))
+  {
+    job->SetDebug(debug);
+  }
+
+  SubmitJob(output, job.release(), body, false /* asynchronous by default */);
 }
 
 
 
 void RetrieveFromServer(OrthancPluginRestOutput* output,
-                        const char* /*url*/,
+                        const char* url,
                         const OrthancPluginHttpRequest* request)
 {
-  static const std::string RESOURCES("Resources");
-  static const char* HTTP_HEADERS = "HttpHeaders";
-  static const std::string GET_ARGUMENTS = "Arguments";
-
-  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+  static const char* const GET_ARGUMENTS = "GetArguments";
+  static const char* const HTTP_HEADERS = "HttpHeaders";
+  static const char* const RESOURCES = "Resources";
+  static const char* const STUDY = "Study";
+  static const char* const SERIES = "Series";
+  static const char* const INSTANCE = "Instance";
 
   if (request->method != OrthancPluginHttpMethod_Post)
   {
-    OrthancPluginSendMethodNotAllowed(context, output, "POST");
-    return;
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
   }
 
-  Orthanc::WebServiceParameters server(OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]));
+  if (request->groupsCount != 1)
+  {
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadRequest);
+  }
+
+  std::string serverName(request->groups[0]);
 
   Json::Value body;
-  Json::Reader reader;
-  if (!reader.parse(request->body, request->body + request->bodySize, body) ||
-      body.type() != Json::objectValue ||
+  OrthancPlugins::ParseJsonBody(body, request);
+
+  std::map<std::string, std::string> getArguments;
+  OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS); 
+
+  std::map<std::string, std::string> additionalHeaders;
+  OrthancPlugins::ParseAssociativeArray(additionalHeaders, body, HTTP_HEADERS);
+
+  std::auto_ptr<WadoRetrieveJob> job(new WadoRetrieveJob(serverName));
+
+  if (body.type() != Json::objectValue ||
       !body.isMember(RESOURCES) ||
       body[RESOURCES].type() != Json::arrayValue)
   {
-    throw Orthanc::OrthancException(
-      Orthanc::ErrorCode_BadFileFormat,
-      "A request to the DICOMweb WADO-RS Retrieve client must provide a JSON object "
-      "with the field \"" + RESOURCES + "\" containing an array of resources");
+    throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                    "The body must be a JSON object containing an array \"" + 
+                                    std::string(RESOURCES) + "\"");
   }
 
-  std::map<std::string, std::string> httpHeaders;
-  OrthancPlugins::ParseAssociativeArray(httpHeaders, body, HTTP_HEADERS);
+  const Json::Value& resources = body[RESOURCES];
 
-  std::map<std::string, std::string> getArguments;
-  OrthancPlugins::ParseAssociativeArray(getArguments, body, GET_ARGUMENTS);
+  for (Json::Value::ArrayIndex i = 0; i < resources.size(); i++)
+  {
+    std::string study;
+    if (!OrthancPlugins::LookupStringValue(study, resources[i], STUDY))
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                      "Missing \"Study\" field in the body");
+    }
 
+    std::string series;
+    if (!OrthancPlugins::LookupStringValue(series, resources[i], SERIES))
+    {
+      series.clear();
+    }
 
-  std::set<std::string> instances;
-  for (Json::Value::ArrayIndex i = 0; i < body[RESOURCES].size(); i++)
-  {
-    RetrieveFromServerInternal(instances, server, httpHeaders, getArguments, body[RESOURCES][i]);
+    std::string instance;
+    if (!OrthancPlugins::LookupStringValue(instance, resources[i], INSTANCE))
+    {
+      instance.clear();
+    }
+
+    if (series.empty() &&
+        !instance.empty())
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_BadFileFormat,
+                                      "Missing \"Series\" field in the body, as \"Instance\" is present");
+    }
+
+    std::string tmp = "/studies/" + study;
+
+    if (!series.empty())
+    {
+      tmp += "/series/" + series;
+    }
+
+    if (!instance.empty())
+    {
+      tmp += "/instances/" + instance;
+    }
+
+    std::string uri;
+    OrthancPlugins::DicomWebServers::UriEncode(uri, tmp, getArguments);
+
+    job->AddResource(uri, additionalHeaders);
   }
 
-  Json::Value status = Json::objectValue;
-  status["Instances"] = Json::arrayValue;
-  
-  for (std::set<std::string>::const_iterator
-         it = instances.begin(); it != instances.end(); ++it)
+  bool debug;
+  if (OrthancPlugins::LookupBooleanValue(debug, body, "Debug"))
   {
-    status["Instances"].append(*it);
+    job->SetDebug(debug);
   }
 
-  std::string s = status.toStyledString();
-  OrthancPluginAnswerBuffer(context, output, s.c_str(), s.size(), "application/json");
+  SubmitJob(output, job.release(), body, 
+            true /* synchronous by default, for compatibility with <= 0.6 */);
 }
+


=====================================
Plugin/DicomWebClient.h
=====================================
@@ -32,6 +32,14 @@ void GetFromServer(OrthancPluginRestOutput* output,
                    const char* /*url*/,
                    const OrthancPluginHttpRequest* request);
 
+void GetFromServer(Json::Value& result,
+                   const OrthancPluginHttpRequest* request);
+
+// TODO => Mark as deprecated
 void RetrieveFromServer(OrthancPluginRestOutput* output,
                         const char* /*url*/,
                         const OrthancPluginHttpRequest* request);
+
+void WadoRetrieveClient(OrthancPluginRestOutput* output,
+                        const char* url,
+                        const OrthancPluginHttpRequest* request);


=====================================
Plugin/DicomWebServers.cpp
=====================================
@@ -25,6 +25,8 @@
 
 #include <Core/Toolbox.h>
 
+#include <boost/algorithm/string/predicate.hpp>
+
 namespace OrthancPlugins
 {
   void DicomWebServers::Clear()
@@ -116,6 +118,67 @@ namespace OrthancPlugins
   }
 
 
+  void DicomWebServers::ConfigureHttpClient(HttpClient& client,
+                                            const std::string& name,
+                                            const std::string& uri)
+  {
+    static const char* HAS_CHUNKED_TRANSFERS = "ChunkedTransfers";
+    
+    const Orthanc::WebServiceParameters parameters = GetServer(name);
+
+    client.SetUrl(RemoveMultipleSlashes(parameters.GetUrl() + "/" + uri));
+    client.SetHeaders(parameters.GetHttpHeaders());
+
+    if (!parameters.GetUsername().empty())
+    {
+      client.SetCredentials(parameters.GetUsername(), parameters.GetPassword());
+    }
+
+    // By default, enable chunked transfers
+    client.SetChunkedTransfersAllowed(
+      parameters.GetBooleanUserProperty(HAS_CHUNKED_TRANSFERS, true));
+  }    
+
+
+  void DicomWebServers::DeleteServer(const std::string& name)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    Servers::iterator found = servers_.find(name);
+
+    if (found == servers_.end())
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange,
+                                      "Unknown DICOMweb server: " + name);
+    }
+    else
+    {
+      assert(found->second != NULL);
+      delete found->second;
+      servers_.erase(found);
+    }
+  }
+
+
+  void DicomWebServers::SetServer(const std::string& name,
+                                  const Orthanc::WebServiceParameters& parameters)
+  {
+    boost::mutex::scoped_lock lock(mutex_);
+
+    Servers::iterator found = servers_.find(name);
+
+    if (found != servers_.end())
+    {
+      assert(found->second != NULL);
+      delete found->second;
+      servers_.erase(found);
+    }
+
+    servers_[name] = new Orthanc::WebServiceParameters(parameters);
+  }
+
+  
+
   static const char* ConvertToCString(const std::string& s)
   {
     if (s.empty())
@@ -252,9 +315,9 @@ namespace OrthancPlugins
   }
 
 
-  void UriEncode(std::string& uri,
-                 const std::string& resource,
-                 const std::map<std::string, std::string>& getArguments)
+  void DicomWebServers::UriEncode(std::string& uri,
+                                  const std::string& resource,
+                                  const std::map<std::string, std::string>& getArguments)
   {
     if (resource.find('?') != std::string::npos)
     {


=====================================
Plugin/DicomWebServers.h
=====================================
@@ -45,6 +45,10 @@ namespace OrthancPlugins
     }
 
   public:
+    static void UriEncode(std::string& uri,
+                          const std::string& resource,
+                          const std::map<std::string, std::string>& getArguments);
+
     void Load(const Json::Value& configuration);
 
     ~DicomWebServers()
@@ -57,6 +61,15 @@ namespace OrthancPlugins
     Orthanc::WebServiceParameters GetServer(const std::string& name);
 
     void ListServers(std::list<std::string>& servers);
+
+    void ConfigureHttpClient(HttpClient& client,
+                             const std::string& name,
+                             const std::string& uri);
+
+    void DeleteServer(const std::string& name);
+
+    void SetServer(const std::string& name,
+                   const Orthanc::WebServiceParameters& parameters);
   };
 
 
@@ -67,8 +80,4 @@ namespace OrthancPlugins
                   const std::map<std::string, std::string>& httpHeaders,
                   const std::string& uri,
                   const std::string& body);
-
-  void UriEncode(std::string& uri,
-                 const std::string& resource,
-                 const std::map<std::string, std::string>& getArguments);
 }


=====================================
Plugin/OrthancExplorer.js
=====================================
@@ -0,0 +1,142 @@
+function ChooseDicomWebServer(callback)
+{
+  var clickedModality = '';
+  var clickedPeer = '';
+  var items = $('<ul>')
+    .attr('data-divider-theme', 'd')
+    .attr('data-role', 'listview');
+
+  $.ajax({
+    url: '../${DICOMWEB_ROOT}/servers',
+    type: 'GET',
+    dataType: 'json',
+    async: false,
+    cache: false,
+    success: function(servers) {
+      var name, item;
+      
+      if (servers.length > 0)
+      {
+        items.append('<li data-role="list-divider">DICOMweb servers</li>');
+
+        for (var i = 0; i < servers.length; i++) {
+          name = servers[i];
+          item = $('<li>')
+            .html('<a href="#" rel="close">' + name + '</a>')
+            .attr('name', name)
+            .click(function() { 
+              clickedModality = $(this).attr('name');
+            });
+          items.append(item);
+        }
+      }
+
+      // Launch the dialog
+      $(document).simpledialog2({
+        mode: 'blank',
+        animate: false,
+        headerText: 'Choose target',
+        headerClose: true,
+        forceInput: false,
+        width: '100%',
+        blankContent: items,
+        callbackClose: function() {
+          var timer;
+          function WaitForDialogToClose() {
+            if (!$('#dialog').is(':visible')) {
+              clearInterval(timer);
+              callback(clickedModality, clickedPeer);
+            }
+          }
+          timer = setInterval(WaitForDialogToClose, 100);
+        }
+      });
+    }
+  });
+}
+
+
+function ConfigureDicomWebStowClient(resourceId, buttonId, positionOnPage)
+{
+  $('#' + buttonId).remove();
+
+  var b = $('<a>')
+      .attr('id', buttonId)
+      .attr('data-role', 'button')
+      .attr('href', '#')
+      .attr('data-icon', 'forward')
+      .attr('data-theme', 'e')
+      .text('Send to DICOMweb server')
+      .button();
+
+  b.insertAfter($('#' + positionOnPage));
+
+  b.click(function() {
+    if ($.mobile.pageData) {
+      ChooseDicomWebServer(function(server) {
+        if (server != '' && resourceId != '') {
+          var query = {
+            'Resources' : [ resourceId ],
+            'Synchronous' : false
+          };
+          
+          $.ajax({
+            url: '../${DICOMWEB_ROOT}/servers/' + server + '/stow',
+            type: 'POST',
+            dataType: 'json',
+            data: JSON.stringify(query),
+            async: false,
+            error: function() {
+              alert('Cannot submit job');
+            },
+            success: function(job) {
+            }
+          });
+        }
+      });
+    }
+  });
+}
+
+
+$('#patient').live('pagebeforeshow', function() {
+  ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-patient', 'patient-info');
+});
+
+$('#study').live('pagebeforeshow', function() {
+  ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-study', 'study-info');
+});
+
+$('#series').live('pagebeforeshow', function() {
+  ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-series', 'series-info');
+});
+
+$('#instance').live('pagebeforeshow', function() {
+  ConfigureDicomWebStowClient($.mobile.pageData.uuid, 'stow-instance', 'instance-info');
+});
+
+$('#lookup').live('pagebeforeshow', function() {
+  $('#open-dicomweb-client').remove();
+  
+  var b = $('<fieldset>')
+      .attr('id', 'open-dicomweb-client')
+      .addClass('ui-grid-b')
+      .append($('<div>')
+              .addClass('ui-block-a'))
+      .append($('<div>')
+              .addClass('ui-block-b')
+              .append($('<a>')
+                      .attr('id', 'coucou')
+                      .attr('data-role', 'button')
+                      .attr('href', '#')
+                      .attr('data-icon', 'forward')
+                      .attr('data-theme', 'a')
+                      .text('Open DICOMweb client')
+                      .button()
+                      .click(function(e) {
+                        window.open('../${DICOMWEB_ROOT}/app/client/index.html');
+                      })));
+  
+  b.insertAfter($('#lookup-result'));
+});
+


=====================================
Plugin/Plugin.cpp
=====================================
@@ -18,7 +18,6 @@
  * along with this program. If not, see <http://www.gnu.org/licenses/>.
  **/
 
-
 #include "DicomWebClient.h"
 #include "DicomWebServers.h"
 #include "GdcmParsedDicomFile.h"
@@ -28,53 +27,17 @@
 #include "WadoUri.h"
 
 #include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
+#include <Core/SystemToolbox.h>
 #include <Core/Toolbox.h>
 
+#include <EmbeddedResources.h>
 
-void SwitchStudies(OrthancPluginRestOutput* output,
-                   const char* url,
-                   const OrthancPluginHttpRequest* request)
-{
-  switch (request->method)
-  {
-    case OrthancPluginHttpMethod_Get:
-      // This is QIDO-RS
-      SearchForStudies(output, url, request);
-      break;
+#include <boost/algorithm/string/predicate.hpp>
 
-    case OrthancPluginHttpMethod_Post:
-      // This is STOW-RS
-      StowCallback(output, url, request);
-      break;
 
-    default:
-      OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST");
-      break;
-  }
-}
+static const char* const HAS_DELETE = "HasDelete";
 
 
-void SwitchStudy(OrthancPluginRestOutput* output,
-                 const char* url,
-                 const OrthancPluginHttpRequest* request)
-{
-  switch (request->method)
-  {
-    case OrthancPluginHttpMethod_Get:
-      // This is WADO-RS
-      RetrieveDicomStudy(output, url, request);
-      break;
-
-    case OrthancPluginHttpMethod_Post:
-      // This is STOW-RS
-      StowCallback(output, url, request);
-      break;
-
-    default:
-      OrthancPluginSendMethodNotAllowed(OrthancPlugins::GetGlobalContext(), output, "GET,POST");
-      break;
-  }
-}
 
 bool RequestHasKey(const OrthancPluginHttpRequest* request, const char* key)
 {
@@ -110,11 +73,7 @@ void ListServers(OrthancPluginRestOutput* output,
         Orthanc::WebServiceParameters server = OrthancPlugins::DicomWebServers::GetInstance().GetServer(*it);
         Json::Value jsonServer;
         // only return the minimum information to identify the destination, do not include "security" information like passwords
-        jsonServer["Url"] = server.GetUrl();
-        if (!server.GetUsername().empty())
-        {
-          jsonServer["Username"] = server.GetUsername();
-        }
+        server.FormatPublic(jsonServer);
         result[*it] = jsonServer;
       }
 
@@ -141,26 +100,374 @@ void ListServerOperations(OrthancPluginRestOutput* output,
 {
   OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
 
+  switch (request->method)
+  {
+    case OrthancPluginHttpMethod_Get:
+    {
+      // Make sure the server does exist
+      const Orthanc::WebServiceParameters& server = 
+        OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]);
+
+      Json::Value json = Json::arrayValue;
+      json.append("get");
+      json.append("retrieve");
+      json.append("stow");
+      json.append("wado");
+      json.append("qido");
+
+      if (server.GetBooleanUserProperty(HAS_DELETE, false))
+      {
+        json.append("delete");
+      }
+
+      std::string answer = json.toStyledString(); 
+      OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
+      break;
+    }
+    
+    case OrthancPluginHttpMethod_Delete:
+    {
+      OrthancPlugins::DicomWebServers::GetInstance().DeleteServer(request->groups[0]);
+      std::string answer = "{}";
+      OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
+      break;
+    }
+    
+    case OrthancPluginHttpMethod_Put:
+    {
+      Json::Value body;
+      OrthancPlugins::ParseJsonBody(body, request);
+      
+      Orthanc::WebServiceParameters parameters(body);
+      
+      OrthancPlugins::DicomWebServers::GetInstance().SetServer(request->groups[0], parameters);
+      std::string answer = "{}";
+      OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
+      break;
+    }
+    
+    default:
+      OrthancPluginSendMethodNotAllowed(context, output, "GET,PUT,DELETE");
+      break;
+  }
+}
+
+
+
+void GetClientInformation(OrthancPluginRestOutput* output,
+                          const char* /*url*/,
+                          const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
   if (request->method != OrthancPluginHttpMethod_Get)
   {
     OrthancPluginSendMethodNotAllowed(context, output, "GET");
   }
   else
   {
-    // Make sure the server does exist
-    OrthancPlugins::DicomWebServers::GetInstance().GetServer(request->groups[0]);
-
-    Json::Value json = Json::arrayValue;
-    json.append("get");
-    json.append("retrieve");
-    json.append("stow");
+    Json::Value info = Json::objectValue;
+    info["DicomWebRoot"] = OrthancPlugins::Configuration::GetDicomWebRoot();
+    info["OrthancApiRoot"] = OrthancPlugins::Configuration::GetOrthancApiRoot();
 
-    std::string answer = json.toStyledString(); 
+    std::string answer = info.toStyledString();
     OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(), "application/json");
   }
 }
 
 
+
+void QidoClient(OrthancPluginRestOutput* output,
+                const char* /*url*/,
+                const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "POST");
+  }
+  else
+  {
+    Json::Value answer;
+    GetFromServer(answer, request);
+    
+    if (answer.type() != Json::arrayValue)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+    }
+
+    Json::Value result = Json::arrayValue;
+    for (Json::Value::ArrayIndex i = 0; i < answer.size(); i++)
+    {
+      if (answer[i].type() != Json::objectValue)
+      {
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+      }
+
+      Json::Value::Members tags = answer[i].getMemberNames();
+
+      Json::Value item = Json::objectValue;
+      
+      for (size_t j = 0; j < tags.size(); j++)
+      {
+        Orthanc::DicomTag tag(0, 0);
+        if (Orthanc::DicomTag::ParseHexadecimal(tag, tags[j].c_str()))
+        {
+          Json::Value value = Json::objectValue;
+          value["Group"] = tag.GetGroup();
+          value["Element"] = tag.GetElement();
+          
+#if ORTHANC_PLUGINS_VERSION_IS_ABOVE(1, 5, 7)
+          OrthancPlugins::OrthancString name;
+
+          name.Assign(OrthancPluginGetTagName(context, tag.GetGroup(), tag.GetElement(), NULL));
+          if (name.GetContent() != NULL)
+          {
+            value["Name"] = std::string(name.GetContent());
+          }
+#endif
+
+          const Json::Value& source = answer[i][tags[j]];
+          if (source.type() != Json::objectValue ||
+              !source.isMember("vr") ||
+              source["vr"].type() != Json::stringValue)
+          {
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_NetworkProtocol);
+          }
+
+          value["vr"] = source["vr"].asString();
+
+          if (source.isMember("Value") &&
+              source["Value"].type() == Json::arrayValue &&
+              source["Value"].size() >= 1)
+          {
+            const Json::Value& content = source["Value"][0];
+
+            switch (content.type())
+            {
+              case Json::stringValue:
+                value["Value"] = content.asString();
+                break;
+
+              case Json::objectValue:
+                if (content.isMember("Alphabetic") &&
+                    content["Alphabetic"].type() == Json::stringValue)
+                {
+                  value["Value"] = content["Alphabetic"].asString();
+                }
+                break;
+
+              default:
+                break;
+            }
+          }
+
+          item[tags[j]] = value;
+        }
+      }
+
+      result.append(item);
+    }
+
+    std::string tmp = result.toStyledString();
+    OrthancPluginAnswerBuffer(context, output, tmp.c_str(), tmp.size(), "application/json");
+  }
+}
+
+
+void DeleteClient(OrthancPluginRestOutput* output,
+                const char* /*url*/,
+                const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Post)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "POST");
+  }
+  else
+  {
+    static const char* const LEVEL = "Level";
+    static const char* const SERIES_INSTANCE_UID = "SeriesInstanceUID";
+    static const char* const STUDY_INSTANCE_UID = "StudyInstanceUID";
+    static const char* const SOP_INSTANCE_UID = "SOPInstanceUID";
+
+    const std::string serverName = request->groups[0];
+
+    const Orthanc::WebServiceParameters& server = 
+      OrthancPlugins::DicomWebServers::GetInstance().GetServer(serverName);
+
+    if (!server.GetBooleanUserProperty(HAS_DELETE, false))
+    {
+      throw Orthanc::OrthancException(
+        Orthanc::ErrorCode_BadFileFormat,
+        "Cannot delete on DICOMweb server, check out property \"" + std::string(HAS_DELETE) + "\": " + serverName);
+    }
+
+    Json::Value body;
+    OrthancPlugins::ParseJsonBody(body, request);
+
+    if (body.type() != Json::objectValue ||
+        !body.isMember(LEVEL) ||
+        !body.isMember(STUDY_INSTANCE_UID) ||
+        body[LEVEL].type() != Json::stringValue ||
+        body[STUDY_INSTANCE_UID].type() != Json::stringValue)
+    {
+      throw Orthanc::OrthancException(
+        Orthanc::ErrorCode_BadFileFormat,
+        "The request body must contain a JSON object with fields \"Level\" and \"StudyInstanceUID\"");
+    }
+
+    Orthanc::ResourceType level = Orthanc::StringToResourceType(body[LEVEL].asCString());
+
+    const std::string study = body[STUDY_INSTANCE_UID].asString();
+
+    std::string series;    
+    if (level == Orthanc::ResourceType_Series ||
+        level == Orthanc::ResourceType_Instance)
+    {
+      if (!body.isMember(SERIES_INSTANCE_UID) ||
+          body[SERIES_INSTANCE_UID].type() != Json::stringValue)
+      {
+        throw Orthanc::OrthancException(
+          Orthanc::ErrorCode_BadFileFormat,
+          "The request body must contain the field \"SeriesInstanceUID\"");
+      }
+      else
+      {
+        series = body[SERIES_INSTANCE_UID].asString();
+      }
+    }
+
+    std::string instance;    
+    if (level == Orthanc::ResourceType_Instance)
+    {
+      if (!body.isMember(SOP_INSTANCE_UID) ||
+          body[SOP_INSTANCE_UID].type() != Json::stringValue)
+      {
+        throw Orthanc::OrthancException(
+          Orthanc::ErrorCode_BadFileFormat,
+          "The request body must contain the field \"SOPInstanceUID\"");
+      }
+      else
+      {
+        instance = body[SOP_INSTANCE_UID].asString();
+      }
+    }
+
+    std::string uri;
+    switch (level)
+    {
+      case Orthanc::ResourceType_Study:
+        uri = "/studies/" + study;
+        break;
+
+      case Orthanc::ResourceType_Series:
+        uri = "/studies/" + study + "/series/" + series;
+        break;
+
+      case Orthanc::ResourceType_Instance:
+        uri = "/studies/" + study + "/series/" + series + "/instances/" + instance;
+        break;
+
+      default:
+        throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+    }
+
+    OrthancPlugins::HttpClient client;
+    OrthancPlugins::DicomWebServers::GetInstance().ConfigureHttpClient(client, serverName, uri);
+    client.SetMethod(OrthancPluginHttpMethod_Delete);
+    client.Execute();
+
+    std::string tmp = "{}";
+    OrthancPluginAnswerBuffer(context, output, tmp.c_str(), tmp.size(), "application/json");
+  }
+}
+
+
+
+
+static void AnswerFrameRendered(OrthancPluginRestOutput* output,
+                                int frame,
+                                const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Get)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "GET");
+  }
+  else
+  {
+    std::string instanceId;
+    if (LocateInstance(output, instanceId, request))
+    {
+      Orthanc::MimeType mime = Orthanc::MimeType_Jpeg;  // This is the default in DICOMweb
+      
+      for (uint32_t i = 0; i < request->headersCount; i++)
+      {
+        if (boost::iequals(request->headersKeys[i], "Accept") &&
+            !boost::iequals(request->headersValues[i], "*/*"))
+        {
+          try
+          {
+            // TODO - Support conversion to GIF
+        
+            mime = Orthanc::StringToMimeType(request->headersValues[i]);
+            if (mime != Orthanc::MimeType_Png &&
+                mime != Orthanc::MimeType_Jpeg)
+            {
+              throw Orthanc::OrthancException(Orthanc::ErrorCode_ParameterOutOfRange);
+            }
+          }
+          catch (Orthanc::OrthancException&)
+          {
+            LOG(ERROR) << "Unsupported MIME type in WADO-RS rendered frame: " << request->headersValues[i];
+            throw;
+          }
+        }
+      }
+
+      std::map<std::string, std::string> headers;
+      headers["Accept"] = Orthanc::EnumerationToString(mime);
+
+      // NB: In DICOMweb, the "frame" parameter is in the range [1..N], whereas
+      // Orthanc uses range [0..N-1], hence the "-1" below
+      OrthancPlugins::MemoryBuffer buffer;
+      if (buffer.RestApiGet("/instances/" + instanceId + "/frames/" +
+                            boost::lexical_cast<std::string>(frame - 1) + "/preview", headers, false))
+      {
+        OrthancPluginAnswerBuffer(context, output, buffer.GetData(),
+                                  buffer.GetSize(), Orthanc::EnumerationToString(mime));
+      }
+    }
+  }
+}
+
+
+void RetrieveInstanceRendered(OrthancPluginRestOutput* output,
+                              const char* url,
+                              const OrthancPluginHttpRequest* request)
+{
+  AnswerFrameRendered(output, 1 /* first frame */, request);
+}
+
+
+void RetrieveFrameRendered(OrthancPluginRestOutput* output,
+                           const char* url,
+                           const OrthancPluginHttpRequest* request)
+{
+  assert(request->groupsCount == 4);
+  const char* frame = request->groups[3];
+
+  AnswerFrameRendered(output, boost::lexical_cast<int>(frame), request);
+}
+
+
+
+
+
 static bool DisplayPerformanceWarning(OrthancPluginContext* context)
 {
   (void) DisplayPerformanceWarning;   // Disable warning about unused function
@@ -170,11 +477,62 @@ static bool DisplayPerformanceWarning(OrthancPluginContext* context)
 }
 
 
+template <enum Orthanc::EmbeddedResources::DirectoryResourceId folder>
+void ServeEmbeddedFolder(OrthancPluginRestOutput* output,
+                         const char* url,
+                         const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Get)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "GET");
+  }
+  else
+  {
+    std::string path = "/" + std::string(request->groups[0]);
+    const char* mime = Orthanc::EnumerationToString(Orthanc::SystemToolbox::AutodetectMimeType(path));
+
+    std::string s;
+    Orthanc::EmbeddedResources::GetDirectoryResource(s, folder, path.c_str());
+
+    const char* resource = s.size() ? s.c_str() : NULL;
+    OrthancPluginAnswerBuffer(context, output, resource, s.size(), mime);
+  }
+}
+
+
+#if ORTHANC_STANDALONE == 0
+void ServeDicomWebClient(OrthancPluginRestOutput* output,
+                         const char* url,
+                         const OrthancPluginHttpRequest* request)
+{
+  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
+
+  if (request->method != OrthancPluginHttpMethod_Get)
+  {
+    OrthancPluginSendMethodNotAllowed(context, output, "GET");
+  }
+  else
+  {
+    const std::string path = std::string(DICOMWEB_CLIENT_PATH) + std::string(request->groups[0]);
+    const char* mime = Orthanc::EnumerationToString(Orthanc::SystemToolbox::AutodetectMimeType(path));
+
+    OrthancPlugins::MemoryBuffer f;
+    f.ReadFile(path);
+
+    OrthancPluginAnswerBuffer(context, output, f.GetData(), f.GetSize(), mime);
+  }
+}
+#endif
+
+
 extern "C"
 {
   ORTHANC_PLUGINS_API int32_t OrthancPluginInitialize(OrthancPluginContext* context)
   {
     assert(DisplayPerformanceWarning(context));
+
     OrthancPlugins::SetGlobalContext(context);
     Orthanc::Logging::Initialize(context);
 
@@ -191,6 +549,11 @@ extern "C"
       return -1;
     }
 
+#if HAS_ORTHANC_PLUGIN_CHUNKED_HTTP_CLIENT == 0
+    LOG(WARNING) << "Performance warning in DICOMweb: The plugin was compiled against "
+                 << "Orthanc SDK <= 1.5.6. STOW and WADO chunked transfers will be entirely stored in RAM.";
+#endif
+
     OrthancPluginSetDescription(context, "Implementation of DICOMweb (QIDO-RS, STOW-RS and WADO-RS) and WADO-URI.");
 
     try
@@ -204,15 +567,21 @@ extern "C"
       // Configure the DICOMweb callbacks
       if (OrthancPlugins::Configuration::GetBooleanValue("Enable", true))
       {
-        std::string root = OrthancPlugins::Configuration::GetRoot();
+        std::string root = OrthancPlugins::Configuration::GetDicomWebRoot();
         assert(!root.empty() && root[root.size() - 1] == '/');
 
         OrthancPlugins::LogWarning("URI to the DICOMweb REST API: " + root);
 
+        OrthancPlugins::ChunkedRestRegistration<
+          SearchForStudies /* TODO => Rename as QIDO-RS */,
+          OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies");
+
+        OrthancPlugins::ChunkedRestRegistration<
+          RetrieveDicomStudy /* TODO => Rename as WADO-RS */,
+          OrthancPlugins::StowServer::PostCallback>::Apply(root + "studies/([^/]*)");
+
         OrthancPlugins::RegisterRestCallback<SearchForInstances>(root + "instances", true);
         OrthancPlugins::RegisterRestCallback<SearchForSeries>(root + "series", true);    
-        OrthancPlugins::RegisterRestCallback<SwitchStudies>(root + "studies", true);
-        OrthancPlugins::RegisterRestCallback<SwitchStudy>(root + "studies/([^/]*)", true);
         OrthancPlugins::RegisterRestCallback<SearchForInstances>(root + "studies/([^/]*)/instances", true);    
         OrthancPlugins::RegisterRestCallback<RetrieveStudyMetadata>(root + "studies/([^/]*)/metadata", true);
         OrthancPlugins::RegisterRestCallback<SearchForSeries>(root + "studies/([^/]*)/series", true);    
@@ -228,8 +597,53 @@ extern "C"
         OrthancPlugins::RegisterRestCallback<ListServers>(root + "servers", true);
         OrthancPlugins::RegisterRestCallback<ListServerOperations>(root + "servers/([^/]*)", true);
         OrthancPlugins::RegisterRestCallback<StowClient>(root + "servers/([^/]*)/stow", true);
+        OrthancPlugins::RegisterRestCallback<WadoRetrieveClient>(root + "servers/([^/]*)/wado", true);
         OrthancPlugins::RegisterRestCallback<GetFromServer>(root + "servers/([^/]*)/get", true);
         OrthancPlugins::RegisterRestCallback<RetrieveFromServer>(root + "servers/([^/]*)/retrieve", true);
+        OrthancPlugins::RegisterRestCallback<QidoClient>(root + "servers/([^/]*)/qido", true);
+        OrthancPlugins::RegisterRestCallback<DeleteClient>(root + "servers/([^/]*)/delete", true);
+
+        OrthancPlugins::RegisterRestCallback
+          <ServeEmbeddedFolder<Orthanc::EmbeddedResources::JAVASCRIPT_LIBS> >
+          (root + "app/libs/(.*)", true);
+
+        OrthancPlugins::RegisterRestCallback<GetClientInformation>(root + "info", true);
+
+        OrthancPlugins::RegisterRestCallback<RetrieveInstanceRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/rendered", true);
+        OrthancPlugins::RegisterRestCallback<RetrieveFrameRendered>(root + "studies/([^/]*)/series/([^/]*)/instances/([^/]*)/frames/([^/]*)/rendered", true);
+
+
+        // Extend the default Orthanc Explorer with custom JavaScript for STOW client
+        std::string explorer;
+
+#if ORTHANC_STANDALONE == 1
+        Orthanc::EmbeddedResources::GetFileResource(explorer, Orthanc::EmbeddedResources::ORTHANC_EXPLORER);
+        OrthancPlugins::RegisterRestCallback
+          <ServeEmbeddedFolder<Orthanc::EmbeddedResources::WEB_APPLICATION> >
+          (root + "app/client/(.*)", true);
+#else
+        Orthanc::SystemToolbox::ReadFile(explorer, std::string(DICOMWEB_CLIENT_PATH) + "../Plugin/OrthancExplorer.js");
+        OrthancPlugins::RegisterRestCallback<ServeDicomWebClient>(root + "app/client/(.*)", true);
+#endif
+
+        {
+          if (root.size() < 2 ||
+              root[0] != '/' ||
+              root[root.size() - 1] != '/')
+          {
+            throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+          }
+
+          std::map<std::string, std::string> dictionary;
+          dictionary["DICOMWEB_ROOT"] = root.substr(1, root.size() - 2);  // Remove heading and trailing slashes
+          std::string configured = Orthanc::Toolbox::SubstituteVariables(explorer, dictionary);
+
+          OrthancPluginExtendOrthancExplorer(OrthancPlugins::GetGlobalContext(), configured.c_str());
+        }
+        
+        
+        std::string uri = root + "app/client/index.html";
+        OrthancPluginSetRootUri(context, uri.c_str());
       }
       else
       {


=====================================
Plugin/QidoRs.cpp
=====================================
@@ -21,7 +21,6 @@
 
 #include "QidoRs.h"
 
-#include "StowRs.h"  // For IsXmlExpected()
 #include "Configuration.h"
 #include "DicomWebFormatter.h"
 
@@ -494,7 +493,8 @@ static void ApplyMatcher(OrthancPluginRestOutput* output,
   
   std::string wadoBase = OrthancPlugins::Configuration::GetBaseUrl(request);
 
-  OrthancPlugins::DicomWebFormatter::HttpWriter writer(output, IsXmlExpected(request));
+  OrthancPlugins::DicomWebFormatter::HttpWriter writer(
+    output, OrthancPlugins::Configuration::IsXmlExpected(request));
 
   // Fix of issue #13
   for (ResourcesAndInstances::const_iterator


=====================================
Plugin/StowRs.cpp
=====================================
@@ -24,135 +24,67 @@
 #include "Configuration.h"
 #include "DicomWebFormatter.h"
 
-#include <Core/Toolbox.h>
-#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
 
-bool IsXmlExpected(const OrthancPluginHttpRequest* request)
+namespace OrthancPlugins
 {
-  std::string accept;
-
-  if (!OrthancPlugins::LookupHttpHeader(accept, request, "accept"))
-  {
-    return false;   // By default, return DICOM+JSON
-  }
-
-  Orthanc::Toolbox::ToLowerCase(accept);
-  if (accept == "application/dicom+json" ||
-      accept == "application/json" ||
-      accept == "*/*")
-  {
-    return false;
-  }
-  else if (accept == "application/dicom+xml" ||
-           accept == "application/xml" ||
-           accept == "text/xml")
-  {
-    return true;
-  }
-  else
-  {
-    OrthancPlugins::LogError("Unsupported return MIME type: " + accept +
-                             ", will return DICOM+JSON");
-    return false;
-  }
-}
-
-
-
-void StowCallback(OrthancPluginRestOutput* output,
-                  const char* url,
-                  const OrthancPluginHttpRequest* request)
-{
-  OrthancPluginContext* context = OrthancPlugins::GetGlobalContext();
-
-  const std::string wadoBase = OrthancPlugins::Configuration::GetBaseUrl(request);
-
-  if (request->method != OrthancPluginHttpMethod_Post)
-  {
-    OrthancPluginSendMethodNotAllowed(context, output, "POST");
-    return;
-  }
-
-  std::string expectedStudy;
-  if (request->groupsCount == 1)
-  {
-    expectedStudy = request->groups[0];
-  }
-
-  if (expectedStudy.empty())
-  {
-    OrthancPlugins::LogInfo("STOW-RS request without study");
-  }
-  else
-  {
-    OrthancPlugins::LogInfo("STOW-RS request restricted to study UID " + expectedStudy);
-  }
-
-  std::string header;
-  if (!OrthancPlugins::LookupHttpHeader(header, request, "content-type"))
-  {
-    OrthancPlugins::LogError("No content type in the HTTP header of a STOW-RS request");
-    OrthancPluginSendHttpStatusCode(context, output, 400 /* Bad request */);
-    return;
-  }
-
-  std::string application;
-  std::map<std::string, std::string> attributes;
-  OrthancPlugins::ParseContentType(application, attributes, header);
-
-  if (application != "multipart/related" ||
-      attributes.find("type") == attributes.end() ||
-      attributes.find("boundary") == attributes.end())
-  {
-    OrthancPlugins::LogError("Unable to parse the content type of a STOW-RS request (" + application + ")");
-    OrthancPluginSendHttpStatusCode(context, output, 400 /* Bad request */);
-    return;
-  }
+  StowServer::StowServer(OrthancPluginContext* context,
+                         const std::map<std::string, std::string>& headers,
+                         const std::string& expectedStudy) :
+    context_(context),
+    xml_(Configuration::IsXmlExpected(headers)),
+    wadoBase_(Configuration::GetBaseUrl(headers)),
+    expectedStudy_(expectedStudy),
+    isFirst_(true),
+    result_(Json::objectValue),
+    success_(Json::arrayValue),
+    failed_(Json::arrayValue)
+  { 
+    std::string tmp, contentType, subType, boundary;
+    if (!Orthanc::MultipartStreamReader::GetMainContentType(tmp, headers) ||
+        !Orthanc::MultipartStreamReader::ParseMultipartContentType(contentType, subType, boundary, tmp))
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType,
+                                      "The STOW-RS server expects a multipart body in its request");
+    }
 
+    if (contentType != "multipart/related")
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType,
+                                      "The Content-Type of a STOW-RS request must be \"multipart/related\"");
+    }
 
-  std::string boundary = attributes["boundary"]; 
+    if (subType != "application/dicom")
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_UnsupportedMediaType,
+                                      "The STOW-RS plugin currently only supports \"application/dicom\" subtype");
+    }
 
-  if (attributes["type"] != "application/dicom")
-  {
-    OrthancPlugins::LogError("The STOW-RS plugin currently only supports application/dicom");
-    OrthancPluginSendHttpStatusCode(context, output, 415 /* Unsupported media type */);
-    return;
+    parser_.reset(new Orthanc::MultipartStreamReader(boundary));
+    parser_->SetHandler(*this);
   }
 
 
-  bool isFirst = true;
-
-  Json::Value result = Json::objectValue;
-  Json::Value success = Json::arrayValue;
-  Json::Value failed = Json::arrayValue;
-  
-  std::vector<OrthancPlugins::MultipartItem> items;
-  OrthancPlugins::ParseMultipartBody(items, request->body, request->bodySize, boundary);
-
-  for (size_t i = 0; i < items.size(); i++)
+  void StowServer::HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers,
+                              const void* part,
+                              size_t size)
   {
-    OrthancPlugins::LogInfo("Detected multipart item with content type \"" + 
-                            items[i].contentType_ + "\" of size " + 
-                            boost::lexical_cast<std::string>(items[i].size_));
-  }  
+    std::string contentType;
 
-  for (size_t i = 0; i < items.size(); i++)
-  {
-    if (!items[i].contentType_.empty() &&
-        items[i].contentType_ != "application/dicom")
+    if (!Orthanc::MultipartStreamReader::GetMainContentType(contentType, headers) ||
+        contentType != "application/dicom")
     {
-      OrthancPlugins::LogError("The STOW-RS request contains a part that is not "
-                               "\"application/dicom\" (it is: \"" + items[i].contentType_ + "\")");
-      OrthancPluginSendHttpStatusCode(context, output, 415 /* Unsupported media type */);
-      return;
+      throw Orthanc::OrthancException(
+        Orthanc::ErrorCode_UnsupportedMediaType,
+        "The STOW-RS request contains a part that is not "
+        "\"application/dicom\" (it is: \"" + contentType + "\")");
     }
 
     Json::Value dicom;
 
     try
     {
-      OrthancPlugins::OrthancString s;
-      s.Assign(OrthancPluginDicomBufferToJson(context, items[i].data_, items[i].size_,
+      OrthancString s;
+      s.Assign(OrthancPluginDicomBufferToJson(context_, part, size,
                                               OrthancPluginDicomToJsonFormat_Short,
                                               OrthancPluginDicomToJsonFlags_None, 256));
       s.ToJson(dicom);
@@ -160,8 +92,8 @@ void StowCallback(OrthancPluginRestOutput* output,
     catch (Orthanc::OrthancException&)
     {
       // Bad DICOM file => TODO add to error
-      OrthancPlugins::LogWarning("STOW-RS cannot parse an incoming DICOM file");
-      continue;
+      LogWarning("STOW-RS cannot parse an incoming DICOM file");
+      return;
     }           
 
     if (dicom.type() != Json::objectValue ||
@@ -174,8 +106,8 @@ void StowCallback(OrthancPluginRestOutput* output,
         dicom[Orthanc::DICOM_TAG_SOP_INSTANCE_UID.Format()].type() != Json::stringValue ||
         dicom[Orthanc::DICOM_TAG_STUDY_INSTANCE_UID.Format()].type() != Json::stringValue)
     {
-      OrthancPlugins::LogWarning("STOW-RS: Missing a mandatory tag in incoming DICOM file");
-      continue;
+      LogWarning("STOW-RS: Missing a mandatory tag in incoming DICOM file");
+      return;
     }
 
     const std::string seriesInstanceUid = dicom[Orthanc::DICOM_TAG_SERIES_INSTANCE_UID.Format()].asString();
@@ -184,63 +116,112 @@ void StowCallback(OrthancPluginRestOutput* output,
     const std::string studyInstanceUid = dicom[Orthanc::DICOM_TAG_STUDY_INSTANCE_UID.Format()].asString();
 
     Json::Value item = Json::objectValue;
-    item[OrthancPlugins::DICOM_TAG_REFERENCED_SOP_CLASS_UID.Format()] = sopClassUid;
-    item[OrthancPlugins::DICOM_TAG_REFERENCED_SOP_INSTANCE_UID.Format()] = sopInstanceUid;
-
-    if (!expectedStudy.empty() &&
-        studyInstanceUid != expectedStudy)
+    item[DICOM_TAG_REFERENCED_SOP_CLASS_UID.Format()] = sopClassUid;
+    item[DICOM_TAG_REFERENCED_SOP_INSTANCE_UID.Format()] = sopInstanceUid;
+      
+    if (!expectedStudy_.empty() &&
+        studyInstanceUid != expectedStudy_)
     {
-      OrthancPlugins::LogInfo("STOW-RS request restricted to study [" + expectedStudy + 
-                              "]: Ignoring instance from study [" + studyInstanceUid + "]");
+      LogInfo("STOW-RS request restricted to study [" + expectedStudy_ + 
+              "]: Ignoring instance from study [" + studyInstanceUid + "]");
 
-      /*item[OrthancPlugins::DICOM_TAG_WARNING_REASON.Format()] =
+      /*item[DICOM_TAG_WARNING_REASON.Format()] =
         boost::lexical_cast<std::string>(0xB006);  // Elements discarded
         success.append(item);*/
     }
     else
     {
-      if (isFirst)
+      if (isFirst_)
       {
-        std::string url = wadoBase + "studies/" + studyInstanceUid;
-        result[OrthancPlugins::DICOM_TAG_RETRIEVE_URL.Format()] = url;
-        isFirst = false;
+        std::string url = wadoBase_ + "studies/" + studyInstanceUid;
+        result_[DICOM_TAG_RETRIEVE_URL.Format()] = url;
+        isFirst_ = false;
       }
 
-      OrthancPlugins::MemoryBuffer tmp;
-      bool ok = tmp.RestApiPost("/instances", items[i].data_, items[i].size_, false);
+      MemoryBuffer tmp;
+      bool ok = tmp.RestApiPost("/instances", part, size, false);
       tmp.Clear();
 
       if (ok)
       {
-        std::string url = (wadoBase + 
+        std::string url = (wadoBase_ + 
                            "studies/" + studyInstanceUid +
                            "/series/" + seriesInstanceUid +
                            "/instances/" + sopInstanceUid);
 
-        item[OrthancPlugins::DICOM_TAG_RETRIEVE_URL.Format()] = url;
-        success.append(item);      
+        item[DICOM_TAG_RETRIEVE_URL.Format()] = url;
+        success_.append(item);      
       }
       else
       {
-        OrthancPlugins::LogError("Orthanc was unable to store instance through STOW-RS request");
-        item[OrthancPlugins::DICOM_TAG_FAILURE_REASON.Format()] =
+        LogError("Orthanc was unable to store one instance in a STOW-RS request");
+        item[DICOM_TAG_FAILURE_REASON.Format()] =
           boost::lexical_cast<std::string>(0x0110);  // Processing failure
-        failed.append(item);      
+        failed_.append(item);
       }
     }
   }
 
-  result[OrthancPlugins::DICOM_TAG_FAILED_SOP_SEQUENCE.Format()] = failed;
-  result[OrthancPlugins::DICOM_TAG_REFERENCED_SOP_SEQUENCE.Format()] = success;
 
-  const bool isXml = IsXmlExpected(request);
-  std::string answer;
-  
+  void StowServer::AddChunk(const void* data,
+                            size_t size)
   {
-    OrthancPlugins::DicomWebFormatter::Locker locker(OrthancPluginDicomWebBinaryMode_Ignore, "");
-    locker.Apply(answer, context, result, isXml);
+    assert(parser_.get() != NULL);
+    parser_->AddChunk(data, size);
   }
 
-  OrthancPluginAnswerBuffer(context, output, answer.c_str(), answer.size(),
-                            isXml ? "application/dicom+xml" : "application/dicom+json");
+
+  void StowServer::Execute(OrthancPluginRestOutput* output)
+  {
+    assert(parser_.get() != NULL);
+    parser_->CloseStream();
+
+    result_[DICOM_TAG_FAILED_SOP_SEQUENCE.Format()] = failed_;
+    result_[DICOM_TAG_REFERENCED_SOP_SEQUENCE.Format()] = success_;
+    
+    std::string answer;
+    
+    {
+      DicomWebFormatter::Locker locker(OrthancPluginDicomWebBinaryMode_Ignore, "");
+      locker.Apply(answer, context_, result_, xml_);
+    }
+      
+    OrthancPluginAnswerBuffer(context_, output, answer.c_str(), answer.size(),
+                              xml_ ? "application/dicom+xml" : "application/dicom+json");
+  };
+
+  
+  IChunkedRequestReader* StowServer::PostCallback(const char* url,
+                                                  const OrthancPluginHttpRequest* request)
+  {
+    OrthancPluginContext* context = GetGlobalContext();
+  
+    if (request->method != OrthancPluginHttpMethod_Post)
+    {
+      throw Orthanc::OrthancException(Orthanc::ErrorCode_InternalError);
+    }
+
+    std::map<std::string, std::string> headers;
+    for (uint32_t i = 0; i < request->headersCount; i++)
+    {
+      headers[request->headersKeys[i]] = request->headersValues[i];
+    }
+
+    std::string expectedStudy;
+    if (request->groupsCount == 1)
+    {
+      expectedStudy = request->groups[0];
+    }
+
+    if (expectedStudy.empty())
+    {
+      LogInfo("STOW-RS request without study");
+    }
+    else
+    {
+      LogInfo("STOW-RS request restricted to study UID " + expectedStudy);
+    }
+
+    return new StowServer(context, headers, expectedStudy);
+  }
 }


=====================================
Plugin/StowRs.h
=====================================
@@ -21,10 +21,42 @@
 
 #pragma once
 
-#include "Configuration.h"
+#include <Core/HttpServer/MultipartStreamReader.h>
+#include <Plugins/Samples/Common/OrthancPluginCppWrapper.h>
 
-bool IsXmlExpected(const OrthancPluginHttpRequest* request);
+namespace OrthancPlugins
+{
+  class StowServer : 
+    public IChunkedRequestReader,
+    private Orthanc::MultipartStreamReader::IHandler
+  {
+  private:
+    OrthancPluginContext*  context_;
+    bool                   xml_;
+    std::string            wadoBase_;
+    std::string            expectedStudy_;
+    bool                   isFirst_;
+    Json::Value            result_;
+    Json::Value            success_;
+    Json::Value            failed_;
 
-void StowCallback(OrthancPluginRestOutput* output,
-                  const char* url,
-                  const OrthancPluginHttpRequest* request);
+    std::auto_ptr<Orthanc::MultipartStreamReader>  parser_;
+
+    virtual void HandlePart(const Orthanc::MultipartStreamReader::HttpHeaders& headers,
+                            const void* part,
+                            size_t size);
+
+  public:
+    StowServer(OrthancPluginContext* context,
+               const std::map<std::string, std::string>& headers,
+               const std::string& expectedStudy);
+
+    virtual void AddChunk(const void* data,
+                          size_t size);
+
+    virtual void Execute(OrthancPluginRestOutput* output);
+
+    static IChunkedRequestReader* PostCallback(const char* url,
+                                               const OrthancPluginHttpRequest* request);
+  };
+}


=====================================
Plugin/WadoRs.cpp
=====================================
@@ -439,9 +439,9 @@ bool LocateInstance(OrthancPluginRestOutput* output,
            series["MainDicomTags"]["SeriesInstanceUID"].asString() != std::string(request->groups[1]))
   {
     throw Orthanc::OrthancException(Orthanc::ErrorCode_InexistentItem,
-                                    "No instance " + std::string(request->groups[2]) + 
-                                    " in study " + std::string(request->groups[0]) +
-                                    " or in series " + std::string(request->groups[1]));
+                                    "Instance " + std::string(request->groups[2]) + 
+                                    " is not both in study " + std::string(request->groups[0]) +
+                                    " and in series " + std::string(request->groups[1]));
   }
   else
   {


=====================================
Resources/CMake/JavaScriptLibraries.cmake
=====================================
@@ -0,0 +1,177 @@
+# Orthanc - A Lightweight, RESTful DICOM Store
+# Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+# Department, University Hospital of Liege, Belgium
+# Copyright (C) 2017-2019 Osimis S.A., Belgium
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Affero General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Affero General Public License for more details.
+# 
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+
+set(BASE_URL "http://orthanc.osimis.io/ThirdPartyDownloads/dicom-web")
+
+DownloadPackage(
+  "da0189f7c33bf9f652ea65401e0a3dc9"
+  "${BASE_URL}/bootstrap-4.3.1.zip"
+  "${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1")
+
+DownloadPackage(
+  "8242afdc5bd44105d9dc9e6535315484"
+  "${BASE_URL}/vuejs-2.6.10.tar.gz"
+  "${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10")
+
+DownloadPackage(
+  "3e2b4e1522661f7fcf8ad49cb933296c"
+  "${BASE_URL}/axios-0.19.0.tar.gz"
+  "${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0")
+
+DownloadPackage(
+  "a6145901f233f7d54165d8ade779082e"
+  "${BASE_URL}/Font-Awesome-4.7.0.tar.gz"
+  "${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0")
+
+
+set(BOOTSTRAP_VUE_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-vue-2.0.0-rc.24)
+
+if (BUILD_BOOTSTRAP_VUE OR
+    BUILD_BABEL_POLYFILL)
+  find_program(NPM_EXECUTABLE npm)
+  if (${NPM_EXECUTABLE} MATCHES "NPM_EXECUTABLE-NOTFOUND")
+    message(FATAL_ERROR "Please install the 'npm' standard command-line tool")
+  endif()
+endif()
+
+if (BUILD_BOOTSTRAP_VUE)
+  DownloadPackage(
+    "36ab31495ab94162e159619532e8def5"
+    "${BASE_URL}/bootstrap-vue-2.0.0-rc.24.tar.gz"
+    "${BOOTSTRAP_VUE_SOURCES_DIR}")
+
+  if (NOT IS_DIRECTORY "${BOOTSTRAP_VUE_SOURCES_DIR}/node_modules")
+    execute_process(
+      COMMAND ${NPM_EXECUTABLE} install
+      WORKING_DIRECTORY ${BOOTSTRAP_VUE_SOURCES_DIR}
+      RESULT_VARIABLE Failure
+      OUTPUT_QUIET
+      )
+    
+    if (Failure)
+      message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
+    endif()
+  endif()
+
+  if (NOT IS_DIRECTORY "${BOOTSTRAP_VUE_SOURCES_DIR}/dist")
+    execute_process(
+      COMMAND ${NPM_EXECUTABLE} run build
+      WORKING_DIRECTORY ${BOOTSTRAP_VUE_SOURCES_DIR}
+      RESULT_VARIABLE Failure
+      OUTPUT_QUIET
+      )
+    
+    if (Failure)
+      message(FATAL_ERROR "Error while running 'npm build' on Bootstrap-Vue")
+    endif()
+  endif()
+
+else()
+
+  ##
+  ## Generation of the precompiled Bootstrap-Vue package:
+  ##
+  ## Possibility 1 (build from sources):
+  ##  $ cmake -DBUILD_BOOTSTRAP_VUE=ON .
+  ##  $ tar cvfz bootstrap-vue-2.0.0-rc.24-dist.tar.gz bootstrap-vue-2.0.0-rc.24/dist/
+  ##
+  ## Possibility 2 (download from CDN):
+  ##  $ mkdir /tmp/i && cd /tmp/i
+  ##  $ wget -r --no-parent https://unpkg.com/bootstrap-vue@2.0.0-rc.24/dist/
+  ##  $ mv unpkg.com/bootstrap-vue at 2.0.0-rc.24/ bootstrap-vue-2.0.0-rc.24
+  ##  $ rm bootstrap-vue-2.0.0-rc.24/dist/index.html
+  ##  $ tar cvfz bootstrap-vue-2.0.0-rc.24-dist.tar.gz bootstrap-vue-2.0.0-rc.24/dist/
+
+  DownloadPackage(
+    "ba0e67b1f0b4ce64e072b42b17f6c578"
+    "${BASE_URL}/bootstrap-vue-2.0.0-rc.24-dist.tar.gz"
+    "${BOOTSTRAP_VUE_SOURCES_DIR}")
+
+endif()
+
+
+if (BUILD_BABEL_POLYFILL)
+  set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR}/node_modules/babel-polyfill/dist)
+
+  if (NOT IS_DIRECTORY "${BABEL_POLYFILL_SOURCES_DIR}")
+    execute_process(
+      COMMAND ${NPM_EXECUTABLE} install babel-polyfill
+      WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
+      RESULT_VARIABLE Failure
+      OUTPUT_QUIET
+      )
+    
+    if (Failure)
+      message(FATAL_ERROR "Error while running 'npm install' on Bootstrap-Vue")
+    endif()
+  endif()
+else()
+
+  ## curl -L https://unpkg.com/babel-polyfill@6.26.0/dist/polyfill.min.js | gzip > babel-polyfill-6.26.0.min.js.gz
+
+  set(BABEL_POLYFILL_SOURCES_DIR ${CMAKE_CURRENT_BINARY_DIR})
+  DownloadCompressedFile(
+    "49f7bad4176d715ce145e75c903988ef"
+    "${BASE_URL}/babel-polyfill-6.26.0.min.js.gz"
+    "${CMAKE_CURRENT_BINARY_DIR}/polyfill.min.js")
+
+endif()
+
+
+set(JAVASCRIPT_LIBS_DIR  ${CMAKE_CURRENT_BINARY_DIR}/javascript-libs)
+file(MAKE_DIRECTORY ${JAVASCRIPT_LIBS_DIR})
+
+file(COPY
+  ${BABEL_POLYFILL_SOURCES_DIR}/polyfill.min.js
+  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js
+  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.js.map
+  ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.js
+  ${CMAKE_CURRENT_BINARY_DIR}/axios-0.19.0/dist/axios.min.map
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/js/bootstrap.min.js
+  ${CMAKE_CURRENT_BINARY_DIR}/vue-2.6.10/dist/vue.min.js
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/js
+  )
+
+file(COPY
+  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.css
+  ${BOOTSTRAP_VUE_SOURCES_DIR}/dist/bootstrap-vue.min.css.map
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/css/font-awesome.min.css
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/css/bootstrap.min.css
+  ${CMAKE_CURRENT_BINARY_DIR}/bootstrap-4.3.1/dist/css/bootstrap.min.css.map
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/css
+  )
+
+file(COPY
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/FontAwesome.otf
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.eot
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.svg
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.ttf
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.woff
+  ${CMAKE_CURRENT_BINARY_DIR}/Font-Awesome-4.7.0/fonts/fontawesome-webfont.woff2
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/fonts
+  )
+
+file(COPY
+  ${ORTHANC_ROOT}/Resources/OrthancLogo.png
+  DESTINATION
+  ${JAVASCRIPT_LIBS_DIR}/img
+  )


=====================================
Resources/Orthanc/DownloadOrthancFramework.cmake
=====================================
@@ -66,6 +66,9 @@ if (ORTHANC_FRAMEWORK_SOURCE STREQUAL "hg" OR
   if (NOT DEFINED ORTHANC_FRAMEWORK_BRANCH)
     if (ORTHANC_FRAMEWORK_VERSION STREQUAL "mainline")
       set(ORTHANC_FRAMEWORK_BRANCH "default")
+      set(ORTHANC_FRAMEWORK_MAJOR 999)
+      set(ORTHANC_FRAMEWORK_MINOR 999)
+      set(ORTHANC_FRAMEWORK_REVISION 999)
 
     else()
       set(ORTHANC_FRAMEWORK_BRANCH "Orthanc-${ORTHANC_FRAMEWORK_VERSION}")
@@ -103,9 +106,18 @@ if (ORTHANC_FRAMEWORK_SOURCE STREQUAL "hg" OR
         set(ORTHANC_FRAMEWORK_MD5 "404baef5d4c43e7c5d9410edda8ef5a5")
       elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.5.5")
         set(ORTHANC_FRAMEWORK_MD5 "cfc437e0687ae4bd725fd93dc1f08bc4")
+      elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.5.6")
+        set(ORTHANC_FRAMEWORK_MD5 "3c29de1e289b5472342947168f0105c0")
+      elseif (ORTHANC_FRAMEWORK_VERSION STREQUAL "1.5.7")
+        set(ORTHANC_FRAMEWORK_MD5 "e1b76f01116d9b5d4ac8cc39980560e3")
       endif()
     endif()
   endif()
+else()
+  message("Using the Orthanc framework from a path of the filesystem. Assuming mainline version.")
+  set(ORTHANC_FRAMEWORK_MAJOR 999)
+  set(ORTHANC_FRAMEWORK_MINOR 999)
+  set(ORTHANC_FRAMEWORK_REVISION 999)
 endif()
 
 


=====================================
Resources/Orthanc/Sdk-1.5.7/orthanc/OrthancCPlugin.h
=====================================
The diff for this file was not included because it is too large.

=====================================
Resources/Samples/Proxy/NOTES.txt
=====================================
@@ -0,0 +1,9 @@
+This is a sample configuration file for nginx to test the DICOMweb
+plugin behind a HTTP proxy. To start the proxy as a regular user:
+
+$ nginx -c ./nginx.local.conf -p $PWD
+
+
+References about "Forwarded" header in nginx:
+https://onefeed.xyz/posts/x-forwarded-for-vs-x-real-ip.html
+https://www.nginx.com/resources/wiki/start/topics/examples/forwarded/


=====================================
Resources/Samples/Proxy/nginx.local.conf
=====================================
@@ -0,0 +1,39 @@
+worker_processes 1;
+error_log stderr;
+daemon off;
+pid nginx.pid;
+
+# `events` section is mandatory
+events {
+  worker_connections 1024; # Default: 1024
+}
+
+http {
+  # prevent nginx sync issues on OSX
+  proxy_buffering off;
+  access_log off;
+
+  server {
+    listen 9977 default_server;
+    client_max_body_size 4G;
+    
+    # location may have to be adjusted depending on your OS and nginx install
+    include /etc/nginx/mime.types;
+
+    # if not in your system mime.types, add this line to support WASM:
+    # types {
+    #    application/wasm                      wasm; 
+    # }
+
+    # reverse proxy orthanc
+	location /orthanc/ {
+		rewrite /orthanc(.*) $1 break;
+		proxy_pass http://127.0.0.1:8042;
+		proxy_set_header Host $http_host;
+		proxy_set_header my-auth-header good-token;
+		#proxy_request_buffering off;
+		#proxy_max_temp_file_size 0;
+		#client_max_body_size 0;
+	}
+  } 
+}


=====================================
Resources/Samples/Python/SendStow.py
=====================================
@@ -31,8 +31,7 @@ import sys
 import json
 import uuid
 
-#if len(sys.argv) < 2:
-if len(sys.argv) < 1:
+if len(sys.argv) < 2:
     print('Usage: %s <StowUri> <file>...' % sys.argv[0])
     print('')
     print('Example: %s http://localhost:8042/dicom-web/studies hello.dcm world.dcm' % sys.argv[0])
@@ -59,11 +58,31 @@ for i in range(2, len(sys.argv)):
 # Closing boundary
 body += bytearray('--%s--' % boundary, 'ascii')
 
-# Do the HTTP POST request to the STOW-RS server
-r = requests.post(URL, data=body, headers= {
+headers = {
     'Content-Type' : 'multipart/related; type=application/dicom; boundary=%s' % boundary,
     'Accept' : 'application/json',
-})
+    }
+
+# Do the HTTP POST request to the STOW-RS server
+if False:
+    # Don't use chunked transfer (this code was in use in DICOMweb plugin <= 0.6)
+    r = requests.post(URL, data=body, headers=headers)
+else:
+    # Use chunked transfer
+    # https://2.python-requests.org/en/master/user/advanced/#chunk-encoded-requests
+    def gen():
+        chunkSize = 1024 * 1024
+
+        l = len(body) / chunkSize
+        for i in range(l):
+            pos = i * chunkSize
+            yield body[pos : pos + chunkSize]
+
+        if len(body) % chunkSize != 0:
+            yield body[l * chunkSize :]
+
+    r = requests.post(URL, data=gen(), headers=headers)
+
 
 j = json.loads(r.text)
 


=====================================
Resources/SyncOrthancFolder.py
=====================================
@@ -11,7 +11,7 @@ import stat
 import urllib2
 
 TARGET = os.path.join(os.path.dirname(__file__), 'Orthanc')
-PLUGIN_SDK_VERSION = '1.5.4'
+PLUGIN_SDK_VERSION = '1.5.7'
 REPOSITORY = 'https://bitbucket.org/sjodogne/orthanc/raw'
 
 FILES = [


=====================================
Status.txt
=====================================
@@ -1,4 +1,4 @@
-Reference: http://medical.nema.org/medical/dicom/current/output/html/part18.html
+Reference: http://dicom.nema.org/MEDICAL/dicom/2019a/output/html/part18.html
 
 
 
@@ -86,7 +86,19 @@ Supported.
 6.5.8 WADO-RS / RetrieveRenderedTransaction
 ===========================================
 
-Not supported yet.
+http://dicom.nema.org/MEDICAL/dicom/2019a/output/chtml/part18/sect_6.5.8.html
+
+Supported
+---------
+
+* Single-frame and multi-frame retrieval
+* JPEG and PNG output
+
+Not supported
+-------------
+
+* GIF output
+* None of the "Retrieve Rendered Query Parameters" (table 6.5.8-2)
 
 
 


=====================================
UnitTestsSources/UnitTestsMain.cpp
=====================================
@@ -21,6 +21,7 @@
 
 #include <gtest/gtest.h>
 #include <boost/lexical_cast.hpp>
+#include <boost/algorithm/string/predicate.hpp>
 
 #include "../Plugin/Configuration.h"
 
@@ -29,6 +30,7 @@ using namespace OrthancPlugins;
 OrthancPluginContext* context_ = NULL;
 
 
+// TODO => Remove this test (now in Orthanc core)
 TEST(ContentType, Parse)
 {
   std::string c;


=====================================
WebApplication/app.js
=====================================
@@ -0,0 +1,398 @@
+var DICOM_TAG_ACCESSION_NUMBER = '00080050';
+var DICOM_TAG_MODALITY = '00080060';
+var DICOM_TAG_PATIENT_ID = '00100020';
+var DICOM_TAG_PATIENT_NAME = '00100010';
+var DICOM_TAG_SERIES_DESCRIPTION = '0008103E';
+var DICOM_TAG_SERIES_INSTANCE_UID = '0020000E';
+var DICOM_TAG_SOP_INSTANCE_UID = '00080018';
+var DICOM_TAG_STUDY_DATE = '00080020';
+var DICOM_TAG_STUDY_ID = '00200010';
+var DICOM_TAG_STUDY_INSTANCE_UID = '0020000D';
+var MAX_RESULTS = 100;
+
+/**
+ * This is a minimal 1x1 PNG image with white background, as generated by:
+ *   $ convert -size 1x1 -define png:include-chunk=none xc:white png:- | base64 -w 0
+ **/
+var DEFAULT_PREVIEW = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAACklEQVQI12NoAAAAggCB3UNq9AAAAABJRU5ErkJggg==';
+
+var app = new Vue({
+  el: '#app',
+  computed: {
+    studiesCount() {
+      return this.studies.length
+    },
+    seriesCount() {
+      return this.series.length
+    }
+  },
+  data: {
+    orthancApiRoot: '../../../',
+    previewFailure: true,
+    preview: DEFAULT_PREVIEW,
+    showTruncatedStudies: false,
+    showNoServer: false,
+    showStudies: false,
+    showSeries: false,
+    maxResults: MAX_RESULTS,
+    currentPage: 0,
+    perPage: 10,
+    servers: [ ],
+    serversInfo: { },
+    activeServer: '',
+    lookup: { },
+    studies: [ ],
+    currentStudy: null,
+    jobId: '',
+    jobLevel: '',
+    jobUri: '',
+    jobDetails: '',
+    studiesFields: [
+      {
+        key: DICOM_TAG_PATIENT_ID + '.Value',
+        label: 'Patient ID',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_PATIENT_NAME + '.Value',
+        label: 'Patient name',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_ACCESSION_NUMBER + '.Value',
+        label: 'Accession number',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_STUDY_DATE + '.Value',
+        label: 'Study date',
+        sortable: true
+      },
+      {
+        key: 'operations',
+        label: ''
+      }
+    ],
+    studyToDelete: null,
+    studyTags: [ ],    
+    studyTagsFields: [
+      {
+        key: 'Tag',
+        sortable: true
+      },
+      {
+        key: 'Name',
+        label: 'Description',
+        sortable: true
+      },
+      {
+        key: 'Value',
+        sortable: true
+      }
+    ],
+    series: [ ],
+    seriesFields: [
+      {
+        key: DICOM_TAG_SERIES_DESCRIPTION + '.Value',
+        label: 'Series description',
+        sortable: true
+      },
+      {
+        key: DICOM_TAG_MODALITY + '.Value',
+        label: 'Modality',
+        sortable: true
+      },
+      {
+        key: 'operations',
+        label: ''
+      }
+    ],
+    seriesToDelete: null,
+    seriesTags: [ ],    
+    seriesTagsFields: [
+      {
+        key: 'Tag',
+        sortable: true
+      },
+      {
+        key: 'Name',
+        label: 'Description',
+        sortable: true
+      },
+      {
+        key: 'Value',
+        sortable: true
+      }
+    ],
+    scrollToSeries: false,
+    scrollToStudies: false
+  },
+  mounted: () => {
+    axios
+      .get('../../servers?expand')
+      .then(response => {
+        app.serversInfo = response.data;
+        app.servers = Object.keys(response.data).map(i => i);
+        app.Clear();
+      });
+    axios
+      .get('../../info')
+      .then(response => {
+        app.orthancApiRoot = response.data.OrthancApiRoot;
+        if (!app.orthancApiRoot.endsWith('/')) {
+          app.orthancApiRoot += '/';
+        }
+        app.orthancApiRoot += '../../';  // To be at the same level as "info"
+      });
+  },
+  methods: {
+    /**
+     * Toolbox
+     **/
+
+    ScrollToRef: function(refName) {
+      var element = app.$refs[refName];
+      window.scrollTo(0, element.offsetTop);
+    },
+    ShowErrorModal: function() {
+      app.$refs['modal-error'].show();
+    },
+    RefreshJobDetails: function() {
+      axios
+        .get(app.jobUri)
+        .then(response => {
+          app.jobDetails = response.data;
+        })
+        .catch(response => {
+          app.jobDetails = 'Job details are not available';
+        })
+    },
+
+    
+    /**
+     * Studies
+     **/
+
+    SetStudies: function(response) {
+      if (response.data.length > app.maxResults) {
+        app.showTruncatedStudies = true;
+        app.studies = response.data.splice(0, app.maxResults);
+      } else {
+        app.showTruncatedStudies = false;
+        app.studies = response.data;
+      }
+      app.showStudies = true;
+      app.showSeries = false;
+      app.studyToDelete = null;
+      app.scrollToStudies = true;
+    },
+    ExecuteLookup: function() {
+      var args = { 
+        'fuzzymatching' : 'true',
+        'limit' : (app.maxResults + 1).toString()
+      };
+
+      if ('patientName' in app.lookup) {
+        args[DICOM_TAG_PATIENT_NAME] = app.lookup.patientName;
+      }
+
+      if ('patientID' in app.lookup) {
+        args[DICOM_TAG_PATIENT_ID] = app.lookup.patientID;
+      }
+
+      if ('studyDate' in app.lookup) {
+        args[DICOM_TAG_STUDY_DATE] = app.lookup.studyDate;
+      }
+
+      if ('accessionNumber' in app.lookup) {
+        args[DICOM_TAG_ACCESSION_NUMBER] = app.lookup.accessionNumber;
+      }
+
+      app.activeServer = app.lookup.server;
+      axios
+        .post('../../servers/' + app.activeServer + '/qido', {
+          'Uri' : '/studies',
+          'Arguments' : args,
+        })
+        .then(app.SetStudies)
+        .catch(response => {
+          app.showStudies = false;
+          app.showSeries = false;
+          app.ShowErrorModal();
+        });
+    },
+    Clear: function() {
+      app.lookup = {};
+      currentStudy = null;
+      app.showSeries = false;
+      app.showStudies = false;
+      if (app.servers.length == 0) {
+        app.showNoServer = true;
+      } else {
+        app.showNoServer = false;
+        app.lookup.server = app.servers[0];
+      }
+    },
+    OnLookup: function(event) {
+      event.preventDefault();
+      app.ExecuteLookup();
+    },
+    OnReset: function(event) {
+      event.preventDefault();
+      app.Clear();
+    },
+    OpenStudyDetails: function(study) {
+      app.studyTags = Object.keys(study).map(i => {
+        var item = study[i];
+        item['Tag'] = i;
+        return item;
+      });
+      
+      app.$refs['study-details'].show();
+    },
+    RetrieveStudy: function(study) {
+      var base = '../../servers/';
+      axios
+        .post(base + app.activeServer + '/wado', {
+          'Uri' : '/studies/' + study[DICOM_TAG_STUDY_INSTANCE_UID].Value
+        })
+        .then(response => {
+          app.jobLevel = 'study';
+          app.jobId = response.data.ID;
+          app.jobUri = base + response.data.Path;
+          app.$refs['retrieve-job'].show();
+          app.RefreshJobDetails();
+        });
+    },
+    ConfirmDeleteStudy: function(study) {
+      app.studyToDelete = study;
+      app.$bvModal.show('study-delete-confirm');
+    },
+    ExecuteDeleteStudy: function(study) {
+      axios
+        .post('../../servers/' + app.activeServer + '/delete', {
+          'Level': 'Study',
+          'StudyInstanceUID': app.studyToDelete[DICOM_TAG_STUDY_INSTANCE_UID].Value
+        })
+        .then(app.ExecuteLookup)
+        .catch(app.ShowErrorModal)
+    },
+
+    
+    /**
+     * Series
+     **/
+
+    LoadSeriesOfCurrentStudy: function() {
+      axios
+        .post('../../servers/' + app.activeServer + '/qido', {
+          'Uri' : '/studies/' + app.currentStudy + '/series'
+        })
+        .then(response => {
+          if (response.data.length > 0) {
+            app.series = response.data;
+            app.showSeries = true;
+            app.seriesToDelete = null;
+            app.scrollToSeries = true;
+          } else {
+            // No more series, so no more study, so re-lookup
+            app.ExecuteLookup();
+          }
+        })
+        .catch(app.ShowErrorModal);
+    }, 
+    OpenSeries: function(series) {
+      app.currentStudy = series[DICOM_TAG_STUDY_INSTANCE_UID].Value;
+      app.LoadSeriesOfCurrentStudy();
+    },
+    OpenSeriesDetails: function(series) {
+      app.seriesTags = Object.keys(series).map(i => {
+        var item = series[i];
+        item['Tag'] = i;
+        return item;
+      });
+      
+      app.$refs['series-details'].show();
+    },
+    RetrieveSeries: function(series) {
+      var base = '../../servers/';
+      axios
+        .post(base + app.activeServer + '/wado', {
+          'Uri' : ('/studies/' + app.currentStudy + 
+                   '/series/' + series[DICOM_TAG_SERIES_INSTANCE_UID].Value)
+        })
+        .then(response => {
+          app.jobLevel = 'series';
+          app.jobId = response.data.ID;
+          app.jobUri = base + response.data.Path;
+          app.$refs['retrieve-job'].show();
+          app.RefreshJobDetails();
+        });
+    },
+    OpenSeriesPreview: function(series) {
+      axios
+        .post('../../servers/' + app.activeServer + '/get', {
+          'Uri' : ('/studies/' + app.currentStudy + '/series/' + 
+                   series[DICOM_TAG_SERIES_INSTANCE_UID].Value + '/instances')
+        })
+        .then(response => {
+          var instance = response.data[Math.floor(response.data.length / 2)];
+
+          axios
+            .post('../../servers/' + app.activeServer + '/get', {
+              'Uri' : ('/studies/' + app.currentStudy + '/series/' + 
+                       series[DICOM_TAG_SERIES_INSTANCE_UID].Value + '/instances/' +
+                       instance[DICOM_TAG_SOP_INSTANCE_UID].Value + '/rendered')
+            }, {
+              responseType: 'arraybuffer'
+            })
+            .then(response => {
+              // https://github.com/axios/axios/issues/513
+              var image = btoa(new Uint8Array(response.data)
+                               .reduce((data, byte) => data + String.fromCharCode(byte), ''));
+              app.preview = ("data:" + 
+                             response.headers['content-type'].toLowerCase() + 
+                             ";base64," + image);
+              app.previewFailure = false;
+            })
+            .catch(response => {
+              app.previewFailure = true;
+            })
+              .finally(function() {
+                app.$refs['series-preview'].show();
+              })
+        })
+    },
+    ConfirmDeleteSeries: function(series) {
+      app.seriesToDelete = series;
+      app.$bvModal.show('series-delete-confirm');
+    },
+    ExecuteDeleteSeries: function(series) {
+      axios
+        .post('../../servers/' + app.activeServer + '/delete', {
+          'Level': 'Series',
+          'StudyInstanceUID': app.currentStudy,
+          'SeriesInstanceUID': app.seriesToDelete[DICOM_TAG_SERIES_INSTANCE_UID].Value
+        })
+        .then(app.LoadSeriesOfCurrentStudy)
+        .catch(app.ShowErrorModal)
+    }
+  },
+
+  updated: function () {
+    this.$nextTick(function () {
+      // Code that will run only after the
+      // entire view has been re-rendered
+
+      if (app.scrollToStudies) {
+        app.scrollToStudies = false;
+        app.ScrollToRef('studies-top');
+      }
+
+      if (app.scrollToSeries) {
+        app.scrollToSeries = false;
+        app.ScrollToRef('series-top');
+      }
+    })
+  }
+});


=====================================
WebApplication/index.html
=====================================
@@ -0,0 +1,261 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+    <title>Orthanc - DICOMweb client</title>
+
+    <!-- Add Bootstrap and Bootstrap-Vue CSS to the <head> section -->
+    <link type="text/css" rel="stylesheet" href="../libs/css/bootstrap.min.css"/>
+    <link type="text/css" rel="stylesheet" href="../libs/css/bootstrap-vue.min.css"/>
+    <link type="text/css" rel="stylesheet" href="../libs/css/font-awesome.min.css"/>
+    
+    <script src="../libs/js/polyfill.min.js"></script>
+   
+    <!-- CSS style to truncate long text in tables, provided they have
+         class "table-layout:fixed;" or attribute ":fixed=true" -->
+    <style>
+      table td { 
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      }
+    </style>
+    
+  </head>
+  <body>
+    <div class="container" id="app">
+      <p style="height:1em"></p>
+
+      <div class="jumbotron">
+        <div class="row">
+          <div class="col-sm-8">
+            <h1 class="display-4">DICOMweb client</h1>
+            <p class="lead">
+              This is a simple client interface to the DICOMweb
+              servers that are configured in Orthanc. From this page,
+              you can search the content of remote DICOMweb servers
+              (QIDO-RS), then locally retrieve the DICOM
+              studies/series of interest
+              (WADO-RS). <a :href="orthancApiRoot"
+              target="_blank">Orthanc Explorer</a> can be used to send
+              DICOM resources to remote DICOMweb servers (STOW-RS).
+            </p>
+            <p>
+              <a class="btn btn-primary btn-lg"
+                 href="https://book.orthanc-server.com/plugins/dicomweb.html"
+                 target="_blank" role="button">Open documentation</a>
+              <a class="btn btn-primary btn-lg"
+                 :href="orthancApiRoot"
+                 target="_blank" role="button">Open Orthanc Explorer</a>
+            </p>
+          </div>
+          <div class="col-sm-4">
+            <a href="http://www.orthanc-server.com/" target="_blank">
+              <img class="img-fluid" alt="Orthanc" src="../libs/img/OrthancLogo.png" />
+            </a>
+          </div>
+        </div>
+      </div>
+
+
+      <b-modal ref="modal-error" size="xl" ok-only="true">
+        <template slot="modal-title">
+          Connection error
+        </template>
+        <div class="d-block">
+          <p>
+            There was an error connecting to "{{ activeServer }}" server.
+          </p>
+        </div>
+      </b-modal>
+
+      
+      <!-- LOOKUP -->
+
+      <div class="row">
+        <b-alert variant="danger" dismissible v-model="showNoServer">
+          No DICOMweb server is configured!
+        </b-alert>
+        <b-form style="width:100%;padding:5px;">
+          <b-form-group label="DICOMweb server:" label-cols-sm="4" label-cols-lg="3">
+            <b-form-select v-model="lookup.server" :options="servers"></b-form-select>
+          </b-form-group>
+          <b-form-group label="Patient ID:" label-cols-sm="4" label-cols-lg="3">
+            <b-form-input v-model="lookup.patientID"></b-form-input>
+          </b-form-group>
+          <b-form-group label="Patient name:" label-cols-sm="4" label-cols-lg="3">
+            <b-form-input v-model="lookup.patientName"></b-form-input>
+          </b-form-group>
+          <b-form-group label="Accession number:" label-cols-sm="4" label-cols-lg="3">
+            <b-form-input v-model="lookup.accessionNumber"></b-form-input>
+          </b-form-group>
+          <b-form-group label="Study date:" label-cols-sm="4" label-cols-lg="3">
+            <b-form-input v-model="lookup.studyDate"></b-form-input>
+          </b-form-group>
+          <p class="pull-right">
+          <b-button type="submit" variant="success" @click="OnLookup" 
+                    size="lg">Do lookup</b-button>
+          <b-button type="reset" variant="outline-danger" @click="OnReset" 
+                    size="lg">Reset</b-button>
+          </p>
+        </b-form>
+      </div>
+
+
+      <!-- STUDIES -->
+
+      <hr v-show="showStudies" ref="studies-top" />
+      <div class="row" v-show="showStudies">
+        <h1>Studies</h1>
+      </div>
+      <div class="row" v-show="showStudies">
+        <b-alert variant="warning" dismissible v-model="showTruncatedStudies">
+          More than {{ maxResults }} matching studies, results have been truncated!
+        </b-alert>
+      </div>
+      <div class="row" v-show="showStudies">
+        <b-pagination v-model="currentPage" :per-page="perPage" :total-rows="studiesCount"></b-pagination>
+        <b-table striped hover :current-page="currentPage" :per-page="perPage"
+                 :items="studies" :fields="studiesFields" :fixed="false">
+          <template slot="operations" slot-scope="data">
+            <b-button @click="OpenSeries(data.item)" title="Open series">
+              <i class="fa fa-folder-open"></i>
+            </b-button>
+            <b-button @click="OpenStudyDetails(data.item)" title="Open tags">
+              <i class="fa fa-address-card"></i>
+            </b-button>
+            <b-button @click="RetrieveStudy(data.item)" title="Retrieve study using WADO-RS">
+              <i class="fa fa-cloud-download"></i>
+            </b-button>
+            <b-button @click="ConfirmDeleteStudy(data.item)"
+                      v-if="serversInfo[activeServer].HasDelete == '1'" title="Delete remote study">
+              <i class="fa fa-trash"></i>
+            </b-button>
+          </template>
+        </b-table>
+
+        <b-modal ref="study-details" size="xl" ok-only="true">
+          <template slot="modal-title">
+            Details of study
+          </template>
+          <div class="d-block text-center">
+            <b-table striped :items="studyTags" :fields="studyTagsFields" :fixed="true">
+            </b-table>
+          </div>
+        </b-modal>
+
+        <b-modal id="study-delete-confirm" size="xl" @ok="ExecuteDeleteStudy">
+          <template slot="modal-title">
+            Confirm deletion
+          </template>
+          <div class="d-block">
+            <p>
+              Are you sure you want to remove this study from the remote server?
+            </p>
+            <p>
+              Patient name: {{ studyToDelete && studyToDelete['00100010'] && studyToDelete['00100010'].Value }}
+            </p>
+          </div>
+        </b-modal>
+      </div>
+
+
+      <!-- SERIES -->
+
+      <hr  v-show="showSeries" ref="series-top" />
+      <div class="row" v-show="showSeries">
+        <h1>Series</h1>
+      </div>
+      <div class="row" v-show="showSeries">
+        <b-table striped hover :items="series" :fields="seriesFields" :fixed="false">
+          <template slot="operations" slot-scope="data">
+            <b-button @click="OpenSeriesPreview(data.item)" title="Preview">
+              <i class="fa fa-eye"></i>
+            </b-button>
+            <b-button @click="OpenSeriesDetails(data.item)" title="Open tags">
+              <i class="fa fa-address-card"></i>
+            </b-button>
+            <b-button @click="RetrieveSeries(data.item)" title="Retrieve series using WADO-RS">
+              <i class="fa fa-cloud-download"></i>
+            </b-button>
+            <b-button @click="ConfirmDeleteSeries(data.item)"
+                      v-if="serversInfo[activeServer].HasDelete" title="Delete remote series">
+              <i class="fa fa-trash"></i>
+            </b-button>
+          </template>
+        </b-table>
+
+        <b-modal ref="series-details" size="xl" ok-only="true">
+          <template slot="modal-title">
+            Details of series
+          </template>
+          <div class="d-block text-center">
+            <b-table striped :items="seriesTags" :fields="seriesTagsFields" :fixed="true">
+            </b-table>
+          </div>
+        </b-modal>
+
+        <b-modal ref="series-preview" size="xl" ok-only="true">
+          <template slot="modal-title">
+            Preview of series
+          </template>
+          <div class="d-block text-center">
+            <b-alert variant="danger" v-model="previewFailure">
+              The remote DICOMweb server cannot generate a preview for this image.
+            </b-alert>
+            <b-img v-if="!previewFailure" :src="preview" fluid alt=""></b-img>
+          </div>
+        </b-modal>
+
+        <b-modal id="series-delete-confirm" size="xl" @ok="ExecuteDeleteSeries">
+          <template slot="modal-title">
+            Confirm deletion
+          </template>
+          <div class="d-block">
+            <p>
+              Are you sure you want to remove this series from the remote server?
+            </p>
+            <p>
+              Series description: {{ seriesToDelete && seriesToDelete['0008103E'] && seriesToDelete['0008103E'].Value }}
+            </p>
+          </div>
+        </b-modal>
+      </div>
+
+
+      <b-modal ref="retrieve-job" size="xl" ok-only="true">
+        <template slot="modal-title">
+          Retrieving {{ jobLevel }}
+        </template>
+        <div class="d-block">
+          <p>
+            Orthanc is now running a background job to retrieve the
+            {{ jobLevel }} from remote server "{{ activeServer }}" using
+            WADO-RS.
+          </p>
+          <p>
+            Job ID: <tt>{{ jobId }}</tt>
+          </p>
+          <p>
+            Job details:
+          </p>
+          <pre>{{ jobDetails }}</pre>
+          <p>
+            <b-button variant="success" @click="RefreshJobDetails()">Refresh job details</b-button>
+          </p>
+        </div>
+      </b-modal>
+
+
+      <p style="height:5em"></p>
+    </div>
+
+    <!-- Add Vue and Bootstrap-Vue JS just before the closing </body> tag -->
+    <script src="../libs/js/vue.min.js"></script>
+    <script src="../libs/js/bootstrap-vue.min.js"></script>
+    <script src="../libs/js/axios.min.js"></script>
+    <script type="text/javascript" src="app.js"></script>
+  </body>
+</html>



View it on GitLab: https://salsa.debian.org/med-team/orthanc-dicomweb/commit/fd1245b9b49e4270b47c106441aacd0381261d8f

-- 
View it on GitLab: https://salsa.debian.org/med-team/orthanc-dicomweb/commit/fd1245b9b49e4270b47c106441aacd0381261d8f
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20190731/ec0215af/attachment-0001.html>


More information about the debian-med-commit mailing list