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/db.py ADDED
@@ -0,0 +1,1483 @@
1
+ """SQLite storage layer.
2
+
3
+ A single connection guarded by a re-entrant lock is plenty for a local,
4
+ single-user tool and keeps the code free of ORM machinery.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ SCHEMA = """
18
+ CREATE TABLE IF NOT EXISTS requests (
19
+ id TEXT PRIMARY KEY,
20
+ name TEXT NOT NULL,
21
+ description TEXT NOT NULL DEFAULT '',
22
+ tags TEXT NOT NULL DEFAULT '[]',
23
+ method TEXT NOT NULL DEFAULT 'GET',
24
+ url TEXT NOT NULL DEFAULT '',
25
+ params TEXT NOT NULL DEFAULT '[]',
26
+ headers TEXT NOT NULL DEFAULT '[]',
27
+ folder_id TEXT,
28
+ body_type TEXT NOT NULL DEFAULT 'none',
29
+ body TEXT NOT NULL DEFAULT '',
30
+ bodies TEXT NOT NULL DEFAULT '[]',
31
+ timeout REAL NOT NULL DEFAULT 30,
32
+ verify_ssl INTEGER NOT NULL DEFAULT 1,
33
+ created_at REAL NOT NULL,
34
+ updated_at REAL NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS folders (
37
+ id TEXT PRIMARY KEY,
38
+ name TEXT NOT NULL,
39
+ parent_id TEXT,
40
+ created_at REAL NOT NULL
41
+ );
42
+ CREATE TABLE IF NOT EXISTS environments (
43
+ id TEXT PRIMARY KEY,
44
+ name TEXT NOT NULL,
45
+ variables TEXT NOT NULL DEFAULT '[]',
46
+ created_at REAL NOT NULL
47
+ );
48
+ CREATE TABLE IF NOT EXISTS settings (
49
+ key TEXT PRIMARY KEY,
50
+ value TEXT
51
+ );
52
+ CREATE TABLE IF NOT EXISTS variables (
53
+ key TEXT PRIMARY KEY,
54
+ value TEXT NOT NULL DEFAULT ''
55
+ );
56
+ CREATE TABLE IF NOT EXISTS runs (
57
+ id TEXT PRIMARY KEY,
58
+ name TEXT NOT NULL,
59
+ status TEXT NOT NULL,
60
+ total INTEGER NOT NULL,
61
+ completed INTEGER NOT NULL DEFAULT 0,
62
+ failed INTEGER NOT NULL DEFAULT 0,
63
+ options TEXT NOT NULL DEFAULT '{}',
64
+ output_dir TEXT NOT NULL,
65
+ started_at REAL NOT NULL,
66
+ finished_at REAL
67
+ );
68
+ CREATE TABLE IF NOT EXISTS run_results (
69
+ id TEXT PRIMARY KEY,
70
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
71
+ position INTEGER NOT NULL,
72
+ request_id TEXT,
73
+ request_name TEXT NOT NULL,
74
+ body_name TEXT,
75
+ method TEXT NOT NULL DEFAULT '',
76
+ status TEXT NOT NULL,
77
+ status_code INTEGER,
78
+ duration_ms REAL,
79
+ error TEXT,
80
+ detail TEXT NOT NULL DEFAULT '{}',
81
+ body_file TEXT,
82
+ detail_file TEXT,
83
+ started_at REAL NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_results_run ON run_results(run_id, position);
86
+ CREATE TABLE IF NOT EXISTS cookies (
87
+ domain TEXT NOT NULL,
88
+ path TEXT NOT NULL DEFAULT '/',
89
+ name TEXT NOT NULL,
90
+ value TEXT NOT NULL DEFAULT '',
91
+ expires REAL,
92
+ secure INTEGER NOT NULL DEFAULT 0,
93
+ http_only INTEGER NOT NULL DEFAULT 0,
94
+ updated_at REAL NOT NULL,
95
+ PRIMARY KEY (domain, path, name)
96
+ );
97
+ CREATE TABLE IF NOT EXISTS history (
98
+ id TEXT PRIMARY KEY,
99
+ ts REAL NOT NULL,
100
+ source TEXT NOT NULL DEFAULT '',
101
+ method TEXT NOT NULL DEFAULT '',
102
+ url TEXT NOT NULL DEFAULT '',
103
+ request_id TEXT,
104
+ request_name TEXT NOT NULL DEFAULT '',
105
+ body_name TEXT,
106
+ status TEXT NOT NULL DEFAULT '',
107
+ status_code INTEGER,
108
+ duration_ms REAL,
109
+ error TEXT,
110
+ environment TEXT,
111
+ snapshot TEXT NOT NULL DEFAULT '{}',
112
+ detail TEXT NOT NULL DEFAULT '{}'
113
+ );
114
+ CREATE INDEX IF NOT EXISTS idx_history_ts ON history(ts);
115
+ CREATE TABLE IF NOT EXISTS dependency_runs (
116
+ request_id TEXT PRIMARY KEY,
117
+ ran_at REAL NOT NULL,
118
+ ok INTEGER NOT NULL DEFAULT 1,
119
+ status_code INTEGER
120
+ );
121
+ CREATE TABLE IF NOT EXISTS monitors (
122
+ id TEXT PRIMARY KEY,
123
+ name TEXT NOT NULL,
124
+ request_ids TEXT NOT NULL DEFAULT '[]',
125
+ interval_s INTEGER NOT NULL DEFAULT 300,
126
+ enabled INTEGER NOT NULL DEFAULT 1,
127
+ webhook_url TEXT NOT NULL DEFAULT '',
128
+ created_at REAL NOT NULL,
129
+ last_run_at REAL,
130
+ last_status TEXT
131
+ );
132
+ CREATE TABLE IF NOT EXISTS monitor_checks (
133
+ id TEXT PRIMARY KEY,
134
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
135
+ ts REAL NOT NULL,
136
+ ok INTEGER NOT NULL DEFAULT 1,
137
+ failed INTEGER NOT NULL DEFAULT 0,
138
+ total INTEGER NOT NULL DEFAULT 0,
139
+ duration_ms REAL,
140
+ results TEXT NOT NULL DEFAULT '[]'
141
+ );
142
+ CREATE INDEX IF NOT EXISTS idx_monitor_checks ON monitor_checks(monitor_id, ts);
143
+ CREATE TABLE IF NOT EXISTS mock_servers (
144
+ id TEXT PRIMARY KEY,
145
+ name TEXT NOT NULL,
146
+ slug TEXT NOT NULL UNIQUE,
147
+ description TEXT NOT NULL DEFAULT '',
148
+ enabled INTEGER NOT NULL DEFAULT 1,
149
+ delay_ms REAL NOT NULL DEFAULT 0,
150
+ cors INTEGER NOT NULL DEFAULT 1,
151
+ log_calls INTEGER NOT NULL DEFAULT 1,
152
+ created_at REAL NOT NULL,
153
+ hits INTEGER NOT NULL DEFAULT 0
154
+ );
155
+ CREATE TABLE IF NOT EXISTS mock_routes (
156
+ id TEXT PRIMARY KEY,
157
+ server_id TEXT NOT NULL REFERENCES mock_servers(id) ON DELETE CASCADE,
158
+ name TEXT NOT NULL DEFAULT '',
159
+ method TEXT NOT NULL DEFAULT 'GET',
160
+ path TEXT NOT NULL DEFAULT '/',
161
+ status_code INTEGER NOT NULL DEFAULT 200,
162
+ headers TEXT NOT NULL DEFAULT '[]',
163
+ content_type TEXT NOT NULL DEFAULT 'application/json',
164
+ body TEXT NOT NULL DEFAULT '',
165
+ delay_ms REAL NOT NULL DEFAULT 0,
166
+ match_query TEXT NOT NULL DEFAULT '',
167
+ enabled INTEGER NOT NULL DEFAULT 1,
168
+ position INTEGER NOT NULL DEFAULT 0,
169
+ hits INTEGER NOT NULL DEFAULT 0
170
+ );
171
+ CREATE INDEX IF NOT EXISTS idx_mock_routes ON mock_routes(server_id, position);
172
+ CREATE TABLE IF NOT EXISTS mock_calls (
173
+ id TEXT PRIMARY KEY,
174
+ server_id TEXT NOT NULL REFERENCES mock_servers(id) ON DELETE CASCADE,
175
+ route_id TEXT,
176
+ route_name TEXT NOT NULL DEFAULT '',
177
+ ts REAL NOT NULL,
178
+ method TEXT NOT NULL,
179
+ path TEXT NOT NULL,
180
+ query TEXT NOT NULL DEFAULT '',
181
+ status_code INTEGER NOT NULL DEFAULT 200,
182
+ matched INTEGER NOT NULL DEFAULT 1,
183
+ duration_ms REAL,
184
+ req_headers TEXT NOT NULL DEFAULT '{}',
185
+ req_body TEXT NOT NULL DEFAULT ''
186
+ );
187
+ CREATE INDEX IF NOT EXISTS idx_mock_calls ON mock_calls(server_id, ts);
188
+ """
189
+
190
+ _JSON_FIELDS = ("tags", "params", "headers", "bodies", "auth", "tests", "extract", "pre_vars", "options")
191
+ _FOLDER_JSON_FIELDS = ("auth", "headers", "variables")
192
+ MAX_HISTORY = 1000
193
+ _conn: sqlite3.Connection | None = None
194
+ _lock = threading.RLock()
195
+
196
+
197
+ def init(path: Path) -> None:
198
+ global _conn
199
+ path.parent.mkdir(parents=True, exist_ok=True)
200
+ _conn = sqlite3.connect(str(path), check_same_thread=False)
201
+ _conn.row_factory = sqlite3.Row
202
+ _conn.execute("PRAGMA foreign_keys = ON")
203
+ _conn.execute("PRAGMA journal_mode = WAL")
204
+ _conn.executescript(SCHEMA)
205
+ _migrate(_conn)
206
+
207
+
208
+ def close() -> None:
209
+ global _conn
210
+ if _conn is not None:
211
+ _conn.close()
212
+ _conn = None
213
+
214
+
215
+ def _columns(conn: sqlite3.Connection, table: str) -> set[str]:
216
+ return {r[1] for r in conn.execute(f"PRAGMA table_info({table})")}
217
+
218
+
219
+ def _migrate(conn: sqlite3.Connection) -> None:
220
+ def add(table: str, column: str, decl: str) -> None:
221
+ if column not in _columns(conn, table):
222
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
223
+
224
+ add("requests", "bodies", "TEXT NOT NULL DEFAULT '[]'")
225
+ add("requests", "folder_id", "TEXT")
226
+ add("requests", "auth", "TEXT NOT NULL DEFAULT '{}'")
227
+ add("requests", "tests", "TEXT NOT NULL DEFAULT '[]'")
228
+ add("requests", "extract", "TEXT NOT NULL DEFAULT '[]'")
229
+ add("requests", "pre_vars", "TEXT NOT NULL DEFAULT '[]'")
230
+ add("requests", "options", "TEXT NOT NULL DEFAULT '{}'")
231
+ add("requests", "position", "INTEGER NOT NULL DEFAULT 0")
232
+ add("folders", "description", "TEXT NOT NULL DEFAULT ''")
233
+ add("folders", "auth", "TEXT NOT NULL DEFAULT '{}'")
234
+ add("folders", "headers", "TEXT NOT NULL DEFAULT '[]'")
235
+ add("folders", "variables", "TEXT NOT NULL DEFAULT '[]'")
236
+ add("folders", "position", "INTEGER NOT NULL DEFAULT 0")
237
+ add("run_results", "body_name", "TEXT")
238
+ add("run_results", "iteration", "INTEGER NOT NULL DEFAULT 1")
239
+ add("run_results", "tests", "TEXT NOT NULL DEFAULT '[]'")
240
+ conn.commit()
241
+
242
+
243
+ def _c() -> sqlite3.Connection:
244
+ assert _conn is not None, "db.init() has not been called"
245
+ return _conn
246
+
247
+
248
+ # ----------------------------------------------------------------- requests
249
+
250
+
251
+ def _row_to_request(row: sqlite3.Row) -> dict[str, Any]:
252
+ d = dict(row)
253
+ for f in _JSON_FIELDS:
254
+ try:
255
+ d[f] = json.loads(d.get(f) or ("{}" if f in ("auth", "options") else "[]"))
256
+ except ValueError:
257
+ d[f] = {} if f in ("auth", "options") else []
258
+ d["verify_ssl"] = bool(d["verify_ssl"])
259
+ d["auth"] = normalize_auth(d["auth"]) # rows from before the auth column hold {}
260
+ d["options"] = normalize_options(d["options"])
261
+ if not d["bodies"] and d.get("body"):
262
+ # legacy single body -> one named variant
263
+ d["bodies"] = [{"id": "default", "name": "Default", "body": d["body"], "enabled": True}]
264
+ return d
265
+
266
+
267
+ AUTH_TYPES = ("none", "inherit", "basic", "bearer", "apikey", "digest", "oauth2")
268
+ BODY_TYPES = ("none", "json", "text", "xml", "html", "javascript", "form", "multipart", "binary", "graphql")
269
+
270
+
271
+ def normalize_auth(data: Any, default: str = "inherit") -> dict[str, Any]:
272
+ d = data if isinstance(data, dict) else {}
273
+ t = str(d.get("type") or default)
274
+ if t not in AUTH_TYPES:
275
+ t = default
276
+ return {
277
+ "type": t,
278
+ "username": str(d.get("username") or ""),
279
+ "password": str(d.get("password") or ""),
280
+ "token": str(d.get("token") or ""),
281
+ "prefix": str(d.get("prefix") if d.get("prefix") is not None else "Bearer"),
282
+ "key": str(d.get("key") or ""),
283
+ "value": str(d.get("value") or ""),
284
+ "add_to": "query" if d.get("add_to") == "query" else "header",
285
+ # OAuth 2.0 (client credentials / password grant)
286
+ "grant": "password" if d.get("grant") == "password" else "client_credentials",
287
+ "token_url": str(d.get("token_url") or ""),
288
+ "client_id": str(d.get("client_id") or ""),
289
+ "client_secret": str(d.get("client_secret") or ""),
290
+ "scope": str(d.get("scope") or ""),
291
+ "audience": str(d.get("audience") or ""),
292
+ "client_auth": "body" if d.get("client_auth") == "body" else "basic",
293
+ }
294
+
295
+
296
+ def _rules(rows: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]:
297
+ out = []
298
+ for r in rows or []:
299
+ if not isinstance(r, dict):
300
+ continue
301
+ item = {k: ("" if r.get(k) is None else str(r.get(k))) for k in keys}
302
+ item["enabled"] = bool(r.get("enabled", True))
303
+ out.append(item)
304
+ return out
305
+
306
+
307
+ def normalize_options(data: Any) -> dict[str, Any]:
308
+ d = data if isinstance(data, dict) else {}
309
+ out: dict[str, Any] = {}
310
+ if d.get("follow_redirects") in (True, False):
311
+ out["follow_redirects"] = bool(d["follow_redirects"])
312
+ out["send_cookies"] = bool(d.get("send_cookies", True))
313
+ out["store_cookies"] = bool(d.get("store_cookies", True))
314
+ if d.get("stream") is True: # show the response live as it arrives (SSE, chunked)
315
+ out["stream"] = True
316
+ scripts = d.get("scripts") if isinstance(d.get("scripts"), dict) else {}
317
+ # scripts for every body, plus optional ones for a single named body (keyed by body id)
318
+ per_body: dict[str, dict[str, str]] = {}
319
+ for body_id, own in (scripts.get("bodies") if isinstance(scripts.get("bodies"), dict) else {}).items():
320
+ if not isinstance(own, dict) or not str(body_id):
321
+ continue
322
+ pre, test = str(own.get("pre") or ""), str(own.get("test") or "")
323
+ if pre.strip() or test.strip():
324
+ per_body[str(body_id)] = {"pre": pre, "test": test}
325
+ out["scripts"] = {"pre": str(scripts.get("pre") or ""), "test": str(scripts.get("test") or ""),
326
+ "lang": "py" if scripts.get("lang") == "py" else "js", "bodies": per_body}
327
+ out["active_body"] = str(d.get("active_body") or "") # body shown in the editor, remembered per request
328
+ # a request to send first (e.g. one that fetches a token), re-sent only when its last result is older than ttl seconds
329
+ dep = d.get("depends") if isinstance(d.get("depends"), dict) else {}
330
+ if str(dep.get("request_id") or ""):
331
+ try:
332
+ ttl = max(0, int(float(dep.get("ttl") or 0)))
333
+ except (TypeError, ValueError):
334
+ ttl = 0
335
+ out["depends"] = {"request_id": str(dep["request_id"]), "ttl": ttl, "retry_on_auth": bool(dep.get("retry_on_auth", True))}
336
+ return out
337
+
338
+
339
+ def _bodies(rows: Any, legacy_body: str) -> list[dict[str, Any]]:
340
+ out: list[dict[str, Any]] = []
341
+ seen: set[str] = set()
342
+ for i, r in enumerate(rows or [], start=1):
343
+ if not isinstance(r, dict):
344
+ continue
345
+ bid = str(r.get("id") or "").strip() or uuid.uuid4().hex[:10]
346
+ while bid in seen:
347
+ bid = uuid.uuid4().hex[:10]
348
+ seen.add(bid)
349
+ out.append({
350
+ "id": bid,
351
+ "name": str(r.get("name") or "").strip() or f"Body {i}",
352
+ "body": str(r.get("body") or ""),
353
+ "enabled": bool(r.get("enabled", True)),
354
+ })
355
+ if not out and legacy_body:
356
+ out.append({"id": "default", "name": "Default", "body": legacy_body, "enabled": True})
357
+ return out
358
+
359
+
360
+ def _kv_rows(rows: Any) -> list[dict[str, Any]]:
361
+ out = []
362
+ for r in rows or []:
363
+ if not isinstance(r, dict):
364
+ continue
365
+ out.append({
366
+ "key": str(r.get("key", "")),
367
+ "value": str(r.get("value", "")),
368
+ "enabled": bool(r.get("enabled", True)),
369
+ **({"secret": True} if r.get("secret") else {}),
370
+ })
371
+ return out
372
+
373
+
374
+ def normalize_request(data: dict[str, Any]) -> dict[str, Any]:
375
+ tags: list[str] = []
376
+ for t in data.get("tags") or []:
377
+ t = str(t).strip()
378
+ if t and t not in tags:
379
+ tags.append(t)
380
+ try:
381
+ timeout = float(data.get("timeout") or 30)
382
+ except (TypeError, ValueError):
383
+ timeout = 30.0
384
+ bodies = _bodies(data.get("bodies"), data.get("body") or "")
385
+ body_type = data.get("body_type") or "none"
386
+ if body_type not in BODY_TYPES:
387
+ body_type = "text"
388
+ return {
389
+ "name": (data.get("name") or "Untitled").strip() or "Untitled",
390
+ "description": data.get("description") or "",
391
+ "tags": tags,
392
+ "method": (data.get("method") or "GET").upper(),
393
+ "url": (data.get("url") or "").strip(),
394
+ "params": _kv_rows(data.get("params")),
395
+ "headers": _kv_rows(data.get("headers")),
396
+ "body_type": body_type,
397
+ "bodies": bodies,
398
+ # `body` mirrors the first body so exports stay readable by older versions
399
+ "body": bodies[0]["body"] if bodies else "",
400
+ "timeout": timeout,
401
+ "verify_ssl": bool(data.get("verify_ssl", True)),
402
+ "auth": normalize_auth(data.get("auth")),
403
+ "tests": _rules(data.get("tests"), ("name", "type", "target", "op", "value", "body_id")),
404
+ "extract": _rules(data.get("extract"), ("name", "source", "path", "scope")),
405
+ "pre_vars": _rules(data.get("pre_vars"), ("key", "value", "scope")),
406
+ "options": normalize_options(data.get("options")),
407
+ }
408
+
409
+
410
+ def list_requests(q: str = "", tag: str = "") -> list[dict[str, Any]]:
411
+ with _lock:
412
+ rows = _c().execute("SELECT * FROM requests ORDER BY position, lower(name)").fetchall()
413
+ items = [_row_to_request(r) for r in rows]
414
+ q = (q or "").strip().lower()
415
+ if q:
416
+ tokens = q.split()
417
+
418
+ def match(r: dict[str, Any]) -> bool:
419
+ hay = " ".join(
420
+ [r["name"], r["description"], r["url"], r["method"], " ".join(r["tags"]),
421
+ " ".join(b["name"] for b in r["bodies"])]
422
+ ).lower()
423
+ return all(tok in hay for tok in tokens)
424
+
425
+ items = [r for r in items if match(r)]
426
+ if tag:
427
+ items = [r for r in items if tag in r["tags"]]
428
+ return items
429
+
430
+
431
+ def get_request(rid: str) -> dict[str, Any] | None:
432
+ with _lock:
433
+ row = _c().execute("SELECT * FROM requests WHERE id = ?", (rid,)).fetchone()
434
+ return _row_to_request(row) if row else None
435
+
436
+
437
+ def get_requests(ids: list[str]) -> list[dict[str, Any]]:
438
+ """Return requests in the same order as `ids`, skipping unknown ids."""
439
+ found = {r["id"]: r for r in (get_request(i) for i in ids) if r}
440
+ return [found[i] for i in ids if i in found]
441
+
442
+
443
+ def create_request(data: dict[str, Any]) -> dict[str, Any]:
444
+ d = normalize_request(data)
445
+ rid = uuid.uuid4().hex
446
+ now = time.time()
447
+ folder_id = data.get("folder_id") or None
448
+ if folder_id and not get_folder(folder_id):
449
+ folder_id = None
450
+ with _lock:
451
+ pos = _c().execute("SELECT COALESCE(MAX(position), 0) + 1 FROM requests WHERE folder_id IS ?",
452
+ (folder_id,)).fetchone()[0]
453
+ _c().execute(
454
+ """INSERT INTO requests (id, name, description, tags, method, url, params, headers,
455
+ body_type, body, bodies, timeout, verify_ssl, created_at, updated_at, folder_id,
456
+ auth, tests, extract, pre_vars, options, position)
457
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
458
+ (rid, d["name"], d["description"], json.dumps(d["tags"]), d["method"], d["url"],
459
+ json.dumps(d["params"]), json.dumps(d["headers"]), d["body_type"], d["body"],
460
+ json.dumps(d["bodies"]), d["timeout"], int(d["verify_ssl"]), now, now, folder_id,
461
+ json.dumps(d["auth"]), json.dumps(d["tests"]), json.dumps(d["extract"]),
462
+ json.dumps(d["pre_vars"]), json.dumps(d["options"]), pos),
463
+ )
464
+ _c().commit()
465
+ return get_request(rid) # type: ignore[return-value]
466
+
467
+
468
+ def update_request(rid: str, data: dict[str, Any]) -> dict[str, Any] | None:
469
+ if not get_request(rid):
470
+ return None
471
+ d = normalize_request(data)
472
+ with _lock:
473
+ _c().execute(
474
+ """UPDATE requests SET name=?, description=?, tags=?, method=?, url=?, params=?,
475
+ headers=?, body_type=?, body=?, bodies=?, timeout=?, verify_ssl=?, updated_at=?,
476
+ auth=?, tests=?, extract=?, pre_vars=?, options=?
477
+ WHERE id=?""",
478
+ (d["name"], d["description"], json.dumps(d["tags"]), d["method"], d["url"],
479
+ json.dumps(d["params"]), json.dumps(d["headers"]), d["body_type"], d["body"],
480
+ json.dumps(d["bodies"]), d["timeout"], int(d["verify_ssl"]), time.time(),
481
+ json.dumps(d["auth"]), json.dumps(d["tests"]), json.dumps(d["extract"]),
482
+ json.dumps(d["pre_vars"]), json.dumps(d["options"]), rid),
483
+ )
484
+ _c().commit()
485
+ return get_request(rid)
486
+
487
+
488
+ def reorder(request_ids: list[str] | None = None, folder_ids: list[str] | None = None) -> None:
489
+ """Assign positions in the given order (used for drag-to-reorder)."""
490
+ with _lock:
491
+ for i, rid in enumerate(request_ids or [], start=1):
492
+ _c().execute("UPDATE requests SET position = ? WHERE id = ?", (i, rid))
493
+ for i, fid in enumerate(folder_ids or [], start=1):
494
+ _c().execute("UPDATE folders SET position = ? WHERE id = ?", (i, fid))
495
+ _c().commit()
496
+
497
+
498
+ def delete_request(rid: str) -> bool:
499
+ with _lock:
500
+ cur = _c().execute("DELETE FROM requests WHERE id = ?", (rid,))
501
+ _c().commit()
502
+ return cur.rowcount > 0
503
+
504
+
505
+ def all_tags() -> list[dict[str, Any]]:
506
+ counts: dict[str, int] = {}
507
+ for r in list_requests():
508
+ for t in r["tags"]:
509
+ counts[t] = counts.get(t, 0) + 1
510
+ return [
511
+ {"tag": t, "count": n}
512
+ for t, n in sorted(counts.items(), key=lambda kv: kv[0].lower())
513
+ ]
514
+
515
+
516
+ def move_request(rid: str, folder_id: str | None) -> dict[str, Any] | None:
517
+ if folder_id and not get_folder(folder_id):
518
+ raise ValueError("Folder not found")
519
+ with _lock:
520
+ cur = _c().execute("UPDATE requests SET folder_id = ?, updated_at = ? WHERE id = ?",
521
+ (folder_id or None, time.time(), rid))
522
+ _c().commit()
523
+ return get_request(rid) if cur.rowcount else None
524
+
525
+
526
+ # ------------------------------------------------------------------ folders
527
+
528
+
529
+ def _row_to_folder(row: sqlite3.Row) -> dict[str, Any]:
530
+ d = dict(row)
531
+ for f in _FOLDER_JSON_FIELDS:
532
+ try:
533
+ d[f] = json.loads(d.get(f) or ("{}" if f == "auth" else "[]"))
534
+ except ValueError:
535
+ d[f] = {} if f == "auth" else []
536
+ d["auth"] = normalize_auth(d["auth"])
537
+ return d
538
+
539
+
540
+ def list_folders() -> list[dict[str, Any]]:
541
+ with _lock:
542
+ rows = _c().execute("SELECT * FROM folders ORDER BY position, lower(name)").fetchall()
543
+ return [_row_to_folder(r) for r in rows]
544
+
545
+
546
+ def get_folder(fid: str) -> dict[str, Any] | None:
547
+ with _lock:
548
+ row = _c().execute("SELECT * FROM folders WHERE id = ?", (fid,)).fetchone()
549
+ return _row_to_folder(row) if row else None
550
+
551
+
552
+ def folder_chain(fid: str | None) -> list[dict[str, Any]]:
553
+ """Folders from the root down to `fid` (empty for a top-level request)."""
554
+ chain: list[dict[str, Any]] = []
555
+ seen: set[str] = set()
556
+ f = get_folder(fid) if fid else None
557
+ while f and f["id"] not in seen:
558
+ seen.add(f["id"])
559
+ chain.append(f)
560
+ f = get_folder(f["parent_id"]) if f.get("parent_id") else None
561
+ chain.reverse()
562
+ return chain
563
+
564
+
565
+ def inherited(folder_id: str | None) -> dict[str, Any]:
566
+ """What a request inside `folder_id` inherits: auth (nearest folder that sets one),
567
+ headers (root to leaf, later folders override) and variables (same precedence)."""
568
+ chain = folder_chain(folder_id)
569
+ auth = {"type": "none"}
570
+ auth_from = None
571
+ headers: dict[str, dict[str, Any]] = {}
572
+ variables: dict[str, str] = {}
573
+ for f in chain:
574
+ if f["auth"]["type"] not in ("inherit", "none"):
575
+ auth, auth_from = f["auth"], f["name"]
576
+ for h in f["headers"]:
577
+ if h.get("enabled", True) and h.get("key", "").strip():
578
+ headers[h["key"].strip().lower()] = {"key": h["key"].strip(), "value": h.get("value", ""), "enabled": True}
579
+ for v in f["variables"]:
580
+ if v.get("enabled", True) and v.get("key", "").strip():
581
+ variables[v["key"].strip()] = v.get("value", "")
582
+ return {"auth": normalize_auth(auth, "none"), "auth_from": auth_from,
583
+ "headers": list(headers.values()), "variables": variables}
584
+
585
+
586
+ def folder_descendants(fid: str) -> set[str]:
587
+ """The folder itself plus every folder nested below it."""
588
+ children: dict[str | None, list[str]] = {}
589
+ for f in list_folders():
590
+ children.setdefault(f["parent_id"], []).append(f["id"])
591
+ out, stack = set(), [fid]
592
+ while stack:
593
+ cur = stack.pop()
594
+ if cur in out:
595
+ continue
596
+ out.add(cur)
597
+ stack.extend(children.get(cur, []))
598
+ return out
599
+
600
+
601
+ def create_folder(name: str, parent_id: str | None = None) -> dict[str, Any]:
602
+ name = (name or "").strip() or "New folder"
603
+ if parent_id and not get_folder(parent_id):
604
+ raise ValueError("Parent folder not found")
605
+ fid = uuid.uuid4().hex
606
+ with _lock:
607
+ pos = _c().execute("SELECT COALESCE(MAX(position), 0) + 1 FROM folders WHERE parent_id IS ?",
608
+ (parent_id or None,)).fetchone()[0]
609
+ _c().execute("INSERT INTO folders (id, name, parent_id, created_at, position) VALUES (?,?,?,?,?)",
610
+ (fid, name, parent_id or None, time.time(), pos))
611
+ _c().commit()
612
+ return get_folder(fid) # type: ignore[return-value]
613
+
614
+
615
+ _KEEP = object()
616
+
617
+
618
+ def update_folder(fid: str, name: Any = _KEEP, parent_id: Any = _KEEP, description: Any = _KEEP,
619
+ auth: Any = _KEEP, headers: Any = _KEEP, variables: Any = _KEEP) -> dict[str, Any] | None:
620
+ f = get_folder(fid)
621
+ if not f:
622
+ return None
623
+ if name is not _KEEP:
624
+ f["name"] = (name or "").strip() or f["name"]
625
+ if parent_id is not _KEEP:
626
+ parent_id = parent_id or None
627
+ if parent_id and (not get_folder(parent_id) or parent_id in folder_descendants(fid)):
628
+ raise ValueError("A folder cannot be moved into itself or its own subfolder")
629
+ f["parent_id"] = parent_id
630
+ if description is not _KEEP:
631
+ f["description"] = str(description or "")
632
+ if auth is not _KEEP:
633
+ f["auth"] = normalize_auth(auth)
634
+ if headers is not _KEEP:
635
+ f["headers"] = _kv_rows(headers)
636
+ if variables is not _KEEP:
637
+ f["variables"] = _kv_rows(variables)
638
+ with _lock:
639
+ _c().execute("""UPDATE folders SET name = ?, parent_id = ?, description = ?, auth = ?, headers = ?,
640
+ variables = ? WHERE id = ?""",
641
+ (f["name"], f["parent_id"], f["description"], json.dumps(f["auth"]),
642
+ json.dumps(f["headers"]), json.dumps(f["variables"]), fid))
643
+ _c().commit()
644
+ return get_folder(fid)
645
+
646
+
647
+ def folder_contents(fid: str) -> dict[str, int]:
648
+ ids = folder_descendants(fid)
649
+ marks = ",".join("?" * len(ids))
650
+ with _lock:
651
+ n = _c().execute(f"SELECT COUNT(*) FROM requests WHERE folder_id IN ({marks})", tuple(ids)).fetchone()[0]
652
+ return {"folders": len(ids) - 1, "requests": n}
653
+
654
+
655
+ def delete_folder(fid: str) -> dict[str, Any] | None:
656
+ """Delete a folder with everything inside it. Returns the deleted ids."""
657
+ if not get_folder(fid):
658
+ return None
659
+ ids = folder_descendants(fid)
660
+ marks = ",".join("?" * len(ids))
661
+ with _lock:
662
+ req_ids = [r[0] for r in _c().execute(
663
+ f"SELECT id FROM requests WHERE folder_id IN ({marks})", tuple(ids)).fetchall()]
664
+ _c().execute(f"DELETE FROM requests WHERE folder_id IN ({marks})", tuple(ids))
665
+ _c().execute(f"DELETE FROM folders WHERE id IN ({marks})", tuple(ids))
666
+ _c().commit()
667
+ return {"folders": sorted(ids), "requests": req_ids}
668
+
669
+
670
+ # ------------------------------------------------------------- environments
671
+
672
+
673
+ def _row_to_env(row: sqlite3.Row) -> dict[str, Any]:
674
+ d = dict(row)
675
+ d["variables"] = json.loads(d["variables"])
676
+ return d
677
+
678
+
679
+ def list_environments() -> list[dict[str, Any]]:
680
+ with _lock:
681
+ rows = _c().execute("SELECT * FROM environments ORDER BY created_at").fetchall()
682
+ return [_row_to_env(r) for r in rows]
683
+
684
+
685
+ def get_environment(eid: str) -> dict[str, Any] | None:
686
+ with _lock:
687
+ row = _c().execute("SELECT * FROM environments WHERE id = ?", (eid,)).fetchone()
688
+ return _row_to_env(row) if row else None
689
+
690
+
691
+ def create_environment(name: str, variables: Any = None) -> dict[str, Any]:
692
+ eid = uuid.uuid4().hex
693
+ with _lock:
694
+ _c().execute("INSERT INTO environments (id, name, variables, created_at) VALUES (?,?,?,?)",
695
+ (eid, (name or "").strip() or "New environment",
696
+ json.dumps(_kv_rows(variables)), time.time()))
697
+ _c().commit()
698
+ return get_environment(eid) # type: ignore[return-value]
699
+
700
+
701
+ def update_environment(eid: str, name: Any = _KEEP, variables: Any = _KEEP) -> dict[str, Any] | None:
702
+ env = get_environment(eid)
703
+ if not env:
704
+ return None
705
+ if name is not _KEEP:
706
+ env["name"] = (name or "").strip() or env["name"]
707
+ if variables is not _KEEP:
708
+ env["variables"] = _kv_rows(variables)
709
+ with _lock:
710
+ _c().execute("UPDATE environments SET name = ?, variables = ? WHERE id = ?",
711
+ (env["name"], json.dumps(env["variables"]), eid))
712
+ _c().commit()
713
+ return get_environment(eid)
714
+
715
+
716
+ def delete_environment(eid: str) -> bool:
717
+ with _lock:
718
+ cur = _c().execute("DELETE FROM environments WHERE id = ?", (eid,))
719
+ _c().commit()
720
+ if get_setting("active_env") == eid:
721
+ set_setting("active_env", None)
722
+ return cur.rowcount > 0
723
+
724
+
725
+ APP_SETTINGS_DEFAULTS: dict[str, Any] = {
726
+ "theme": "dark", "verify_ssl": True, "follow_redirects": True, "max_redirects": 10,
727
+ "proxy_url": "", "proxy_bypass": "localhost, 127.0.0.1", "user_agent": "", "default_timeout": 30,
728
+ "certs": [], "editor_font_size": 13, "layout": "below", "update_check": True, "send_cookies": True,
729
+ "max_history": 500, "update_repo": "",
730
+ "ui_font": "inter", "mono_font": "default",
731
+ "parallel": False, "concurrency": 8, # Send ×N and runs: everything at once (bounded) instead of in order
732
+ }
733
+ UI_FONTS = ("inter", "system", "segoe", "roboto", "helvetica", "ubuntu", "verdana") # ids match static/features.js
734
+ MONO_FONTS = ("default", "cascadia", "jetbrains", "consolas", "fira", "source", "courier")
735
+ _BOOL_SETTINGS = ("verify_ssl", "follow_redirects", "update_check", "send_cookies", "parallel")
736
+ _INT_SETTINGS = ("max_redirects", "editor_font_size", "max_history", "concurrency")
737
+ _FLOAT_SETTINGS = ("default_timeout",)
738
+ _JSON_SETTINGS = ("certs",)
739
+
740
+
741
+ def app_settings() -> dict[str, Any]:
742
+ out: dict[str, Any] = {}
743
+ for key, default in APP_SETTINGS_DEFAULTS.items():
744
+ raw = get_setting(key)
745
+ if raw is None:
746
+ out[key] = default
747
+ elif key in _BOOL_SETTINGS:
748
+ out[key] = raw != "0"
749
+ elif key in _INT_SETTINGS:
750
+ try:
751
+ out[key] = int(float(raw))
752
+ except ValueError:
753
+ out[key] = default
754
+ elif key in _FLOAT_SETTINGS:
755
+ try:
756
+ out[key] = float(raw)
757
+ except ValueError:
758
+ out[key] = default
759
+ elif key in _JSON_SETTINGS:
760
+ try:
761
+ out[key] = json.loads(raw)
762
+ except ValueError:
763
+ out[key] = default
764
+ else:
765
+ out[key] = raw
766
+ if out["theme"] not in ("dark", "light", "system"):
767
+ out["theme"] = "dark"
768
+ if out["layout"] not in ("below", "beside"):
769
+ out["layout"] = "below"
770
+ if out["ui_font"] not in UI_FONTS:
771
+ out["ui_font"] = "inter"
772
+ if out["mono_font"] not in MONO_FONTS:
773
+ out["mono_font"] = "default"
774
+ return out
775
+
776
+
777
+ def update_settings(patch: dict[str, Any]) -> dict[str, Any]:
778
+ for key, value in patch.items():
779
+ if key not in APP_SETTINGS_DEFAULTS or value is None:
780
+ continue
781
+ if key in _BOOL_SETTINGS:
782
+ set_setting(key, "1" if value else "0")
783
+ elif key in _JSON_SETTINGS:
784
+ set_setting(key, json.dumps(value))
785
+ else:
786
+ set_setting(key, str(value))
787
+ return app_settings()
788
+
789
+
790
+ def verify_ssl_enabled() -> bool:
791
+ return get_setting("verify_ssl") != "0"
792
+
793
+
794
+ def get_setting(key: str) -> str | None:
795
+ with _lock:
796
+ row = _c().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
797
+ return row["value"] if row else None
798
+
799
+
800
+ def set_setting(key: str, value: str | None) -> None:
801
+ with _lock:
802
+ _c().execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value))
803
+ _c().commit()
804
+
805
+
806
+ def active_environment() -> dict[str, Any] | None:
807
+ eid = get_setting("active_env")
808
+ return get_environment(eid) if eid else None
809
+
810
+
811
+ # ---- dependency runs: when a request last ran as a dependency of another (so a token is not fetched on every send)
812
+ def dependency_ran(request_id: str) -> dict[str, Any] | None:
813
+ with _lock:
814
+ row = _c().execute("SELECT * FROM dependency_runs WHERE request_id = ?", (request_id,)).fetchone()
815
+ return dict(row) if row else None
816
+
817
+
818
+ def set_dependency_ran(request_id: str, ok: bool, status_code: int | None) -> None:
819
+ with _lock:
820
+ _c().execute("INSERT OR REPLACE INTO dependency_runs (request_id, ran_at, ok, status_code) VALUES (?,?,?,?)",
821
+ (request_id, time.time(), int(ok), status_code))
822
+ _c().commit()
823
+
824
+
825
+ def clear_dependency_ran(request_id: str) -> None:
826
+ with _lock:
827
+ _c().execute("DELETE FROM dependency_runs WHERE request_id = ?", (request_id,))
828
+ _c().commit()
829
+
830
+
831
+ def effective_variables(folder_id: str | None = None) -> tuple[dict[str, str], str | None]:
832
+ """Global variables, overridden by folder (collection) variables, overridden by the
833
+ enabled variables of the active environment. Same precedence as Postman."""
834
+ values = get_variables()
835
+ if folder_id:
836
+ values.update(inherited(folder_id)["variables"])
837
+ env = active_environment()
838
+ if env:
839
+ for row in env["variables"]:
840
+ if row["enabled"] and row["key"].strip():
841
+ values[row["key"].strip()] = row["value"]
842
+ return values, (env["name"] if env else None)
843
+
844
+
845
+ def secret_values(folder_id: str | None = None) -> list[str]:
846
+ """Values of the variables marked secret (active environment and the folder chain),
847
+ longest first, so they can be masked in everything written to disk."""
848
+ rows = list((active_environment() or {}).get("variables") or [])
849
+ for f in folder_chain(folder_id) if folder_id else []:
850
+ rows += f.get("variables") or []
851
+ vals = {r["value"] for r in rows if r.get("secret") and len(r.get("value") or "") >= 3}
852
+ return sorted(vals, key=len, reverse=True)
853
+
854
+
855
+ def set_variable(name: str, value: str, scope: str = "env", folder_id: str | None = None) -> str:
856
+ """Set one variable in the active environment (or globally when scope is
857
+ 'global' or no environment is active). Scope 'collection' writes the variable
858
+ on the request's folder (the nearest one). Returns the scope actually used."""
859
+ name = name.strip()
860
+ if not name:
861
+ return scope
862
+ if scope == "collection":
863
+ f = get_folder(folder_id) if folder_id else None
864
+ if f:
865
+ rows = f["variables"]
866
+ for row in rows:
867
+ if row["key"].strip() == name:
868
+ row["value"] = value
869
+ row["enabled"] = True
870
+ break
871
+ else:
872
+ rows.append({"key": name, "value": value, "enabled": True})
873
+ update_folder(f["id"], variables=rows)
874
+ return "collection"
875
+ scope = "global"
876
+ env = active_environment() if scope != "global" else None
877
+ if env:
878
+ rows = env["variables"]
879
+ for row in rows:
880
+ if row["key"].strip() == name:
881
+ row["value"] = value
882
+ row["enabled"] = True
883
+ break
884
+ else:
885
+ rows.append({"key": name, "value": value, "enabled": True})
886
+ update_environment(env["id"], variables=rows)
887
+ return "env"
888
+ values = get_variables()
889
+ values[name] = value
890
+ set_variables(values)
891
+ return "global"
892
+
893
+
894
+ def unset_variable(name: str, scope: str = "env", folder_id: str | None = None) -> None:
895
+ name = name.strip()
896
+ if not name:
897
+ return
898
+ if scope == "collection":
899
+ f = get_folder(folder_id) if folder_id else None
900
+ if f:
901
+ update_folder(f["id"], variables=[r for r in f["variables"] if r["key"].strip() != name])
902
+ return
903
+ if scope == "env":
904
+ env = active_environment()
905
+ if env:
906
+ update_environment(env["id"], variables=[r for r in env["variables"] if r["key"].strip() != name])
907
+ return
908
+ values = get_variables()
909
+ if name in values:
910
+ del values[name]
911
+ set_variables(values)
912
+
913
+
914
+ # ------------------------------------------------------------------ cookies
915
+
916
+
917
+ def list_cookies() -> list[dict[str, Any]]:
918
+ with _lock:
919
+ rows = _c().execute("SELECT * FROM cookies ORDER BY domain, path, name").fetchall()
920
+ out = []
921
+ for r in rows:
922
+ d = dict(r)
923
+ d["secure"], d["http_only"] = bool(d["secure"]), bool(d["http_only"])
924
+ out.append(d)
925
+ return out
926
+
927
+
928
+ def replace_cookies(cookies: list[dict[str, Any]]) -> None:
929
+ now = time.time()
930
+ with _lock:
931
+ _c().execute("DELETE FROM cookies")
932
+ _c().executemany(
933
+ """INSERT OR REPLACE INTO cookies (domain, path, name, value, expires, secure, http_only, updated_at)
934
+ VALUES (?,?,?,?,?,?,?,?)""",
935
+ [(c["domain"], c.get("path") or "/", c["name"], c.get("value", ""), c.get("expires"),
936
+ int(bool(c.get("secure"))), int(bool(c.get("http_only"))), now) for c in cookies],
937
+ )
938
+ _c().commit()
939
+
940
+
941
+ def upsert_cookie(c: dict[str, Any]) -> None:
942
+ with _lock:
943
+ _c().execute(
944
+ """INSERT OR REPLACE INTO cookies (domain, path, name, value, expires, secure, http_only, updated_at)
945
+ VALUES (?,?,?,?,?,?,?,?)""",
946
+ (c["domain"], c.get("path") or "/", c["name"], c.get("value", ""), c.get("expires"),
947
+ int(bool(c.get("secure"))), int(bool(c.get("http_only"))), time.time()),
948
+ )
949
+ _c().commit()
950
+
951
+
952
+ def delete_cookies(domain: str | None = None, name: str | None = None, path: str | None = None) -> int:
953
+ where, args = [], []
954
+ if domain is not None:
955
+ where.append("domain = ?")
956
+ args.append(domain)
957
+ if name is not None:
958
+ where.append("name = ?")
959
+ args.append(name)
960
+ if path is not None:
961
+ where.append("path = ?")
962
+ args.append(path)
963
+ sql = "DELETE FROM cookies" + (" WHERE " + " AND ".join(where) if where else "")
964
+ with _lock:
965
+ cur = _c().execute(sql, args)
966
+ _c().commit()
967
+ return cur.rowcount
968
+
969
+
970
+ # ------------------------------------------------------------------ history
971
+
972
+
973
+ def add_history(entry: dict[str, Any]) -> str:
974
+ hid = uuid.uuid4().hex
975
+ with _lock:
976
+ _c().execute(
977
+ """INSERT INTO history (id, ts, source, method, url, request_id, request_name, body_name, status,
978
+ status_code, duration_ms, error, environment, snapshot, detail)
979
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
980
+ (hid, entry.get("ts") or time.time(), entry.get("source", ""), entry.get("method", ""),
981
+ entry.get("url", ""), entry.get("request_id"), entry.get("request_name", ""), entry.get("body_name"),
982
+ entry.get("status", ""), entry.get("status_code"), entry.get("duration_ms"), entry.get("error"),
983
+ entry.get("environment"), json.dumps(entry.get("snapshot") or {}), json.dumps(entry.get("detail") or {})),
984
+ )
985
+ limit = max(50, int(get_setting("max_history") or APP_SETTINGS_DEFAULTS["max_history"]))
986
+ _c().execute(
987
+ "DELETE FROM history WHERE id IN (SELECT id FROM history ORDER BY ts DESC LIMIT -1 OFFSET ?)", (limit,)
988
+ )
989
+ _c().commit()
990
+ return hid
991
+
992
+
993
+ def _status_group(code: Any, status: str) -> str:
994
+ if status == "cancelled":
995
+ return "error"
996
+ if code is None:
997
+ return "error"
998
+ return f"{int(code) // 100}xx"
999
+
1000
+
1001
+ def list_history(limit: int = 300, q: str = "", status: str = "") -> list[dict[str, Any]]:
1002
+ """Newest first. `q`: words matched against method, URL, name and status code;
1003
+ `status`: "2xx", "3xx", "4xx", "5xx" or "error" (no response)."""
1004
+ with _lock:
1005
+ rows = _c().execute(
1006
+ """SELECT id, ts, source, method, url, request_id, request_name, body_name, status, status_code,
1007
+ duration_ms, error, environment FROM history ORDER BY ts DESC LIMIT ?""", (limit,)
1008
+ ).fetchall()
1009
+ items = [dict(r) for r in rows]
1010
+ q = (q or "").strip().lower()
1011
+ if q:
1012
+ toks = q.split()
1013
+ items = [i for i in items if all(t in f"{i['method']} {i['url']} {i['request_name']} {i['status_code']}".lower() for t in toks)]
1014
+ if status:
1015
+ items = [i for i in items if _status_group(i["status_code"], i["status"]) == status]
1016
+ return items
1017
+
1018
+
1019
+ def get_history(hid: str) -> dict[str, Any] | None:
1020
+ with _lock:
1021
+ row = _c().execute("SELECT * FROM history WHERE id = ?", (hid,)).fetchone()
1022
+ if not row:
1023
+ return None
1024
+ d = dict(row)
1025
+ d["snapshot"] = json.loads(d["snapshot"])
1026
+ d["detail"] = json.loads(d["detail"])
1027
+ return d
1028
+
1029
+
1030
+ def delete_history(hid: str | None = None) -> int:
1031
+ with _lock:
1032
+ cur = _c().execute("DELETE FROM history WHERE id = ?", (hid,)) if hid else _c().execute("DELETE FROM history")
1033
+ _c().commit()
1034
+ return cur.rowcount
1035
+
1036
+
1037
+ # ---------------------------------------------------------------- variables
1038
+
1039
+
1040
+ def get_variables() -> dict[str, str]:
1041
+ with _lock:
1042
+ rows = _c().execute("SELECT key, value FROM variables ORDER BY key").fetchall()
1043
+ return {r["key"]: r["value"] for r in rows}
1044
+
1045
+
1046
+ def set_variables(values: dict[str, str]) -> dict[str, str]:
1047
+ with _lock:
1048
+ _c().execute("DELETE FROM variables")
1049
+ _c().executemany(
1050
+ "INSERT OR REPLACE INTO variables (key, value) VALUES (?, ?)",
1051
+ [(k.strip(), str(v)) for k, v in values.items() if k.strip()],
1052
+ )
1053
+ _c().commit()
1054
+ return get_variables()
1055
+
1056
+
1057
+ # --------------------------------------------------------------------- runs
1058
+
1059
+
1060
+ def create_run(name: str, total: int, options: dict[str, Any], output_dir: str) -> dict[str, Any]:
1061
+ run_id = uuid.uuid4().hex
1062
+ with _lock:
1063
+ _c().execute(
1064
+ """INSERT INTO runs (id, name, status, total, options, output_dir, started_at)
1065
+ VALUES (?,?,?,?,?,?,?)""",
1066
+ (run_id, name, "running", total, json.dumps(options), output_dir, time.time()),
1067
+ )
1068
+ _c().commit()
1069
+ return get_run(run_id) # type: ignore[return-value]
1070
+
1071
+
1072
+ def update_run(run_id: str, **fields: Any) -> None:
1073
+ if not fields:
1074
+ return
1075
+ cols = ", ".join(f"{k} = ?" for k in fields)
1076
+ with _lock:
1077
+ _c().execute(f"UPDATE runs SET {cols} WHERE id = ?", (*fields.values(), run_id))
1078
+ _c().commit()
1079
+
1080
+
1081
+ def add_result(run_id: str, result: dict[str, Any]) -> dict[str, Any]:
1082
+ res_id = uuid.uuid4().hex
1083
+ with _lock:
1084
+ _c().execute(
1085
+ """INSERT INTO run_results (id, run_id, position, request_id, request_name, body_name,
1086
+ method, status, status_code, duration_ms, error, detail, body_file, detail_file,
1087
+ started_at, iteration, tests)
1088
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
1089
+ (res_id, run_id, result["position"], result.get("request_id"),
1090
+ result["request_name"], result.get("body_name"), result.get("method", ""), result["status"],
1091
+ result.get("status_code"), result.get("duration_ms"), result.get("error"),
1092
+ json.dumps(result.get("detail") or {}), result.get("body_file"),
1093
+ result.get("detail_file"), result["started_at"], result.get("iteration", 1),
1094
+ json.dumps(result.get("tests") or [])),
1095
+ )
1096
+ _c().commit()
1097
+ return {"id": res_id, **result}
1098
+
1099
+
1100
+ def _row_to_run(row: sqlite3.Row) -> dict[str, Any]:
1101
+ d = dict(row)
1102
+ d["options"] = json.loads(d["options"])
1103
+ return d
1104
+
1105
+
1106
+ def list_runs(limit: int = 200) -> list[dict[str, Any]]:
1107
+ with _lock:
1108
+ rows = _c().execute(
1109
+ "SELECT * FROM runs ORDER BY started_at DESC LIMIT ?", (limit,)
1110
+ ).fetchall()
1111
+ return [_row_to_run(r) for r in rows]
1112
+
1113
+
1114
+ def get_run(run_id: str, with_results: bool = True) -> dict[str, Any] | None:
1115
+ with _lock:
1116
+ row = _c().execute("SELECT * FROM runs WHERE id = ?", (run_id,)).fetchone()
1117
+ if not row:
1118
+ return None
1119
+ run = _row_to_run(row)
1120
+ if with_results:
1121
+ rows = _c().execute(
1122
+ "SELECT * FROM run_results WHERE run_id = ? ORDER BY position", (run_id,)
1123
+ ).fetchall()
1124
+ results = []
1125
+ for r in rows:
1126
+ d = dict(r)
1127
+ d["detail"] = json.loads(d["detail"])
1128
+ try:
1129
+ d["tests"] = json.loads(d.get("tests") or "[]")
1130
+ except ValueError:
1131
+ d["tests"] = []
1132
+ results.append(d)
1133
+ run["results"] = results
1134
+ return run
1135
+
1136
+
1137
+ def get_result(run_id: str, result_id: str) -> dict[str, Any] | None:
1138
+ with _lock:
1139
+ row = _c().execute(
1140
+ "SELECT * FROM run_results WHERE run_id = ? AND id = ?", (run_id, result_id)
1141
+ ).fetchone()
1142
+ if not row:
1143
+ return None
1144
+ d = dict(row)
1145
+ d["detail"] = json.loads(d["detail"])
1146
+ return d
1147
+
1148
+
1149
+ def delete_run(run_id: str) -> bool:
1150
+ with _lock:
1151
+ cur = _c().execute("DELETE FROM runs WHERE id = ?", (run_id,))
1152
+ _c().commit()
1153
+ return cur.rowcount > 0
1154
+
1155
+
1156
+ def mark_interrupted_runs() -> None:
1157
+ """Runs left in 'running' state by a previous process exit are marked interrupted."""
1158
+ with _lock:
1159
+ _c().execute(
1160
+ "UPDATE runs SET status = 'interrupted', finished_at = ? WHERE status = 'running'",
1161
+ (time.time(),),
1162
+ )
1163
+ _c().commit()
1164
+
1165
+
1166
+ # ----------------------------------------------------------------- monitors
1167
+
1168
+
1169
+ def _row_to_monitor(row: sqlite3.Row) -> dict[str, Any]:
1170
+ d = dict(row)
1171
+ d["request_ids"] = json.loads(d["request_ids"])
1172
+ d["enabled"] = bool(d["enabled"])
1173
+ return d
1174
+
1175
+
1176
+ def list_monitors() -> list[dict[str, Any]]:
1177
+ with _lock:
1178
+ rows = _c().execute("SELECT * FROM monitors ORDER BY created_at").fetchall()
1179
+ return [_row_to_monitor(r) for r in rows]
1180
+
1181
+
1182
+ def get_monitor(monitor_id: str) -> dict[str, Any] | None:
1183
+ with _lock:
1184
+ row = _c().execute("SELECT * FROM monitors WHERE id = ?", (monitor_id,)).fetchone()
1185
+ return _row_to_monitor(row) if row else None
1186
+
1187
+
1188
+ def create_monitor(data: dict[str, Any]) -> dict[str, Any]:
1189
+ mid = uuid.uuid4().hex
1190
+ with _lock:
1191
+ _c().execute(
1192
+ "INSERT INTO monitors (id, name, request_ids, interval_s, enabled, webhook_url, created_at) "
1193
+ "VALUES (?,?,?,?,?,?,?)",
1194
+ (mid, str(data.get("name") or "Monitor"), json.dumps(list(data.get("request_ids") or [])),
1195
+ max(10, int(data.get("interval_s") or 300)), 1 if data.get("enabled", True) else 0,
1196
+ str(data.get("webhook_url") or ""), time.time()),
1197
+ )
1198
+ _c().commit()
1199
+ return get_monitor(mid) # type: ignore[return-value]
1200
+
1201
+
1202
+ def update_monitor(monitor_id: str, **fields: Any) -> dict[str, Any] | None:
1203
+ if "request_ids" in fields:
1204
+ fields["request_ids"] = json.dumps(list(fields["request_ids"] or []))
1205
+ if "interval_s" in fields:
1206
+ fields["interval_s"] = max(10, int(fields["interval_s"]))
1207
+ if "enabled" in fields:
1208
+ fields["enabled"] = 1 if fields["enabled"] else 0
1209
+ if fields:
1210
+ cols = ", ".join(f"{k} = ?" for k in fields)
1211
+ with _lock:
1212
+ _c().execute(f"UPDATE monitors SET {cols} WHERE id = ?", (*fields.values(), monitor_id))
1213
+ _c().commit()
1214
+ return get_monitor(monitor_id)
1215
+
1216
+
1217
+ def delete_monitor(monitor_id: str) -> bool:
1218
+ with _lock:
1219
+ cur = _c().execute("DELETE FROM monitors WHERE id = ?", (monitor_id,))
1220
+ _c().commit()
1221
+ return cur.rowcount > 0
1222
+
1223
+
1224
+ def due_monitors(now: float) -> list[dict[str, Any]]:
1225
+ """Enabled monitors whose interval has passed since their last check (or that never ran)."""
1226
+ with _lock:
1227
+ rows = _c().execute(
1228
+ "SELECT * FROM monitors WHERE enabled = 1 AND (last_run_at IS NULL OR ? - last_run_at >= interval_s)",
1229
+ (now,),
1230
+ ).fetchall()
1231
+ return [_row_to_monitor(r) for r in rows]
1232
+
1233
+
1234
+ def add_monitor_check(monitor_id: str, check: dict[str, Any], keep: int = 500) -> dict[str, Any]:
1235
+ cid = uuid.uuid4().hex
1236
+ with _lock:
1237
+ _c().execute(
1238
+ "INSERT INTO monitor_checks (id, monitor_id, ts, ok, failed, total, duration_ms, results) "
1239
+ "VALUES (?,?,?,?,?,?,?,?)",
1240
+ (cid, monitor_id, check["ts"], 1 if check.get("ok") else 0, int(check.get("failed") or 0),
1241
+ int(check.get("total") or 0), check.get("duration_ms"), json.dumps(check.get("results") or [])),
1242
+ )
1243
+ _c().execute(
1244
+ "DELETE FROM monitor_checks WHERE monitor_id = ? AND id NOT IN "
1245
+ "(SELECT id FROM monitor_checks WHERE monitor_id = ? ORDER BY ts DESC LIMIT ?)",
1246
+ (monitor_id, monitor_id, keep),
1247
+ )
1248
+ _c().commit()
1249
+ return {"id": cid, **check}
1250
+
1251
+
1252
+ def list_monitor_checks(monitor_id: str, limit: int = 50, with_results: bool = True) -> list[dict[str, Any]]:
1253
+ with _lock:
1254
+ rows = _c().execute(
1255
+ "SELECT * FROM monitor_checks WHERE monitor_id = ? ORDER BY ts DESC LIMIT ?",
1256
+ (monitor_id, limit),
1257
+ ).fetchall()
1258
+ out = []
1259
+ for r in rows:
1260
+ d = dict(r)
1261
+ d["ok"] = bool(d["ok"])
1262
+ if with_results:
1263
+ d["results"] = json.loads(d["results"])
1264
+ else:
1265
+ d.pop("results", None)
1266
+ out.append(d)
1267
+ return out
1268
+
1269
+
1270
+ # -------------------------------------------------------------- mock servers
1271
+ MAX_MOCK_CALLS = 200 # per server; older calls are dropped
1272
+
1273
+
1274
+ def _row_to_mock(row: sqlite3.Row) -> dict[str, Any]:
1275
+ d = dict(row)
1276
+ d["enabled"] = bool(d["enabled"])
1277
+ d["cors"] = bool(d["cors"])
1278
+ d["log_calls"] = bool(d["log_calls"])
1279
+ return d
1280
+
1281
+
1282
+ def _row_to_mock_route(row: sqlite3.Row) -> dict[str, Any]:
1283
+ d = dict(row)
1284
+ d["headers"] = json.loads(d["headers"])
1285
+ d["enabled"] = bool(d["enabled"])
1286
+ return d
1287
+
1288
+
1289
+ def mock_slug(name: str, taken: set[str] | None = None) -> str:
1290
+ """A URL-safe slug for a mock server's base path, unique across the table."""
1291
+ base = re.sub(r"[^a-z0-9]+", "-", str(name or "").lower()).strip("-") or "mock"
1292
+ used = taken if taken is not None else {m["slug"] for m in list_mocks(with_routes=False)}
1293
+ slug, n = base, 2
1294
+ while slug in used:
1295
+ slug, n = f"{base}-{n}", n + 1
1296
+ return slug
1297
+
1298
+
1299
+ def list_mocks(with_routes: bool = True) -> list[dict[str, Any]]:
1300
+ with _lock:
1301
+ rows = _c().execute("SELECT * FROM mock_servers ORDER BY created_at").fetchall()
1302
+ mocks = [_row_to_mock(r) for r in rows]
1303
+ if with_routes:
1304
+ for m in mocks:
1305
+ m["routes"] = list_mock_routes(m["id"])
1306
+ return mocks
1307
+
1308
+
1309
+ def get_mock(mock_id: str, with_routes: bool = True) -> dict[str, Any] | None:
1310
+ with _lock:
1311
+ row = _c().execute("SELECT * FROM mock_servers WHERE id = ?", (mock_id,)).fetchone()
1312
+ if not row:
1313
+ return None
1314
+ m = _row_to_mock(row)
1315
+ if with_routes:
1316
+ m["routes"] = list_mock_routes(m["id"])
1317
+ return m
1318
+
1319
+
1320
+ def get_mock_by_slug(slug: str, with_routes: bool = True) -> dict[str, Any] | None:
1321
+ with _lock:
1322
+ row = _c().execute("SELECT * FROM mock_servers WHERE slug = ?", (slug,)).fetchone()
1323
+ if not row:
1324
+ return None
1325
+ m = _row_to_mock(row)
1326
+ if with_routes:
1327
+ m["routes"] = list_mock_routes(m["id"])
1328
+ return m
1329
+
1330
+
1331
+ def create_mock(data: dict[str, Any]) -> dict[str, Any]:
1332
+ mid = uuid.uuid4().hex
1333
+ name = str(data.get("name") or "Mock server")
1334
+ slug = mock_slug(data.get("slug") or name)
1335
+ with _lock:
1336
+ _c().execute(
1337
+ "INSERT INTO mock_servers (id, name, slug, description, enabled, delay_ms, cors, log_calls, created_at) "
1338
+ "VALUES (?,?,?,?,?,?,?,?,?)",
1339
+ (mid, name, slug, str(data.get("description") or ""), 1 if data.get("enabled", True) else 0,
1340
+ max(0.0, float(data.get("delay_ms") or 0)), 1 if data.get("cors", True) else 0,
1341
+ 1 if data.get("log_calls", True) else 0, time.time()),
1342
+ )
1343
+ _c().commit()
1344
+ for route in data.get("routes") or []:
1345
+ create_mock_route(mid, route)
1346
+ return get_mock(mid) # type: ignore[return-value]
1347
+
1348
+
1349
+ def update_mock(mock_id: str, **fields: Any) -> dict[str, Any] | None:
1350
+ if "slug" in fields:
1351
+ taken = {m["slug"] for m in list_mocks(with_routes=False) if m["id"] != mock_id}
1352
+ fields["slug"] = mock_slug(fields["slug"], taken)
1353
+ for key in ("enabled", "cors", "log_calls"):
1354
+ if key in fields:
1355
+ fields[key] = 1 if fields[key] else 0
1356
+ if "delay_ms" in fields:
1357
+ fields["delay_ms"] = max(0.0, float(fields["delay_ms"]))
1358
+ if fields:
1359
+ cols = ", ".join(f"{k} = ?" for k in fields)
1360
+ with _lock:
1361
+ _c().execute(f"UPDATE mock_servers SET {cols} WHERE id = ?", (*fields.values(), mock_id))
1362
+ _c().commit()
1363
+ return get_mock(mock_id)
1364
+
1365
+
1366
+ def delete_mock(mock_id: str) -> bool:
1367
+ with _lock:
1368
+ cur = _c().execute("DELETE FROM mock_servers WHERE id = ?", (mock_id,))
1369
+ _c().commit()
1370
+ return cur.rowcount > 0
1371
+
1372
+
1373
+ def list_mock_routes(mock_id: str) -> list[dict[str, Any]]:
1374
+ with _lock:
1375
+ rows = _c().execute(
1376
+ "SELECT * FROM mock_routes WHERE server_id = ? ORDER BY position, rowid", (mock_id,),
1377
+ ).fetchall()
1378
+ return [_row_to_mock_route(r) for r in rows]
1379
+
1380
+
1381
+ def get_mock_route(route_id: str) -> dict[str, Any] | None:
1382
+ with _lock:
1383
+ row = _c().execute("SELECT * FROM mock_routes WHERE id = ?", (route_id,)).fetchone()
1384
+ return _row_to_mock_route(row) if row else None
1385
+
1386
+
1387
+ def create_mock_route(mock_id: str, data: dict[str, Any]) -> dict[str, Any]:
1388
+ rid = uuid.uuid4().hex
1389
+ with _lock:
1390
+ pos = _c().execute("SELECT COALESCE(MAX(position), -1) + 1 FROM mock_routes WHERE server_id = ?",
1391
+ (mock_id,)).fetchone()[0]
1392
+ _c().execute(
1393
+ "INSERT INTO mock_routes (id, server_id, name, method, path, status_code, headers, content_type, "
1394
+ "body, delay_ms, match_query, enabled, position) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
1395
+ (rid, mock_id, str(data.get("name") or ""), str(data.get("method") or "GET").upper(),
1396
+ str(data.get("path") or "/"), int(data.get("status_code") or 200),
1397
+ json.dumps(_kv_rows(data.get("headers"))), str(data.get("content_type") or "application/json"),
1398
+ str(data.get("body") or ""), max(0.0, float(data.get("delay_ms") or 0)),
1399
+ str(data.get("match_query") or ""), 1 if data.get("enabled", True) else 0, pos),
1400
+ )
1401
+ _c().commit()
1402
+ return get_mock_route(rid) # type: ignore[return-value]
1403
+
1404
+
1405
+ def update_mock_route(route_id: str, **fields: Any) -> dict[str, Any] | None:
1406
+ if "headers" in fields:
1407
+ fields["headers"] = json.dumps(_kv_rows(fields["headers"]))
1408
+ if "method" in fields:
1409
+ fields["method"] = str(fields["method"]).upper()
1410
+ if "status_code" in fields:
1411
+ fields["status_code"] = int(fields["status_code"])
1412
+ if "delay_ms" in fields:
1413
+ fields["delay_ms"] = max(0.0, float(fields["delay_ms"]))
1414
+ if "enabled" in fields:
1415
+ fields["enabled"] = 1 if fields["enabled"] else 0
1416
+ if fields:
1417
+ cols = ", ".join(f"{k} = ?" for k in fields)
1418
+ with _lock:
1419
+ _c().execute(f"UPDATE mock_routes SET {cols} WHERE id = ?", (*fields.values(), route_id))
1420
+ _c().commit()
1421
+ return get_mock_route(route_id)
1422
+
1423
+
1424
+ def delete_mock_route(route_id: str) -> bool:
1425
+ with _lock:
1426
+ cur = _c().execute("DELETE FROM mock_routes WHERE id = ?", (route_id,))
1427
+ _c().commit()
1428
+ return cur.rowcount > 0
1429
+
1430
+
1431
+ def reorder_mock_routes(mock_id: str, route_ids: list[str]) -> None:
1432
+ with _lock:
1433
+ for pos, rid in enumerate(route_ids):
1434
+ _c().execute("UPDATE mock_routes SET position = ? WHERE id = ? AND server_id = ?", (pos, rid, mock_id))
1435
+ _c().commit()
1436
+
1437
+
1438
+ def bump_mock_hits(mock_id: str, route_id: str | None) -> None:
1439
+ with _lock:
1440
+ _c().execute("UPDATE mock_servers SET hits = hits + 1 WHERE id = ?", (mock_id,))
1441
+ if route_id:
1442
+ _c().execute("UPDATE mock_routes SET hits = hits + 1 WHERE id = ?", (route_id,))
1443
+ _c().commit()
1444
+
1445
+
1446
+ def add_mock_call(mock_id: str, call: dict[str, Any], keep: int = MAX_MOCK_CALLS) -> dict[str, Any]:
1447
+ cid = uuid.uuid4().hex
1448
+ with _lock:
1449
+ _c().execute(
1450
+ "INSERT INTO mock_calls (id, server_id, route_id, route_name, ts, method, path, query, status_code, "
1451
+ "matched, duration_ms, req_headers, req_body) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
1452
+ (cid, mock_id, call.get("route_id"), str(call.get("route_name") or ""), call["ts"],
1453
+ str(call.get("method") or ""), str(call.get("path") or ""), str(call.get("query") or ""),
1454
+ int(call.get("status_code") or 200), 1 if call.get("matched") else 0, call.get("duration_ms"),
1455
+ json.dumps(call.get("req_headers") or {}), str(call.get("req_body") or "")),
1456
+ )
1457
+ _c().execute(
1458
+ "DELETE FROM mock_calls WHERE server_id = ? AND id NOT IN "
1459
+ "(SELECT id FROM mock_calls WHERE server_id = ? ORDER BY ts DESC LIMIT ?)",
1460
+ (mock_id, mock_id, keep),
1461
+ )
1462
+ _c().commit()
1463
+ return {"id": cid, **call}
1464
+
1465
+
1466
+ def list_mock_calls(mock_id: str, limit: int = 100) -> list[dict[str, Any]]:
1467
+ with _lock:
1468
+ rows = _c().execute("SELECT * FROM mock_calls WHERE server_id = ? ORDER BY ts DESC LIMIT ?",
1469
+ (mock_id, limit)).fetchall()
1470
+ out = []
1471
+ for r in rows:
1472
+ d = dict(r)
1473
+ d["matched"] = bool(d["matched"])
1474
+ d["req_headers"] = json.loads(d["req_headers"])
1475
+ out.append(d)
1476
+ return out
1477
+
1478
+
1479
+ def clear_mock_calls(mock_id: str) -> int:
1480
+ with _lock:
1481
+ cur = _c().execute("DELETE FROM mock_calls WHERE server_id = ?", (mock_id,))
1482
+ _c().commit()
1483
+ return cur.rowcount