How to Build a Branch Protection Audit Mock API in Python

A mock HTTP API that serves branch protection rules for repositories and audits them for compliance, built with Python's standard library.

Medium Python 3.9+ Aug 9, 2026 Git + Python 10 views 0 copies

Python code

107 lines
Python 3.9+
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs

REPOSITORIES = {
    "alpha": {
        "default_branch": "main",
        "branches": ["main", "develop", "feature-x"],
        "protection_rules": {
            "main": {"required_reviews": 2, "dismiss_stale": True, "required_checks": ["CI"]},
            "develop": {"required_reviews": 1, "dismiss_stale": False, "required_checks": []},
            "feature-x": {"required_reviews": 0, "dismiss_stale": False, "required_checks": []}
        },
        "collaborators": ["alice", "bob", "carol"]
    },
    "beta": {
        "default_branch": "trunk",
        "branches": ["trunk", "release"],
        "protection_rules": {
            "trunk": {"required_reviews": 3, "dismiss_stale": True, "required_checks": ["lint", "test"]},
            "release": {"required_reviews": 1, "dismiss_stale": True, "required_checks": ["build"]}
        },
        "collaborators": ["dave", "erin"]
    }
}


class AuditHandler(BaseHTTPRequestHandler):

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path.strip("/")
        query = parse_qs(parsed.query)

        if not path:
            self._send_json(200, {"repositories": list(REPOSITORIES.keys())})
            return

        parts = path.split("/")
        if len(parts) == 1 and parts[0] in REPOSITORIES:
            repo_name = parts[0]
            branch_filter = query.get("branch", [None])[0]
            repo = REPOSITORIES[repo_name]
            if branch_filter:
                if branch_filter not in repo["protection_rules"]:
                    self._send_json(404, {"error": f"Branch '{branch_filter}' not found"})
                    return
                rules = {branch_filter: repo["protection_rules"][branch_filter]}
            else:
                rules = repo["protection_rules"]
            self._send_json(200, {"repository": repo_name, "protection_rules": rules})
            return

        if len(parts) == 2 and parts[0] == "audit":
            repo_name = parts[1]
            if repo_name not in REPOSITORIES:
                self._send_json(404, {"error": f"Repository '{repo_name}' not found"})
                return
            repo = REPOSITORIES[repo_name]
            audit_results = {}
            for branch, rules in repo["protection_rules"].items():
                issues = []
                if branch == repo["default_branch"]:
                    if rules["required_reviews"] < 2:
                        issues.append("default branch requires at least 2 reviews")
                    if not rules["required_checks"]:
                        issues.append("default branch missing required checks")
                else:
                    if not rules["required_checks"] and len(repo["branches"]) > 2:
                        issues.append("non-default branch lacks required checks")
                if rules["required_reviews"] == 0:
                    issues.append("no review requirement")
                audit_results[branch] = {
                    "status": "compliant" if not issues else "non-compliant",
                    "issues": issues,
                    "protection": rules
                }
            self._send_json(200, {"repository": repo_name, "audit_results": audit_results})
            return

        self._send_json(404, {"error": "Endpoint not found"})

    def _send_json(self, status, data):
        body = json.dumps(data, indent=2).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


def run_server(port=8000):
    server = HTTPServer(("127.0.0.1", port), AuditHandler)
    print(f"Mock API running on http://127.0.0.1:{port}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()


if __name__ == "__main__":
    run_server()

Output

stdout
Mock API running on http://127.0.0.1:8000

GET / → {
  "repositories": ["alpha", "beta"]
}

GET /alpha → {
  "repository": "alpha",
  "protection_rules": {
    "main": {"required_reviews": 2, "dismiss_stale": true, "required_checks": ["CI"]},
    "develop": {"required_reviews": 1, "dismiss_stale": false, "required_checks": []},
    "feature-x": {"required_reviews": 0, "dismiss_stale": false, "required_checks": []}
  }
}

GET /audit/alpha → {
  "repository": "alpha",
  "audit_results": {
    "main": {"status": "compliant", "issues": [], "protection": {"required_reviews": 2, "dismiss_stale": true, "required_checks": ["CI"]}},
    "develop": {"status": "non-compliant", "issues": ["non-default branch lacks required checks"], "protection": {"required_reviews": 1, "dismiss_stale": false, "required_checks": []}},
    "feature-x": {"status": "non-compliant", "issues": ["no review requirement"], "protection": {"required_reviews": 0, "dismiss_stale": false, "required_checks": []}}
  }
}

How it works

This mock API uses http.server from the standard library to create a lightweight HTTP server without external dependencies. The BaseHTTPRequestHandler class lets you implement do_GET to define how GET requests are handled. URL parsing with urlparse and parse_qs extracts the path and query parameters, enabling branch filtering. The audit logic walks through each repository's protection rules and flags issues like missing required reviews or checks on default branches. This pattern is great for testing client code against a fake backend before connecting to the real GitHub API.

Common mistakes

  • Forgetting to strip leading/trailing slashes from the path before routing
  • Not handling unknown branch names in query filters, resulting in wrong 200 responses
  • Using `json.dumps` without `indent` makes debugging responses harder
  • Sending Content-Length header with wrong byte count when body includes non-ASCII characters

Variations

  1. Use `http.server.ThreadingHTTPServer` for concurrent request handling
  2. Replace the hardcoded REPOSITORIES dict with a JSON file loaded via `json.load`

Real-world use cases

  • Building a local test harness to validate GitHub API client code before production deployment
  • Simulating a compliance checker that enforces branch protection policies across many repos in CI pipelines
  • Creating a demo server for security teams to review audit findings in a sandboxed environment

Sponsored

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Git + Python

Related tutorials and quizzes for this topic.