]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
Let the API return the number of records in zone details. 17568/head
authorMiod Vallat <miod.vallat@powerdns.com>
Fri, 12 Jun 2026 13:57:27 +0000 (15:57 +0200)
committerMiod Vallat <miod.vallat@powerdns.com>
Thu, 25 Jun 2026 20:27:53 +0000 (22:27 +0200)
Also, allow the record count to be obtained without the record contents.

Signed-off-by: Miod Vallat <miod.vallat@powerdns.com>
docs/http-api/openapi/authoritative-api-openapi.yaml
pdns/ws-auth.cc
regression-tests.api/test_Zones.py

index a1e8ff4df6ea2253a69b80a208fd51270721f454..cbe05a22cd0fd4622b82c7320e577a06e5a1709a 100644 (file)
@@ -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:
index 6e91f63dd976d3fafced7ea12cbc48cfab64fccc..5361bd30b69f91dcdd3c0b6437161aeb08175654 100644 (file)
@@ -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<DNSResourceRecord> records;
     vector<Comment> 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<int>(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<double>(recordCount);
   }
 
   resp->setJsonBody(doc);
index 03cf6127853db221c8c9a220cd40638e5e96ab7f..452ec0e19b1131e2c0370cf22dd3a84e2c8f7d22 100644 (file)
@@ -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):