Three follow-ups from the smoke run, all additive: 1. Status asserts: bruno_gen.go parses each doc's documented success status (## Response (2NN), 'Success is **201**', '**204 No Content**') and emits a runtime tests script (expect(res.status).to.eql(N)) on 148 of 191 mirrors. GETs default to 200 (uniform across this API); mutating verbs stay assert-less unless documented — no guessed asserts. Verified live: asserts execute and fail loudly (deliberate 999 probe), pass on real responses. 2. OpenAPI: the same -update run now also writes docs/developer/openapi.yaml (141 paths, tags from docs areas, security schemes per auth label, request examples literal, no invented schemas — the docs site remains the shape reference). Verify mode fails when the file is missing. 3. Scenarios: 7 safe negative scenarios rebuilt (auth registration matrix 409/403/400, invalid-uuid 400, unknown 404 x2, invalid range 400) — all no-mutation, repeatable, run via bru: 7/7 tests pass. These replace a fraction of the deleted broken suite; mutations- heavy scenarios stay out until a design for setup chains exists. created_at RFC3339 deliberately NOT done — it is the one item that would break things (Android parses current formats; plan reserves it as a compat decision).bru run of mutating mirrors also proven to touch live data (a 'To Read' collection was created and deleted in testing); only id-less GET mirrors and negative scenarios are safe for blanket runs. make test green; generation idempotent (byte-identical re-runs); no stray smoke rows in users.
387 lines
13 KiB
Go
387 lines
13 KiB
Go
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
|
|
}
|