This is an automated email from the ASF dual-hosted git repository.

mgreber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/kudu.git


The following commit(s) were added to refs/heads/master by this push:
     new d3802e7e5 KUDU-3700 Add swagger verification script docs
d3802e7e5 is described below

commit d3802e7e5761d206a1793c3d3165d1bc754134d8
Author: Gabriella Lotz <[email protected]>
AuthorDate: Mon Jan 26 15:25:13 2026 +0100

    KUDU-3700 Add swagger verification script docs
    
    Document usage and expected output for the swagger verification helper to
    make it easy to run manually and possibly in CI.
    
    Manual testing:
    - ./build-support/verify_swagger_spec.py
    - Manual negative checks by temporarily changing swagger path/method/param
      confirmed mismatch output
    
    Examples:
    - Path coverage: swagger paths (with server base /api/v1) must match
      registered /api/... handlers in source.
      Swagger verification failed with 2 issue(s):
      - Swagger path missing in source: /api/v1/leaders
      - Source path missing in swagger: /api/v1/leader
    - HTTP methods: methods inferred from req.request_method checks must match
      swagger operations.
      Swagger verification failed with 2 issue(s):
      - Swagger missing methods for /api/v1/tables: POST
      - Swagger has extra methods for /api/v1/tables: PUT
    - Path parameters: swagger path params must match <param> names in the
      handler path.
      Swagger verification failed with 1 issue(s):
      - Parameter mismatch for GET /api/v1/tables/{table_id}:
        swagger=['table_uuid'] source=['table_id']
    
    Change-Id: I3ca60becef11eb2170544f87fafc5d9aa4110a31
    Reviewed-on: http://gerrit.cloudera.org:8080/23904
    Reviewed-by: Marton Greber <[email protected]>
    Tested-by: Marton Greber <[email protected]>
    Reviewed-by: Alexey Serbin <[email protected]>
---
 build-support/verify_swagger_spec.py | 259 +++++++++++++++++++++++++++++++++++
 1 file changed, 259 insertions(+)

diff --git a/build-support/verify_swagger_spec.py 
b/build-support/verify_swagger_spec.py
new file mode 100755
index 000000000..1c9106b5b
--- /dev/null
+++ b/build-support/verify_swagger_spec.py
@@ -0,0 +1,259 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Verify that the hand-written Swagger spec matches the REST API handlers.
+#
+# Usage:
+#   ./build-support/verify_swagger_spec.py
+#
+# Expected output:
+# - Success:
+#   Swagger verification successful.
+# - Failure:
+#   Swagger verification failed with N issue(s):
+#   - <details>
+
+import argparse
+import json
+import os
+import re
+import sys
+from glob import glob
+
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+REPO_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, os.pardir))
+DEFAULT_SWAGGER_PATH = os.path.join(REPO_ROOT, "www", "swagger", 
"kudu-api.json")
+DEFAULT_SOURCE_ROOT = os.path.join(REPO_ROOT, "src", "kudu")
+DEFAULT_IGNORE_PATHS = ["/api/v1/spec", "/api/docs"]
+
+
+# Matches RegisterPathHandler/RegisterPrerenderedPathHandler argument lists.
+REGISTER_HANDLER_RE = re.compile(
+    r"Register(?:Prerendered)?PathHandler\(\s*(?P<args>.*?)\);\s*",
+    re.DOTALL,
+)
+# Extracts "/api/..." path literals from handler registrations.
+PATH_RE = re.compile(r'"(?P<path>/api/[^"]+)"')
+# Finds handler method names used in lambdas (e.g., this->HandleFoo).
+HANDLER_RE = re.compile(r"this->(?P<handler>\w+)\s*\(")
+# Detects explicit method checks (req.request_method == "GET").
+METHOD_EQ_RE = re.compile(r'req\.request_method\s*==\s*"(?P<method>[A-Z]+)"')
+# Detects negated method checks (req.request_method != "GET").
+METHOD_NE_RE = re.compile(r'req\.request_method\s*!=\s*"(?P<method>[A-Z]+)"')
+# Finds RestCatalogPathHandlers method definitions to scan for method checks.
+FUNC_DEF_RE = re.compile(
+    r"void\s+RestCatalogPathHandlers::(?P<name>\w+)\s*\([^)]*\)\s*\{",
+    re.DOTALL,
+)
+
+
+def read_file(path):
+  with open(path, "r", encoding="utf-8") as fh:
+    return fh.read()
+
+
+def normalize_base_path(base_path):
+  if not base_path:
+    return ""
+  return "/" + base_path.strip("/")
+
+
+def normalize_path(base_path, path):
+  if not path.startswith("/"):
+    path = "/" + path
+  base = normalize_base_path(base_path)
+  if base and path.startswith(base):
+    return path
+  return base + path
+
+
+def swagger_operations(swagger):
+  servers = swagger.get("servers", [])
+  base_path = servers[0].get("url", "") if servers else ""
+  paths = swagger.get("paths", {})
+  operations = {}
+  for swagger_path, path_item in paths.items():
+    full_path = normalize_path(base_path, swagger_path)
+    path_params = path_item.get("parameters", [])
+    for method, spec in path_item.items():
+      if method.lower() not in ("get", "put", "post", "delete", "patch", 
"head", "options"):
+        continue
+      op_params = list(path_params) + list(spec.get("parameters", []))
+      op_path_params = {
+          param["name"]
+          for param in op_params
+          if isinstance(param, dict) and param.get("in") == "path"
+      }
+      operations.setdefault(full_path, {})[method.upper()] = op_path_params
+  return operations
+
+
+def extract_block(text, start_idx):
+  brace_count = 0
+  in_block = False
+  for idx in range(start_idx, len(text)):
+    if text[idx] == "{":
+      brace_count += 1
+      in_block = True
+    elif text[idx] == "}":
+      brace_count -= 1
+      if in_block and brace_count == 0:
+        return text[start_idx:idx + 1]
+  return ""
+
+
+def extract_handler_methods(text, handler_name):
+  for match in FUNC_DEF_RE.finditer(text):
+    if match.group("name") != handler_name:
+      continue
+    body = extract_block(text, match.start())
+    methods = set(METHOD_EQ_RE.findall(body))
+    if methods:
+      return methods
+    method_ne = METHOD_NE_RE.search(body)
+    if method_ne:
+      return {method_ne.group("method")}
+    return set()
+  return None
+
+
+def source_operations(source_root):
+  source_paths = glob(os.path.join(source_root, "**", "*.cc"), recursive=True)
+  operations = {}
+  for path in source_paths:
+    text = read_file(path)
+    if "/api/" not in text or "Register" not in text:
+      continue
+    for match in REGISTER_HANDLER_RE.finditer(text):
+      args = match.group("args")
+      path_match = PATH_RE.search(args)
+      handler_match = HANDLER_RE.search(args)
+      if not path_match or not handler_match:
+        continue
+      endpoint_path = path_match.group("path")
+      handler = handler_match.group("handler")
+      methods = extract_handler_methods(text, handler)
+      path_params = set(re.findall(r"<([^>]+)>", endpoint_path))
+      normalized_path = re.sub(r"<([^>]+)>", r"{\1}", endpoint_path)
+      operations[normalized_path] = {
+          "methods": methods,
+          "path_params": path_params,
+          "handler": handler,
+          "source": path,
+      }
+  return operations
+
+
+def compare(swagger_ops, source_ops, ignore_paths):
+  errors = []
+  swagger_paths = set(swagger_ops.keys())
+  source_paths = set(source_ops.keys()) - set(ignore_paths)
+
+  extra_swagger = sorted(swagger_paths - source_paths)
+  extra_source = sorted(source_paths - swagger_paths)
+
+  for path in extra_swagger:
+    errors.append("Swagger path missing in source: {}".format(path))
+  for path in extra_source:
+    errors.append("Source path missing in swagger: {}".format(path))
+
+  for path in sorted(swagger_paths & source_paths):
+    swagger_methods = set(swagger_ops[path].keys())
+    source_methods = source_ops[path]["methods"]
+    if source_methods is None:
+      errors.append("Unable to locate handler for source path: 
{}".format(path))
+      continue
+    if not source_methods:
+      errors.append(
+          "Unable to infer HTTP methods for source path: {} "
+          "(checker only scans direct req.request_method checks in the handler 
"
+          "body; nested helpers are not detected)".format(path)
+      )
+      continue
+    if swagger_methods != source_methods:
+      missing = sorted(source_methods - swagger_methods)
+      extra = sorted(swagger_methods - source_methods)
+      if missing:
+        errors.append("Swagger missing methods for {}: {}".format(path, ", 
".join(missing)))
+      if extra:
+        errors.append("Swagger has extra methods for {}: {}".format(path, ", 
".join(extra)))
+    source_params = source_ops[path]["path_params"]
+    for method, swagger_params in swagger_ops[path].items():
+      if swagger_params != source_params:
+        errors.append(
+            "Parameter mismatch for {} {}: swagger={} source={}".format(
+                method,
+                path,
+                sorted(swagger_params),
+                sorted(source_params),
+            )
+        )
+  return errors
+
+
+def parse_args():
+  parser = argparse.ArgumentParser(
+      description="Verify Swagger spec matches REST API handlers."
+  )
+  parser.add_argument(
+      "--swagger",
+      default=DEFAULT_SWAGGER_PATH,
+      help="Path to swagger json (default: %(default)s)",
+  )
+  parser.add_argument(
+      "--source-root",
+      default=DEFAULT_SOURCE_ROOT,
+      help="Path to Kudu source root (default: %(default)s)",
+  )
+  parser.add_argument(
+      "--ignore-path",
+      action="append",
+      default=list(DEFAULT_IGNORE_PATHS),
+      help="API paths to ignore (repeatable)",
+  )
+  return parser.parse_args()
+
+
+def main():
+  args = parse_args()
+  if not os.path.exists(args.swagger):
+    print("Swagger spec not found: {}".format(args.swagger), file=sys.stderr)
+    return 2
+  swagger = json.loads(read_file(args.swagger))
+  swagger_ops = swagger_operations(swagger)
+  if not swagger_ops:
+    print("No swagger operations found in {}".format(args.swagger), 
file=sys.stderr)
+    return 2
+  source_ops = source_operations(args.source_root)
+  if not source_ops:
+    print("No REST handlers found under {}".format(args.source_root), 
file=sys.stderr)
+    return 2
+  errors = compare(swagger_ops, source_ops, args.ignore_path)
+  if errors:
+    print("Swagger verification failed with {} issue(s):".format(len(errors)))
+    for error in errors:
+      print("- {}".format(error))
+    return 1
+  print("Swagger verification successful.")
+  return 0
+
+
+if __name__ == "__main__":
+  sys.exit(main())

Reply via email to