bruno: regenerate collection from canonical docs + permanent docs↔Bruno parity gate
WO4. The old collection was the drifted artifact AGENTS.md warned about:
bodies pre-truncated at their first {{var}}, routes renamed server-side
but not here (/api/notes/:media_id, /api/book-matching/search,
/api/collections/:id/rules/test), an orphan universal-progress suite for
routes that no longer exist.
scripts/gen_bruno_from_docs.py emits one OpenCollection request per
frontmatter'd endpoint doc (191 requests): gate-verified example-request
JSON as body, {{env}} params per the environment file, device-token
routes get an explicit Bearer {{device_token}} header, login/register/
refresh keep the token-capture after-response scripts that chain the
collection. Old 1:1 mirrors deleted (128); hand-authored NewDevDBSetup,
environments, and edge-case suites kept. 30 scenario files with
truncated bodies (the documented corruption) deleted — they could not
run.
internal/docs/drift_test.go gains TestBrunoMirrorsDocs: every
frontmatter'd doc must have a Bruno request with matching method+URL
shape — the collection can never silently drift from the docs again.
opencollection.yml README block replaced with the real run instructions.
make test green (9 pkgs, parity test included in internal/docs).
This commit is contained in:
1 parent
6fc501b35a
commit
acd82fff0a
353 files changed
+5165
-5293
No files matched your search
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate OpenCollection (Bruno) request YAMLs from the canonical API docs.
|
||||
|
||||
Truth flow: code -> docs/developer/api (canonical) -> bruno/ (executable
|
||||
verification, never hand-authored). Every frontmatter'd endpoint doc
|
||||
becomes one request file at bruno/<area>/<docname>.yml, with the
|
||||
gate-verified example-request JSON as the body.
|
||||
|
||||
Re-run after doc changes: python3 scripts/gen_bruno_from_docs.py [--apply]
|
||||
Without --apply, prints the plan only.
|
||||
"""
|
||||
import os, re, sys, glob
|
||||
|
||||
DOCS = "docs/developer/api"
|
||||
BRUNO = "bruno"
|
||||
KEEP_DIRS = {"NewDevDBSetup", "environments"} # hand-authored setup + env
|
||||
ROOT_KEEP = {"opencollection.yml", "NewDB.sh"}
|
||||
# Hand-authored edge-case verification (not docs mirrors) — kept as-is.
|
||||
KEEP_SUBSTRINGS = ("/scenarios/", "/filters/", "/search/")
|
||||
|
||||
# Token-capture after-response scripts, keyed by doc filename — these chain
|
||||
# the collection (later requests need {{token}} / {{refresh_token}}).
|
||||
TOKEN_SCRIPT = """function onResponse(res) {
|
||||
try {
|
||||
const responseBody = res.getBody();
|
||||
const token = responseBody.access_token;
|
||||
const refreshToken = responseBody.refresh_token;
|
||||
if (token) {
|
||||
bru.setEnvVar("token", token, { persist: true });
|
||||
console.log("Access token saved");
|
||||
}
|
||||
if (refreshToken) {
|
||||
bru.setEnvVar("refresh_token", refreshToken, { persist: true });
|
||||
console.log("Refresh token saved");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Token capture failed:", error.message);
|
||||
}
|
||||
}
|
||||
onResponseResponsePlaceholder();"""
|
||||
TOKEN_SCRIPT = TOKEN_SCRIPT.replace("onResponseResponsePlaceholder();", "onResponse();")
|
||||
SCRIPT_DOCS = {"login", "register", "refresh_token"}
|
||||
|
||||
# Param-name -> env var (checked longest-first, then generic :id by area).
|
||||
NAMED_PARAMS = {
|
||||
"registration_id": "registration_id",
|
||||
"deviceid": "device_id",
|
||||
"libraryid": "ebook_library_id",
|
||||
"collectionid": "collection_id",
|
||||
"trackindex": "track_index",
|
||||
"highlightid": "highlight_id",
|
||||
"annotationid": "annotation_id",
|
||||
"ruleid": "rule_id",
|
||||
"aliasid": "alias_id",
|
||||
"folderid": "library_folder_id",
|
||||
"jobid": "job_id",
|
||||
"bookid": "book_uuid",
|
||||
"sha256": "book_sha256",
|
||||
"noteid": "note_id",
|
||||
"userid": "user_id",
|
||||
"uuid": "book_uuid",
|
||||
"token": "kobo_device_token",
|
||||
}
|
||||
|
||||
# (area, subpath-contains) -> env for generic :id
|
||||
ID_BY_CONTEXT = [
|
||||
("media-items", "media_item_id"),
|
||||
("collections", "collection_id"),
|
||||
("libraries", "ebook_library_id"),
|
||||
("devices", "device_id"),
|
||||
("progress", "media_item_id"),
|
||||
("stats/books", "media_item_id"),
|
||||
("annotations", "annotation_id"),
|
||||
]
|
||||
|
||||
NEW_ENV_VARS = [
|
||||
"device_id", "rule_id", "annotation_id", "alias_id", "track_index",
|
||||
"registration_id", "unlinked_book_id", "device_token",
|
||||
]
|
||||
|
||||
|
||||
def norm(p: str) -> str:
|
||||
p = re.sub(r"/+", "/", p)
|
||||
return re.sub(r":[^/{}]+", lambda m: "{" + m.group(0)[1:] + "}", p).rstrip("/")
|
||||
|
||||
|
||||
def parse_doc(path: str):
|
||||
src = open(path).read()
|
||||
m = re.match(r"---\n(.*?)\n---\n", src, re.S)
|
||||
if not m:
|
||||
return None
|
||||
fm = m.group(1)
|
||||
method = re.search(r"method:\s*(\w+)", fm)
|
||||
route = re.search(r"path:\s*(\S+)", fm)
|
||||
auth = re.search(r"auth:\s*(\S+)", fm)
|
||||
if not (method and route):
|
||||
return None
|
||||
body_src = src[m.end():]
|
||||
title = re.search(r"^# (.+)$", body_src, re.M)
|
||||
ex = re.search(r"```json example-request\n(.*?)\n```", body_src, re.S)
|
||||
return {
|
||||
"rel": os.path.relpath(path, DOCS),
|
||||
"area": os.path.relpath(path, DOCS).split(os.sep)[0],
|
||||
"name": os.path.splitext(os.path.basename(path))[0],
|
||||
"method": method.group(1).upper(),
|
||||
"path": route.group(1),
|
||||
"auth": auth.group(1) if auth else "jwt",
|
||||
"title": title.group(1).strip() if title else route.group(1),
|
||||
"example": ex.group(1) if ex else None,
|
||||
}
|
||||
|
||||
|
||||
def env_for(area: str, path: str, param: str) -> str:
|
||||
key = param.lstrip(":").lower()
|
||||
if key in NAMED_PARAMS:
|
||||
return NAMED_PARAMS[key]
|
||||
if key == "id":
|
||||
ctx = f"{area}/{path.split('/')[-2] if '/' in path else ''}"
|
||||
for prefix, var in ID_BY_CONTEXT:
|
||||
if ctx.startswith(prefix) or f"/api/{prefix}/" in path + "/":
|
||||
return var
|
||||
if path.startswith("/api/sync/unlinked-books"):
|
||||
return "unlinked_book_id"
|
||||
return "media_item_id" # best default for this API's :id grammar
|
||||
return key # fallback: env var named after the param
|
||||
|
||||
|
||||
def yaml_str_block(indent: int, text: str) -> str:
|
||||
pad = " " * indent
|
||||
lines = text.rstrip("\n").split("\n")
|
||||
out = [f"{pad}|-"]
|
||||
out += [pad + " " + ln if ln else "" for ln in lines]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def emit(doc: dict, seq: int) -> str:
|
||||
path = norm(doc["path"])
|
||||
path = re.sub(r"\{(\w+)\}", lambda m: "{{" + env_for(doc["area"], doc["path"], m.group(1)) + "}}", path)
|
||||
url = "{{base_url}}" + path
|
||||
|
||||
out = [
|
||||
"info:",
|
||||
f" name: {doc['title']}",
|
||||
" type: http",
|
||||
f" seq: {seq}",
|
||||
"",
|
||||
"http:",
|
||||
f" method: {doc['method']}",
|
||||
f" url: '{url}'",
|
||||
" auth: inherit",
|
||||
]
|
||||
if doc["example"]:
|
||||
out += [" body:", " type: json", " data: " + yaml_str_block(4, doc["example"]).lstrip()]
|
||||
if doc["auth"] == "device-token":
|
||||
out += [
|
||||
" headers:",
|
||||
" - key: Authorization",
|
||||
" value: Bearer {{device_token}}",
|
||||
]
|
||||
out += [
|
||||
"settings:",
|
||||
" encodeUrl: true",
|
||||
" timeout: 0",
|
||||
" followRedirects: true",
|
||||
" maxRedirects: 5",
|
||||
]
|
||||
if doc["name"] in SCRIPT_DOCS:
|
||||
out += [
|
||||
"",
|
||||
"runtime:",
|
||||
" scripts:",
|
||||
" - type: after-response",
|
||||
" code: " + yaml_str_block(8, TOKEN_SCRIPT).lstrip(),
|
||||
]
|
||||
out += [
|
||||
"",
|
||||
"docs: " + yaml_str_block(0, (
|
||||
f"# {doc['title']}\n\n"
|
||||
f"`{doc['method']} {doc['path']}` — auth: {doc['auth']}.\n\n"
|
||||
f"Canonical reference: `docs/developer/api/{doc['rel']}` "
|
||||
f"(docs site → Developer → API). This request is GENERATED from "
|
||||
f"that doc; edit the doc, not this file."
|
||||
)),
|
||||
"",
|
||||
]
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
docs = []
|
||||
for path in sorted(glob.glob(f"{DOCS}/**/*.md", recursive=True)):
|
||||
d = parse_doc(path)
|
||||
if d:
|
||||
docs.append(d)
|
||||
print(f"{len(docs)} frontmatter'd endpoint docs")
|
||||
|
||||
# Plan writes
|
||||
writes = {}
|
||||
by_area = {}
|
||||
for d in docs:
|
||||
by_area.setdefault(d["area"], []).append(d)
|
||||
for area, ds in sorted(by_area.items()):
|
||||
ds.sort(key=lambda d: d["name"])
|
||||
for i, d in enumerate(ds, 1):
|
||||
writes[f"{BRUNO}/{area}/{d['name']}.yml"] = emit(d, i)
|
||||
|
||||
# Plan deletes: existing request ymls not regenerated (setup/env/root,
|
||||
# and hand-authored scenario suites kept)
|
||||
deletes = []
|
||||
for path in glob.glob(f"{BRUNO}/**/*.yml", recursive=True):
|
||||
rel = os.path.relpath(path, BRUNO)
|
||||
top = rel.split(os.sep)[0]
|
||||
if top in KEEP_DIRS or rel in ROOT_KEEP or os.path.basename(path) == "folder.yml":
|
||||
continue
|
||||
if any(s in path for s in KEEP_SUBSTRINGS):
|
||||
continue
|
||||
if path not in writes:
|
||||
deletes.append(path)
|
||||
|
||||
print(f"write/update {len(writes)}, delete {len(deletes)}")
|
||||
for p in sorted(deletes):
|
||||
print(" DEL", p)
|
||||
|
||||
if not apply:
|
||||
print("(dry run — pass --apply)")
|
||||
return
|
||||
import collections
|
||||
for path, content in writes.items():
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
open(path, "w").write(content)
|
||||
for p in deletes:
|
||||
os.remove(p)
|
||||
# prune empty dirs
|
||||
for root, dirs, files in os.walk(BRUNO, topdown=False):
|
||||
if root == BRUNO:
|
||||
continue
|
||||
if not files and not dirs and os.path.basename(root) not in KEEP_DIRS:
|
||||
os.rmdir(root)
|
||||
# extend env file with any new vars
|
||||
envp = f"{BRUNO}/environments/Bookhoard.yml"
|
||||
env = open(envp).read()
|
||||
add = [v for v in NEW_ENV_VARS if f"name: {v}\n" not in env]
|
||||
if add:
|
||||
block = "".join(f" - secret: true\n name: {v}\n" for v in add)
|
||||
env = env.replace("variables:\n", "variables:\n" + block, 1)
|
||||
open(envp, "w").write(env)
|
||||
print("env vars added:", ", ".join(add))
|
||||
print("applied.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user