REST APIs with requests: Talking to DNAC and Meraki-Style Controllers

Modern network controllers — Cisco DNA Center, Meraki Dashboard, ACI, SD‑WAN vManage — do not want a CLI session. They expose REST APIs: send an HTTP request, get JSON back. This is cleaner than screen‑scraping in every way, and the Python library for it, requests, is one of the most pleasant tools in the whole ecosystem. Today covers the HTTP verbs, authentication, and JSON handling that unlock every controller and cloud platform in the network world.

This is Day 17 of the 21‑post Python for Network Engineers series. The JSON skills from Day 15 pay off immediately here — REST APIs speak JSON natively.

Install and a First GET

pip install requests
import requests

# A public test API that echoes JSON back
resp = requests.get("https://httpbin.org/get")

print(resp.status_code)      # 200
print(resp.headers["Content-Type"])   # application/json
data = resp.json()           # parse the JSON body into a Python dict
print(data["url"])           # https://httpbin.org/get

Three things to note on every response: .status_code (the HTTP result), .json() (parse the body — no need for json.loads, requests does it), and .headers. The .json() method is the bridge back to Day 15: it returns exactly the dicts and lists already familiar.

Status Codes: The API’s Return Code

Just as ping uses exit code 0 for success, HTTP uses numeric status codes. The families worth knowing:

Range Meaning Common examples
2xx Success 200 OK, 201 Created, 204 No Content
4xx Client error (the request is wrong) 400 Bad Request, 401 Unauthorized, 404 Not Found
5xx Server error (the controller broke) 500 Internal Error, 503 Unavailable

Rather than checking the number by hand every time, ask requests to raise on failure:

import requests

try:
    resp = requests.get("https://httpbin.org/status/404")
    resp.raise_for_status()      # raises HTTPError on 4xx/5xx
except requests.exceptions.HTTPError as e:
    print(f"API returned an error: {e}")
except requests.exceptions.ConnectionError:
    print("Could not reach the controller")
except requests.exceptions.Timeout:
    print("Request timed out")

raise_for_status() plus the Week 1 exception handling is the robust pattern for every API call. ConnectionError (host unreachable) and Timeout are the two transport failures to expect on real networks.

The Four Verbs

  • GET — read something (list devices, get an interface’s state). No body.
  • POST — create something (add a device, run an action). Sends a body.
  • PUT / PATCH — update something (change a setting). Sends a body.
  • DELETE — remove something.
import requests

# POST with a JSON body — requests serializes the dict for you
new_network = {"name": "branch-42", "type": "switch"}
resp = requests.post(
    "https://httpbin.org/post",
    json=new_network,        # 'json=' sets Content-Type and encodes the dict
    timeout=10,
)
print(resp.json()["json"])   # httpbin echoes the body back
# {'name': 'branch-42', 'type': 'switch'}

The key detail: pass json=some_dict and requests both encodes it and sets the Content-Type: application/json header automatically. Avoid data=json.dumps(...) by hand — json= is the clean way.

Authentication and Headers

Real controllers need credentials. Three patterns cover almost everything:

import requests

# 1. API key or token in a header (Meraki, many SaaS)
headers = {"X-Cisco-Meraki-API-Key": "YOUR_KEY",
           "Content-Type": "application/json"}
resp = requests.get("https://api.meraki.com/api/v1/organizations",
                    headers=headers, timeout=10)

# 2. Bearer token (DNA Center, after a login call)
headers = {"Authorization": "Bearer eyJhbGci..."}

# 3. HTTP Basic auth (username/password) — the tuple form
resp = requests.get("https://device/api", auth=("admin", "cisco123"),
                    timeout=10, verify=False)

verify=False disables TLS certificate validation — often needed against lab gear with self‑signed certs, but it prints a warning and should never be used against production controllers with real certificates. It appears here because lab devices commonly ship self‑signed certs.

Cisco Context: The DNA Center Token‑Then‑Query Pattern

Most enterprise controllers use a two‑step flow: authenticate once to get a token, then send that token on every subsequent request. Here is the shape, using DNA Center’s always‑on DevNet sandbox as the model:

import requests
requests.packages.urllib3.disable_warnings()   # quiet the self-signed cert warning

BASE = "https://sandboxdnac.cisco.com"

# Step 1: get a token (POST to the auth endpoint with Basic auth)
token_resp = requests.post(
    f"{BASE}/dna/system/api/v1/auth/token",
    auth=("devnetuser", "Cisco123!"),
    verify=False, timeout=15,
)
token_resp.raise_for_status()
token = token_resp.json()["Token"]

# Step 2: use the token on the real query
headers = {"X-Auth-Token": token}
devices = requests.get(
    f"{BASE}/dna/intent/api/v1/network-device",
    headers=headers, verify=False, timeout=15,
).json()

for d in devices["response"][:5]:
    print(f"{d['hostname']:20} {d['managementIpAddress']:16} {d['type']}")

That token‑then‑query pattern is the template for DNA Center, vManage, ACI, and most enterprise APIs. Read the vendor’s API docs for the exact endpoints, but the Python shape does not change: authenticate, capture the token, attach it as a header, loop the results.

Query Parameters and Pagination

import requests

# params= builds the ?key=value query string, URL-encoding safely
resp = requests.get("https://httpbin.org/get",
                    params={"vlan": 10, "site": "hq"}, timeout=10)
print(resp.json()["args"])   # {'site': 'hq', 'vlan': '10'}

Large result sets arrive in pages — an API might return 100 devices at a time with a “next” link or a page number. The pattern is a loop that keeps requesting until no more pages remain. Each API documents its own pagination style, but they all reduce to “request, process, check for more, repeat.”

Exercises

https://httpbin.org is a free request‑echo service, ideal for these without any real controller.

  1. Warm-up. GET https://httpbin.org/ip and print the origin IP from the JSON response.
  2. Status handling. Write safe_get(url) that returns the parsed JSON on success or the string "error: <code>" on any 4xx/5xx, using raise_for_status. Test it against /status/200 and /status/503.
  3. POST. POST the dict {"hostname": "r99", "role": "edge"} to https://httpbin.org/post and print the echoed body back from resp.json()["json"].
  4. Headers. GET https://httpbin.org/headers while sending a custom header X-Auth-Token: test123, then confirm the server saw it in the response.
  5. Challenge. Write get_all(base_url, headers, per_page=50) that fetches paginated results: request pages starting at 1, append each page’s items to a master list, and stop when a page returns fewer than per_page items. Simulate it against any test endpoint (or write it against the DNA Center sandbox if reachable).

Answers

Show answers

1. Warm-up

import requests
print(requests.get("https://httpbin.org/ip", timeout=10).json()["origin"])

2. Status handling

import requests
def safe_get(url):
    try:
        r = requests.get(url, timeout=10)
        r.raise_for_status()
        return r.json()
    except requests.exceptions.HTTPError:
        return f"error: {r.status_code}"

print(safe_get("https://httpbin.org/status/200"))
print(safe_get("https://httpbin.org/status/503"))   # error: 503

3. POST

import requests
r = requests.post("https://httpbin.org/post",
                  json={"hostname": "r99", "role": "edge"}, timeout=10)
print(r.json()["json"])   # {'hostname': 'r99', 'role': 'edge'}

4. Headers

import requests
r = requests.get("https://httpbin.org/headers",
                 headers={"X-Auth-Token": "test123"}, timeout=10)
print(r.json()["headers"]["X-Auth-Token"])   # test123

5. Challenge

import requests

def get_all(base_url, headers, per_page=50):
    results = []
    page = 1
    while True:
        r = requests.get(base_url, headers=headers,
                         params={"page": page, "per_page": per_page},
                         timeout=15)
        r.raise_for_status()
        items = r.json()          # assume the body is a list of items
        results.extend(items)
        if len(items) < per_page:  # last page is short (or empty)
            break
        page += 1
    return results

The stop condition — a page shorter than per_page means the last page — is the standard way to know pagination is done without a separate count call. This same WordPress site’s REST API, incidentally, paginates exactly this way.


Previously: Jinja2 Templates. Coming tomorrow — NETCONF with ncclient: structured, transactional configuration on IOS‑XE using XML instead of screen‑scraping.

Leave a Reply