changeset 4399:80fd140b12ba

New command-line option: "--openapi" to write the OpenAPI documentation of the REST API to a file
author Sebastien Jodogne <s.jodogne@gmail.com>
date Wed, 23 Dec 2020 12:21:03 +0100
parents 38c22715bb56
children 029366f95217
files NEWS OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake OrthancFramework/Sources/Enumerations.cpp OrthancFramework/Sources/Enumerations.h OrthancFramework/Sources/RestApi/RestApi.cpp OrthancFramework/Sources/RestApi/RestApi.h OrthancFramework/Sources/RestApi/RestApiCall.cpp OrthancFramework/Sources/RestApi/RestApiCall.h OrthancFramework/Sources/RestApi/RestApiCallDocumentation.cpp OrthancFramework/Sources/RestApi/RestApiCallDocumentation.h OrthancFramework/Sources/RestApi/RestApiDeleteCall.h OrthancFramework/Sources/RestApi/RestApiGetCall.h OrthancFramework/Sources/RestApi/RestApiHierarchy.cpp OrthancFramework/Sources/RestApi/RestApiHierarchy.h OrthancFramework/Sources/RestApi/RestApiOutput.h OrthancFramework/Sources/RestApi/RestApiPostCall.h OrthancFramework/Sources/RestApi/RestApiPutCall.h OrthancFramework/Sources/SystemToolbox.cpp OrthancFramework/UnitTestsSources/FrameworkTests.cpp OrthancFramework/UnitTestsSources/RestApiTests.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestApi.cpp OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp OrthancServer/Sources/main.cpp
diffstat 23 files changed, 1212 insertions(+), 132 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Tue Dec 22 09:39:06 2020 +0100
+++ b/NEWS	Wed Dec 23 12:21:03 2020 +0100
@@ -1,6 +1,7 @@
 Pending changes in the mainline
 ===============================
 
+* New command-line option: "--openapi" to write the OpenAPI documentation of the REST API to a file
 * Upgraded dependencies for static builds (notably on Windows):
   - jsoncpp 1.9.4
 
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Wed Dec 23 12:21:03 2020 +0100
@@ -311,6 +311,7 @@
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/HttpServer/StringHttpOutput.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/RestApi/RestApi.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/RestApi/RestApiCall.cpp
+    ${CMAKE_CURRENT_LIST_DIR}/../../Sources/RestApi/RestApiCallDocumentation.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/RestApi/RestApiGetCall.cpp
     ${CMAKE_CURRENT_LIST_DIR}/../../Sources/RestApi/RestApiOutput.cpp
     )
--- a/OrthancFramework/Sources/Enumerations.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/Enumerations.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -2282,6 +2282,97 @@
 
     LOG(INFO) << "Default encoding for DICOM was changed to: " << name;
   }
+
+
+  const char* GetResourceTypeText(ResourceType type,
+                                  bool isPlural,
+                                  bool isUpperCase)
+  {
+    if (isPlural && !isUpperCase)
+    {
+      switch (type)
+      {
+        case ResourceType_Patient:
+          return "patients";
+
+        case ResourceType_Study:
+          return "studies";
+
+        case ResourceType_Series:
+          return "series";
+
+        case ResourceType_Instance:
+          return "instances";
+      
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+    }
+    else if (isPlural && isUpperCase)
+    {
+      switch (type)
+      {
+        case ResourceType_Patient:
+          return "Patients";
+
+        case ResourceType_Study:
+          return "Studies";
+
+        case ResourceType_Series:
+          return "Series";
+
+        case ResourceType_Instance:
+          return "Instances";
+      
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+    }
+    else if (!isPlural && !isUpperCase)
+    {
+      switch (type)
+      {
+        case ResourceType_Patient:
+          return "patient";
+
+        case ResourceType_Study:
+          return "study";
+
+        case ResourceType_Series:
+          return "series";
+
+        case ResourceType_Instance:
+          return "instance";
+      
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+    }
+    else if (!isPlural && isUpperCase)
+    {
+      switch (type)
+      {
+        case ResourceType_Patient:
+          return "Patient";
+
+        case ResourceType_Study:
+          return "Study";
+
+        case ResourceType_Series:
+          return "Series";
+
+        case ResourceType_Instance:
+          return "Instance";
+      
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+    }
+    else
+    {
+      throw OrthancException(ErrorCode_InternalError);
+    }
+  }
 }
 
 
--- a/OrthancFramework/Sources/Enumerations.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/Enumerations.h	Wed Dec 23 12:21:03 2020 +0100
@@ -499,7 +499,8 @@
     RequestOrigin_RestApi,
     RequestOrigin_Plugins,
     RequestOrigin_Lua,
-    RequestOrigin_WebDav   // New in Orthanc 1.8.0
+    RequestOrigin_WebDav,   // New in Orthanc 1.8.0
+    RequestOrigin_Documentation  // New in Orthanc in Orthanc 1.8.3 for API documentation (OpenAPI)
   };
 
   enum ServerBarrierEvent
@@ -885,4 +886,9 @@
   ORTHANC_PUBLIC
   bool LookupTransferSyntax(DicomTransferSyntax& target,
                             const std::string& uid);
+
+  ORTHANC_PUBLIC
+  const char* GetResourceTypeText(ResourceType type,
+                                  bool isPlural,
+                                  bool isLowerCase);
 }
--- a/OrthancFramework/Sources/RestApi/RestApi.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApi.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -23,7 +23,9 @@
 #include "../PrecompiledHeaders.h"
 #include "RestApi.h"
 
+#include "../HttpServer/StringHttpOutput.h"
 #include "../Logging.h"
+#include "../OrthancException.h"
 
 #include <stdlib.h>   // To define "_exit()" under Windows
 #include <stdio.h>
@@ -73,10 +75,11 @@
 
       virtual bool Visit(const RestApiHierarchy::Resource& resource,
                          const UriComponents& uri,
+                         bool hasTrailing,
                          const HttpToolbox::Arguments& components,
                          const UriComponents& trailing)
       {
-        if (resource.HasHandler(method_))
+        if (resource.HasMethod(method_))
         {
           switch (method_)
           {
@@ -120,6 +123,190 @@
         return false;
       }
     };
+
+
+
+    class OpenApiVisitor : public RestApiHierarchy::IVisitor
+    {
+    private:
+      RestApi&    restApi_;
+      Json::Value paths_;
+  
+    public:
+      OpenApiVisitor(RestApi& restApi) :
+        restApi_(restApi)
+      {
+      }
+  
+      virtual bool Visit(const RestApiHierarchy::Resource& resource,
+                         const UriComponents& uri,
+                         bool hasTrailing,
+                         const HttpToolbox::Arguments& components,
+                         const UriComponents& trailing)
+      {
+        const std::string path = Toolbox::FlattenUri(uri);
+
+        if (hasTrailing)
+          LOG(WARNING) << ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> " << path;
+
+        if (paths_.isMember(path))
+        {
+          throw OrthancException(ErrorCode_InternalError);
+        }
+
+        //if (path == "/patients/{id}/protected")
+        //asm("int $3");
+
+        if (resource.HasMethod(HttpMethod_Get))
+        {
+          StringHttpOutput o1;
+          HttpOutput o2(o1, false);
+          RestApiOutput o3(o2, HttpMethod_Get);
+          RestApiGetCall call(o3, restApi_, RequestOrigin_Documentation, "" /* remote IP */,
+                              "" /* username */, HttpToolbox::Arguments() /* HTTP headers */,
+                              HttpToolbox::Arguments() /* URI components */,
+                              UriComponents() /* trailing */,
+                              uri, HttpToolbox::Arguments() /* GET arguments */);
+
+          bool ok = false;
+          Json::Value v;
+      
+          try
+          {
+            ok = (resource.Handle(call) &&
+                  call.GetDocumentation().FormatOpenApi(v));
+          }
+          catch (OrthancException&)
+          {
+          }
+          catch (boost::bad_lexical_cast&)
+          {
+          }
+
+          if (ok)
+          {
+            paths_[path]["get"] = v;
+          }
+          else
+          {
+            LOG(WARNING) << "Ignoring URI without API documentation: GET " << path;
+          }
+        }
+    
+        if (resource.HasMethod(HttpMethod_Post))
+        {
+          StringHttpOutput o1;
+          HttpOutput o2(o1, false);
+          RestApiOutput o3(o2, HttpMethod_Post);
+          RestApiPostCall call(o3, restApi_, RequestOrigin_Documentation, "" /* remote IP */,
+                               "" /* username */, HttpToolbox::Arguments() /* HTTP headers */,
+                               HttpToolbox::Arguments() /* URI components */,
+                               UriComponents() /* trailing */, uri, NULL /* body */, 0 /* body size */);
+
+          bool ok = false;
+          Json::Value v;
+      
+          try
+          {
+            ok = (resource.Handle(call) &&
+                  call.GetDocumentation().FormatOpenApi(v));
+          }
+          catch (OrthancException&)
+          {
+          }
+          catch (boost::bad_lexical_cast&)
+          {
+          }
+
+          if (ok)
+          {
+            paths_[path]["post"] = v;
+          }
+          else
+          {
+            LOG(WARNING) << "Ignoring URI without API documentation: POST " << path;
+          }
+        }
+    
+        if (resource.HasMethod(HttpMethod_Delete))
+        {
+          StringHttpOutput o1;
+          HttpOutput o2(o1, false);
+          RestApiOutput o3(o2, HttpMethod_Delete);
+          RestApiDeleteCall call(o3, restApi_, RequestOrigin_Documentation, "" /* remote IP */,
+                                 "" /* username */, HttpToolbox::Arguments() /* HTTP headers */,
+                                 HttpToolbox::Arguments() /* URI components */,
+                                 UriComponents() /* trailing */, uri);
+
+          bool ok = false;
+          Json::Value v;
+      
+          try
+          {
+            ok = (resource.Handle(call) &&
+                  call.GetDocumentation().FormatOpenApi(v));
+          }
+          catch (OrthancException&)
+          {
+          }
+          catch (boost::bad_lexical_cast&)
+          {
+          }
+
+          if (ok)
+          {
+            paths_[path]["delete"] = v;
+          }
+          else
+          {
+            LOG(WARNING) << "Ignoring URI without API documentation: DELETE " << path;
+          }
+        }
+
+        if (resource.HasMethod(HttpMethod_Put))
+        {
+          StringHttpOutput o1;
+          HttpOutput o2(o1, false);
+          RestApiOutput o3(o2, HttpMethod_Put);
+          RestApiPutCall call(o3, restApi_, RequestOrigin_Documentation, "" /* remote IP */,
+                              "" /* username */, HttpToolbox::Arguments() /* HTTP headers */,
+                              HttpToolbox::Arguments() /* URI components */,
+                              UriComponents() /* trailing */, uri, NULL /* body */, 0 /* body size */);
+
+          bool ok = false;
+          Json::Value v;
+      
+          try
+          {
+            ok = (resource.Handle(call) &&
+                  call.GetDocumentation().FormatOpenApi(v));
+          }
+          catch (OrthancException&)
+          {
+          }
+          catch (boost::bad_lexical_cast&)
+          {
+          }
+
+          if (ok)
+          {
+            paths_[path]["put"] = v;
+          }
+          else
+          {
+            LOG(WARNING) << "Ignoring URI without API documentation: PUT " << path;
+          }
+        }
+    
+        return true;
+      }
+
+
+      const Json::Value& GetPaths() const
+      {
+        return paths_;
+      }
+    };
   }
 
 
@@ -162,6 +349,18 @@
 
 
 
+  bool RestApi::CreateChunkedRequestReader(std::unique_ptr<IChunkedRequestReader>& target,
+                                           RequestOrigin origin,
+                                           const char* remoteIp,
+                                           const char* username,
+                                           HttpMethod method,
+                                           const UriComponents& uri,
+                                           const HttpToolbox::Arguments& headers)
+  {
+    return false;
+  }
+
+
   bool RestApi::Handle(HttpOutput& output,
                        RequestOrigin origin,
                        const char* remoteIp,
@@ -255,13 +454,47 @@
   }
   
   void RestApi::AutoListChildren(RestApiGetCall& call)
-  {
+  {    
+    call.GetDocumentation()
+      .SetTag("Other")
+      .SetSummary("List of operations")
+      .SetDescription("List the available operations under URI: " + call.FlattenUri())
+      .AddAnswerType(MimeType_Json, "List of the available operations");
+
     RestApi& context = call.GetContext();
 
     Json::Value directory;
     if (context.root_.GetDirectory(directory, call.GetFullUri()))
     {
-      call.GetOutput().AnswerJson(directory);
+      if (call.IsDocumentation())
+      {
+        call.GetDocumentation().SetSample(directory);
+      }
+      else
+      {
+        call.GetOutput().AnswerJson(directory);
+      }
     }    
   }
+
+
+  void RestApi::GenerateOpenApiDocumentation(Json::Value& target)
+  {
+    OpenApiVisitor visitor(*this);
+    
+    UriComponents root;
+    root_.ExploreAllResources(visitor, root);
+
+    target = Json::objectValue;
+
+    target["info"]["version"] = ORTHANC_VERSION;
+    target["info"]["title"] = "Orthanc";
+
+    target["openapi"] = "3.0.0";
+
+    target["servers"].append(Json::objectValue);
+    target["servers"][0]["url"] = "https://demo.orthanc-server.com/";
+
+    target["paths"] = visitor.GetPaths();
+  }
 }
--- a/OrthancFramework/Sources/RestApi/RestApi.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApi.h	Wed Dec 23 12:21:03 2020 +0100
@@ -44,10 +44,7 @@
                                             const char* username,
                                             HttpMethod method,
                                             const UriComponents& uri,
-                                            const HttpToolbox::Arguments& headers) ORTHANC_OVERRIDE
-    {
-      return false;
-    }
+                                            const HttpToolbox::Arguments& headers) ORTHANC_OVERRIDE;
 
     virtual bool Handle(HttpOutput& output,
                         RequestOrigin origin,
@@ -71,5 +68,7 @@
 
     void Register(const std::string& path,
                   RestApiDeleteCall::Handler handler);
+
+    void GenerateOpenApiDocumentation(Json::Value& target);
   };
 }
--- a/OrthancFramework/Sources/RestApi/RestApiCall.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiCall.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -36,4 +36,15 @@
 
     return s;
   }
+
+
+  RestApiCallDocumentation& RestApiCall::GetDocumentation()
+  {
+    if (documentation_.get() == NULL)
+    {
+      documentation_.reset(new RestApiCallDocumentation(method_));
+    }
+    
+    return *documentation_;
+  }
 }
--- a/OrthancFramework/Sources/RestApi/RestApiCall.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiCall.h	Wed Dec 23 12:21:03 2020 +0100
@@ -23,6 +23,7 @@
 #pragma once
 
 #include "../HttpServer/HttpToolbox.h"
+#include "RestApiCallDocumentation.h"
 #include "RestApiPath.h"
 #include "RestApiOutput.h"
 
@@ -44,6 +45,8 @@
     const HttpToolbox::Arguments& uriComponents_;
     const UriComponents& trailing_;
     const UriComponents& fullUri_;
+    HttpMethod method_;  // To create RestApiCallDocumentation on demand
+    std::unique_ptr<RestApiCallDocumentation>  documentation_;  // Lazy creation
 
   public:
     RestApiCall(RestApiOutput& output,
@@ -51,6 +54,7 @@
                 RequestOrigin origin,
                 const char* remoteIp,
                 const char* username,
+                HttpMethod method,
                 const HttpToolbox::Arguments& httpHeaders,
                 const HttpToolbox::Arguments& uriComponents,
                 const UriComponents& trailing,
@@ -63,7 +67,8 @@
       httpHeaders_(httpHeaders),
       uriComponents_(uriComponents),
       trailing_(trailing),
-      fullUri_(fullUri)
+      fullUri_(fullUri),
+      method_(method)
     {
     }
 
@@ -127,5 +132,12 @@
     }
 
     virtual bool ParseJsonRequest(Json::Value& result) const = 0;
+
+    RestApiCallDocumentation& GetDocumentation();
+
+    bool IsDocumentation() const
+    {
+      return (origin_ == RequestOrigin_Documentation);
+    }
   };
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Sources/RestApi/RestApiCallDocumentation.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -0,0 +1,364 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2020 Osimis S.A., Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#include "../PrecompiledHeaders.h"
+#include "RestApiCallDocumentation.h"
+
+#if ORTHANC_ENABLE_CURL == 1
+#  include "../HttpClient.h"
+#endif
+
+#include "../Logging.h"
+#include "../OrthancException.h"
+
+
+namespace Orthanc
+{
+  RestApiCallDocumentation& RestApiCallDocumentation::AddRequestType(MimeType mime,
+                                                                     const std::string& description)
+  {
+    if (method_ != HttpMethod_Post &&
+        method_ != HttpMethod_Put)
+    {
+      throw OrthancException(ErrorCode_BadParameterType, "Request body is only allowed on POST and PUT");
+    }
+    else if (requestTypes_.find(mime) != requestTypes_.end() &&
+             mime != MimeType_Json)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls, "Cannot register twice the same type of request: " +
+                             std::string(EnumerationToString(mime)));
+    }
+    else
+    {
+      requestTypes_[mime] = description;
+    }
+        
+    return *this;
+  }
+
+
+  RestApiCallDocumentation& RestApiCallDocumentation::SetRequestField(const std::string& name,
+                                                                      Type type,
+                                                                      const std::string& description)
+  {
+    if (method_ != HttpMethod_Post &&
+        method_ != HttpMethod_Put)
+    {
+      throw OrthancException(ErrorCode_BadParameterType, "Request body is only allowed on POST and PUT");
+    }    
+
+    if (requestTypes_.find(MimeType_Json) == requestTypes_.end())
+    {
+      requestTypes_[MimeType_Json] = "";
+    }
+    
+    if (requestFields_.find(name) != requestFields_.end())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange, "Field \"" + name + "\" of JSON request is already documented");
+    }
+    else
+    {
+      Parameter p;
+      p.type_ = type;
+      p.description_ = description;
+      requestFields_[name] = p;
+      return *this;
+    }    
+  }
+
+
+  RestApiCallDocumentation& RestApiCallDocumentation::AddAnswerType(MimeType mime,
+                                                                    const std::string& description)
+  {
+    if (answerTypes_.find(mime) != answerTypes_.end() &&
+        mime != MimeType_Json)
+    {
+      throw OrthancException(ErrorCode_BadSequenceOfCalls, "Cannot register twice the same type of answer: " +
+                             std::string(EnumerationToString(mime)));
+    }
+    else
+    {
+      answerTypes_[mime] = description;
+    }
+
+    return *this;
+  }
+  
+
+  RestApiCallDocumentation& RestApiCallDocumentation::SetUriComponent(const std::string& name,
+                                                                      Type type,
+                                                                      const std::string& description)
+  {
+    if (uriComponents_.find(name) != uriComponents_.end())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange, "URI component \"" + name + "\" is already documented");
+    }
+    else
+    {
+      Parameter p;
+      p.type_ = type;
+      p.description_ = description;
+      uriComponents_[name] = p;
+      return *this;
+    }
+  }
+
+
+  RestApiCallDocumentation& RestApiCallDocumentation::SetHttpHeader(const std::string& name,
+                                                                    const std::string& description)
+  {
+    if (httpHeaders_.find(name) != httpHeaders_.end())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange, "HTTP header \"" + name + "\" is already documented");
+    }
+    else
+    {
+      Parameter p;
+      p.type_ = Type_String;
+      p.description_ = description;
+      httpHeaders_[name] = p;
+      return *this;
+    }
+  }
+
+
+  RestApiCallDocumentation& RestApiCallDocumentation::SetHttpGetArgument(const std::string& name,
+                                                                         Type type,
+                                                                         const std::string& description)
+  {
+    if (method_ != HttpMethod_Get)
+    {
+      throw OrthancException(ErrorCode_InternalError, "Cannot set a HTTP GET argument on HTTP method: " +
+                             std::string(EnumerationToString(method_)));
+    }    
+    else if (getArguments_.find(name) != getArguments_.end())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange, "GET argument \"" + name + "\" is already documented");
+    }
+    else
+    {
+      Parameter p;
+      p.type_ = type;
+      p.description_ = description;
+      getArguments_[name] = p;
+      return *this;
+    }
+  }
+
+  
+  RestApiCallDocumentation& RestApiCallDocumentation::SetAnswerField(const std::string& name,
+                                                                     Type type,
+                                                                     const std::string& description)
+  {
+    if (answerTypes_.find(MimeType_Json) == answerTypes_.end())
+    {
+      answerTypes_[MimeType_Json] = "";
+    }
+    
+    if (answerFields_.find(name) != answerFields_.end())
+    {
+      throw OrthancException(ErrorCode_ParameterOutOfRange, "Field \"" + name + "\" of JSON answer is already documented");
+    }
+    else
+    {
+      Parameter p;
+      p.type_ = type;
+      p.description_ = description;
+      answerFields_[name] = p;
+      return *this;
+    }    
+  }
+
+
+  void RestApiCallDocumentation::SetHttpGetSample(const std::string& url)
+  {
+#if ORTHANC_ENABLE_CURL == 1
+    HttpClient client;
+    client.SetUrl(url);
+    client.SetHttpsVerifyPeers(false);
+    if (!client.Apply(sample_))
+    {
+      LOG(ERROR) << "Cannot GET: " << url;
+      sample_ = Json::nullValue;
+    }
+#else
+    LOG(WARNING) << "HTTP client is not available to generated the documentation";
+#endif
+  }
+
+
+  static const char* TypeToString(RestApiCallDocumentation::Type type)
+  {
+    switch (type)
+    {
+      case RestApiCallDocumentation::Type_Unknown:
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+
+      case RestApiCallDocumentation::Type_String:
+      case RestApiCallDocumentation::Type_Text:
+        return "string";
+
+      case RestApiCallDocumentation::Type_Number:
+        return "number";
+
+      case RestApiCallDocumentation::Type_Boolean:
+        return "boolean";
+
+      case RestApiCallDocumentation::Type_JsonObject:
+      case RestApiCallDocumentation::Type_JsonListOfStrings:
+        return "object";
+
+      default:
+        throw OrthancException(ErrorCode_ParameterOutOfRange);
+    }
+  }
+
+
+  bool RestApiCallDocumentation::FormatOpenApi(Json::Value& target) const
+  {
+    if (summary_.empty() &&
+        description_.empty())
+    {
+      return false;
+    }
+    else
+    {
+      target = Json::objectValue;
+    
+      if (!tag_.empty())
+      {
+        target["tags"].append(tag_);
+      }
+
+      if (!summary_.empty())
+      {
+        target["summary"] = summary_;
+      }
+      else if (!description_.empty())
+      {
+        target["summary"] = description_;
+      }
+
+      if (!description_.empty())
+      {
+        target["description"] = description_;
+      }
+      else if (!summary_.empty())
+      {
+        target["description"] = summary_;
+      }
+
+      if (method_ == HttpMethod_Post ||
+          method_ == HttpMethod_Put)
+      {
+        for (AllowedTypes::const_iterator it = requestTypes_.begin();
+             it != requestTypes_.end(); ++it)
+        {
+          Json::Value& schema = target["requestBody"]["content"][EnumerationToString(it->first)]["schema"];
+          schema["description"] = it->second;
+
+          if (it->first == MimeType_Json)
+          {
+            for (Parameters::const_iterator it = requestFields_.begin();
+                 it != requestFields_.end(); ++it)
+            {
+              Json::Value p = Json::objectValue;
+              p["type"] = TypeToString(it->second.type_);
+              p["description"] = it->second.description_;
+              schema["properties"][it->first] = p;         
+            }        
+          }
+        }
+      }
+
+      target["responses"]["200"]["description"] = (answerDescription_.empty() ? "" : answerDescription_);
+
+      for (AllowedTypes::const_iterator it = answerTypes_.begin();
+           it != answerTypes_.end(); ++it)
+      {
+        Json::Value& schema = target["responses"]["200"]["content"][EnumerationToString(it->first)]["schema"];
+        schema["description"] = it->second;
+
+        if (it->first == MimeType_Json)
+        {
+          for (Parameters::const_iterator it = answerFields_.begin();
+               it != answerFields_.end(); ++it)
+          {
+            Json::Value p = Json::objectValue;
+            p["type"] = TypeToString(it->second.type_);
+            p["description"] = it->second.description_;
+            schema["properties"][it->first] = p;         
+          }        
+        }
+      }
+
+      if (sample_.type() != Json::nullValue)
+      {
+        target["responses"]["200"]["content"]["application/json"]["schema"]["example"] = sample_;
+      }
+      else
+      {
+        target["responses"]["200"]["content"]["application/json"]["examples"] = Json::arrayValue;
+      }
+
+      Json::Value parameters = Json::arrayValue;
+        
+      for (Parameters::const_iterator it = getArguments_.begin();
+           it != getArguments_.end(); ++it)
+      {
+        Json::Value p = Json::objectValue;
+        p["name"] = it->first;
+        p["in"] = "query";
+        p["schema"]["type"] = TypeToString(it->second.type_);
+        p["description"] = it->second.description_;
+        parameters.append(p);         
+      }
+
+      for (Parameters::const_iterator it = httpHeaders_.begin();
+           it != httpHeaders_.end(); ++it)
+      {
+        Json::Value p = Json::objectValue;
+        p["name"] = it->first;
+        p["in"] = "header";
+        p["schema"]["type"] = TypeToString(it->second.type_);
+        p["description"] = it->second.description_;
+        parameters.append(p);         
+      }
+
+      for (Parameters::const_iterator it = uriComponents_.begin();
+           it != uriComponents_.end(); ++it)
+      {
+        Json::Value p = Json::objectValue;
+        p["name"] = it->first;
+        p["in"] = "path";
+        p["required"] = true;
+        p["schema"]["type"] = TypeToString(it->second.type_);
+        p["description"] = it->second.description_;
+        parameters.append(p);         
+      }
+
+      target["parameters"] = parameters;
+
+      return true;
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Sources/RestApi/RestApiCallDocumentation.h	Wed Dec 23 12:21:03 2020 +0100
@@ -0,0 +1,132 @@
+/**
+ * Orthanc - A Lightweight, RESTful DICOM Store
+ * Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+ * Department, University Hospital of Liege, Belgium
+ * Copyright (C) 2017-2020 Osimis S.A., Belgium
+ *
+ * This program is free software: you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser 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
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this program. If not, see
+ * <http://www.gnu.org/licenses/>.
+ **/
+
+
+#pragma once
+
+#include "../Enumerations.h"
+
+#include <boost/noncopyable.hpp>
+#include <json/value.h>
+
+#include <map>
+#include <set>
+
+namespace Orthanc
+{
+  class RestApiCallDocumentation : public boost::noncopyable
+  {
+  public:
+    enum Type
+    {
+      Type_Unknown,
+      Type_Text,
+      Type_String,
+      Type_Number,
+      Type_Boolean,
+      Type_JsonListOfStrings,
+      Type_JsonObject
+    };    
+    
+  private:
+    struct Parameter
+    {
+      Type         type_;
+      std::string  description_;
+    };
+    
+    typedef std::map<std::string, Parameter>  Parameters;
+    typedef std::map<MimeType, std::string>   AllowedTypes;
+
+    HttpMethod    method_;
+    std::string   tag_;
+    std::string   summary_;
+    std::string   description_;
+    Parameters    uriComponents_;
+    Parameters    httpHeaders_;
+    Parameters    getArguments_;
+    AllowedTypes  requestTypes_;
+    Parameters    requestFields_;  // For JSON request
+    AllowedTypes  answerTypes_;
+    Parameters    answerFields_;  // Only if JSON object
+    std::string   answerDescription_;
+    Json::Value   sample_;
+
+  public:
+    RestApiCallDocumentation(HttpMethod method) :
+      method_(method),
+      sample_(Json::nullValue)
+    {
+    }
+    
+    RestApiCallDocumentation& SetTag(const std::string& tag)
+    {
+      tag_ = tag;
+      return *this;
+    }
+
+    RestApiCallDocumentation& SetSummary(const std::string& summary)
+    {
+      summary_ = summary;
+      return *this;
+    }
+
+    RestApiCallDocumentation& SetDescription(const std::string& description)
+    {
+      description_ = description;
+      return *this;
+    }
+
+    RestApiCallDocumentation& AddRequestType(MimeType mime,
+                                             const std::string& description);
+
+    RestApiCallDocumentation& SetRequestField(const std::string& name,
+                                              Type type,
+                                              const std::string& description);
+
+    RestApiCallDocumentation& AddAnswerType(MimeType type,
+                                            const std::string& description);
+
+    RestApiCallDocumentation& SetUriComponent(const std::string& name,
+                                              Type type,
+                                              const std::string& description);
+
+    RestApiCallDocumentation& SetHttpHeader(const std::string& name,
+                                            const std::string& description);
+
+    RestApiCallDocumentation& SetHttpGetArgument(const std::string& name,
+                                                 Type type,
+                                                 const std::string& description);
+
+    RestApiCallDocumentation& SetAnswerField(const std::string& name,
+                                             Type type,
+                                             const std::string& description);
+
+    void SetHttpGetSample(const std::string& url);
+
+    void SetSample(const Json::Value& sample)
+    {
+      sample_ = sample;
+    }
+
+    bool FormatOpenApi(Json::Value& target) const;
+  };
+}
--- a/OrthancFramework/Sources/RestApi/RestApiDeleteCall.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiDeleteCall.h	Wed Dec 23 12:21:03 2020 +0100
@@ -40,7 +40,7 @@
                       const HttpToolbox::Arguments& uriComponents,
                       const UriComponents& trailing,
                       const UriComponents& fullUri) :
-      RestApiCall(output, context, origin, remoteIp, username,
+      RestApiCall(output, context, origin, remoteIp, username, HttpMethod_Delete,
                   httpHeaders, uriComponents, trailing, fullUri)
     {
     }
--- a/OrthancFramework/Sources/RestApi/RestApiGetCall.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiGetCall.h	Wed Dec 23 12:21:03 2020 +0100
@@ -44,7 +44,7 @@
                    const UriComponents& trailing,
                    const UriComponents& fullUri,
                    const HttpToolbox::Arguments& getArguments) :
-      RestApiCall(output, context, origin, remoteIp, username, 
+      RestApiCall(output, context, origin, remoteIp, username, HttpMethod_Get,
                   httpHeaders, uriComponents, trailing, fullUri),
       getArguments_(getArguments)
     {
--- a/OrthancFramework/Sources/RestApi/RestApiHierarchy.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiHierarchy.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -39,7 +39,7 @@
   }
 
 
-  bool RestApiHierarchy::Resource::HasHandler(HttpMethod method) const
+  bool RestApiHierarchy::Resource::HasMethod(HttpMethod method) const
   {
     switch (method)
     {
@@ -186,7 +186,7 @@
     {
       if (path.IsUniversalTrailing())
       {
-        universalHandlers_.Register(handler);
+        handlersWithTrailing_.Register(handler);
       }
       else
       {
@@ -221,14 +221,14 @@
       return false;
     }
 
-    UriComponents trailing;
-
     // Look for an exact match on the resource of interest
     if (uri.size() == 0 ||
         level == uri.size())
     {
+      UriComponents noTrailing;
+
       if (!handlers_.IsEmpty() &&
-          visitor.Visit(handlers_, uri, components, trailing))
+          visitor.Visit(handlers_, uri, false, components, noTrailing))
       {
         return true;
       }
@@ -263,8 +263,9 @@
 
 
     // As a last resort, call the universal handlers, if any
-    if (!universalHandlers_.IsEmpty())
+    if (!handlersWithTrailing_.IsEmpty())
     {
+      UriComponents trailing;
       trailing.resize(uri.size() - level);
       size_t pos = 0;
       for (size_t i = level; i < uri.size(); i++, pos++)
@@ -274,7 +275,7 @@
 
       assert(pos == trailing.size());
 
-      if (visitor.Visit(universalHandlers_, uri, components, trailing))
+      if (visitor.Visit(handlersWithTrailing_, uri, true, components, trailing))
       {
         return true;
       }
@@ -286,7 +287,7 @@
 
   bool RestApiHierarchy::CanGenerateDirectory() const
   {
-    return (universalHandlers_.IsEmpty() &&
+    return (handlersWithTrailing_.IsEmpty() &&
             wildcardChildren_.empty());
   }
 
@@ -376,22 +377,22 @@
     target = Json::objectValue;
 
     /*std::string s = " ";
-      if (handlers_.HasHandler(HttpMethod_Get))
+      if (handlers_.HasMethod(HttpMethod_Get))
       {
       s += "GET ";
       }
 
-      if (handlers_.HasHandler(HttpMethod_Post))
+      if (handlers_.HasMethod(HttpMethod_Post))
       {
       s += "POST ";
       }
 
-      if (handlers_.HasHandler(HttpMethod_Put))
+      if (handlers_.HasMethod(HttpMethod_Put))
       {
       s += "PUT ";
       }
 
-      if (handlers_.HasHandler(HttpMethod_Delete))
+      if (handlers_.HasMethod(HttpMethod_Delete))
       {
       s += "DELETE ";
       }
@@ -443,27 +444,28 @@
 
       virtual bool Visit(const RestApiHierarchy::Resource& resource,
                          const UriComponents& uri,
+                         bool hasTrailing,
                          const HttpToolbox::Arguments& components,
                          const UriComponents& trailing)
       {
-        if (trailing.size() == 0)  // Ignore universal handlers
+        if (!hasTrailing)  // Ignore universal handlers
         {
-          if (resource.HasHandler(HttpMethod_Get))
+          if (resource.HasMethod(HttpMethod_Get))
           {
             methods_.insert(HttpMethod_Get);
           }
 
-          if (resource.HasHandler(HttpMethod_Post))
+          if (resource.HasMethod(HttpMethod_Post))
           {
             methods_.insert(HttpMethod_Post);
           }
 
-          if (resource.HasHandler(HttpMethod_Put))
+          if (resource.HasMethod(HttpMethod_Put))
           {
             methods_.insert(HttpMethod_Put);
           }
 
-          if (resource.HasHandler(HttpMethod_Delete))
+          if (resource.HasMethod(HttpMethod_Delete))
           {
             methods_.insert(HttpMethod_Delete);
           }
@@ -489,4 +491,35 @@
     }
   }
 
+  void RestApiHierarchy::ExploreAllResources(IVisitor& visitor,
+                                             const UriComponents& path) const
+  {
+    if (!handlers_.IsEmpty())
+    {
+      visitor.Visit(handlers_, path, false, HttpToolbox::Arguments(), UriComponents());
+    }
+
+    if (!handlersWithTrailing_.IsEmpty())
+    {
+      visitor.Visit(handlersWithTrailing_, path, true, HttpToolbox::Arguments(), UriComponents());
+    }
+    
+    for (Children::const_iterator
+           it = children_.begin(); it != children_.end(); ++it)
+    {
+      assert(it->second != NULL);
+      UriComponents c = path;
+      c.push_back(it->first);
+      it->second->ExploreAllResources(visitor, c);
+    }
+    
+    for (Children::const_iterator
+           it = wildcardChildren_.begin(); it != wildcardChildren_.end(); ++it)
+    {
+      assert(it->second != NULL);
+      UriComponents c = path;
+      c.push_back("{" + it->first + "}");
+      it->second->ExploreAllResources(visitor, c);
+    }
+  }
 }
--- a/OrthancFramework/Sources/RestApi/RestApiHierarchy.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiHierarchy.h	Wed Dec 23 12:21:03 2020 +0100
@@ -45,7 +45,7 @@
     public:
       Resource();
 
-      bool HasHandler(HttpMethod method) const;
+      bool HasMethod(HttpMethod method) const;
 
       void Register(RestApiGetCall::Handler handler);
 
@@ -76,7 +76,9 @@
 
       virtual bool Visit(const Resource& resource,
                          const UriComponents& uri,
-                         const HttpToolbox::Arguments& components,
+                         bool hasTrailing,
+                         // The two arguments below are empty if using "ExploreAllResources()"
+                         const HttpToolbox::Arguments& uriComponents,
                          const UriComponents& trailing) = 0;
     };
 
@@ -87,7 +89,7 @@
     Resource  handlers_;
     Children  children_;
     Children  wildcardChildren_;
-    Resource  universalHandlers_;
+    Resource  handlersWithTrailing_;
 
     static RestApiHierarchy& AddChild(Children& children,
                                       const std::string& name);
@@ -135,5 +137,8 @@
 
     void GetAcceptedMethods(std::set<HttpMethod>& methods,
                             const UriComponents& uri);
+
+    void ExploreAllResources(IVisitor& visitor,
+                             const UriComponents& path) const;
   };
 }
--- a/OrthancFramework/Sources/RestApi/RestApiOutput.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiOutput.h	Wed Dec 23 12:21:03 2020 +0100
@@ -32,10 +32,10 @@
   class RestApiOutput
   {
   private:
-    HttpOutput&  output_;
-    HttpMethod   method_;
-    bool         alreadySent_;
-    bool         convertJsonToXml_;
+    HttpOutput&           output_;
+    HttpMethod            method_;
+    bool                  alreadySent_;
+    bool                  convertJsonToXml_;
 
     void CheckStatus();
 
--- a/OrthancFramework/Sources/RestApi/RestApiPostCall.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiPostCall.h	Wed Dec 23 12:21:03 2020 +0100
@@ -46,7 +46,7 @@
                     const UriComponents& fullUri,
                     const void* bodyData,
                     size_t bodySize) :
-      RestApiCall(output, context, origin, remoteIp, username, 
+      RestApiCall(output, context, origin, remoteIp, username, HttpMethod_Post,
                   httpHeaders, uriComponents, trailing, fullUri),
       bodyData_(bodyData),
       bodySize_(bodySize)
--- a/OrthancFramework/Sources/RestApi/RestApiPutCall.h	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/RestApi/RestApiPutCall.h	Wed Dec 23 12:21:03 2020 +0100
@@ -46,7 +46,7 @@
                    const UriComponents& fullUri,
                    const void* bodyData,
                    size_t bodySize) :
-      RestApiCall(output, context, origin, remoteIp, username,
+      RestApiCall(output, context, origin, remoteIp, username, HttpMethod_Put,
                   httpHeaders, uriComponents, trailing, fullUri),
       bodyData_(bodyData),
       bodySize_(bodySize)
--- a/OrthancFramework/Sources/SystemToolbox.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/Sources/SystemToolbox.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -213,30 +213,41 @@
                              log);
     }
 
-    boost::filesystem::ifstream f;
-    f.open(path, std::ifstream::in | std::ifstream::binary);
-    if (!f.good())
+    try
     {
-      throw OrthancException(ErrorCode_InexistentFile,
-                             "File not found: " + path,
-                             log);
-    }
+      boost::filesystem::ifstream f;
+      f.open(path, std::ifstream::in | std::ifstream::binary);
+      if (!f.good())
+      {
+        throw OrthancException(ErrorCode_InexistentFile,
+                               "File not found: " + path,
+                               log);
+      }
 
-    std::streamsize size = GetStreamSize(f);
-    content.resize(static_cast<size_t>(size));
+      std::streamsize size = GetStreamSize(f);
+      content.resize(static_cast<size_t>(size));
 
-    if (static_cast<std::streamsize>(content.size()) != size)
-    {
-      throw OrthancException(ErrorCode_InternalError,
-                             "Reading a file that is too large for a 32bit architecture");
-    }
+      if (static_cast<std::streamsize>(content.size()) != size)
+      {
+        throw OrthancException(ErrorCode_InternalError,
+                               "Reading a file that is too large for a 32bit architecture");
+      }
     
-    if (size != 0)
+      if (size != 0)
+      {
+        f.read(&content[0], size);
+      }
+
+      f.close();
+    }
+    catch (boost::filesystem::filesystem_error&)
     {
-      f.read(&content[0], size);
+      throw OrthancException(ErrorCode_InexistentFile);
     }
-
-    f.close();
+    catch (std::system_error&)
+    {
+      throw OrthancException(ErrorCode_InexistentFile);
+    }
   }
 
 
@@ -256,38 +267,49 @@
                              "The path does not point to a regular file: " + path);
     }
 
-    boost::filesystem::ifstream f;
-    f.open(path, std::ifstream::in | std::ifstream::binary);
-    if (!f.good())
+    try
+    {
+      boost::filesystem::ifstream f;
+      f.open(path, std::ifstream::in | std::ifstream::binary);
+      if (!f.good())
+      {
+        throw OrthancException(ErrorCode_InexistentFile);
+      }
+
+      bool full = true;
+
+      {
+        std::streamsize size = GetStreamSize(f);
+        if (size <= 0)
+        {
+          headerSize = 0;
+          full = false;
+        }
+        else if (static_cast<size_t>(size) < headerSize)
+        {
+          headerSize = static_cast<size_t>(size);  // Truncate to the size of the file
+          full = false;
+        }
+      }
+
+      header.resize(headerSize);
+      if (headerSize != 0)
+      {
+        f.read(&header[0], headerSize);
+      }
+
+      f.close();
+
+      return full;
+    }
+    catch (boost::filesystem::filesystem_error&)
     {
       throw OrthancException(ErrorCode_InexistentFile);
     }
-
-    bool full = true;
-
+    catch (std::system_error&)
     {
-      std::streamsize size = GetStreamSize(f);
-      if (size <= 0)
-      {
-        headerSize = 0;
-        full = false;
-      }
-      else if (static_cast<size_t>(size) < headerSize)
-      {
-        headerSize = static_cast<size_t>(size);  // Truncate to the size of the file
-        full = false;
-      }
+      throw OrthancException(ErrorCode_InexistentFile);
     }
-
-    header.resize(headerSize);
-    if (headerSize != 0)
-    {
-      f.read(&header[0], headerSize);
-    }
-
-    f.close();
-
-    return full;
   }
 
 
@@ -296,56 +318,67 @@
                                 const std::string& path,
                                 bool callFsync)
   {
-    //boost::filesystem::ofstream f;
-    boost::iostreams::stream<boost::iostreams::file_descriptor_sink> f;
+    try
+    {
+      //boost::filesystem::ofstream f;
+      boost::iostreams::stream<boost::iostreams::file_descriptor_sink> f;
     
-    f.open(path, std::ofstream::out | std::ofstream::binary);
-    if (!f.good())
+      f.open(path, std::ofstream::out | std::ofstream::binary);
+      if (!f.good())
+      {
+        throw OrthancException(ErrorCode_CannotWriteFile);
+      }
+
+      if (size != 0)
+      {
+        f.write(reinterpret_cast<const char*>(content), size);
+
+        if (!f.good())
+        {
+          f.close();
+          throw OrthancException(ErrorCode_CannotWriteFile);
+        }
+      }
+
+      if (callFsync)
+      {
+        // https://stackoverflow.com/a/23826489/881731
+        f.flush();
+
+        bool success;
+
+        /**
+         * "f->handle()" corresponds to "FILE*" (aka "HANDLE") on
+         * Microsoft Windows, and to "int" (file descriptor) on other
+         * systems:
+         * https://github.com/boostorg/iostreams/blob/develop/include/boost/iostreams/detail/file_handle.hpp
+         **/
+      
+#if defined(_WIN32)
+        // https://docs.microsoft.com/fr-fr/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers
+        success = (::FlushFileBuffers(f->handle()) != 0);
+#elif (_POSIX_C_SOURCE >= 199309L || _XOPEN_SOURCE >= 500)
+        success = (::fdatasync(f->handle()) == 0);
+#else
+        success = (::fsync(f->handle()) == 0);
+#endif
+
+        if (!success)
+        {
+          throw OrthancException(ErrorCode_CannotWriteFile, "Cannot force flush to disk");
+        }
+      }
+
+      f.close();
+    }
+    catch (boost::filesystem::filesystem_error&)
     {
       throw OrthancException(ErrorCode_CannotWriteFile);
     }
-
-    if (size != 0)
-    {
-      f.write(reinterpret_cast<const char*>(content), size);
-
-      if (!f.good())
-      {
-        f.close();
-        throw OrthancException(ErrorCode_FileStorageCannotWrite);
-      }
-    }
-
-    if (callFsync)
+    catch (std::system_error&)
     {
-      // https://stackoverflow.com/a/23826489/881731
-      f.flush();
-
-      bool success;
-
-      /**
-       * "f->handle()" corresponds to "FILE*" (aka "HANDLE") on
-       * Microsoft Windows, and to "int" (file descriptor) on other
-       * systems:
-       * https://github.com/boostorg/iostreams/blob/develop/include/boost/iostreams/detail/file_handle.hpp
-       **/
-      
-#if defined(_WIN32)
-      // https://docs.microsoft.com/fr-fr/windows/win32/api/fileapi/nf-fileapi-flushfilebuffers
-      success = (::FlushFileBuffers(f->handle()) != 0);
-#elif (_POSIX_C_SOURCE >= 199309L || _XOPEN_SOURCE >= 500)
-      success = (::fdatasync(f->handle()) == 0);
-#else
-      success = (::fsync(f->handle()) == 0);
-#endif
-
-      if (!success)
-      {
-        throw OrthancException(ErrorCode_FileStorageCannotWrite, "Cannot force flush to disk");
-      }
+      throw OrthancException(ErrorCode_CannotWriteFile);
     }
-
-    f.close();
   }
 
 
@@ -396,6 +429,10 @@
     {
       throw OrthancException(ErrorCode_InexistentFile);
     }
+    catch (std::system_error&)
+    {
+      throw OrthancException(ErrorCode_InexistentFile);
+    }
   }
 
 
@@ -575,18 +612,16 @@
 
   bool SystemToolbox::IsRegularFile(const std::string& path)
   {
-    namespace fs = boost::filesystem;
-
     try
     {
-      if (fs::exists(path))
+      if (boost::filesystem::exists(path))
       {
-        fs::file_status status = fs::status(path);
+        boost::filesystem::file_status status = boost::filesystem::status(path);
         return (status.type() == boost::filesystem::regular_file ||
                 status.type() == boost::filesystem::reparse_file);   // Fix BitBucket issue #11
       }
     }
-    catch (fs::filesystem_error&)
+    catch (boost::filesystem::filesystem_error&)
     {
     }
 
--- a/OrthancFramework/UnitTestsSources/FrameworkTests.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/UnitTestsSources/FrameworkTests.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -787,6 +787,23 @@
   ASSERT_FALSE(IsResourceLevelAboveOrEqual(ResourceType_Instance, ResourceType_Study));
   ASSERT_FALSE(IsResourceLevelAboveOrEqual(ResourceType_Instance, ResourceType_Series));
   ASSERT_TRUE(IsResourceLevelAboveOrEqual(ResourceType_Instance, ResourceType_Instance));
+
+  ASSERT_STREQ("Patients", GetResourceTypeText(ResourceType_Patient, true /* plural */, true /* upper case */));
+  ASSERT_STREQ("patients", GetResourceTypeText(ResourceType_Patient, true, false));
+  ASSERT_STREQ("Patient", GetResourceTypeText(ResourceType_Patient, false, true));
+  ASSERT_STREQ("patient", GetResourceTypeText(ResourceType_Patient, false, false));
+  ASSERT_STREQ("Studies", GetResourceTypeText(ResourceType_Study, true, true));
+  ASSERT_STREQ("studies", GetResourceTypeText(ResourceType_Study, true, false));
+  ASSERT_STREQ("Study", GetResourceTypeText(ResourceType_Study, false, true));
+  ASSERT_STREQ("study", GetResourceTypeText(ResourceType_Study, false, false));
+  ASSERT_STREQ("Series", GetResourceTypeText(ResourceType_Series, true, true));
+  ASSERT_STREQ("series", GetResourceTypeText(ResourceType_Series, true, false));
+  ASSERT_STREQ("Series", GetResourceTypeText(ResourceType_Series, false, true));
+  ASSERT_STREQ("series", GetResourceTypeText(ResourceType_Series, false, false));
+  ASSERT_STREQ("Instances", GetResourceTypeText(ResourceType_Instance, true, true));
+  ASSERT_STREQ("instances", GetResourceTypeText(ResourceType_Instance, true, false));
+  ASSERT_STREQ("Instance", GetResourceTypeText(ResourceType_Instance, false, true));
+  ASSERT_STREQ("instance", GetResourceTypeText(ResourceType_Instance, false, false));
 }
 
 
--- a/OrthancFramework/UnitTestsSources/RestApiTests.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancFramework/UnitTestsSources/RestApiTests.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -312,6 +312,7 @@
   public:
     virtual bool Visit(const RestApiHierarchy::Resource& resource,
                        const UriComponents& uri,
+                       bool hasTrailing,
                        const HttpToolbox::Arguments& components,
                        const UriComponents& trailing) ORTHANC_OVERRIDE
     {
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestApi.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestApi.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -120,6 +120,27 @@
 
   static void UploadDicomFile(RestApiPostCall& call)
   {
+    if (call.GetRequestOrigin() == RequestOrigin_Documentation)
+    {
+      Json::Value sample = Json::objectValue;
+      sample["ID"] = "19816330-cb02e1cf-df3a8fe8-bf510623-ccefe9f5";
+      sample["ParentPatient"] = "ef9d77db-eb3b2bef-9b31fd3e-bf42ae46-dbdb0cc3";
+      sample["ParentSeries"] = "3774320f-ccda46d8-69ee8641-9e791cbf-3ecbbcc6";
+      sample["ParentStudy"] = "66c8e41e-ac3a9029-0b85e42a-8195ee0a-92c2e62e";
+      sample["Path"] = "/instances/19816330-cb02e1cf-df3a8fe8-bf510623-ccefe9f5";
+      sample["Status"] = "Success";
+      
+      call.GetDocumentation()
+        .SetTag("Instances")
+        .SetSummary("Upload DICOM files")
+        .AddRequestType(MimeType_Dicom, "DICOM file to be uploaded")
+        .AddRequestType(MimeType_Zip, "ZIP archive containing DICOM files (new in Orthanc 1.8.2)")
+        .AddAnswerType(MimeType_Json, "Information about the uploaded instance, "
+                       "or list of information for each uploaded instance in the case of ZIP archive")
+        .SetSample(sample);
+      return;
+    }
+
     ServerContext& context = OrthancRestApi::GetContext(call);
 
     CLOG(INFO, HTTP) << "Receiving a DICOM file of " << call.GetBodySize() << " bytes through HTTP";
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -161,6 +161,21 @@
   template <enum ResourceType resourceType>
   static void ListResources(RestApiGetCall& call)
   {
+    if (call.IsDocumentation())
+    {
+      const std::string resources = GetResourceTypeText(resourceType, true /* plural */, false /* lower case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(resourceType, true /* plural */, true /* upper case */))
+        .SetSummary("List the available " + resources)
+        .SetDescription("List the Orthanc identifiers of all the available DICOM " + resources)
+        .SetHttpGetArgument("limit", RestApiCallDocumentation::Type_Number, "Limit the number of results")
+        .SetHttpGetArgument("since", RestApiCallDocumentation::Type_Number, "Show only the resources since the provided index")
+        .SetHttpGetArgument("expand", RestApiCallDocumentation::Type_String,
+                            "If present, retrieve detailed information about the individual " + resources)
+        .SetHttpGetSample("https://demo.orthanc-server.com/" + resources + "?since=0&limit=2");
+      return;
+    }
+    
     ServerIndex& index = OrthancRestApi::GetIndex(call);
 
     std::list<std::string> result;
@@ -198,6 +213,37 @@
   template <enum ResourceType resourceType>
   static void GetSingleResource(RestApiGetCall& call)
   {
+    if (call.IsDocumentation())
+    {
+      std::string sampleUrl;
+      switch (resourceType)
+      {
+        case Orthanc::ResourceType_Instance:
+          sampleUrl = "https://demo.orthanc-server.com/instances/d94d9a03-3003b047-a4affc69-322313b2-680530a2";
+          break;
+        case Orthanc::ResourceType_Series:
+          sampleUrl = "https://demo.orthanc-server.com/series/37836232-d13a2350-fa1dedc5-962b31aa-010f8e52";
+          break;
+        case Orthanc::ResourceType_Study:
+          sampleUrl = "https://demo.orthanc-server.com/studies/27f7126f-4f66fb14-03f4081b-f9341db2-53925988";
+          break;
+        case Orthanc::ResourceType_Patient:
+          sampleUrl = "https://demo.orthanc-server.com/patients/46e6332c-677825b6-202fcf7c-f787bc5f-7b07c382";
+          break;          
+        default:
+          throw OrthancException(ErrorCode_ParameterOutOfRange);
+      }
+      
+      const std::string resource = GetResourceTypeText(resourceType, false /* plural */, false /* lower case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(resourceType, true /* plural */, true /* upper case */))
+        .SetSummary("Get information about some " + resource)
+        .SetDescription("Get information about the DICOM " + resource + " of interest whose Orthanc identifier is provided in the URL")
+        .SetUriComponent("id", RestApiCallDocumentation::Type_String, "Orthanc identifier of the " + resource + " of interest")
+        .SetHttpGetSample(sampleUrl);
+      return;
+    }
+    
     Json::Value result;
     if (OrthancRestApi::GetIndex(call).LookupResource(result, call.GetUriComponent("id", ""), resourceType))
     {
@@ -208,6 +254,17 @@
   template <enum ResourceType resourceType>
   static void DeleteSingleResource(RestApiDeleteCall& call)
   {
+    if (call.IsDocumentation())
+    {
+      const std::string resource = GetResourceTypeText(resourceType, false /* plural */, false /* lower case */);
+      call.GetDocumentation()
+        .SetTag(GetResourceTypeText(resourceType, true /* plural */, true /* upper case */))
+        .SetSummary("Delete some " + resource)
+        .SetDescription("Delete the DICOM " + resource + " of interest whose Orthanc identifier is provided in the URL")
+        .SetUriComponent("id", RestApiCallDocumentation::Type_String, "Orthanc identifier of the " + resource + " of interest");
+      return;
+    }
+
     Json::Value result;
     if (OrthancRestApi::GetContext(call).DeleteResource(result, call.GetUriComponent("id", ""), resourceType))
     {
@@ -220,6 +277,16 @@
  
   static void IsProtectedPatient(RestApiGetCall& call)
   {
+    if (call.IsDocumentation())
+    {
+      call.GetDocumentation()
+        .SetTag("Patients")
+        .SetSummary("Is the patient protected against recycling?")
+        .SetUriComponent("id", RestApiCallDocumentation::Type_String, "Orthanc identifier of the patient of interest")
+        .AddAnswerType(MimeType_PlainText, "\"1\" if protected, \"0\" if not protected");
+      return;
+    }
+    
     std::string publicId = call.GetUriComponent("id", "");
     bool isProtected = OrthancRestApi::GetIndex(call).IsProtectedPatient(publicId);
     call.GetOutput().AnswerBuffer(isProtected ? "1" : "0", MimeType_PlainText);
@@ -228,6 +295,16 @@
 
   static void SetPatientProtection(RestApiPutCall& call)
   {
+    if (call.IsDocumentation())
+    {
+      call.GetDocumentation()
+        .SetTag("Patients")
+        .SetSummary("Protect one patient against recycling")
+        .SetDescription("Check out configuration options \"MaximumStorageSize\" and \"MaximumPatientCount\"")
+        .SetUriComponent("id", RestApiCallDocumentation::Type_String, "Orthanc identifier of the patient of interest");
+      return;
+    }
+    
     ServerContext& context = OrthancRestApi::GetContext(call);
 
     std::string publicId = call.GetUriComponent("id", "");
--- a/OrthancServer/Sources/main.cpp	Tue Dec 22 09:39:06 2020 +0100
+++ b/OrthancServer/Sources/main.cpp	Wed Dec 23 12:21:03 2020 +0100
@@ -39,11 +39,13 @@
 #include "../../OrthancFramework/Sources/DicomNetworking/DicomAssociationParameters.h"
 #include "../../OrthancFramework/Sources/DicomNetworking/DicomServer.h"
 #include "../../OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.h"
+#include "../../OrthancFramework/Sources/FileStorage/MemoryStorageArea.h"
 #include "../../OrthancFramework/Sources/HttpServer/FilesystemHttpHandler.h"
 #include "../../OrthancFramework/Sources/HttpServer/HttpServer.h"
 #include "../../OrthancFramework/Sources/Logging.h"
 #include "../../OrthancFramework/Sources/Lua/LuaFunctionCall.h"
 #include "../Plugins/Engine/OrthancPlugins.h"
+#include "Database/SQLiteDatabaseWrapper.h"
 #include "EmbeddedResourceHttpHandler.h"
 #include "OrthancConfiguration.h"
 #include "OrthancFindRequestHandler.h"
@@ -655,8 +657,10 @@
     << "  --upgrade\t\tallow Orthanc to upgrade the version of the" << std::endl
     << "\t\t\tdatabase (beware that the database will become" << std::endl
     << "\t\t\tincompatible with former versions of Orthanc)" << std::endl
-    << "  --no-jobs\t\tDon't restart the jobs that were stored during" << std::endl
+    << "  --no-jobs\t\tdon't restart the jobs that were stored during" << std::endl
     << "\t\t\tthe last execution of Orthanc" << std::endl
+    << "  --openapi=[file]\twrite the OpenAPI documentation and exit" << std::endl
+    << "\t\t\t(if \"file\" is \"-\", dumps to stdout)" << std::endl
     << "  --version\t\toutput version information and exit" << std::endl
     << std::endl
     << "Fine-tuning of log categories:" << std::endl;
@@ -1745,6 +1749,43 @@
         return -1;
       }
     }
+    else if (boost::starts_with(argument, "--openapi="))
+    {
+      std::string target = argument.substr(10);
+
+      try
+      {
+        Json::Value openapi;
+
+        {
+          SQLiteDatabaseWrapper inMemoryDatabase;
+          inMemoryDatabase.Open();
+          MemoryStorageArea inMemoryStorage;
+          ServerContext context(inMemoryDatabase, inMemoryStorage, true /* unit testing */, 0 /* max completed jobs */);
+          OrthancRestApi restApi(context, false /* no Orthanc Explorer */);
+          restApi.GenerateOpenApiDocumentation(openapi);
+          context.Stop();
+        }
+        
+        std::string s;
+        Toolbox::WriteStyledJson(s, openapi);
+
+        if (target == "-")
+        {
+          std::cout << s;   // Print to stdout
+        }
+        else
+        {
+          SystemToolbox::WriteFile(s, target);
+        }
+        return 0;
+      }
+      catch (OrthancException&)
+      {
+        LOG(ERROR) << "Cannot export OpenAPI documentation as file \"" << target << "\"";
+        return -1;
+      }
+    }
     else
     {
       LOG(WARNING) << "Option unsupported by the core of Orthanc: " << argument;