view OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp @ 5911:bfae0fc2ea1b get-scu-test

Started to work on handling errors as warnings when trying to store instances whose SOPClassUID has not been accepted during the negotiation. Work to be finalized later
author Alain Mazy <am@orthanc.team>
date Mon, 09 Dec 2024 10:07:19 +0100
parents cf1b46bf5ee1
children
line wrap: on
line source

/**
 * Orthanc - A Lightweight, RESTful DICOM Store
 * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
 * Department, University Hospital of Liege, Belgium
 * Copyright (C) 2017-2023 Osimis S.A., Belgium
 * Copyright (C) 2024-2024 Orthanc Team SRL, Belgium
 * Copyright (C) 2021-2024 Sebastien Jodogne, ICTEAM UCLouvain, 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.
 * 
 * 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 "../PrecompiledHeadersServer.h"
#include "OrthancRestApi.h"

#include "../../../OrthancFramework/Sources/Cache/SharedArchive.h"
#include "../../../OrthancFramework/Sources/DicomNetworking/DicomAssociation.h"
#include "../../../OrthancFramework/Sources/DicomNetworking/DicomControlUserConnection.h"
#include "../../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
#include "../../../OrthancFramework/Sources/Logging.h"
#include "../../../OrthancFramework/Sources/SerializationToolbox.h"

#include "../OrthancConfiguration.h"
#include "../QueryRetrieveHandler.h"
#include "../ServerContext.h"
#include "../ServerJobs/DicomModalityStoreJob.h"
#include "../ServerJobs/DicomMoveScuJob.h"
#include "../ServerJobs/DicomGetScuJob.h"
#include "../ServerJobs/OrthancPeerStoreJob.h"
#include "../ServerToolbox.h"
#include "../StorageCommitmentReports.h"


namespace Orthanc
{
  static const char* const KEY_LEVEL = "Level";
  static const char* const KEY_LOCAL_AET = "LocalAet";
  static const char* const KEY_CALLED_AET = "CalledAet";
  static const char* const KEY_HOST = "Host";
  static const char* const KEY_PORT = "Port";
  static const char* const KEY_NORMALIZE = "Normalize";
  static const char* const KEY_QUERY = "Query";
  static const char* const KEY_RESOURCES = "Resources";
  static const char* const KEY_TARGET_AET = "TargetAet";
  static const char* const KEY_TIMEOUT = "Timeout";
  static const char* const KEY_CHECK_FIND = "CheckFind";
  static const char* const SOP_CLASS_UID = "SOPClassUID";
  static const char* const SOP_INSTANCE_UID = "SOPInstanceUID";
  static const char* const KEY_RETRIEVE_METHOD = "RetrieveMethod";
  
  static RemoteModalityParameters MyGetModalityUsingSymbolicName(const std::string& name)
  {
    OrthancConfiguration::ReaderLock lock;
    return lock.GetConfiguration().GetModalityUsingSymbolicName(name);
  }


  static void InjectAssociationTimeout(DicomAssociationParameters& params,
                                       const Json::Value& body)
  {
    if (body.type() == Json::objectValue &&
        body.isMember(KEY_TIMEOUT))
    {
      // New in Orthanc 1.7.0
      params.SetTimeout(SerializationToolbox::ReadUnsignedInteger(body, KEY_TIMEOUT));
    }
  }

  static DicomAssociationParameters GetAssociationParameters(RestApiPostCall& call,
                                                             const Json::Value& body)
  {   
    const std::string& localAet =
      OrthancRestApi::GetContext(call).GetDefaultLocalApplicationEntityTitle();
    const RemoteModalityParameters remote =
      MyGetModalityUsingSymbolicName(call.GetUriComponent("id", ""));

    DicomAssociationParameters params(localAet, remote);
    InjectAssociationTimeout(params, body);
    
    return params;
  }


  static DicomAssociationParameters GetAssociationParameters(RestApiPostCall& call)
  {
    Json::Value body;

    if (!call.ParseJsonRequest(body))
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Cannot parse the JSON body");
    }
      
    return GetAssociationParameters(call, body);
  }
  

  static void DocumentModalityParametersShared(RestApiCall& call,
                                               bool includePermissions)
  {
    call.GetDocumentation()
      .SetRequestField("AET", RestApiCallDocumentation::Type_String,
                       "AET of the remote DICOM modality", true)
      .SetRequestField("Host", RestApiCallDocumentation::Type_String,
                       "Host address of the remote DICOM modality (typically, an IP address)", true)
      .SetRequestField("Port", RestApiCallDocumentation::Type_Number,
                       "TCP port of the remote DICOM modality", true)
      .SetRequestField("Manufacturer", RestApiCallDocumentation::Type_String, "Manufacturer of the remote DICOM "
                       "modality (check configuration option `DicomModalities` for possible values", false)
      .SetRequestField("UseDicomTls", RestApiCallDocumentation::Type_Boolean, "Whether to use DICOM TLS "
                       "in the SCU connection initiated by Orthanc (new in Orthanc 1.9.0)", false)
      .SetRequestField(KEY_LOCAL_AET, RestApiCallDocumentation::Type_String, "Whether to override the default DicomAet "
                       "in the SCU connection initiated by Orthanc to this modality", false)
      .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number, "Whether to override the default DicomScuTimeout "
                       "in the SCU connection initiated by Orthanc to this modality", false);

    if (includePermissions)
    {
      call.GetDocumentation()
        .SetRequestField("AllowEcho", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept C-ECHO SCU commands issued by the remote modality", false)
        .SetRequestField("AllowStore", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept C-STORE SCU commands issued by the remote modality", false)
        .SetRequestField("AllowFind", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept C-FIND SCU commands issued by the remote modality", false)
        .SetRequestField("AllowFindWorklist", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept C-FIND SCU commands for worklists issued by the remote modality", false)
        .SetRequestField("AllowMove", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept C-MOVE SCU commands issued by the remote modality", false)
        .SetRequestField("AllowGet", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept C-GET SCU commands issued by the remote modality", false)
        .SetRequestField("AllowStorageCommitment", RestApiCallDocumentation::Type_Boolean,
                         "Whether to accept storage commitment requests issued by the remote modality", false)
        .SetRequestField("AllowTranscoding", RestApiCallDocumentation::Type_Boolean,
                         "Whether to allow transcoding for operations initiated by this modality. "
                         "This option applies to Orthanc C-GET SCP and to Orthanc C-STORE SCU. "
                         "It only has an effect if the global option `EnableTranscoding` is set to `true`.", false);
    }
  }


  /***************************************************************************
   * DICOM C-Echo SCU
   ***************************************************************************/

  static void ExecuteEcho(RestApiOutput& output,
                          const DicomAssociationParameters& parameters,
                          const Json::Value& body)
  {
    bool checkFind = false;
    
    if (body.type() == Json::objectValue &&
        body.isMember(KEY_CHECK_FIND))
    {
      checkFind = SerializationToolbox::ReadBoolean(body, KEY_CHECK_FIND);
    }
    else
    {
      OrthancConfiguration::ReaderLock lock;
      checkFind = lock.GetConfiguration().GetBooleanParameter("DicomEchoChecksFind", false);
    }

    ScuOperationFlags operations = ScuOperationFlags_Echo;
    
    if (checkFind)
    {
      operations = static_cast<ScuOperationFlags>(operations | ScuOperationFlags_Find);
    }

    DicomControlUserConnection connection(parameters, operations);
    if (connection.Echo())
    {

      if (checkFind)
      {
        // Issue a C-FIND request at the study level about a random Study Instance UID
        const std::string studyInstanceUid = FromDcmtkBridge::GenerateUniqueIdentifier(ResourceType_Study);
        
        DicomMap query;
        query.SetValue(DICOM_TAG_STUDY_INSTANCE_UID, studyInstanceUid, false);

        DicomFindAnswers answers(false /* not a worklist */);

        // The following line throws an exception if the remote modality doesn't support C-FIND
        connection.Find(answers, ResourceType_Study, query, false /* normalize */);
      }

      // Echo has succeeded
      output.AnswerBuffer("{}", MimeType_Json);
    }
    else
    {
      // Echo has failed
      output.SignalError(HttpStatus_500_InternalServerError);
    }
  }


  static void DocumentEchoShared(RestApiPostCall& call)
  {
    call.GetDocumentation()
      .SetRequestField(KEY_CHECK_FIND, RestApiCallDocumentation::Type_Boolean,
                       "Issue a dummy C-FIND command after the C-GET SCU, in order to check whether the remote "
                       "modality knows about Orthanc. This field defaults to the value of the `DicomEchoChecksFind` "
                       "configuration option. New in Orthanc 1.8.1.", false);
  }
  
  
  static void DicomEcho(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      DocumentEchoShared(call);
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Trigger C-ECHO SCU")
        .SetDescription("Trigger C-ECHO SCU command against the DICOM modality whose identifier is provided in URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#performing-c-echo")
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the C-ECHO command, in seconds", false)
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    Json::Value body = Json::objectValue;

    if (call.GetBodySize() == 0 /* allow empty body, was disallowed in Orthanc 1.7.0->1.8.1 */ ||
        call.ParseJsonRequest(body))
    {
      const DicomAssociationParameters parameters = GetAssociationParameters(call, body);
      ExecuteEcho(call.GetOutput(), parameters, body);
    }
    else
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Cannot parse the JSON body");
    }
  }
  

  static void DicomEchoTool(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      DocumentEchoShared(call);
      DocumentModalityParametersShared(call, false);
      call.GetDocumentation()
        .SetTag("System")
        .SetSummary("Trigger C-ECHO SCU")
        .SetDescription("Trigger C-ECHO SCU command against a DICOM modality described in the POST body, "
                        "without having to register the modality in some `/modalities/{id}` (new in Orthanc 1.8.1)");
      return;
    }

    Json::Value body;
    if (call.ParseJsonRequest(body))
    {
      RemoteModalityParameters modality;
      modality.Unserialize(body);

      const std::string& localAet =
        OrthancRestApi::GetContext(call).GetDefaultLocalApplicationEntityTitle();
      
      DicomAssociationParameters params(localAet, modality);
      InjectAssociationTimeout(params, body);

      ExecuteEcho(call.GetOutput(), params, body);
    }
    else
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Cannot parse the JSON body");
    }
  }


  /***************************************************************************
   * DICOM C-Find SCU => DEPRECATED!
   ***************************************************************************/

  static bool MergeQueryAndTemplate(DicomMap& result,
                                    const RestApiCall& call)
  {
    Json::Value query;

    if (!call.ParseJsonRequest(query) ||
        query.type() != Json::objectValue)
    {
      return false;
    }

    Json::Value::Members members = query.getMemberNames();
    for (size_t i = 0; i < members.size(); i++)
    {
      DicomTag t = FromDcmtkBridge::ParseTag(members[i]);
      result.SetValue(t, query[members[i]].asString(), false);
    }

    return true;
  }


  static void FindPatient(DicomFindAnswers& result,
                          DicomControlUserConnection& connection,
                          const DicomMap& fields)
  {
    // Only keep the filters from "fields" that are related to the patient
    DicomMap s;
    fields.ExtractPatientInformation(s);
    connection.Find(result, ResourceType_Patient, s, true /* normalize */);
  }


  static void FindStudy(DicomFindAnswers& result,
                        DicomControlUserConnection& connection,
                        const DicomMap& fields)
  {
    // Only keep the filters from "fields" that are related to the study
    DicomMap s;
    fields.ExtractStudyInformation(s);

    s.CopyTagIfExists(fields, DICOM_TAG_PATIENT_ID);
    s.CopyTagIfExists(fields, DICOM_TAG_ACCESSION_NUMBER);
    s.CopyTagIfExists(fields, DICOM_TAG_MODALITIES_IN_STUDY);

    connection.Find(result, ResourceType_Study, s, true /* normalize */);
  }

  static void FindSeries(DicomFindAnswers& result,
                         DicomControlUserConnection& connection,
                         const DicomMap& fields)
  {
    // Only keep the filters from "fields" that are related to the series
    DicomMap s;
    fields.ExtractSeriesInformation(s);

    s.CopyTagIfExists(fields, DICOM_TAG_PATIENT_ID);
    s.CopyTagIfExists(fields, DICOM_TAG_ACCESSION_NUMBER);
    s.CopyTagIfExists(fields, DICOM_TAG_STUDY_INSTANCE_UID);

    connection.Find(result, ResourceType_Series, s, true /* normalize */);
  }

  static void FindInstance(DicomFindAnswers& result,
                           DicomControlUserConnection& connection,
                           const DicomMap& fields)
  {
    // Only keep the filters from "fields" that are related to the instance
    DicomMap s;
    fields.ExtractInstanceInformation(s);

    s.CopyTagIfExists(fields, DICOM_TAG_PATIENT_ID);
    s.CopyTagIfExists(fields, DICOM_TAG_ACCESSION_NUMBER);
    s.CopyTagIfExists(fields, DICOM_TAG_STUDY_INSTANCE_UID);
    s.CopyTagIfExists(fields, DICOM_TAG_SERIES_INSTANCE_UID);

    connection.Find(result, ResourceType_Instance, s, true /* normalize */);
  }


  static void DicomFindPatient(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetDeprecated()
        .SetTag("Networking")
        .SetSummary("C-FIND SCU for patients")
        .SetDescription("Trigger C-FIND SCU command against the DICOM modality whose identifier is provided in URL, "
                        "in order to find a patient. Deprecated in favor of `/modalities/{id}/query`.")
        .AddRequestType(MimeType_Json, "Associative array containing the query on the values of the DICOM tags")
        .AddAnswerType(MimeType_Json, "JSON array describing the DICOM tags of the matching patients")
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    LOG(WARNING) << "This URI is deprecated: " << call.FlattenUri();

    DicomMap fields;
    DicomMap::SetupFindPatientTemplate(fields);
    if (!MergeQueryAndTemplate(fields, call))
    {
      return;
    }

    DicomFindAnswers answers(false);

    {
      DicomControlUserConnection connection(GetAssociationParameters(call), ScuOperationFlags_FindPatient);
      FindPatient(answers, connection, fields);
    }

    Json::Value result;
    answers.ToJson(result, DicomToJsonFormat_Human);
    call.GetOutput().AnswerJson(result);
  }

  static void DicomFindStudy(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetDeprecated()
        .SetTag("Networking")
        .SetSummary("C-FIND SCU for studies")
        .SetDescription("Trigger C-FIND SCU command against the DICOM modality whose identifier is provided in URL, "
                        "in order to find a study. Deprecated in favor of `/modalities/{id}/query`.")
        .AddRequestType(MimeType_Json, "Associative array containing the query on the values of the DICOM tags")
        .AddAnswerType(MimeType_Json, "JSON array describing the DICOM tags of the matching studies")
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    LOG(WARNING) << "This URI is deprecated: " << call.FlattenUri();

    DicomMap fields;
    DicomMap::SetupFindStudyTemplate(fields);
    if (!MergeQueryAndTemplate(fields, call))
    {
      return;
    }

    if (fields.GetValue(DICOM_TAG_ACCESSION_NUMBER).GetContent().size() <= 2 &&
        fields.GetValue(DICOM_TAG_PATIENT_ID).GetContent().size() <= 2)
    {
      return;
    }        
      
    DicomFindAnswers answers(false);

    {
      DicomControlUserConnection connection(GetAssociationParameters(call), ScuOperationFlags_FindStudy);
      FindStudy(answers, connection, fields);
    }

    Json::Value result;
    answers.ToJson(result, DicomToJsonFormat_Human);
    call.GetOutput().AnswerJson(result);
  }

  static void DicomFindSeries(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetDeprecated()
        .SetTag("Networking")
        .SetSummary("C-FIND SCU for series")
        .SetDescription("Trigger C-FIND SCU command against the DICOM modality whose identifier is provided in URL, "
                        "in order to find a series. Deprecated in favor of `/modalities/{id}/query`.")
        .AddRequestType(MimeType_Json, "Associative array containing the query on the values of the DICOM tags")
        .AddAnswerType(MimeType_Json, "JSON array describing the DICOM tags of the matching series")
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    LOG(WARNING) << "This URI is deprecated: " << call.FlattenUri();

    DicomMap fields;
    DicomMap::SetupFindSeriesTemplate(fields);
    if (!MergeQueryAndTemplate(fields, call))
    {
      return;
    }

    if ((fields.GetValue(DICOM_TAG_ACCESSION_NUMBER).GetContent().size() <= 2 &&
         fields.GetValue(DICOM_TAG_PATIENT_ID).GetContent().size() <= 2) ||
        fields.GetValue(DICOM_TAG_STUDY_INSTANCE_UID).GetContent().size() <= 2)
    {
      return;
    }        
         
    DicomFindAnswers answers(false);

    {
      DicomControlUserConnection connection(GetAssociationParameters(call), ScuOperationFlags_FindStudy);
      FindSeries(answers, connection, fields);
    }

    Json::Value result;
    answers.ToJson(result, DicomToJsonFormat_Human);
    call.GetOutput().AnswerJson(result);
  }

  static void DicomFindInstance(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetDeprecated()
        .SetTag("Networking")
        .SetSummary("C-FIND SCU for instances")
        .SetDescription("Trigger C-FIND SCU command against the DICOM modality whose identifier is provided in URL, "
                        "in order to find an instance. Deprecated in favor of `/modalities/{id}/query`.")
        .AddRequestType(MimeType_Json, "Associative array containing the query on the values of the DICOM tags")
        .AddAnswerType(MimeType_Json, "JSON array describing the DICOM tags of the matching instances")
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    LOG(WARNING) << "This URI is deprecated: " << call.FlattenUri();

    DicomMap fields;
    DicomMap::SetupFindInstanceTemplate(fields);
    if (!MergeQueryAndTemplate(fields, call))
    {
      return;
    }

    if ((fields.GetValue(DICOM_TAG_ACCESSION_NUMBER).GetContent().size() <= 2 &&
         fields.GetValue(DICOM_TAG_PATIENT_ID).GetContent().size() <= 2) ||
        fields.GetValue(DICOM_TAG_STUDY_INSTANCE_UID).GetContent().size() <= 2 ||
        fields.GetValue(DICOM_TAG_SERIES_INSTANCE_UID).GetContent().size() <= 2)
    {
      return;
    }        
         
    DicomFindAnswers answers(false);

    {
      DicomControlUserConnection connection(GetAssociationParameters(call), ScuOperationFlags_FindStudy);
      FindInstance(answers, connection, fields);
    }

    Json::Value result;
    answers.ToJson(result, DicomToJsonFormat_Human);
    call.GetOutput().AnswerJson(result);
  }


  static void CopyTagIfExists(DicomMap& target,
                              const ParsedDicomFile& source,
                              const DicomTag& tag)
  {
    std::string tmp;
    if (source.GetTagValue(tmp, tag))
    {
      target.SetValue(tag, tmp, false);
    }
  }


  static void DicomFind(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetDeprecated()
        .SetTag("Networking")
        .SetSummary("Hierarchical C-FIND SCU")
        .SetDescription("Trigger a sequence of C-FIND SCU commands against the DICOM modality whose identifier is provided in URL, "
                        "in order to discover a hierarchy of matching patients/studies/series. "
                        "Deprecated in favor of `/modalities/{id}/query`.")
        .AddRequestType(MimeType_Json, "Associative array containing the query on the values of the DICOM tags")
        .AddAnswerType(MimeType_Json, "JSON array describing the DICOM tags of the matching patients, embedding the "
                       "matching studies, then the matching series.")
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    LOG(WARNING) << "This URI is deprecated: " << call.FlattenUri();

    DicomMap m;
    DicomMap::SetupFindPatientTemplate(m);
    if (!MergeQueryAndTemplate(m, call))
    {
      return;
    }
 
    DicomControlUserConnection connection(GetAssociationParameters(call), ScuOperationFlags_Find);
    
    DicomFindAnswers patients(false);
    FindPatient(patients, connection, m);

    // Loop over the found patients
    Json::Value result = Json::arrayValue;
    for (size_t i = 0; i < patients.GetSize(); i++)
    {
      Json::Value patient;
      patients.ToJson(patient, i, DicomToJsonFormat_Human);

      DicomMap::SetupFindStudyTemplate(m);
      if (!MergeQueryAndTemplate(m, call))
      {
        return;
      }

      CopyTagIfExists(m, patients.GetAnswer(i), DICOM_TAG_PATIENT_ID);

      DicomFindAnswers studies(false);
      FindStudy(studies, connection, m);

      patient["Studies"] = Json::arrayValue;
      
      // Loop over the found studies
      for (size_t j = 0; j < studies.GetSize(); j++)
      {
        Json::Value study;
        studies.ToJson(study, j, DicomToJsonFormat_Human);

        DicomMap::SetupFindSeriesTemplate(m);
        if (!MergeQueryAndTemplate(m, call))
        {
          return;
        }

        CopyTagIfExists(m, studies.GetAnswer(j), DICOM_TAG_PATIENT_ID);
        CopyTagIfExists(m, studies.GetAnswer(j), DICOM_TAG_STUDY_INSTANCE_UID);

        DicomFindAnswers series(false);
        FindSeries(series, connection, m);

        // Loop over the found series
        study["Series"] = Json::arrayValue;
        for (size_t k = 0; k < series.GetSize(); k++)
        {
          Json::Value series2;
          series.ToJson(series2, k, DicomToJsonFormat_Human);
          study["Series"].append(series2);
        }

        patient["Studies"].append(study);
      }

      result.append(patient);
    }
    
    call.GetOutput().AnswerJson(result);
  }



  /***************************************************************************
   * DICOM C-Find and C-Move SCU => Recommended since Orthanc 0.9.0
   ***************************************************************************/

  static void AnswerQueryHandler(RestApiPostCall& call,
                                 std::unique_ptr<QueryRetrieveHandler>& handler)
  {
    ServerContext& context = OrthancRestApi::GetContext(call);

    if (handler.get() == NULL)
    {
      throw OrthancException(ErrorCode_NullPointer);
    }

    handler->Run();
    
    std::string s = context.GetQueryRetrieveArchive().Add(handler.release());
    Json::Value result = Json::objectValue;
    result["ID"] = s;
    result["Path"] = "/queries/" + s;
    
    call.GetOutput().AnswerJson(result);
  }

  
  static void DicomQuery(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Trigger C-FIND SCU")
        .SetDescription("Trigger C-FIND SCU command against the DICOM modality whose identifier is provided in URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#performing-query-retrieve-c-find-and-find-with-rest")
        .SetUriArgument("id", "Identifier of the modality of interest")
        .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject,
                         "Associative array containing the filter on the values of the DICOM tags", true)
        .SetRequestField(KEY_LEVEL, RestApiCallDocumentation::Type_String,
                         "Level of the query (`Patient`, `Study`, `Series` or `Instance`)", true)
        .SetRequestField(KEY_NORMALIZE, RestApiCallDocumentation::Type_Boolean,
                         "Whether to normalize the query, i.e. whether to wipe out from the query, the DICOM tags "
                         "that are not applicable for the query-retrieve level of interest", false)
        .SetRequestField(KEY_LOCAL_AET, RestApiCallDocumentation::Type_String,
                         "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                         "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the C-FIND command and subsequent C-MOVE retrievals, in seconds (new in Orthanc 1.9.1)", false)
        .SetAnswerField("ID", RestApiCallDocumentation::Type_JsonObject,
                        "Identifier of the query, to be used with `/queries/{id}`")
        .SetAnswerField("Path", RestApiCallDocumentation::Type_JsonObject,
                        "Root path to the query in the REST API");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);
    Json::Value request;

    if (!call.ParseJsonRequest(request) ||
        request.type() != Json::objectValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON object");
    }
    else if (!request.isMember(KEY_LEVEL) ||
             request[KEY_LEVEL].type() != Json::stringValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat,
                             "The JSON body must contain field " + std::string(KEY_LEVEL));
    }
    else if (request.isMember(KEY_NORMALIZE) &&
             request[KEY_NORMALIZE].type() != Json::booleanValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat,
                             "The field " + std::string(KEY_NORMALIZE) + " must contain a Boolean");
    }
    else if (request.isMember(KEY_QUERY) &&
             request[KEY_QUERY].type() != Json::objectValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat,
                             "The field " + std::string(KEY_QUERY) + " must contain a JSON object");
    }
    else if (request.isMember(KEY_LOCAL_AET) &&
             request[KEY_LOCAL_AET].type() != Json::stringValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat,
                             "The field " + std::string(KEY_LOCAL_AET) + " must contain a string");
    }
    else
    {
      std::unique_ptr<QueryRetrieveHandler>  handler(new QueryRetrieveHandler(context));
      
      handler->SetModality(call.GetUriComponent("id", ""));
      handler->SetLevel(StringToResourceType(request[KEY_LEVEL].asCString()));

      if (request.isMember(KEY_QUERY))
      {
        std::map<DicomTag, std::string> query;
        SerializationToolbox::ReadMapOfTags(query, request, KEY_QUERY);

        for (std::map<DicomTag, std::string>::const_iterator
               it = query.begin(); it != query.end(); ++it)
        {
          handler->SetQuery(it->first, it->second);
        }
      }

      if (request.isMember(KEY_NORMALIZE))
      {
        handler->SetFindNormalized(request[KEY_NORMALIZE].asBool());
      }

      if (request.isMember(KEY_LOCAL_AET))
      {
        handler->SetLocalAet(request[KEY_LOCAL_AET].asString());
      }

      if (request.isMember(KEY_TIMEOUT))
      {
        // New in Orthanc 1.9.1
        handler->SetTimeout(SerializationToolbox::ReadUnsignedInteger(request, KEY_TIMEOUT));
      }

      AnswerQueryHandler(call, handler);
    }
  }


  static void ListQueries(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List query/retrieve operations")
        .SetDescription("List the identifiers of all the query/retrieve operations on DICOM modalities, "
                        "as initiated by calls to `/modalities/{id}/query`. The length of this list is bounded "
                        "by the `QueryRetrieveSize` configuration option of Orthanc. "
                        "https://orthanc.uclouvain.be/book/users/rest.html#performing-query-retrieve-c-find-and-find-with-rest")
        .AddAnswerType(MimeType_Json, "JSON array containing the identifiers");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    std::list<std::string> queries;
    context.GetQueryRetrieveArchive().List(queries);

    Json::Value result = Json::arrayValue;
    for (std::list<std::string>::const_iterator
           it = queries.begin(); it != queries.end(); ++it)
    {
      result.append(*it);
    }

    call.GetOutput().AnswerJson(result);
  }


  namespace
  {
    class QueryAccessor
    {
    private:
      ServerContext&            context_;
      SharedArchive::Accessor   accessor_;
      QueryRetrieveHandler*     handler_;

    public:
      explicit QueryAccessor(RestApiCall& call) :
        context_(OrthancRestApi::GetContext(call)),
        accessor_(context_.GetQueryRetrieveArchive(), call.GetUriComponent("id", "")),
        handler_(NULL)
      {
        if (accessor_.IsValid())
        {
          handler_ = &dynamic_cast<QueryRetrieveHandler&>(accessor_.GetItem());
        }
        else
        {
          throw OrthancException(ErrorCode_UnknownResource);
        }
      }                     

      QueryRetrieveHandler& GetHandler() const
      {
        assert(handler_ != NULL);
        return *handler_;
      }
    };

    static void AnswerDicomMap(RestApiGetCall& call,
                               const DicomMap& value,
                               DicomToJsonFormat format)
    {
      Json::Value full = Json::objectValue;
      FromDcmtkBridge::ToJson(full, value, format);
      call.GetOutput().AnswerJson(full);
    }
  }


  static void ListQueryAnswers(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentDicomFormat(call, DicomToJsonFormat_Full);
      
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List answers to a query")
        .SetDescription("List the indices of all the available answers resulting from a query/retrieve operation "
                        "on some DICOM modality, whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .SetHttpGetArgument("expand", RestApiCallDocumentation::Type_String,
                            "If present, retrieve detailed information about the individual answers", false)        
        .AddAnswerType(MimeType_Json, "JSON array containing the indices of the answers, or detailed information "
                       "about the reported answers (if `expand` argument is provided)");
      return;
    }

    const bool expand = call.HasArgument("expand") && call.GetBooleanArgument("expand", true);
    const DicomToJsonFormat format = OrthancRestApi::GetDicomFormat(call, DicomToJsonFormat_Full);
    
    QueryAccessor query(call);
    size_t count = query.GetHandler().GetAnswersCount();

    Json::Value result = Json::arrayValue;
    for (size_t i = 0; i < count; i++)
    {
      if (expand)
      {
        // New in Orthanc 1.5.0
        DicomMap value;
        query.GetHandler().GetAnswer(value, i);
        
        Json::Value json = Json::objectValue;
        FromDcmtkBridge::ToJson(json, value, format);

        result.append(json);
      }
      else
      {
        result.append(boost::lexical_cast<std::string>(i));
      }
    }

    call.GetOutput().AnswerJson(result);
  }


  static void GetQueryOneAnswer(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentDicomFormat(call, DicomToJsonFormat_Full);

      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get one answer")
        .SetDescription("Get the content (DICOM tags) of one answer associated with the "
                        "query/retrieve operation whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .SetUriArgument("index", "Index of the answer")
        .AddAnswerType(MimeType_Json, "JSON object containing the DICOM tags of the answer");
      return;
    }

    size_t index = boost::lexical_cast<size_t>(call.GetUriComponent("index", ""));

    QueryAccessor query(call);

    DicomMap map;
    query.GetHandler().GetAnswer(map, index);

    AnswerDicomMap(call, map, OrthancRestApi::GetDicomFormat(call, DicomToJsonFormat_Full));
  }


  static void SubmitRetrieveJob(RestApiPostCall& call,
                                bool allAnswers,
                                size_t index)
  {
    ServerContext& context = OrthancRestApi::GetContext(call);

    std::string targetAet;
    int timeout = -1;

    QueryAccessor query(call);

    RetrieveMethod retrieveMethod = query.GetHandler().GetRemoteModality().GetRetrieveMethod();

    Json::Value body;
    if (call.ParseJsonRequest(body))
    {
      OrthancConfiguration::ReaderLock lock;

      targetAet = Toolbox::GetJsonStringField(body, KEY_TARGET_AET, context.GetDefaultLocalApplicationEntityTitle());
      timeout = Toolbox::GetJsonIntegerField(body, KEY_TIMEOUT, -1);
      
      std::string strRetrieveMethod = SerializationToolbox::ReadString(body, KEY_RETRIEVE_METHOD, "");
      
      if (!strRetrieveMethod.empty())
      {
        retrieveMethod = StringToRetrieveMethod(strRetrieveMethod);
      }
    }
    else
    {
      body = Json::objectValue;
      if (call.GetBodySize() > 0)
      {
        call.BodyToString(targetAet);
      }
      else
      {
        targetAet = context.GetDefaultLocalApplicationEntityTitle();
      }
    }
    
    if (retrieveMethod == RetrieveMethod_SystemDefault)
    {
      retrieveMethod = context.GetDefaultDicomRetrieveMethod();
    }

    std::unique_ptr<DicomRetrieveScuBaseJob> job;


    switch (retrieveMethod)
    {
      case RetrieveMethod_Move:
      {
        job.reset(new DicomMoveScuJob(context));
        (dynamic_cast<DicomMoveScuJob*>(job.get()))->SetTargetAet(targetAet);

        LOG(WARNING) << "Driving C-Move SCU on remote modality "
                    << query.GetHandler().GetRemoteModality().GetApplicationEntityTitle()
                    << " to target modality " << targetAet;
      }; break;
      case RetrieveMethod_Get:
      {
        job.reset(new DicomGetScuJob(context));

        LOG(WARNING) << "Driving C-Get SCU on remote modality "
                    << query.GetHandler().GetRemoteModality().GetApplicationEntityTitle();
      }; break;
      default:
        throw OrthancException(ErrorCode_NotImplemented);
    }

    job->SetQueryFormat(OrthancRestApi::GetDicomFormat(body, DicomToJsonFormat_Short));

    job->SetLocalAet(query.GetHandler().GetLocalAet());
    job->SetRemoteModality(query.GetHandler().GetRemoteModality());

    if (timeout >= 0)
    {
      // New in Orthanc 1.7.0
      job->SetTimeout(static_cast<uint32_t>(timeout));
    }
    else if (query.GetHandler().HasTimeout())
    {
      // New in Orthanc 1.9.1
      job->SetTimeout(query.GetHandler().GetTimeout());
    }

    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 DocumentRetrieveShared(RestApiPostCall& call)
  {
    OrthancRestApi::DocumentSubmitCommandsJob(call);
    OrthancRestApi::DocumentDicomFormat(call, DicomToJsonFormat_Short);

    call.GetDocumentation()
      .SetTag("Networking")
      .SetUriArgument("id", "Identifier of the query of interest")
      .SetRequestField(KEY_TARGET_AET, RestApiCallDocumentation::Type_String,
                       "AET of the target modality. By default, the AET of Orthanc is used, as defined in the "
                       "`DicomAet` configuration option.", false)
      .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                       "Timeout for the C-MOVE command, in seconds", false)
      .SetRequestField(KEY_RETRIEVE_METHOD, RestApiCallDocumentation::Type_String,
                        "Force usage of C-MOVE or C-GET to retrieve the resource.  If note defined in the payload, "
                        "the retrieve method is defined in the DicomDefaultRetrieveMethod configuration or in "
                        "DicomModalities->..->RetrieveMethod", false)
      .AddRequestType(MimeType_PlainText, "AET of the target modality");
  }
  

  static void RetrieveOneAnswer(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      DocumentRetrieveShared(call);
      call.GetDocumentation()
        .SetSummary("Retrieve one answer with a C-MOVE or a C-GET SCU")
        .SetDescription("Start a C-MOVE or a C-GET SCU command as a job, in order to retrieve one answer associated with the "
                        "query/retrieve operation whose identifiers are provided in the URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#performing-retrieve-c-move")
        .SetUriArgument("index", "Index of the answer");
      return;
    }

    size_t index = boost::lexical_cast<size_t>(call.GetUriComponent("index", ""));
    SubmitRetrieveJob(call, false, index);
  }




  static void RetrieveAllAnswers(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      DocumentRetrieveShared(call);
      call.GetDocumentation()
        .SetSummary("Retrieve all answers with C-MOVE SCU")
        .SetDescription("Start a C-MOVE SCU command as a job, in order to retrieve all the answers associated with the "
                        "query/retrieve operation whose identifier is provided in the URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#performing-retrieve-c-move");
      return;
    }

    SubmitRetrieveJob(call, true, 0);
  }


  static void GetQueryArguments(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentDicomFormat(call, DicomToJsonFormat_Full);

      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get original query arguments")
        .SetDescription("Get the original DICOM filter associated with the query/retrieve operation "
                        "whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .AddAnswerType(MimeType_Json, "Content of the original query");
      return;
    }

    QueryAccessor query(call);
    AnswerDicomMap(call, query.GetHandler().GetQuery(), OrthancRestApi::GetDicomFormat(call, DicomToJsonFormat_Full));
  }


  static void GetQueryLevel(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get level of original query")
        .SetDescription("Get the query level (value of the `QueryRetrieveLevel` tag) of the query/retrieve operation "
                        "whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .AddAnswerType(MimeType_PlainText, "The level");
      return;
    }

    QueryAccessor query(call);
    call.GetOutput().AnswerBuffer(EnumerationToString(query.GetHandler().GetLevel()), MimeType_PlainText);
  }


  static void GetQueryModality(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get modality of original query")
        .SetDescription("Get the identifier of the DICOM modality that was targeted by the query/retrieve operation "
                        "whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .AddAnswerType(MimeType_PlainText, "The identifier of the DICOM modality");
      return;
    }

    QueryAccessor query(call);
    call.GetOutput().AnswerBuffer(query.GetHandler().GetModalitySymbolicName(), MimeType_PlainText);
  }


  static void DeleteQuery(RestApiDeleteCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Delete a query")
        .SetDescription("Delete the query/retrieve operation whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);
    context.GetQueryRetrieveArchive().Remove(call.GetUriComponent("id", ""));
    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
  }


  static void ListQueryOperations(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List operations on a query")
        .SetDescription("List the available operations for the query/retrieve operation whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .AddAnswerType(MimeType_Json, "JSON array containing the list of operations");
      return;
    }

    // Ensure that the query of interest does exist
    QueryAccessor query(call);  

    RestApi::AutoListChildren(call);
  }


  static void ListQueryAnswerOperations(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List operations on an answer")
        .SetDescription("List the available operations on an answer associated with the "
                        "query/retrieve operation whose identifier is provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .SetUriArgument("index", "Index of the answer")
        .AddAnswerType(MimeType_Json, "JSON array containing the list of operations");
      return;
    }

    // Ensure that the query of interest does exist
    QueryAccessor query(call);

    // Ensure that the answer of interest does exist
    size_t index = boost::lexical_cast<size_t>(call.GetUriComponent("index", ""));

    DicomMap map;
    query.GetHandler().GetAnswer(map, index);

    Json::Value answer = Json::arrayValue;
    answer.append("content");
    answer.append("retrieve");

    switch (query.GetHandler().GetLevel())
    {
      case ResourceType_Patient:
        answer.append("query-study");

      case ResourceType_Study:
        answer.append("query-series");

      case ResourceType_Series:
        answer.append("query-instances");
        break;

      default:
        break;
    }
    
    call.GetOutput().AnswerJson(answer);
  }


  template <ResourceType CHILDREN_LEVEL>
  static void QueryAnswerChildren(RestApiPostCall& call)
  {
    // New in Orthanc 1.5.0
    assert(CHILDREN_LEVEL == ResourceType_Study ||
           CHILDREN_LEVEL == ResourceType_Series ||
           CHILDREN_LEVEL == ResourceType_Instance);
    
    if (call.IsDocumentation())
    {
      const std::string resources = GetResourceTypeText(CHILDREN_LEVEL, true /* plural */, false /* lower case */);      
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Query the child " + resources + " of an answer")
        .SetDescription("Issue a second DICOM C-FIND operation, in order to query the child " + resources +
                        " associated with one answer to some query/retrieve operation whose identifiers are provided in the URL")
        .SetUriArgument("id", "Identifier of the query of interest")
        .SetUriArgument("index", "Index of the answer")
        .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject,
                         "Associative array containing the filter on the values of the DICOM tags", true)
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the C-FIND command, in seconds (new in Orthanc 1.9.1)", false)
        .SetAnswerField("ID", RestApiCallDocumentation::Type_JsonObject,
                        "Identifier of the query, to be used with `/queries/{id}`")
        .SetAnswerField("Path", RestApiCallDocumentation::Type_JsonObject,
                        "Root path to the query in the REST API");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    std::unique_ptr<QueryRetrieveHandler>  handler(new QueryRetrieveHandler(context));
      
    {
      const QueryAccessor parent(call);
      const ResourceType level = parent.GetHandler().GetLevel();
    
      const size_t index = boost::lexical_cast<size_t>(call.GetUriComponent("index", ""));

      Json::Value request;

      if (index >= parent.GetHandler().GetAnswersCount())
      {
        throw OrthancException(ErrorCode_ParameterOutOfRange);
      }
      else if (CHILDREN_LEVEL == ResourceType_Study &&
               level != ResourceType_Patient)
      {
        throw OrthancException(ErrorCode_UnknownResource);
      }
      else if (CHILDREN_LEVEL == ResourceType_Series &&
               level != ResourceType_Patient &&
               level != ResourceType_Study)
      {
        throw OrthancException(ErrorCode_UnknownResource);
      }      
      else if (CHILDREN_LEVEL == ResourceType_Instance &&
               level != ResourceType_Patient &&
               level != ResourceType_Study &&
               level != ResourceType_Series)
      {
        throw OrthancException(ErrorCode_UnknownResource);
      }
      else if (!call.ParseJsonRequest(request))
      {
        throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON object");
      }
      else
      {
        handler->SetFindNormalized(parent.GetHandler().IsFindNormalized());
        handler->SetModality(parent.GetHandler().GetModalitySymbolicName());
        handler->SetLevel(CHILDREN_LEVEL);

        // New in Orthanc 1.9.1
        if (request.isMember(KEY_TIMEOUT))
        {
          handler->SetTimeout(SerializationToolbox::ReadUnsignedInteger(request, KEY_TIMEOUT));
        }
        else if (parent.GetHandler().HasTimeout())
        {
          handler->SetTimeout(parent.GetHandler().GetTimeout());
        }

        if (request.isMember(KEY_QUERY))
        {
          std::map<DicomTag, std::string> query;
          SerializationToolbox::ReadMapOfTags(query, request, KEY_QUERY);

          for (std::map<DicomTag, std::string>::const_iterator
                 it = query.begin(); it != query.end(); ++it)
          {
            handler->SetQuery(it->first, it->second);
          }
        }

        DicomMap answer;
        parent.GetHandler().GetAnswer(answer, index);

        // This switch-case mimics "DicomControlUserConnection::Move()"
        switch (parent.GetHandler().GetLevel())
        {
          case ResourceType_Patient:
            handler->CopyStringTag(answer, DICOM_TAG_PATIENT_ID);
            break;

          case ResourceType_Study:
            handler->CopyStringTag(answer, DICOM_TAG_STUDY_INSTANCE_UID);
            break;

          case ResourceType_Series:
            handler->CopyStringTag(answer, DICOM_TAG_STUDY_INSTANCE_UID);
            handler->CopyStringTag(answer, DICOM_TAG_SERIES_INSTANCE_UID);
            break;

          case ResourceType_Instance:
            handler->CopyStringTag(answer, DICOM_TAG_STUDY_INSTANCE_UID);
            handler->CopyStringTag(answer, DICOM_TAG_SERIES_INSTANCE_UID);
            handler->CopyStringTag(answer, DICOM_TAG_SOP_INSTANCE_UID);
            break;

          default:
            throw OrthancException(ErrorCode_InternalError);
        }
      }
    }
      
    AnswerQueryHandler(call, handler);
  }
  


  /***************************************************************************
   * DICOM C-Store SCU
   ***************************************************************************/

  static void GetInstancesToExport(Json::Value& otherArguments,
                                   SetOfInstancesJob& job,
                                   const std::string& remote,
                                   RestApiPostCall& call)
  {
    otherArguments = Json::objectValue;
    ServerContext& context = OrthancRestApi::GetContext(call);

    Json::Value request;
    if (Toolbox::IsSHA1(call.GetBodyData(), call.GetBodySize()))
    {
      std::string s;
      call.BodyToString(s);

      // This is for compatibility with Orthanc <= 0.5.1.
      request = Json::arrayValue;
      request.append(Toolbox::StripSpaces(s));
    }
    else if (!call.ParseJsonRequest(request))
    {
      // Bad JSON request
      throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON value");
    }

    if (request.isString())
    {
      std::string item = request.asString();
      request = Json::arrayValue;
      request.append(item);
    }
    else if (!request.isArray() &&
             !request.isObject())
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON object, or a JSON array of strings");
    }

    const Json::Value* resources;
    if (request.isArray())
    {
      resources = &request;
    }
    else
    {
      if (request.type() != Json::objectValue ||
          !request.isMember(KEY_RESOURCES))
      {
        throw OrthancException(ErrorCode_BadFileFormat,
                               "Missing field in JSON: \"" + std::string(KEY_RESOURCES) + "\"");
      }

      resources = &request[KEY_RESOURCES];
      if (!resources->isArray())
      {
        throw OrthancException(ErrorCode_BadFileFormat,
                               "JSON field \"" + std::string(KEY_RESOURCES) + "\" must contain an array");
      }

      // Copy the remaining arguments
      Json::Value::Members members = request.getMemberNames();
      for (Json::Value::ArrayIndex i = 0; i < members.size(); i++)
      {
        otherArguments[members[i]] = request[members[i]];
      }
    }

    bool logExportedResources;

    {
      OrthancConfiguration::ReaderLock lock;
      logExportedResources = lock.GetConfiguration().GetBooleanParameter("LogExportedResources", false);
    }

    for (Json::Value::ArrayIndex i = 0; i < resources->size(); i++)
    {
      if (!(*resources) [i].isString())
      {
        throw OrthancException(ErrorCode_BadFileFormat,
                               "Resources to be exported must be specified as a JSON array of strings");
      }

      std::string stripped = Toolbox::StripSpaces((*resources) [i].asString());
      if (!Toolbox::IsSHA1(stripped))
      {
        throw OrthancException(ErrorCode_BadFileFormat,
                               "This string is not a valid Orthanc identifier: " + stripped);
      }

      job.AddParentResource(stripped);  // New in Orthanc 1.5.7
      
      context.AddChildInstances(job, stripped);

      if (logExportedResources)
      {
        context.GetIndex().LogExportedResource(stripped, remote);
      }
    }
  }


  static void DicomStore(RestApiPostCall& call)
  {
    static const char* KEY_MOVE_ORIGINATOR_AET = "MoveOriginatorAet";
    static const char* KEY_MOVE_ORIGINATOR_ID = "MoveOriginatorID";
    static const char* KEY_STORAGE_COMMITMENT = "StorageCommitment";
    
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentSubmitCommandsJob(call);
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Trigger C-STORE SCU")
        .SetDescription("Start a C-STORE SCU command as a job, in order to send DICOM resources stored locally "
                        "to some remote DICOM modality whose identifier is provided in the URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#rest-store-scu")
        .AddRequestType(MimeType_PlainText, "The Orthanc identifier of one resource to be sent")
        .SetRequestField(KEY_RESOURCES, RestApiCallDocumentation::Type_JsonListOfStrings,
                         "List of the Orthanc identifiers of all the DICOM resources to be sent", true)
        .SetRequestField(KEY_LOCAL_AET, RestApiCallDocumentation::Type_String,
                         "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                         "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
        .SetRequestField(KEY_CALLED_AET, RestApiCallDocumentation::Type_String,
                         "Called AET that is used for this commands, defaults to `AET` configuration option. "
                         "Allows you to overwrite the destination AET for a specific operation.", false)
        .SetRequestField(KEY_HOST, RestApiCallDocumentation::Type_String,
                         "Host that is used for this commands, defaults to `Host` configuration option. "
                         "Allows you to overwrite the destination host for a specific operation.", false)
        .SetRequestField(KEY_PORT, RestApiCallDocumentation::Type_Number,
                         "Port that is used for this command, defaults to `Port` configuration option. "
                         "Allows you to overwrite the destination port for a specific operation.", false)
        .SetRequestField(KEY_MOVE_ORIGINATOR_AET, RestApiCallDocumentation::Type_String,
                         "Move originator AET that is used for this commands, in order to fake a C-MOVE SCU", false)
        .SetRequestField(KEY_MOVE_ORIGINATOR_ID, RestApiCallDocumentation::Type_Number,
                         "Move originator ID that is used for this commands, in order to fake a C-MOVE SCU", false)
        .SetRequestField(KEY_STORAGE_COMMITMENT, RestApiCallDocumentation::Type_Boolean,
                         "Whether to chain C-STORE with DICOM storage commitment to validate the success of the transmission: "
                         "https://orthanc.uclouvain.be/book/users/storage-commitment.html#chaining-c-store-with-storage-commitment", false)
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the C-STORE command, in seconds", false)
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    std::string remote = call.GetUriComponent("id", "");

    Json::Value request;
    std::unique_ptr<DicomModalityStoreJob> job(new DicomModalityStoreJob(context));

    GetInstancesToExport(request, *job, remote, call);

    std::string localAet = Toolbox::GetJsonStringField
      (request, KEY_LOCAL_AET, context.GetDefaultLocalApplicationEntityTitle());
    std::string moveOriginatorAET = Toolbox::GetJsonStringField
      (request, KEY_MOVE_ORIGINATOR_AET, context.GetDefaultLocalApplicationEntityTitle());
    int moveOriginatorID = Toolbox::GetJsonIntegerField
      (request, KEY_MOVE_ORIGINATOR_ID, 0 /* By default, not a C-MOVE */);

    RemoteModalityParameters remoteModality = MyGetModalityUsingSymbolicName(remote);

    remoteModality.SetApplicationEntityTitle(Toolbox::GetJsonStringField
      (request, KEY_CALLED_AET, remoteModality.GetApplicationEntityTitle()));
    remoteModality.SetHost(Toolbox::GetJsonStringField
      (request, KEY_HOST, remoteModality.GetHost()));
    remoteModality.SetPortNumber(static_cast<uint16_t>(Toolbox::GetJsonUnsignedIntegerField
      (request, KEY_PORT, remoteModality.GetPortNumber())));

    job->SetLocalAet(localAet);
    job->SetRemoteModality(remoteModality);

    if (moveOriginatorID != 0)
    {
      job->SetMoveOriginator(moveOriginatorAET, moveOriginatorID);
    }

    // New in Orthanc 1.6.0
    if (Toolbox::GetJsonBooleanField(request, KEY_STORAGE_COMMITMENT, false))
    {
      job->EnableStorageCommitment(true);
    }

    // New in Orthanc 1.7.0
    if (request.isMember(KEY_TIMEOUT))
    {
      job->SetTimeout(SerializationToolbox::ReadUnsignedInteger(request, KEY_TIMEOUT));
    }

    OrthancRestApi::GetApi(call).SubmitCommandsJob
      (call, job.release(), true /* synchronous by default */, request);
  }


  static void DicomStoreStraight(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Straight C-STORE SCU")
        .SetDescription("Synchronously send the DICOM instance in the POST body to the remote DICOM modality "
                        "whose identifier is provided in URL, without having to first store it locally within Orthanc. "
                        "This is an alternative to command-line tools such as `storescu` from DCMTK or dcm4che.")
        .SetUriArgument("id", "Identifier of the modality of interest")
        .AddRequestType(MimeType_Dicom, "DICOM instance to be sent")
        .SetAnswerField(SOP_CLASS_UID, RestApiCallDocumentation::Type_String,
                        "SOP class UID of the DICOM instance, if the C-STORE SCU has succeeded")
        .SetAnswerField(SOP_INSTANCE_UID, RestApiCallDocumentation::Type_String,
                        "SOP instance UID of the DICOM instance, if the C-STORE SCU has succeeded");
      return;
    }

    Json::Value body = Json::objectValue;  // No body
    DicomStoreUserConnection connection(GetAssociationParameters(call, body));

    std::string sopClassUid, sopInstanceUid;
    connection.Store(sopClassUid, sopInstanceUid, call.GetBodyData(),
                     call.GetBodySize(), 
                     false /* Not a C-MOVE */, 
                     "", 0, false);

    Json::Value answer = Json::objectValue;
    answer[SOP_CLASS_UID] = sopClassUid;
    answer[SOP_INSTANCE_UID] = sopInstanceUid;
    
    call.GetOutput().AnswerJson(answer);
  }

  void ParseMoveGetJob(DicomRetrieveScuBaseJob& job, Json::Value& request, RestApiPostCall& call)
  {
    ServerContext& context = OrthancRestApi::GetContext(call);

    if (!call.ParseJsonRequest(request) ||
        request.type() != Json::objectValue ||
        !request.isMember(KEY_RESOURCES) ||
        !request.isMember(KEY_LEVEL) ||
        request[KEY_RESOURCES].type() != Json::arrayValue ||
        request[KEY_LEVEL].type() != Json::stringValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON body containing fields " +
                             std::string(KEY_RESOURCES) + " and " + std::string(KEY_LEVEL));
    }

    ResourceType level = StringToResourceType(request[KEY_LEVEL].asCString());
    
    std::string localAet = Toolbox::GetJsonStringField
      (request, KEY_LOCAL_AET, context.GetDefaultLocalApplicationEntityTitle());

    const RemoteModalityParameters source =
      MyGetModalityUsingSymbolicName(call.GetUriComponent("id", ""));

    job.SetQueryFormat(DicomToJsonFormat_Short);
    job.SetLocalAet(localAet);
    job.SetRemoteModality(source);

    if (request.isMember(KEY_TIMEOUT))
    {
      job.SetTimeout(SerializationToolbox::ReadUnsignedInteger(request, KEY_TIMEOUT));
    }

    for (Json::Value::ArrayIndex i = 0; i < request[KEY_RESOURCES].size(); i++)
    {
      DicomMap resource;
      FromDcmtkBridge::FromJson(resource, request[KEY_RESOURCES][i], "Resources elements");

      resource.SetValue(DICOM_TAG_QUERY_RETRIEVE_LEVEL, std::string(ResourceTypeToDicomQueryRetrieveLevel(level)), false);

      job.AddQuery(resource);      
    }
  }

  /***************************************************************************
   * DICOM C-Move SCU
   ***************************************************************************/
  
  static void DicomMove(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentSubmitCommandsJob(call);
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Trigger C-MOVE SCU")
        .SetDescription("Start a C-MOVE SCU command as a job, in order to drive the execution of a sequence of "
                        "C-STORE commands by some remote DICOM modality whose identifier is provided in the URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#performing-c-move")
        .SetRequestField(KEY_RESOURCES, RestApiCallDocumentation::Type_JsonListOfObjects,
                         "List of queries identifying all the DICOM resources to be sent", true)
        .SetRequestField(KEY_LEVEL, RestApiCallDocumentation::Type_String,
                         "Level of the query (`Patient`, `Study`, `Series` or `Instance`)", true)
        .SetRequestField(KEY_LOCAL_AET, RestApiCallDocumentation::Type_String,
                         "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                         "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
        .SetRequestField(KEY_TARGET_AET, RestApiCallDocumentation::Type_String,
                         "Target AET that will be used by the remote DICOM modality as a target for its C-STORE SCU "
                         "commands, defaults to `DicomAet` configuration option in order to do a simple query/retrieve", false)
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the C-MOVE command, in seconds", false)
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);
    Json::Value request;

    std::unique_ptr<DicomMoveScuJob> job(new DicomMoveScuJob(context));

    ParseMoveGetJob(*job, request, call);

    std::string targetAet = Toolbox::GetJsonStringField
      (request, KEY_TARGET_AET, context.GetDefaultLocalApplicationEntityTitle());
    job->SetTargetAet(targetAet);

    OrthancRestApi::GetApi(call).SubmitCommandsJob
      (call, job.release(), true /* synchronous by default */, request);
    return;
  }


  /***************************************************************************
   * DICOM C-Get SCU
   ***************************************************************************/
  
  static void DicomGet(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentSubmitCommandsJob(call);
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Trigger C-GET SCU")
        .SetDescription("Start a C-GET SCU command as a job, in order to retrieve DICOM resources "
                        "from a remote DICOM modality whose identifier is provided in the URL: ")
                        // "https://orthanc.uclouvain.be/book/users/rest.html#performing-c-move")   // TODO-GET
        .SetRequestField(KEY_RESOURCES, RestApiCallDocumentation::Type_JsonListOfObjects,
                         "List of queries identifying all the DICOM resources to be sent.  "
                         "Usage of wildcards is prohibited and the query shall only contain DICOM ID tags.  "
                         "Additionally, you may provide SOPClassesInStudy to limit the scope of the DICOM "
                         "negotiation to certain SOPClassUID or to present uncommon SOPClassUID during "
                         "the DICOM negotation.  By default, "
                         "Orhanc will propose the most 120 common SOPClassUIDs.", true)
        .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject,
                         "A query object identifying all the DICOM resources to be retrieved", true)
        .SetRequestField(KEY_LOCAL_AET, RestApiCallDocumentation::Type_String,
                         "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                         "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the C-GET command, in seconds", false)
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);
    Json::Value request;

    std::unique_ptr<DicomGetScuJob> job(new DicomGetScuJob(context));

    ParseMoveGetJob(*job, request, call);

    OrthancRestApi::GetApi(call).SubmitCommandsJob
      (call, job.release(), true /* synchronous by default */, request);
    return;
  }


  /***************************************************************************
   * Orthanc Peers => Store client
   ***************************************************************************/

  static bool IsExistingPeer(const OrthancRestApi::SetOfStrings& peers,
                             const std::string& id)
  {
    return peers.find(id) != peers.end();
  }

  static void ListPeers(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List Orthanc peers")
        .SetDescription("List all the Orthanc peers that are known to Orthanc. This corresponds either to the content of the "
                        "`OrthancPeers` configuration option, or to the information stored in the database if "
                        "`OrthancPeersInDatabase` is `true`.")
        .SetHttpGetArgument("expand", RestApiCallDocumentation::Type_String,
                            "If present, retrieve detailed information about the individual Orthanc peers", false)
        .AddAnswerType(MimeType_Json, "JSON array containing either the identifiers of the peers, or detailed information "
                       "about the peers (if `expand` argument is provided)");
      return;
    }

    OrthancConfiguration::ReaderLock lock;

    OrthancRestApi::SetOfStrings peers;
    lock.GetConfiguration().GetListOfOrthancPeers(peers);

    if (call.HasArgument("expand") && call.GetBooleanArgument("expand", true))
    {
      Json::Value result = Json::objectValue;
      for (OrthancRestApi::SetOfStrings::const_iterator
             it = peers.begin(); it != peers.end(); ++it)
      {
        WebServiceParameters peer;
        
        if (lock.GetConfiguration().LookupOrthancPeer(peer, *it))
        {
          Json::Value info;
          peer.FormatPublic(info);
          result[*it] = info;
        }
      }
      call.GetOutput().AnswerJson(result);
    }
    else // if expand is not present, keep backward compatibility and return an array of peers
    {
      Json::Value result = Json::arrayValue;
      for (OrthancRestApi::SetOfStrings::const_iterator
             it = peers.begin(); it != peers.end(); ++it)
      {
        result.append(*it);
      }

      call.GetOutput().AnswerJson(result);
    }
  }

  static void ListPeerOperations(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List operations on peer")
        .SetDescription("List the operations that are available for an Orthanc peer.")
        .SetUriArgument("id", "Identifier of the peer of interest")
        .AddAnswerType(MimeType_Json, "List of the available operations");
      return;
    }

    OrthancConfiguration::ReaderLock lock;

    OrthancRestApi::SetOfStrings peers;
    lock.GetConfiguration().GetListOfOrthancPeers(peers);

    std::string id = call.GetUriComponent("id", "");
    if (IsExistingPeer(peers, id))
    {
      RestApi::AutoListChildren(call);
    }
  }

  static void PeerStore(RestApiPostCall& call)
  {
    static const char* KEY_TRANSCODE = "Transcode";
    static const char* KEY_COMPRESS = "Compress";

    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentSubmitCommandsJob(call);
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Send to Orthanc peer")
        .SetDescription("Send DICOM resources stored locally to some remote Orthanc peer whose identifier is provided in the URL: "
                        "https://orthanc.uclouvain.be/book/users/rest.html#sending-one-resource")
        .AddRequestType(MimeType_PlainText, "The Orthanc identifier of one resource to be sent")
        .SetRequestField(KEY_RESOURCES, RestApiCallDocumentation::Type_JsonListOfStrings,
                         "List of the Orthanc identifiers of all the DICOM resources to be sent", true)
        .SetRequestField(KEY_TRANSCODE, RestApiCallDocumentation::Type_String,
                         "Transcode to the provided DICOM transfer syntax before the actual sending", false)
        .SetRequestField(KEY_COMPRESS, RestApiCallDocumentation::Type_Boolean,
                         "Whether to compress the DICOM instances using gzip before the actual sending", false)
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    std::string remote = call.GetUriComponent("id", "");

    Json::Value request;
    std::unique_ptr<OrthancPeerStoreJob> job(new OrthancPeerStoreJob(context));

    GetInstancesToExport(request, *job, remote, call);

    if (request.type() == Json::objectValue &&
        request.isMember(KEY_TRANSCODE))
    {
      job->SetTranscode(SerializationToolbox::ReadString(request, KEY_TRANSCODE));
    }

    if (request.type() == Json::objectValue &&
        request.isMember(KEY_COMPRESS))
    {
      job->SetCompress(SerializationToolbox::ReadBoolean(request, KEY_COMPRESS));
    }
    
    {
      OrthancConfiguration::ReaderLock lock;
      
      WebServiceParameters peer;
      if (lock.GetConfiguration().LookupOrthancPeer(peer, remote))
      {
        job->SetPeer(peer);    
      }
      else
      {
        throw OrthancException(ErrorCode_UnknownResource,
                               "No peer with symbolic name: " + remote);
      }
    }

    OrthancRestApi::GetApi(call).SubmitCommandsJob
      (call, job.release(), true /* synchronous by default */, request);
  }

  static void PeerSystem(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get peer system information")
        .SetDescription("Get system information about some Orthanc peer. This corresponds to doing a `GET` request "
                        "against the `/system` URI of the remote peer. This route can be used to test connectivity.")
        .SetUriArgument("id", "Identifier of the peer of interest")
        .AddAnswerType(MimeType_Json, "System information about the peer");
      return;
    }

    std::string remote = call.GetUriComponent("id", "");

    OrthancConfiguration::ReaderLock lock;

    WebServiceParameters peer;
    if (lock.GetConfiguration().LookupOrthancPeer(peer, remote))
    {
      HttpClient client(peer, "system");
      std::string answer;

      client.SetMethod(HttpMethod_Get);

      if (!client.Apply(answer))
      {
        LOG(ERROR) << "Unable to get the system info from remote Orthanc peer: " << peer.GetUrl();
        call.GetOutput().SignalError(client.GetLastStatus());
        return;
      }

      call.GetOutput().AnswerBuffer(answer, MimeType_Json);
    }
    else
    {
      throw OrthancException(ErrorCode_UnknownResource,
                             "No peer with symbolic name: " + remote);
    }
  }

  static void GetPeerConfiguration(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      Json::Value sample;
      sample["HttpHeaders"] = Json::objectValue;
      sample["Password"] = Json::nullValue;
      sample["Pkcs11"] = false;
      sample["Url"] = "http://127.0.1.1:5000/";
      sample["Username"] = "alice";      
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get peer configuration")
        .SetDescription("Get detailed information about the configuration of some Orthanc peer")
        .SetUriArgument("id", "Identifier of the peer of interest")
        .AddAnswerType(MimeType_Json, "Configuration of the peer")
        .SetSample(sample);
      return;
    }

    OrthancConfiguration::ReaderLock lock;
    const std::string peer = call.GetUriComponent("id", "");

    WebServiceParameters info;  
    if (lock.GetConfiguration().LookupOrthancPeer(info, peer))
    {
      Json::Value answer;
      info.FormatPublic(answer);
      call.GetOutput().AnswerJson(answer);
    }
    else
    {
      throw OrthancException(ErrorCode_UnknownResource,
                             "No peer with symbolic name: " + peer);
    }
  }

  static void PeerStoreStraight(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Straight store to peer")
        .SetDescription("Synchronously send the DICOM instance in the POST body to the Orthanc peer "
                        "whose identifier is provided in URL, without having to first store it locally within Orthanc. "
                        "This is an alternative to command-line tools such as `curl`.")
        .SetUriArgument("id", "Identifier of the modality of interest")
        .AddRequestType(MimeType_Dicom, "DICOM instance to be sent")
        .SetAnswerField("ID", RestApiCallDocumentation::Type_String,
                        "Orthanc identifier of the DICOM instance in the remote Orthanc peer")
        .SetAnswerField("ParentPatient", RestApiCallDocumentation::Type_String,
                        "Orthanc identifier of the parent patient in the remote Orthanc peer")
        .SetAnswerField("ParentStudy", RestApiCallDocumentation::Type_String,
                        "Orthanc identifier of the parent study in the remote Orthanc peer")
        .SetAnswerField("ParentSeries", RestApiCallDocumentation::Type_String,
                        "Orthanc identifier of the parent series in the remote Orthanc peer")
        .SetAnswerField("Path", RestApiCallDocumentation::Type_String,
                        "Path to the DICOM instance in the remote Orthanc server")
        .SetAnswerField("Status", RestApiCallDocumentation::Type_String,
                        "Status of the store operation");
      return;
    }

    const std::string peer = call.GetUriComponent("id", "");

    WebServiceParameters info;  

    {
      OrthancConfiguration::ReaderLock lock;
      if (!lock.GetConfiguration().LookupOrthancPeer(info, peer))
      {
        throw OrthancException(ErrorCode_UnknownResource, "No peer with symbolic name: " + peer);
      }
    }

    HttpClient client(info, "instances");
    client.SetMethod(HttpMethod_Post);
    client.AddHeader("Expect", "");
    client.SetExternalBody(call.GetBodyData(), call.GetBodySize());

    Json::Value answer;
    if (client.Apply(answer))
    {
      call.GetOutput().AnswerJson(answer);
    }
    else
    {
      throw OrthancException(ErrorCode_NetworkProtocol, "Cannot send DICOM to remote peer: " + peer);
    }
  }


  // DICOM bridge -------------------------------------------------------------

  static bool IsExistingModality(const OrthancRestApi::SetOfStrings& modalities,
                                 const std::string& id)
  {
    return modalities.find(id) != modalities.end();
  }

  static void ListModalities(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List DICOM modalities")
        .SetDescription("List all the DICOM modalities that are known to Orthanc. This corresponds either to the content of the "
                        "`DicomModalities` configuration option, or to the information stored in the database if "
                        "`DicomModalitiesInDatabase` is `true`.")
        .SetHttpGetArgument("expand", RestApiCallDocumentation::Type_String,
                            "If present, retrieve detailed information about the individual DICOM modalities", false)
        .AddAnswerType(MimeType_Json, "JSON array containing either the identifiers of the modalities, or detailed information "
                       "about the modalities (if `expand` argument is provided)");
      return;
    }

    OrthancConfiguration::ReaderLock lock;

    OrthancRestApi::SetOfStrings modalities;
    lock.GetConfiguration().GetListOfDicomModalities(modalities);

    if (call.HasArgument("expand") && call.GetBooleanArgument("expand", true))
    {
      Json::Value result = Json::objectValue;
      for (OrthancRestApi::SetOfStrings::const_iterator
             it = modalities.begin(); it != modalities.end(); ++it)
      {
        const RemoteModalityParameters& remote = lock.GetConfiguration().GetModalityUsingSymbolicName(*it);
        
        Json::Value info;
        remote.Serialize(info, true /* force advanced format */);
        result[*it] = info;
      }
      call.GetOutput().AnswerJson(result);
    }
    else // if expand is not present, keep backward compatibility and return an array of modalities ids
    {
      Json::Value result = Json::arrayValue;
      for (OrthancRestApi::SetOfStrings::const_iterator
             it = modalities.begin(); it != modalities.end(); ++it)
      {
        result.append(*it);
      }
      call.GetOutput().AnswerJson(result);
    }
  }


  static void ListModalityOperations(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("List operations on modality")
        .SetDescription("List the operations that are available for a DICOM modality.")
        .SetUriArgument("id", "Identifier of the DICOM modality of interest")
        .AddAnswerType(MimeType_Json, "List of the available operations");
      return;
    }

    OrthancConfiguration::ReaderLock lock;

    OrthancRestApi::SetOfStrings modalities;
    lock.GetConfiguration().GetListOfDicomModalities(modalities);

    std::string id = call.GetUriComponent("id", "");
    if (IsExistingModality(modalities, id))
    {
      RestApi::AutoListChildren(call);
    }
  }


  static void UpdateModality(RestApiPutCall& call)
  {
    if (call.IsDocumentation())
    {
      DocumentModalityParametersShared(call, true);
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Update DICOM modality")
        .SetDescription("Define a new DICOM modality, or update an existing one. This change is permanent iff. "
                        "`DicomModalitiesInDatabase` is `true`, otherwise it is lost at the next restart of Orthanc.")
        .SetUriArgument("id", "Identifier of the new/updated DICOM modality");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    Json::Value json;
    if (call.ParseJsonRequest(json))
    {
      RemoteModalityParameters modality;
      modality.Unserialize(json);

      {
        OrthancConfiguration::WriterLock lock;
        lock.GetConfiguration().UpdateModality(call.GetUriComponent("id", ""), modality);
      }

      context.SignalUpdatedModalities();

      call.GetOutput().AnswerBuffer("", MimeType_PlainText);
    }
    else
    {
      throw OrthancException(ErrorCode_BadFileFormat);
    }
  }


  static void DeleteModality(RestApiDeleteCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Delete DICOM modality")
        .SetDescription("Delete one DICOM modality. This change is permanent iff. `DicomModalitiesInDatabase` is `true`, "
                        "otherwise it is lost at the next restart of Orthanc.")
        .SetUriArgument("id", "Identifier of the DICOM modality of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    {
      OrthancConfiguration::WriterLock lock;
      lock.GetConfiguration().RemoveModality(call.GetUriComponent("id", ""));
    }

    context.SignalUpdatedModalities();

    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
  }


  static void GetModalityConfiguration(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      Json::Value sample;
      sample["AET"] = "ORTHANCTEST";
      sample["AllowEcho"] = true;
      sample["AllowEventReport"] = true;
      sample["AllowFind"] = true;
      sample["AllowFindWorklist"] = true;
      sample["AllowGet"] = true;
      sample["AllowMove"] = true;
      sample["AllowNAction"] = true;
      sample["AllowStore"] = true;
      sample["AllowTranscoding"] = true;
      sample["Host"] = "127.0.1.1";
      sample["Manufacturer"] = "Generic";
      sample["Port"] = 5001;
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get modality configuration")
        .SetDescription("Get detailed information about the configuration of some DICOM modality")
        .SetUriArgument("id", "Identifier of the modality of interest")
        .AddAnswerType(MimeType_Json, "Configuration of the modality")
        .SetSample(sample);
      return;
    }

    const std::string modality = call.GetUriComponent("id", "");

    Json::Value answer;

    {
      OrthancConfiguration::ReaderLock lock;
      lock.GetConfiguration().GetModalityUsingSymbolicName(modality).Serialize(answer, true /* force advanced format */);
    }
    
    call.GetOutput().AnswerJson(answer);
  }


  static void UpdatePeer(RestApiPutCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Update Orthanc peer")
        .SetDescription("Define a new Orthanc peer, or update an existing one. This change is permanent iff. "
                        "`OrthancPeersInDatabase` is `true`, otherwise it is lost at the next restart of Orthanc.")
        .SetUriArgument("id", "Identifier of the new/updated Orthanc peer")
        .SetRequestField("URL", RestApiCallDocumentation::Type_String,
                         "URL of the root of the REST API of the remote Orthanc peer, for instance `http://localhost:8042/`", true)
        .SetRequestField("Username", RestApiCallDocumentation::Type_String,
                         "Username for the credentials", false)
        .SetRequestField("Password", RestApiCallDocumentation::Type_String,
                         "Password for the credentials", false)
        .SetRequestField("CertificateFile", RestApiCallDocumentation::Type_String,
                         "SSL certificate for the HTTPS connections", false)
        .SetRequestField("CertificateKeyFile", RestApiCallDocumentation::Type_String,
                         "Key file for the SSL certificate for the HTTPS connections", false)
        .SetRequestField("CertificateKeyPassword", RestApiCallDocumentation::Type_String,
                         "Key password for the SSL certificate for the HTTPS connections", false)
        .SetRequestField("HttpHeaders", RestApiCallDocumentation::Type_JsonObject,
                         "HTTP headers to be used for the connections to the remote peer", false);
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    Json::Value json;
    if (call.ParseJsonRequest(json))
    {
      WebServiceParameters peer;
      peer.Unserialize(json);

      {
        OrthancConfiguration::WriterLock lock;
        lock.GetConfiguration().UpdatePeer(call.GetUriComponent("id", ""), peer);
      }

      context.SignalUpdatedPeers();

      call.GetOutput().AnswerBuffer("", MimeType_PlainText);
    }
  }


  static void DeletePeer(RestApiDeleteCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Delete Orthanc peer")
        .SetDescription("Delete one Orthanc peer. This change is permanent iff. `OrthancPeersInDatabase` is `true`, "
                        "otherwise it is lost at the next restart of Orthanc.")
        .SetUriArgument("id", "Identifier of the Orthanc peer of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    {
      OrthancConfiguration::WriterLock lock;
      lock.GetConfiguration().RemovePeer(call.GetUriComponent("id", ""));
    }

    context.SignalUpdatedPeers();

    call.GetOutput().AnswerBuffer("", MimeType_PlainText);
  }


  static void DicomFindWorklist(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      OrthancRestApi::DocumentDicomFormat(call, DicomToJsonFormat_Human);

      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("C-FIND SCU for worklist")
        .SetDescription("Trigger C-FIND SCU command against the remote worklists of the DICOM modality "
                        "whose identifier is provided in URL")
        .SetUriArgument("id", "Identifier of the modality of interest")
        .SetRequestField(KEY_QUERY, RestApiCallDocumentation::Type_JsonObject,
                         "Associative array containing the filter on the values of the DICOM tags", true)
        .AddAnswerType(MimeType_Json, "JSON array describing the DICOM tags of the matching worklists");
      return;
    }

    Json::Value json;
    if (call.ParseJsonRequest(json))
    {
      std::unique_ptr<ParsedDicomFile> query;
      DicomToJsonFormat format;

      if (json.isMember(KEY_QUERY))
      {
        // New in Orthanc 1.9.5
        query.reset(ParsedDicomFile::CreateFromJson(json[KEY_QUERY], static_cast<DicomFromJsonFlags>(0),
                                                    "" /* no private creator */));
        format = OrthancRestApi::GetDicomFormat(json, DicomToJsonFormat_Human);
      }
      else
      {
        // Compatibility with Orthanc <= 1.9.4
        query.reset(ParsedDicomFile::CreateFromJson(json, static_cast<DicomFromJsonFlags>(0),
                                                    "" /* no private creator */));
        format = DicomToJsonFormat_Human;
      }

      DicomFindAnswers answers(true);

      {
        DicomControlUserConnection connection(GetAssociationParameters(call, json), ScuOperationFlags_FindWorklist);
        connection.FindWorklist(answers, *query);
      }

      Json::Value result;
      answers.ToJson(result, format);
      call.GetOutput().AnswerJson(result);
    }
    else
    {
      throw OrthancException(ErrorCode_BadFileFormat, "Must provide a JSON object");
    }
  }


  // Storage commitment SCU ---------------------------------------------------

  static void StorageCommitmentScu(RestApiPostCall& call)
  {
    static const char* const ORTHANC_RESOURCES = "Resources";
    static const char* const DICOM_INSTANCES = "DicomInstances";

    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Trigger storage commitment request")
        .SetDescription("Trigger a storage commitment request to some remote DICOM modality whose identifier is provided "
                        "in the URL: https://orthanc.uclouvain.be/book/users/storage-commitment.html#storage-commitment-scu")
        .SetRequestField(ORTHANC_RESOURCES, RestApiCallDocumentation::Type_JsonListOfStrings,
                         "List of the Orthanc identifiers of the DICOM resources to be checked by storage commitment", true)
        .SetRequestField(DICOM_INSTANCES, RestApiCallDocumentation::Type_JsonListOfObjects,
                         "List of DICOM resources that are not necessarily stored within Orthanc, but that must "
                         "be checked by storage commitment. This is a list of JSON objects that must contain the "
                         "`SOPClassUID` and `SOPInstanceUID` fields.", true)
        .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
                         "Timeout for the storage commitment command (new in Orthanc 1.9.1)", false)
        .SetAnswerField("ID", RestApiCallDocumentation::Type_JsonObject,
                        "Identifier of the storage commitment report, to be used with `/storage-commitment/{id}`")
        .SetAnswerField("Path", RestApiCallDocumentation::Type_JsonObject,
                        "Root path to the storage commitment report in the REST API")
        .SetUriArgument("id", "Identifier of the modality of interest");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    Json::Value json;
    if (!call.ParseJsonRequest(json) ||
        json.type() != Json::objectValue)
    {
      throw OrthancException(ErrorCode_BadFileFormat,
                             "Must provide a JSON object with a list of resources");
    }
    else if (!json.isMember(ORTHANC_RESOURCES) &&
             !json.isMember(DICOM_INSTANCES))
    {
      throw OrthancException(ErrorCode_BadFileFormat,
                             "Empty storage commitment request, one of these fields is mandatory: \"" +
                             std::string(ORTHANC_RESOURCES) + "\" or \"" + std::string(DICOM_INSTANCES) + "\"");
    }
    else
    {
      std::list<std::string> sopClassUids, sopInstanceUids;

      if (json.isMember(ORTHANC_RESOURCES))
      {
        const Json::Value& resources = json[ORTHANC_RESOURCES];
          
        if (resources.type() != Json::arrayValue)
        {
          throw OrthancException(ErrorCode_BadFileFormat,
                                 "The \"" + std::string(ORTHANC_RESOURCES) +
                                 "\" field must provide an array of Orthanc resources");
        }
        else
        {
          for (Json::Value::ArrayIndex i = 0; i < resources.size(); i++)
          {
            if (resources[i].type() != Json::stringValue)
            {
              throw OrthancException(ErrorCode_BadFileFormat,
                                     "The \"" + std::string(ORTHANC_RESOURCES) +
                                     "\" field must provide an array of strings, found: " + resources[i].toStyledString());
            }

            std::list<std::string> instances;
            context.GetIndex().GetChildInstances(instances, resources[i].asString());
            
            for (std::list<std::string>::const_iterator
                   it = instances.begin(); it != instances.end(); ++it)
            {
              std::string sopClassUid, sopInstanceUid;
              DicomMap tags;
              if (context.LookupOrReconstructMetadata(sopClassUid, *it, ResourceType_Instance, MetadataType_Instance_SopClassUid) &&
                  context.GetIndex().GetAllMainDicomTags(tags, *it) &&
                  tags.LookupStringValue(sopInstanceUid, DICOM_TAG_SOP_INSTANCE_UID, false))
              {
                sopClassUids.push_back(sopClassUid);
                sopInstanceUids.push_back(sopInstanceUid);
              }
              else
              {
                throw OrthancException(ErrorCode_InternalError,
                                       "Cannot retrieve SOP Class/Instance UID of Orthanc instance: " + *it);
              }
            }
          }
        }
      }

      if (json.isMember(DICOM_INSTANCES))
      {
        const Json::Value& instances = json[DICOM_INSTANCES];
          
        if (instances.type() != Json::arrayValue)
        {
          throw OrthancException(ErrorCode_BadFileFormat,
                                 "The \"" + std::string(DICOM_INSTANCES) +
                                 "\" field must provide an array of DICOM instances");
        }
        else
        {
          for (Json::Value::ArrayIndex i = 0; i < instances.size(); i++)
          {
            if (instances[i].type() == Json::arrayValue)
            {
              if (instances[i].size() != 2 ||
                  instances[i][0].type() != Json::stringValue ||
                  instances[i][1].type() != Json::stringValue)
              {
                throw OrthancException(ErrorCode_BadFileFormat,
                                       "An instance entry must provide an array with 2 strings: "
                                       "SOP Class UID and SOP Instance UID");
              }
              else
              {
                sopClassUids.push_back(instances[i][0].asString());
                sopInstanceUids.push_back(instances[i][1].asString());
              }
            }
            else if (instances[i].type() == Json::objectValue)
            {
              if (!instances[i].isMember(SOP_CLASS_UID) ||
                  !instances[i].isMember(SOP_INSTANCE_UID) ||
                  instances[i][SOP_CLASS_UID].type() != Json::stringValue ||
                  instances[i][SOP_INSTANCE_UID].type() != Json::stringValue)
              {
                throw OrthancException(ErrorCode_BadFileFormat,
                                       "An instance entry must provide an object with 2 string fiels: "
                                       "\"" + std::string(SOP_CLASS_UID) + "\" and \"" +
                                       std::string(SOP_INSTANCE_UID));
              }
              else
              {
                sopClassUids.push_back(instances[i][SOP_CLASS_UID].asString());
                sopInstanceUids.push_back(instances[i][SOP_INSTANCE_UID].asString());
              }
            }
            else
            {
              throw OrthancException(ErrorCode_BadFileFormat,
                                     "JSON array or object is expected to specify one "
                                     "instance to be queried, found: " + instances[i].toStyledString());
            }
          }
        }
      }

      if (sopClassUids.size() != sopInstanceUids.size())
      {
        throw OrthancException(ErrorCode_InternalError);
      }

      const std::string transactionUid = Toolbox::GenerateDicomPrivateUniqueIdentifier();

      if (sopClassUids.empty())
      {
        LOG(WARNING) << "Issuing an outgoing storage commitment request that is empty: " << transactionUid;
      }

      {
        const RemoteModalityParameters remote =
          MyGetModalityUsingSymbolicName(call.GetUriComponent("id", ""));

        const std::string& remoteAet = remote.GetApplicationEntityTitle();
        const std::string& localAet = context.GetDefaultLocalApplicationEntityTitle();
        
        // Create a "pending" storage commitment report BEFORE the
        // actual SCU call in order to avoid race conditions
        context.GetStorageCommitmentReports().Store(
          transactionUid, new StorageCommitmentReports::Report(remoteAet));

        DicomAssociationParameters parameters(localAet, remote);
        InjectAssociationTimeout(parameters, json);
        
        std::vector<std::string> a(sopClassUids.begin(), sopClassUids.end());
        std::vector<std::string> b(sopInstanceUids.begin(), sopInstanceUids.end());
        DicomAssociation::RequestStorageCommitment(parameters, transactionUid, a, b);
      }

      Json::Value result = Json::objectValue;
      result["ID"] = transactionUid;
      result["Path"] = "/storage-commitment/" + transactionUid;
      call.GetOutput().AnswerJson(result);
    }
  }


  static void GetStorageCommitmentReport(RestApiGetCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Get storage commitment report")
        .SetDescription("Get the storage commitment report whose identifier is provided in the URL: "
                        "https://orthanc.uclouvain.be/book/users/storage-commitment.html#storage-commitment-scu")
        .SetAnswerField("Status", RestApiCallDocumentation::Type_String,
                        "Can be `Success`, `Failure`, or `Pending` (the latter means that no report has been received yet)")
        .SetAnswerField("RemoteAET", RestApiCallDocumentation::Type_String,
                        "AET of the remote DICOM modality")
        .SetAnswerField("Failures", RestApiCallDocumentation::Type_JsonListOfObjects,
                        "List of failures that have been encountered during the storage commitment request")
        .SetAnswerField("Success", RestApiCallDocumentation::Type_JsonListOfObjects,
                        "List of DICOM instances that have been acknowledged by the remote modality, "
                        "each one is reported as a JSON object containing the `SOPClassUID` and "
                        "`SOPInstanceUID` DICOM tags")
        .SetUriArgument("id", "Identifier of the storage commitment report");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    const std::string& transactionUid = call.GetUriComponent("id", "");

    {
      StorageCommitmentReports::Accessor accessor(
        context.GetStorageCommitmentReports(), transactionUid);

      if (accessor.IsValid())
      {
        Json::Value json;
        accessor.GetReport().Format(json);
        call.GetOutput().AnswerJson(json);
      }
      else
      {
        throw OrthancException(ErrorCode_InexistentItem,
                               "No storage commitment transaction with UID: " + transactionUid);
      }
    }
  }
  

  static void RemoveAfterStorageCommitment(RestApiPostCall& call)
  {
    if (call.IsDocumentation())
    {
      call.GetDocumentation()
        .SetTag("Networking")
        .SetSummary("Remove after storage commitment")
        .SetDescription("Remove out of Orthanc, the DICOM instances that have been reported to have been properly "
                        "received the storage commitment report whose identifier is provided in the URL. This is "
                        "only possible if the `Status` of the storage commitment report is `Success`. "
                        "https://orthanc.uclouvain.be/book/users/storage-commitment.html#removing-the-instances")
        .SetUriArgument("id", "Identifier of the storage commitment report");
      return;
    }

    ServerContext& context = OrthancRestApi::GetContext(call);

    const std::string& transactionUid = call.GetUriComponent("id", "");

    {
      StorageCommitmentReports::Accessor accessor(
        context.GetStorageCommitmentReports(), transactionUid);

      if (!accessor.IsValid())
      {
        throw OrthancException(ErrorCode_InexistentItem,
                               "No storage commitment transaction with UID: " + transactionUid);
      }
      else if (accessor.GetReport().GetStatus() != StorageCommitmentReports::Report::Status_Success)
      {
        throw OrthancException(ErrorCode_BadSequenceOfCalls,
                               "Cannot remove DICOM instances after failure "
                               "in storage commitment transaction: " + transactionUid);
      }
      else
      {
        std::vector<std::string> sopInstanceUids;
        accessor.GetReport().GetSuccessSopInstanceUids(sopInstanceUids);

        for (size_t i = 0; i < sopInstanceUids.size(); i++)
        {
          std::vector<std::string> orthancId;
          context.GetIndex().LookupIdentifierExact(
            orthancId, ResourceType_Instance, DICOM_TAG_SOP_INSTANCE_UID, sopInstanceUids[i]);

          for (size_t j = 0; j < orthancId.size(); j++)
          {
            CLOG(INFO, HTTP) << "Storage commitment - Removing SOP instance UID / Orthanc ID: "
                             << sopInstanceUids[i] << " / " << orthancId[j];

            Json::Value tmp;
            context.GetIndex().DeleteResource(tmp, orthancId[j], ResourceType_Instance);
          }
        }
          
        call.GetOutput().AnswerBuffer("{}", MimeType_Json);
      }
    }
  }
  

  void OrthancRestApi::RegisterModalities()
  {
    Register("/modalities", ListModalities);
    Register("/modalities/{id}", ListModalityOperations);
    Register("/modalities/{id}", UpdateModality);
    Register("/modalities/{id}", DeleteModality);
    Register("/modalities/{id}/echo", DicomEcho);
    Register("/modalities/{id}/find-patient", DicomFindPatient);
    Register("/modalities/{id}/find-study", DicomFindStudy);
    Register("/modalities/{id}/find-series", DicomFindSeries);
    Register("/modalities/{id}/find-instance", DicomFindInstance);
    Register("/modalities/{id}/find", DicomFind);
    Register("/modalities/{id}/store", DicomStore);
    Register("/modalities/{id}/store-straight", DicomStoreStraight);  // New in 1.6.1
    Register("/modalities/{id}/move", DicomMove);
    Register("/modalities/{id}/get", DicomGet);
    Register("/modalities/{id}/configuration", GetModalityConfiguration);  // New in 1.8.1

    // For Query/Retrieve
    Register("/modalities/{id}/query", DicomQuery);
    Register("/queries", ListQueries);
    Register("/queries/{id}", DeleteQuery);
    Register("/queries/{id}", ListQueryOperations);
    Register("/queries/{id}/answers", ListQueryAnswers);
    Register("/queries/{id}/answers/{index}", ListQueryAnswerOperations);
    Register("/queries/{id}/answers/{index}/content", GetQueryOneAnswer);
    Register("/queries/{id}/answers/{index}/retrieve", RetrieveOneAnswer);
    Register("/queries/{id}/answers/{index}/query-instances",
             QueryAnswerChildren<ResourceType_Instance>);
    Register("/queries/{id}/answers/{index}/query-series",
             QueryAnswerChildren<ResourceType_Series>);
    Register("/queries/{id}/answers/{index}/query-studies",
             QueryAnswerChildren<ResourceType_Study>);
    Register("/queries/{id}/level", GetQueryLevel);
    Register("/queries/{id}/modality", GetQueryModality);
    Register("/queries/{id}/query", GetQueryArguments);
    Register("/queries/{id}/retrieve", RetrieveAllAnswers);

    Register("/peers", ListPeers);
    Register("/peers/{id}", ListPeerOperations);
    Register("/peers/{id}", UpdatePeer);
    Register("/peers/{id}", DeletePeer);
    Register("/peers/{id}/store", PeerStore);
    Register("/peers/{id}/system", PeerSystem);
    Register("/peers/{id}/configuration", GetPeerConfiguration);  // New in 1.8.1
    Register("/peers/{id}/store-straight", PeerStoreStraight);    // New in 1.9.1

    Register("/modalities/{id}/find-worklist", DicomFindWorklist);

    // Storage commitment
    Register("/modalities/{id}/storage-commitment", StorageCommitmentScu);
    Register("/storage-commitment/{id}", GetStorageCommitmentReport);
    Register("/storage-commitment/{id}/remove", RemoveAfterStorageCommitment);

    Register("/tools/dicom-echo", DicomEchoTool);  // New in 1.8.1
  }
}