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/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