pyhttp-app 2.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyhttp/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
pyhttp/mock.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Mock servers served from pyhttp's own port.
|
|
2
|
+
|
|
3
|
+
A mock server is a name, a slug and a list of routes. Everything under
|
|
4
|
+
`/mock/<slug>/…` on the port pyhttp already listens on is answered from those
|
|
5
|
+
routes, so there is no second process and no second port to remember:
|
|
6
|
+
|
|
7
|
+
http://127.0.0.1:8765/mock/payments/users/42
|
|
8
|
+
|
|
9
|
+
A route path may hold parameters written either way (`/users/:id` or
|
|
10
|
+
`/users/{id}`); `*` matches one segment and `**` matches the rest of the path
|
|
11
|
+
(as `{{req.params.rest}}`).
|
|
12
|
+
The response body is a `{{ ... }}` template, so besides variables and the
|
|
13
|
+
dynamic values (`{{$guid}}`, `{{$randomEmail}}`…) it can read the incoming
|
|
14
|
+
request:
|
|
15
|
+
|
|
16
|
+
{{req.params.id}} a path parameter (from `:id` / `{id}` in the route path)
|
|
17
|
+
{{req.query.limit}} a query-string value
|
|
18
|
+
{{req.headers.x-key}} a request header (case-insensitive)
|
|
19
|
+
{{req.body}} the raw request body
|
|
20
|
+
{{req.json.user.name}} a path into the JSON request body
|
|
21
|
+
{{req.method}} {{req.path}}
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
from urllib.parse import parse_qsl
|
|
30
|
+
|
|
31
|
+
from . import db
|
|
32
|
+
from .templating import build_context, render
|
|
33
|
+
|
|
34
|
+
METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
35
|
+
MAX_LOGGED_BODY = 4000 # a logged call keeps at most this much of the request body
|
|
36
|
+
_PARAM = re.compile(r":([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_path(path: str) -> str:
|
|
40
|
+
"""`users/:id/` -> `/users/:id` so routes and incoming paths compare equal."""
|
|
41
|
+
path = (path or "").strip()
|
|
42
|
+
path = path.split("?", 1)[0].split("#", 1)[0]
|
|
43
|
+
if not path.startswith("/"):
|
|
44
|
+
path = "/" + path
|
|
45
|
+
return path.rstrip("/") or "/"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def compile_path(path: str) -> tuple[re.Pattern[str], int]:
|
|
49
|
+
"""Regex for a route path, plus how many wildcard segments it has (lower = more specific)."""
|
|
50
|
+
parts: list[str] = []
|
|
51
|
+
wild = 0
|
|
52
|
+
for seg in normalize_path(path).strip("/").split("/"):
|
|
53
|
+
if not seg:
|
|
54
|
+
continue
|
|
55
|
+
if seg == "**":
|
|
56
|
+
parts.append("(?P<rest>.*)")
|
|
57
|
+
wild += 100
|
|
58
|
+
break
|
|
59
|
+
if seg == "*":
|
|
60
|
+
parts.append("[^/]+")
|
|
61
|
+
wild += 1
|
|
62
|
+
continue
|
|
63
|
+
m = _PARAM.fullmatch(seg)
|
|
64
|
+
if m:
|
|
65
|
+
parts.append(f"(?P<{m.group(1) or m.group(2)}>[^/]+)")
|
|
66
|
+
wild += 1
|
|
67
|
+
else:
|
|
68
|
+
parts.append(re.escape(seg))
|
|
69
|
+
pattern = "/" + "/".join(parts) if parts else "/"
|
|
70
|
+
return re.compile(f"^{pattern}/?$"), wild
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _query_conditions(match_query: str) -> list[tuple[str, str]]:
|
|
74
|
+
"""`status=error&kind=card` -> the pairs an incoming query must contain."""
|
|
75
|
+
return [(k, v) for k, v in parse_qsl(match_query.lstrip("?"), keep_blank_values=True)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def match_route(routes: list[dict[str, Any]], method: str, path: str,
|
|
79
|
+
query: dict[str, str]) -> tuple[dict[str, Any] | None, dict[str, str]]:
|
|
80
|
+
"""The best route for a request: exact paths beat parameters, and a route with
|
|
81
|
+
query conditions beats a plain one. Ties go to the earlier route."""
|
|
82
|
+
method, path = method.upper(), normalize_path(path)
|
|
83
|
+
best: tuple[tuple[int, int, int], dict[str, Any], dict[str, str]] | None = None
|
|
84
|
+
for pos, route in enumerate(routes):
|
|
85
|
+
if not route.get("enabled", True):
|
|
86
|
+
continue
|
|
87
|
+
route_method = str(route.get("method") or "GET").upper()
|
|
88
|
+
if route_method not in ("ANY", "*") and route_method != method:
|
|
89
|
+
continue
|
|
90
|
+
rx, wild = compile_path(route.get("path") or "/")
|
|
91
|
+
m = rx.match(path)
|
|
92
|
+
if not m:
|
|
93
|
+
continue
|
|
94
|
+
conditions = _query_conditions(route.get("match_query") or "")
|
|
95
|
+
if any(query.get(k) != v for k, v in conditions):
|
|
96
|
+
continue
|
|
97
|
+
rank = (wild, -len(conditions), pos) # smaller wins
|
|
98
|
+
if best is None or rank < best[0]:
|
|
99
|
+
best = (rank, route, {k: v for k, v in m.groupdict().items() if v is not None})
|
|
100
|
+
if best is None and method == "HEAD":
|
|
101
|
+
return match_route(routes, "GET", path, query) # HEAD is a GET without the body
|
|
102
|
+
return (best[1], best[2]) if best else (None, {})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def request_context(method: str, path: str, query: dict[str, str], headers: dict[str, str],
|
|
106
|
+
body: str, params: dict[str, str]) -> dict[str, Any]:
|
|
107
|
+
"""Template context for a mock response: variables plus a `req` object."""
|
|
108
|
+
parsed: Any = {}
|
|
109
|
+
if body:
|
|
110
|
+
try:
|
|
111
|
+
parsed = json.loads(body)
|
|
112
|
+
except ValueError:
|
|
113
|
+
parsed = {}
|
|
114
|
+
variables, _env = db.effective_variables()
|
|
115
|
+
ctx = build_context(variables)
|
|
116
|
+
ctx["req"] = {"method": method.upper(), "path": normalize_path(path), "params": params,
|
|
117
|
+
"query": dict(query), "headers": dict(headers), "body": body, "json": parsed}
|
|
118
|
+
return ctx
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cors_headers(origin: str = "*", request_headers: str = "") -> dict[str, str]:
|
|
122
|
+
return {
|
|
123
|
+
"Access-Control-Allow-Origin": origin or "*",
|
|
124
|
+
"Access-Control-Allow-Methods": ", ".join(METHODS),
|
|
125
|
+
"Access-Control-Allow-Headers": request_headers or "*",
|
|
126
|
+
"Access-Control-Max-Age": "600",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_response(server: dict[str, Any], route: dict[str, Any], ctx: dict[str, Any]) -> dict[str, Any]:
|
|
131
|
+
"""Status, headers and rendered body for a matched route."""
|
|
132
|
+
body = render(route.get("body") or "", ctx)
|
|
133
|
+
headers = {"Content-Type": route.get("content_type") or "application/json"}
|
|
134
|
+
for row in route.get("headers") or []:
|
|
135
|
+
if row.get("enabled", True) and str(row.get("key") or "").strip():
|
|
136
|
+
headers[str(row["key"]).strip()] = render(str(row.get("value") or ""), ctx)
|
|
137
|
+
if server.get("cors", True):
|
|
138
|
+
headers.update(cors_headers())
|
|
139
|
+
return {"status_code": int(route.get("status_code") or 200), "headers": headers, "body": body,
|
|
140
|
+
"delay_s": (float(server.get("delay_ms") or 0) + float(route.get("delay_ms") or 0)) / 1000.0}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def not_found_body(server: dict[str, Any], method: str, path: str) -> dict[str, Any]:
|
|
144
|
+
"""The answer when nothing matches: a 404 that says what the server does know."""
|
|
145
|
+
routes = server.get("routes") or []
|
|
146
|
+
known = [f"{r.get('method', 'GET')} {normalize_path(r.get('path') or '/')}" for r in routes
|
|
147
|
+
if r.get("enabled", True)]
|
|
148
|
+
detail = (f"No mock route in \"{server['name']}\" matches {method.upper()} {normalize_path(path)}")
|
|
149
|
+
body = json.dumps({"error": "no_mock_route", "detail": detail, "mock": server["name"],
|
|
150
|
+
"routes": known}, indent=2)
|
|
151
|
+
headers = {"Content-Type": "application/json"}
|
|
152
|
+
if server.get("cors", True):
|
|
153
|
+
headers.update(cors_headers())
|
|
154
|
+
return {"status_code": 404, "headers": headers, "body": body, "delay_s": 0.0}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def log_call(server: dict[str, Any], route: dict[str, Any] | None, method: str, path: str,
|
|
158
|
+
query_string: str, headers: dict[str, str], body: str, status_code: int,
|
|
159
|
+
duration_ms: float) -> None:
|
|
160
|
+
if not server.get("log_calls", True):
|
|
161
|
+
return
|
|
162
|
+
db.add_mock_call(server["id"], {
|
|
163
|
+
"route_id": route["id"] if route else None,
|
|
164
|
+
"route_name": (route.get("name") or f"{route.get('method')} {route.get('path')}") if route else "",
|
|
165
|
+
"ts": time.time(), "method": method.upper(), "path": normalize_path(path),
|
|
166
|
+
"query": query_string, "status_code": status_code, "matched": bool(route),
|
|
167
|
+
"duration_ms": duration_ms, "req_headers": headers, "req_body": body[:MAX_LOGGED_BODY],
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------- seeding from requests
|
|
172
|
+
def _path_of(url: str) -> str:
|
|
173
|
+
"""The path part of a saved request's URL, variables and all: `{{base}}/users/:id` -> `/users/:id`."""
|
|
174
|
+
url = (url or "").strip()
|
|
175
|
+
url = re.sub(r"^\s*\{\{[^}]*\}\}", "", url) # a leading {{base_url}}
|
|
176
|
+
url = re.sub(r"^[a-zA-Z][\w+.-]*://[^/]+", "", url) # or a real scheme + host
|
|
177
|
+
return normalize_path(url.split("?", 1)[0] or "/")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def routes_from_requests(request_ids: list[str]) -> list[dict[str, Any]]:
|
|
181
|
+
"""Draft routes for saved requests, answering with the response each last gave
|
|
182
|
+
(from the request history) or an empty JSON object."""
|
|
183
|
+
history = db.list_history(limit=db.MAX_HISTORY)
|
|
184
|
+
last: dict[str, dict[str, Any]] = {}
|
|
185
|
+
for entry in history: # newest first
|
|
186
|
+
rid = entry.get("request_id")
|
|
187
|
+
if rid and rid not in last and entry.get("status_code"):
|
|
188
|
+
last[rid] = entry
|
|
189
|
+
drafts = []
|
|
190
|
+
for rid in request_ids:
|
|
191
|
+
req = db.get_request(rid)
|
|
192
|
+
if not req:
|
|
193
|
+
continue
|
|
194
|
+
seen = last.get(rid)
|
|
195
|
+
full = db.get_history(seen["id"]) if seen else None
|
|
196
|
+
resp = ((full or {}).get("detail") or {}).get("response") or {}
|
|
197
|
+
body = resp.get("body_text") or ""
|
|
198
|
+
status = int(resp.get("status_code") or 200)
|
|
199
|
+
content_type = next((v for k, v in (resp.get("headers") or []) if str(k).lower() == "content-type"),
|
|
200
|
+
"application/json")
|
|
201
|
+
drafts.append({"name": req["name"], "method": req.get("method") or "GET", "path": _path_of(req.get("url")),
|
|
202
|
+
"status_code": status, "content_type": content_type.split(";")[0].strip(),
|
|
203
|
+
"body": body or "{}"})
|
|
204
|
+
return drafts
|
pyhttp/monitors.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Scheduled monitors: picked requests sent again on an interval, in the background of the
|
|
2
|
+
running app. Each check stores a compact result row (no run folders, no history entries, so
|
|
3
|
+
a monitor every minute does not flood the disk); failures can ping a webhook.
|
|
4
|
+
|
|
5
|
+
A check fails when any request errors, answers 4xx/5xx, or fails one of its tests —
|
|
6
|
+
the same rule the collection runner uses.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from . import USER_AGENT, db, runner
|
|
17
|
+
from .templating import build_context
|
|
18
|
+
|
|
19
|
+
TICK_SECONDS = 5.0 # how often the scheduler looks for due monitors
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_check(monitor: dict[str, Any]) -> dict[str, Any]:
|
|
23
|
+
"""Send the monitor's requests in order and store one check row. Returns the check."""
|
|
24
|
+
started = time.time()
|
|
25
|
+
results: list[dict[str, Any]] = []
|
|
26
|
+
failed = 0
|
|
27
|
+
prev: dict[str, Any] | None = None
|
|
28
|
+
for rid in monitor.get("request_ids") or []:
|
|
29
|
+
req = db.get_request(rid)
|
|
30
|
+
if not req:
|
|
31
|
+
results.append({"request_id": rid, "name": "(deleted request)", "ok": False,
|
|
32
|
+
"error": "the request no longer exists"})
|
|
33
|
+
failed += 1
|
|
34
|
+
continue
|
|
35
|
+
for item in runner.expand(req):
|
|
36
|
+
variables, _env = db.effective_variables(item.get("folder_id"))
|
|
37
|
+
ctx = build_context(variables, prev)
|
|
38
|
+
result = await runner.execute(item, ctx)
|
|
39
|
+
prev = runner.prev_context(result)
|
|
40
|
+
ok = result["status"] != "error" and (result.get("status_code") or 0) < 400 \
|
|
41
|
+
and not result.get("tests_failed")
|
|
42
|
+
if not ok:
|
|
43
|
+
failed += 1
|
|
44
|
+
results.append({
|
|
45
|
+
"request_id": rid, "name": runner.display_name(item), "method": item.get("method", ""),
|
|
46
|
+
"ok": ok, "status_code": result.get("status_code"),
|
|
47
|
+
"duration_ms": result.get("duration_ms"), "error": result.get("error"),
|
|
48
|
+
"tests_failed": result.get("tests_failed", 0),
|
|
49
|
+
})
|
|
50
|
+
check = {"ts": started, "ok": failed == 0, "failed": failed, "total": len(results),
|
|
51
|
+
"duration_ms": round((time.time() - started) * 1000, 1), "results": results}
|
|
52
|
+
stored = db.add_monitor_check(monitor["id"], check)
|
|
53
|
+
db.update_monitor(monitor["id"], last_run_at=started, last_status="fail" if failed else "pass")
|
|
54
|
+
if failed and (monitor.get("webhook_url") or "").strip():
|
|
55
|
+
await _webhook(monitor, check)
|
|
56
|
+
return stored
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _webhook(monitor: dict[str, Any], check: dict[str, Any]) -> None:
|
|
60
|
+
"""POST the failure to the monitor's webhook. Errors are swallowed: an unreachable
|
|
61
|
+
webhook must never break the schedule."""
|
|
62
|
+
payload = {
|
|
63
|
+
"monitor": monitor["name"], "monitor_id": monitor["id"], "status": "fail",
|
|
64
|
+
"failed": check["failed"], "total": check["total"], "ts": check["ts"],
|
|
65
|
+
"results": [r for r in check["results"] if not r["ok"]],
|
|
66
|
+
}
|
|
67
|
+
try:
|
|
68
|
+
async with httpx.AsyncClient(timeout=10, headers={"User-Agent": USER_AGENT}) as client:
|
|
69
|
+
await client.post(monitor["webhook_url"].strip(), json=payload)
|
|
70
|
+
except Exception: # noqa: BLE001
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class MonitorScheduler:
|
|
75
|
+
"""Runs due monitors from the app's event loop. One check per monitor at a time."""
|
|
76
|
+
|
|
77
|
+
def __init__(self) -> None:
|
|
78
|
+
self._task: asyncio.Task[None] | None = None
|
|
79
|
+
self._running: set[str] = set()
|
|
80
|
+
|
|
81
|
+
def start(self) -> None:
|
|
82
|
+
if self._task is None or self._task.done():
|
|
83
|
+
self._task = asyncio.create_task(self._loop())
|
|
84
|
+
|
|
85
|
+
def stop(self) -> None:
|
|
86
|
+
if self._task is not None:
|
|
87
|
+
self._task.cancel()
|
|
88
|
+
self._task = None
|
|
89
|
+
|
|
90
|
+
async def _loop(self) -> None:
|
|
91
|
+
while True:
|
|
92
|
+
try:
|
|
93
|
+
self.tick()
|
|
94
|
+
except Exception: # noqa: BLE001 - the schedule must survive anything
|
|
95
|
+
pass
|
|
96
|
+
await asyncio.sleep(TICK_SECONDS)
|
|
97
|
+
|
|
98
|
+
def tick(self, now: float | None = None) -> list[asyncio.Task[Any]]:
|
|
99
|
+
"""Start a check task for every due monitor; called by the loop and by tests."""
|
|
100
|
+
tasks = []
|
|
101
|
+
for monitor in db.due_monitors(now if now is not None else time.time()):
|
|
102
|
+
if monitor["id"] in self._running:
|
|
103
|
+
continue
|
|
104
|
+
self._running.add(monitor["id"])
|
|
105
|
+
# claim at once, so the monitor is not due again while its check runs
|
|
106
|
+
db.update_monitor(monitor["id"], last_run_at=time.time())
|
|
107
|
+
tasks.append(asyncio.create_task(self._run(monitor)))
|
|
108
|
+
return tasks
|
|
109
|
+
|
|
110
|
+
async def _run(self, monitor: dict[str, Any]) -> None:
|
|
111
|
+
try:
|
|
112
|
+
await run_check(monitor)
|
|
113
|
+
except Exception: # noqa: BLE001
|
|
114
|
+
db.update_monitor(monitor["id"], last_status="fail")
|
|
115
|
+
finally:
|
|
116
|
+
self._running.discard(monitor["id"])
|
pyhttp/oauth2.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""OAuth 2.0 tokens for the "OAuth 2.0" auth type.
|
|
2
|
+
|
|
3
|
+
Grants that need no browser: client credentials and password (resource owner).
|
|
4
|
+
Tokens are cached in memory per (token URL, client, user, scope) and fetched
|
|
5
|
+
again when they expire; a refresh token is used first when the server gave one.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
GRANTS = ("client_credentials", "password")
|
|
18
|
+
_EARLY = 30 # seconds: treat a token as expired this long before it really is
|
|
19
|
+
|
|
20
|
+
_cache: dict[tuple[str, ...], dict[str, Any]] = {}
|
|
21
|
+
_lock = threading.Lock()
|
|
22
|
+
_fetch_locks: dict[tuple[str, ...], asyncio.Lock] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _key(auth: dict[str, Any]) -> tuple[str, ...]:
|
|
26
|
+
return (auth.get("grant") or "client_credentials", auth.get("token_url") or "", auth.get("client_id") or "",
|
|
27
|
+
auth.get("username") or "", auth.get("scope") or "", auth.get("audience") or "")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cached(auth: dict[str, Any]) -> dict[str, Any] | None:
|
|
31
|
+
"""The cached token for these (rendered) settings, if it is still valid."""
|
|
32
|
+
with _lock:
|
|
33
|
+
tok = _cache.get(_key(auth))
|
|
34
|
+
if tok and (not tok.get("expires_at") or tok["expires_at"] - _EARLY > time.time()):
|
|
35
|
+
return tok
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def status(auth: dict[str, Any]) -> dict[str, Any]:
|
|
40
|
+
"""Token info for the UI (the token itself is shortened)."""
|
|
41
|
+
with _lock:
|
|
42
|
+
tok = _cache.get(_key(auth))
|
|
43
|
+
if not tok:
|
|
44
|
+
return {"has_token": False}
|
|
45
|
+
exp = tok.get("expires_at")
|
|
46
|
+
return {
|
|
47
|
+
"has_token": True, "valid": not exp or exp - _EARLY > time.time(), "expires_at": exp,
|
|
48
|
+
"token_type": tok.get("token_type") or "Bearer", "scope": tok.get("scope") or "",
|
|
49
|
+
"preview": _preview(tok["access_token"]), "has_refresh": bool(tok.get("refresh_token")),
|
|
50
|
+
"fetched_at": tok.get("fetched_at"),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _preview(token: str) -> str:
|
|
55
|
+
return token if len(token) <= 16 else f"{token[:8]}…{token[-6:]}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def forget(auth: dict[str, Any] | None = None) -> None:
|
|
59
|
+
with _lock:
|
|
60
|
+
if auth is None:
|
|
61
|
+
_cache.clear()
|
|
62
|
+
else:
|
|
63
|
+
_cache.pop(_key(auth), None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _store(auth: dict[str, Any], data: dict[str, Any], old_refresh: str | None = None) -> dict[str, Any]:
|
|
67
|
+
if not isinstance(data, dict) or not data.get("access_token"):
|
|
68
|
+
raise ValueError("token response has no access_token")
|
|
69
|
+
try:
|
|
70
|
+
expires_in = float(data.get("expires_in")) if data.get("expires_in") not in (None, "") else None
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
expires_in = None
|
|
73
|
+
now = time.time()
|
|
74
|
+
tok = {
|
|
75
|
+
"access_token": str(data["access_token"]),
|
|
76
|
+
"token_type": str(data.get("token_type") or "Bearer"),
|
|
77
|
+
"refresh_token": data.get("refresh_token") or old_refresh,
|
|
78
|
+
"scope": str(data.get("scope") or auth.get("scope") or ""),
|
|
79
|
+
"expires_at": now + expires_in if expires_in else None,
|
|
80
|
+
"fetched_at": now,
|
|
81
|
+
}
|
|
82
|
+
with _lock:
|
|
83
|
+
_cache[_key(auth)] = tok
|
|
84
|
+
return tok
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _token_request(auth: dict[str, Any], refresh_token: str | None) -> tuple[dict[str, str], dict[str, str]]:
|
|
88
|
+
"""Form fields and headers of the token request."""
|
|
89
|
+
if refresh_token:
|
|
90
|
+
form = {"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
91
|
+
elif auth.get("grant") == "password":
|
|
92
|
+
form = {"grant_type": "password", "username": auth.get("username") or "", "password": auth.get("password") or ""}
|
|
93
|
+
else:
|
|
94
|
+
form = {"grant_type": "client_credentials"}
|
|
95
|
+
if auth.get("scope"):
|
|
96
|
+
form["scope"] = auth["scope"]
|
|
97
|
+
if auth.get("audience"):
|
|
98
|
+
form["audience"] = auth["audience"]
|
|
99
|
+
headers = {"Accept": "application/json"}
|
|
100
|
+
cid, secret = auth.get("client_id") or "", auth.get("client_secret") or ""
|
|
101
|
+
if auth.get("client_auth") == "body":
|
|
102
|
+
if cid:
|
|
103
|
+
form["client_id"] = cid
|
|
104
|
+
if secret:
|
|
105
|
+
form["client_secret"] = secret
|
|
106
|
+
elif cid or secret:
|
|
107
|
+
headers["Authorization"] = "Basic " + base64.b64encode(f"{cid}:{secret}".encode()).decode()
|
|
108
|
+
return form, headers
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def fetch(auth: dict[str, Any], client_kwargs: dict[str, Any] | None = None, *, force: bool = False) -> dict[str, Any]:
|
|
112
|
+
"""A valid token: cached, refreshed, or newly requested. Raises ValueError with a readable message."""
|
|
113
|
+
if not force:
|
|
114
|
+
tok = cached(auth)
|
|
115
|
+
if tok:
|
|
116
|
+
return tok
|
|
117
|
+
if not (auth.get("token_url") or "").strip():
|
|
118
|
+
raise ValueError("OAuth 2.0: no token URL")
|
|
119
|
+
key = _key(auth)
|
|
120
|
+
lock = _fetch_locks.setdefault(key, asyncio.Lock())
|
|
121
|
+
async with lock:
|
|
122
|
+
if not force:
|
|
123
|
+
tok = cached(auth) # another request may have fetched it while we waited
|
|
124
|
+
if tok:
|
|
125
|
+
return tok
|
|
126
|
+
with _lock:
|
|
127
|
+
old = _cache.get(key)
|
|
128
|
+
refresh = old.get("refresh_token") if old else None
|
|
129
|
+
kwargs = {k: v for k, v in (client_kwargs or {}).items() if k in ("verify", "proxy", "timeout")}
|
|
130
|
+
kwargs.setdefault("timeout", 30)
|
|
131
|
+
async with httpx.AsyncClient(**kwargs) as client:
|
|
132
|
+
if refresh:
|
|
133
|
+
form, headers = _token_request(auth, refresh)
|
|
134
|
+
resp = await client.post(auth["token_url"], data=form, headers=headers)
|
|
135
|
+
if resp.status_code < 400:
|
|
136
|
+
try:
|
|
137
|
+
return _store(auth, resp.json(), refresh)
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
form, headers = _token_request(auth, None)
|
|
141
|
+
resp = await client.post(auth["token_url"], data=form, headers=headers)
|
|
142
|
+
if resp.status_code >= 400:
|
|
143
|
+
detail = resp.text.strip()[:300]
|
|
144
|
+
raise ValueError(f"OAuth 2.0 token request failed: HTTP {resp.status_code}" + (f" — {detail}" if detail else ""))
|
|
145
|
+
try:
|
|
146
|
+
data = resp.json()
|
|
147
|
+
except ValueError:
|
|
148
|
+
# some servers answer with a form-encoded body
|
|
149
|
+
data = dict(httpx.QueryParams(resp.text))
|
|
150
|
+
try:
|
|
151
|
+
return _store(auth, data)
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise ValueError(f"OAuth 2.0: {exc}") from None
|