bruno: replace Python generator with Go golden-writer in the drift test

The generator moves into the Go toolchain per repo preference:
internal/docs/bruno_gen.go emits the OpenCollection mirrors (same output
contract — gate-verified example-request bodies, env-var params,
device-token headers, login/register/refresh token-capture scripts) and
TestBrunoMirrorsDocs grows a -update flag: verification by default,
regeneration on demand:

  go test ./internal/docs -run TestBrunoMirrorsDocs -update

Generator and checker are now the same code — they cannot drift apart.
scripts/gen_bruno_from_docs.py deleted; opencollection.yml README points
at the go-test command.

Also fixed vs the Python pass: mirror placement follows the docs tree
folder (RelPath), not the area frontmatter (several docs — highlights,
notes, ratings, users, progress — carry a different area); docs blocks
emit the collection's mapping form (content + type), not a bare scalar;
JSON body sanity is checked at generation time.

Idempotent: back-to-back -update runs are byte-identical.
make test green (9 pkgs).
This commit is contained in:
John O'Keefe committed 2026-10-24 18:47:56 -04:00
1 parent acd82fff0a
commit cfcd95b25e
195 files changed
+1479 -1073

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
package docs
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// Bruno mirror generation. The docs tree is canonical; bruno/ holds the
// executable-verification layer, one OpenCollection YAML request per
// frontmatter'd endpoint doc. TestBrunoMirrorsDocs verifies parity on
// every run; with -update it (re)writes the mirrors from the docs and
// prunes mirrors whose docs are gone. Never hand-edit bruno request
// files — edit the doc and run:
//
// go test ./internal/docs -run TestBrunoMirrorsDocs -update
const brunoRoot = "../../bruno"
const docsRoot = "../../docs/developer/api"
// brunoKeepDir / brunoKeepSub name the hand-authored parts of the
// collection that generation must never touch or delete.
var brunoKeepDir = map[string]bool{"NewDevDBSetup": true, "environments": true}
var brunoKeepSub = []string{"/scenarios/", "/filters/", "/search/"}
// namedParams maps a named Echo path param to the Bruno environment
// variable that carries it (matched case-insensitively, longest name
// first by the caller).
var namedParams = map[string]string{
"registrationid": "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",
}
// idByContext resolves the generic :id by which resource the URL is
// about (checked in order).
var idByContext = []struct{ prefix, env string }{
{"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"},
}
// brunoScriptDocs get the after-response token-capture script that chains
// the collection (later requests need {{token}} / {{refresh_token}}).
var brunoScriptDocs = map[string]bool{"login": true, "register": true, "refresh_token": true}
const brunoTokenScript = `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);
}
}
onResponse();`
var brunoParamRe = regexp.MustCompile(`:[^/{}]+`)
func brunoEnvFor(path, param string) string {
key := strings.ToLower(strings.TrimPrefix(param, ":"))
if env, ok := namedParams[key]; ok {
return env
}
if key == "id" {
for _, ic := range idByContext {
if strings.Contains(path, "/api/"+ic.prefix) ||
strings.HasPrefix(strings.TrimPrefix(path, "/api/"), ic.prefix+"/") {
return ic.env
}
}
if strings.HasPrefix(path, "/api/sync/unlinked-books") {
return "unlinked_book_id"
}
return "media_item_id" // this API's :id is usually a media item
}
return key
}
// brunoURL renders the request URL: {{base_url}} prefix, params swapped
// for their environment variables.
func brunoURL(doc APIDoc) string {
path := brunoParamRe.ReplaceAllStringFunc(doc.Frontmatter.Path, func(p string) string {
return "{{" + brunoEnvFor(doc.Frontmatter.Path, p) + "}}"
})
return "{{base_url}}" + path
}
func brunoDocTitle(doc APIDoc) string {
raw, err := os.ReadFile(filepath.Join(docsRoot, brunoRel(doc)))
if err == nil {
for _, line := range strings.Split(string(raw), "\n") {
if strings.HasPrefix(line, "# ") {
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
}
}
}
return doc.Frontmatter.Path
}
// yamlBlock emits a literal block scalar at the given indent.
func yamlBlock(indent int, text string) string {
pad := strings.Repeat(" ", indent)
var b strings.Builder
b.WriteString("|-\n")
for _, line := range strings.Split(strings.TrimRight(text, "\n"), "\n") {
if line == "" {
b.WriteString("\n")
} else {
b.WriteString(pad + line + "\n")
}
}
return b.String()
}
// brunoYAML renders one request file in the OpenCollection format the
// collection already uses.
func brunoYAML(doc APIDoc, seq int) ([]byte, error) {
var b strings.Builder
fmt.Fprintf(&b, "info:\n name: %s\n type: http\n seq: %d\n\n", brunoDocTitle(doc), seq)
fmt.Fprintf(&b, "http:\n method: %s\n url: '%s'\n auth: inherit\n",
doc.Frontmatter.Method, brunoURL(doc))
if len(doc.ExampleRequest) > 0 {
var sanity map[string]interface{}
if err := json.Unmarshal(doc.ExampleRequest, &sanity); err != nil {
return nil, fmt.Errorf("%s: example-request is not valid JSON: %w", doc.RelPath, err)
}
b.WriteString(" body:\n type: json\n data: " + yamlBlock(6, string(doc.ExampleRequest)))
}
if doc.Frontmatter.Auth == "device-token" {
b.WriteString(" headers:\n - key: Authorization\n value: Bearer {{device_token}}\n")
}
b.WriteString("settings:\n encodeUrl: true\n timeout: 0\n followRedirects: true\n maxRedirects: 5\n")
if brunoScriptDocs[doc.Name()] {
b.WriteString("\nruntime:\n scripts:\n - type: after-response\n code: " +
yamlBlock(8, brunoTokenScript))
}
b.WriteString("\ndocs:\n content: " + yamlBlock(4, fmt.Sprintf(
"# %s\n\n`%s %s` — auth: %s.\n\nCanonical reference: docs/developer/api/%s (docs site → Developer → API). "+
"This request is GENERATED from that doc; edit the doc, then run the drift test with -update.",
brunoDocTitle(doc), doc.Frontmatter.Method, doc.Frontmatter.Path, doc.Frontmatter.Auth, brunoRel(doc))) +
" type: text/markdown\n")
return []byte(b.String()), nil
}
// Name is the doc filename without extension (login.md → login).
func (d APIDoc) Name() string {
return strings.TrimSuffix(filepath.Base(d.RelPath), ".md")
}
// updateBrunoMirrors rewrites every doc's Bruno mirror and deletes
// mirrors whose docs no longer exist. Returns the changed relpaths.
// brunoRel strips the docs-root prefix from a doc's RelPath, yielding the
// docs-tree-relative identity used for mirror placement ("area/file").
func brunoRel(doc APIDoc) string {
return strings.TrimPrefix(doc.RelPath, docsRoot+"/")
}
func updateBrunoMirrors(docs []APIDoc) (written, deleted []string, err error) {
byArea := map[string][]APIDoc{}
for _, d := range docs {
// Mirror placement follows the docs tree folder, not the area
// frontmatter — bruno/ mirrors docs/developer/api/ 1:1.
area := filepath.Dir(brunoRel(d))
if area == "." {
area = "misc"
}
byArea[area] = append(byArea[area], d)
}
areas := make([]string, 0, len(byArea))
for area := range byArea {
areas = append(areas, area)
}
sort.Strings(areas)
want := map[string]bool{}
for _, area := range areas {
ds := byArea[area]
sort.Slice(ds, func(i, j int) bool { return ds[i].Name() < ds[j].Name() })
for i, d := range ds {
rel := filepath.Join(area, d.Name()+".yml")
want[rel] = true
content, err := brunoYAML(d, i+1)
if err != nil {
return written, deleted, err
}
full := filepath.Join(brunoRoot, rel)
if old, err := os.ReadFile(full); err != nil || string(old) != string(content) {
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return written, deleted, err
}
if err := os.WriteFile(full, content, 0o644); err != nil {
return written, deleted, err
}
written = append(written, rel)
}
}
}
filepath.Walk(brunoRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".yml") {
return nil
}
rel, relErr := filepath.Rel(brunoRoot, path)
if relErr != nil {
return nil
}
top := strings.Split(rel, string(filepath.Separator))[0]
if brunoKeepDir[top] || rel == "opencollection.yml" || filepath.Base(path) == "folder.yml" {
return nil
}
for _, sub := range brunoKeepSub {
if strings.Contains(path, sub) {
return nil
}
}
if !want[rel] {
if os.Remove(path) == nil {
deleted = append(deleted, rel)
}
}
return nil
})
return written, deleted, nil
}