package docs import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "time" ) // 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 brunoDocBody(doc APIDoc) string { raw, err := os.ReadFile(filepath.Join(docsRoot, brunoRel(doc))) if err != nil { return "" } return string(raw) } func brunoDocTitle(doc APIDoc) string { for _, line := range strings.Split(brunoDocBody(doc), "\n") { if strings.HasPrefix(line, "# ") { return strings.TrimSpace(strings.TrimPrefix(line, "# ")) } } return doc.Frontmatter.Path } var brunoStatusRe = regexp.MustCompile(`(?:## Response \((\d{3})|Success is \*\*(\d{3})|\*\*(\d{3}) No Content)`) // brunoSuccessStatus extracts the documented happy-path status code from // the doc body ("## Response (201 Created)", "Success is **201**", // "**204 No Content**"). ok=false when the doc declares none — no assert // is emitted rather than guessing. func brunoSuccessStatus(doc APIDoc) (int, bool) { m := brunoStatusRe.FindStringSubmatch(brunoDocBody(doc)) if m == nil { return 0, false } for _, g := range m[1:] { if g != "" { n := 0 fmt.Sscanf(g, "%d", &n) return n, true } } return 0, false } // 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") scripts := "" if brunoScriptDocs[doc.Name()] { scripts += " - type: after-response\n code: " + yamlBlock(8, brunoTokenScript) } status, hasStatus := brunoSuccessStatus(doc) // GET is uniformly 200 across this API; mutating verbs vary // (201/202/204/200) and stay assert-less unless the doc declares one. if !hasStatus && doc.Frontmatter.Method == "GET" { status, hasStatus = 200, true } if hasStatus { if scripts != "" { scripts += "\n" } assert := fmt.Sprintf("test(\"status is %d\", function() {\n expect(res.status).to.eql(%d);\n});", status, status) scripts += " - type: tests\n code: " + yamlBlock(8, assert) } if scripts != "" { b.WriteString("\nruntime:\n scripts:\n" + scripts) } 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 } // updateOpenAPISpec writes docs/developer/openapi.yaml from the same doc // set: paths, tags (docs area), summaries, auth schemes, and request // examples. Lean by design — examples are literal, schemas stay out // (the docs site remains the shape reference). func updateOpenAPISpec(docs []APIDoc) error { type op struct { method string doc APIDoc } paths := map[string][]op{} for _, d := range docs { p := brunoParamRe.ReplaceAllStringFunc(d.Frontmatter.Path, func(p string) string { return "{" + strings.TrimPrefix(p, ":") + "}" }) paths[p] = append(paths[p], op{strings.ToLower(d.Frontmatter.Method), d}) } keys := make([]string, 0, len(paths)) for k := range paths { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder b.WriteString("openapi: 3.0.3\n") b.WriteString("info:\n title: Bookhoard API\n") fmt.Fprintf(&b, " version: '%s'\n", time.Now().Format("2006.01.02")) b.WriteString(" description: |\n Generated from the canonical API docs (docs/developer/api/**) by\n" + " internal/docs (go test ./internal/docs -run TestBrunoMirrorsDocs -update).\n" + " The docs site is the shape reference; this file lists the surface.\n") b.WriteString("servers:\n - url: /\n") b.WriteString("components:\n securitySchemes:\n bearerAuth:\n type: http\n scheme: bearer\n bearerFormat: JWT\n") b.WriteString("paths:\n") for _, p := range keys { fmt.Fprintf(&b, " %s:\n", mustYAMLString(p)) ops := paths[p] sort.Slice(ops, func(i, j int) bool { return ops[i].method < ops[j].method }) for _, o := range ops { fmt.Fprintf(&b, " %s:\n", o.method) fmt.Fprintf(&b, " tags: [%s]\n", o.doc.Frontmatter.Area) fmt.Fprintf(&b, " summary: %s\n", mustYAMLString(brunoDocTitle(o.doc))) switch o.doc.Frontmatter.Auth { case "none": b.WriteString(" security: []\n") case "device-token": b.WriteString(" description: 'Device token: Bearer header (KOReader) or ?token= query (Kobo/OPDS).'\n") default: b.WriteString(" security:\n - bearerAuth: []\n") } if len(o.doc.ExampleRequest) > 0 { b.WriteString(" requestBody:\n content:\n application/json:\n") b.WriteString(" example: " + yamlBlock(14, string(o.doc.ExampleRequest))) } status, ok := brunoSuccessStatus(o.doc) if !ok && o.doc.Frontmatter.Method == "GET" { status = 200 } if ok || o.doc.Frontmatter.Method == "GET" { desc := "OK" switch status { case 201: desc = "Created" case 202: desc = "Accepted" case 204: desc = "No Content" } fmt.Fprintf(&b, " responses:\n '%d':\n description: %s\n", status, desc) } else { b.WriteString(" responses:\n '200':\n description: Success\n") } } } full := filepath.Join(docsRoot, "..", "openapi.yaml") return os.WriteFile(full, []byte(b.String()), 0o644) } // mustYAMLString renders s as a safely-quoted YAML scalar (JSON string // syntax is valid YAML flow scalar syntax). func mustYAMLString(s string) string { out, err := json.Marshal(s) if err != nil { return `""` } return string(out) } // 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 }