From 917f686a035e6039f8a4f318c4a93fc3a282d5e8 Mon Sep 17 00:00:00 2001 From: "Kevin P. Fleming" Date: Tue, 10 Nov 2020 07:29:39 -0500 Subject: [PATCH] auth: Add /api/docs endpoint to obtain OpenAPI document This patch adds an /api/docs endpoint to the API webserver, allowing clients to obtain the OpenAPI (Swagger) document that describes the server's API directly from the server. It also modifies the response body mechanism in the webserver to no longer assume JSON output, but allow handlers to specify JSON, YAML, or plain text. It also adds detection of YAML support in the request so that handlers can choose which type to send in their response. Since there is not yet a standard MIME type for YAML, 'application/x-yaml' is used since it appears to be the most commonly used type. Signed-off-by: Kevin P. Fleming --- .github/actions/spell-check/expect.txt | 1 + .github/workflows/codeql-analysis.yml | 6 +++ docs/http-api/index.rst | 4 +- .../swagger/authoritative-api-swagger.yaml | 9 ++-- ext/Makefile.am | 3 +- lgtm.yml | 3 ++ m4/pdns_check_virtualenv.m4 | 2 +- pdns/.gitignore | 5 +- pdns/Makefile.am | 38 ++++++++++++-- pdns/incfiles | 11 +++++ pdns/requirements.txt | 1 + pdns/webserver.cc | 46 ++++++++++++----- pdns/webserver.hh | 6 ++- pdns/ws-api.cc | 12 ++--- pdns/ws-auth.cc | 49 +++++++++++++------ pdns/ws-auth.hh | 2 + pdns/ws-recursor.cc | 16 +++--- 17 files changed, 160 insertions(+), 54 deletions(-) create mode 100755 pdns/incfiles create mode 100644 pdns/requirements.txt diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 6199044115..8eb330a5e9 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -69,6 +69,7 @@ ANSSI Antoin anycast api +apidocfiles apikey APIv AQAB diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index a1ed8831f1..d31152a37d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -33,6 +33,12 @@ jobs: - run: git checkout HEAD^2 if: ${{ github.event_name == 'pull_request' }} + # Python and virtualenv are required for building the Authoritative server + - uses: actions/setup-python@v2 + with: + python-version: '2.7' + - run: pip install virtualenv + # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 diff --git a/docs/http-api/index.rst b/docs/http-api/index.rst index 21b369c94a..b7e01df683 100644 --- a/docs/http-api/index.rst +++ b/docs/http-api/index.rst @@ -318,7 +318,9 @@ Working with the API This chapter describes the PowerDNS Authoritative API. When creating an API wrapper (for instance when fronting multiple API's), it is recommended to stick to this API specification. -The API is described in the `OpenAPI format `_, also known as "Swagger", and this description is `available `_. +The API is described in the `OpenAPI format `_, also known as "Swagger", and this description is `available `_. It can also be obtained from a running server if the administrator of that server has enabled the API; it +is available at the `/api/docs` endpoint in both YAML and JSON formats (the 'Accept' header can be used to indicate the +desired format). Authentication ~~~~~~~~~~~~~~ diff --git a/docs/http-api/swagger/authoritative-api-swagger.yaml b/docs/http-api/swagger/authoritative-api-swagger.yaml index 42b0a431f2..1fa5c67cf5 100644 --- a/docs/http-api/swagger/authoritative-api-swagger.yaml +++ b/docs/http-api/swagger/authoritative-api-swagger.yaml @@ -4,10 +4,7 @@ info: title: PowerDNS Authoritative HTTP API license: name: MIT -host: localhost:8081 basePath: /api/v1 -schemes: - - http consumes: - application/json produces: @@ -406,9 +403,9 @@ paths: schema: type: array items: - - $ref: '#/definitions/StatisticItem' - - $ref: '#/definitions/MapStatisticItem' - - $ref: '#/definitions/RingStatisticItem' + - $ref: '#/definitions/StatisticItem' + - $ref: '#/definitions/MapStatisticItem' + - $ref: '#/definitions/RingStatisticItem' '422': description: 'Returned when a non-existing statistic name has been requested. Contains an error message' diff --git a/ext/Makefile.am b/ext/Makefile.am index 7c0a42d419..1a99b60386 100644 --- a/ext/Makefile.am +++ b/ext/Makefile.am @@ -9,4 +9,5 @@ DIST_SUBDIRS = \ yahttp EXTRA_DIST = \ - luawrapper/include/LuaContext.hpp + luawrapper/include/LuaContext.hpp \ + incbin/incbin.h diff --git a/lgtm.yml b/lgtm.yml index a78d90d5e3..67929165e2 100644 --- a/lgtm.yml +++ b/lgtm.yml @@ -23,6 +23,9 @@ extraction: - "sed -i 's/codedocs docs//' Makefile.am" - "autoreconf -vi" - "./configure --with-modules='bind gsqlite3 gmysql gpgsql ldap'" + - "pushd pdns" + - "touch .venv api-swagger.yaml api-swagger.json" + - "popd" index: build_command: - "pushd pdns/dnsdistdist" diff --git a/m4/pdns_check_virtualenv.m4 b/m4/pdns_check_virtualenv.m4 index 498cff7737..873cde5df0 100644 --- a/m4/pdns_check_virtualenv.m4 +++ b/m4/pdns_check_virtualenv.m4 @@ -8,5 +8,5 @@ AC_DEFUN([PDNS_CHECK_VIRTUALENV], [ ]) AM_CONDITIONAL([HAVE_VIRTUALENV], [test "x$VIRTUALENV" != "xno"]) AM_CONDITIONAL([HAVE_MANPAGES], [test -e "$srcdir/docs/pdns_server.1"]) + AM_CONDITIONAL([HAVE_API_SWAGGER_JSON], [test -e "$srcdir/pdns/api-swagger.json"]) ]) - diff --git a/pdns/.gitignore b/pdns/.gitignore index 41e738c994..7b6406ec34 100644 --- a/pdns/.gitignore +++ b/pdns/.gitignore @@ -55,7 +55,10 @@ version_generated.h /pubsuffix.cc /calidns /dumresp -/htmlfiles.h +/apidocfiles.h +/api-swagger.yaml +/api-swagger.json +/.venv effective_tld_names.dat /dnsmessage.pb.cc /dnsmessage.pb.h diff --git a/pdns/Makefile.am b/pdns/Makefile.am index 3ad600c2a8..d85f1d7050 100644 --- a/pdns/Makefile.am +++ b/pdns/Makefile.am @@ -50,12 +50,17 @@ EXTRA_DIST = \ ixfrdist.example.yml \ lua-record.cc \ minicurl.cc \ - minicurl.hh + minicurl.hh \ + api-swagger.yaml \ + api-swagger.json \ + requirements.txt \ + incfiles BUILT_SOURCES = \ bind-dnssec.schema.sqlite3.sql.h \ bindparser.h \ - dnslabeltext.cc + dnslabeltext.cc \ + apidocfiles.h CLEANFILES = \ *.gcda \ @@ -65,7 +70,34 @@ CLEANFILES = \ backends/gsql/gsqlbackend.gcno \ backends/gsql/gsqlbackend.gcov \ dnsmessage.pb.cc dnsmessage.pb.h \ - pdns.conf-dist + pdns.conf-dist \ + apidocfiles.h \ + api-swagger.yaml \ + api-swagger.json + +# use a $(wildcard) wrapper here to allow build to proceed if output +# file is present but input file is not (e.g. in a dist tarball) +api-swagger.yaml: $(wildcard ../docs/http-api/swagger/authoritative-api-swagger.yaml) + cp $< $@ + +if HAVE_VIRTUALENV +.venv: requirements.txt + virtualenv .venv + .venv/bin/pip install -U pip setuptools setuptools-git + .venv/bin/pip install -r requirements.txt + +api-swagger.json: api-swagger.yaml .venv + .venv/bin/python -c "import sys, json, yaml; y = yaml.safe_load(sys.stdin.read()); json.dump(y, sys.stdout, indent=2, separators=(',', ': '))" < $< > $@ +else # if HAVE_VIRTUALENV +if !HAVE_API_SWAGGER_JSON +api-swagger.json: + echo "You need virtualenv to generate the JSON API document" + exit 1 +endif +endif + +apidocfiles.h: api-swagger.yaml api-swagger.json + ./incfiles $^ > $@ noinst_SCRIPTS = pdns.init sysconf_DATA = pdns.conf-dist diff --git a/pdns/incfiles b/pdns/incfiles new file mode 100755 index 0000000000..c2a99c0d0e --- /dev/null +++ b/pdns/incfiles @@ -0,0 +1,11 @@ +#!/bin/sh + +export LC_ALL=C.UTF-8 +export LANG=C.UTF-8 + +for a in $@ +do + c=$(echo $a | tr "/.-" "___") + echo "INCBIN(${c}, \"${a}\");" + echo "static const string g_${c}{(const char*)g${c}Data, g${c}Size};" +done diff --git a/pdns/requirements.txt b/pdns/requirements.txt new file mode 100644 index 0000000000..5500f007d0 --- /dev/null +++ b/pdns/requirements.txt @@ -0,0 +1 @@ +PyYAML diff --git a/pdns/webserver.cc b/pdns/webserver.cc index ca10668787..252506da0f 100644 --- a/pdns/webserver.cc +++ b/pdns/webserver.cc @@ -81,21 +81,43 @@ bool HttpRequest::compareHeader(const string &header_name, const string &expecte return (0==strcmp(header->second.c_str(), expected_value.c_str())); } +void HttpResponse::setPlainBody(const string& document) +{ + this->headers["Content-Type"] = "text/plain; charset=utf-8"; + + this->body = document; +} -void HttpResponse::setBody(const json11::Json& document) +void HttpResponse::setYamlBody(const string& document) { + this->headers["Content-Type"] = "application/x-yaml"; + + this->body = document; +} + +void HttpResponse::setJsonBody(const string& document) +{ + this->headers["Content-Type"] = "application/json"; + + this->body = document; +} + +void HttpResponse::setJsonBody(const json11::Json& document) +{ + this->headers["Content-Type"] = "application/json"; + document.dump(this->body); } void HttpResponse::setErrorResult(const std::string& message, const int status_) { - setBody(json11::Json::object { { "error", message } }); + setJsonBody(json11::Json::object { { "error", message } }); this->status = status_; } void HttpResponse::setSuccessResult(const std::string& message, const int status_) { - setBody(json11::Json::object { { "result", message } }); + setJsonBody(json11::Json::object { { "result", message } }); this->status = status_; } @@ -150,8 +172,6 @@ void WebServer::apiWrapper(WebServer::HandlerFunction handler, HttpRequest* req, throw HttpUnauthorizedException("X-API-Key"); } - resp->headers["Content-Type"] = "application/json"; - // security headers resp->headers["X-Content-Type-Options"] = "nosniff"; resp->headers["X-Frame-Options"] = "deny"; @@ -233,8 +253,12 @@ void WebServer::handleRequest(HttpRequest& req, HttpResponse& resp) const YaHTTP::strstr_map_t::iterator header; if ((header = req.headers.find("accept")) != req.headers.end()) { - // json wins over html - if (header->second.find("application/json") != std::string::npos) { + // yaml wins over json, json wins over html + if (header->second.find("application/x-yaml") != std::string::npos) { + req.accept_yaml = true; + } else if (header->second.find("text/x-yaml") != std::string::npos) { + req.accept_yaml = true; + } else if (header->second.find("application/json") != std::string::npos) { req.accept_json = true; } else if (header->second.find("text/html") != std::string::npos) { req.accept_html = true; @@ -272,14 +296,14 @@ void WebServer::handleRequest(HttpRequest& req, HttpResponse& resp) const // TODO rm this logline? g_log<

" + what + "

"; - } else if (req.accept_json) { + if (req.accept_json) { resp.headers["Content-Type"] = "application/json"; if (resp.body.empty()) { resp.setErrorResult(what, resp.status); } + } else if (req.accept_html) { + resp.headers["Content-Type"] = "text/html; charset=utf-8"; + resp.body = "" + what + "

" + what + "

"; } else { resp.headers["Content-Type"] = "text/plain; charset=utf-8"; resp.body = what; diff --git a/pdns/webserver.hh b/pdns/webserver.hh index ea98581c93..9e1f67971e 100644 --- a/pdns/webserver.hh +++ b/pdns/webserver.hh @@ -33,6 +33,7 @@ class HttpRequest : public YaHTTP::Request { public: HttpRequest(const string& logprefix_="") : YaHTTP::Request(), accept_json(false), accept_html(false), complete(false), logprefix(logprefix_) { }; + bool accept_yaml; bool accept_json; bool accept_html; bool complete; @@ -49,7 +50,10 @@ public: HttpResponse() : YaHTTP::Response() { }; HttpResponse(const YaHTTP::Response &resp) : YaHTTP::Response(resp) { }; - void setBody(const json11::Json& document); + void setPlainBody(const string& document); + void setYamlBody(const string& document); + void setJsonBody(const string& document); + void setJsonBody(const json11::Json& document); void setErrorResult(const std::string& message, const int status); void setSuccessResult(const std::string& message, const int status = 200); }; diff --git a/pdns/ws-api.cc b/pdns/ws-api.cc index 64c87efa33..bae04278f6 100644 --- a/pdns/ws-api.cc +++ b/pdns/ws-api.cc @@ -114,7 +114,7 @@ void apiDiscovery(HttpRequest* req, HttpResponse* resp) { }; Json doc = Json::array { version1 }; - resp->setBody(doc); + resp->setJsonBody(doc); } void apiServer(HttpRequest* req, HttpResponse* resp) { @@ -122,14 +122,14 @@ void apiServer(HttpRequest* req, HttpResponse* resp) { throw HttpMethodNotAllowedException(); Json doc = Json::array {getServerDetail()}; - resp->setBody(doc); + resp->setJsonBody(doc); } void apiServerDetail(HttpRequest* req, HttpResponse* resp) { if(req->method != "GET") throw HttpMethodNotAllowedException(); - resp->setBody(getServerDetail()); + resp->setJsonBody(getServerDetail()); } void apiServerConfig(HttpRequest* req, HttpResponse* resp) { @@ -151,7 +151,7 @@ void apiServerConfig(HttpRequest* req, HttpResponse* resp) { { "value", value }, }); } - resp->setBody(doc); + resp->setJsonBody(doc); } void apiServerStatistics(HttpRequest* req, HttpResponse* resp) { @@ -172,7 +172,7 @@ void apiServerStatistics(HttpRequest* req, HttpResponse* resp) { { "value", std::to_string(*stat) }, }); - resp->setBody(doc); + resp->setJsonBody(doc); return; } @@ -273,7 +273,7 @@ void apiServerStatistics(HttpRequest* req, HttpResponse* resp) { } #endif - resp->setBody(doc); + resp->setJsonBody(doc); } DNSName apiNameToDNSName(const string& name) { diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index eafe939f7a..df0124f09b 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -47,6 +47,7 @@ #include "auth-caches.hh" #include "threadname.hh" #include "tsigutils.hh" +#include "ext/incbin/incbin.h" using json11::Json; @@ -486,7 +487,7 @@ static void fillZone(UeberBackend& B, const DNSName& zonename, HttpResponse* res doc["rrsets"] = rrsets; } - resp->setBody(doc); + resp->setJsonBody(doc); } void productServerStatisticsFetch(map& out) @@ -882,6 +883,23 @@ static bool isValidMetadataKind(const string& kind, bool readonly) { return found; } +/* Return OpenAPI document describing the supported API. + */ +#include "apidocfiles.h" + +void apiDocs(HttpRequest* req, HttpResponse* resp) { + if(req->method != "GET") + throw HttpMethodNotAllowedException(); + + if (req->accept_yaml) { + resp->setYamlBody(g_api_swagger_yaml); + } else if (req->accept_json) { + resp->setJsonBody(g_api_swagger_json); + } else { + resp->setPlainBody(g_api_swagger_yaml); + } +} + static void apiZoneMetadata(HttpRequest* req, HttpResponse *resp) { DNSName zonename = apiZoneIdToName(req->parameters["id"]); @@ -912,7 +930,7 @@ static void apiZoneMetadata(HttpRequest* req, HttpResponse *resp) { document.push_back(key); } - resp->setBody(document); + resp->setJsonBody(document); } else if (req->method == "POST") { auto document = req->json(); string kind; @@ -962,7 +980,7 @@ static void apiZoneMetadata(HttpRequest* req, HttpResponse *resp) { }; resp->status = 201; - resp->setBody(key); + resp->setJsonBody(key); } else throw HttpMethodNotAllowedException(); } @@ -995,7 +1013,7 @@ static void apiZoneMetadataKind(HttpRequest* req, HttpResponse* resp) { entries.push_back(i); document["metadata"] = entries; - resp->setBody(document); + resp->setJsonBody(document); } else if (req->method == "PUT") { auto document = req->json(); @@ -1022,7 +1040,7 @@ static void apiZoneMetadataKind(HttpRequest* req, HttpResponse* resp) { { "metadata", metadata } }; - resp->setBody(key); + resp->setJsonBody(key); } else if (req->method == "DELETE") { if (!isValidMetadataKind(kind, false)) throw ApiException("Unsupported metadata kind '" + kind + "'"); @@ -1090,7 +1108,7 @@ static void apiZoneCryptokeysGET(DNSName zonename, int inquireKeyId, HttpRespons if (inquireSingleKey) { key["privatekey"] = value.first.getKey()->convertToISC(); - resp->setBody(key); + resp->setJsonBody(key); return; } doc.push_back(key); @@ -1100,7 +1118,7 @@ static void apiZoneCryptokeysGET(DNSName zonename, int inquireKeyId, HttpRespons // we came here because we couldn't find the requested key. throw HttpNotFoundException(); } - resp->setBody(doc); + resp->setJsonBody(doc); } @@ -1452,7 +1470,7 @@ static void apiServerTSIGKeys(HttpRequest* req, HttpResponse* resp) { for(const auto &key : keys) { doc.push_back(makeJSONTSIGKey(key, false)); } - resp->setBody(doc); + resp->setJsonBody(doc); } else if (req->method == "POST") { auto document = req->json(); DNSName keyname(stringFromJson(document, "name")); @@ -1475,7 +1493,7 @@ static void apiServerTSIGKeys(HttpRequest* req, HttpResponse* resp) { } resp->status = 201; - resp->setBody(makeJSONTSIGKey(keyname, algo, content)); + resp->setJsonBody(makeJSONTSIGKey(keyname, algo, content)); } else { throw HttpMethodNotAllowedException(); } @@ -1497,7 +1515,7 @@ static void apiServerTSIGKeyDetail(HttpRequest* req, HttpResponse* resp) { tsk.key = content; if (req->method == "GET") { - resp->setBody(makeJSONTSIGKey(tsk)); + resp->setJsonBody(makeJSONTSIGKey(tsk)); } else if (req->method == "PUT") { json11::Json document; if (!req->body.empty()) { @@ -1531,7 +1549,7 @@ static void apiServerTSIGKeyDetail(HttpRequest* req, HttpResponse* resp) { throw HttpInternalServerErrorException("Unable to remove TSIG key '" + keyname.toStringNoDot() + "'"); } } - resp->setBody(makeJSONTSIGKey(tsk)); + resp->setJsonBody(makeJSONTSIGKey(tsk)); } else if (req->method == "DELETE") { if (!B.deleteTSIGKey(keyname)) { throw HttpInternalServerErrorException("Unable to remove TSIG key '" + keyname.toStringNoDot() + "'"); @@ -1747,7 +1765,7 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp) { for(const DomainInfo& di : domains) { doc.push_back(getZoneInfo(di, with_dnssec ? &dk : nullptr)); } - resp->setBody(doc); + resp->setJsonBody(doc); } static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) { @@ -1828,7 +1846,7 @@ static void apiServerZoneExport(HttpRequest* req, HttpResponse* resp) { } if (req->accept_json) { - resp->setBody(Json::object { { "zone", ss.str() } }); + resp->setJsonBody(Json::object { { "zone", ss.str() } }); } else { resp->headers["Content-Type"] = "text/plain; charset=us-ascii"; resp->body = ss.str(); @@ -2190,7 +2208,7 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) { } } - resp->setBody(doc); + resp->setJsonBody(doc); } static void apiServerCacheFlush(HttpRequest* req, HttpResponse* resp) { @@ -2200,7 +2218,7 @@ static void apiServerCacheFlush(HttpRequest* req, HttpResponse* resp) { DNSName canon = apiNameToDNSName(req->getvars["domain"]); uint64_t count = purgeAuthCachesExact(canon); - resp->setBody(Json::object { + resp->setJsonBody(Json::object { { "count", (int) count }, { "result", "Flushed cache." } }); @@ -2298,6 +2316,7 @@ void AuthWebServer::webThread() d_ws->registerApiHandler("/api/v1/servers/localhost/zones", &apiServerZones); d_ws->registerApiHandler("/api/v1/servers/localhost", &apiServerDetail); d_ws->registerApiHandler("/api/v1/servers", &apiServer); + d_ws->registerApiHandler("/api/docs", &apiDocs); d_ws->registerApiHandler("/api", &apiDiscovery); } if (::arg().mustDo("webserver")) { diff --git a/pdns/ws-auth.hh b/pdns/ws-auth.hh index 2cd127345f..8c84360974 100644 --- a/pdns/ws-auth.hh +++ b/pdns/ws-auth.hh @@ -94,3 +94,5 @@ private: Ewma d_qcachehits, d_qcachemisses; WebServer *d_ws{nullptr}; }; + +void apiDocs(HttpRequest* req, HttpResponse* resp); diff --git a/pdns/ws-recursor.cc b/pdns/ws-recursor.cc index 68feb47e1a..e301cf7e07 100644 --- a/pdns/ws-recursor.cc +++ b/pdns/ws-recursor.cc @@ -116,7 +116,7 @@ static void apiServerConfigAllowFrom(HttpRequest* req, HttpResponse* resp) vector entries; t_allowFrom->toStringVector(&entries); - resp->setBody(Json::object { + resp->setJsonBody(Json::object { { "name", "allow-from" }, { "value", entries }, }); @@ -157,7 +157,7 @@ static void fillZone(const DNSName& zonename, HttpResponse* resp) { "records", records } }; - resp->setBody(doc); + resp->setJsonBody(doc); } static void doCreateZone(const Json document) @@ -296,7 +296,7 @@ static void apiServerZones(HttpRequest* req, HttpResponse* resp) { "recursion_desired", zone.d_servers.empty() ? false : zone.d_rdForward } }); } - resp->setBody(doc); + resp->setJsonBody(doc); } static void apiServerZoneDetail(HttpRequest* req, HttpResponse* resp) @@ -372,7 +372,7 @@ static void apiServerSearchData(HttpRequest* req, HttpResponse* resp) { }); } } - resp->setBody(doc); + resp->setJsonBody(doc); } static void apiServerCacheFlush(HttpRequest* req, HttpResponse* resp) { @@ -389,7 +389,7 @@ static void apiServerCacheFlush(HttpRequest* req, HttpResponse* resp) { int count = g_recCache->doWipeCache(canon, subtree, qtype); count += broadcastAccFunction([=]{return pleaseWipePacketCache(canon, subtree, qtype);}); count += g_negCache->wipe(canon, subtree); - resp->setBody(Json::object { + resp->setJsonBody(Json::object { { "count", count }, { "result", "Flushed cache." } }); @@ -422,7 +422,7 @@ static void apiServerRPZStats(HttpRequest* req, HttpResponse* resp) { }; ret[name] = zoneInfo; } - resp->setBody(ret); + resp->setJsonBody(ret); } @@ -594,7 +594,7 @@ void RecursorWebServer::jsonstat(HttpRequest* req, HttpResponse *resp) (int)(queries.size() - totIncluded), "", "" }); } - resp->setBody(Json::object { { "entries", entries } }); + resp->setJsonBody(Json::object { { "entries", entries } }); return; } else if(command == "get-remote-ring") { @@ -640,7 +640,7 @@ void RecursorWebServer::jsonstat(HttpRequest* req, HttpResponse *resp) }); } - resp->setBody(Json::object { { "entries", entries } }); + resp->setJsonBody(Json::object { { "entries", entries } }); return; } else { resp->setErrorResult("Command '"+command+"' not found", 404); -- 2.47.2