From: Miod Vallat Date: Fri, 12 Jun 2026 13:57:27 +0000 (+0200) Subject: Let the API return the number of records in zone details. X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=e12bdc84cc8087e5ffb39501fd3ae76b79d1fb43;p=thirdparty%2Fpdns.git Let the API return the number of records in zone details. Also, allow the record count to be obtained without the record contents. Signed-off-by: Miod Vallat --- diff --git a/docs/http-api/openapi/authoritative-api-openapi.yaml b/docs/http-api/openapi/authoritative-api-openapi.yaml index a1e8ff4df6..cbe05a22cd 100644 --- a/docs/http-api/openapi/authoritative-api-openapi.yaml +++ b/docs/http-api/openapi/authoritative-api-openapi.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 info: - version: "0.0.18" + version: "0.0.19" title: PowerDNS Authoritative HTTP API license: name: MIT @@ -627,14 +627,20 @@ paths: schema: type: boolean default: true + - name: record_count + in: query + description: "“true” or “false” (default), whether to include a count of the records returned in “rrsets” in the response Zone object." + schema: + type: boolean + default: false - name: rrset_name in: query - description: Limit output to RRsets for this name. + description: Limit output and/or count to RRsets for this name. schema: type: string - name: rrset_type in: query - description: Limit output to the RRset of this type. Can only be used together with rrset_name. + description: Limit output and/or count to the RRset of this type. Can only be used together with rrset_name. schema: type: string - name: include_disabled @@ -642,6 +648,7 @@ paths: description: "“true” (default) or “false”, whether to include disabled RRsets in the response." schema: type: boolean + default: true responses: "200": description: A Zone @@ -1047,6 +1054,9 @@ components: - "Producer" - "Consumer" description: "Zone kind, one of “Native”, “Master”, “Slave”, “Producer”, “Consumer”" + record_count: + type: integer + description: "The number of records in this zone (for zones/{zone_id} endpoint only; omitted during GET on the .../zones list endpoint)" rrsets: type: array items: diff --git a/pdns/ws-auth.cc b/pdns/ws-auth.cc index 6e91f63dd9..5361bd30b6 100644 --- a/pdns/ws-auth.cc +++ b/pdns/ws-auth.cc @@ -406,9 +406,12 @@ static Json::object getZoneInfo(const DomainInfo& domainInfo, DNSSECKeeper* dnss return obj; } -static bool boolFromHttpRequest(HttpRequest* req, const std::string& var) +static bool boolFromHttpRequest(HttpRequest* req, const std::string& var, bool deflt) { - if (req->getvars.count(var) == 0 || req->getvars[var] == "true") { + if (req->getvars.count(var) == 0) { + return deflt; + } + if (req->getvars[var] == "true") { return true; } if (req->getvars[var] == "false") { @@ -418,6 +421,7 @@ static bool boolFromHttpRequest(HttpRequest* req, const std::string& var) throw ApiException("'" + var + "' request parameter value '" + req->getvars[var] + "' is not supported"); } +// NOLINTNEXTLINE(readability-function-cognitive-complexity) static void fillZone(UeberBackend& backend, const ZoneName& zonename, HttpResponse* resp, HttpRequest* req) { DomainInfo domainInfo; @@ -477,9 +481,12 @@ static void fillZone(UeberBackend& backend, const ZoneName& zonename, HttpRespon } doc["slave_tsig_key_ids"] = tsig_secondary_keys; - if (boolFromHttpRequest(req, "rrsets")) { + bool returnRRSets = boolFromHttpRequest(req, "rrsets", true); + bool countRecords = boolFromHttpRequest(req, "record_count", false); + if (returnRRSets || countRecords) { vector records; vector comments; + size_t recordCount{0}; QType qType = QType::ANY; DNSName qName; @@ -495,31 +502,36 @@ static void fillZone(UeberBackend& backend, const ZoneName& zonename, HttpRespon if (req->getvars.count("rrset_type") != 0) { qType = req->getvars["rrset_type"]; } - bool include_disabled = boolFromHttpRequest(req, "include_disabled"); + bool include_disabled = boolFromHttpRequest(req, "include_disabled", true); domainInfo.backend->APILookup(qType, qName, static_cast(domainInfo.id), include_disabled); } while (domainInfo.backend->get(resourceRecord)) { if (resourceRecord.qtype.getCode() == 0) { continue; // skip empty non-terminals } - records.push_back(resourceRecord); + ++recordCount; + if (returnRRSets) { + records.push_back(resourceRecord); + } } - sort(records.begin(), records.end(), [](const DNSResourceRecord& rrA, const DNSResourceRecord& rrB) { - /* if you ever want to update this comparison function, - please be aware that you will also need to update the conditions in the code merging - the records and comments below */ - if (rrA.qname == rrB.qname) { - if (rrA.qtype == rrB.qtype) { - return rrB.content > rrA.content; + if (returnRRSets) { + sort(records.begin(), records.end(), [](const DNSResourceRecord& rrA, const DNSResourceRecord& rrB) { + /* if you ever want to update this comparison function, + please be aware that you will also need to update the conditions in the code merging + the records and comments below */ + if (rrA.qname == rrB.qname) { + if (rrA.qtype == rrB.qtype) { + return rrB.content > rrA.content; + } + return rrB.qtype < rrA.qtype; } - return rrB.qtype < rrA.qtype; - } - return rrB.qname < rrA.qname; - }); + return rrB.qname < rrA.qname; + }); + } } // load all comments + sort - { + if (returnRRSets) { Comment comment; domainInfo.backend->listComments(domainInfo.id); while (domainInfo.backend->getComment(comment)) { @@ -539,74 +551,76 @@ static void fillZone(UeberBackend& backend, const ZoneName& zonename, HttpRespon } return rrB.qname < rrA.qname; }); - } - Json::array rrsets; - Json::object rrset; - Json::array rrset_records; - Json::array rrset_comments; - DNSName current_qname; - QType current_qtype; - uint32_t ttl = 0; - auto rit = records.begin(); - auto cit = comments.begin(); - - while (rit != records.end() || cit != comments.end()) { - // if you think this should be rit < cit instead of cit < rit, note the b < a instead of a < b in the sort comparison functions above - if (cit == comments.end() || (rit != records.end() && (rit->qname == cit->qname ? (cit->qtype < rit->qtype || cit->qtype == rit->qtype) : cit->qname < rit->qname))) { - current_qname = rit->qname; - current_qtype = rit->qtype; - ttl = rit->ttl; - } - else { - current_qname = cit->qname; - current_qtype = cit->qtype; - ttl = 0; - } - - while (rit != records.end() && rit->qname == current_qname && rit->qtype == current_qtype) { - ttl = min(ttl, rit->ttl); - std::string content; - try { - content = makeApiRecordContent(rit->qtype, rit->content); + Json::array rrsets; + Json::object rrset; + Json::array rrset_records; + Json::array rrset_comments; + DNSName current_qname; + QType current_qtype; + uint32_t ttl = 0; + auto rit = records.begin(); + auto cit = comments.begin(); + + while (rit != records.end() || cit != comments.end()) { + // if you think this should be rit < cit instead of cit < rit, note the b < a instead of a < b in the sort comparison functions above + if (cit == comments.end() || (rit != records.end() && (rit->qname == cit->qname ? (cit->qtype < rit->qtype || cit->qtype == rit->qtype) : cit->qname < rit->qname))) { + current_qname = rit->qname; + current_qtype = rit->qtype; + ttl = rit->ttl; + } + else { + current_qname = cit->qname; + current_qtype = cit->qtype; + ttl = 0; } - catch (std::exception& e) { - // makeApiRecordContent may throw an exception if the backend data - // is not well-formed (e.g. corrupted bind zone file). - // The exception gets caught here and rethrown as ApiException in - // order to return a 422 error code with a (hopefully) useful error - // message instead of a 500 error. - throw ApiException("Ill-formed record contents found for " + current_qname.toString() + ": " + e.what()); + + while (rit != records.end() && rit->qname == current_qname && rit->qtype == current_qtype) { + ttl = min(ttl, rit->ttl); + std::string content; + try { + content = makeApiRecordContent(rit->qtype, rit->content); + } + catch (std::exception& e) { + // makeApiRecordContent may throw an exception if the backend data + // is not well-formed (e.g. corrupted bind zone file). + // The exception gets caught here and rethrown as ApiException in + // order to return a 422 error code with a (hopefully) useful error + // message instead of a 500 error. + throw ApiException("Ill-formed record contents found for " + current_qname.toString() + ": " + e.what()); + } + auto object = Json::object{ + {"disabled", rit->disabled}, + {"content", content}}; + if (rit->last_modified != 0) { + object["modified_at"] = (double)rit->last_modified; + } + rrset_records.push_back(object); + rit++; } - auto object = Json::object{ - {"disabled", rit->disabled}, - {"content", content}}; - if (rit->last_modified != 0) { - object["modified_at"] = (double)rit->last_modified; + while (cit != comments.end() && cit->qname == current_qname && cit->qtype == current_qtype) { + rrset_comments.push_back(Json::object{ + {"modified_at", (double)cit->modified_at}, + {"account", cit->account}, + {"content", cit->content}}); + cit++; } - rrset_records.push_back(object); - rit++; - } - while (cit != comments.end() && cit->qname == current_qname && cit->qtype == current_qtype) { - rrset_comments.push_back(Json::object{ - {"modified_at", (double)cit->modified_at}, - {"account", cit->account}, - {"content", cit->content}}); - cit++; + + rrset["name"] = current_qname.toString(); + rrset["type"] = current_qtype.toString(); + rrset["records"] = rrset_records; + rrset["comments"] = rrset_comments; + rrset["ttl"] = (double)ttl; + rrsets.emplace_back(rrset); + rrset.clear(); + rrset_records.clear(); + rrset_comments.clear(); } - rrset["name"] = current_qname.toString(); - rrset["type"] = current_qtype.toString(); - rrset["records"] = rrset_records; - rrset["comments"] = rrset_comments; - rrset["ttl"] = (double)ttl; - rrsets.emplace_back(rrset); - rrset.clear(); - rrset_records.clear(); - rrset_comments.clear(); + doc["rrsets"] = rrsets; } - doc["rrsets"] = rrsets; + doc["record_count"] = static_cast(recordCount); } resp->setJsonBody(doc); diff --git a/regression-tests.api/test_Zones.py b/regression-tests.api/test_Zones.py index 03cf612785..452ec0e19b 100644 --- a/regression-tests.api/test_Zones.py +++ b/regression-tests.api/test_Zones.py @@ -3221,6 +3221,34 @@ $NAME$ 1D IN SOA ns1.example.org. hostmaster.example.org. ( # check our record has appeared self.assertEqual(get_rrset(data, rrset["name"], "A")["records"], rrset["records"]) + def test_record_count(self): + name, payload, zone = self.create_zone() + rrsets = [] + count = 100 + for record in range(count): + rrset = { + "changetype": "replace", + "name": "rec" + str(record) + "." + name, + "type": "A", + "ttl": 3600, + "records": [{"content": "192.168.0." + str(record), "disabled": False}], + } + rrsets.append(rrset) + payload = {"rrsets": rrsets} + r = self.session.patch( + self.url("/api/v1/servers/localhost/zones/" + name), + data=json.dumps(payload), + headers={"content-type": "application/json"}, + ) + self.assert_success(r) + # ask for a record count, without records + data = self.get_zone(name, rrsets="false", record_count="true") + self.assertEqual(data["record_count"], count + 3) # SOA + 2 NS + self.assertEqual(data.get("rrsets"), None) + # ask for a filtered record count + data = self.get_zone(name, rrset_name="rec42." + name) + self.assertEqual(data["record_count"], 1) + @unittest.skipIf(not is_auth(), "Not applicable") class AuthRootZone(ZonesApiTestCase, AuthZonesHelperMixin):