293 lines
9.3 KiB
Python
293 lines
9.3 KiB
Python
"""
|
|
The API notebook: set parameters, call, print the result, change one parameter,
|
|
call again.
|
|
|
|
This is a **shape, not a description of any particular API**. The service it
|
|
drives is confidential and is not readable from here, so every place that needs
|
|
a real value carries a `# FILL:` comment instead of a guess. Search the emitted
|
|
notebook for `FILL` and that is the complete list of what has to be replaced —
|
|
nothing else in the file makes a claim about the API.
|
|
|
|
Why placeholders rather than an approximation: an endpoint that is nearly right
|
|
is worse than one that is obviously blank. The blank one gets filled in; the
|
|
nearly-right one gets run, fails somewhere in the middle, and costs whoever
|
|
opened it an afternoon deciding whether the notebook or the service is wrong.
|
|
|
|
The client is `urllib.request` from the standard library rather than `httpx` or
|
|
`requests`. A notebook that opens in Colab and runs without a `pip install` cell
|
|
is a notebook that runs; one that needs a dependency has a step before the first
|
|
step, and that step fails behind a proxy.
|
|
"""
|
|
|
|
from ..doc import Doc
|
|
|
|
# One place for the placeholder names, so the emitted notebook and the FILL list
|
|
# cannot disagree about what they are called.
|
|
BASE_URL = "https://api.example.invalid"
|
|
ENV_VAR = "API_TOKEN"
|
|
|
|
|
|
def build() -> Doc:
|
|
doc = Doc(title="API walkthrough")
|
|
|
|
doc.md(
|
|
f"""
|
|
# API walkthrough
|
|
|
|
Set the parameters, make a call, read the result, change a parameter, call
|
|
again. That is the whole notebook.
|
|
|
|
**Before running anything**, replace every `FILL` in the cells below. There are
|
|
no other values to change — anything not marked `FILL` is either standard
|
|
library or scaffolding.
|
|
|
|
| | |
|
|
|---|---|
|
|
| base URL | the `BASE_URL` cell |
|
|
| credentials | read from the `{ENV_VAR}` environment variable, never written here |
|
|
| endpoints | one section each, `FILL` on the path and the body |
|
|
|
|
Nothing is installed. The client below is `urllib.request` from the standard
|
|
library, so this runs on a bare Python 3 kernel and on Colab as-is.
|
|
|
|
**Credentials do not go in this file.** Set the environment variable before
|
|
starting the kernel, or use your platform's secret store. A token pasted into a
|
|
cell is a token in every copy of the notebook from then on, including the ones
|
|
in someone's Downloads folder.
|
|
"""
|
|
)
|
|
|
|
# --- Parameters ---------------------------------------------------------
|
|
doc.md(
|
|
"""
|
|
## 1. Parameters
|
|
|
|
Everything the calls depend on, in one cell, so changing where this points is
|
|
one edit in one place rather than a search through the notebook.
|
|
"""
|
|
)
|
|
doc.code(
|
|
f'''
|
|
import json
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
# FILL: the base URL of the service, no trailing slash
|
|
BASE_URL = "{BASE_URL}"
|
|
|
|
# FILL: confirm the variable name your deployment uses.
|
|
# Read from the environment on purpose — a token written into a cell travels
|
|
# with every copy of this notebook.
|
|
TOKEN = os.environ.get("{ENV_VAR}", "")
|
|
|
|
# FILL: the header the service expects. Bearer is the common case; some services
|
|
# want "X-Api-Key" or a query parameter instead.
|
|
AUTH_HEADER = {{"Authorization": f"Bearer {{TOKEN}}"}} if TOKEN else {{}}
|
|
|
|
TIMEOUT = 30 # seconds; raise it if the service is slow to warm up
|
|
VERBOSE = True # print the request line before each call
|
|
|
|
print(f"base {{BASE_URL}}")
|
|
print(f"token {{'set — ' + str(len(TOKEN)) + ' chars' if TOKEN else 'NOT SET — export {ENV_VAR}=... and restart the kernel'}}")
|
|
print(f"timeout {{TIMEOUT}}s")
|
|
'''
|
|
)
|
|
|
|
# --- Client -------------------------------------------------------------
|
|
doc.md(
|
|
"""
|
|
## 2. The client
|
|
|
|
One function for every call in the notebook. It returns the status, the headers
|
|
and the parsed body rather than raising on a non-2xx, because a 401 or a 422 is
|
|
a result worth reading — the body usually says what was wrong with the request,
|
|
and an exception throws that away.
|
|
"""
|
|
)
|
|
doc.code(
|
|
'''
|
|
def call(method, path, params=None, body=None, headers=None, timeout=None):
|
|
"""Make one request. Returns (status, headers, parsed_body).
|
|
|
|
Non-2xx is returned, not raised: the error body is the useful part.
|
|
"""
|
|
url = BASE_URL.rstrip("/") + "/" + path.lstrip("/")
|
|
if params:
|
|
url += "?" + urllib.parse.urlencode(params)
|
|
|
|
data = None
|
|
hdrs = {"Accept": "application/json", **AUTH_HEADER, **(headers or {})}
|
|
if body is not None:
|
|
data = json.dumps(body).encode()
|
|
hdrs["Content-Type"] = "application/json"
|
|
|
|
if VERBOSE:
|
|
print(f"-> {method} {url}")
|
|
|
|
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
started = time.time()
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout or TIMEOUT) as resp:
|
|
status, raw, got = resp.status, resp.read(), dict(resp.headers)
|
|
except urllib.error.HTTPError as e:
|
|
# An HTTPError *is* the response. Read it rather than re-raising.
|
|
status, raw, got = e.code, e.read(), dict(e.headers)
|
|
except urllib.error.URLError as e:
|
|
print(f"<- could not reach {url}: {e.reason}")
|
|
return None, {}, None
|
|
|
|
elapsed = time.time() - started
|
|
try:
|
|
parsed = json.loads(raw) if raw else None
|
|
except json.JSONDecodeError:
|
|
parsed = raw.decode("utf-8", "replace")
|
|
|
|
if VERBOSE:
|
|
print(f"<- {status} in {elapsed:.2f}s, {len(raw)} bytes")
|
|
return status, got, parsed
|
|
|
|
|
|
def show(result, limit=2000):
|
|
"""Print a result readably, and say so when it has been cut short."""
|
|
status, _, body = result
|
|
if status is None:
|
|
print("no response")
|
|
return
|
|
text = json.dumps(body, indent=2, ensure_ascii=False) if not isinstance(body, str) else body
|
|
print(f"status {status}")
|
|
print(text[:limit])
|
|
if len(text) > limit:
|
|
print(f"... {len(text) - limit} more characters")
|
|
'''
|
|
)
|
|
|
|
# --- Health -------------------------------------------------------------
|
|
doc.md(
|
|
"""
|
|
## 3. Is it up?
|
|
|
|
The cheapest call the service has. Run this first — every failure below is
|
|
easier to read once you know whether the problem is the request or the network.
|
|
"""
|
|
)
|
|
doc.code(
|
|
'''
|
|
# FILL: the service's health or version path
|
|
HEALTH_PATH = "/health"
|
|
|
|
show(call("GET", HEALTH_PATH))
|
|
'''
|
|
)
|
|
doc.code(
|
|
'''
|
|
# Reachability and latency, before anything that costs money.
|
|
for attempt in range(3):
|
|
started = time.time()
|
|
status, _, _ = call("GET", HEALTH_PATH)
|
|
print(f" attempt {attempt + 1}: {status} in {time.time() - started:.2f}s")
|
|
''',
|
|
live_only=True,
|
|
)
|
|
|
|
# --- First endpoint -----------------------------------------------------
|
|
doc.md(
|
|
"""
|
|
## 4. First call
|
|
|
|
The parameters live in their own cell above the call. That is the point of the
|
|
notebook: change the cell, re-run the two below it, compare.
|
|
"""
|
|
)
|
|
doc.code(
|
|
'''
|
|
# FILL: the path for this endpoint
|
|
ENDPOINT = "/resource"
|
|
|
|
# FILL: the query parameters it takes, with values that return something small
|
|
QUERY = {
|
|
"limit": 10,
|
|
}
|
|
'''
|
|
)
|
|
doc.code('result = call("GET", ENDPOINT, params=QUERY)\nshow(result)')
|
|
|
|
# --- Second endpoint ----------------------------------------------------
|
|
doc.md(
|
|
"""
|
|
## 5. A call with a body
|
|
|
|
Same shape, POST instead of GET. Keep the request body in its own cell for the
|
|
same reason as the query above.
|
|
"""
|
|
)
|
|
doc.code(
|
|
'''
|
|
# FILL: the path for the endpoint that takes a body
|
|
POST_ENDPOINT = "/resource"
|
|
|
|
# FILL: the request body. Keep it minimal — the smallest thing that returns a
|
|
# valid response, so a failure is about the endpoint and not about the payload.
|
|
PAYLOAD = {
|
|
"example": "value",
|
|
}
|
|
'''
|
|
)
|
|
doc.code('created = call("POST", POST_ENDPOINT, body=PAYLOAD)\nshow(created)')
|
|
|
|
# --- Update params and call again ---------------------------------------
|
|
doc.md(
|
|
"""
|
|
## 6. Change a parameter, call again
|
|
|
|
The comparison is the reason to do this in a notebook rather than with `curl`:
|
|
both results are still in memory, so the difference is one cell rather than two
|
|
terminal scrollbacks.
|
|
"""
|
|
)
|
|
doc.code(
|
|
'''
|
|
# FILL: the parameter worth varying, and a second value for it
|
|
QUERY = {**QUERY, "limit": 50}
|
|
|
|
second = call("GET", ENDPOINT, params=QUERY)
|
|
show(second)
|
|
'''
|
|
)
|
|
doc.code(
|
|
'''
|
|
first_status, _, first_body = result
|
|
second_status, _, second_body = second
|
|
|
|
print(f"first {first_status} {type(first_body).__name__}")
|
|
print(f"second {second_status} {type(second_body).__name__}")
|
|
|
|
# FILL: whatever "how much came back" means for this endpoint — a list length,
|
|
# a `total` field, a row count.
|
|
for name, payload in (("first", first_body), ("second", second_body)):
|
|
size = len(payload) if isinstance(payload, (list, dict, str)) else "n/a"
|
|
print(f"{name:8} size {size}")
|
|
'''
|
|
)
|
|
|
|
# --- Closing ------------------------------------------------------------
|
|
doc.md(
|
|
"""
|
|
## Notes
|
|
|
|
- Every `FILL` above is a value this notebook could not know. Nothing else needs
|
|
editing.
|
|
- `call` returns non-2xx rather than raising, so read the body on a failure —
|
|
it usually names the field that was wrong.
|
|
- If `URLError` comes back instead of a status, nothing reached the service:
|
|
check `BASE_URL`, then the network path, before changing anything about the
|
|
request.
|
|
- The token is read from the environment. If you change it, restart the kernel —
|
|
`os.environ` is read once, when the parameters cell runs.
|
|
"""
|
|
)
|
|
|
|
return doc
|