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 }