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/app.py
ADDED
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
"""FastAPI application: JSON API under /api plus the static single-page UI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from fastapi import FastAPI, HTTPException, Query, Request, Response
|
|
18
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
19
|
+
from fastapi.staticfiles import StaticFiles
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from . import (USER_AGENT, __version__, console, curl, db, mock, monitors, oauth2, openapi, postman, runner,
|
|
23
|
+
templating, updater)
|
|
24
|
+
from .templating import build_context, render
|
|
25
|
+
|
|
26
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
27
|
+
DEFAULT_RELEASE_REPO = "pyhttp2/pyhttp-releases" # public repo that holds the .exe/.dmg releases
|
|
28
|
+
try: # written by packaging/build.py so packaged apps know where their releases live
|
|
29
|
+
from ._build import GITHUB_REPO as _BUILT_REPO
|
|
30
|
+
except ImportError: # pragma: no cover - source checkout
|
|
31
|
+
_BUILT_REPO = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def github_repo() -> str:
|
|
35
|
+
"""owner/repo used for update checks: Settings > env PYHTTP_GITHUB_REPO > baked at build > default."""
|
|
36
|
+
return (db.get_setting("update_repo") or os.environ.get("PYHTTP_GITHUB_REPO") or _BUILT_REPO
|
|
37
|
+
or DEFAULT_RELEASE_REPO).strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class KV(BaseModel):
|
|
41
|
+
key: str = ""
|
|
42
|
+
value: str = ""
|
|
43
|
+
enabled: bool = True
|
|
44
|
+
secret: bool = False # environment and folder variables: masked in the UI and in saved history
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BodyIn(BaseModel):
|
|
48
|
+
id: str = ""
|
|
49
|
+
name: str = ""
|
|
50
|
+
body: str = ""
|
|
51
|
+
enabled: bool = True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RequestIn(BaseModel):
|
|
55
|
+
id: str | None = None
|
|
56
|
+
folder_id: str | None = None
|
|
57
|
+
name: str = "Untitled"
|
|
58
|
+
description: str = ""
|
|
59
|
+
tags: list[str] = Field(default_factory=list)
|
|
60
|
+
method: str = "GET"
|
|
61
|
+
url: str = ""
|
|
62
|
+
params: list[KV] = Field(default_factory=list)
|
|
63
|
+
headers: list[KV] = Field(default_factory=list)
|
|
64
|
+
body_type: str = "none"
|
|
65
|
+
body: str = ""
|
|
66
|
+
bodies: list[BodyIn] = Field(default_factory=list)
|
|
67
|
+
timeout: float = 30
|
|
68
|
+
verify_ssl: bool = True
|
|
69
|
+
auth: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
tests: list[dict[str, Any]] = Field(default_factory=list)
|
|
71
|
+
extract: list[dict[str, Any]] = Field(default_factory=list)
|
|
72
|
+
pre_vars: list[dict[str, Any]] = Field(default_factory=list)
|
|
73
|
+
options: dict[str, Any] = Field(default_factory=dict)
|
|
74
|
+
send_id: str | None = None # client-chosen id so an in-flight Send can be cancelled
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RunIn(BaseModel):
|
|
78
|
+
request_ids: list[str]
|
|
79
|
+
name: str = ""
|
|
80
|
+
stop_on_error: bool = False
|
|
81
|
+
fail_on_http_error: bool = True
|
|
82
|
+
fail_on_test: bool = True
|
|
83
|
+
delay_ms: float = 0
|
|
84
|
+
iterations: int = 1
|
|
85
|
+
data: list[dict[str, Any]] = Field(default_factory=list)
|
|
86
|
+
parallel: bool = False # send everything at once instead of in order
|
|
87
|
+
concurrency: int = 8 # at most this many in flight when parallel
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MonitorIn(BaseModel):
|
|
91
|
+
name: str = "Monitor"
|
|
92
|
+
request_ids: list[str] = Field(default_factory=list)
|
|
93
|
+
interval_s: int = 300
|
|
94
|
+
webhook_url: str = ""
|
|
95
|
+
enabled: bool = True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class MonitorPatch(BaseModel):
|
|
99
|
+
name: str | None = None
|
|
100
|
+
request_ids: list[str] | None = None
|
|
101
|
+
interval_s: int | None = None
|
|
102
|
+
webhook_url: str | None = None
|
|
103
|
+
enabled: bool | None = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class MockRouteIn(BaseModel):
|
|
107
|
+
name: str = ""
|
|
108
|
+
method: str = "GET" # or ANY to answer every method
|
|
109
|
+
path: str = "/" # /users/:id or /users/{id}; * one segment, ** the rest
|
|
110
|
+
status_code: int = 200
|
|
111
|
+
headers: list[KV] = Field(default_factory=list)
|
|
112
|
+
content_type: str = "application/json"
|
|
113
|
+
body: str = "" # a {{...}} template; {{req.…}} reads the incoming request
|
|
114
|
+
delay_ms: float = 0
|
|
115
|
+
match_query: str = "" # only answer when the query holds these pairs: status=error
|
|
116
|
+
enabled: bool = True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MockRoutePatch(BaseModel):
|
|
120
|
+
name: str | None = None
|
|
121
|
+
method: str | None = None
|
|
122
|
+
path: str | None = None
|
|
123
|
+
status_code: int | None = None
|
|
124
|
+
headers: list[KV] | None = None
|
|
125
|
+
content_type: str | None = None
|
|
126
|
+
body: str | None = None
|
|
127
|
+
delay_ms: float | None = None
|
|
128
|
+
match_query: str | None = None
|
|
129
|
+
enabled: bool | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class MockIn(BaseModel):
|
|
133
|
+
name: str = "Mock server"
|
|
134
|
+
slug: str = "" # base path: /mock/<slug>; derived from the name when empty
|
|
135
|
+
description: str = ""
|
|
136
|
+
enabled: bool = True
|
|
137
|
+
delay_ms: float = 0
|
|
138
|
+
cors: bool = True
|
|
139
|
+
log_calls: bool = True
|
|
140
|
+
routes: list[MockRouteIn] = Field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class MockPatch(BaseModel):
|
|
144
|
+
name: str | None = None
|
|
145
|
+
slug: str | None = None
|
|
146
|
+
description: str | None = None
|
|
147
|
+
enabled: bool | None = None
|
|
148
|
+
delay_ms: float | None = None
|
|
149
|
+
cors: bool | None = None
|
|
150
|
+
log_calls: bool | None = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class MockSeedIn(BaseModel):
|
|
154
|
+
request_ids: list[str] = Field(default_factory=list)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class MockReorderIn(BaseModel):
|
|
158
|
+
route_ids: list[str] = Field(default_factory=list)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class VariablesIn(BaseModel):
|
|
162
|
+
variables: dict[str, str]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class FolderIn(BaseModel):
|
|
166
|
+
name: str = "New folder"
|
|
167
|
+
parent_id: str | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class FolderPatch(BaseModel):
|
|
171
|
+
name: str | None = None
|
|
172
|
+
parent_id: str | None = None
|
|
173
|
+
move: bool = False # parent_id is only applied when move is true (null = root)
|
|
174
|
+
description: str | None = None
|
|
175
|
+
auth: dict[str, Any] | None = None
|
|
176
|
+
headers: list[KV] | None = None
|
|
177
|
+
variables: list[KV] | None = None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class MoveIn(BaseModel):
|
|
181
|
+
folder_id: str | None = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class ReorderIn(BaseModel):
|
|
185
|
+
request_ids: list[str] = Field(default_factory=list)
|
|
186
|
+
folder_ids: list[str] = Field(default_factory=list)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class EnvIn(BaseModel):
|
|
190
|
+
name: str = "New environment"
|
|
191
|
+
variables: list[KV] = Field(default_factory=list)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class EnvPatch(BaseModel):
|
|
195
|
+
name: str | None = None
|
|
196
|
+
variables: list[KV] | None = None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ActiveEnvIn(BaseModel):
|
|
200
|
+
id: str | None = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class ImportFolder(BaseModel):
|
|
204
|
+
id: str
|
|
205
|
+
name: str
|
|
206
|
+
parent_id: str | None = None
|
|
207
|
+
description: str = ""
|
|
208
|
+
auth: dict[str, Any] = Field(default_factory=dict)
|
|
209
|
+
headers: list[KV] = Field(default_factory=list)
|
|
210
|
+
variables: list[KV] = Field(default_factory=list)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ImportIn(BaseModel):
|
|
214
|
+
folders: list[ImportFolder] = Field(default_factory=list)
|
|
215
|
+
environments: list[EnvIn] = Field(default_factory=list)
|
|
216
|
+
requests: list[RequestIn] = Field(default_factory=list)
|
|
217
|
+
variables: dict[str, str] = Field(default_factory=dict)
|
|
218
|
+
replace: bool = False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class CurlIn(BaseModel):
|
|
222
|
+
command: str
|
|
223
|
+
folder_id: str | None = None
|
|
224
|
+
save: bool = True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class CookieIn(BaseModel):
|
|
228
|
+
domain: str
|
|
229
|
+
name: str
|
|
230
|
+
value: str = ""
|
|
231
|
+
path: str = "/"
|
|
232
|
+
expires: float | None = None
|
|
233
|
+
secure: bool = False
|
|
234
|
+
http_only: bool = False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class DataFileIn(BaseModel):
|
|
238
|
+
text: str
|
|
239
|
+
filename: str = ""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _open_in_file_manager(path: Path) -> None:
|
|
243
|
+
if sys.platform.startswith("win"):
|
|
244
|
+
os.startfile(str(path)) # type: ignore[attr-defined]
|
|
245
|
+
elif sys.platform == "darwin":
|
|
246
|
+
subprocess.Popen(["open", str(path)])
|
|
247
|
+
else:
|
|
248
|
+
subprocess.Popen(["xdg-open", str(path)])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def parse_data_file(text: str, filename: str = "") -> list[dict[str, Any]]:
|
|
252
|
+
"""CSV (header row) or JSON (array of objects) variables file for the runner: one row of values per iteration."""
|
|
253
|
+
text = text.lstrip("")
|
|
254
|
+
if filename.lower().endswith(".json") or text.lstrip().startswith("["):
|
|
255
|
+
data = json.loads(text)
|
|
256
|
+
if not isinstance(data, list):
|
|
257
|
+
raise ValueError("JSON variables file must be an array of objects")
|
|
258
|
+
return [row for row in data if isinstance(row, dict)]
|
|
259
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
260
|
+
return [{(k or "").strip(): (v or "") for k, v in row.items()} for row in reader]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_SAMPLE_VALUES = {
|
|
264
|
+
"email": ["alice@example.com", "bob@example.com", "carol@example.com"],
|
|
265
|
+
"name": ["Alice", "Bob", "Carol"],
|
|
266
|
+
"username": ["alice", "bob", "carol"],
|
|
267
|
+
"password": ["Secret-1", "Secret-2", "Secret-3"],
|
|
268
|
+
"token": ["token-1", "token-2", "token-3"],
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def sample_data_file(requests: list[dict[str, Any]], fmt: str = "csv") -> dict[str, Any]:
|
|
273
|
+
"""A small variables file for the runner, with one column per {{variable}} the given requests use
|
|
274
|
+
and no value for yet (or generic columns when they use none), and three example rows."""
|
|
275
|
+
from .templating import _TOKEN # noqa: PLC0415 - the token regex is private to the templating module
|
|
276
|
+
|
|
277
|
+
found: list[str] = []
|
|
278
|
+
defined: list[str] = []
|
|
279
|
+
for r in requests:
|
|
280
|
+
have = db.effective_variables(r.get("folder_id"))[0]
|
|
281
|
+
for m in _TOKEN.finditer(json.dumps(r, ensure_ascii=False)):
|
|
282
|
+
name = m.group(1).strip()
|
|
283
|
+
if not re.fullmatch(r"[A-Za-z_][\w.-]*", name) or name.startswith(("data.", "prev.")) or name in ("body_name", "body_id"):
|
|
284
|
+
continue
|
|
285
|
+
if name not in found:
|
|
286
|
+
found.append(name)
|
|
287
|
+
if name in have and name not in defined:
|
|
288
|
+
defined.append(name)
|
|
289
|
+
columns = [c for c in found if c not in defined] or found or ["name", "email", "user_id"]
|
|
290
|
+
rows: list[dict[str, str]] = []
|
|
291
|
+
for i in range(3):
|
|
292
|
+
row: dict[str, str] = {}
|
|
293
|
+
for c in columns:
|
|
294
|
+
base = c.lower().rsplit(".", 1)[-1]
|
|
295
|
+
if base in _SAMPLE_VALUES:
|
|
296
|
+
row[c] = _SAMPLE_VALUES[base][i]
|
|
297
|
+
elif base == "id" or base.endswith("_id") or base in ("count", "limit", "page", "size", "offset"):
|
|
298
|
+
row[c] = str(i + 1)
|
|
299
|
+
else:
|
|
300
|
+
row[c] = f"{c} {i + 1}"
|
|
301
|
+
rows.append(row)
|
|
302
|
+
if fmt == "json":
|
|
303
|
+
return {"filename": "sample-variables.json", "text": json.dumps(rows, indent=2, ensure_ascii=False) + "\n", "columns": columns, "rows": rows}
|
|
304
|
+
buf = io.StringIO()
|
|
305
|
+
writer = csv.DictWriter(buf, fieldnames=columns, lineterminator="\n")
|
|
306
|
+
writer.writeheader()
|
|
307
|
+
writer.writerows(rows)
|
|
308
|
+
return {"filename": "sample-variables.csv", "text": buf.getvalue(), "columns": columns, "rows": rows}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _version_tuple(v: str) -> tuple[int, ...]:
|
|
312
|
+
out = []
|
|
313
|
+
for part in v.lstrip("vV").split("."):
|
|
314
|
+
digits = "".join(ch for ch in part if ch.isdigit())
|
|
315
|
+
out.append(int(digits) if digits else 0)
|
|
316
|
+
return tuple(out)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def import_collection(folders: list[dict[str, Any]], requests: list[dict[str, Any]], environments: list[dict[str, Any]],
|
|
320
|
+
variables: dict[str, str], replace: bool) -> dict[str, Any]:
|
|
321
|
+
"""Create folders (parents first), requests and environments; optionally replace everything."""
|
|
322
|
+
if replace:
|
|
323
|
+
for r in db.list_requests():
|
|
324
|
+
db.delete_request(r["id"])
|
|
325
|
+
for f in db.list_folders():
|
|
326
|
+
if not f["parent_id"]:
|
|
327
|
+
db.delete_folder(f["id"])
|
|
328
|
+
for e in db.list_environments():
|
|
329
|
+
db.delete_environment(e["id"])
|
|
330
|
+
# folders: create parents before children, remapping ids
|
|
331
|
+
id_map: dict[str, str] = {}
|
|
332
|
+
pending = list(folders)
|
|
333
|
+
while pending:
|
|
334
|
+
progressed = False
|
|
335
|
+
for f in list(pending):
|
|
336
|
+
if f.get("parent_id") and f["parent_id"] not in id_map and any(p["id"] == f["parent_id"] for p in pending):
|
|
337
|
+
continue
|
|
338
|
+
created_f = db.create_folder(f["name"], id_map.get(f.get("parent_id") or ""))
|
|
339
|
+
id_map[f["id"]] = created_f["id"]
|
|
340
|
+
db.update_folder(created_f["id"], description=f.get("description", ""), auth=f.get("auth") or {},
|
|
341
|
+
headers=f.get("headers") or [], variables=f.get("variables") or [])
|
|
342
|
+
pending.remove(f)
|
|
343
|
+
progressed = True
|
|
344
|
+
if not progressed: # cycle in the file: put the rest at the root
|
|
345
|
+
for f in pending:
|
|
346
|
+
id_map[f["id"]] = db.create_folder(f["name"])["id"]
|
|
347
|
+
break
|
|
348
|
+
created = []
|
|
349
|
+
for r in requests:
|
|
350
|
+
data = dict(r)
|
|
351
|
+
data["folder_id"] = id_map.get(r.get("folder_id") or "")
|
|
352
|
+
created.append(db.create_request(data))
|
|
353
|
+
for e in environments:
|
|
354
|
+
db.create_environment(e["name"], e.get("variables") or [])
|
|
355
|
+
if variables:
|
|
356
|
+
merged = {} if replace else db.get_variables()
|
|
357
|
+
merged.update(variables)
|
|
358
|
+
db.set_variables(merged)
|
|
359
|
+
return {"imported": len(created), "folders": len(id_map), "environments": len(environments)}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _own_origin(request: Request) -> str:
|
|
363
|
+
"""The origin this app is actually reachable at, taken from the socket we are
|
|
364
|
+
bound to rather than the Host header, so mock URLs are always the real ones."""
|
|
365
|
+
server = request.scope.get("server") or ("127.0.0.1", 0)
|
|
366
|
+
host = "127.0.0.1" if str(server[0]) in ("0.0.0.0", "::", "") else str(server[0])
|
|
367
|
+
port = int(server[1] or 0)
|
|
368
|
+
default = 443 if request.url.scheme == "https" else 80
|
|
369
|
+
return f"{request.url.scheme}://{host}" + ("" if port in (0, default) else f":{port}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _mock_route_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|
373
|
+
"""Validate and tidy a mock route coming from the API."""
|
|
374
|
+
if "method" in data:
|
|
375
|
+
method = str(data["method"] or "GET").upper()
|
|
376
|
+
if method not in (*mock.METHODS, "ANY", "*"):
|
|
377
|
+
raise HTTPException(400, f"Unsupported method: {method}")
|
|
378
|
+
data["method"] = "ANY" if method == "*" else method
|
|
379
|
+
if "path" in data:
|
|
380
|
+
data["path"] = mock.normalize_path(data["path"])
|
|
381
|
+
if "status_code" in data and not 100 <= int(data["status_code"]) <= 599:
|
|
382
|
+
raise HTTPException(400, "The status code must be between 100 and 599")
|
|
383
|
+
if "delay_ms" in data and float(data["delay_ms"]) < 0:
|
|
384
|
+
raise HTTPException(400, "The delay cannot be negative")
|
|
385
|
+
return data
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def create_app(data_dir: Path) -> FastAPI:
|
|
389
|
+
data_dir = data_dir.resolve()
|
|
390
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
391
|
+
output_dir = data_dir / "outputs"
|
|
392
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
393
|
+
db.init(data_dir / "pyhttp.db")
|
|
394
|
+
db.mark_interrupted_runs()
|
|
395
|
+
manager = runner.RunManager(output_dir)
|
|
396
|
+
scheduler = monitors.MonitorScheduler()
|
|
397
|
+
update_cache: dict[str, Any] = {}
|
|
398
|
+
self_updater = updater.Updater(data_dir)
|
|
399
|
+
|
|
400
|
+
app = FastAPI(title="pyhttp", version=__version__, docs_url="/api/docs", redoc_url=None)
|
|
401
|
+
|
|
402
|
+
@app.on_event("startup")
|
|
403
|
+
async def _start_monitors() -> None:
|
|
404
|
+
scheduler.start()
|
|
405
|
+
|
|
406
|
+
@app.on_event("shutdown")
|
|
407
|
+
async def _stop_monitors() -> None:
|
|
408
|
+
scheduler.stop()
|
|
409
|
+
|
|
410
|
+
# ------------------------------------------------------------------ meta
|
|
411
|
+
@app.get("/api/meta")
|
|
412
|
+
def meta() -> dict[str, Any]:
|
|
413
|
+
return {"version": __version__, "data_dir": str(data_dir), "output_dir": str(output_dir),
|
|
414
|
+
"github_repo": github_repo(), "frozen": bool(getattr(sys, "frozen", False)), "platform": sys.platform,
|
|
415
|
+
"install": updater.info(), "user_agent": USER_AGENT, "python": sys.executable}
|
|
416
|
+
|
|
417
|
+
@app.get("/api/update-check")
|
|
418
|
+
async def update_check(force: bool = Query(False)) -> dict[str, Any]:
|
|
419
|
+
"""Latest GitHub release, compared with the running version (cached for an hour)."""
|
|
420
|
+
repo = github_repo()
|
|
421
|
+
if not repo or not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
|
|
422
|
+
return {"available": False, "reason": "no GitHub repository configured (Settings > About)", "current": __version__}
|
|
423
|
+
if not force and update_cache.get("at", 0) > time.time() - 3600 and update_cache.get("repo") == repo:
|
|
424
|
+
return update_cache["data"]
|
|
425
|
+
try:
|
|
426
|
+
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
|
|
427
|
+
r = await client.get(f"https://api.github.com/repos/{repo}/releases/latest",
|
|
428
|
+
headers={"Accept": "application/vnd.github+json", "User-Agent": USER_AGENT})
|
|
429
|
+
if r.status_code != 200:
|
|
430
|
+
data = {"available": False, "reason": f"GitHub answered {r.status_code}", "current": __version__}
|
|
431
|
+
else:
|
|
432
|
+
rel = r.json()
|
|
433
|
+
latest = str(rel.get("tag_name") or rel.get("name") or "")
|
|
434
|
+
assets = [{"name": a.get("name"), "url": a.get("browser_download_url"), "size": a.get("size")} for a in rel.get("assets") or []]
|
|
435
|
+
data = {"available": _version_tuple(latest) > _version_tuple(__version__), "current": __version__,
|
|
436
|
+
"latest": latest.lstrip("vV"), "url": rel.get("html_url"), "notes": (rel.get("body") or "")[:4000],
|
|
437
|
+
"asset": updater.pick_asset(assets), "assets": assets, "published_at": rel.get("published_at"),
|
|
438
|
+
"install": updater.info(latest.lstrip("vV"))}
|
|
439
|
+
except Exception as exc: # noqa: BLE001 - offline is fine
|
|
440
|
+
data = {"available": False, "reason": f"{type(exc).__name__}: {exc}", "current": __version__}
|
|
441
|
+
update_cache.update({"at": time.time(), "data": data, "repo": repo})
|
|
442
|
+
return data
|
|
443
|
+
|
|
444
|
+
@app.post("/api/update/install")
|
|
445
|
+
def update_install() -> dict[str, Any]:
|
|
446
|
+
"""Apply the update found by /api/update-check: download and install (Windows) or
|
|
447
|
+
`brew upgrade` (macOS). Progress is polled from /api/update/status; the app restarts at the end."""
|
|
448
|
+
data = update_cache.get("data") or {}
|
|
449
|
+
if not data.get("available"):
|
|
450
|
+
raise HTTPException(400, "No update is available; check for updates first.")
|
|
451
|
+
try:
|
|
452
|
+
return self_updater.start(data)
|
|
453
|
+
except RuntimeError as exc:
|
|
454
|
+
raise HTTPException(400, str(exc)) from exc
|
|
455
|
+
|
|
456
|
+
@app.get("/api/update/status")
|
|
457
|
+
def update_status() -> dict[str, Any]:
|
|
458
|
+
return self_updater.status()
|
|
459
|
+
|
|
460
|
+
# -------------------------------------------------------------- requests
|
|
461
|
+
@app.get("/api/requests")
|
|
462
|
+
def list_requests(q: str = Query(""), tag: str = Query("")) -> list[dict[str, Any]]:
|
|
463
|
+
return db.list_requests(q, tag)
|
|
464
|
+
|
|
465
|
+
@app.post("/api/requests", status_code=201)
|
|
466
|
+
def create_request(body: RequestIn) -> dict[str, Any]:
|
|
467
|
+
return db.create_request(body.model_dump())
|
|
468
|
+
|
|
469
|
+
@app.get("/api/requests/{rid}")
|
|
470
|
+
def get_request(rid: str) -> dict[str, Any]:
|
|
471
|
+
r = db.get_request(rid)
|
|
472
|
+
if not r:
|
|
473
|
+
raise HTTPException(404, "Request not found")
|
|
474
|
+
return r
|
|
475
|
+
|
|
476
|
+
@app.put("/api/requests/{rid}")
|
|
477
|
+
def update_request(rid: str, body: RequestIn) -> dict[str, Any]:
|
|
478
|
+
r = db.update_request(rid, body.model_dump())
|
|
479
|
+
if not r:
|
|
480
|
+
raise HTTPException(404, "Request not found")
|
|
481
|
+
return r
|
|
482
|
+
|
|
483
|
+
@app.get("/api/requests/{rid}/dependency")
|
|
484
|
+
def dependency_status(rid: str) -> dict[str, Any]:
|
|
485
|
+
"""When the request last ran as a dependency of another request."""
|
|
486
|
+
last = db.dependency_ran(rid)
|
|
487
|
+
if not last:
|
|
488
|
+
return {"ran_at": None}
|
|
489
|
+
return {**last, "age": round(time.time() - last["ran_at"])}
|
|
490
|
+
|
|
491
|
+
@app.delete("/api/requests/{rid}/dependency")
|
|
492
|
+
def dependency_reset(rid: str) -> dict[str, Any]:
|
|
493
|
+
"""Forget the last run so the request is sent again before the next dependent send."""
|
|
494
|
+
db.clear_dependency_ran(rid)
|
|
495
|
+
return {"ok": True}
|
|
496
|
+
|
|
497
|
+
@app.delete("/api/requests/{rid}")
|
|
498
|
+
def delete_request(rid: str) -> dict[str, Any]:
|
|
499
|
+
if not db.delete_request(rid):
|
|
500
|
+
raise HTTPException(404, "Request not found")
|
|
501
|
+
return {"ok": True}
|
|
502
|
+
|
|
503
|
+
@app.post("/api/requests/{rid}/duplicate", status_code=201)
|
|
504
|
+
def duplicate_request(rid: str) -> dict[str, Any]:
|
|
505
|
+
r = db.get_request(rid)
|
|
506
|
+
if not r:
|
|
507
|
+
raise HTTPException(404, "Request not found")
|
|
508
|
+
r = dict(r)
|
|
509
|
+
r["name"] = f"{r['name']} (copy)"
|
|
510
|
+
return db.create_request(r)
|
|
511
|
+
|
|
512
|
+
@app.post("/api/requests/{rid}/move")
|
|
513
|
+
def move_request(rid: str, body: MoveIn) -> dict[str, Any]:
|
|
514
|
+
try:
|
|
515
|
+
r = db.move_request(rid, body.folder_id)
|
|
516
|
+
except ValueError as exc:
|
|
517
|
+
raise HTTPException(400, str(exc)) from exc
|
|
518
|
+
if not r:
|
|
519
|
+
raise HTTPException(404, "Request not found")
|
|
520
|
+
return r
|
|
521
|
+
|
|
522
|
+
@app.post("/api/reorder")
|
|
523
|
+
def reorder(body: ReorderIn) -> dict[str, Any]:
|
|
524
|
+
db.reorder(body.request_ids, body.folder_ids)
|
|
525
|
+
return {"ok": True}
|
|
526
|
+
|
|
527
|
+
@app.get("/api/requests/{rid}/inherited")
|
|
528
|
+
def request_inherited(rid: str) -> dict[str, Any]:
|
|
529
|
+
r = db.get_request(rid)
|
|
530
|
+
if not r:
|
|
531
|
+
raise HTTPException(404, "Request not found")
|
|
532
|
+
return db.inherited(r.get("folder_id"))
|
|
533
|
+
|
|
534
|
+
@app.get("/api/folders/{fid}/inherited")
|
|
535
|
+
def folder_inherited(fid: str) -> dict[str, Any]:
|
|
536
|
+
return db.inherited(fid)
|
|
537
|
+
|
|
538
|
+
# --------------------------------------------------------------- folders
|
|
539
|
+
@app.get("/api/folders")
|
|
540
|
+
def list_folders() -> list[dict[str, Any]]:
|
|
541
|
+
return db.list_folders()
|
|
542
|
+
|
|
543
|
+
@app.post("/api/folders", status_code=201)
|
|
544
|
+
def create_folder(body: FolderIn) -> dict[str, Any]:
|
|
545
|
+
try:
|
|
546
|
+
return db.create_folder(body.name, body.parent_id)
|
|
547
|
+
except ValueError as exc:
|
|
548
|
+
raise HTTPException(400, str(exc)) from exc
|
|
549
|
+
|
|
550
|
+
@app.patch("/api/folders/{fid}")
|
|
551
|
+
def update_folder(fid: str, body: FolderPatch) -> dict[str, Any]:
|
|
552
|
+
kwargs: dict[str, Any] = {}
|
|
553
|
+
if body.name is not None:
|
|
554
|
+
kwargs["name"] = body.name
|
|
555
|
+
if body.move:
|
|
556
|
+
kwargs["parent_id"] = body.parent_id
|
|
557
|
+
if body.description is not None:
|
|
558
|
+
kwargs["description"] = body.description
|
|
559
|
+
if body.auth is not None:
|
|
560
|
+
kwargs["auth"] = body.auth
|
|
561
|
+
if body.headers is not None:
|
|
562
|
+
kwargs["headers"] = [h.model_dump() for h in body.headers]
|
|
563
|
+
if body.variables is not None:
|
|
564
|
+
kwargs["variables"] = [v.model_dump() for v in body.variables]
|
|
565
|
+
try:
|
|
566
|
+
f = db.update_folder(fid, **kwargs)
|
|
567
|
+
except ValueError as exc:
|
|
568
|
+
raise HTTPException(400, str(exc)) from exc
|
|
569
|
+
if not f:
|
|
570
|
+
raise HTTPException(404, "Folder not found")
|
|
571
|
+
return f
|
|
572
|
+
|
|
573
|
+
@app.get("/api/folders/{fid}/contents")
|
|
574
|
+
def folder_contents(fid: str) -> dict[str, Any]:
|
|
575
|
+
if not db.get_folder(fid):
|
|
576
|
+
raise HTTPException(404, "Folder not found")
|
|
577
|
+
return db.folder_contents(fid)
|
|
578
|
+
|
|
579
|
+
@app.delete("/api/folders/{fid}")
|
|
580
|
+
def delete_folder(fid: str) -> dict[str, Any]:
|
|
581
|
+
out = db.delete_folder(fid)
|
|
582
|
+
if out is None:
|
|
583
|
+
raise HTTPException(404, "Folder not found")
|
|
584
|
+
return out
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------- environments
|
|
587
|
+
def env_payload() -> dict[str, Any]:
|
|
588
|
+
active = db.active_environment()
|
|
589
|
+
return {"environments": db.list_environments(), "active_id": active["id"] if active else None}
|
|
590
|
+
|
|
591
|
+
@app.get("/api/environments")
|
|
592
|
+
def list_environments() -> dict[str, Any]:
|
|
593
|
+
return env_payload()
|
|
594
|
+
|
|
595
|
+
@app.post("/api/environments", status_code=201)
|
|
596
|
+
def create_environment(body: EnvIn) -> dict[str, Any]:
|
|
597
|
+
return db.create_environment(body.name, [v.model_dump() for v in body.variables])
|
|
598
|
+
|
|
599
|
+
@app.put("/api/environments/active")
|
|
600
|
+
def set_active_environment(body: ActiveEnvIn) -> dict[str, Any]:
|
|
601
|
+
if body.id and not db.get_environment(body.id):
|
|
602
|
+
raise HTTPException(404, "Environment not found")
|
|
603
|
+
db.set_setting("active_env", body.id or None)
|
|
604
|
+
return env_payload()
|
|
605
|
+
|
|
606
|
+
@app.patch("/api/environments/{eid}")
|
|
607
|
+
def update_environment(eid: str, body: EnvPatch) -> dict[str, Any]:
|
|
608
|
+
kwargs: dict[str, Any] = {}
|
|
609
|
+
if body.name is not None:
|
|
610
|
+
kwargs["name"] = body.name
|
|
611
|
+
if body.variables is not None:
|
|
612
|
+
kwargs["variables"] = [v.model_dump() for v in body.variables]
|
|
613
|
+
env = db.update_environment(eid, **kwargs)
|
|
614
|
+
if not env:
|
|
615
|
+
raise HTTPException(404, "Environment not found")
|
|
616
|
+
return env
|
|
617
|
+
|
|
618
|
+
@app.post("/api/environments/{eid}/duplicate", status_code=201)
|
|
619
|
+
def duplicate_environment(eid: str) -> dict[str, Any]:
|
|
620
|
+
env = db.get_environment(eid)
|
|
621
|
+
if not env:
|
|
622
|
+
raise HTTPException(404, "Environment not found")
|
|
623
|
+
return db.create_environment(f"{env['name']} copy", env["variables"])
|
|
624
|
+
|
|
625
|
+
@app.delete("/api/environments/{eid}")
|
|
626
|
+
def delete_environment(eid: str) -> dict[str, Any]:
|
|
627
|
+
if not db.delete_environment(eid):
|
|
628
|
+
raise HTTPException(404, "Environment not found")
|
|
629
|
+
return env_payload()
|
|
630
|
+
|
|
631
|
+
@app.get("/api/tags")
|
|
632
|
+
def tags() -> list[dict[str, Any]]:
|
|
633
|
+
return db.all_tags()
|
|
634
|
+
|
|
635
|
+
# -------------------------------------------------------------- settings
|
|
636
|
+
@app.get("/api/settings")
|
|
637
|
+
def get_settings() -> dict[str, Any]:
|
|
638
|
+
return db.app_settings()
|
|
639
|
+
|
|
640
|
+
@app.put("/api/settings")
|
|
641
|
+
def put_settings(body: dict[str, Any]) -> dict[str, Any]:
|
|
642
|
+
if body.get("theme") is not None and body["theme"] not in ("dark", "light", "system"):
|
|
643
|
+
raise HTTPException(400, "theme must be dark, light or system")
|
|
644
|
+
if body.get("layout") is not None and body["layout"] not in ("below", "beside"):
|
|
645
|
+
raise HTTPException(400, "layout must be below or beside")
|
|
646
|
+
if body.get("ui_font") is not None and body["ui_font"] not in db.UI_FONTS:
|
|
647
|
+
raise HTTPException(400, "ui_font must be one of " + ", ".join(db.UI_FONTS))
|
|
648
|
+
if body.get("mono_font") is not None and body["mono_font"] not in db.MONO_FONTS:
|
|
649
|
+
raise HTTPException(400, "mono_font must be one of " + ", ".join(db.MONO_FONTS))
|
|
650
|
+
if body.get("certs") is not None and not isinstance(body["certs"], list):
|
|
651
|
+
raise HTTPException(400, "certs must be a list")
|
|
652
|
+
return db.update_settings(body)
|
|
653
|
+
|
|
654
|
+
# ------------------------------------------------------------- variables
|
|
655
|
+
@app.get("/api/variables")
|
|
656
|
+
def get_variables() -> dict[str, str]:
|
|
657
|
+
return db.get_variables()
|
|
658
|
+
|
|
659
|
+
@app.put("/api/variables")
|
|
660
|
+
def put_variables(body: VariablesIn) -> dict[str, str]:
|
|
661
|
+
return db.set_variables(body.variables)
|
|
662
|
+
|
|
663
|
+
@app.get("/api/variables/effective")
|
|
664
|
+
def effective_variables(folder_id: str = Query("")) -> dict[str, Any]:
|
|
665
|
+
values, env = db.effective_variables(folder_id or None)
|
|
666
|
+
return {"variables": values, "environment": env}
|
|
667
|
+
|
|
668
|
+
# ------------------------------------------------------------------ send
|
|
669
|
+
def _editor_request(body: RequestIn) -> dict[str, Any]:
|
|
670
|
+
req = db.normalize_request(body.model_dump())
|
|
671
|
+
req["id"] = body.id
|
|
672
|
+
req["folder_id"] = body.folder_id
|
|
673
|
+
if body.id and not body.folder_id:
|
|
674
|
+
saved = db.get_request(body.id)
|
|
675
|
+
if saved:
|
|
676
|
+
req["folder_id"] = saved.get("folder_id")
|
|
677
|
+
return req
|
|
678
|
+
|
|
679
|
+
sends: dict[str, asyncio.Task[dict[str, Any]]] = {}
|
|
680
|
+
# Cancel can arrive before its send has started (the UI shows Cancel as soon as it posts the
|
|
681
|
+
# send): remember it, and the send stops as soon as it starts. Oldest entries are dropped.
|
|
682
|
+
early_cancels: dict[str, float] = {}
|
|
683
|
+
finished_sends: dict[str, float] = {} # a cancel after the send ended is answered "not cancelled"
|
|
684
|
+
|
|
685
|
+
def _remember(store: dict[str, float], send_id: str) -> None:
|
|
686
|
+
store[send_id] = time.time()
|
|
687
|
+
while len(store) > 100:
|
|
688
|
+
store.pop(next(iter(store)))
|
|
689
|
+
|
|
690
|
+
@app.post("/api/send")
|
|
691
|
+
async def send(body: RequestIn) -> dict[str, Any]:
|
|
692
|
+
"""Send a single request straight from the editor without saving it.
|
|
693
|
+
Pass send_id to be able to cancel it with POST /api/send/{send_id}/cancel."""
|
|
694
|
+
req = _editor_request(body)
|
|
695
|
+
item = runner.expand(req)[0]
|
|
696
|
+
variables, env_name = db.effective_variables(req.get("folder_id"))
|
|
697
|
+
# no await between this check and registering the task, so a cancel cannot slip in between
|
|
698
|
+
if body.send_id and early_cancels.pop(body.send_id, None) is not None:
|
|
699
|
+
result = runner.cancelled_result(item)
|
|
700
|
+
runner.record(result, source="Send", req=item, environment=env_name)
|
|
701
|
+
out = runner.public_result(result)
|
|
702
|
+
out["body_name"] = item.get("body_name")
|
|
703
|
+
return out
|
|
704
|
+
task = asyncio.create_task(runner.execute(item, build_context(variables)))
|
|
705
|
+
if body.send_id:
|
|
706
|
+
sends[body.send_id] = task
|
|
707
|
+
try:
|
|
708
|
+
result = await task
|
|
709
|
+
except asyncio.CancelledError:
|
|
710
|
+
if not task.cancelled(): # the HTTP handler itself was cancelled: stop the send too
|
|
711
|
+
task.cancel()
|
|
712
|
+
raise
|
|
713
|
+
result = runner.cancelled_result(item)
|
|
714
|
+
finally:
|
|
715
|
+
if body.send_id:
|
|
716
|
+
sends.pop(body.send_id, None)
|
|
717
|
+
_remember(finished_sends, body.send_id)
|
|
718
|
+
runner.record(result, source="Send", req=item, environment=env_name)
|
|
719
|
+
out = runner.public_result(result)
|
|
720
|
+
out["body_name"] = item.get("body_name")
|
|
721
|
+
return out
|
|
722
|
+
|
|
723
|
+
@app.post("/api/send-stream")
|
|
724
|
+
async def send_stream(body: RequestIn) -> StreamingResponse:
|
|
725
|
+
"""Send like /api/send, but stream the response as NDJSON events: {"type": "status", ...}
|
|
726
|
+
once the headers arrive, {"type": "chunk", "text": ...} for every piece of the body, and
|
|
727
|
+
{"type": "done", "result": ...} with the same shape /api/send returns. Made for Server-Sent
|
|
728
|
+
Events and long chunked responses; closing the connection stops the send."""
|
|
729
|
+
req = _editor_request(body)
|
|
730
|
+
item = runner.expand(req)[0]
|
|
731
|
+
variables, env_name = db.effective_variables(req.get("folder_id"))
|
|
732
|
+
queue: asyncio.Queue[dict[str, Any] | None] = asyncio.Queue()
|
|
733
|
+
|
|
734
|
+
async def work() -> None:
|
|
735
|
+
try:
|
|
736
|
+
result = await runner.execute(item, build_context(variables), on_chunk=queue.put)
|
|
737
|
+
runner.record(result, source="Send", req=item, environment=env_name)
|
|
738
|
+
out = runner.public_result(result)
|
|
739
|
+
out["body_name"] = item.get("body_name")
|
|
740
|
+
await queue.put({"type": "done", "result": out})
|
|
741
|
+
finally:
|
|
742
|
+
await queue.put(None)
|
|
743
|
+
|
|
744
|
+
async def gen():
|
|
745
|
+
task = asyncio.create_task(work())
|
|
746
|
+
try:
|
|
747
|
+
while True:
|
|
748
|
+
ev = await queue.get()
|
|
749
|
+
if ev is None:
|
|
750
|
+
break
|
|
751
|
+
yield json.dumps(ev, default=str) + "\n"
|
|
752
|
+
finally:
|
|
753
|
+
task.cancel()
|
|
754
|
+
|
|
755
|
+
return StreamingResponse(gen(), media_type="application/x-ndjson")
|
|
756
|
+
|
|
757
|
+
@app.post("/api/send/{send_id}/cancel")
|
|
758
|
+
async def cancel_send(send_id: str) -> dict[str, Any]:
|
|
759
|
+
# async: runs on the event loop, never between send()'s check and its registration
|
|
760
|
+
task = sends.get(send_id)
|
|
761
|
+
if task is None and send_id not in finished_sends:
|
|
762
|
+
_remember(early_cancels, send_id)
|
|
763
|
+
return {"cancelled": True}
|
|
764
|
+
if task is None:
|
|
765
|
+
return {"cancelled": False}
|
|
766
|
+
if task.done():
|
|
767
|
+
return {"cancelled": False}
|
|
768
|
+
task.cancel()
|
|
769
|
+
return {"cancelled": True}
|
|
770
|
+
|
|
771
|
+
@app.post("/api/resolve")
|
|
772
|
+
def resolve(body: RequestIn, body_id: str = Query("")) -> dict[str, Any]:
|
|
773
|
+
"""Everything that would be sent (variables substituted, auth applied), without sending."""
|
|
774
|
+
req = _editor_request(body)
|
|
775
|
+
items = runner.expand(req)
|
|
776
|
+
item = next((i for i in items if body_id and any(b.get("id") == body_id and b.get("name") == i.get("body_name") for b in req["bodies"])), items[0])
|
|
777
|
+
variables, env_name = db.effective_variables(req.get("folder_id"))
|
|
778
|
+
ctx = build_context(variables)
|
|
779
|
+
for row in req.get("pre_vars") or []: # preview only: no persistence
|
|
780
|
+
if row.get("enabled", True) and row.get("key", "").strip():
|
|
781
|
+
ctx["vars"][row["key"].strip()] = row.get("value", "")
|
|
782
|
+
try:
|
|
783
|
+
p = runner.prepare(item, ctx)
|
|
784
|
+
except Exception as exc: # noqa: BLE001
|
|
785
|
+
raise HTTPException(400, f"{type(exc).__name__}: {exc}") from exc
|
|
786
|
+
out = runner.public_request(p)
|
|
787
|
+
out["environment"] = env_name
|
|
788
|
+
out["body_name"] = item.get("body_name")
|
|
789
|
+
return out
|
|
790
|
+
|
|
791
|
+
# --------------------------------------------------------------- oauth 2
|
|
792
|
+
def _oauth_settings(body: RequestIn) -> dict[str, Any]:
|
|
793
|
+
"""The OAuth 2.0 settings a request would use (folder inheritance, variables rendered)."""
|
|
794
|
+
req = _editor_request(body)
|
|
795
|
+
auth = db.normalize_auth(req.get("auth"))
|
|
796
|
+
if auth["type"] == "inherit" and req.get("folder_id"):
|
|
797
|
+
auth = db.inherited(req["folder_id"])["auth"]
|
|
798
|
+
if auth["type"] != "oauth2":
|
|
799
|
+
raise HTTPException(400, "This request does not use OAuth 2.0")
|
|
800
|
+
variables, _ = db.effective_variables(req.get("folder_id"))
|
|
801
|
+
ctx = build_context(variables)
|
|
802
|
+
return {k: (render(v, ctx) if isinstance(v, str) else v) for k, v in auth.items()}
|
|
803
|
+
|
|
804
|
+
@app.post("/api/oauth2/status")
|
|
805
|
+
def oauth_status(body: RequestIn) -> dict[str, Any]:
|
|
806
|
+
return oauth2.status(_oauth_settings(body))
|
|
807
|
+
|
|
808
|
+
@app.post("/api/oauth2/token")
|
|
809
|
+
async def oauth_token(body: RequestIn) -> dict[str, Any]:
|
|
810
|
+
"""Get a new token now (replaces the cached one)."""
|
|
811
|
+
auth = _oauth_settings(body)
|
|
812
|
+
settings = db.app_settings()
|
|
813
|
+
kwargs: dict[str, Any] = {"verify": db.verify_ssl_enabled(), "timeout": float(settings.get("default_timeout") or 30)}
|
|
814
|
+
if (settings.get("proxy_url") or "").strip():
|
|
815
|
+
kwargs["proxy"] = settings["proxy_url"].strip()
|
|
816
|
+
try:
|
|
817
|
+
await oauth2.fetch(auth, kwargs, force=True)
|
|
818
|
+
except Exception as exc: # noqa: BLE001 - network errors and bad token responses
|
|
819
|
+
raise HTTPException(400, str(exc)) from exc
|
|
820
|
+
return oauth2.status(auth)
|
|
821
|
+
|
|
822
|
+
@app.post("/api/oauth2/forget")
|
|
823
|
+
def oauth_forget(body: RequestIn) -> dict[str, Any]:
|
|
824
|
+
oauth2.forget(_oauth_settings(body))
|
|
825
|
+
return {"has_token": False}
|
|
826
|
+
|
|
827
|
+
# --------------------------------------------------------------- console
|
|
828
|
+
@app.get("/api/console")
|
|
829
|
+
def console_entries(after: int = Query(0)) -> dict[str, Any]:
|
|
830
|
+
return console.entries(after)
|
|
831
|
+
|
|
832
|
+
@app.delete("/api/console")
|
|
833
|
+
def console_clear() -> dict[str, Any]:
|
|
834
|
+
console.clear()
|
|
835
|
+
return {"ok": True}
|
|
836
|
+
|
|
837
|
+
# --------------------------------------------------------------- history
|
|
838
|
+
@app.get("/api/history")
|
|
839
|
+
def history(q: str = Query(""), limit: int = Query(300), status: str = Query("")) -> list[dict[str, Any]]:
|
|
840
|
+
return db.list_history(limit=min(max(limit, 1), 1000), q=q, status=status)
|
|
841
|
+
|
|
842
|
+
@app.post("/api/history/{hid}/resend")
|
|
843
|
+
async def history_resend(hid: str) -> dict[str, Any]:
|
|
844
|
+
"""Send a past request again exactly as it was (with today's variables); returns the new entry."""
|
|
845
|
+
h = db.get_history(hid)
|
|
846
|
+
if not h:
|
|
847
|
+
raise HTTPException(404, "History entry not found")
|
|
848
|
+
snap = dict(h["snapshot"])
|
|
849
|
+
req = db.normalize_request(snap)
|
|
850
|
+
req["id"] = h.get("request_id")
|
|
851
|
+
req["folder_id"] = snap.get("folder_id")
|
|
852
|
+
if req["folder_id"] and not db.get_folder(req["folder_id"]):
|
|
853
|
+
req["folder_id"] = None
|
|
854
|
+
item = runner.expand(req)[0]
|
|
855
|
+
if h.get("body_name"):
|
|
856
|
+
item["body_name"] = h["body_name"]
|
|
857
|
+
variables, env_name = db.effective_variables(req.get("folder_id"))
|
|
858
|
+
result = await runner.execute(item, build_context(variables))
|
|
859
|
+
runner.record(result, source="Send again", req=item, environment=env_name)
|
|
860
|
+
newest = db.list_history(limit=1)
|
|
861
|
+
return {"id": newest[0]["id"] if newest else None, "status_code": result.get("status_code"),
|
|
862
|
+
"status": result.get("status"), "error": result.get("error")}
|
|
863
|
+
|
|
864
|
+
@app.get("/api/history/{hid}")
|
|
865
|
+
def history_item(hid: str) -> dict[str, Any]:
|
|
866
|
+
h = db.get_history(hid)
|
|
867
|
+
if not h:
|
|
868
|
+
raise HTTPException(404, "History entry not found")
|
|
869
|
+
return h
|
|
870
|
+
|
|
871
|
+
@app.delete("/api/history/{hid}")
|
|
872
|
+
def history_delete(hid: str) -> dict[str, Any]:
|
|
873
|
+
return {"deleted": db.delete_history(hid)}
|
|
874
|
+
|
|
875
|
+
@app.delete("/api/history")
|
|
876
|
+
def history_clear() -> dict[str, Any]:
|
|
877
|
+
return {"deleted": db.delete_history()}
|
|
878
|
+
|
|
879
|
+
# --------------------------------------------------------------- cookies
|
|
880
|
+
@app.get("/api/cookies")
|
|
881
|
+
def cookies() -> list[dict[str, Any]]:
|
|
882
|
+
return db.list_cookies()
|
|
883
|
+
|
|
884
|
+
@app.put("/api/cookies")
|
|
885
|
+
def put_cookie(body: CookieIn) -> list[dict[str, Any]]:
|
|
886
|
+
if not body.domain.strip() or not body.name.strip():
|
|
887
|
+
raise HTTPException(400, "domain and name are required")
|
|
888
|
+
db.upsert_cookie(body.model_dump())
|
|
889
|
+
return db.list_cookies()
|
|
890
|
+
|
|
891
|
+
@app.delete("/api/cookies")
|
|
892
|
+
def delete_cookies(domain: str | None = Query(None), name: str | None = Query(None),
|
|
893
|
+
path: str | None = Query(None)) -> dict[str, Any]:
|
|
894
|
+
return {"deleted": db.delete_cookies(domain, name, path)}
|
|
895
|
+
|
|
896
|
+
@app.post("/api/send-bodies", status_code=201)
|
|
897
|
+
async def send_bodies(body: RequestIn) -> dict[str, Any]:
|
|
898
|
+
"""Send the editor's request once per enabled body; outputs are saved like a run."""
|
|
899
|
+
req = _editor_request(body)
|
|
900
|
+
items = runner.expand(req)
|
|
901
|
+
settings = db.app_settings()
|
|
902
|
+
options = {"name": req["name"], "mode": "bodies", "request_id": body.id,
|
|
903
|
+
"stop_on_error": False, "fail_on_http_error": True, "delay_ms": 0,
|
|
904
|
+
"parallel": bool(settings.get("parallel")), "concurrency": int(settings.get("concurrency") or 8)}
|
|
905
|
+
try:
|
|
906
|
+
return await manager.start(items, options)
|
|
907
|
+
except ValueError as exc:
|
|
908
|
+
raise HTTPException(400, str(exc)) from exc
|
|
909
|
+
|
|
910
|
+
# ------------------------------------------------------------------ runs
|
|
911
|
+
@app.post("/api/runs", status_code=201)
|
|
912
|
+
async def start_run(body: RunIn) -> dict[str, Any]:
|
|
913
|
+
options = body.model_dump(exclude={"request_ids"})
|
|
914
|
+
try:
|
|
915
|
+
return await manager.start_saved(body.request_ids, options)
|
|
916
|
+
except ValueError as exc:
|
|
917
|
+
raise HTTPException(400, str(exc)) from exc
|
|
918
|
+
|
|
919
|
+
@app.post("/api/runs/data-file")
|
|
920
|
+
def run_data_file(body: DataFileIn) -> dict[str, Any]:
|
|
921
|
+
"""Parse a CSV / JSON variables file for the runner and return its rows."""
|
|
922
|
+
try:
|
|
923
|
+
rows = parse_data_file(body.text, body.filename)
|
|
924
|
+
except (ValueError, csv.Error) as exc:
|
|
925
|
+
raise HTTPException(400, f"Could not read variables file: {exc}") from exc
|
|
926
|
+
columns: list[str] = []
|
|
927
|
+
for row in rows:
|
|
928
|
+
for k in row:
|
|
929
|
+
if k not in columns:
|
|
930
|
+
columns.append(k)
|
|
931
|
+
return {"rows": rows, "columns": columns}
|
|
932
|
+
|
|
933
|
+
@app.get("/api/runs/sample-data")
|
|
934
|
+
def run_sample_data(request_ids: str = Query(""), format: str = Query("csv")) -> dict[str, Any]:
|
|
935
|
+
"""A sample CSV / JSON variables file for the runner, with a column per {{variable}} the requests use."""
|
|
936
|
+
ids = [i for i in request_ids.split(",") if i]
|
|
937
|
+
requests = [r for r in (db.get_request(i) for i in ids) if r]
|
|
938
|
+
return sample_data_file(requests, "json" if format == "json" else "csv")
|
|
939
|
+
|
|
940
|
+
@app.get("/api/runs")
|
|
941
|
+
def list_runs() -> list[dict[str, Any]]:
|
|
942
|
+
return db.list_runs()
|
|
943
|
+
|
|
944
|
+
@app.get("/api/runs/{run_id}")
|
|
945
|
+
def get_run(run_id: str) -> dict[str, Any]:
|
|
946
|
+
run = db.get_run(run_id)
|
|
947
|
+
if not run:
|
|
948
|
+
raise HTTPException(404, "Run not found")
|
|
949
|
+
run["active"] = manager.is_active(run_id)
|
|
950
|
+
return run
|
|
951
|
+
|
|
952
|
+
@app.post("/api/runs/{run_id}/cancel")
|
|
953
|
+
def cancel_run(run_id: str) -> dict[str, Any]:
|
|
954
|
+
return {"cancelled": manager.cancel(run_id)}
|
|
955
|
+
|
|
956
|
+
@app.delete("/api/runs/{run_id}")
|
|
957
|
+
def delete_run(run_id: str) -> dict[str, Any]:
|
|
958
|
+
if manager.is_active(run_id):
|
|
959
|
+
raise HTTPException(409, "Run is still active; cancel it first")
|
|
960
|
+
if not db.delete_run(run_id):
|
|
961
|
+
raise HTTPException(404, "Run not found")
|
|
962
|
+
return {"ok": True}
|
|
963
|
+
|
|
964
|
+
@app.get("/api/runs/{run_id}/files/{filename}")
|
|
965
|
+
def run_file(run_id: str, filename: str) -> FileResponse:
|
|
966
|
+
run = db.get_run(run_id, with_results=False)
|
|
967
|
+
if not run:
|
|
968
|
+
raise HTTPException(404, "Run not found")
|
|
969
|
+
base = Path(run["output_dir"]).resolve()
|
|
970
|
+
target = (base / filename).resolve()
|
|
971
|
+
if target.parent != base or not target.is_file():
|
|
972
|
+
raise HTTPException(404, "File not found")
|
|
973
|
+
return FileResponse(target, filename=filename)
|
|
974
|
+
|
|
975
|
+
@app.get("/api/runs/{run_id}/report")
|
|
976
|
+
def run_report(run_id: str) -> dict[str, Any]:
|
|
977
|
+
"""Run report: results with tests, for export as JSON or HTML in the UI."""
|
|
978
|
+
run = db.get_run(run_id)
|
|
979
|
+
if not run:
|
|
980
|
+
raise HTTPException(404, "Run not found")
|
|
981
|
+
tests_total = sum(len(r.get("tests") or []) for r in run["results"])
|
|
982
|
+
tests_failed = sum(1 for r in run["results"] for t in (r.get("tests") or []) if not t.get("passed"))
|
|
983
|
+
return {**run, "tests_total": tests_total, "tests_failed": tests_failed, "pyhttp_version": __version__}
|
|
984
|
+
|
|
985
|
+
@app.post("/api/runs/{run_id}/open-folder")
|
|
986
|
+
def open_folder(run_id: str) -> dict[str, Any]:
|
|
987
|
+
run = db.get_run(run_id, with_results=False)
|
|
988
|
+
if not run:
|
|
989
|
+
raise HTTPException(404, "Run not found")
|
|
990
|
+
try:
|
|
991
|
+
_open_in_file_manager(Path(run["output_dir"]))
|
|
992
|
+
except Exception as exc: # noqa: BLE001
|
|
993
|
+
raise HTTPException(500, f"Could not open folder: {exc}") from exc
|
|
994
|
+
return {"ok": True, "path": run["output_dir"]}
|
|
995
|
+
|
|
996
|
+
# ----------------------------------------------------------- monitors
|
|
997
|
+
@app.get("/api/monitors")
|
|
998
|
+
def list_monitors() -> dict[str, Any]:
|
|
999
|
+
mons = db.list_monitors()
|
|
1000
|
+
for mon in mons:
|
|
1001
|
+
mon["recent"] = db.list_monitor_checks(mon["id"], limit=30, with_results=False)[::-1]
|
|
1002
|
+
return {"monitors": mons,
|
|
1003
|
+
"failing": sum(1 for mon in mons if mon["enabled"] and mon.get("last_status") == "fail")}
|
|
1004
|
+
|
|
1005
|
+
def _monitor_payload(body: MonitorIn | MonitorPatch) -> dict[str, Any]:
|
|
1006
|
+
data = body.model_dump(exclude_none=True)
|
|
1007
|
+
if "request_ids" in data:
|
|
1008
|
+
missing = [i for i in data["request_ids"] if not db.get_request(i)]
|
|
1009
|
+
if missing:
|
|
1010
|
+
raise HTTPException(400, f"Unknown request ids: {', '.join(missing)}")
|
|
1011
|
+
if not data["request_ids"]:
|
|
1012
|
+
raise HTTPException(400, "Pick at least one request to monitor")
|
|
1013
|
+
if "interval_s" in data and data["interval_s"] < 10:
|
|
1014
|
+
raise HTTPException(400, "The interval must be at least 10 seconds")
|
|
1015
|
+
return data
|
|
1016
|
+
|
|
1017
|
+
@app.post("/api/monitors", status_code=201)
|
|
1018
|
+
def create_monitor(body: MonitorIn) -> dict[str, Any]:
|
|
1019
|
+
data = _monitor_payload(body)
|
|
1020
|
+
if not data.get("request_ids"):
|
|
1021
|
+
raise HTTPException(400, "Pick at least one request to monitor")
|
|
1022
|
+
return db.create_monitor(data)
|
|
1023
|
+
|
|
1024
|
+
@app.patch("/api/monitors/{mid}")
|
|
1025
|
+
def update_monitor(mid: str, body: MonitorPatch) -> dict[str, Any]:
|
|
1026
|
+
if not db.get_monitor(mid):
|
|
1027
|
+
raise HTTPException(404, "Monitor not found")
|
|
1028
|
+
return db.update_monitor(mid, **_monitor_payload(body))
|
|
1029
|
+
|
|
1030
|
+
@app.delete("/api/monitors/{mid}")
|
|
1031
|
+
def delete_monitor(mid: str) -> dict[str, Any]:
|
|
1032
|
+
if not db.delete_monitor(mid):
|
|
1033
|
+
raise HTTPException(404, "Monitor not found")
|
|
1034
|
+
return {"ok": True}
|
|
1035
|
+
|
|
1036
|
+
@app.post("/api/monitors/{mid}/run-now")
|
|
1037
|
+
async def run_monitor_now(mid: str) -> dict[str, Any]:
|
|
1038
|
+
mon = db.get_monitor(mid)
|
|
1039
|
+
if not mon:
|
|
1040
|
+
raise HTTPException(404, "Monitor not found")
|
|
1041
|
+
return await monitors.run_check(mon)
|
|
1042
|
+
|
|
1043
|
+
@app.get("/api/monitors/{mid}/checks")
|
|
1044
|
+
def monitor_checks(mid: str, limit: int = Query(50)) -> list[dict[str, Any]]:
|
|
1045
|
+
if not db.get_monitor(mid):
|
|
1046
|
+
raise HTTPException(404, "Monitor not found")
|
|
1047
|
+
return db.list_monitor_checks(mid, limit=min(max(limit, 1), 500))
|
|
1048
|
+
|
|
1049
|
+
# ------------------------------------------------------------ mock servers
|
|
1050
|
+
# Mocks are answered by this very app, so they live on the port pyhttp already
|
|
1051
|
+
# listens on: http://127.0.0.1:<port>/mock/<slug>/<route path>
|
|
1052
|
+
def _mock_or_404(mid: str) -> dict[str, Any]:
|
|
1053
|
+
m = db.get_mock(mid)
|
|
1054
|
+
if not m:
|
|
1055
|
+
raise HTTPException(404, "Mock server not found")
|
|
1056
|
+
return m
|
|
1057
|
+
|
|
1058
|
+
def _mock_url(request: Request, slug: str) -> str:
|
|
1059
|
+
return f"{_own_origin(request)}/mock/{slug}"
|
|
1060
|
+
|
|
1061
|
+
def _with_url(request: Request, m: dict[str, Any]) -> dict[str, Any]:
|
|
1062
|
+
return {**m, "base_path": f"/mock/{m['slug']}", "url": _mock_url(request, m["slug"])}
|
|
1063
|
+
|
|
1064
|
+
@app.get("/api/mocks")
|
|
1065
|
+
def list_mocks(request: Request) -> dict[str, Any]:
|
|
1066
|
+
mocks = [_with_url(request, m) for m in db.list_mocks()]
|
|
1067
|
+
return {"mocks": mocks, "base_url": f"{_own_origin(request)}/mock",
|
|
1068
|
+
"enabled": sum(1 for m in mocks if m["enabled"])}
|
|
1069
|
+
|
|
1070
|
+
@app.post("/api/mocks", status_code=201)
|
|
1071
|
+
def create_mock(body: MockIn, request: Request) -> dict[str, Any]:
|
|
1072
|
+
data = body.model_dump()
|
|
1073
|
+
if not str(data.get("name") or "").strip():
|
|
1074
|
+
raise HTTPException(400, "Give the mock server a name")
|
|
1075
|
+
return _with_url(request, db.create_mock(data))
|
|
1076
|
+
|
|
1077
|
+
@app.patch("/api/mocks/{mid}")
|
|
1078
|
+
def update_mock(mid: str, body: MockPatch, request: Request) -> dict[str, Any]:
|
|
1079
|
+
_mock_or_404(mid)
|
|
1080
|
+
fields = body.model_dump(exclude_none=True)
|
|
1081
|
+
if "name" in fields and not fields["name"].strip():
|
|
1082
|
+
raise HTTPException(400, "Give the mock server a name")
|
|
1083
|
+
return _with_url(request, db.update_mock(mid, **fields))
|
|
1084
|
+
|
|
1085
|
+
@app.delete("/api/mocks/{mid}")
|
|
1086
|
+
def delete_mock(mid: str) -> dict[str, Any]:
|
|
1087
|
+
if not db.delete_mock(mid):
|
|
1088
|
+
raise HTTPException(404, "Mock server not found")
|
|
1089
|
+
return {"ok": True}
|
|
1090
|
+
|
|
1091
|
+
@app.post("/api/mocks/{mid}/routes", status_code=201)
|
|
1092
|
+
def create_mock_route(mid: str, body: MockRouteIn) -> dict[str, Any]:
|
|
1093
|
+
_mock_or_404(mid)
|
|
1094
|
+
return db.create_mock_route(mid, _mock_route_payload(body.model_dump()))
|
|
1095
|
+
|
|
1096
|
+
@app.patch("/api/mocks/{mid}/routes/{rid}")
|
|
1097
|
+
def update_mock_route(mid: str, rid: str, body: MockRoutePatch) -> dict[str, Any]:
|
|
1098
|
+
_mock_or_404(mid)
|
|
1099
|
+
route = db.get_mock_route(rid)
|
|
1100
|
+
if not route or route["server_id"] != mid:
|
|
1101
|
+
raise HTTPException(404, "Route not found")
|
|
1102
|
+
return db.update_mock_route(rid, **_mock_route_payload(body.model_dump(exclude_none=True)))
|
|
1103
|
+
|
|
1104
|
+
@app.delete("/api/mocks/{mid}/routes/{rid}")
|
|
1105
|
+
def delete_mock_route(mid: str, rid: str) -> dict[str, Any]:
|
|
1106
|
+
_mock_or_404(mid)
|
|
1107
|
+
route = db.get_mock_route(rid)
|
|
1108
|
+
if not route or route["server_id"] != mid:
|
|
1109
|
+
raise HTTPException(404, "Route not found")
|
|
1110
|
+
db.delete_mock_route(rid)
|
|
1111
|
+
return {"ok": True}
|
|
1112
|
+
|
|
1113
|
+
@app.post("/api/mocks/{mid}/routes/reorder")
|
|
1114
|
+
def reorder_mock_routes(mid: str, body: MockReorderIn) -> dict[str, Any]:
|
|
1115
|
+
_mock_or_404(mid)
|
|
1116
|
+
db.reorder_mock_routes(mid, body.route_ids)
|
|
1117
|
+
return {"routes": db.list_mock_routes(mid)}
|
|
1118
|
+
|
|
1119
|
+
@app.post("/api/mocks/{mid}/from-requests", status_code=201)
|
|
1120
|
+
def mock_from_requests(mid: str, body: MockSeedIn) -> dict[str, Any]:
|
|
1121
|
+
"""Add a route per saved request, answering with the response it last gave."""
|
|
1122
|
+
_mock_or_404(mid)
|
|
1123
|
+
missing = [i for i in body.request_ids if not db.get_request(i)]
|
|
1124
|
+
if missing:
|
|
1125
|
+
raise HTTPException(400, f"Unknown request ids: {', '.join(missing)}")
|
|
1126
|
+
if not body.request_ids:
|
|
1127
|
+
raise HTTPException(400, "Pick at least one request")
|
|
1128
|
+
drafts = mock.routes_from_requests(body.request_ids)
|
|
1129
|
+
for draft in drafts:
|
|
1130
|
+
db.create_mock_route(mid, draft)
|
|
1131
|
+
return {"added": len(drafts), "routes": db.list_mock_routes(mid)}
|
|
1132
|
+
|
|
1133
|
+
@app.get("/api/mocks/{mid}/calls")
|
|
1134
|
+
def mock_calls(mid: str, limit: int = Query(100)) -> list[dict[str, Any]]:
|
|
1135
|
+
_mock_or_404(mid)
|
|
1136
|
+
return db.list_mock_calls(mid, limit=min(max(limit, 1), db.MAX_MOCK_CALLS))
|
|
1137
|
+
|
|
1138
|
+
@app.delete("/api/mocks/{mid}/calls")
|
|
1139
|
+
def clear_mock_calls(mid: str) -> dict[str, Any]:
|
|
1140
|
+
_mock_or_404(mid)
|
|
1141
|
+
return {"cleared": db.clear_mock_calls(mid)}
|
|
1142
|
+
|
|
1143
|
+
@app.get("/mock", include_in_schema=False)
|
|
1144
|
+
def mock_root(request: Request) -> JSONResponse:
|
|
1145
|
+
"""What is being mocked on this port, for a quick look from curl or a browser."""
|
|
1146
|
+
servers = [{"name": m["name"], "url": _mock_url(request, m["slug"]), "enabled": m["enabled"],
|
|
1147
|
+
"routes": [f"{r['method']} {mock.normalize_path(r['path'])}" for r in m["routes"]
|
|
1148
|
+
if r["enabled"]]}
|
|
1149
|
+
for m in db.list_mocks()]
|
|
1150
|
+
return JSONResponse({"mock_servers": servers})
|
|
1151
|
+
|
|
1152
|
+
@app.api_route("/mock/{slug}", methods=list(mock.METHODS), include_in_schema=False)
|
|
1153
|
+
@app.api_route("/mock/{slug}/{path:path}", methods=list(mock.METHODS), include_in_schema=False)
|
|
1154
|
+
async def mock_dispatch(slug: str, request: Request, path: str = "") -> Response:
|
|
1155
|
+
started = time.perf_counter()
|
|
1156
|
+
server = db.get_mock_by_slug(slug)
|
|
1157
|
+
if not server:
|
|
1158
|
+
raise HTTPException(404, f'No mock server answers "/mock/{slug}" (see /mock)')
|
|
1159
|
+
if not server["enabled"]:
|
|
1160
|
+
raise HTTPException(503, f'The mock server "{server["name"]}" is turned off')
|
|
1161
|
+
req_path = "/" + path
|
|
1162
|
+
query = dict(request.query_params)
|
|
1163
|
+
headers = {k.lower(): v for k, v in request.headers.items()}
|
|
1164
|
+
body = (await request.body()).decode("utf-8", errors="replace")
|
|
1165
|
+
route, params = mock.match_route(server["routes"], request.method, req_path, query)
|
|
1166
|
+
if route is None and request.method == "OPTIONS" and server["cors"]:
|
|
1167
|
+
# CORS preflight: answer it even without a route for OPTIONS
|
|
1168
|
+
return Response(status_code=204, headers=mock.cors_headers(
|
|
1169
|
+
headers.get("origin", "*"), headers.get("access-control-request-headers", "")))
|
|
1170
|
+
if route is None:
|
|
1171
|
+
out = mock.not_found_body(server, request.method, req_path)
|
|
1172
|
+
else:
|
|
1173
|
+
ctx = mock.request_context(request.method, req_path, query, headers, body, params)
|
|
1174
|
+
out = mock.build_response(server, route, ctx)
|
|
1175
|
+
if out["delay_s"]:
|
|
1176
|
+
await asyncio.sleep(min(out["delay_s"], 60))
|
|
1177
|
+
took = (time.perf_counter() - started) * 1000
|
|
1178
|
+
db.bump_mock_hits(server["id"], route["id"] if route else None)
|
|
1179
|
+
mock.log_call(server, route, request.method, req_path, request.url.query, headers, body,
|
|
1180
|
+
out["status_code"], took)
|
|
1181
|
+
content = b"" if request.method == "HEAD" else out["body"]
|
|
1182
|
+
return Response(content=content, status_code=out["status_code"], headers=out["headers"])
|
|
1183
|
+
|
|
1184
|
+
# --------------------------------------------------------- import/export
|
|
1185
|
+
def _export_payload() -> dict[str, Any]:
|
|
1186
|
+
reqs = []
|
|
1187
|
+
for r in db.list_requests():
|
|
1188
|
+
reqs.append({k: v for k, v in r.items() if k not in ("id", "created_at", "updated_at")})
|
|
1189
|
+
envs = [{"name": e["name"], "variables": e["variables"]} for e in db.list_environments()]
|
|
1190
|
+
return {"pyhttp_version": __version__, "folders": db.list_folders(), "environments": envs,
|
|
1191
|
+
"requests": reqs, "variables": db.get_variables()}
|
|
1192
|
+
|
|
1193
|
+
@app.get("/api/export")
|
|
1194
|
+
def export_all() -> dict[str, Any]:
|
|
1195
|
+
return _export_payload()
|
|
1196
|
+
|
|
1197
|
+
@app.get("/api/export/postman")
|
|
1198
|
+
def export_postman(folder_id: str = Query(""), name: str = Query("")) -> dict[str, Any]:
|
|
1199
|
+
"""A Postman v2.1 collection (optionally just one folder) plus environments."""
|
|
1200
|
+
folders = db.list_folders()
|
|
1201
|
+
requests = db.list_requests()
|
|
1202
|
+
title = name or "pyhttp collection"
|
|
1203
|
+
if folder_id:
|
|
1204
|
+
keep = db.folder_descendants(folder_id)
|
|
1205
|
+
root = db.get_folder(folder_id)
|
|
1206
|
+
title = name or (root["name"] if root else title)
|
|
1207
|
+
# the exported folder becomes the collection itself: its children move to the root
|
|
1208
|
+
folders = [{**f, "parent_id": None} if f.get("parent_id") == folder_id else f
|
|
1209
|
+
for f in folders if f["id"] in keep and f["id"] != folder_id]
|
|
1210
|
+
requests = [{**r, "folder_id": None} if r.get("folder_id") == folder_id else r
|
|
1211
|
+
for r in requests if r.get("folder_id") in keep]
|
|
1212
|
+
return {"collection": postman.to_collection(title, folders, requests, db.get_variables()),
|
|
1213
|
+
"environments": [postman.to_environment(e) for e in db.list_environments()]}
|
|
1214
|
+
|
|
1215
|
+
@app.post("/api/import")
|
|
1216
|
+
def import_all(body: ImportIn) -> dict[str, Any]:
|
|
1217
|
+
return import_collection([f.model_dump() for f in body.folders], [r.model_dump() for r in body.requests],
|
|
1218
|
+
[e.model_dump() for e in body.environments], body.variables, body.replace)
|
|
1219
|
+
|
|
1220
|
+
@app.post("/api/import/detect")
|
|
1221
|
+
def import_detect(body: dict[str, Any]) -> dict[str, Any]:
|
|
1222
|
+
"""Tell the UI what a JSON file is: pyhttp export, Postman collection or Postman environment."""
|
|
1223
|
+
data = body.get("data")
|
|
1224
|
+
if postman.is_collection(data):
|
|
1225
|
+
conv = postman.from_collection(data)
|
|
1226
|
+
return {"kind": "postman_collection", "name": conv["name"], "requests": len(conv["requests"]),
|
|
1227
|
+
"folders": len(conv["folders"])}
|
|
1228
|
+
if postman.is_environment(data):
|
|
1229
|
+
env = postman.from_environment(data)
|
|
1230
|
+
return {"kind": "postman_environment", "name": env["name"], "variables": len(env["variables"])}
|
|
1231
|
+
if openapi.is_openapi(data):
|
|
1232
|
+
conv = openapi.from_openapi(data)
|
|
1233
|
+
return {"kind": "openapi", "name": conv["name"], "requests": len(conv["requests"]),
|
|
1234
|
+
"folders": len(conv["folders"]), "version": str(data.get("openapi") or data.get("swagger"))}
|
|
1235
|
+
if openapi.is_har(data):
|
|
1236
|
+
conv = openapi.from_har(data)
|
|
1237
|
+
return {"kind": "har", "name": conv["name"], "requests": len(conv["requests"]),
|
|
1238
|
+
"folders": len(conv["folders"]), "entries": len(data["log"]["entries"])}
|
|
1239
|
+
if isinstance(data, dict) and ("requests" in data or "folders" in data):
|
|
1240
|
+
return {"kind": "pyhttp", "requests": len(data.get("requests") or []), "folders": len(data.get("folders") or []),
|
|
1241
|
+
"environments": len(data.get("environments") or [])}
|
|
1242
|
+
if isinstance(data, list):
|
|
1243
|
+
return {"kind": "pyhttp", "requests": len(data), "folders": 0, "environments": 0}
|
|
1244
|
+
return {"kind": "unknown"}
|
|
1245
|
+
|
|
1246
|
+
@app.post("/api/import/postman")
|
|
1247
|
+
def import_postman(body: dict[str, Any]) -> dict[str, Any]:
|
|
1248
|
+
data = body.get("data")
|
|
1249
|
+
replace = bool(body.get("replace"))
|
|
1250
|
+
if postman.is_collection(data):
|
|
1251
|
+
conv = postman.from_collection(data)
|
|
1252
|
+
out = import_collection(conv["folders"], conv["requests"], [], {}, replace)
|
|
1253
|
+
out["kind"] = "postman_collection"
|
|
1254
|
+
return out
|
|
1255
|
+
if postman.is_environment(data):
|
|
1256
|
+
env = postman.from_environment(data)
|
|
1257
|
+
db.create_environment(env["name"], env["variables"])
|
|
1258
|
+
return {"kind": "postman_environment", "imported": 0, "folders": 0, "environments": 1}
|
|
1259
|
+
for kind, test, convert in (("openapi", openapi.is_openapi, openapi.from_openapi),
|
|
1260
|
+
("har", openapi.is_har, lambda d: openapi.from_har(d, bool(body.get("include_assets"))))):
|
|
1261
|
+
if test(data):
|
|
1262
|
+
conv = convert(data)
|
|
1263
|
+
out = import_collection(conv["folders"], conv["requests"], [], {}, replace)
|
|
1264
|
+
out["kind"] = kind
|
|
1265
|
+
return out
|
|
1266
|
+
raise HTTPException(400, "Not a Postman collection (v2.1) or environment, OpenAPI / Swagger spec or HAR file")
|
|
1267
|
+
|
|
1268
|
+
@app.post("/api/import/parse")
|
|
1269
|
+
def import_parse(body: dict[str, Any]) -> dict[str, Any]:
|
|
1270
|
+
"""Parse a pasted or opened file that is not JSON (OpenAPI specs are often YAML)."""
|
|
1271
|
+
try:
|
|
1272
|
+
return {"data": openapi.parse_text(str(body.get("text") or ""))}
|
|
1273
|
+
except ValueError as exc:
|
|
1274
|
+
raise HTTPException(400, str(exc)) from exc
|
|
1275
|
+
|
|
1276
|
+
@app.post("/api/import/curl", status_code=201)
|
|
1277
|
+
def import_curl(body: CurlIn) -> dict[str, Any]:
|
|
1278
|
+
try:
|
|
1279
|
+
req = curl.parse(body.command)
|
|
1280
|
+
except ValueError as exc:
|
|
1281
|
+
raise HTTPException(400, str(exc)) from exc
|
|
1282
|
+
if not body.save:
|
|
1283
|
+
return req
|
|
1284
|
+
req["folder_id"] = body.folder_id
|
|
1285
|
+
return db.create_request(req)
|
|
1286
|
+
|
|
1287
|
+
# ------------------------------------------------------------- utilities
|
|
1288
|
+
@app.get("/api/path-exists")
|
|
1289
|
+
def path_exists(path: str = Query("")) -> dict[str, Any]:
|
|
1290
|
+
p = Path(path).expanduser()
|
|
1291
|
+
return {"exists": p.is_file(), "size": p.stat().st_size if p.is_file() else None, "name": p.name}
|
|
1292
|
+
|
|
1293
|
+
@app.post("/api/open-path")
|
|
1294
|
+
def open_path(body: dict[str, Any]) -> dict[str, Any]:
|
|
1295
|
+
p = Path(str(body.get("path") or "")).expanduser()
|
|
1296
|
+
if not p.exists():
|
|
1297
|
+
raise HTTPException(404, "Path not found")
|
|
1298
|
+
try:
|
|
1299
|
+
_open_in_file_manager(p if p.is_dir() else p.parent)
|
|
1300
|
+
except Exception as exc: # noqa: BLE001
|
|
1301
|
+
raise HTTPException(500, f"Could not open: {exc}") from exc
|
|
1302
|
+
return {"ok": True}
|
|
1303
|
+
|
|
1304
|
+
# -------------------------------------------------------------------- UI
|
|
1305
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
1306
|
+
|
|
1307
|
+
@app.middleware("http")
|
|
1308
|
+
async def no_stale_static(request: Any, call_next: Any) -> Any:
|
|
1309
|
+
# The webview caches aggressively; after an upgrade a new index.html with old
|
|
1310
|
+
# scripts breaks the UI. Static files always revalidate, and index.html never caches.
|
|
1311
|
+
# {{mock_url}} points at the mocks on this very port, whichever one we got
|
|
1312
|
+
templating.BUILTINS["mock_url"] = f"{_own_origin(request)}/mock"
|
|
1313
|
+
response = await call_next(request)
|
|
1314
|
+
path = request.url.path
|
|
1315
|
+
if path.startswith("/static/"):
|
|
1316
|
+
response.headers["Cache-Control"] = "no-cache, must-revalidate"
|
|
1317
|
+
elif path == "/":
|
|
1318
|
+
response.headers["Cache-Control"] = "no-store"
|
|
1319
|
+
return response
|
|
1320
|
+
|
|
1321
|
+
@app.get("/", include_in_schema=False)
|
|
1322
|
+
def index() -> HTMLResponse:
|
|
1323
|
+
# script/style URLs get ?v=<version> so every release loads fresh files
|
|
1324
|
+
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
|
1325
|
+
html = re.sub(r'((?:src|href)="/static/[^"?]+)"', rf'\1?v={__version__}"', html)
|
|
1326
|
+
html = html.replace("<html lang=\"en\">", f"<html lang=\"en\" data-version=\"{__version__}\">", 1)
|
|
1327
|
+
return HTMLResponse(html)
|
|
1328
|
+
|
|
1329
|
+
@app.exception_handler(ValueError)
|
|
1330
|
+
def value_error(_request: Any, exc: ValueError) -> JSONResponse:
|
|
1331
|
+
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
|
1332
|
+
|
|
1333
|
+
return app
|