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.
147 lines
4.1 KiB
Go
147 lines
4.1 KiB
Go
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
|
|
}
|