changeset 2867:251614c2edac

DicomMoveScuJob
author Sebastien Jodogne <s.jodogne@gmail.com>
date Mon, 08 Oct 2018 16:08:51 +0200
parents 437e6ba20a5e
children abce036683cd
files CMakeLists.txt Core/JobsEngine/JobsRegistry.cpp Core/JobsEngine/JobsRegistry.h NEWS OrthancServer/OrthancRestApi/OrthancRestAnonymizeModify.cpp OrthancServer/OrthancRestApi/OrthancRestApi.cpp OrthancServer/OrthancRestApi/OrthancRestApi.h OrthancServer/OrthancRestApi/OrthancRestArchive.cpp OrthancServer/OrthancRestApi/OrthancRestModalities.cpp OrthancServer/QueryRetrieveHandler.cpp OrthancServer/QueryRetrieveHandler.h OrthancServer/ServerJobs/DicomMoveScuJob.cpp OrthancServer/ServerJobs/DicomMoveScuJob.h OrthancServer/ServerJobs/OrthancJobUnserializer.cpp Resources/CMake/OrthancFrameworkParameters.cmake UnitTestsSources/MultiThreadingTests.cpp
diffstat 16 files changed, 547 insertions(+), 87 deletions(-) [+]
line wrap: on
line diff
--- a/CMakeLists.txt	Mon Oct 08 11:40:31 2018 +0200
+++ b/CMakeLists.txt	Mon Oct 08 16:08:51 2018 +0200
@@ -84,6 +84,7 @@
   OrthancServer/ServerIndex.cpp
   OrthancServer/ServerJobs/ArchiveJob.cpp
   OrthancServer/ServerJobs/DicomModalityStoreJob.cpp
+  OrthancServer/ServerJobs/DicomMoveScuJob.cpp
   OrthancServer/ServerJobs/LuaJobManager.cpp
   OrthancServer/ServerJobs/MergeStudyJob.cpp
   OrthancServer/ServerJobs/Operations/DeleteResourceOperation.cpp
--- a/Core/JobsEngine/JobsRegistry.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/Core/JobsEngine/JobsRegistry.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -685,22 +685,53 @@
   }
 
 
-  bool JobsRegistry::SubmitAndWait(IJob* job,        // Takes ownership
+  bool JobsRegistry::SubmitAndWait(Json::Value& successContent,
+                                   IJob* job,        // Takes ownership
                                    int priority)
   {
     std::string id;
     Submit(id, job, priority);
 
-    JobState state = JobState_Pending;
+    JobState state = JobState_Pending;  // Dummy initialization
 
     {
       boost::mutex::scoped_lock lock(mutex_);
 
-      while (GetStateInternal(state, id) &&
-             state != JobState_Success &&
-             state != JobState_Failure)
+      for (;;)
       {
-        someJobComplete_.wait(lock);
+        if (!GetStateInternal(state, id))
+        {
+          // Job has finished and has been lost (should not happen)
+          state = JobState_Failure;
+          break;
+        }
+        else if (state == JobState_Failure)
+        {
+          // Failure
+          break;
+        }
+        else if (state == JobState_Success)
+        {
+          // Success, try and retrieve the status of the job
+          JobsIndex::const_iterator it = jobsIndex_.find(id);
+          if (it == jobsIndex_.end())
+          {
+            // Should not happen
+            state = JobState_Failure;
+          }
+          else
+          {
+            const JobStatus& status = it->second->GetLastStatus();
+            successContent = status.GetPublicContent();
+          }
+          
+          break;
+        }
+        else
+        {
+          // This job has not finished yet, wait for new completion
+          someJobComplete_.wait(lock);
+        }
       }
     }
 
--- a/Core/JobsEngine/JobsRegistry.h	Mon Oct 08 11:40:31 2018 +0200
+++ b/Core/JobsEngine/JobsRegistry.h	Mon Oct 08 16:08:51 2018 +0200
@@ -161,7 +161,8 @@
     void Submit(IJob* job,        // Takes ownership
                 int priority);
 
-    bool SubmitAndWait(IJob* job,        // Takes ownership
+    bool SubmitAndWait(Json::Value& successContent,
+                       IJob* job,        // Takes ownership
                        int priority);
     
     bool SetPriority(const std::string& id,
--- a/NEWS	Mon Oct 08 11:40:31 2018 +0200
+++ b/NEWS	Mon Oct 08 16:08:51 2018 +0200
@@ -11,6 +11,7 @@
 Maintenance
 -----------
 
+* Executing a query/retrieve from the REST API now creates a job
 * Fix: Closing DICOM associations after running query/retrieve from REST API
 
 
--- a/OrthancServer/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -164,7 +164,9 @@
 
     context.AddChildInstances(*job, call.GetUriComponent("id", ""));
     
-    if (context.GetJobsEngine().GetRegistry().SubmitAndWait(job.release(), priority))
+    Json::Value publicContent;
+    if (context.GetJobsEngine().GetRegistry().SubmitAndWait
+        (publicContent, job.release(), priority))
     {
       Json::Value json;
       if (output->Format(json))
--- a/OrthancServer/OrthancRestApi/OrthancRestApi.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/OrthancRestApi/OrthancRestApi.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -35,6 +35,7 @@
 #include "OrthancRestApi.h"
 
 #include "../../Core/Logging.h"
+#include "../../Core/SerializationToolbox.h"
 #include "../ServerContext.h"
 
 namespace Orthanc
@@ -139,4 +140,102 @@
   {
     return GetContext(call).GetIndex();
   }
+
+
+
+  static const char* KEY_PERMISSIVE = "Permissive";
+  static const char* KEY_PRIORITY = "Priority";
+  static const char* KEY_SYNCHRONOUS = "Synchronous";
+  static const char* KEY_ASYNCHRONOUS = "Asynchronous";
+  
+  void OrthancRestApi::SubmitCommandsJob(RestApiPostCall& call,
+                                         SetOfCommandsJob* job,
+                                         bool isDefaultSynchronous,
+                                         const Json::Value& body) const
+  {
+    std::auto_ptr<SetOfCommandsJob> raii(job);
+    
+    if (job == NULL)
+    {
+      throw OrthancException(ErrorCode_NullPointer);
+    }
+
+    if (body.type() != Json::objectValue)
+    {
+      throw OrthancException(ErrorCode_BadFileFormat);
+    }
+
+    job->SetDescription("REST API");
+    
+    if (body.isMember(KEY_PERMISSIVE))
+    {
+      job->SetPermissive(SerializationToolbox::ReadBoolean(body, KEY_PERMISSIVE));
+    }
+    else
+    {
+      job->SetPermissive(false);
+    }
+
+    int priority = 0;
+
+    if (body.isMember(KEY_PRIORITY))
+    {
+      priority = SerializationToolbox::ReadInteger(body, KEY_PRIORITY);
+    }
+
+    bool synchronous = isDefaultSynchronous;
+    
+    if (body.isMember(KEY_SYNCHRONOUS))
+    {
+      synchronous = SerializationToolbox::ReadBoolean(body, KEY_SYNCHRONOUS);
+    }
+    else if (body.isMember(KEY_ASYNCHRONOUS))
+    {
+      synchronous = !SerializationToolbox::ReadBoolean(body, KEY_ASYNCHRONOUS);
+    }
+
+    if (synchronous)
+    {
+      Json::Value successContent;
+      if (context_.GetJobsEngine().GetRegistry().SubmitAndWait
+          (successContent, raii.release(), priority))
+      {
+        // Success in synchronous execution
+        call.GetOutput().AnswerJson(successContent);
+      }
+      else
+      {
+        // Error during synchronous execution
+        call.GetOutput().SignalError(HttpStatus_500_InternalServerError);
+      }
+    }
+    else
+    {
+      // Asynchronous mode: Submit the job, but don't wait for its completion
+      std::string id;
+      context_.GetJobsEngine().GetRegistry().Submit(id, raii.release(), priority);
+
+      Json::Value v;
+      v["ID"] = id;
+      v["Path"] = "/jobs/" + id;
+      call.GetOutput().AnswerJson(v);
+    }
+  }
+  
+
+  void OrthancRestApi::SubmitCommandsJob(RestApiPostCall& call,
+                                         SetOfCommandsJob* job,
+                                         bool isDefaultSynchronous) const
+  {
+    std::auto_ptr<SetOfCommandsJob> raii(job);
+    
+    Json::Value body;
+    
+    if (!call.ParseJsonRequest(body))
+    {
+      body = Json::objectValue;
+    }
+
+    SubmitCommandsJob(call, raii.release(), isDefaultSynchronous, body);
+  }
 }
--- a/OrthancServer/OrthancRestApi/OrthancRestApi.h	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/OrthancRestApi/OrthancRestApi.h	Mon Oct 08 16:08:51 2018 +0200
@@ -33,6 +33,7 @@
 
 #pragma once
 
+#include "../../Core/JobsEngine/SetOfCommandsJob.h"
 #include "../../Core/RestApi/RestApi.h"
 #include "../../Core/DicomParsing/DicomModification.h"
 #include "../ServerEnumerations.h"
@@ -96,5 +97,14 @@
                               const std::string& publicId,
                               ResourceType resourceType,
                               StoreStatus status) const;
+
+    void SubmitCommandsJob(RestApiPostCall& call,
+                           SetOfCommandsJob* job,
+                           bool isDefaultSynchronous,
+                           const Json::Value& body) const;
+
+    void SubmitCommandsJob(RestApiPostCall& call,
+                           SetOfCommandsJob* job,
+                           bool isDefaultSynchronous) const;
   };
 }
--- a/OrthancServer/OrthancRestApi/OrthancRestArchive.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/OrthancRestApi/OrthancRestArchive.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -78,7 +78,9 @@
 
     job->SetDescription("REST API");
 
-    if (context.GetJobsEngine().GetRegistry().SubmitAndWait(job.release(), 0 /* TODO priority */))
+    Json::Value publicContent;
+    if (context.GetJobsEngine().GetRegistry().SubmitAndWait
+        (publicContent, job.release(), 0 /* TODO priority */))
     {
       // The archive is now created: Prepare the sending of the ZIP file
       FilesystemHttpSender sender(tmp->GetPath());
--- a/OrthancServer/OrthancRestApi/OrthancRestModalities.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/OrthancRestApi/OrthancRestModalities.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -36,9 +36,11 @@
 
 #include "../../Core/DicomParsing/FromDcmtkBridge.h"
 #include "../../Core/Logging.h"
+#include "../../Core/SerializationToolbox.h"
 #include "../OrthancInitialization.h"
 #include "../QueryRetrieveHandler.h"
 #include "../ServerJobs/DicomModalityStoreJob.h"
+#include "../ServerJobs/DicomMoveScuJob.h"
 #include "../ServerJobs/OrthancPeerStoreJob.h"
 #include "../ServerToolbox.h"
 
@@ -470,9 +472,9 @@
       {
       }                     
 
-      QueryRetrieveHandler* operator->()
+      QueryRetrieveHandler& GetHandler() const
       {
-        return &handler_;
+        return handler_;
       }
     };
 
@@ -490,7 +492,7 @@
   static void ListQueryAnswers(RestApiGetCall& call)
   {
     QueryAccessor query(call);
-    size_t count = query->GetAnswerCount();
+    size_t count = query.GetHandler().GetAnswersCount();
 
     Json::Value result = Json::arrayValue;
     for (size_t i = 0; i < count; i++)
@@ -509,62 +511,92 @@
     QueryAccessor query(call);
 
     DicomMap map;
-    query->GetAnswer(map, index);
+    query.GetHandler().GetAnswer(map, index);
 
     AnswerDicomMap(call, map, call.HasArgument("simplify"));
   }
 
 
+  static void SubmitRetrieveJob(RestApiPostCall& call,
+                                bool allAnswers,
+                                size_t index)
+  {
+    ServerContext& context = OrthancRestApi::GetContext(call);
+
+    std::string targetAet;
+    
+    Json::Value body;
+    if (call.ParseJsonRequest(body))
+    {
+      targetAet = SerializationToolbox::ReadString(body, "TargetAet");
+    }
+    else
+    {
+      body = Json::objectValue;
+      call.BodyToString(targetAet);
+    }
+    
+    std::auto_ptr<DicomMoveScuJob> job(new DicomMoveScuJob(context));
+    
+    {
+      QueryAccessor query(call);
+      job->SetTargetAet(targetAet);
+      job->SetLocalAet(query.GetHandler().GetLocalAet());
+      job->SetRemoteModality(query.GetHandler().GetRemoteModality());
+
+      LOG(WARNING) << "Driving C-Move SCU on remote modality "
+                   << query.GetHandler().GetRemoteModality().GetApplicationEntityTitle()
+                   << " to target modality " << targetAet;
+
+      if (allAnswers)
+      {
+        for (size_t i = 0; i < query.GetHandler().GetAnswersCount(); i++)
+        {
+          job->AddFindAnswer(query.GetHandler(), i);
+        }
+      }
+      else
+      {
+        job->AddFindAnswer(query.GetHandler(), index);
+      }
+    }
+
+    OrthancRestApi::GetApi(call).SubmitCommandsJob
+      (call, job.release(), true /* synchronous by default */, body);
+  }
+  
+
   static void RetrieveOneAnswer(RestApiPostCall& call)
   {
     size_t index = boost::lexical_cast<size_t>(call.GetUriComponent("index", ""));
-
-    std::string modality;
-    call.BodyToString(modality);
-
-    LOG(WARNING) << "Driving C-Move SCU on modality: " << modality;
-
-    QueryAccessor query(call);
-    query->Retrieve(modality, index);
-
-    // Retrieve has succeeded
-    call.GetOutput().AnswerBuffer("{}", "application/json");
+    SubmitRetrieveJob(call, false, index);
   }
 
 
   static void RetrieveAllAnswers(RestApiPostCall& call)
   {
-    std::string modality;
-    call.BodyToString(modality);
-
-    LOG(WARNING) << "Driving C-Move SCU on modality: " << modality;
-
-    QueryAccessor query(call);
-    query->Retrieve(modality);
-
-    // Retrieve has succeeded
-    call.GetOutput().AnswerBuffer("{}", "application/json");
+    SubmitRetrieveJob(call, true, 0);
   }
 
 
   static void GetQueryArguments(RestApiGetCall& call)
   {
     QueryAccessor query(call);
-    AnswerDicomMap(call, query->GetQuery(), call.HasArgument("simplify"));
+    AnswerDicomMap(call, query.GetHandler().GetQuery(), call.HasArgument("simplify"));
   }
 
 
   static void GetQueryLevel(RestApiGetCall& call)
   {
     QueryAccessor query(call);
-    call.GetOutput().AnswerBuffer(EnumerationToString(query->GetLevel()), "text/plain");
+    call.GetOutput().AnswerBuffer(EnumerationToString(query.GetHandler().GetLevel()), "text/plain");
   }
 
 
   static void GetQueryModality(RestApiGetCall& call)
   {
     QueryAccessor query(call);
-    call.GetOutput().AnswerBuffer(query->GetModalitySymbolicName(), "text/plain");
+    call.GetOutput().AnswerBuffer(query.GetHandler().GetModalitySymbolicName(), "text/plain");
   }
 
 
@@ -594,7 +626,7 @@
     size_t index = boost::lexical_cast<size_t>(call.GetUriComponent("index", ""));
 
     DicomMap map;
-    query->GetAnswer(map, index);
+    query.GetHandler().GetAnswer(map, index);
 
     RestApi::AutoListChildren(call);
   }
@@ -708,6 +740,8 @@
 
     job->SetPermissive(permissive);
     
+    Json::Value publicContent;
+
     if (asynchronous)
     {
       // Asynchronous mode: Submit the job, but don't wait for its completion
@@ -718,7 +752,8 @@
       v["ID"] = id;
       call.GetOutput().AnswerJson(v);
     }
-    else if (context.GetJobsEngine().GetRegistry().SubmitAndWait(job.release(), priority))
+    else if (context.GetJobsEngine().GetRegistry().SubmitAndWait
+             (publicContent, job.release(), priority))
     {
       // Synchronous mode: We have submitted and waited for completion
       call.GetOutput().AnswerBuffer("{}", "application/json");
--- a/OrthancServer/QueryRetrieveHandler.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/QueryRetrieveHandler.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -138,7 +138,7 @@
   }
 
 
-  size_t QueryRetrieveHandler::GetAnswerCount()
+  size_t QueryRetrieveHandler::GetAnswersCount()
   {
     Run();
     return answers_.GetSize();
@@ -151,36 +151,4 @@
     Run();
     answers_.GetAnswer(i).ExtractDicomSummary(target);
   }
-
-
-  void QueryRetrieveHandler::RetrieveInternal(DicomUserConnection& connection,
-                                              const std::string& target,
-                                              size_t i)
-  {
-    DicomMap map;
-    GetAnswer(map, i);
-    connection.Move(target, map);
-  }
-
-
-  void QueryRetrieveHandler::Retrieve(const std::string& target,
-                                      size_t i)
-  {
-    DicomUserConnection connection(localAet_, modality_);
-    connection.Open();
-    
-    RetrieveInternal(connection, target, i);
-  }
-
-
-  void QueryRetrieveHandler::Retrieve(const std::string& target)
-  {
-    DicomUserConnection connection(localAet_, modality_);
-    connection.Open();
-        
-    for (size_t i = 0; i < GetAnswerCount(); i++)
-    {
-      RetrieveInternal(connection, target, i);
-    }
-  }
 }
--- a/OrthancServer/QueryRetrieveHandler.h	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/QueryRetrieveHandler.h	Mon Oct 08 16:08:51 2018 +0200
@@ -41,7 +41,7 @@
   {
   private:
     ServerContext&             context_;
-    const std::string&         localAet_;
+    std::string                localAet_;
     bool                       done_;
     RemoteModalityParameters   modality_;
     ResourceType               level_;
@@ -51,20 +51,21 @@
 
     void Invalidate();
 
-    void RetrieveInternal(DicomUserConnection& connection,
-                          const std::string& target,
-                          size_t i);
-
   public:
     QueryRetrieveHandler(ServerContext& context);
 
     void SetModality(const std::string& symbolicName);
 
-    const RemoteModalityParameters& GetModality() const
+    const RemoteModalityParameters& GetRemoteModality() const
     {
       return modality_;
     }
 
+    const std::string& GetLocalAet() const
+    {
+      return localAet_;
+    }
+
     const std::string& GetModalitySymbolicName() const
     {
       return modalityName_;
@@ -87,14 +88,9 @@
 
     void Run();
 
-    size_t GetAnswerCount();
+    size_t GetAnswersCount();
 
     void GetAnswer(DicomMap& target,
                    size_t i);
-
-    void Retrieve(const std::string& target,
-                  size_t i);
-
-    void Retrieve(const std::string& target);
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/ServerJobs/DicomMoveScuJob.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -0,0 +1,199 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2018 Osimis S.A., Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * In addition, as a special exception, the copyright holders of this
+ * program give permission to link the code of its release with the
+ * OpenSSL project's "OpenSSL" library (or with modified versions of it
+ * that use the same license as the "OpenSSL" library), and distribute
+ * the linked executables. You must obey the GNU General Public License
+ * in all respects for all of the code used other than "OpenSSL". If you
+ * modify file(s) with this exception, you may extend this exception to
+ * your version of the file(s), but you are not obligated to do so. If
+ * you do not wish to do so, delete this exception statement from your
+ * version. If you delete this exception statement from all source files
+ * in the program, then also delete it here.
+ * 
+ * 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
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "DicomMoveScuJob.h"
+
+#include "../../Core/SerializationToolbox.h"
+
+namespace Orthanc
+{
+  class DicomMoveScuJob::Command : public SetOfCommandsJob::ICommand
+  {
+  private:
+    DicomMoveScuJob&         that_;
+    std::auto_ptr<DicomMap>  findAnswer_;
+
+  public:
+    Command(DicomMoveScuJob& that,
+            const DicomMap&  findAnswer) :
+      that_(that),
+      findAnswer_(findAnswer.Clone())
+    {
+    }
+
+    virtual bool Execute()
+    {
+      that_.Retrieve(*findAnswer_);
+      return true;
+    }
+
+    virtual void Serialize(Json::Value& target) const
+    {
+      findAnswer_->Serialize(target);
+    }
+  };
+
+
+  class DicomMoveScuJob::Unserializer :
+    public SetOfCommandsJob::ICommandUnserializer
+  {
+  private:
+    DicomMoveScuJob&   that_;
+
+  public:
+    Unserializer(DicomMoveScuJob&  that) :
+      that_(that)
+    {
+    }
+
+    virtual ICommand* Unserialize(const Json::Value& source) const
+    {
+      DicomMap findAnswer;
+      findAnswer.Unserialize(source);
+      return new Command(that_, findAnswer);
+    }
+  };
+
+
+
+  void DicomMoveScuJob::Retrieve(const DicomMap& findAnswer)
+  {
+    if (connection_.get() == NULL)
+    {
+      connection_.reset(new DicomUserConnection(localAet_, remote_));
+      connection_->Open();
+    }
+    
+    connection_->Move(targetAet_, findAnswer);
+  }
+    
+
+  void DicomMoveScuJob::AddFindAnswer(const DicomMap& answer)
+  {
+    AddCommand(new Command(*this, answer));
+  }
+
+  
+  void DicomMoveScuJob::AddFindAnswer(QueryRetrieveHandler& query,
+                                      size_t i)
+  {
+    DicomMap answer;
+    query.GetAnswer(answer, i);
+    AddFindAnswer(answer);
+  }    
+
+
+  void DicomMoveScuJob::SetLocalAet(const std::string& aet)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      localAet_ = aet;
+    }
+  }
+
+  
+  void DicomMoveScuJob::SetTargetAet(const std::string& aet)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      targetAet_ = aet;
+    }
+  }
+
+  
+  void DicomMoveScuJob::SetRemoteModality(const RemoteModalityParameters& remote)
+  {
+    if (IsStarted())
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls);
+    }
+    else
+    {
+      remote_ = remote;
+    }
+  }
+
+  
+  void DicomMoveScuJob::Stop(JobStopReason reason)
+  {
+    connection_.reset();
+  }
+  
+
+  void DicomMoveScuJob::GetPublicContent(Json::Value& value)
+  {
+    SetOfCommandsJob::GetPublicContent(value);
+    
+    value["LocalAet"] = localAet_;
+    value["RemoteAet"] = remote_.GetApplicationEntityTitle();
+  }
+
+
+  static const char* LOCAL_AET = "LocalAet";
+  static const char* TARGET_AET = "TargetAet";
+  static const char* REMOTE = "Remote";
+
+  DicomMoveScuJob::DicomMoveScuJob(ServerContext& context,
+                                   const Json::Value& serialized) :
+    SetOfCommandsJob(new Unserializer(*this), serialized),
+    context_(context)
+  {
+    localAet_ = SerializationToolbox::ReadString(serialized, LOCAL_AET);
+    targetAet_ = SerializationToolbox::ReadString(serialized, TARGET_AET);
+    remote_ = RemoteModalityParameters(serialized[REMOTE]);
+  }
+
+  
+  bool DicomMoveScuJob::Serialize(Json::Value& target)
+  {
+    if (!SetOfCommandsJob::Serialize(target))
+    {
+      return false;
+    }
+    else
+    {
+      target[LOCAL_AET] = localAet_;
+      target[TARGET_AET] = targetAet_;
+      remote_.Serialize(target[REMOTE]);
+      return true;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancServer/ServerJobs/DicomMoveScuJob.h	Mon Oct 08 16:08:51 2018 +0200
@@ -0,0 +1,104 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2018 Osimis S.A., Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * In addition, as a special exception, the copyright holders of this
+ * program give permission to link the code of its release with the
+ * OpenSSL project's "OpenSSL" library (or with modified versions of it
+ * that use the same license as the "OpenSSL" library), and distribute
+ * the linked executables. You must obey the GNU General Public License
+ * in all respects for all of the code used other than "OpenSSL". If you
+ * modify file(s) with this exception, you may extend this exception to
+ * your version of the file(s), but you are not obligated to do so. If
+ * you do not wish to do so, delete this exception statement from your
+ * version. If you delete this exception statement from all source files
+ * in the program, then also delete it here.
+ * 
+ * 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
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "../../Core/JobsEngine/SetOfCommandsJob.h"
+#include "../../Core/DicomNetworking/DicomUserConnection.h"
+
+#include "../QueryRetrieveHandler.h"
+#include "../ServerContext.h"
+
+namespace Orthanc
+{
+  class DicomMoveScuJob : public SetOfCommandsJob
+  {
+  private:
+    class Command;
+    class Unserializer;
+    
+    ServerContext&                      context_;
+    std::string                         localAet_;
+    std::string                         targetAet_;
+    RemoteModalityParameters            remote_;
+    std::auto_ptr<DicomUserConnection>  connection_;
+
+    void Retrieve(const DicomMap& findAnswer);
+    
+  public:
+    DicomMoveScuJob(ServerContext& context) :
+      context_(context)
+    {
+    }
+
+    DicomMoveScuJob(ServerContext& context,
+                    const Json::Value& serialized);
+
+    void AddFindAnswer(const DicomMap& answer);
+    
+    void AddFindAnswer(QueryRetrieveHandler& query,
+                       size_t i);
+    
+    const std::string& GetLocalAet() const
+    {
+      return localAet_;
+    }
+
+    void SetLocalAet(const std::string& aet);
+
+    const std::string& GetTargetAet() const
+    {
+      return targetAet_;
+    }
+
+    void SetTargetAet(const std::string& aet);
+
+    const RemoteModalityParameters& GetRemoteModality() const
+    {
+      return remote_;
+    }
+
+    void SetRemoteModality(const RemoteModalityParameters& remote);
+
+    virtual void Stop(JobStopReason reason);
+
+    virtual void GetJobType(std::string& target)
+    {
+      target = "DicomMoveScu";
+    }
+
+    virtual void GetPublicContent(Json::Value& value);
+
+    virtual bool Serialize(Json::Value& target);
+  };
+}
--- a/OrthancServer/ServerJobs/OrthancJobUnserializer.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/OrthancServer/ServerJobs/OrthancJobUnserializer.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -46,6 +46,7 @@
 #include "Operations/SystemCallOperation.h"
 
 #include "DicomModalityStoreJob.h"
+#include "DicomMoveScuJob.h"
 #include "OrthancPeerStoreJob.h"
 #include "ResourceModificationJob.h"
 #include "MergeStudyJob.h"
@@ -88,6 +89,10 @@
     {
       return new SplitStudyJob(context_, source);
     }
+    else if (type == "DicomMoveScu")
+    {
+      return new DicomMoveScuJob(context_, source);
+    }
     else
     {
       return GenericJobUnserializer::UnserializeJob(source);
--- a/Resources/CMake/OrthancFrameworkParameters.cmake	Mon Oct 08 11:40:31 2018 +0200
+++ b/Resources/CMake/OrthancFrameworkParameters.cmake	Mon Oct 08 16:08:51 2018 +0200
@@ -17,7 +17,7 @@
 # Version of the Orthanc API, can be retrieved from "/system" URI in
 # order to check whether new URI endpoints are available even if using
 # the mainline version of Orthanc
-set(ORTHANC_API_VERSION "1.1")
+set(ORTHANC_API_VERSION "1.2")
 
 
 #####################################################################
--- a/UnitTestsSources/MultiThreadingTests.cpp	Mon Oct 08 11:40:31 2018 +0200
+++ b/UnitTestsSources/MultiThreadingTests.cpp	Mon Oct 08 16:08:51 2018 +0200
@@ -716,8 +716,14 @@
   engine.SetWorkersCount(3);
   engine.Start();
 
-  ASSERT_TRUE(engine.GetRegistry().SubmitAndWait(new DummyJob(), rand() % 10));
-  ASSERT_FALSE(engine.GetRegistry().SubmitAndWait(new DummyJob(true), rand() % 10));
+  Json::Value content = Json::nullValue;
+  ASSERT_TRUE(engine.GetRegistry().SubmitAndWait(content, new DummyJob(), rand() % 10));
+  ASSERT_EQ(Json::objectValue, content.type());
+  ASSERT_EQ("world", content["hello"].asString());
+
+  content = Json::nullValue;
+  ASSERT_FALSE(engine.GetRegistry().SubmitAndWait(content, new DummyJob(true), rand() % 10));
+  ASSERT_EQ(Json::nullValue, content.type());
 
   engine.Stop();
 }