feat(docs): drift gate — docs frontmatter validated against real routes
Every API doc that declares frontmatter (method/path/auth/area) is now checked by a unit test against three sources of truth: 1. Route registration: internal/router/*.go is parsed the way echo resolves it (group prefixes concatenate; scanner.go's parameterized groups seeded). Doc paths that no longer exist fail. (This is the check that would have caught the /api/api/admin/hash-conflicts split.) 2. Auth: frontmatter auth label must match the middleware chain (none|jwt|jwt+admin|device-token|optional-jwt). 3. Shapes: internal/docs/schema.go registry — example-request decoded with unknown fields DISALLOWED (typo guard), required-by-validate fields must appear (staleness guard), example-response top-level key set compared both directions against the marshaled handler type (or a RawMessage pin for hand-built map responses). Five collections docs are the pilots with real handler shapes; notable corrections they pin: update returns 201 (not 200), list excludes smart collections unless include_auto=true, rule priority is 1-10 higher-wins (docs claimed 1=highest), created_at is Go time.String() not RFC3339, hide_finished/show_up_next toggles documented. CollectionResponse hoisted out of GetCollections/GetBookCollections as CollectionSummary / BookCollectionSummary so the registry can pin real types. The gate fails on missing registry entries by design — that is the maintenance contract. Known pre-existing (not this commit): vet copy-lock warnings in media.go.
This commit is contained in:
1 parent
994e0f86c4
commit
cc893f758e
8 files changed
+824
-168
No files matched your search
@@ -0,0 +1,146 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DocFrontmatter is the machine-readable identity header of an API doc page.
|
||||
// It is the contract the drift gate (drift_test.go) checks against the real
|
||||
// router registrations and the schema registry (schema.go).
|
||||
type DocFrontmatter struct {
|
||||
Method string // GET|POST|PUT|PATCH|DELETE
|
||||
Path string // Echo syntax, e.g. /api/collections/:id
|
||||
Auth string // none|jwt|jwt+admin|device-token|optional-jwt
|
||||
Area string // sidebar area, e.g. collections
|
||||
}
|
||||
|
||||
// APIDoc is one endpoint doc file: its frontmatter plus the labeled example
|
||||
// blocks extracted from the body.
|
||||
type APIDoc struct {
|
||||
RelPath string
|
||||
Frontmatter DocFrontmatter
|
||||
ExampleRequest []byte
|
||||
ExampleResponse []byte
|
||||
HasExampleResponse bool
|
||||
Unverified []string // UNVERIFIED markers found in the body
|
||||
}
|
||||
|
||||
// parseFrontmatter splits a leading "---" delimited block of flat key:value
|
||||
// lines off content. Recognized keys: method, path, auth, area. Returns the
|
||||
// frontmatter and the remaining body; hasFM is false when no well-formed
|
||||
// block is present (prose pages like overviews have none and are skipped by
|
||||
// the gate).
|
||||
func parseFrontmatter(content string) (fm DocFrontmatter, body string, hasFM bool) {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return fm, content, false
|
||||
}
|
||||
end := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if end == -1 {
|
||||
return fm, content, false
|
||||
}
|
||||
|
||||
for _, line := range lines[1:end] {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "method":
|
||||
fm.Method = strings.ToUpper(value)
|
||||
case "path":
|
||||
fm.Path = value
|
||||
case "auth":
|
||||
fm.Auth = strings.ToLower(value)
|
||||
case "area":
|
||||
fm.Area = strings.ToLower(value)
|
||||
}
|
||||
}
|
||||
if fm.Method == "" || fm.Path == "" {
|
||||
return fm, content, false // identity is the minimum for a gateable doc
|
||||
}
|
||||
return fm, strings.Join(lines[end+1:], "\n"), true
|
||||
}
|
||||
|
||||
// extractExampleBlocks pulls fenced code blocks tagged
|
||||
// "json example-request" / "json example-response" out of a doc body, and
|
||||
// counts UNVERIFIED markers.
|
||||
func extractExampleBlocks(body string, doc *APIDoc) {
|
||||
lines := strings.Split(body, "\n")
|
||||
for i := 0; i < len(lines); i++ {
|
||||
fence := strings.TrimSpace(lines[i])
|
||||
switch {
|
||||
case fence == "```json example-request":
|
||||
doc.ExampleRequest = extractUntilClose(lines, i+1)
|
||||
i = skipBlock(lines, i+1)
|
||||
case fence == "```json example-response":
|
||||
doc.ExampleResponse = extractUntilClose(lines, i+1)
|
||||
doc.HasExampleResponse = true
|
||||
i = skipBlock(lines, i+1)
|
||||
}
|
||||
if strings.Contains(lines[i], "UNVERIFIED:") {
|
||||
doc.Unverified = append(doc.Unverified, strings.TrimSpace(lines[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractUntilClose(lines []string, start int) []byte {
|
||||
var b strings.Builder
|
||||
for i := start; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "```" {
|
||||
break
|
||||
}
|
||||
b.WriteString(lines[i])
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
// skipBlock returns the index of the closing fence line so the outer loop
|
||||
// resumes after it.
|
||||
func skipBlock(lines []string, start int) int {
|
||||
for i := start; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "```" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return len(lines) - 1
|
||||
}
|
||||
|
||||
// LoadAPIDocs walks dir recursively and returns every markdown file carrying
|
||||
// well-formed endpoint frontmatter. Prose pages (overviews, contracts) are
|
||||
// skipped: the gate only checks docs that claim to describe an endpoint.
|
||||
func LoadAPIDocs(dir string) ([]APIDoc, error) {
|
||||
var docs []APIDoc
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".md") {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fm, body, hasFM := parseFrontmatter(string(raw))
|
||||
if !hasFM {
|
||||
return nil
|
||||
}
|
||||
doc := APIDoc{
|
||||
RelPath: path,
|
||||
Frontmatter: fm,
|
||||
}
|
||||
extractExampleBlocks(body, &doc)
|
||||
docs = append(docs, doc)
|
||||
return nil
|
||||
})
|
||||
return docs, err
|
||||
}
|
||||
Reference in new issue
Block a user