initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Development image for the Go backend. `docker-compose up` bind-mounts the
|
||||
# source and runs `go run main.go`, so this image only needs the toolchain.
|
||||
FROM golang:1.27-alpine
|
||||
|
||||
RUN apk add --no-cache git ca-certificates tzdata wget
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOFLAGS=-mod=mod
|
||||
|
||||
WORKDIR /app/backend
|
||||
|
||||
# Warm the module cache before the bind mount replaces the working directory.
|
||||
COPY go.mod ./
|
||||
RUN go mod download || true
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["go", "run", "main.go", "-addr", "0.0.0.0:8080"]
|
||||
95
backend/auth/auth.go
Normal file
95
backend/auth/auth.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"go-doc/go-loose/client"
|
||||
)
|
||||
|
||||
// User represents an authenticated user from go-loose
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
DisplayName string
|
||||
AvatarURL string
|
||||
TenantID string
|
||||
TenantSlug string
|
||||
ApplicationID string
|
||||
Application string
|
||||
Role string
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
func (u User) Can(permission string) bool {
|
||||
for _, granted := range u.Permissions {
|
||||
if granted == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (u User) IsAdmin() bool {
|
||||
return u.Role == "admin" || u.Role == "owner" || u.Can("console.read")
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey = contextKey("go-doc-user")
|
||||
|
||||
func WithUser(ctx context.Context, user User) context.Context {
|
||||
return context.WithValue(ctx, userContextKey, user)
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (User, bool) {
|
||||
user, ok := ctx.Value(userContextKey).(User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func TenantIDFromContext(ctx context.Context) (string, bool) {
|
||||
user, ok := UserFromContext(ctx)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return user.TenantID, true
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) (string, bool) {
|
||||
user, ok := UserFromContext(ctx)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return user.ID, true
|
||||
}
|
||||
|
||||
func ConvertUser(looseUser client.User) User {
|
||||
return User{
|
||||
ID: looseUser.ID,
|
||||
Email: looseUser.Email,
|
||||
DisplayName: looseUser.DisplayName,
|
||||
AvatarURL: looseUser.AvatarURL,
|
||||
TenantID: looseUser.TenantID,
|
||||
TenantSlug: looseUser.TenantSlug,
|
||||
ApplicationID: looseUser.ApplicationID,
|
||||
Application: looseUser.Application,
|
||||
Role: looseUser.Role,
|
||||
Permissions: looseUser.Permissions,
|
||||
}
|
||||
}
|
||||
|
||||
func BrowserAuthMiddleware(browserAuth *client.BrowserAuth) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if browserAuth == nil {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
browserAuth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if user, ok := client.UserFromContext(r.Context()); ok {
|
||||
r = r.WithContext(WithUser(r.Context(), ConvertUser(user)))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
728
backend/docgen/docgen.go
Normal file
728
backend/docgen/docgen.go
Normal file
|
|
@ -0,0 +1,728 @@
|
|||
// Package docgen analyses a Go project and renders audience-segmented
|
||||
// documentation for go-doc.
|
||||
//
|
||||
// The pipeline is deliberately mechanical: Analyze walks a project tree and
|
||||
// extracts facts that can be read off the filesystem (module metadata, HTTP
|
||||
// routes, CLI flags, environment variables, Make targets, container services,
|
||||
// Helm charts, existing Markdown, language breakdown), and Render turns those
|
||||
// facts into the same three-audience layout go-doc uses natively:
|
||||
//
|
||||
// ## For Users what the thing does and how to use it
|
||||
// ## For Operators running, configuring and deploying it
|
||||
// ## For Developers contracts, layout and dependencies
|
||||
//
|
||||
// Import wraps the rendered page in generated markers so re-running the tool
|
||||
// refreshes only the machine-written region and never clobbers prose a human
|
||||
// added outside it.
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxReadBytes caps how much of any single file is read into memory.
|
||||
const maxReadBytes = 2 << 20
|
||||
|
||||
// maxWalkFiles caps the walk so a mis-pointed root cannot spin forever.
|
||||
const maxWalkFiles = 200000
|
||||
|
||||
// Route is one HTTP endpoint discovered in the source.
|
||||
type Route struct {
|
||||
Method string
|
||||
Path string
|
||||
Handler string
|
||||
}
|
||||
|
||||
// String renders the route the way it is registered.
|
||||
func (r Route) String() string {
|
||||
if r.Method == "" {
|
||||
return r.Path
|
||||
}
|
||||
return r.Method + " " + r.Path
|
||||
}
|
||||
|
||||
// SortKey orders routes by path, then method, so output is stable.
|
||||
func (r Route) SortKey() string { return r.Path + "\x00" + r.Method + "\x00" + r.Handler }
|
||||
|
||||
// Flag is a command-line flag declared with the flag package.
|
||||
type Flag struct {
|
||||
Name string
|
||||
Type string
|
||||
Default string
|
||||
Usage string
|
||||
}
|
||||
|
||||
// EnvVar is an environment variable the code reads, plus a note when it was
|
||||
// discovered in a .env.example file rather than in source.
|
||||
type EnvVar struct {
|
||||
Name string
|
||||
Example string
|
||||
FromEnv bool
|
||||
}
|
||||
|
||||
// Dependency is one requirement line from a go.mod.
|
||||
type Dependency struct {
|
||||
Path string
|
||||
Version string
|
||||
Indirect bool
|
||||
}
|
||||
|
||||
// Module is one go.mod file found in the tree.
|
||||
type Module struct {
|
||||
Path string
|
||||
File string
|
||||
GoVersion string
|
||||
Direct []Dependency
|
||||
Indirect int
|
||||
}
|
||||
|
||||
// ComposeService is a service declared in a docker-compose file.
|
||||
type ComposeService struct {
|
||||
Name string
|
||||
Image string
|
||||
Ports string
|
||||
File string
|
||||
}
|
||||
|
||||
// DockerInfo summarises the project's Dockerfile.
|
||||
type DockerInfo struct {
|
||||
File string
|
||||
Stages []string
|
||||
Bases []string
|
||||
Exposed []string
|
||||
}
|
||||
|
||||
// DocFile is an existing Markdown document that ships with the project.
|
||||
type DocFile struct {
|
||||
Path string
|
||||
Title string
|
||||
Bytes int
|
||||
}
|
||||
|
||||
// Dir is a first-level source directory with a purpose inferred from its name.
|
||||
type Dir struct {
|
||||
Name string
|
||||
Files int
|
||||
Lines int
|
||||
}
|
||||
|
||||
// Language is a per-extension line count.
|
||||
type Language struct {
|
||||
Name string
|
||||
Files int
|
||||
Lines int
|
||||
}
|
||||
|
||||
// Stats are the headline counters shown in the generated page.
|
||||
type Stats struct {
|
||||
GoFiles int
|
||||
TestFiles int
|
||||
TestFuncs int
|
||||
Components int
|
||||
TotalLines int
|
||||
OldestCommit string
|
||||
}
|
||||
|
||||
// Project is the full analysis result for one repository.
|
||||
type Project struct {
|
||||
Name string
|
||||
Path string
|
||||
Summary string
|
||||
Readme string
|
||||
|
||||
Modules []Module
|
||||
Routes []Route
|
||||
Flags []Flag
|
||||
EnvVars []EnvVar
|
||||
MakeTargets []string
|
||||
Compose []ComposeService
|
||||
Docker *DockerInfo
|
||||
HelmCharts []string
|
||||
Docs []DocFile
|
||||
Dirs []Dir
|
||||
Languages []Language
|
||||
Ports []string
|
||||
Stats Stats
|
||||
|
||||
// Signature changes whenever any analysed input changes. Watch mode uses it
|
||||
// to decide whether a regeneration is needed.
|
||||
Signature string
|
||||
}
|
||||
|
||||
// Analyze walks root and extracts everything Render needs.
|
||||
//
|
||||
// A missing or partial project is not an error: a repository without a go.mod
|
||||
// or without a README still yields a usable page describing whatever was found.
|
||||
func Analyze(name, root string) (*Project, error) {
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgen: stat %s: %w", root, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("docgen: %s is not a directory", root)
|
||||
}
|
||||
|
||||
p := &Project{Name: name, Path: root}
|
||||
|
||||
files, err := collectFiles(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Signature = signature(files)
|
||||
|
||||
for _, file := range files {
|
||||
rel := file.rel
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(rel, ".go"):
|
||||
p.Stats.GoFiles++
|
||||
p.Stats.TotalLines += file.lines
|
||||
if strings.HasSuffix(rel, "_test.go") {
|
||||
p.Stats.TestFiles++
|
||||
p.Stats.TestFuncs += countTestFuncs(file.text())
|
||||
}
|
||||
if isComponentPath(rel) {
|
||||
p.Stats.Components++
|
||||
}
|
||||
analyzeGoFile(p, file)
|
||||
|
||||
case filepath.Base(rel) == "go.mod":
|
||||
p.Modules = append(p.Modules, parseGoMod(rel, file.text()))
|
||||
|
||||
case isMarkdown(rel):
|
||||
p.Docs = append(p.Docs, DocFile{
|
||||
Path: rel,
|
||||
Title: markdownTitle(file.text()),
|
||||
Bytes: int(file.size),
|
||||
})
|
||||
|
||||
case filepath.Base(rel) == "Makefile" || filepath.Base(rel) == "makefile":
|
||||
p.MakeTargets = append(p.MakeTargets, makeTargets(file.text())...)
|
||||
|
||||
case isDockerfile(rel):
|
||||
if p.Docker == nil {
|
||||
p.Docker = parseDockerfile(rel, file.text())
|
||||
}
|
||||
|
||||
case isComposeFile(rel):
|
||||
p.Compose = append(p.Compose, parseCompose(rel, file.text())...)
|
||||
|
||||
case strings.HasPrefix(filepath.Base(rel), ".env"):
|
||||
p.EnvVars = append(p.EnvVars, envFromExample(file.text())...)
|
||||
|
||||
case isHelmFile(rel):
|
||||
p.HelmCharts = append(p.HelmCharts, chartRoot(rel))
|
||||
}
|
||||
|
||||
p.Languages = addLanguage(p.Languages, file)
|
||||
if portBearingFiles(rel) {
|
||||
p.Ports = append(p.Ports, portsIn(file.text())...)
|
||||
}
|
||||
}
|
||||
|
||||
finish(p)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func finish(p *Project) {
|
||||
p.Readme = findReadme(p)
|
||||
p.Summary = summarize(p)
|
||||
|
||||
p.Modules = dedupeModules(p.Modules)
|
||||
p.Routes = dedupeRoutes(p.Routes)
|
||||
p.Flags = dedupeFlags(p.Flags)
|
||||
p.EnvVars = dedupeEnv(p.EnvVars)
|
||||
p.MakeTargets = dedupeStrings(p.MakeTargets)
|
||||
p.HelmCharts = dedupeStrings(p.HelmCharts)
|
||||
p.Ports = dedupeStrings(p.Ports)
|
||||
p.Dirs = topDirs(p)
|
||||
p.Languages = rankLanguages(p.Languages)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Walking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type scannedFile struct {
|
||||
path string
|
||||
rel string
|
||||
size int64
|
||||
lines int
|
||||
}
|
||||
|
||||
func (f scannedFile) text() string {
|
||||
data, err := readCapped(f.path, maxReadBytes)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// ignoredDirs never contain first-party source worth documenting.
|
||||
var ignoredDirs = map[string]struct{}{
|
||||
"node_modules": {}, ".git": {}, ".idea": {}, ".vscode": {}, "dist": {},
|
||||
"build": {}, "bin": {}, "vendor": {}, "coverage": {}, "tmp": {},
|
||||
".cache": {}, ".next": {}, "target": {}, "__pycache__": {}, ".venv": {},
|
||||
}
|
||||
|
||||
func collectFiles(root string) ([]scannedFile, error) {
|
||||
var files []scannedFile
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// An unreadable subtree must not abort the whole analysis.
|
||||
if entry != nil && entry.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(files) >= maxWalkFiles {
|
||||
return fs.SkipAll
|
||||
}
|
||||
|
||||
name := entry.Name()
|
||||
if entry.IsDir() {
|
||||
if _, skip := ignoredDirs[name]; skip && path != root {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !entry.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
if isNoise(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = name
|
||||
}
|
||||
files = append(files, scannedFile{
|
||||
path: path,
|
||||
rel: filepath.ToSlash(rel),
|
||||
size: info.Size(),
|
||||
lines: countLinesFast(path),
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docgen: walk %s: %w", root, err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// isNoise filters artefacts that would pollute the analysis.
|
||||
func isNoise(name string) bool {
|
||||
switch filepath.Ext(name) {
|
||||
case ".log", ".lock", ".sum", ".pem", ".crt", ".key", ".png", ".jpg", ".jpeg",
|
||||
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar",
|
||||
".gz", ".mp4", ".sql.gz", ".db", ".sqlite", ".pyc":
|
||||
return true
|
||||
}
|
||||
// Certificates and keys are frequently committed without extensions.
|
||||
switch {
|
||||
case strings.HasSuffix(name, ".crt"), strings.HasSuffix(name, ".key"):
|
||||
return true
|
||||
case strings.HasSuffix(name, "-chain.crt"), strings.HasSuffix(name, "-root-ca.crt"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isMarkdown(rel string) bool {
|
||||
return strings.EqualFold(filepath.Ext(rel), ".md")
|
||||
}
|
||||
|
||||
func isDockerfile(rel string) bool {
|
||||
base := filepath.Base(rel)
|
||||
return base == "Dockerfile" || strings.HasPrefix(base, "Dockerfile.")
|
||||
}
|
||||
|
||||
func isComposeFile(rel string) bool {
|
||||
base := strings.ToLower(filepath.Base(rel))
|
||||
if !strings.HasPrefix(base, "docker-compose") && base != "compose.yaml" && base != "compose.yml" {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml")
|
||||
}
|
||||
|
||||
func isHelmChart(rel string) bool {
|
||||
parts := strings.Split(rel, "/")
|
||||
for i, part := range parts {
|
||||
if part == "helm" || part == "charts" {
|
||||
// A chart is a directory that owns a values.yaml or Chart.yaml.
|
||||
if i+1 < len(parts) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// chartRoot collapses a chart file path to the directory that owns the chart,
|
||||
// so the page lists one entry per chart rather than one per template.
|
||||
func chartRoot(rel string) string {
|
||||
dir := path.Dir(rel)
|
||||
if dir == "." {
|
||||
return "./ (repository root)"
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func isComponentPath(rel string) bool {
|
||||
parts := strings.Split(rel, "/")
|
||||
if len(parts) < 2 {
|
||||
return false
|
||||
}
|
||||
switch parts[len(parts)-2] {
|
||||
case "internal", "pkg", "cmd", "api", "services", "web", "ui", "frontend", "backend":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func readCapped(path string, limit int64) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
return io.ReadAll(io.LimitReader(file, limit))
|
||||
}
|
||||
|
||||
func countLinesFast(path string) int {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Skip .md / .json / .lock style files where line counts are meaningless.
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".md", ".json", ".yaml", ".yml", ".txt", ".lock", ".css", ".html":
|
||||
return 0
|
||||
}
|
||||
|
||||
buf := make([]byte, 32<<10)
|
||||
lines := 0
|
||||
for {
|
||||
n, err := file.Read(buf)
|
||||
if n > 0 {
|
||||
for _, b := range buf[:n] {
|
||||
if b == '\n' {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// signature hashes the identity of every analysed file. It deliberately uses
|
||||
// size and mtime rather than content so watch mode can poll large trees cheaply.
|
||||
func signature(files []scannedFile) string {
|
||||
hash := sha256.New()
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file.path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(hash, "%s|%d|%d\n", file.rel, file.size, info.ModTime().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))[:32]
|
||||
}
|
||||
|
||||
func countTestFuncs(source string) int {
|
||||
return strings.Count(source, "func Test") + strings.Count(source, "func Fuzz") +
|
||||
strings.Count(source, "func Benchmark")
|
||||
}
|
||||
|
||||
// noiseExtensions are file types that carry no language signal: build
|
||||
// metadata, ignore lists and editor configuration all have extensions that would
|
||||
// otherwise show up as their own "language".
|
||||
var noiseExtensions = map[string]struct{}{
|
||||
"mod": {}, "sum": {}, "lock": {}, "gitignore": {}, "dockerignore": {},
|
||||
"helmignore": {}, "npmignore": {}, "eslintignore": {}, "gitattributes": {},
|
||||
"env": {},
|
||||
"editorconfig": {}, "prettierignore": {}, "example": {}, "template": {},
|
||||
}
|
||||
|
||||
func addLanguage(langs []Language, file scannedFile) []Language {
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.path), "."))
|
||||
if ext == "" {
|
||||
return langs
|
||||
}
|
||||
if _, skip := noiseExtensions[ext]; skip {
|
||||
return langs
|
||||
}
|
||||
name := languageName(ext)
|
||||
lines := file.lines
|
||||
if lines == 0 {
|
||||
lines = countAllLinesFast(file.path)
|
||||
}
|
||||
for i := range langs {
|
||||
if langs[i].Name == name {
|
||||
langs[i].Files++
|
||||
langs[i].Lines += lines
|
||||
return langs
|
||||
}
|
||||
}
|
||||
return append(langs, Language{Name: name, Files: 1, Lines: lines})
|
||||
}
|
||||
|
||||
func countAllLinesFast(path string) int {
|
||||
data, err := readCapped(path, maxReadBytes)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
lines := strings.Count(string(data), "\n")
|
||||
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
|
||||
lines++
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func languageName(ext string) string {
|
||||
switch ext {
|
||||
case "go":
|
||||
return "Go"
|
||||
case "js", "mjs", "cjs":
|
||||
return "JavaScript"
|
||||
case "jsx":
|
||||
return "JSX"
|
||||
case "ts", "tsx":
|
||||
return "TypeScript"
|
||||
case "css":
|
||||
return "CSS"
|
||||
case "html", "htm":
|
||||
return "HTML"
|
||||
case "sh", "bash":
|
||||
return "Shell"
|
||||
case "sql":
|
||||
return "SQL"
|
||||
case "yml", "yaml":
|
||||
return "YAML"
|
||||
case "json":
|
||||
return "JSON"
|
||||
case "md":
|
||||
return "Markdown"
|
||||
case "tf":
|
||||
return "Terraform"
|
||||
case "proto":
|
||||
return "Protobuf"
|
||||
case "tpl", "gotmpl":
|
||||
return "Templates"
|
||||
case "py":
|
||||
return "Python"
|
||||
default:
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
func rankLanguages(langs []Language) []Language {
|
||||
sort.Slice(langs, func(i, j int) bool {
|
||||
if langs[i].Lines == langs[j].Lines {
|
||||
return langs[i].Name < langs[j].Name
|
||||
}
|
||||
return langs[i].Lines > langs[j].Lines
|
||||
})
|
||||
// A page does not benefit from a tail of one-file languages.
|
||||
if len(langs) > maxLanguages {
|
||||
langs = langs[:maxLanguages]
|
||||
}
|
||||
return langs
|
||||
}
|
||||
|
||||
// maxLanguages caps the language table on a generated page.
|
||||
const maxLanguages = 10
|
||||
|
||||
func topDirs(p *Project) []Dir {
|
||||
byName := map[string]*Dir{}
|
||||
err := filepath.WalkDir(p.Path, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if path != p.Path {
|
||||
if _, skip := ignoredDirs[entry.Name()]; skip {
|
||||
return fs.SkipDir
|
||||
}
|
||||
// Dot directories hold tooling, not first-party source.
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(p.Path, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
dir := byName[parts[0]]
|
||||
if dir == nil {
|
||||
dir = &Dir{Name: parts[0]}
|
||||
byName[parts[0]] = dir
|
||||
}
|
||||
dir.Files++
|
||||
dir.Lines += countLinesFast(path)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dirs := make([]Dir, 0, len(byName))
|
||||
for _, dir := range byName {
|
||||
dirs = append(dirs, *dir)
|
||||
}
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
if dirs[i].Files == dirs[j].Files {
|
||||
return dirs[i].Name < dirs[j].Name
|
||||
}
|
||||
return dirs[i].Files > dirs[j].Files
|
||||
})
|
||||
if len(dirs) > 14 {
|
||||
dirs = dirs[:14]
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func dedupeStrings(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, value := range in {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeInOrder removes duplicates while preserving first-seen order, for lists
|
||||
// whose order is meaningful (Docker build stages, published ports).
|
||||
func dedupeInOrder(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, value := range in {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeModules(mods []Module) []Module {
|
||||
sort.Slice(mods, func(i, j int) bool { return mods[i].File < mods[j].File })
|
||||
return mods
|
||||
}
|
||||
|
||||
// dedupeRoutes collapses registrations that describe the same endpoint. The
|
||||
// key is method plus path, because one endpoint is often registered in several
|
||||
// places (a group helper plus the router); when several carry a handler name,
|
||||
// the one with a handler wins over the one without.
|
||||
func dedupeRoutes(routes []Route) []Route {
|
||||
best := make(map[string]Route, len(routes))
|
||||
for _, route := range routes {
|
||||
if route.Path == "" {
|
||||
continue
|
||||
}
|
||||
key := route.Method + " " + route.Path
|
||||
existing, seen := best[key]
|
||||
switch {
|
||||
case !seen:
|
||||
best[key] = route
|
||||
case existing.Handler == "" && route.Handler != "":
|
||||
best[key] = route
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]Route, 0, len(best))
|
||||
for _, route := range best {
|
||||
out = append(out, route)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Path == out[j].Path {
|
||||
return out[i].Method < out[j].Method
|
||||
}
|
||||
return out[i].Path < out[j].Path
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeFlags(flags []Flag) []Flag {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]Flag, 0, len(flags))
|
||||
for _, flag := range flags {
|
||||
if _, dup := seen[flag.Name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[flag.Name] = struct{}{}
|
||||
out = append(out, flag)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func dedupeEnv(vars []EnvVar) []EnvVar {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]EnvVar, 0, len(vars))
|
||||
for _, v := range vars {
|
||||
if _, dup := seen[v.Name]; dup {
|
||||
continue
|
||||
}
|
||||
seen[v.Name] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// Timestamp is used inside generated markers so a reader can tell how fresh a
|
||||
// machine-written region is.
|
||||
func Timestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") }
|
||||
621
backend/docgen/docgen_test.go
Normal file
621
backend/docgen/docgen_test.go
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fixture writes a small but representative project tree: all three route
|
||||
// registration styles, a module file, a Makefile, a Dockerfile, a compose file,
|
||||
// an .env, a README and one unrelated Markdown document.
|
||||
func fixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
root := t.TempDir()
|
||||
files := map[string]string{
|
||||
"README.md": "# Demo Service\n\nDemo is a service that answers questions about things. " +
|
||||
"It runs behind Go Loose and exposes a JSON API.\n\n" +
|
||||
"## Install\n\nNot part of the summary.\n",
|
||||
|
||||
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire (\n" +
|
||||
"\tgithub.com/go-chi/chi/v5 v5.2.3\n\tgithub.com/spf13/cobra v1.9.1\n" +
|
||||
"\tgolang.org/x/sync v0.14.0 // indirect\n)\n",
|
||||
|
||||
"Makefile": "build:\n\tgo build ./...\n\n.PHONY: build\n\ntest:\n\tgo test ./...\n",
|
||||
|
||||
"Dockerfile": "FROM golang:1.27-alpine AS builder\nRUN go build ./...\n\n" +
|
||||
"FROM alpine:3.20\nCOPY --from=builder /app/demo /demo\nEXPOSE 8080\n",
|
||||
|
||||
"docker-compose.yml": "services:\n api:\n image: demo:latest\n ports:\n" +
|
||||
" - \"8080:8080\"\n db:\n image: postgres:17\n environment:\n" +
|
||||
" POSTGRES_PASSWORD: secret\n",
|
||||
|
||||
".env.example": "# comment\nDEMO_HTTP_PORT=8080\nDEMO_API_KEY=\n",
|
||||
|
||||
"internal/server/routes.go": "package server\n\n" +
|
||||
"import (\n\t\"net/http\"\n\t\"os\"\n)\n\n" +
|
||||
"func Routes(mux *http.ServeMux, r chi.Router) {\n" +
|
||||
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
|
||||
"\tmux.HandleFunc(\"/plain\", plainHandler)\n" +
|
||||
"\tregister(r, http.MethodPost, \"/api/items\", createItem)\n" +
|
||||
"\tr.Get(\"/api/items/{id}\", getItem)\n" +
|
||||
"\t_ = os.Getenv(\"DEMO_RUNTIME_SECRET\")\n}\n",
|
||||
|
||||
"internal/server/routes_test.go": "package server\n\n" +
|
||||
"func TestRoutes(t *testing.T) {\n" +
|
||||
"\t_ = httptest.NewRequest(http.MethodGet, \"/api/jobs\", nil)\n}\n",
|
||||
|
||||
"cmd/demo/main.go": "package main\n\nimport \"flag\"\n\nfunc main() {\n" +
|
||||
"\tport := flag.String(\"port\", \"8080\", \"listen port\")\n" +
|
||||
"\tverbose := flag.Bool(\"verbose\", false, \"chatty output\")\n" +
|
||||
"\t_ = port\n\t_ = verbose\n}\n",
|
||||
|
||||
"docs/OPERATIONS.md": "# Operations\n\nHow to run the thing.\n",
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
path := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A directory that must never be walked.
|
||||
if err := os.MkdirAll(filepath.Join(root, "node_modules", "left-pad"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir node_modules: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "node_modules", "index.js"), []byte("x\n"), 0o644); err != nil {
|
||||
t.Fatalf("write node_modules file: %v", err)
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func analyzeFixture(t *testing.T) *Project {
|
||||
t.Helper()
|
||||
p, err := Analyze("demo", fixture(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAnalyzeExtractsModuleMetadata(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
if len(p.Modules) != 1 {
|
||||
t.Fatalf("want 1 module, got %d: %+v", len(p.Modules), p.Modules)
|
||||
}
|
||||
module := p.Modules[0]
|
||||
if module.Path != "github.com/danyel/demo" {
|
||||
t.Errorf("module path = %q", module.Path)
|
||||
}
|
||||
if module.GoVersion != "1.27.1" {
|
||||
t.Errorf("go version = %q", module.GoVersion)
|
||||
}
|
||||
if len(module.Direct) != 2 {
|
||||
t.Errorf("want 2 direct deps, got %d: %+v", len(module.Direct), module.Direct)
|
||||
}
|
||||
if module.Direct[0].Path != "github.com/go-chi/chi/v5" || module.Direct[0].Version != "v5.2.3" {
|
||||
t.Errorf("unexpected first dep: %+v", module.Direct[0])
|
||||
}
|
||||
if module.Indirect != 1 {
|
||||
t.Errorf("want 1 indirect dep, got %d", module.Indirect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeExtractsAllRouteStyles(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
want := map[string]string{
|
||||
"GET /healthz": "health",
|
||||
"/plain": "plainHandler",
|
||||
"POST /api/items": "createItem",
|
||||
"GET /api/items/{id}": "getItem",
|
||||
}
|
||||
for key, handler := range want {
|
||||
var found *Route
|
||||
for i := range p.Routes {
|
||||
if p.Routes[i].String() == key {
|
||||
found = &p.Routes[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Errorf("route %q not found; got %v", key, routeStrings(p.Routes))
|
||||
continue
|
||||
}
|
||||
if found.Handler != handler {
|
||||
t.Errorf("route %q handler = %q, want %q", key, found.Handler, handler)
|
||||
}
|
||||
}
|
||||
if len(p.Routes) != len(want) {
|
||||
t.Errorf("want %d routes, got %d: %v", len(want), len(p.Routes), routeStrings(p.Routes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIgnoresRoutesInTestFiles(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
for _, route := range p.Routes {
|
||||
if route.Path == "/api/jobs" {
|
||||
t.Fatalf("route from a _test.go file leaked into the analysis: %v", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeExtractsFlagsAndEnvironment(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
flags := map[string]Flag{}
|
||||
for _, flag := range p.Flags {
|
||||
flags[flag.Name] = flag
|
||||
}
|
||||
if flags["port"].Default != "8080" || flags["port"].Usage != "listen port" {
|
||||
t.Errorf("port flag = %+v", flags["port"])
|
||||
}
|
||||
if flags["verbose"].Type != "bool" {
|
||||
t.Errorf("verbose flag = %+v", flags["verbose"])
|
||||
}
|
||||
|
||||
env := map[string]EnvVar{}
|
||||
for _, v := range p.EnvVars {
|
||||
env[v.Name] = v
|
||||
}
|
||||
if env["DEMO_RUNTIME_SECRET"].FromEnv {
|
||||
t.Error("os.Getenv should not be reported as coming from a .env file")
|
||||
}
|
||||
if !env["DEMO_HTTP_PORT"].FromEnv || env["DEMO_HTTP_PORT"].Example != "8080" {
|
||||
t.Errorf("DEMO_HTTP_PORT = %+v", env["DEMO_HTTP_PORT"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeExtractsOperationalFacts(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
if got := p.MakeTargets; len(got) != 2 || got[0] != "build" || got[1] != "test" {
|
||||
t.Errorf("make targets = %v", got)
|
||||
}
|
||||
if p.Docker == nil {
|
||||
t.Fatal("no Dockerfile analysed")
|
||||
}
|
||||
if len(p.Docker.Bases) != 2 || p.Docker.Bases[0] != "golang:1.27-alpine" {
|
||||
t.Errorf("docker bases = %v", p.Docker.Bases)
|
||||
}
|
||||
if len(p.Docker.Exposed) != 1 || p.Docker.Exposed[0] != "8080" {
|
||||
t.Errorf("docker exposed = %v", p.Docker.Exposed)
|
||||
}
|
||||
if len(p.Compose) != 2 {
|
||||
t.Fatalf("compose services = %+v", p.Compose)
|
||||
}
|
||||
if p.Compose[0].Name != "api" || p.Compose[0].Image != "demo:latest" {
|
||||
t.Errorf("compose[0] = %+v", p.Compose[0])
|
||||
}
|
||||
if len(p.Ports) == 0 {
|
||||
t.Error("expected the compose file port to be picked up")
|
||||
}
|
||||
if p.Readme != "README.md" {
|
||||
t.Errorf("readme = %q", p.Readme)
|
||||
}
|
||||
if len(p.Docs) != 2 {
|
||||
t.Errorf("docs = %+v", p.Docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIgnoresVendoredTrees(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
for _, lang := range p.Languages {
|
||||
if lang.Name == "JavaScript" {
|
||||
t.Errorf("node_modules leaked into the language table: %+v", p.Languages)
|
||||
}
|
||||
}
|
||||
for _, dir := range p.Dirs {
|
||||
if dir.Name == "node_modules" {
|
||||
t.Errorf("node_modules leaked into the directory map: %+v", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryComesFromReadmeProse(t *testing.T) {
|
||||
p := analyzeFixture(t)
|
||||
|
||||
if !strings.HasPrefix(p.Summary, "Demo is a service") {
|
||||
t.Errorf("summary = %q", p.Summary)
|
||||
}
|
||||
if strings.Contains(p.Summary, "Install") {
|
||||
t.Errorf("summary leaked a later section: %q", p.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeWithoutReadmeFallsBackToFacts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p, err := Analyze("bare", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Analyze: %v", err)
|
||||
}
|
||||
if p.Summary == "" {
|
||||
t.Error("a project with no README still needs a summary")
|
||||
}
|
||||
if p.Stats.GoFiles != 1 {
|
||||
t.Errorf("go files = %d", p.Stats.GoFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignatureChangesOnlyWithTheTree(t *testing.T) {
|
||||
root := fixture(t)
|
||||
|
||||
first, err := Analyze("demo", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Analyze("demo", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Signature != again.Signature {
|
||||
t.Errorf("signature is not stable: %q vs %q", first.Signature, again.Signature)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := Analyze("demo", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.Signature == first.Signature {
|
||||
t.Error("signature did not change after a file was added")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRenderProducesAudienceSections(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []string{
|
||||
"# demo",
|
||||
"## For Users",
|
||||
"## For Operators",
|
||||
"## For Developers",
|
||||
"### HTTP API",
|
||||
"| Method | Path | Handler |",
|
||||
"| `GET` | `/healthz` | `health` |",
|
||||
"### Direct dependencies",
|
||||
"`github.com/go-chi/chi/v5` v5.2.3",
|
||||
"### Network surface",
|
||||
"`make build`",
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("rendered page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIsWrappedInMarkers(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(page, beginPrefix) {
|
||||
t.Errorf("page does not start with the opening marker:\n%s", firstLine(page))
|
||||
}
|
||||
if !strings.HasSuffix(page, EndMarker) {
|
||||
t.Errorf("page does not end with the closing marker:\n%s", lastLine(page))
|
||||
}
|
||||
if !FindBlock(page).Found {
|
||||
t.Error("FindBlock cannot locate the markers on a freshly rendered page")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderKeepsMarkdownSpacingSane(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
|
||||
// Trim the marker lines: they are not part of the Markdown body.
|
||||
body := page
|
||||
if block := FindBlock(page); block.Found {
|
||||
body = page[block.Body : block.End-len(EndMarker)]
|
||||
}
|
||||
|
||||
lines := strings.Split(body, "\n")
|
||||
for i := 0; i < len(lines)-1; i++ {
|
||||
if lines[i] == "" && lines[i+1] == "" {
|
||||
t.Errorf("blank line %d: two blank lines in a row", i+1)
|
||||
}
|
||||
if strings.HasPrefix(lines[i], "#") && lines[i+1] != "" {
|
||||
t.Errorf("heading on line %d is glued to %q", i+1, lines[i+1])
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(lines[0]), "<!--") {
|
||||
t.Errorf("body starts with a comment: %q", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIsDeterministic(t *testing.T) {
|
||||
root := fixture(t)
|
||||
first, err := Analyze("demo", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Analyze("demo", root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, err := Render(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Render(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The only permitted difference is the generation timestamp.
|
||||
if stampPattern.ReplaceAllString(a, "") != stampPattern.ReplaceAllString(b, "") {
|
||||
t.Error("Render is not deterministic for an unchanged tree")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMergeCreatesWhenNoPageExists(t *testing.T) {
|
||||
merged, status, err := Merge("", "generated body\n", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusCreated {
|
||||
t.Errorf("status = %q", status)
|
||||
}
|
||||
if merged != "generated body\n" {
|
||||
t.Errorf("merged = %q", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSkipsUnmarkedPages(t *testing.T) {
|
||||
const handWritten = "# demo\n\nWritten by a person.\n"
|
||||
|
||||
merged, status, err := Merge(handWritten, "generated\n", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusSkipped {
|
||||
t.Errorf("status = %q", status)
|
||||
}
|
||||
if merged != handWritten {
|
||||
t.Errorf("a hand-written page was modified: %q", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeForcesOverUnmarkedPages(t *testing.T) {
|
||||
merged, status, err := Merge("# demo\n\nWritten by a person.\n", "generated\n", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusUpdated {
|
||||
t.Errorf("status = %q", status)
|
||||
}
|
||||
if merged != "generated\n" {
|
||||
t.Errorf("merged = %q", merged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePreservesProseOutsideMarkers(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withFooter := page + "\n## House rules\n\nNever edit the generated part by hand.\n"
|
||||
|
||||
// A second render of the same tree with different content, as if the
|
||||
// project's README had been rewritten.
|
||||
block := FindBlock(withFooter)
|
||||
if !block.Found {
|
||||
t.Fatal("markers missing from the page under test")
|
||||
}
|
||||
updated := strings.Replace(page, "Demo is a service", "Demo is a service, renamed", 1)
|
||||
if updated == page {
|
||||
t.Fatal("test setup failed to change the generated content")
|
||||
}
|
||||
|
||||
merged, status, err := Merge(withFooter, updated, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusUpdated {
|
||||
t.Errorf("status = %q", status)
|
||||
}
|
||||
if !strings.Contains(merged, "Never edit the generated part by hand.") {
|
||||
t.Error("hand-written footer was dropped")
|
||||
}
|
||||
if !strings.Contains(merged, "Demo is a service, renamed") {
|
||||
t.Error("generated region was not replaced")
|
||||
}
|
||||
if got := strings.Count(merged, EndMarker); got != 1 {
|
||||
t.Errorf("want exactly 1 closing marker, got %d", got)
|
||||
}
|
||||
if got := strings.Count(merged, beginPrefix); got != 1 {
|
||||
t.Errorf("want exactly 1 opening marker, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeReportsUnchangedAndKeepsTheFileStable(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Storage normalises the trailing newline, so the on-disk page has one.
|
||||
onDisk := page + "\n"
|
||||
|
||||
merged, status, err := Merge(onDisk, page, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusUnchanged {
|
||||
t.Errorf("status = %q, want unchanged", status)
|
||||
}
|
||||
if merged != onDisk {
|
||||
t.Error("an unchanged sync must return the file untouched, timestamp included")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBlockIgnoresMalformedMarkers(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"no markers at all": "# demo\n\nBody.\n",
|
||||
"only an opening marker": beginPrefix + " source=demo -->\n\nBody.\n",
|
||||
"only a closing marker": "Body.\n" + EndMarker + "\n",
|
||||
"marker not at line start": "text " + beginPrefix + " -->\nBody.\n" + EndMarker + "\n",
|
||||
"markers out of order": EndMarker + "\n" + beginPrefix + " -->\n",
|
||||
}
|
||||
|
||||
for name, content := range tests {
|
||||
if block := FindBlock(content); block.Found {
|
||||
t.Errorf("%s: expected no block, got %+v", name, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindBlockReportsBoundaries(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
block := FindBlock(page)
|
||||
if !block.Found {
|
||||
t.Fatal("block not found")
|
||||
}
|
||||
if block.Marker != 0 {
|
||||
t.Errorf("marker offset = %d, want 0", block.Marker)
|
||||
}
|
||||
if !strings.HasPrefix(page[block.Body:], "# demo") {
|
||||
t.Errorf("body does not start after the opening marker line: %q", page[block.Body:block.Body+8])
|
||||
}
|
||||
if got := page[block.End:]; got != "" {
|
||||
t.Errorf("bytes after the block = %q, want none", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapNormalisesTrailingNewlines(t *testing.T) {
|
||||
page := Wrap("demo", "sig", "body\n\n\n")
|
||||
if strings.HasSuffix(page, "\n") {
|
||||
t.Error("Wrap must not end with a newline")
|
||||
}
|
||||
if !strings.HasSuffix(page, "\n"+EndMarker) {
|
||||
t.Errorf("the closing marker must start its own line: %q", lastLine(page))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func routeStrings(routes []Route) []string {
|
||||
out := make([]string, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
out = append(out, route.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func lastLine(s string) string {
|
||||
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
|
||||
return s[i+1:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// TestMergeIgnoresAMinuteOldTimestamp is the regression test for a bug that
|
||||
// only showed up once a minute: two renders of an unchanged project carry
|
||||
// different "generated=" stamps, and a pattern that stopped at the first space
|
||||
// left "15:04 UTC" in place, so every sync in a new minute rewrote all four
|
||||
// pages and reported "updated" for content that had not changed at all.
|
||||
func TestMergeIgnoresAMinuteOldTimestamp(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate a page imported an hour ago, down to the stamp.
|
||||
stale := strings.Replace(page, "generated="+Timestamp(), "generated=2001-01-01 00:00 UTC", 1)
|
||||
if stale == page {
|
||||
t.Fatal("could not age the timestamp; the marker format changed")
|
||||
}
|
||||
|
||||
merged, status, err := Merge(stale, page, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusUnchanged {
|
||||
t.Errorf("status = %q, want unchanged for an hour-old page with identical content", status)
|
||||
}
|
||||
if merged != stale {
|
||||
t.Error("an unchanged sync must return the file untouched, timestamp included")
|
||||
}
|
||||
|
||||
// A genuine content change must still be reported, even with an old stamp.
|
||||
edited := strings.Replace(stale, "# demo", "# demo, revised", 1)
|
||||
if edited == stale {
|
||||
t.Fatal("could not edit the title")
|
||||
}
|
||||
merged, status, err = Merge(stale, edited, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != StatusUpdated {
|
||||
t.Errorf("status = %q, want updated for a real content change", status)
|
||||
}
|
||||
if !strings.Contains(merged, "# demo, revised") {
|
||||
t.Error("the edit did not reach the merged page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapStampIsFullyStripped guards the pattern itself: every byte of the
|
||||
// timestamp has to be removed, not just the date.
|
||||
func TestWrapStampIsFullyStripped(t *testing.T) {
|
||||
page, err := Render(analyzeFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stripped := stampPattern.ReplaceAllString(page, stampReplacement)
|
||||
if strings.Contains(stripped, " UTC") {
|
||||
t.Errorf("the timestamp was not fully stripped: %q", firstLineOf(stripped))
|
||||
}
|
||||
if strings.Count(stripped, "-->") != strings.Count(page, "-->") {
|
||||
t.Error("stripping the stamp swallowed more than the timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func firstLineOf(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
560
backend/docgen/extract.go
Normal file
560
backend/docgen/extract.go
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Go source extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
// net/http style registrations, including the Go 1.22 method-prefixed form
|
||||
// mux.HandleFunc("GET /health", h). The literal is captured whole and
|
||||
// validated in parseRoute, which is where the method prefix is split off.
|
||||
routeHandleCall = regexp.MustCompile(`\b(?:Handle|HandleFunc)\(\s*"([^"]+)"\s*(?:,\s*([A-Za-z0-9_.]+))?`)
|
||||
|
||||
// Route tables that pass the method as an http.MethodXxx constant, as
|
||||
// go-guess does: register(router, spec, false, http.MethodGet, "/api/health", h)
|
||||
routeMethodConst = regexp.MustCompile(`\bhttp\.Method([A-Za-z]+)\s*,\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
|
||||
|
||||
// Router-value registrations: r.Get("/api/x", h.X)
|
||||
routeRouterCall = regexp.MustCompile(`\b\w+\.(Get|Post|Put|Patch|Delete|Head|Options|Trace)\(\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
|
||||
|
||||
// "GET /api/v1/x" as a literal argument.
|
||||
methodPrefix = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S.*)$`)
|
||||
|
||||
// flag.String("name", default, "usage")
|
||||
flagCall = regexp.MustCompile(`flag\.(\w+)\(\s*"([^"]+)"\s*(?:,\s*([^,)]+))?\s*(?:,\s*"([^"]*)")?`)
|
||||
|
||||
// os.Getenv("X") / os.LookupEnv("X")
|
||||
envCall = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*"([A-Z][A-Z0-9_]*)"`)
|
||||
|
||||
// Port literals anywhere in the project.
|
||||
portLiteral = regexp.MustCompile(`(?:\bPORT\b|\bport\b|EXPOSE)[^0-9]{0,20}([0-9]{2,5})\b`)
|
||||
|
||||
testFuncDecl = regexp.MustCompile(`(?m)^func\s+(Test|Benchmark|Fuzz)\w*\(`)
|
||||
)
|
||||
|
||||
// analyzeGoFile pulls routes, flags and environment variables out of one source
|
||||
// file. Each extractor is independent: a syntax oddity in one never affects the
|
||||
// others because these are textual scans, not a type-checked AST walk.
|
||||
//
|
||||
// Test files are excluded. They build requests with httptest.NewRequest, which
|
||||
// looks exactly like a route registration, and a route table is never declared
|
||||
// in a _test.go file.
|
||||
func analyzeGoFile(p *Project, file scannedFile) {
|
||||
if strings.HasSuffix(file.rel, "_test.go") {
|
||||
return
|
||||
}
|
||||
|
||||
source := file.text()
|
||||
if source == "" {
|
||||
return
|
||||
}
|
||||
|
||||
for _, match := range routeHandleCall.FindAllStringSubmatch(source, -1) {
|
||||
if route := parseRoute(match[1], match[2]); route.Path != "" {
|
||||
p.Routes = append(p.Routes, route)
|
||||
}
|
||||
}
|
||||
for _, match := range routeMethodConst.FindAllStringSubmatch(source, -1) {
|
||||
p.Routes = append(p.Routes, Route{
|
||||
Method: strings.ToUpper(match[1]),
|
||||
Path: match[2],
|
||||
Handler: handlerName(match[3]),
|
||||
})
|
||||
}
|
||||
for _, match := range routeRouterCall.FindAllStringSubmatch(source, -1) {
|
||||
p.Routes = append(p.Routes, Route{
|
||||
Method: strings.ToUpper(match[1]),
|
||||
Path: match[2],
|
||||
Handler: handlerName(match[3]),
|
||||
})
|
||||
}
|
||||
|
||||
for _, match := range flagCall.FindAllStringSubmatch(source, -1) {
|
||||
p.Flags = append(p.Flags, Flag{
|
||||
Name: match[2],
|
||||
Type: strings.ToLower(match[1]),
|
||||
Default: unquote(strings.TrimSpace(match[3])),
|
||||
Usage: strings.TrimSpace(match[4]),
|
||||
})
|
||||
}
|
||||
|
||||
for _, match := range envCall.FindAllStringSubmatch(source, -1) {
|
||||
p.EnvVars = append(p.EnvVars, EnvVar{Name: match[1]})
|
||||
}
|
||||
}
|
||||
|
||||
// parseRoute splits an optional method prefix and normalises the path. A
|
||||
// literal that is not a route — a query key, a header name, a MIME type — is
|
||||
// rejected by requiring the path to look like one.
|
||||
func parseRoute(raw, handler string) Route {
|
||||
method, path := "", strings.TrimSpace(raw)
|
||||
if match := methodPrefix.FindStringSubmatch(raw); match != nil {
|
||||
method, path = match[1], strings.TrimSpace(match[2])
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
return Route{}
|
||||
}
|
||||
return Route{Method: method, Path: path, Handler: handlerName(handler)}
|
||||
}
|
||||
|
||||
// handlerNames that are never useful in a route table: they are literal
|
||||
// arguments, receivers or framework values rather than handler functions.
|
||||
var noiseHandlers = map[string]struct{}{
|
||||
"nil": {}, "true": {}, "false": {}, "spec": {}, "router": {}, "mux": {},
|
||||
"r": {}, "g": {}, "chi": {}, "group": {}, "protected": {}, "public": {},
|
||||
"api": {}, "v1": {}, "sub": {}, "app": {}, "server": {}, "srv": {},
|
||||
"ctx": {}, "c": {}, "w": {}, "req": {}, "request": {}, "http": {},
|
||||
}
|
||||
|
||||
// handlerName reduces a handler expression to a readable name, dropping the
|
||||
// receiver and rejecting anything that is not plainly a function reference.
|
||||
func handlerName(handler string) string {
|
||||
handler = strings.TrimSpace(handler)
|
||||
if handler == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.LastIndex(handler, "."); i >= 0 {
|
||||
handler = handler[i+1:]
|
||||
}
|
||||
handler = strings.TrimSuffix(handler, ".func1")
|
||||
if _, noise := noiseHandlers[handler]; noise {
|
||||
return ""
|
||||
}
|
||||
if handler == "HandlerFunc" || strings.Contains(handler, "(") {
|
||||
return ""
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// go.mod
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
modLine = regexp.MustCompile(`^module\s+(\S+)`)
|
||||
goVersionLine = regexp.MustCompile(`^go\s+(\S+)`)
|
||||
requireLine = regexp.MustCompile(`^\s*(\S+)\s+(v\S+)(.*)$`)
|
||||
)
|
||||
|
||||
// parseGoMod reads a go.mod outside any require block.
|
||||
func parseGoMod(rel, source string) Module {
|
||||
module := Module{File: rel}
|
||||
inBlock := false
|
||||
|
||||
for _, raw := range strings.Split(source, "\n") {
|
||||
line := strings.TrimRight(raw, " \t")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "module "):
|
||||
if module.Path == "" {
|
||||
module.Path = strings.TrimSpace(strings.TrimPrefix(trimmed, "module "))
|
||||
}
|
||||
case strings.HasPrefix(trimmed, "go "):
|
||||
if module.GoVersion == "" {
|
||||
if match := goVersionLine.FindStringSubmatch(trimmed); match != nil {
|
||||
module.GoVersion = match[1]
|
||||
}
|
||||
}
|
||||
case trimmed == "require (":
|
||||
inBlock = true
|
||||
case inBlock && trimmed == ")":
|
||||
inBlock = false
|
||||
case inBlock && trimmed != "" && !strings.HasPrefix(trimmed, "//"):
|
||||
match := requireLine.FindStringSubmatch(line)
|
||||
if match != nil {
|
||||
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
|
||||
}
|
||||
case strings.HasPrefix(trimmed, "require ") && !inBlock:
|
||||
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "require "))
|
||||
if match := requireLine.FindStringSubmatch(" " + rest); match != nil {
|
||||
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return module
|
||||
}
|
||||
|
||||
func addDependency(module *Module, path, version string, indirect bool) {
|
||||
if path == "" || strings.HasPrefix(path, "//") {
|
||||
return
|
||||
}
|
||||
if indirect {
|
||||
module.Indirect++
|
||||
return
|
||||
}
|
||||
module.Direct = append(module.Direct, Dependency{Path: path, Version: version})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Makefile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var makeTargetLine = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*):`)
|
||||
|
||||
// makeTargets extracts invocable targets, skipping .PHONY declarations and
|
||||
// variable assignments.
|
||||
func makeTargets(source string) []string {
|
||||
var targets []string
|
||||
phony := map[string]struct{}{}
|
||||
|
||||
for _, raw := range strings.Split(source, "\n") {
|
||||
if strings.HasPrefix(raw, "\t") || strings.HasPrefix(raw, " ") {
|
||||
continue
|
||||
}
|
||||
line := strings.TrimRight(raw, " \t")
|
||||
if strings.HasPrefix(line, ".PHONY") {
|
||||
for _, name := range strings.Fields(line) {
|
||||
if name == ".PHONY:" || name == ".PHONY" {
|
||||
continue
|
||||
}
|
||||
phony[strings.TrimSuffix(name, ":")] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
match := makeTargetLine.FindStringSubmatch(line)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
name := match[1]
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if _, isPhony := phony[name]; isPhony {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, name)
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Docker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
dockerFrom = regexp.MustCompile(`(?mi)^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?`)
|
||||
dockerExp = regexp.MustCompile(`(?mi)^\s*EXPOSE\s+(\S+)`)
|
||||
)
|
||||
|
||||
func parseDockerfile(rel, source string) *DockerInfo {
|
||||
info := &DockerInfo{File: rel}
|
||||
for _, match := range dockerFrom.FindAllStringSubmatch(source, -1) {
|
||||
info.Bases = append(info.Bases, match[1])
|
||||
if match[2] != "" {
|
||||
info.Stages = append(info.Stages, match[2])
|
||||
}
|
||||
}
|
||||
for _, match := range dockerExp.FindAllStringSubmatch(source, -1) {
|
||||
info.Exposed = append(info.Exposed, match[1])
|
||||
}
|
||||
// Order matters: the first FROM is the build stage and the last is the
|
||||
// runtime image, so these lists are deduplicated but never sorted.
|
||||
info.Bases = dedupeInOrder(info.Bases)
|
||||
info.Stages = dedupeInOrder(info.Stages)
|
||||
info.Exposed = dedupeInOrder(info.Exposed)
|
||||
return info
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// docker-compose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// parseCompose reads service names, images and published ports out of a compose
|
||||
// file. It is a line scanner rather than a YAML parser: compose files in these
|
||||
// projects are consistently indented two spaces per level, and avoiding a YAML
|
||||
// dependency keeps the module dependency-free.
|
||||
func parseCompose(rel, source string) []ComposeService {
|
||||
var (
|
||||
services []ComposeService
|
||||
inBlock bool
|
||||
current int = -1
|
||||
)
|
||||
|
||||
for _, raw := range strings.Split(source, "\n") {
|
||||
line := strings.TrimRight(raw, " \t")
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
indent := len(line) - len(strings.TrimLeft(line, " "))
|
||||
|
||||
if indent == 0 {
|
||||
inBlock = trimmed == "services:"
|
||||
current = -1
|
||||
continue
|
||||
}
|
||||
if !inBlock {
|
||||
continue
|
||||
}
|
||||
|
||||
if indent == 2 && strings.HasSuffix(trimmed, ":") {
|
||||
services = append(services, ComposeService{Name: strings.TrimSuffix(trimmed, ":"), File: rel})
|
||||
current = len(services) - 1
|
||||
continue
|
||||
}
|
||||
if current < 0 || indent != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
key, value, found := strings.Cut(trimmed, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "image":
|
||||
services[current].Image = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
case "ports":
|
||||
if services[current].Ports == "" {
|
||||
services[current].Ports = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
} else {
|
||||
services[current].Ports += ", " + strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the well-known internal networks and volume declarations that the
|
||||
// indentation scanner otherwise mistakes for services.
|
||||
names := map[string]struct{}{}
|
||||
for _, name := range []string{
|
||||
"networks", "volumes", "configs", "secrets", "services",
|
||||
} {
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
|
||||
out := services[:0]
|
||||
for _, service := range services {
|
||||
if _, skip := names[service.Name]; skip {
|
||||
continue
|
||||
}
|
||||
out = append(out, service)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// .env files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// unquote strips the quoting Go string literals carry around literal values.
|
||||
func unquote(value string) string {
|
||||
if len(value) >= 2 {
|
||||
if (value[0] == '"' && value[len(value)-1] == '"') ||
|
||||
(value[0] == '`' && value[len(value)-1] == '`') {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// envFromExample reads KEY=VALUE pairs out of a .env or .env.example file.
|
||||
func envFromExample(source string) []EnvVar {
|
||||
var out []EnvVar
|
||||
for _, raw := range strings.Split(source, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || !strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||
continue
|
||||
}
|
||||
out = append(out, EnvVar{Name: key, Example: strings.Trim(strings.TrimSpace(value), `"'`), FromEnv: true})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Misc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// portsIn extracts plausible TCP port literals from any file.
|
||||
func portsIn(source string) []string {
|
||||
if source == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, match := range portLiteral.FindAllStringSubmatch(source, -1) {
|
||||
port, err := strconv.Atoi(match[1])
|
||||
if err != nil || port < 1024 || port > 65535 {
|
||||
continue
|
||||
}
|
||||
out = append(out, match[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// portBearingFiles reports whether a file is a plausible source of a declared
|
||||
// port. Scanning every source file for port literals produces noise (version
|
||||
// numbers, row counts), so only operational files are considered.
|
||||
func portBearingFiles(rel string) bool {
|
||||
base := strings.ToLower(filepath.Base(rel))
|
||||
switch {
|
||||
case isDockerfile(rel), isComposeFile(rel), isHelmFile(rel):
|
||||
return true
|
||||
case base == "makefile", base == "makefile.am":
|
||||
return true
|
||||
case base == "readme.md":
|
||||
return true
|
||||
case strings.HasPrefix(base, ".env"):
|
||||
return true
|
||||
case strings.HasSuffix(base, ".service"), strings.HasSuffix(base, ".env.example"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isHelmFile reports whether a path is part of a Helm chart, anchored on the two
|
||||
// files that define one.
|
||||
func isHelmFile(rel string) bool {
|
||||
base := strings.ToLower(filepath.Base(rel))
|
||||
return base == "chart.yaml" || base == "values.yaml"
|
||||
}
|
||||
|
||||
// markdownTitle returns the first heading of a Markdown document.
|
||||
func markdownTitle(source string) string {
|
||||
for _, raw := range strings.Split(source, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(line, "#") {
|
||||
return strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||
}
|
||||
if line != "" {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findReadme locates the project's README, preferring the repository root.
|
||||
func findReadme(p *Project) string {
|
||||
for _, candidate := range []string{"README.md", "readme.md", "README.markdown", "Readme.md"} {
|
||||
if info, err := os.Stat(filepath.Join(p.Path, candidate)); err == nil && !info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
for _, doc := range p.Docs {
|
||||
if strings.EqualFold(filepath.Base(doc.Path), "readme.md") {
|
||||
return doc.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// summarize produces the one-paragraph preamble shown above the audience tabs.
|
||||
// It prefers the README's opening prose and falls back to a description derived
|
||||
// from what the analyser found, so a project without a README still gets one.
|
||||
func summarize(p *Project) string {
|
||||
if p.Readme != "" {
|
||||
if data, err := readCapped(p.Path+"/"+p.Readme, maxReadBytes); err == nil {
|
||||
if summary := firstParagraph(string(data)); summary != "" {
|
||||
return collapse(summary, 460)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
if module := primaryModule(p); module != "" {
|
||||
parts = append(parts, "Go module `"+module+"`")
|
||||
}
|
||||
if p.Stats.GoFiles > 0 {
|
||||
parts = append(parts, plural(p.Stats.GoFiles, "Go source file", "Go source files"))
|
||||
}
|
||||
if len(p.Routes) > 0 {
|
||||
parts = append(parts, plural(len(p.Routes), "HTTP endpoint", "HTTP endpoints"))
|
||||
}
|
||||
if len(p.EnvVars) > 0 {
|
||||
parts = append(parts, plural(len(p.EnvVars), "environment variable", "environment variables"))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "This project has no README yet. The sections below were derived from its structure on disk."
|
||||
}
|
||||
return "This project has no README yet. The analyser found " + joinList(parts) + "."
|
||||
}
|
||||
|
||||
// firstParagraph returns the opening prose of a document: the first run of
|
||||
// non-empty lines that is not a heading, badge, table or HTML block.
|
||||
func firstParagraph(source string) string {
|
||||
var lines []string
|
||||
|
||||
for _, raw := range strings.Split(source, "\n") {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
|
||||
if strings.HasPrefix(trimmed, "```") {
|
||||
break // prose never starts after a code fence in these READMEs
|
||||
}
|
||||
switch {
|
||||
case trimmed == "":
|
||||
if len(lines) > 0 {
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
case strings.HasPrefix(trimmed, "#"):
|
||||
continue
|
||||
case strings.HasPrefix(trimmed, "!"), strings.HasPrefix(trimmed, "[!"),
|
||||
strings.HasPrefix(trimmed, "<"), strings.HasPrefix(trimmed, "|"):
|
||||
continue // badge, HTML or table row
|
||||
default:
|
||||
lines = append(lines, trimmed)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, " ")
|
||||
}
|
||||
|
||||
// collapse folds whitespace and truncates on a word boundary.
|
||||
func collapse(text string, limit int) string {
|
||||
fields := strings.Fields(text)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := strings.Join(fields, " ")
|
||||
if len(out) <= limit {
|
||||
return out
|
||||
}
|
||||
cut := out[:limit]
|
||||
if i := strings.LastIndexByte(cut, ' '); i > limit/2 {
|
||||
cut = cut[:i]
|
||||
}
|
||||
return strings.TrimRight(cut, " ,;:.") + "…"
|
||||
}
|
||||
|
||||
func primaryModule(p *Project) string {
|
||||
for _, module := range p.Modules {
|
||||
if module.Path != "" {
|
||||
return module.Path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func plural(n int, singular, many string) string {
|
||||
if n == 1 {
|
||||
return "1 " + singular
|
||||
}
|
||||
return strconv.Itoa(n) + " " + many
|
||||
}
|
||||
|
||||
func joinList(parts []string) string {
|
||||
switch len(parts) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return parts[0]
|
||||
case 2:
|
||||
return parts[0] + " and " + parts[1]
|
||||
default:
|
||||
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
143
backend/docgen/merge.go
Normal file
143
backend/docgen/merge.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// beginPrefix opens the machine-written region of an imported page. Everything
|
||||
// between begin and EndMarker is replaced on every sync; anything outside it is
|
||||
// preserved exactly as written.
|
||||
const beginPrefix = "<!-- go-doc:generated:begin"
|
||||
|
||||
// EndMarker closes the machine-written region.
|
||||
const EndMarker = "<!-- go-doc:generated:end -->"
|
||||
|
||||
// stampPattern matches the generated=<time> field of the opening marker. It is
|
||||
// stripped before comparing an existing page with a freshly rendered one, because
|
||||
// the timestamp changes on every run and would otherwise make every sync look
|
||||
// like a real edit.
|
||||
//
|
||||
// The field has to be matched all the way to the "-->" that closes the comment:
|
||||
// Timestamp writes "2006-01-02 15:04 UTC", so a pattern that stopped at the
|
||||
// first space would leave the time behind and every sync that crossed a minute
|
||||
// boundary would rewrite all four pages for nothing.
|
||||
var stampPattern = regexp.MustCompile(`generated=[^\n]*?-->`)
|
||||
|
||||
// stampReplacement stands in for the stripped timestamp on both sides of the
|
||||
// comparison, so an old page and a fresh render still compare equal.
|
||||
const stampReplacement = "generated=STAMP-->"
|
||||
|
||||
// Status describes what Merge did, so callers can log precisely.
|
||||
type Status string
|
||||
|
||||
// Possible merge outcomes.
|
||||
const (
|
||||
// StatusCreated means no file existed, so the page was written whole.
|
||||
StatusCreated Status = "created"
|
||||
// StatusUpdated means the generated region was replaced in place.
|
||||
StatusUpdated Status = "updated"
|
||||
// StatusUnchanged means the generated region already matched.
|
||||
StatusUnchanged Status = "unchanged"
|
||||
// StatusSkipped means the file had no generated markers and force was off.
|
||||
StatusSkipped Status = "skipped"
|
||||
)
|
||||
|
||||
// Block locates the three boundaries of the generated region inside a page:
|
||||
// the opening marker, the body that follows it, and the byte after the closing
|
||||
// marker. All offsets are absolute and end-exclusive where it matters, so a
|
||||
// caller can rebuild a page as prefix + replacement + suffix.
|
||||
type Block struct {
|
||||
// Marker is the index of the opening marker.
|
||||
Marker int
|
||||
// Body is the index of the first byte after the opening marker's line.
|
||||
Body int
|
||||
// End is the index of the first byte after the closing marker.
|
||||
End int
|
||||
Found bool
|
||||
}
|
||||
|
||||
// FindBlock locates the generated markers. Both must be present and the opening
|
||||
// marker must sit at the start of a line, otherwise Found is false — which is
|
||||
// the signal that a file was authored by hand and must not be clobbered.
|
||||
func FindBlock(content string) Block {
|
||||
start := strings.Index(content, beginPrefix)
|
||||
if start < 0 {
|
||||
return Block{}
|
||||
}
|
||||
// The marker must own its line: either it opens the file or a newline
|
||||
// precedes it.
|
||||
if start > 0 && content[start-1] != '\n' {
|
||||
return Block{}
|
||||
}
|
||||
|
||||
lineEnd := strings.IndexByte(content[start:], '\n')
|
||||
if lineEnd < 0 {
|
||||
return Block{}
|
||||
}
|
||||
|
||||
body := start + lineEnd + 1
|
||||
|
||||
rest := content[body:]
|
||||
end := strings.Index(rest, EndMarker)
|
||||
if end < 0 {
|
||||
return Block{}
|
||||
}
|
||||
return Block{
|
||||
Marker: start,
|
||||
Body: body,
|
||||
End: body + end + len(EndMarker),
|
||||
Found: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Merge folds a freshly generated page into the existing file.
|
||||
//
|
||||
// Hand-written prose outside the markers survives untouched. A file with no
|
||||
// markers at all is left alone unless force is set, so importing can never
|
||||
// destroy a document a human wrote. An identical page is reported unchanged and
|
||||
// returned verbatim, so a no-op sync does not rewrite the file or churn its
|
||||
// modification time.
|
||||
func Merge(existing string, generated string, force bool) (string, Status, error) {
|
||||
if strings.TrimSpace(existing) == "" {
|
||||
return generated, StatusCreated, nil
|
||||
}
|
||||
|
||||
block := FindBlock(existing)
|
||||
if !block.Found {
|
||||
if !force {
|
||||
return existing, StatusSkipped, nil
|
||||
}
|
||||
return generated, StatusUpdated, nil
|
||||
}
|
||||
|
||||
// The replacement carries its own markers, so the preserved prefix stops
|
||||
// before the old opening marker rather than after it.
|
||||
var b strings.Builder
|
||||
b.WriteString(existing[:block.Marker])
|
||||
b.WriteString(generated)
|
||||
b.WriteString(existing[block.End:])
|
||||
|
||||
merged := b.String()
|
||||
if stampPattern.ReplaceAllString(merged, stampReplacement) ==
|
||||
stampPattern.ReplaceAllString(existing, stampReplacement) {
|
||||
return existing, StatusUnchanged, nil
|
||||
}
|
||||
return merged, StatusUpdated, nil
|
||||
}
|
||||
|
||||
// Wrap surrounds body with the generated markers, stamping provenance so a
|
||||
// reader can tell which project and which source revision produced the page.
|
||||
//
|
||||
// The body is normalised to exactly one trailing newline so the closing marker
|
||||
// always starts its own line, and no newline is emitted after that marker: the
|
||||
// marker is the last thing Wrap writes, so replacing a region in place and
|
||||
// keeping the following bytes of the file cannot double the trailing blank line.
|
||||
func Wrap(source, signature, body string) string {
|
||||
return fmt.Sprintf(
|
||||
"%s source=%s signature=%s generated=%s -->\n%s\n%s",
|
||||
beginPrefix, source, signature, Timestamp(),
|
||||
strings.TrimRight(body, "\n"), EndMarker,
|
||||
)
|
||||
}
|
||||
565
backend/docgen/render.go
Normal file
565
backend/docgen/render.go
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
package docgen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Render produces the full Markdown page for a project, wrapped in generated
|
||||
// markers. The output always carries the three level-2 audience headings that
|
||||
// go-doc's reader keys off, so an imported page is immediately navigable.
|
||||
func Render(p *Project) (string, error) {
|
||||
view := buildView(p)
|
||||
|
||||
var b builder
|
||||
b.h1(p.Name)
|
||||
b.para(p.Summary)
|
||||
b.table(
|
||||
[]string{"", ""},
|
||||
[]row{
|
||||
{"Module", view.ModuleSummary},
|
||||
{"Endpoints", view.EndpointCount},
|
||||
{"Components", view.ComponentCount},
|
||||
{"Tests", view.TestSummary},
|
||||
{"Go lines", view.LineSummary},
|
||||
},
|
||||
)
|
||||
b.note("Everything between the generated markers is rewritten by every sync. " +
|
||||
"Notes written outside the markers are kept.")
|
||||
|
||||
users(&b, view)
|
||||
operators(&b, view)
|
||||
developers(&b, view)
|
||||
|
||||
return Wrap(p.Name, p.Signature, strings.TrimRight(b.String(), "\n")), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audience sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func users(b *builder, v docView) {
|
||||
b.h2("For Users")
|
||||
|
||||
b.h3("What this application does")
|
||||
b.para(v.Summary)
|
||||
|
||||
if v.HasReadme {
|
||||
b.para(fmt.Sprintf(
|
||||
"The project ships its own README at `%s`, which is the long form of this page. "+
|
||||
"The three audience tabs split the same facts three ways so you only read the part "+
|
||||
"that concerns you.", v.Readme))
|
||||
} else {
|
||||
b.para("This project ships no README, so the summary above was derived from the source " +
|
||||
"tree. If that is wrong, write a README and re-run the sync.")
|
||||
}
|
||||
|
||||
b.h3("Reading this in go-doc")
|
||||
b.para("Open the page and pick the tab that matches you:")
|
||||
b.bullets([]string{
|
||||
"**User** — you use the finished application. This section is all you need.",
|
||||
"**Operator** — you run it. Configuration, ports and deployment notes are under that tab.",
|
||||
"**Developer** — you integrate with it or change it. The API and layout tables are under that tab.",
|
||||
})
|
||||
b.para("The feedback panel stores a note per audience, tagged with your badge, so operators " +
|
||||
"and developers can see which audience raised it.")
|
||||
|
||||
if len(v.Docs) > 0 {
|
||||
b.h3("Where the original documentation lives")
|
||||
b.para(fmt.Sprintf(
|
||||
"This project already carries %s. They stay in the project repository: this page "+
|
||||
"points at them instead of copying them, so there is always exactly one copy of "+
|
||||
"the truth.", plural(len(v.Docs), "Markdown document", "Markdown documents")))
|
||||
|
||||
rows := make([]row, 0, len(v.Docs))
|
||||
for _, doc := range v.Docs {
|
||||
rows = append(rows, row{"`" + doc.Path + "`", fmt.Sprintf("%d bytes", doc.Bytes)})
|
||||
}
|
||||
b.table([]string{"Document", "Size"}, rows)
|
||||
}
|
||||
}
|
||||
|
||||
func operators(b *builder, v docView) {
|
||||
b.rule()
|
||||
b.h2("For Operators")
|
||||
|
||||
b.h3("Running it locally")
|
||||
if len(v.MakeTargets) > 0 {
|
||||
b.para(fmt.Sprintf("The root Makefile defines %s:",
|
||||
plural(len(v.MakeTargets), "target", "targets")))
|
||||
b.bullets(backtickEach(v.MakeTargets, "make "))
|
||||
} else {
|
||||
b.para("This project has no root Makefile, so the binary or container is run directly.")
|
||||
}
|
||||
|
||||
if len(v.Compose) > 0 {
|
||||
b.h3("Local containers")
|
||||
b.para("The compose file defines these services:")
|
||||
|
||||
rows := make([]row, 0, len(v.Compose))
|
||||
for _, service := range v.Compose {
|
||||
image := "built locally"
|
||||
if service.Image != "" {
|
||||
image = "`" + service.Image + "`"
|
||||
}
|
||||
ports := "internal only"
|
||||
if service.Ports != "" {
|
||||
ports = service.Ports
|
||||
}
|
||||
rows = append(rows, row{service.Name, image, ports})
|
||||
}
|
||||
b.table([]string{"Service", "Image", "Published ports"}, rows)
|
||||
}
|
||||
|
||||
if v.Docker != nil {
|
||||
b.h3("Image")
|
||||
|
||||
var sentence strings.Builder
|
||||
fmt.Fprintf(&sentence, "`%s` builds from", v.Docker.File)
|
||||
for i, base := range v.Docker.Bases {
|
||||
if i > 0 {
|
||||
sentence.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sentence, " `%s`", base)
|
||||
}
|
||||
if len(v.Docker.Stages) > 0 {
|
||||
fmt.Fprintf(&sentence, " in stages: %s", strings.Join(v.Docker.Stages, ", "))
|
||||
}
|
||||
if len(v.Docker.Exposed) > 0 {
|
||||
fmt.Fprintf(&sentence, " and exposes %s", strings.Join(v.Docker.Exposed, ", "))
|
||||
}
|
||||
sentence.WriteString(".")
|
||||
b.para(sentence.String())
|
||||
}
|
||||
|
||||
b.h3("Configuration")
|
||||
if len(v.EnvVars) > 0 {
|
||||
b.para("Every environment variable found in the source or in a `.env` file:")
|
||||
|
||||
rows := make([]row, 0, len(v.EnvVars))
|
||||
for _, env := range v.EnvVars {
|
||||
example := "—"
|
||||
if env.Example != "" {
|
||||
example = "`" + env.Example + "`"
|
||||
}
|
||||
source := "source"
|
||||
if env.FromEnv {
|
||||
source = ".env file"
|
||||
}
|
||||
rows = append(rows, row{"`" + env.Name + "`", example, source})
|
||||
}
|
||||
b.table([]string{"Variable", "Example", "Found in"}, rows)
|
||||
} else {
|
||||
b.para("No environment variables were found in the source or in a `.env` file.")
|
||||
}
|
||||
|
||||
if len(v.Flags) > 0 {
|
||||
b.para("Command-line flags override the environment where both exist:")
|
||||
|
||||
rows := make([]row, 0, len(v.Flags))
|
||||
for _, flag := range v.Flags {
|
||||
decl := "-" + flag.Name
|
||||
if flag.Default != "" {
|
||||
decl += "=" + flag.Default
|
||||
}
|
||||
def := "—"
|
||||
if flag.Default != "" {
|
||||
def = "`" + flag.Default + "`"
|
||||
}
|
||||
usage := "—"
|
||||
if flag.Usage != "" {
|
||||
usage = collapse(flag.Usage, 90)
|
||||
}
|
||||
rows = append(rows, row{"`" + decl + "`", def, usage})
|
||||
}
|
||||
b.table([]string{"Flag", "Default", "Purpose"}, rows)
|
||||
}
|
||||
|
||||
b.h3("Network surface")
|
||||
if len(v.Ports) > 0 {
|
||||
quoted := make([]string, 0, len(v.Ports))
|
||||
for _, port := range v.Ports {
|
||||
quoted = append(quoted, "`"+port+"`")
|
||||
}
|
||||
b.para("Ports referenced across the project: " + strings.Join(quoted, ", ") + ".")
|
||||
} else {
|
||||
b.para("No explicit port declaration was found. Check the README and the compose file " +
|
||||
"before assuming a default.")
|
||||
}
|
||||
|
||||
b.h3("Deployment artefacts")
|
||||
if len(v.HelmCharts) > 0 {
|
||||
b.para(fmt.Sprintf("Helm %s found:",
|
||||
plural(len(v.HelmCharts), "chart", "charts")))
|
||||
b.bullets(backtickEach(v.HelmCharts, ""))
|
||||
} else {
|
||||
b.para("No Helm chart was found, so deployment is expected to be plain Docker or a " +
|
||||
"managed platform.")
|
||||
}
|
||||
|
||||
if len(v.OperatorDocs) > 0 {
|
||||
b.h3("Operations documentation that already exists")
|
||||
b.para("Read these in place rather than duplicating them here:")
|
||||
b.bullets(docBullets(v.OperatorDocs))
|
||||
}
|
||||
|
||||
for _, note := range v.Notes {
|
||||
b.note(note)
|
||||
}
|
||||
}
|
||||
|
||||
func developers(b *builder, v docView) {
|
||||
b.rule()
|
||||
b.h2("For Developers")
|
||||
|
||||
b.h3("Module layout")
|
||||
if len(v.ModuleSummaries) > 0 {
|
||||
b.bullets(v.ModuleSummaries)
|
||||
} else {
|
||||
b.para("No go.mod in this tree: the project may be vendored, a workspace member, or " +
|
||||
"not Go at all.")
|
||||
}
|
||||
|
||||
b.h3("HTTP API")
|
||||
if len(v.Routes) > 0 {
|
||||
b.para("Every route registration found in the source:")
|
||||
|
||||
rows := make([]row, 0, len(v.Routes))
|
||||
for _, route := range v.Routes {
|
||||
method := "ANY"
|
||||
if route.Method != "" {
|
||||
method = "`" + route.Method + "`"
|
||||
}
|
||||
handler := "—"
|
||||
if route.Handler != "" {
|
||||
handler = "`" + route.Handler + "`"
|
||||
}
|
||||
rows = append(rows, row{method, "`" + route.Path + "`", handler})
|
||||
}
|
||||
b.table([]string{"Method", "Path", "Handler"}, rows)
|
||||
} else {
|
||||
b.para("No route registrations were detected, which is expected for a CLI, a library " +
|
||||
"or a worker.")
|
||||
}
|
||||
|
||||
b.h3("Direct dependencies")
|
||||
if deps := v.DirectDeps(); len(deps) > 0 {
|
||||
items := make([]string, 0, len(deps))
|
||||
for _, dep := range deps {
|
||||
items = append(items, "`"+dep.Path+"` "+dep.Version)
|
||||
}
|
||||
b.bullets(items)
|
||||
} else {
|
||||
b.para("No third-party dependencies are declared.")
|
||||
}
|
||||
|
||||
b.h3("Directory map")
|
||||
if len(v.Dirs) > 0 {
|
||||
rows := make([]row, 0, len(v.Dirs))
|
||||
for _, dir := range v.Dirs {
|
||||
rows = append(rows, row{
|
||||
"`" + dir.Name + "/`",
|
||||
fmt.Sprintf("%d", dir.Files),
|
||||
fmt.Sprintf("%d", dir.Lines),
|
||||
directoryPurpose(dir.Name),
|
||||
})
|
||||
}
|
||||
b.table([]string{"Directory", "Files", "Lines", "Likely purpose"}, rows)
|
||||
} else {
|
||||
b.para("No first-level directories were found.")
|
||||
}
|
||||
|
||||
b.h3("Languages")
|
||||
if len(v.Languages) > 0 {
|
||||
items := make([]string, 0, len(v.Languages))
|
||||
for _, lang := range v.Languages {
|
||||
items = append(items, fmt.Sprintf("**%s** — %s, %d lines", lang.Name,
|
||||
plural(lang.Files, "file", "files"), lang.Lines))
|
||||
}
|
||||
b.bullets(items)
|
||||
} else {
|
||||
b.para("No source files were recognised.")
|
||||
}
|
||||
|
||||
if len(v.DeveloperDocs) > 0 {
|
||||
b.h3("Developer documentation that already exists")
|
||||
b.bullets(docBullets(v.DeveloperDocs))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// builder assembles Markdown with predictable spacing. Block elements ask for a
|
||||
// separator instead of writing blank lines by hand, so every block is preceded
|
||||
// by exactly one blank line no matter which branch produced it — the failure
|
||||
// mode this replaces was headings glued to the paragraph above them.
|
||||
type builder struct {
|
||||
out strings.Builder
|
||||
lastBlank bool
|
||||
}
|
||||
|
||||
func (b *builder) String() string { return b.out.String() }
|
||||
|
||||
// ensureBlank separates the next line from the previous one unless it already is.
|
||||
func (b *builder) ensureBlank() {
|
||||
if b.out.Len() == 0 || b.lastBlank {
|
||||
return
|
||||
}
|
||||
b.out.WriteString("\n")
|
||||
}
|
||||
|
||||
func (b *builder) line(text string) {
|
||||
b.out.WriteString(text)
|
||||
b.out.WriteString("\n")
|
||||
b.lastBlank = text == ""
|
||||
}
|
||||
|
||||
func (b *builder) h1(text string) { b.ensureBlank(); b.line("# " + text) }
|
||||
|
||||
func (b *builder) h2(text string) { b.ensureBlank(); b.line("## " + text) }
|
||||
|
||||
func (b *builder) h3(text string) { b.ensureBlank(); b.line("### " + text) }
|
||||
|
||||
func (b *builder) para(text string) { b.ensureBlank(); b.line(text) }
|
||||
|
||||
func (b *builder) note(text string) { b.ensureBlank(); b.line("> " + text) }
|
||||
|
||||
// rule writes a horizontal rule, which always gets its own block.
|
||||
func (b *builder) rule() { b.ensureBlank(); b.line("---") }
|
||||
|
||||
// bullets writes a tight list: the separator goes before the list, never between
|
||||
// its items.
|
||||
func (b *builder) bullets(items []string) {
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
b.ensureBlank()
|
||||
for _, item := range items {
|
||||
b.line("- " + item)
|
||||
}
|
||||
}
|
||||
|
||||
// row is one Markdown table row.
|
||||
type row []string
|
||||
|
||||
// table writes a GitHub-flavoured table. Cell content is emitted verbatim: every
|
||||
// caller has already added its own backticks, so the builder must not guess.
|
||||
func (b *builder) table(headers []string, rows []row) {
|
||||
b.ensureBlank()
|
||||
|
||||
cells := make([]string, len(headers))
|
||||
for i, header := range headers {
|
||||
cells[i] = header
|
||||
}
|
||||
b.line("| " + strings.Join(cells, " | ") + " |")
|
||||
|
||||
separators := make([]string, len(headers))
|
||||
for i := range separators {
|
||||
separators[i] = "---"
|
||||
}
|
||||
b.line("| " + strings.Join(separators, " | ") + " |")
|
||||
|
||||
for _, r := range rows {
|
||||
b.line("| " + strings.Join(r, " | ") + " |")
|
||||
}
|
||||
}
|
||||
|
||||
// backtickEach renders values as a list of code-formatted bullet items.
|
||||
func backtickEach(values []string, prefix string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, "`"+prefix+value+"`")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// docBullets renders discovered documents as code-formatted bullets with their
|
||||
// titles when they have one.
|
||||
func docBullets(docs []DocFile) []string {
|
||||
out := make([]string, 0, len(docs))
|
||||
for _, doc := range docs {
|
||||
item := "`" + doc.Path + "`"
|
||||
if doc.Title != "" {
|
||||
item += " — " + doc.Title
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// docView is the pre-computed, presentation-ready view of a project.
|
||||
type docView struct {
|
||||
*Project
|
||||
|
||||
ModuleSummary string
|
||||
EndpointCount string
|
||||
ComponentCount string
|
||||
TestSummary string
|
||||
LineSummary string
|
||||
HasReadme bool
|
||||
OperatorDocs []DocFile
|
||||
DeveloperDocs []DocFile
|
||||
ModuleSummaries []string
|
||||
Notes []string
|
||||
}
|
||||
|
||||
// DirectDeps flattens the direct requirements of every module in the tree.
|
||||
func (v docView) DirectDeps() []Dependency {
|
||||
var out []Dependency
|
||||
for _, m := range v.Modules {
|
||||
out = append(out, m.Direct...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out
|
||||
}
|
||||
|
||||
func buildView(p *Project) docView {
|
||||
v := docView{Project: p}
|
||||
|
||||
switch module := primaryModule(p); {
|
||||
case module == "":
|
||||
v.ModuleSummary = "no go.mod in this tree"
|
||||
default:
|
||||
parts := []string{module}
|
||||
for _, m := range p.Modules {
|
||||
if m.GoVersion != "" {
|
||||
parts = append(parts, "go "+m.GoVersion)
|
||||
break
|
||||
}
|
||||
}
|
||||
parts = append(parts, plural(p.Stats.GoFiles, "Go file", "Go files"))
|
||||
v.ModuleSummary = strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
v.EndpointCount = plural(len(p.Routes), "endpoint", "endpoints")
|
||||
v.ComponentCount = plural(p.Stats.Components, "component directory", "component directories")
|
||||
v.LineSummary = plural(p.Stats.TotalLines, "line", "lines")
|
||||
v.HasReadme = p.Readme != ""
|
||||
|
||||
if p.Stats.TestFuncs > 0 {
|
||||
v.TestSummary = fmt.Sprintf("%d in %s", p.Stats.TestFuncs,
|
||||
plural(p.Stats.TestFiles, "file", "files"))
|
||||
} else {
|
||||
v.TestSummary = "none detected"
|
||||
}
|
||||
|
||||
// Route existing project docs under the audience that most likely owns them,
|
||||
// so the generated page points rather than duplicates.
|
||||
for _, doc := range p.Docs {
|
||||
lower := strings.ToLower(doc.Path)
|
||||
switch {
|
||||
case containsAny(lower, "operat", "runbook", "deploy", "infra", "install", "troubleshoot"):
|
||||
v.OperatorDocs = append(v.OperatorDocs, doc)
|
||||
case containsAny(lower, "architect", "develop", "api", "integration", "contract", "coding", "design"):
|
||||
v.DeveloperDocs = append(v.DeveloperDocs, doc)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range p.Modules {
|
||||
var summary strings.Builder
|
||||
summary.WriteString(m.File)
|
||||
if m.Path != "" {
|
||||
summary.WriteString(" — module " + m.Path)
|
||||
}
|
||||
if m.GoVersion != "" {
|
||||
summary.WriteString(", go " + m.GoVersion)
|
||||
}
|
||||
fmt.Fprintf(&summary, ", %d direct and %d indirect dependencies", len(m.Direct), m.Indirect)
|
||||
v.ModuleSummaries = append(v.ModuleSummaries, summary.String())
|
||||
}
|
||||
|
||||
// Anything a reader should know before trusting the generated content.
|
||||
if !v.HasReadme {
|
||||
v.Notes = append(v.Notes,
|
||||
"This project ships no README, so the summary was derived from the tree layout. Add one and re-run the sync.")
|
||||
}
|
||||
if len(p.Modules) == 0 {
|
||||
v.Notes = append(v.Notes, "No go.mod was found, so this tree was analysed as plain source files.")
|
||||
}
|
||||
if p.Docker == nil {
|
||||
v.Notes = append(v.Notes, "No Dockerfile was found at any level.")
|
||||
}
|
||||
if len(p.Routes) == 0 && len(p.Modules) > 0 {
|
||||
v.Notes = append(v.Notes,
|
||||
"No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.")
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func containsAny(haystack string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(haystack, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// directoryPurpose guesses what a top-level directory is for. It is a heuristic
|
||||
// on names only, and it admits ignorance rather than inventing an answer.
|
||||
func directoryPurpose(name string) string {
|
||||
switch strings.ToLower(name) {
|
||||
case "internal":
|
||||
return "unimportable application internals"
|
||||
case "pkg":
|
||||
return "public library packages"
|
||||
case "cmd", "bin", "main":
|
||||
return "binary entry points"
|
||||
case "api":
|
||||
return "HTTP handlers and routing"
|
||||
case "server":
|
||||
return "server entry point"
|
||||
case "services", "service":
|
||||
return "domain services"
|
||||
case "web", "ui", "frontend", "client":
|
||||
return "frontend assets"
|
||||
case "bdd", "e2e", "tests", "test", "features":
|
||||
return "tests"
|
||||
case "deploy", "deployment", "charts", "helm", "k8s", "kubernetes":
|
||||
return "deployment manifests"
|
||||
case "docker", "scripts", "script", "hack":
|
||||
return "operational tooling"
|
||||
case "docs", "documentation":
|
||||
return "project documentation"
|
||||
case "migrations":
|
||||
return "schema migrations"
|
||||
case "contracts", "openapi", "proto", "schemas":
|
||||
return "shared schemas or contracts"
|
||||
case "data":
|
||||
return "flat-file data"
|
||||
case "dist", "build", "out":
|
||||
return "build output"
|
||||
case "examples", "example":
|
||||
return "worked examples"
|
||||
case "auth":
|
||||
return "authentication"
|
||||
case "config":
|
||||
return "configuration loading"
|
||||
case "security":
|
||||
return "security controls"
|
||||
case "store", "storage":
|
||||
return "persistence layer"
|
||||
case "database":
|
||||
return "database access"
|
||||
case "models", "model", "schema":
|
||||
return "domain models"
|
||||
case "middleware":
|
||||
return "HTTP middleware"
|
||||
case "templates", "views":
|
||||
return "render templates"
|
||||
case "assets", "static":
|
||||
return "static assets"
|
||||
case "feedback", "comments":
|
||||
return "user feedback"
|
||||
default:
|
||||
return "unknown — inspect directly"
|
||||
}
|
||||
}
|
||||
BIN
backend/go-doc
Executable file
BIN
backend/go-doc
Executable file
Binary file not shown.
141
backend/go-loose/client/client.go
Normal file
141
backend/go-loose/client/client.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
// Package client provides fail-closed API-key authorization middleware for Go services.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultHeader = "X-API-Key"
|
||||
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
Tenant string
|
||||
Application string
|
||||
Header string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
tenant string
|
||||
application string
|
||||
header string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type Principal struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
ApplicationSlug string `json:"application_slug"`
|
||||
APIKeyID string `json:"api_key_id"`
|
||||
APIKeyName string `json:"api_key_name"`
|
||||
}
|
||||
|
||||
type authorizationResponse struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason"`
|
||||
Principal
|
||||
}
|
||||
|
||||
const XTenantId = "X-Tenant-Id"
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Application) == "" {
|
||||
return nil, errors.New("go-loose client requires BaseURL and Application")
|
||||
}
|
||||
if cfg.Header == "" {
|
||||
cfg.Header = DefaultHeader
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: 3 * time.Second}
|
||||
}
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"), tenant: cfg.Tenant,
|
||||
application: cfg.Application, header: cfg.Header, httpClient: cfg.HTTPClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Authorize(ctx context.Context, apiKey, method, requestPath, host string) (Principal, error) {
|
||||
payload, err := json.Marshal(map[string]string{
|
||||
"tenant": c.tenant, "application": c.application, "method": method,
|
||||
"path": requestPath, "host": stripPort(host),
|
||||
})
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("encode authorization request: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/authorize", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("create authorization request: %w", err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set(c.header, apiKey)
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("authorize with Go Loose: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return Principal{}, fmt.Errorf("read authorization response: %w", err)
|
||||
}
|
||||
var result authorizationResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return Principal{}, fmt.Errorf("decode authorization response (HTTP %d): %w", response.StatusCode, err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || !result.Allowed {
|
||||
if result.Reason == "" {
|
||||
result.Reason = http.StatusText(response.StatusCode)
|
||||
}
|
||||
return Principal{}, fmt.Errorf("request denied by Go Loose: %s", result.Reason)
|
||||
}
|
||||
return result.Principal, nil
|
||||
}
|
||||
|
||||
// Middleware rejects missing, invalid, or unverifiable keys and places the
|
||||
// authorized Principal in the request context.
|
||||
func (c *Client) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
apiKey := strings.TrimSpace(r.Header.Get(c.header))
|
||||
if apiKey == "" {
|
||||
writeMiddlewareError(w, http.StatusUnauthorized, "missing API key")
|
||||
return
|
||||
}
|
||||
principal, err := c.Authorize(r.Context(), apiKey, r.Method, r.URL.Path, r.Host)
|
||||
if err != nil {
|
||||
writeMiddlewareError(w, http.StatusForbidden, "API key not authorized")
|
||||
return
|
||||
}
|
||||
r.Header.Del(c.header)
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, principal)))
|
||||
})
|
||||
}
|
||||
|
||||
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
|
||||
principal, ok := ctx.Value(contextKey{}).(Principal)
|
||||
return principal, ok
|
||||
}
|
||||
|
||||
func stripPort(host string) string {
|
||||
if parsed, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(parsed)
|
||||
}
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
func writeMiddlewareError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
54
backend/go-loose/client/client_test.go
Normal file
54
backend/go-loose/client/client_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMiddlewareAuthorizesAndAddsPrincipal(t *testing.T) {
|
||||
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(DefaultHeader) != "gl_test_key_that_is_long_enough_for_client" {
|
||||
t.Errorf("unexpected API key")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"allowed":true,"tenant_id":"tenant-1","tenant_slug":"nmbs","application_id":"app-1","application_slug":"guess","api_key_id":"key-1","api_key_name":"test"}`))
|
||||
}))
|
||||
defer authorizationServer.Close()
|
||||
|
||||
client, err := New(Config{BaseURL: authorizationServer.URL, Tenant: "nmbs", Application: "guess"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
handler := client.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := PrincipalFromContext(r.Context())
|
||||
if !ok || principal.TenantSlug != "nmbs" {
|
||||
t.Fatalf("missing principal: %#v", principal)
|
||||
}
|
||||
if r.Header.Get(DefaultHeader) != "" {
|
||||
t.Fatal("middleware must not forward the credential")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
request := httptest.NewRequest(http.MethodGet, "http://nmbs.guess.local/guesses", nil)
|
||||
request.Header.Set(DefaultHeader, "gl_test_key_that_is_long_enough_for_client")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareFailsClosedWithoutKey(t *testing.T) {
|
||||
client, err := New(Config{BaseURL: "http://go-loose", Application: "guess"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
called := false
|
||||
handler := client.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if recorder.Code != http.StatusUnauthorized || called {
|
||||
t.Fatalf("status = %d, called = %v", recorder.Code, called)
|
||||
}
|
||||
}
|
||||
237
backend/go-loose/client/webauth.go
Normal file
237
backend/go-loose/client/webauth.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSessionCookie = "go_loose_user_session"
|
||||
stateCookie = "go_loose_login_state"
|
||||
)
|
||||
|
||||
type BrowserAuthConfig struct {
|
||||
BaseURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
AfterLoginURL string
|
||||
AfterLogoutURL string
|
||||
LoginPath string
|
||||
CookieName string
|
||||
CookieSecure bool
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type BrowserAuth struct {
|
||||
config BrowserAuthConfig
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Application string `json:"application"`
|
||||
// Role is the user's membership role in the owning tenant, for example
|
||||
// user, operator, developer, admin, or owner.
|
||||
Role string `json:"role"`
|
||||
// Permissions lists the capability names granted by Role, such as
|
||||
// "console.read", "keys.manage", or "profile.edit". Gate application
|
||||
// features on these instead of comparing role names.
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
|
||||
// Can reports whether the role behind this session grants a capability.
|
||||
func (u User) Can(permission string) bool {
|
||||
for _, granted := range u.Permissions {
|
||||
if granted == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type userContextKey struct{}
|
||||
|
||||
func NewBrowserAuth(config BrowserAuthConfig) (*BrowserAuth, error) {
|
||||
if config.BaseURL == "" || config.ClientID == "" || config.ClientSecret == "" || config.RedirectURL == "" {
|
||||
return nil, errors.New("browser auth requires BaseURL, ClientID, ClientSecret, and RedirectURL")
|
||||
}
|
||||
redirect, err := url.Parse(config.RedirectURL)
|
||||
if err != nil || !redirect.IsAbs() {
|
||||
return nil, errors.New("RedirectURL must be absolute")
|
||||
}
|
||||
config.BaseURL = strings.TrimRight(config.BaseURL, "/")
|
||||
if config.AfterLoginURL == "" {
|
||||
config.AfterLoginURL = "/"
|
||||
}
|
||||
if config.AfterLogoutURL == "" {
|
||||
config.AfterLogoutURL = "/"
|
||||
}
|
||||
if config.LoginPath == "" {
|
||||
config.LoginPath = "/login"
|
||||
}
|
||||
if config.CookieName == "" {
|
||||
config.CookieName = defaultSessionCookie
|
||||
}
|
||||
if config.HTTPClient == nil {
|
||||
config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
return &BrowserAuth{config: config}, nil
|
||||
}
|
||||
|
||||
func (a *BrowserAuth) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := randomValue(24)
|
||||
if err != nil {
|
||||
http.Error(w, "could not start login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: stateCookie, Value: state, Path: callbackPath(a.config.RedirectURL),
|
||||
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
||||
})
|
||||
query := url.Values{
|
||||
"response_type": {"code"}, "client_id": {a.config.ClientID},
|
||||
"redirect_uri": {a.config.RedirectURL}, "state": {state},
|
||||
}
|
||||
http.Redirect(w, r, a.config.BaseURL+"/connect/authorize?"+query.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *BrowserAuth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
state, err := r.Cookie(stateCookie)
|
||||
if err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
|
||||
http.Error(w, "invalid login state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "authorization code missing", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"authorization_code"}, "code": {code},
|
||||
"redirect_uri": {a.config.RedirectURL},
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
http.Error(w, "could not complete login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.SetBasicAuth(a.config.ClientID, a.config.ClientSecret)
|
||||
response, err := a.config.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var tokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&tokenResponse); err != nil || response.StatusCode != http.StatusOK || tokenResponse.AccessToken == "" {
|
||||
http.Error(w, "authorization code exchange failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.config.CookieName, Value: tokenResponse.AccessToken, Path: "/",
|
||||
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: tokenResponse.ExpiresIn,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: callbackPath(a.config.RedirectURL), MaxAge: -1, HttpOnly: true})
|
||||
http.Redirect(w, r, a.config.AfterLoginURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *BrowserAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var revokeErr error
|
||||
if cookie, err := r.Cookie(a.config.CookieName); err == nil {
|
||||
request, requestErr := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/logout", nil)
|
||||
if requestErr != nil {
|
||||
revokeErr = requestErr
|
||||
} else {
|
||||
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||
response, responseErr := a.config.HTTPClient.Do(request)
|
||||
if responseErr != nil {
|
||||
revokeErr = responseErr
|
||||
} else {
|
||||
response.Body.Close()
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
revokeErr = fmt.Errorf("Go Loose logout returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if revokeErr != nil {
|
||||
http.Error(w, "local session cleared, but central session revocation failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, a.config.AfterLogoutURL, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *BrowserAuth) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(a.config.CookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.config.BaseURL+"/connect/userinfo", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "could not validate session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||
response, err := a.config.HTTPClient.Do(request)
|
||||
if err != nil {
|
||||
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var user User
|
||||
if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
|
||||
http.SetCookie(w, &http.Cookie{Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
|
||||
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey{}, user)))
|
||||
})
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (User, bool) {
|
||||
user, ok := ctx.Value(userContextKey{}).(User)
|
||||
return user, ok
|
||||
}
|
||||
|
||||
func randomValue(size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", fmt.Errorf("generate random value: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func callbackPath(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Path == "" {
|
||||
return "/"
|
||||
}
|
||||
return parsed.Path
|
||||
}
|
||||
101
backend/go-loose/client/webauth_test.go
Normal file
101
backend/go-loose/client/webauth_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBrowserLoginRedirect(t *testing.T) {
|
||||
auth, err := NewBrowserAuth(BrowserAuthConfig{
|
||||
BaseURL: "https://nmbs.goloose.local", ClientID: "client",
|
||||
ClientSecret: "secret", RedirectURL: "https://nmbs.guess.local/auth/callback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewBrowserAuth() error = %v", err)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if recorder.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
target, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse location: %v", err)
|
||||
}
|
||||
if target.Path != "/connect/authorize" || target.Query().Get("client_id") != "client" || target.Query().Get("state") == "" {
|
||||
t.Fatalf("unexpected redirect: %s", target)
|
||||
}
|
||||
if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly {
|
||||
t.Fatal("expected an HTTP-only state cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserMiddlewareAddsUser(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer session-token" {
|
||||
t.Fatal("missing bearer token")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":"user-1","email":"user@example.com","tenant_slug":"nmbs","application":"guess"}`))
|
||||
}))
|
||||
defer identity.Close()
|
||||
auth, err := NewBrowserAuth(BrowserAuthConfig{
|
||||
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
|
||||
RedirectURL: "http://localhost/auth/callback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewBrowserAuth() error = %v", err)
|
||||
}
|
||||
handler := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok || user.Email != "user@example.com" {
|
||||
t.Fatalf("unexpected user: %#v", user)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
request := httptest.NewRequest(http.MethodGet, "http://localhost/private", nil)
|
||||
request.AddCookie(&http.Cookie{Name: defaultSessionCookie, Value: "session-token"})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserCallbackUsesBasicClientAuthentication(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientID, secret, ok := r.BasicAuth()
|
||||
if !ok || clientID != "client-id" || secret != "client-secret" {
|
||||
t.Fatalf("unexpected client authentication: %q, %q, %v", clientID, secret, ok)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"access_token":"session-token","expires_in":3600}`))
|
||||
}))
|
||||
defer identity.Close()
|
||||
auth, err := NewBrowserAuth(BrowserAuthConfig{
|
||||
BaseURL: identity.URL, ClientID: "client-id", ClientSecret: "client-secret",
|
||||
RedirectURL: "http://localhost/auth/callback",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewBrowserAuth() error = %v", err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodGet, "/auth/callback?code=one-time-code&state=login-state", nil)
|
||||
request.AddCookie(&http.Cookie{Name: stateCookie, Value: "login-state"})
|
||||
recorder := httptest.NewRecorder()
|
||||
auth.CallbackHandler(recorder, request)
|
||||
if recorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var session *http.Cookie
|
||||
for _, cookie := range recorder.Result().Cookies() {
|
||||
if cookie.Name == defaultSessionCookie {
|
||||
session = cookie
|
||||
}
|
||||
}
|
||||
if session == nil || session.Value != "session-token" || !session.HttpOnly {
|
||||
t.Fatalf("unexpected session cookie: %#v", session)
|
||||
}
|
||||
}
|
||||
5
backend/go.mod
Normal file
5
backend/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module go-doc
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1
|
||||
1
backend/go.sum
Normal file
1
backend/go.sum
Normal file
|
|
@ -0,0 +1 @@
|
|||
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=
|
||||
226
backend/main.go
Normal file
226
backend/main.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// Command godoc runs the go-doc documentation and feedback platform.
|
||||
//
|
||||
// It serves the REST API on /api/* and, when the React bundle has been compiled
|
||||
// in via `make build`, serves the single-page application on every other path.
|
||||
// Shutdown is graceful: SIGINT/SIGTERM stop accepting connections, let in-flight
|
||||
// requests finish, and close the process cleanly.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go-doc/go-loose/client"
|
||||
"go-doc/router"
|
||||
"go-doc/storage"
|
||||
"go-doc/webui"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAddr = "0.0.0.0:8080"
|
||||
readHeaderTimeout = 10 * time.Second
|
||||
readTimeout = 30 * time.Second
|
||||
writeTimeout = 60 * time.Second
|
||||
idleTimeout = 120 * time.Second
|
||||
shutdownGracePeriod = 10 * time.Second
|
||||
shutdownSignalWindow = 5 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var (
|
||||
addr = flag.String("addr", envOr("GO_DOC_ADDR", defaultAddr), "host:port the API listens on")
|
||||
dataDir = flag.String("data", "", "flat-file data directory (defaults to GO_DOC_DATA_DIR or auto-discovery)")
|
||||
origins = flag.String("cors", envOr("GO_DOC_CORS", ""), "comma-separated allowed origins (default: *)")
|
||||
logLevel = flag.String("log-level", envOr("GO_DOC_LOG_LEVEL", "info"), "debug, info, warn or error")
|
||||
logFormat = flag.String("log-format", envOr("GO_DOC_LOG_FORMAT", "text"), "text or json")
|
||||
looseBaseURL = flag.String("loose-base-url", envOr("GO_LOOSE_BASE_URL", ""), "go-loose base URL for authentication (e.g., https://auth.example.com)")
|
||||
looseClientID = flag.String("loose-client-id", envOr("GO_LOOSE_CLIENT_ID", ""), "go-loose client ID for OAuth")
|
||||
looseSecret = flag.String("loose-client-secret", envOr("GO_LOOSE_CLIENT_SECRET", ""), "go-loose client secret for OAuth")
|
||||
looseRedirect = flag.String("loose-redirect-url", envOr("GO_LOOSE_REDIRECT_URL", ""), "go-loose redirect URL after login (e.g., https://doc.example.com/auth/callback)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
logger := newLogger(*logLevel, *logFormat)
|
||||
slog.SetDefault(logger)
|
||||
|
||||
root := *dataDir
|
||||
if root == "" {
|
||||
root = storage.ResolveRoot()
|
||||
}
|
||||
|
||||
engine, err := storage.New(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialise storage: %w", err)
|
||||
}
|
||||
logger.Info("storage ready",
|
||||
"data_root", engine.Root(),
|
||||
"docs", engine.DocsDir(),
|
||||
"comments", engine.CommentsDir(),
|
||||
)
|
||||
|
||||
staticFS := webui.FS()
|
||||
if staticFS == nil {
|
||||
logger.Warn("no embedded frontend bundle found; serving API only",
|
||||
"hint", "run `make frontend-run` for the Vite dev server or `make build` to embed assets")
|
||||
}
|
||||
|
||||
// Initialize go-loose browser auth if configured
|
||||
var browserAuth *client.BrowserAuth
|
||||
if *looseBaseURL != "" && *looseClientID != "" && *looseSecret != "" && *looseRedirect != "" {
|
||||
browserAuthConfig := client.BrowserAuthConfig{
|
||||
BaseURL: *looseBaseURL,
|
||||
ClientID: *looseClientID,
|
||||
ClientSecret: *looseSecret,
|
||||
RedirectURL: *looseRedirect,
|
||||
AfterLoginURL: "/",
|
||||
AfterLogoutURL: "/",
|
||||
LoginPath: "/auth/login",
|
||||
CookieName: "go_doc_session",
|
||||
CookieSecure: true,
|
||||
}
|
||||
browserAuth, err = client.NewBrowserAuth(browserAuthConfig)
|
||||
if err != nil {
|
||||
logger.Warn("go-loose browser auth not configured", "error", err)
|
||||
} else {
|
||||
logger.Info("go-loose browser auth enabled", "base_url", *looseBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := router.Config{
|
||||
Engine: engine,
|
||||
Static: staticFS,
|
||||
AllowedOrigins: splitAndTrim(*origins),
|
||||
Logger: logger,
|
||||
BrowserAuth: browserAuth,
|
||||
}
|
||||
if len(cfg.AllowedOrigins) == 0 {
|
||||
cfg.AllowedOrigins = []string{router.DefaultAllowedOrigin}
|
||||
}
|
||||
|
||||
handler, err := router.NewWithConfig(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build router: %w", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: *addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
IdleTimeout: idleTimeout,
|
||||
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn),
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
listener, err := net.Listen("tcp", *addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", *addr, err)
|
||||
}
|
||||
|
||||
logger.Info("go-doc listening",
|
||||
"addr", listener.Addr().String(),
|
||||
"frontend_embedded", staticFS != nil,
|
||||
"cors_origins", strings.Join(cfg.AllowedOrigins, ", "),
|
||||
)
|
||||
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
serveErr <- err
|
||||
return
|
||||
}
|
||||
serveErr <- nil
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
logger.Info("shutdown signal received", "grace", shutdownGracePeriod.String())
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod)
|
||||
defer cancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("graceful shutdown failed; forcing close", "error", err)
|
||||
if closeErr := srv.Close(); closeErr != nil {
|
||||
return fmt.Errorf("force close: %w", closeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
if err != nil {
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
}
|
||||
case <-time.After(shutdownSignalWindow):
|
||||
}
|
||||
logger.Info("stopped cleanly")
|
||||
return nil
|
||||
}
|
||||
|
||||
func newLogger(level, format string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: lvl}
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func splitAndTrim(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
768
backend/router/router.go
Normal file
768
backend/router/router.go
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
// Package router wires the go-doc REST API onto net/http.
|
||||
//
|
||||
// It exposes flat-file CRUD for Markdown documents, audience-badged comment
|
||||
// tracking, and -- when built via `make build` -- serves the compiled React
|
||||
// bundle from an embedded filesystem so the Go binary is fully self-contained.
|
||||
// Routes use the standard library's method-and-wildcard patterns, which keeps
|
||||
// the backend dependency-free.
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-doc/auth"
|
||||
"go-doc/go-loose/client"
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// Default payload ceilings, applied with http.MaxBytesReader before any parsing.
|
||||
const (
|
||||
DefaultMaxDocBytes = 5 << 20 // 5 MiB of Markdown
|
||||
DefaultMaxCommentBytes = 256 << 10 // 256 KiB per comment
|
||||
DefaultAllowedOrigin = "*"
|
||||
)
|
||||
|
||||
// Config tunes the HTTP layer. Zero values fall back to the defaults above.
|
||||
type Config struct {
|
||||
Engine *storage.Engine
|
||||
Static fs.FS
|
||||
AllowedOrigins []string
|
||||
MaxDocBytes int64
|
||||
MaxCommentByte int64
|
||||
Logger *slog.Logger
|
||||
// DevFallback is returned when Static is nil so a developer hitting the API
|
||||
// port directly gets an actionable hint instead of a bare 404.
|
||||
DevFallback string
|
||||
// BrowserAuth enables go-loose OAuth authentication for the web UI.
|
||||
BrowserAuth *client.BrowserAuth
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.MaxDocBytes <= 0 {
|
||||
c.MaxDocBytes = DefaultMaxDocBytes
|
||||
}
|
||||
if c.MaxCommentByte <= 0 {
|
||||
c.MaxCommentByte = DefaultMaxCommentBytes
|
||||
}
|
||||
if len(c.AllowedOrigins) == 0 {
|
||||
c.AllowedOrigins = []string{DefaultAllowedOrigin}
|
||||
}
|
||||
if c.Logger == nil {
|
||||
c.Logger = slog.Default()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// New builds the fully decorated handler around a storage engine.
|
||||
func New(engine *storage.Engine) (http.Handler, error) {
|
||||
return NewWithConfig(Config{Engine: engine})
|
||||
}
|
||||
|
||||
// NewWithConfig builds the handler stack from an explicit configuration.
|
||||
func NewWithConfig(cfg Config) (http.Handler, error) {
|
||||
cfg = cfg.withDefaults()
|
||||
if cfg.Engine == nil {
|
||||
engine, err := storage.Default()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("router: resolve storage engine: %w", err)
|
||||
}
|
||||
cfg.Engine = engine
|
||||
}
|
||||
|
||||
h := &handler{engine: cfg.Engine, cfg: cfg}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /api/health", h.health)
|
||||
mux.HandleFunc("GET /api/roles", h.roles)
|
||||
mux.HandleFunc("GET /api/docs", h.listDocs)
|
||||
mux.HandleFunc("GET /api/docs/{slug}", h.getDoc)
|
||||
mux.HandleFunc("HEAD /api/docs/{slug}", h.getDoc)
|
||||
mux.HandleFunc("POST /api/docs/{slug}", h.saveDoc)
|
||||
mux.HandleFunc("PUT /api/docs/{slug}", h.saveDoc)
|
||||
mux.HandleFunc("DELETE /api/docs/{slug}", h.deleteDoc)
|
||||
mux.HandleFunc("GET /api/docs/{slug}/comments", h.listComments)
|
||||
mux.HandleFunc("POST /api/docs/{slug}/comments", h.createComment)
|
||||
mux.HandleFunc("DELETE /api/docs/{slug}/comments/{id}", h.deleteComment)
|
||||
mux.HandleFunc("GET /api/docs/{slug}/stats", h.docStats)
|
||||
mux.HandleFunc("GET /api/stats", h.globalStats)
|
||||
|
||||
// Operations endpoints
|
||||
mux.HandleFunc("GET /api/ops/scripts", h.downloadScripts)
|
||||
mux.HandleFunc("GET /api/ops/docs", h.getOpsDoc)
|
||||
|
||||
// Auth endpoints
|
||||
mux.HandleFunc("GET /auth/login", h.login)
|
||||
mux.HandleFunc("GET /auth/callback", h.callback)
|
||||
mux.HandleFunc("POST /auth/logout", h.logout)
|
||||
mux.HandleFunc("GET /api/auth/profile", h.profile)
|
||||
|
||||
mux.Handle("/", auth.BrowserAuthMiddleware(cfg.BrowserAuth)(h.frontend()))
|
||||
|
||||
var handler http.Handler = mux
|
||||
handler = corsMiddleware(cfg.AllowedOrigins)(handler)
|
||||
handler = loggingMiddleware(cfg.Logger)(handler)
|
||||
handler = recoverMiddleware(cfg.Logger)(handler)
|
||||
return handler, nil
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
engine *storage.Engine
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(payload); err != nil {
|
||||
slog.Error("router: encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errorBody struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
} `json:"error"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
|
||||
var body errorBody
|
||||
body.Error.Code = code
|
||||
body.Error.Message = message
|
||||
body.Error.RequestID = requestIDFrom(r.Context())
|
||||
body.Status = status
|
||||
writeJSON(w, status, body)
|
||||
}
|
||||
|
||||
// writeStoreError maps engine sentinels onto HTTP status codes.
|
||||
func (h *handler) writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
writeError(w, r, http.StatusNotFound, "not_found", err.Error())
|
||||
case errors.Is(err, storage.ErrInvalidSlug):
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_slug", err.Error())
|
||||
case errors.Is(err, storage.ErrInvalidRole):
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_role", err.Error())
|
||||
case errors.Is(err, storage.ErrInvalidComment):
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_comment", err.Error())
|
||||
default:
|
||||
h.cfg.Logger.Error("storage failure", "error", err, "path", r.URL.Path)
|
||||
writeError(w, r, http.StatusInternalServerError, "internal_error", "storage operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API: documents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *handler) health(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "go-doc",
|
||||
"data_root": h.engine.Root(),
|
||||
"time": time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) roles(w http.ResponseWriter, r *http.Request) {
|
||||
roles := make([]map[string]string, 0, 3)
|
||||
for _, role := range storage.Roles() {
|
||||
roles = append(roles, map[string]string{
|
||||
"role": string(role),
|
||||
"badge": role.Badge(),
|
||||
"label": role.Label(),
|
||||
"hex": role.Hex(),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
|
||||
}
|
||||
|
||||
func (h *handler) listDocs(w http.ResponseWriter, r *http.Request) {
|
||||
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
detailed := r.URL.Query().Get("counts") == "1" || r.URL.Query().Get("counts") == "true"
|
||||
|
||||
var (
|
||||
docs []storage.DocMeta
|
||||
err error
|
||||
)
|
||||
if detailed {
|
||||
docs, err = h.engine.ListDocsDetailed()
|
||||
} else {
|
||||
docs, err = h.engine.Search(query)
|
||||
}
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"docs": docs,
|
||||
"count": len(docs),
|
||||
"query": query,
|
||||
"data_root": h.engine.Root(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) getDoc(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
content, err := h.engine.ReadDoc(slug)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
if wantsJSON(r) {
|
||||
meta, metaErr := h.engine.Stat(slug)
|
||||
if metaErr != nil {
|
||||
h.writeStoreError(w, r, metaErr)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"content": content,
|
||||
"size": meta.Size,
|
||||
"modified": meta.Modified,
|
||||
"comments": meta.Comments,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := io.WriteString(w, content); err != nil {
|
||||
h.cfg.Logger.Error("write markdown response", "error", err, "slug", slug)
|
||||
}
|
||||
}
|
||||
|
||||
// saveRequest is the JSON body accepted by POST /api/docs/{slug}. Raw
|
||||
// text/markdown bodies are accepted too, which is what the Markdown editor tab
|
||||
// in the UI sends.
|
||||
type saveRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
func (h *handler) saveDoc(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxDocBytes)
|
||||
|
||||
title := strings.TrimSpace(r.URL.Query().Get("title"))
|
||||
var content string
|
||||
|
||||
if isJSONRequest(r) {
|
||||
var payload saveRequest
|
||||
if err := json.NewDecoder(body).Decode(&payload); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with a content field: "+err.Error())
|
||||
return
|
||||
}
|
||||
content = payload.Content
|
||||
if t := strings.TrimSpace(payload.Title); t != "" {
|
||||
title = t
|
||||
}
|
||||
} else {
|
||||
raw, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, "body_too_large", fmt.Sprintf("document exceeds %d bytes", h.cfg.MaxDocBytes))
|
||||
return
|
||||
}
|
||||
content = string(raw)
|
||||
}
|
||||
|
||||
existed, err := h.engine.DocExists(slug)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
normalized, err := h.engine.SaveDoc(slug, content)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := h.engine.Stat(normalized)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
if title != "" {
|
||||
meta.Title = title
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if !existed {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"size": meta.Size,
|
||||
"modified": meta.Modified,
|
||||
"created": !existed,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) deleteDoc(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if err := h.engine.DeleteDoc(slug); err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handler) listComments(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
comments, err := h.engine.ListComments(slug)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter comments by tenant if user is authenticated
|
||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||
filtered := make([]storage.Comment, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
// Admin can see all comments, otherwise only show comments from same tenant
|
||||
if user.IsAdmin() || c.TenantID == user.TenantID {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
comments = filtered
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"slug": storage.Slugify(slug),
|
||||
"comments": comments,
|
||||
"count": len(comments),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) createComment(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxCommentByte)
|
||||
|
||||
var input storage.CommentInput
|
||||
if err := json.NewDecoder(body).Decode(&input); err != nil {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with author, role and content: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Attach tenant/user info from auth context if available
|
||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||
input.TenantID = user.TenantID
|
||||
input.TenantSlug = user.TenantSlug
|
||||
input.UserID = user.ID
|
||||
if input.Author == "" {
|
||||
input.Author = user.DisplayName
|
||||
}
|
||||
}
|
||||
|
||||
comment, err := h.engine.AppendComment(slug, input)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"comment": comment,
|
||||
"badge": comment.Badge,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) deleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
removed, err := h.engine.DeleteComment(slug, r.PathValue("id"))
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
if !removed {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "comment not found")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handler) docStats(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
stats, err := h.engine.Stats(slug)
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *handler) globalStats(w http.ResponseWriter, r *http.Request) {
|
||||
docs, err := h.engine.ListDocs()
|
||||
if err != nil {
|
||||
h.writeStoreError(w, r, err)
|
||||
return
|
||||
}
|
||||
byRole := map[string]int{}
|
||||
totalComments := 0
|
||||
for _, doc := range docs {
|
||||
stats, err := h.engine.Stats(doc.Slug)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totalComments += stats.Total
|
||||
for role, n := range stats.ByRole {
|
||||
byRole[role] += n
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"docs": len(docs),
|
||||
"comments": totalComments,
|
||||
"by_role": byRole,
|
||||
})
|
||||
}
|
||||
|
||||
// downloadScripts serves the operations scripts zip file.
|
||||
func (h *handler) downloadScripts(w http.ResponseWriter, r *http.Request) {
|
||||
zipPath := path.Join(h.engine.DocsDir(), "ops", "scripts.zip")
|
||||
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "scripts archive not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"ops-scripts.zip\"")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.ServeFile(w, r, zipPath)
|
||||
}
|
||||
|
||||
// getOpsDoc serves the operations documentation.
|
||||
func (h *handler) getOpsDoc(w http.ResponseWriter, r *http.Request) {
|
||||
docPath := path.Join(h.engine.DocsDir(), "ops", "DEPLOYMENT.md")
|
||||
content, err := os.ReadFile(docPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "documentation not found")
|
||||
return
|
||||
}
|
||||
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to read documentation")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// login redirects to go-loose for authentication.
|
||||
func (h *handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfg.BrowserAuth == nil {
|
||||
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
|
||||
return
|
||||
}
|
||||
h.cfg.BrowserAuth.LoginHandler(w, r)
|
||||
}
|
||||
|
||||
// callback handles the OAuth callback from go-loose.
|
||||
func (h *handler) callback(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfg.BrowserAuth == nil {
|
||||
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
|
||||
return
|
||||
}
|
||||
h.cfg.BrowserAuth.CallbackHandler(w, r)
|
||||
}
|
||||
|
||||
// logout clears the session and redirects to go-loose logout.
|
||||
func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.cfg.BrowserAuth == nil {
|
||||
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
|
||||
return
|
||||
}
|
||||
h.cfg.BrowserAuth.LogoutHandler(w, r)
|
||||
}
|
||||
|
||||
// profile returns the current user's profile.
|
||||
func (h *handler) profile(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeError(w, r, http.StatusUnauthorized, "unauthorized", "not authenticated")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"display_name": user.DisplayName,
|
||||
"avatar_url": user.AvatarURL,
|
||||
"tenant_id": user.TenantID,
|
||||
"tenant_slug": user.TenantSlug,
|
||||
"application_id": user.ApplicationID,
|
||||
"application": user.Application,
|
||||
"role": user.Role,
|
||||
"permissions": user.Permissions,
|
||||
"memberships": []any{}, // TODO: fetch from go-loose if needed
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (h *handler) frontend() http.Handler {
|
||||
if h.cfg.Static == nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
message := h.cfg.DevFallback
|
||||
if message == "" {
|
||||
message = "go-doc backend is running. Start the Vite dev server with `make frontend-run`, or run `make build` to embed the production bundle."
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "<!doctype html><meta charset=\"utf-8\"><title>go-doc API</title><body style=\"font-family:ui-monospace,monospace;background:#0b0f14;color:#cbd5e1;padding:3rem\"><h1>go-doc API</h1><p>%s</p><p>Endpoints: <code>/api/health</code>, <code>/api/docs</code>, <code>/api/docs/{slug}</code>, <code>/api/docs/{slug}/comments</code>.</p></body>", message)
|
||||
})
|
||||
}
|
||||
return spaHandler(h.cfg.Static)
|
||||
}
|
||||
|
||||
// spaHandler serves the embedded React bundle and falls back to index.html for
|
||||
// client-side routes such as /docs/core-service-api.
|
||||
func spaHandler(static fs.FS) http.Handler {
|
||||
files := http.FileServerFS(static)
|
||||
serveIndex := func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
data, err := fs.ReadFile(static, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "frontend bundle missing: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method != http.MethodHead {
|
||||
w.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
if name == "" || name == "." {
|
||||
serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
if !fs.ValidPath(name) {
|
||||
writeError(w, r, http.StatusBadRequest, "invalid_path", "invalid request path")
|
||||
return
|
||||
}
|
||||
|
||||
f, err := static.Open(name)
|
||||
if err != nil {
|
||||
serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
info, statErr := f.Stat()
|
||||
f.Close()
|
||||
if statErr != nil || info.IsDir() {
|
||||
serveIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Hashed build artefacts are immutable; everything else must revalidate.
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func corsMiddleware(allowed []string) func(http.Handler) http.Handler {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, o := range allowed {
|
||||
if trimmed := strings.TrimSpace(o); trimmed != "" {
|
||||
set[strings.ToLower(trimmed)] = struct{}{}
|
||||
}
|
||||
}
|
||||
// A literal "*" entry means "reflect any origin", which is the default
|
||||
// configuration. Credentials are never allowed, so the wildcard stays safe.
|
||||
_, allowAll := set["*"]
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
switch {
|
||||
case origin == "":
|
||||
w.Header().Set("Access-Control-Allow-Origin", DefaultAllowedOrigin)
|
||||
case allowAll:
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
default:
|
||||
normalized := strings.TrimSpace(strings.ToLower(origin))
|
||||
if _, ok := set[normalized]; ok {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
w.Header().Set(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Origin, Content-Type, Content-Length, Accept, Authorization, X-Requested-With, X-Request-Id",
|
||||
)
|
||||
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id, Content-Length, Content-Type")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.status == 0 {
|
||||
s.status = code
|
||||
s.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Write(b []byte) (int, error) {
|
||||
if s.status == 0 {
|
||||
s.status = http.StatusOK
|
||||
}
|
||||
n, err := s.ResponseWriter.Write(b)
|
||||
s.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *statusRecorder) Flush() {
|
||||
if f, ok := s.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
status := rec.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
level := slog.LevelInfo
|
||||
if status >= 500 {
|
||||
level = slog.LevelError
|
||||
} else if status >= 400 {
|
||||
level = slog.LevelWarn
|
||||
}
|
||||
logger.Log(r.Context(), level, "http",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", status,
|
||||
"bytes", rec.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func recoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
logger.Error("panic recovered",
|
||||
"error", fmt.Sprint(rec),
|
||||
"path", r.URL.Path,
|
||||
"stack", string(debug.Stack()),
|
||||
)
|
||||
writeError(w, r, http.StatusInternalServerError, "internal_error", "unexpected server error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func isJSONRequest(r *http.Request) bool {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return false
|
||||
}
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
ct = strings.ToLower(strings.TrimSpace(ct))
|
||||
return ct == "application/json" || strings.HasSuffix(ct, "+json")
|
||||
}
|
||||
|
||||
func wantsJSON(r *http.Request) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) {
|
||||
case "json":
|
||||
return true
|
||||
}
|
||||
accept := r.Header.Get("Accept")
|
||||
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/markdown")
|
||||
}
|
||||
|
||||
func requestIDFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := ctx.Value(requestIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
const requestIDKey = contextKey("godoc-request-id")
|
||||
|
||||
type contextKey string
|
||||
498
backend/scripts/docgen/main.go
Normal file
498
backend/scripts/docgen/main.go
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
// Command docgen analyses every go-* project under a workspace root and imports
|
||||
// audience-segmented documentation for each one into go-doc.
|
||||
//
|
||||
// One-shot sync:
|
||||
//
|
||||
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data
|
||||
//
|
||||
// Continuous sync, re-importing whenever a project's docs or source change:
|
||||
//
|
||||
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data -watch
|
||||
//
|
||||
// Imported pages are wrapped in generated markers. Re-running replaces only the
|
||||
// machine-written region, so prose written outside the markers survives, and a
|
||||
// page that was never generated is left untouched unless -force is passed.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-doc/docgen"
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
slog.Error("fatal", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
type options struct {
|
||||
root string
|
||||
dataDir string
|
||||
apps string
|
||||
skip string
|
||||
watch bool
|
||||
interval time.Duration
|
||||
dryRun bool
|
||||
force bool
|
||||
importSubdoc bool
|
||||
verbose bool
|
||||
}
|
||||
|
||||
func run() error {
|
||||
var opts options
|
||||
|
||||
flag.StringVar(&opts.root, "root", defaultRoot(), "directory containing the go-* projects")
|
||||
flag.StringVar(&opts.dataDir, "data", storage.ResolveRoot(), "go-doc data directory to import into")
|
||||
flag.StringVar(&opts.apps, "apps", "", "comma-separated project names to limit the run (default: all)")
|
||||
flag.StringVar(&opts.skip, "skip", "go-doc", "comma-separated project names to ignore")
|
||||
flag.BoolVar(&opts.watch, "watch", false, "keep running and re-import when a project changes")
|
||||
flag.DurationVar(&opts.interval, "interval", 5*time.Second, "poll interval for -watch")
|
||||
flag.BoolVar(&opts.dryRun, "dry-run", false, "analyse and render without writing anything")
|
||||
flag.BoolVar(&opts.force, "force", false, "overwrite pages that carry no generated markers")
|
||||
flag.BoolVar(&opts.importSubdoc, "import-subdocs", false, "also import each project's own Markdown as separate go-doc pages")
|
||||
flag.BoolVar(&opts.verbose, "verbose", false, "log at debug level")
|
||||
flag.Parse()
|
||||
|
||||
level := slog.LevelInfo
|
||||
if opts.verbose {
|
||||
level = slog.LevelDebug
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
|
||||
engine, err := storage.New(opts.dataDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open the go-doc data directory: %w", err)
|
||||
}
|
||||
|
||||
im := newImporter(opts, engine, logger)
|
||||
|
||||
if !opts.watch {
|
||||
result, err := im.SyncOnce()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.report(logger)
|
||||
if result.Failed > 0 {
|
||||
return fmt.Errorf("%d project(s) failed to import", result.Failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Info("watching for documentation changes",
|
||||
"root", opts.root,
|
||||
"interval", opts.interval.String(),
|
||||
"data", engine.Root(),
|
||||
"hint", "Ctrl-C to stop",
|
||||
)
|
||||
|
||||
ticker := time.NewTicker(opts.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
changed, err := im.Scan()
|
||||
switch {
|
||||
case err != nil:
|
||||
logger.Error("scan failed", "error", err)
|
||||
case len(changed) == 0:
|
||||
logger.Debug("no changes")
|
||||
default:
|
||||
logger.Info("change detected", "projects", strings.Join(sortedKeys(changed), ", "))
|
||||
result, syncErr := im.Import(sortedKeys(changed))
|
||||
if syncErr != nil {
|
||||
logger.Error("sync failed", "error", syncErr)
|
||||
}
|
||||
result.report(logger)
|
||||
}
|
||||
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRoot() string {
|
||||
if v := strings.TrimSpace(os.Getenv("GO_DOC_WORKSPACE")); v != "" {
|
||||
return v
|
||||
}
|
||||
return "/home/dnoulet/go"
|
||||
}
|
||||
|
||||
// Result summarises one sync pass.
|
||||
type Result struct {
|
||||
Created int
|
||||
Updated int
|
||||
Unchanged int
|
||||
Skipped int
|
||||
Failed int
|
||||
Subdocs int
|
||||
Pages []pageResult
|
||||
}
|
||||
|
||||
func (r Result) report(logger *slog.Logger) {
|
||||
logger.Info("sync complete",
|
||||
"created", r.Created,
|
||||
"updated", r.Updated,
|
||||
"unchanged", r.Unchanged,
|
||||
"skipped", r.Skipped,
|
||||
"subdocs", r.Subdocs,
|
||||
"failed", r.Failed,
|
||||
)
|
||||
for _, page := range r.Pages {
|
||||
attrs := []any{"project", page.Project, "slug", page.Slug, "status", string(page.Status)}
|
||||
if len(page.Notes) > 0 {
|
||||
attrs = append(attrs, "notes", strings.Join(page.Notes, ", "))
|
||||
}
|
||||
if page.Sub {
|
||||
attrs = append(attrs, "subdocument", true)
|
||||
}
|
||||
if page.Status == docgen.StatusSkipped {
|
||||
logger.Warn("skipped", attrs...)
|
||||
continue
|
||||
}
|
||||
logger.Info("imported", attrs...)
|
||||
}
|
||||
}
|
||||
|
||||
// pageResult is the outcome for one written page.
|
||||
type pageResult struct {
|
||||
Project string
|
||||
Slug string
|
||||
Status docgen.Status
|
||||
Notes []string
|
||||
Sub bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// importer holds the sync state shared by the one-shot and watch paths.
|
||||
type importer struct {
|
||||
opts options
|
||||
engine *storage.Engine
|
||||
logger *slog.Logger
|
||||
|
||||
only map[string]struct{}
|
||||
skipSet map[string]struct{}
|
||||
|
||||
// seen maps a project name to the signature observed on the previous poll,
|
||||
// which is what makes "did this change?" a cheap map lookup.
|
||||
seen map[string]string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newImporter(opts options, engine *storage.Engine, logger *slog.Logger) *importer {
|
||||
return &importer{
|
||||
opts: opts,
|
||||
engine: engine,
|
||||
logger: logger,
|
||||
only: splitSet(opts.apps),
|
||||
skipSet: splitSet(opts.skip),
|
||||
seen: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func splitSet(raw string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
out[trimmed] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Discover returns the project names selected by -apps and -skip.
|
||||
func (im *importer) Discover() ([]string, error) {
|
||||
entries, err := os.ReadDir(im.opts.root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read workspace root %s: %w", im.opts.root, err)
|
||||
}
|
||||
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !entry.IsDir() || !strings.HasPrefix(name, "go-") {
|
||||
continue
|
||||
}
|
||||
if _, skip := im.skipSet[name]; skip {
|
||||
continue
|
||||
}
|
||||
if len(im.only) > 0 {
|
||||
if _, wanted := im.only[name]; !wanted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// Signatures returns the current source signature per project.
|
||||
func (im *importer) Signatures() (map[string]string, error) {
|
||||
names, err := im.Discover()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]string, len(names))
|
||||
for _, name := range names {
|
||||
project, err := docgen.Analyze(name, filepath.Join(im.opts.root, name))
|
||||
if err != nil {
|
||||
im.logger.Debug("cannot analyse project", "project", name, "error", err)
|
||||
continue
|
||||
}
|
||||
out[name] = project.Signature
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SyncOnce analyses and imports every discovered project.
|
||||
func (im *importer) SyncOnce() (Result, error) {
|
||||
names, err := im.Discover()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
im.logger.Warn("no go-* projects discovered", "root", im.opts.root)
|
||||
}
|
||||
return im.Import(names)
|
||||
}
|
||||
|
||||
// Scan returns the projects whose signature changed since the previous poll,
|
||||
// including any project seen for the first time. Remembering every signature —
|
||||
// not just the changed ones — keeps a project from re-importing forever once it
|
||||
// has been synced. A project that has left the workspace is forgotten and
|
||||
// reported: its page stays on disk untouched, because a sync that silently
|
||||
// deletes documentation is worse than one that leaves it stale.
|
||||
func (im *importer) Scan() (map[string]string, error) {
|
||||
sigs, err := im.Signatures()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
im.mu.Lock()
|
||||
defer im.mu.Unlock()
|
||||
|
||||
changed := map[string]string{}
|
||||
for name, sig := range sigs {
|
||||
if previous, seen := im.seen[name]; !seen || previous != sig {
|
||||
changed[name] = sig
|
||||
}
|
||||
}
|
||||
for name, sig := range sigs {
|
||||
im.seen[name] = sig
|
||||
}
|
||||
|
||||
var departed []string
|
||||
for name := range im.seen {
|
||||
if _, present := sigs[name]; !present {
|
||||
departed = append(departed, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(departed)
|
||||
for _, name := range departed {
|
||||
delete(im.seen, name)
|
||||
im.logger.Info("project left the workspace; its page was left as is",
|
||||
"project", name,
|
||||
"hint", "delete the page by hand if the project is gone for good",
|
||||
)
|
||||
}
|
||||
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// Import analyses each named project and writes its page through the storage
|
||||
// engine, so the result is slugified, atomically written and immediately
|
||||
// visible to the API and the frontend.
|
||||
func (im *importer) Import(names []string) (Result, error) {
|
||||
var (
|
||||
result Result
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
for _, name := range names {
|
||||
wg.Add(1)
|
||||
go func(name string) {
|
||||
defer wg.Done()
|
||||
|
||||
pages, subdocs, err := im.importOne(name)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
result.Failed++
|
||||
im.logger.Error("import failed", "project", name, "error", err)
|
||||
return
|
||||
}
|
||||
result.Subdocs += subdocs
|
||||
result.Pages = append(result.Pages, pages...)
|
||||
for _, page := range pages {
|
||||
if page.Sub {
|
||||
continue
|
||||
}
|
||||
switch page.Status {
|
||||
case docgen.StatusCreated:
|
||||
result.Created++
|
||||
case docgen.StatusUpdated:
|
||||
result.Updated++
|
||||
case docgen.StatusUnchanged:
|
||||
result.Unchanged++
|
||||
case docgen.StatusSkipped:
|
||||
result.Skipped++
|
||||
}
|
||||
}
|
||||
}(name)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(result.Pages, func(i, j int) bool { return result.Pages[i].Slug < result.Pages[j].Slug })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// importOne builds and writes the page for a single project, plus any sub-pages
|
||||
// when -import-subdocs is set.
|
||||
func (im *importer) importOne(name string) ([]pageResult, int, error) {
|
||||
root := filepath.Join(im.opts.root, name)
|
||||
|
||||
project, err := docgen.Analyze(name, root)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
page, err := docgen.Render(project)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
slug := storage.Slugify(name)
|
||||
|
||||
status, err := im.write(slug, page)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
out := []pageResult{{
|
||||
Project: name,
|
||||
Slug: slug,
|
||||
Status: status,
|
||||
Notes: notes(project),
|
||||
}}
|
||||
|
||||
if !im.opts.importSubdoc {
|
||||
return out, 0, nil
|
||||
}
|
||||
|
||||
subpages := im.subdocPages(project)
|
||||
for _, sub := range subpages {
|
||||
subStatus, err := im.write(sub.slug, sub.body)
|
||||
if err != nil {
|
||||
im.logger.Warn("subdocument import failed",
|
||||
"project", name, "path", sub.source, "error", err)
|
||||
continue
|
||||
}
|
||||
out = append(out, pageResult{
|
||||
Project: name,
|
||||
Slug: sub.slug,
|
||||
Status: subStatus,
|
||||
Sub: true,
|
||||
})
|
||||
}
|
||||
return out, len(subpages), nil
|
||||
}
|
||||
|
||||
type subdocPage struct {
|
||||
source string
|
||||
slug string
|
||||
body string
|
||||
}
|
||||
|
||||
// subdocPages turns each Markdown file the project already owns into its own
|
||||
// go-doc page, wrapped so a later sync refreshes it instead of orphaning it.
|
||||
func (im *importer) subdocPages(project *docgen.Project) []subdocPage {
|
||||
base := storage.Slugify(project.Name)
|
||||
var out []subdocPage
|
||||
|
||||
for _, doc := range project.Docs {
|
||||
if doc.Path == project.Readme {
|
||||
continue // already the summary of the main page
|
||||
}
|
||||
stem := strings.TrimSuffix(filepath.Base(doc.Path), filepath.Ext(doc.Path))
|
||||
slug := storage.Slugify(project.Name + "-" + stem)
|
||||
if slug == "" || slug == base {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(project.Path, filepath.FromSlash(doc.Path)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
body := fmt.Sprintf(
|
||||
"Imported from %s in the %s repository.\n\n---\n\n%s",
|
||||
doc.Path, project.Name, strings.TrimRight(string(data), "\n"),
|
||||
)
|
||||
out = append(out, subdocPage{
|
||||
source: doc.Path,
|
||||
slug: slug,
|
||||
body: docgen.Wrap(project.Name+":"+doc.Path, project.Signature, body),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// write merges a generated page with whatever is already on disk. Existing prose
|
||||
// outside the markers survives; a page with no markers is skipped unless force.
|
||||
func (im *importer) write(slug, page string) (docgen.Status, error) {
|
||||
var existing string
|
||||
if data, err := os.ReadFile(filepath.Join(im.engine.DocsDir(), slug+".md")); err == nil {
|
||||
existing = string(data)
|
||||
}
|
||||
|
||||
merged, status, err := docgen.Merge(existing, page, im.opts.force)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if im.opts.dryRun {
|
||||
im.logger.Debug("dry run", "slug", slug, "status", string(status), "bytes", len(merged))
|
||||
return status, nil
|
||||
}
|
||||
if _, err := im.engine.SaveDoc(slug, merged); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// notes surfaces anything a reader should know about a generated page.
|
||||
func notes(project *docgen.Project) []string {
|
||||
var out []string
|
||||
if project.Readme == "" {
|
||||
out = append(out, "no README found")
|
||||
}
|
||||
if len(project.Routes) == 0 {
|
||||
out = append(out, "no HTTP routes detected")
|
||||
}
|
||||
if len(project.Modules) == 0 {
|
||||
out = append(out, "no go.mod found")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
172
backend/scripts/docgen/main_test.go
Normal file
172
backend/scripts/docgen/main_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func TestScanDetectsNewProjectsAndEditsButSettlesWhenIdle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
writeProject(t, filepath.Join(root, "go-alpha"))
|
||||
|
||||
im := newImporter(options{root: root}, newEngine(t), discardLogger())
|
||||
|
||||
// A project nobody has seen counts as changed.
|
||||
changed := scan(t, im)
|
||||
if want := []string{"go-alpha"}; !equal(changed, want) {
|
||||
t.Fatalf("first scan = %v, want %v", changed, want)
|
||||
}
|
||||
|
||||
// An untouched project must not re-import on every poll.
|
||||
if changed := scan(t, im); len(changed) != 0 {
|
||||
t.Errorf("idle scan reported %v, want no changes", changed)
|
||||
}
|
||||
|
||||
// A project that appears mid-run is picked up.
|
||||
writeProject(t, filepath.Join(root, "go-beta"))
|
||||
if changed := scan(t, im); !equal(changed, []string{"go-beta"}) {
|
||||
t.Errorf("scan after a new project = %v, want [go-beta]", changed)
|
||||
}
|
||||
|
||||
// An edit to an existing project is picked up, once.
|
||||
writeProject(t, filepath.Join(root, "go-alpha"))
|
||||
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
|
||||
t.Errorf("scan after an edit = %v, want [go-alpha]", changed)
|
||||
}
|
||||
if changed := scan(t, im); len(changed) != 0 {
|
||||
t.Errorf("the edit was reported twice: %v", changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForgetsProjectsThatLeaveTheWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
project := filepath.Join(root, "go-alpha")
|
||||
writeProject(t, project)
|
||||
|
||||
var logs strings.Builder
|
||||
im := newImporter(options{root: root}, newEngine(t),
|
||||
slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
|
||||
|
||||
if changed := scan(t, im); len(changed) != 1 {
|
||||
t.Fatalf("first scan = %v, want one project", changed)
|
||||
}
|
||||
logs.Reset()
|
||||
|
||||
if err := os.RemoveAll(project); err != nil {
|
||||
t.Fatalf("remove project: %v", err)
|
||||
}
|
||||
|
||||
if changed := scan(t, im); len(changed) != 0 {
|
||||
t.Errorf("a departed project was treated as changed: %v", changed)
|
||||
}
|
||||
if !strings.Contains(logs.String(), "project left the workspace") {
|
||||
t.Errorf("a departed project was not reported:\n%s", logs.String())
|
||||
}
|
||||
|
||||
// Coming back is a change again rather than a silent no-op.
|
||||
writeProject(t, project)
|
||||
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
|
||||
t.Errorf("scan after the project returned = %v, want [go-alpha]", changed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHonoursAppsAndSkip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"go-alpha", "go-beta", "go-doc", "not-a-go-app"} {
|
||||
writeProject(t, filepath.Join(root, name))
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
opts options
|
||||
want []string
|
||||
}{
|
||||
{name: "default skips go-doc", opts: options{root: root, skip: "go-doc"}, want: []string{"go-alpha", "go-beta"}},
|
||||
{name: "-apps narrows the run", opts: options{root: root, skip: "", apps: "go-beta"}, want: []string{"go-beta"}},
|
||||
{name: "no skip at all", opts: options{root: root}, want: []string{"go-alpha", "go-beta", "go-doc"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
im := newImporter(tc.opts, newEngine(t), discardLogger())
|
||||
got, err := im.Discover()
|
||||
if err != nil {
|
||||
t.Fatalf("discover: %v", err)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("discover = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverReportsAMissingWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
im := newImporter(options{root: filepath.Join(t.TempDir(), "absent")}, newEngine(t), discardLogger())
|
||||
if _, err := im.Discover(); err == nil {
|
||||
t.Fatal("a missing workspace should be reported, not silently ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func scan(t *testing.T, im *importer) []string {
|
||||
t.Helper()
|
||||
changed, err := im.Scan()
|
||||
if err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(changed))
|
||||
for name := range changed {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func equal(got, want []string) bool {
|
||||
return strings.Join(got, ",") == strings.Join(want, ",")
|
||||
}
|
||||
|
||||
func newEngine(t *testing.T) *storage.Engine {
|
||||
t.Helper()
|
||||
engine, err := storage.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("provision engine: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
}
|
||||
|
||||
func writeProject(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Join(root, "internal"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", root, err)
|
||||
}
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.com/" + filepath.Base(root) + "\n\ngo 1.27.1\n",
|
||||
"README.md": "# " + filepath.Base(root) + "\n\nIt serves an HTTP API.\n",
|
||||
"internal/app.go": "package internal\n\n// Run starts the app.\nfunc Run() error { return nil }\n",
|
||||
"internal/app_t.go": "placeholder\n",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
300
backend/scripts/seed_perf_data.go
Normal file
300
backend/scripts/seed_perf_data.go
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
// Command seed_perf_data floods the go-doc flat-file store with generated
|
||||
// documentation and feedback so the platform can be exercised under realistic
|
||||
// load.
|
||||
//
|
||||
// go run ./scripts/seed_perf_data.go -data ../data -docs 120 -comments 25 -workers 8
|
||||
//
|
||||
// Every document is a syntactically valid Markdown page carrying all three
|
||||
// audience sections, and every comment is a real JSON record with a real badge.
|
||||
// The generator is deterministic for a given -seed so runs are reproducible.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
dataDir = flag.String("data", storage.ResolveRoot(), "flat-file data directory")
|
||||
docCount = flag.Int("docs", 120, "number of Markdown documents to generate (spec floor: 100)")
|
||||
perDoc = flag.Int("comments", 25, "comments to inject per document")
|
||||
workers = flag.Int("workers", 8, "parallel seeding workers")
|
||||
seed = flag.Int64("seed", 20260101, "PRNG seed for reproducible output")
|
||||
force = flag.Bool("force", false, "overwrite documents that already exist")
|
||||
minBodyKB = flag.Int("min-body-kb", 2, "minimum document body size in KiB")
|
||||
skipTemplate = flag.Bool("skip-go-doc", true, "leave data/docs/go-doc.md untouched")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
|
||||
if *docCount < 100 && !*force {
|
||||
logger.Warn("doc count is below the 100 document floor from the specification", "docs", *docCount)
|
||||
}
|
||||
|
||||
engine, err := storage.New(*dataDir)
|
||||
if err != nil {
|
||||
logger.Error("cannot open the data directory", "error", err, "data", *dataDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("seeding flat-file store",
|
||||
"data_root", engine.Root(),
|
||||
"docs", *docCount,
|
||||
"comments_per_doc", *perDoc,
|
||||
"total_comments", *docCount**perDoc,
|
||||
"workers", *workers,
|
||||
)
|
||||
|
||||
jobs := make(chan int, *docCount)
|
||||
for i := 0; i < *docCount; i++ {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
var (
|
||||
docsWritten atomic.Int64
|
||||
docsSkipped atomic.Int64
|
||||
commentsWritten atomic.Int64
|
||||
bytesWritten atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
failures atomic.Int64
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for w := 0; w < *workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
// Each worker owns its own PRNG stream, which keeps output
|
||||
// reproducible regardless of scheduling order.
|
||||
rng := rand.New(rand.NewSource(*seed + int64(worker)*7919))
|
||||
|
||||
for index := range jobs {
|
||||
title := documentTitle(index)
|
||||
slug := storage.Slugify(title)
|
||||
|
||||
if *skipTemplate && slug == "go-doc" {
|
||||
docsSkipped.Add(1)
|
||||
continue
|
||||
}
|
||||
if !*force {
|
||||
exists, err := engine.DocExists(slug)
|
||||
if err != nil {
|
||||
failures.Add(1)
|
||||
logger.Error("existence check failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
docsSkipped.Add(1)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
body := documentBody(title, slug, *minBodyKB, rng)
|
||||
if _, err := engine.SaveDoc(slug, body); err != nil {
|
||||
failures.Add(1)
|
||||
logger.Error("document write failed", "slug", slug, "error", err)
|
||||
continue
|
||||
}
|
||||
docsWritten.Add(1)
|
||||
bytesWritten.Add(int64(len(body)))
|
||||
|
||||
for n := 0; n < *perDoc; n++ {
|
||||
role := storage.Roles()[rng.Intn(len(storage.Roles()))]
|
||||
comment := storage.CommentInput{
|
||||
Author: authorName(rng, role),
|
||||
Role: string(role),
|
||||
Content: commentBody(role, slug, n, rng),
|
||||
}
|
||||
if _, err := engine.AppendComment(slug, comment); err != nil {
|
||||
failures.Add(1)
|
||||
logger.Error("comment write failed", "slug", slug, "error", err)
|
||||
break
|
||||
}
|
||||
commentsWritten.Add(1)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
metas, err := engine.ListDocs()
|
||||
if err != nil {
|
||||
logger.Error("index read failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totalComments, byRole := 0, map[string]int{}
|
||||
for _, meta := range metas {
|
||||
stats, err := engine.Stats(meta.Slug)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totalComments += stats.Total
|
||||
for role, n := range stats.ByRole {
|
||||
byRole[role] += n
|
||||
}
|
||||
}
|
||||
|
||||
rate := 0.0
|
||||
if elapsed > 0 {
|
||||
rate = float64(commentsWritten.Load()) / elapsed.Seconds()
|
||||
}
|
||||
|
||||
logger.Info("seed complete",
|
||||
"elapsed", elapsed.Round(time.Millisecond),
|
||||
"documents_written", docsWritten.Load(),
|
||||
"documents_skipped", docsSkipped.Load(),
|
||||
"documents_indexed", len(metas),
|
||||
"comments_written", commentsWritten.Load(),
|
||||
"comments_indexed", totalComments,
|
||||
"markdown_bytes", bytesWritten.Load(),
|
||||
"comments_per_second", int(rate),
|
||||
"by_role", byRole,
|
||||
)
|
||||
|
||||
if failures.Load() > 0 {
|
||||
logger.Error("seed completed with failures", "failures", failures.Load())
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("\nSeeded %d documents and %d comments into %s\n",
|
||||
docsWritten.Load(), commentsWritten.Load(), filepath.Clean(engine.Root()))
|
||||
}
|
||||
|
||||
var serviceNouns = []string{
|
||||
"Gateway", "Pipeline", "Registry", "Collector", "Scheduler", "Router",
|
||||
"Indexer", "Notifier", "Bridge", "Vault", "Ledger", "Beacon",
|
||||
"Annex", "Relay", "Forge", "Harbor", "Signal", "Archive",
|
||||
"Compass", "Foundry", "Sentinel", "Orchid", "Quartz", "Meridian",
|
||||
}
|
||||
|
||||
var serviceScopes = []string{
|
||||
"Billing", "Identity", "Storage", "Messaging", "Search", "Payments",
|
||||
"Analytics", "Provisioning", "Fulfilment", "Telemetry", "Consent", "Routing",
|
||||
}
|
||||
|
||||
// documentTitle builds a stable, human title for the nth generated document.
|
||||
func documentTitle(index int) string {
|
||||
scope := serviceScopes[index%len(serviceScopes)]
|
||||
noun := serviceNouns[(index/len(serviceScopes))%len(serviceNouns)]
|
||||
return fmt.Sprintf("%s %s Service %03d", scope, noun, index)
|
||||
}
|
||||
|
||||
func documentBody(title, slug string, minBodyKB int, rng *rand.Rand) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n\n", title)
|
||||
fmt.Fprintf(&b, "> Generated performance fixture for `%s`. Regenerate with `make seed`.\n\n", slug)
|
||||
b.WriteString("This page isolates the same system across three audiences. Only the section that\n")
|
||||
b.WriteString("matches the selected tab is rendered in the reader.\n\n")
|
||||
|
||||
b.WriteString("## For Users\n\n")
|
||||
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||
"The guided flow walks a new user from an empty workspace to a first successful run.",
|
||||
"Every destructive action asks for confirmation and explains what is about to change.",
|
||||
"Keyboard shortcuts mirror the visual toolbar so the workflow stays fast.",
|
||||
})
|
||||
b.WriteString("\n### Common tasks\n\n")
|
||||
for _, task := range []string{"Create your first workspace", "Invite a teammate", "Export a report", "Reset your password"} {
|
||||
fmt.Fprintf(&b, "- %s\n", task)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
b.WriteString("## For Operators\n\n")
|
||||
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||
"The deployment is idempotent: re-running the pipeline converges on the declared state.",
|
||||
"Health checks probe the read path before the write path is allowed to serve traffic.",
|
||||
"Rollback restores the previous artefact and replays the migration in reverse.",
|
||||
})
|
||||
b.WriteString("\n### Runbook\n\n")
|
||||
b.WriteString("```bash\n# verify the release is healthy\ncurl -fsS https://localhost:8080/api/health\n\n# roll back to the previous artefact\nmake rollback RELEASE=previous\n```\n\n")
|
||||
b.WriteString("| Signal | Threshold | Action |\n| --- | --- | --- |\n| p99 latency | 250 ms | Page the on-call |\n| error rate | 1 % | Halt the rollout |\n| queue depth | 1000 | Scale workers |\n\n")
|
||||
|
||||
b.WriteString("## For Developers\n\n")
|
||||
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
|
||||
"The API is versioned in the path; a breaking change ships under a new major prefix.",
|
||||
"Every write is atomic: readers observe either the previous file or the new one, never a partial write.",
|
||||
"The engine slugifies titles into lower-case, hyphen-separated identifiers.",
|
||||
})
|
||||
b.WriteString("\n### Endpoints\n\n")
|
||||
b.WriteString("```http\nGET /api/docs\nGET /api/docs/{slug}\nPOST /api/docs/{slug}\nDELETE /api/docs/{slug}\nGET /api/docs/{slug}/comments\nPOST /api/docs/{slug}/comments\n```\n\n")
|
||||
|
||||
b.WriteString("### Integration notes\n\n")
|
||||
fmt.Fprintf(&b, "Inline references such as `data/docs/%s.md` and **bold emphasis** must survive a round trip through the editor.\n", slug)
|
||||
|
||||
// Top up the body so every fixture is at least minBodyKB.
|
||||
for b.Len() < minBodyKB*1024 {
|
||||
b.WriteString("\nAdditional reference material for load testing. ")
|
||||
fmt.Fprintf(&b, "Section %d of the generated body for `%s`. ", rng.Intn(1<<20), slug)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeParagraphs(b *strings.Builder, rng *rand.Rand, count, targetLen int, seeds []string) {
|
||||
for i := 0; i < count; i++ {
|
||||
seed := seeds[i%len(seeds)]
|
||||
fmt.Fprintf(b, "%s ", seed)
|
||||
for len(b.String()) < targetLen*1024 && i == count-1 {
|
||||
fmt.Fprintf(b, "Reference sentence %d covering the behaviour of `%s`. ", rng.Intn(1<<20), "service")
|
||||
break
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
var givenNames = []string{"ana", "bo", "chi", "dee", "eli", "fen", "gita", "hugo", "iris", "jo", "kit", "lena", "mo", "nia", "omar", "pia"}
|
||||
|
||||
func authorName(rng *rand.Rand, role storage.Role) string {
|
||||
name := givenNames[rng.Intn(len(givenNames))]
|
||||
suffix := map[storage.Role]string{
|
||||
storage.RoleUser: " (user)",
|
||||
storage.RoleOperator: " (ops)",
|
||||
storage.RoleDeveloper: " (dev)",
|
||||
}[role]
|
||||
return name + suffix
|
||||
}
|
||||
|
||||
var commentSeeds = map[storage.Role][]string{
|
||||
storage.RoleUser: {
|
||||
"The guided setup was easier to follow than the older docs.",
|
||||
"I could not find the export button on the first screen.",
|
||||
"Step 3 assumes a workspace that most new accounts do not have.",
|
||||
"The keyboard shortcut list is genuinely useful.",
|
||||
"Copy in the empty state does not match what actually happens.",
|
||||
},
|
||||
storage.RoleOperator: {
|
||||
"Rollback took 20 minutes; the runbook should call that out.",
|
||||
"Health checks pass while the write path is still degraded.",
|
||||
"We need the p99 latency threshold surfaced on the dashboard.",
|
||||
"The idempotent redeploy saved us during last week's incident.",
|
||||
"Queue depth alerting fires too late to be actionable.",
|
||||
},
|
||||
storage.RoleDeveloper: {
|
||||
"The retry budget is undocumented; please add the numbers.",
|
||||
"A `--dry-run` flag would make this safe to run in CI.",
|
||||
"The response schema changed without a version bump.",
|
||||
"Slug collisions are possible when titles differ only by punctuation.",
|
||||
"Atomic writes look correct, but the rename is not fsynced in CI images.",
|
||||
},
|
||||
}
|
||||
|
||||
func commentBody(role storage.Role, slug string, index int, rng *rand.Rand) string {
|
||||
seeds := commentSeeds[role]
|
||||
base := seeds[rng.Intn(len(seeds))]
|
||||
return fmt.Sprintf("%s (finding %d on %s)", base, index+1, slug)
|
||||
}
|
||||
381
backend/storage/comments.go
Normal file
381
backend/storage/comments.go
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Role identifies which audience segment a piece of feedback belongs to.
|
||||
type Role string
|
||||
|
||||
// The three audience segments isolated by go-doc.
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleOperator Role = "operator"
|
||||
RoleDeveloper Role = "developer"
|
||||
)
|
||||
|
||||
// Badge glyphs and display labels per role. Badges are derived server-side from
|
||||
// the role so a client can never spoof the visual marker of another audience.
|
||||
var roleMeta = map[Role]struct {
|
||||
Badge string
|
||||
Label string
|
||||
Hex string
|
||||
}{
|
||||
RoleUser: {Badge: "🟢", Label: "User", Hex: "#22c55e"},
|
||||
RoleOperator: {Badge: "🔵", Label: "Operator", Hex: "#3b82f6"},
|
||||
RoleDeveloper: {Badge: "🔴", Label: "Developer", Hex: "#ef4444"},
|
||||
}
|
||||
|
||||
// Badge returns the coloured badge glyph for the role (User 🟢, Operator 🔵,
|
||||
// Developer 🔴). Unknown roles yield an empty badge.
|
||||
func (r Role) Badge() string { return roleMeta[r].Badge }
|
||||
|
||||
// Label returns the human name of the role: User, Operator or Developer.
|
||||
func (r Role) Label() string { return roleMeta[r].Label }
|
||||
|
||||
// Hex returns the badge colour as a CSS hex value for the frontend to mirror.
|
||||
func (r Role) Hex() string { return roleMeta[r].Hex }
|
||||
|
||||
// Valid reports whether r is one of the three known roles.
|
||||
func (r Role) Valid() bool {
|
||||
_, ok := roleMeta[r]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ParseRole canonicalises user input ("Developer", "DEV", " developer ") into a
|
||||
// Role. It returns ErrInvalidRole for anything unrecognised.
|
||||
func ParseRole(raw string) (Role, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch normalized {
|
||||
case string(RoleUser):
|
||||
return RoleUser, nil
|
||||
case string(RoleOperator):
|
||||
return RoleOperator, nil
|
||||
case string(RoleDeveloper):
|
||||
return RoleDeveloper, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %q (want user, operator or developer)", ErrInvalidRole, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Roles lists every supported role in audience order.
|
||||
func Roles() []Role { return []Role{RoleUser, RoleOperator, RoleDeveloper} }
|
||||
|
||||
// Comment is one audience message attached to a document.
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Role Role `json:"role"`
|
||||
Badge string `json:"badge"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// CommentInput is the write-side payload for an incoming comment. Badge and
|
||||
// Timestamp are optional; the engine fills both in when they are absent.
|
||||
type CommentInput struct {
|
||||
Author string `json:"author"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxAuthorLen = 120
|
||||
maxCommentLen = 8 << 10
|
||||
)
|
||||
|
||||
// commentsSuffix keeps feedback for slug "core-service-api" in
|
||||
// core-service-api-comments.json, as mandated by the storage layout.
|
||||
const commentsSuffix = "-comments.json"
|
||||
|
||||
func (e *Engine) commentPath(slug string) string {
|
||||
return filepath.Join(e.commentsDir, slug+commentsSuffix)
|
||||
}
|
||||
|
||||
// normalizeBody canonicalises line endings in a comment body without appending a
|
||||
// trailing newline, so consumers can render it verbatim.
|
||||
func normalizeBody(content string) string {
|
||||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
return strings.Trim(normalized, "\n")
|
||||
}
|
||||
|
||||
// AppendComment validates and stores a new comment for slug, returning the
|
||||
// stored record with its generated ID, badge, and timestamp. The read-modify-write
|
||||
// cycle is serialised per slug and committed atomically, so concurrent writers
|
||||
// cannot lose or interleave entries.
|
||||
func (e *Engine) AppendComment(slug string, in CommentInput) (Comment, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
|
||||
role, err := ParseRole(in.Role)
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
|
||||
author := strings.TrimSpace(in.Author)
|
||||
if author == "" {
|
||||
author = role.Label()
|
||||
}
|
||||
if utf8.RuneCountInString(author) > maxAuthorLen {
|
||||
return Comment{}, fmt.Errorf("%w: author exceeds %d characters", ErrInvalidComment, maxAuthorLen)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if content == "" {
|
||||
return Comment{}, fmt.Errorf("%w: content is empty", ErrInvalidComment)
|
||||
}
|
||||
content = normalizeBody(content)
|
||||
if len(content) > maxCommentLen {
|
||||
return Comment{}, fmt.Errorf("%w: content exceeds %d bytes", ErrInvalidComment, maxCommentLen)
|
||||
}
|
||||
|
||||
comment := Comment{
|
||||
ID: NewCommentID(),
|
||||
Author: author,
|
||||
Role: role,
|
||||
Badge: role.Badge(),
|
||||
Content: content,
|
||||
Timestamp: in.Timestamp.UTC(),
|
||||
TenantID: in.TenantID,
|
||||
TenantSlug: in.TenantSlug,
|
||||
UserID: in.UserID,
|
||||
}
|
||||
if in.Timestamp.IsZero() {
|
||||
comment.Timestamp = e.now().UTC()
|
||||
}
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
existing, err := e.readComments(normalized)
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
|
||||
existing = append(existing, comment)
|
||||
if err := e.writeComments(normalized, existing); err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// ListComments returns every comment for slug in insertion order, oldest first.
|
||||
// A document without feedback yields an empty slice and no error.
|
||||
func (e *Engine) ListComments(slug string) ([]Comment, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return e.readComments(normalized)
|
||||
}
|
||||
|
||||
// DeleteComment removes a single comment by ID and reports whether it existed.
|
||||
func (e *Engine) DeleteComment(slug, id string) (bool, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, fmt.Errorf("%w: comment id is empty", ErrInvalidComment)
|
||||
}
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
existing, err := e.readComments(normalized)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
remaining := make([]Comment, 0, len(existing))
|
||||
found := false
|
||||
for _, c := range existing {
|
||||
if !found && c.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, c)
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
if err := e.writeComments(normalized, remaining); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteAllComments drops every comment registered for slug.
|
||||
func (e *Engine) DeleteAllComments(slug string) error {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if err := os.Remove(e.commentPath(normalized)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommentStats summarises the feedback load on a single document.
|
||||
type CommentStats struct {
|
||||
Total int `json:"total"`
|
||||
ByRole map[string]int `json:"by_role"`
|
||||
LatestAt *time.Time `json:"latest_at,omitempty"`
|
||||
OldestAt *time.Time `json:"oldest_at,omitempty"`
|
||||
MostActive string `json:"most_active,omitempty"`
|
||||
}
|
||||
|
||||
// Stats aggregates a document's comments by role.
|
||||
func (e *Engine) Stats(slug string) (CommentStats, error) {
|
||||
comments, err := e.ListComments(slug)
|
||||
if err != nil {
|
||||
return CommentStats{}, err
|
||||
}
|
||||
stats := CommentStats{Total: len(comments), ByRole: map[string]int{}}
|
||||
if len(comments) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
oldest, newest := comments[0].Timestamp, comments[0].Timestamp
|
||||
for _, c := range comments {
|
||||
stats.ByRole[string(c.Role)]++
|
||||
if c.Timestamp.Before(oldest) {
|
||||
oldest = c.Timestamp
|
||||
}
|
||||
if c.Timestamp.After(newest) {
|
||||
newest = c.Timestamp
|
||||
}
|
||||
}
|
||||
stats.OldestAt = &oldest
|
||||
stats.LatestAt = &newest
|
||||
|
||||
best, bestCount := "", -1
|
||||
for _, role := range Roles() {
|
||||
if n := stats.ByRole[string(role)]; n > bestCount {
|
||||
best, bestCount = string(role), n
|
||||
}
|
||||
}
|
||||
if bestCount > 0 {
|
||||
stats.MostActive = best
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// readComments loads the JSON array backing a slug. Callers must hold the slug
|
||||
// lock. A missing or empty file yields an empty slice.
|
||||
func (e *Engine) readComments(slug string) ([]Comment, error) {
|
||||
path := e.commentPath(slug)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []Comment{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage: read comments for %q: %w", slug, err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return []Comment{}, nil
|
||||
}
|
||||
|
||||
var comments []Comment
|
||||
if err := json.Unmarshal(data, &comments); err != nil {
|
||||
return nil, fmt.Errorf("storage: decode comments for %q: %w", slug, err)
|
||||
}
|
||||
for i := range comments {
|
||||
if comments[i].Badge == "" {
|
||||
comments[i].Badge = comments[i].Role.Badge()
|
||||
}
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// writeComments serialises comments back as a JSON array. Callers must hold the
|
||||
// slug lock. Entries are sorted by timestamp so the file order matches the
|
||||
// reader's view even when the seeding script injects backdated records.
|
||||
func (e *Engine) writeComments(slug string, comments []Comment) error {
|
||||
if comments == nil {
|
||||
comments = []Comment{}
|
||||
}
|
||||
sorted := make([]Comment, len(comments))
|
||||
copy(sorted, comments)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if sorted[i].Timestamp.Equal(sorted[j].Timestamp) {
|
||||
return sorted[i].ID < sorted[j].ID
|
||||
}
|
||||
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
||||
})
|
||||
|
||||
encoded, err := json.MarshalIndent(sorted, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: encode comments for %q: %w", slug, err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
|
||||
if err := writeFileAtomic(e.commentPath(slug), encoded, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCommentID returns a collision-resistant identifier: a millisecond timestamp
|
||||
// prefix for rough chronological ordering plus 8 random bytes.
|
||||
func NewCommentID() string {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// crypto/rand cannot fail on Linux; fall back to a time-derived value so
|
||||
// the API degrades rather than errors.
|
||||
return fmt.Sprintf("c%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("c%x_%s", time.Now().UnixMilli(), hex.EncodeToString(buf[:]))
|
||||
}
|
||||
|
||||
// AppendComment stores feedback through the process-wide engine.
|
||||
func AppendComment(slug string, in CommentInput) (Comment, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
return e.AppendComment(slug, in)
|
||||
}
|
||||
|
||||
// ListComments reads feedback through the process-wide engine.
|
||||
func ListComments(slug string) ([]Comment, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.ListComments(slug)
|
||||
}
|
||||
635
backend/storage/engine.go
Normal file
635
backend/storage/engine.go
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
// Package storage implements the flat-file persistence engine behind go-doc.
|
||||
//
|
||||
// Documents live on disk as raw Markdown files at data/docs/<slug>.md and
|
||||
// audience feedback is stored as JSON arrays at data/comments/<slug>-comments.json.
|
||||
//
|
||||
// Every mutation is written with a temp-file + fsync + rename sequence so a
|
||||
// concurrent reader can never observe a half-written file, and read-modify-write
|
||||
// cycles on the same document are serialised by a keyed mutex. That combination
|
||||
// is what lets the engine stay correct while the seed script floods it with
|
||||
// thousands of concurrent comments.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// DocExt is the file extension used for every Markdown document.
|
||||
DocExt = ".md"
|
||||
|
||||
// maxSlugLen caps generated slugs so a pathological title cannot produce an
|
||||
// unusable filename.
|
||||
maxSlugLen = 120
|
||||
|
||||
// maxTitleScanBytes limits how much of a document is scanned when extracting
|
||||
// its title from the leading heading.
|
||||
maxTitleScanBytes = 64 << 10
|
||||
|
||||
docsDirName = "docs"
|
||||
commentsDirName = "comments"
|
||||
)
|
||||
|
||||
// Errors returned by the engine. Callers map these onto HTTP status codes.
|
||||
var (
|
||||
// ErrNotFound is returned when a document or comment does not exist.
|
||||
ErrNotFound = errors.New("storage: not found")
|
||||
// ErrInvalidSlug is returned for slugs that cannot be safely slugified.
|
||||
ErrInvalidSlug = errors.New("storage: invalid slug")
|
||||
// ErrInvalidRole is returned when a comment carries an unknown role.
|
||||
ErrInvalidRole = errors.New("storage: invalid role")
|
||||
// ErrInvalidComment is returned when a comment payload is unusable.
|
||||
ErrInvalidComment = errors.New("storage: invalid comment")
|
||||
)
|
||||
|
||||
// Engine owns a data directory and all flat-file operations against it.
|
||||
// Engine is safe for concurrent use by multiple goroutines.
|
||||
type Engine struct {
|
||||
root string
|
||||
docsDir string
|
||||
commentsDir string
|
||||
|
||||
locksMu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
|
||||
// Now is injectable so tests can assert deterministic timestamps.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New returns an Engine rooted at root and creates docs/ and comments/ beneath
|
||||
// it when they are missing.
|
||||
func New(root string) (*Engine, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, fmt.Errorf("storage: empty data root")
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: resolve data root: %w", err)
|
||||
}
|
||||
e := &Engine{
|
||||
root: abs,
|
||||
docsDir: filepath.Join(abs, docsDirName),
|
||||
commentsDir: filepath.Join(abs, commentsDirName),
|
||||
locks: make(map[string]*sync.Mutex),
|
||||
Now: time.Now,
|
||||
}
|
||||
if err := os.MkdirAll(e.docsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage: create docs dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(e.commentsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage: create comments dir: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Root returns the absolute data directory backing the engine.
|
||||
func (e *Engine) Root() string { return e.root }
|
||||
|
||||
// DocsDir returns the absolute directory holding Markdown documents.
|
||||
func (e *Engine) DocsDir() string { return e.docsDir }
|
||||
|
||||
// CommentsDir returns the absolute directory holding comment JSON files.
|
||||
func (e *Engine) CommentsDir() string { return e.commentsDir }
|
||||
|
||||
func (e *Engine) now() time.Time {
|
||||
if e.Now != nil {
|
||||
return e.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// lockFor returns the mutex guarding a single slug. RLock-free by design: the
|
||||
// critical sections are short, and per-slug granularity is what allows writes to
|
||||
// different documents to proceed fully in parallel.
|
||||
func (e *Engine) lockFor(slug string) *sync.Mutex {
|
||||
e.locksMu.Lock()
|
||||
defer e.locksMu.Unlock()
|
||||
m, ok := e.locks[slug]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
e.locks[slug] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ResolveRoot discovers the data directory without any configuration. It honours
|
||||
// GO_DOC_DATA_DIR first, then walks up from the working directory looking for an
|
||||
// existing data/ folder, and finally falls back to a relative "data".
|
||||
func ResolveRoot() string {
|
||||
if v := strings.TrimSpace(os.Getenv("GO_DOC_DATA_DIR")); v != "" {
|
||||
return v
|
||||
}
|
||||
dir, err := os.Getwd()
|
||||
if err == nil {
|
||||
for i := 0; i < 8; i++ {
|
||||
candidate := filepath.Join(dir, "data")
|
||||
if info, err := os.Stat(filepath.Join(candidate, docsDirName)); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
return "data"
|
||||
}
|
||||
|
||||
var (
|
||||
defaultOnce sync.Once
|
||||
defaultEng *Engine
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// Default returns the process-wide engine rooted at ResolveRoot().
|
||||
func Default() (*Engine, error) {
|
||||
defaultOnce.Do(func() {
|
||||
defaultEng, defaultErr = New(ResolveRoot())
|
||||
})
|
||||
return defaultEng, defaultErr
|
||||
}
|
||||
|
||||
// ResetDefault drops the cached engine so the next Default call re-resolves the
|
||||
// root. Tests use it between suites that point at different temp directories.
|
||||
func ResetDefault() {
|
||||
defaultOnce = sync.Once{}
|
||||
defaultEng = nil
|
||||
defaultErr = nil
|
||||
}
|
||||
|
||||
// SetDefault installs root as the process-wide engine.
|
||||
func SetDefault(root string) (*Engine, error) {
|
||||
e, err := New(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ResetDefault()
|
||||
defaultOnce.Do(func() {})
|
||||
defaultEng, defaultErr = e, nil
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// latinFold maps lower-case Latin letters carrying diacritics, plus common
|
||||
// ligatures, onto their ASCII skeleton. Folding happens after ToLower, so the
|
||||
// keys are always lower case.
|
||||
var latinFold = map[rune]rune{
|
||||
'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ă': 'a', 'ą': 'a',
|
||||
'ç': 'c', 'ć': 'c', 'č': 'c', 'ĉ': 'c', 'ċ': 'c',
|
||||
'ď': 'd', 'đ': 'd',
|
||||
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e', 'ě': 'e',
|
||||
'ğ': 'g', 'ĝ': 'g', 'ġ': 'g', 'ģ': 'g',
|
||||
'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i',
|
||||
'ĵ': 'j',
|
||||
'ķ': 'k',
|
||||
'ł': 'l', 'ľ': 'l', 'ĺ': 'l',
|
||||
'ñ': 'n', 'ń': 'n', 'ň': 'n',
|
||||
'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o',
|
||||
'ŕ': 'r', 'ř': 'r',
|
||||
'ś': 's', 'š': 's', 'ş': 's', 'ș': 's',
|
||||
'ß': 's', 'þ': 't', 'ť': 't',
|
||||
'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u',
|
||||
'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y',
|
||||
'ź': 'z', 'ż': 'z', 'ž': 'z',
|
||||
}
|
||||
|
||||
// Slugify converts an arbitrary human title into a lower-case, hyphen-separated
|
||||
// slug: "Core Service API" becomes "core-service-api". Diacritics are folded to
|
||||
// ASCII, every other separator run collapses into a single hyphen, and the result
|
||||
// is trimmed and length-capped.
|
||||
func Slugify(title string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(title))
|
||||
|
||||
pendingSep := true // suppresses a leading hyphen
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if folded, ok := latinFold[r]; ok {
|
||||
r = folded
|
||||
}
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
pendingSep = false
|
||||
default:
|
||||
// Everything else -- punctuation, whitespace, non-ASCII letters that
|
||||
// carry no ASCII skeleton, and invalid UTF-8 -- collapses into a single
|
||||
// separator so slugs stay safe in URLs and filenames everywhere.
|
||||
if !pendingSep {
|
||||
b.WriteByte('-')
|
||||
pendingSep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slug := strings.Trim(b.String(), "-")
|
||||
if utf8.RuneCountInString(slug) > maxSlugLen {
|
||||
truncated := slug[:maxSlugLen]
|
||||
for len(truncated) > 0 && truncated[len(truncated)-1] == '-' {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
slug = truncated
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// NormalizeSlug validates and canonicalises a slug coming from a URL path.
|
||||
// Path separators, traversal segments and control characters are rejected
|
||||
// outright with ErrInvalidSlug; anything else is slugified so that
|
||||
// "Core Service API" and "core-service-api" address the same document.
|
||||
func NormalizeSlug(slug string) (string, error) {
|
||||
trimmed := strings.TrimSpace(slug)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%w: slug is empty", ErrInvalidSlug)
|
||||
}
|
||||
if len(trimmed) > 2*maxSlugLen {
|
||||
return "", fmt.Errorf("%w: slug exceeds %d bytes", ErrInvalidSlug, 2*maxSlugLen)
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "/\\\x00") || strings.Contains(trimmed, "..") {
|
||||
return "", fmt.Errorf("%w: slug %q contains path separators", ErrInvalidSlug, slug)
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
if unicode.IsControl(r) {
|
||||
return "", fmt.Errorf("%w: slug %q contains control characters", ErrInvalidSlug, slug)
|
||||
}
|
||||
}
|
||||
|
||||
normalized := Slugify(trimmed)
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("%w: slug %q has no usable characters", ErrInvalidSlug, slug)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
var strictSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// IsCanonicalSlug reports whether slug is already in the canonical form produced
|
||||
// by Slugify. Handlers use it to decide whether a rename touched the filesystem.
|
||||
func IsCanonicalSlug(slug string) bool { return strictSlug.MatchString(slug) }
|
||||
|
||||
func (e *Engine) docPath(slug string) (string, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(e.docsDir, normalized+DocExt), nil
|
||||
}
|
||||
|
||||
// SaveDoc writes content to data/docs/<slug>.md, creating the document when it
|
||||
// does not exist and replacing it wholesale otherwise. The write is atomic and
|
||||
// therefore safe to call concurrently with readers.
|
||||
func (e *Engine) SaveDoc(slug, content string) (string, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
normalizedContent := NormalizeMarkdown(content)
|
||||
if err := writeFileAtomic(path, []byte(normalizedContent), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// NormalizeMarkdown converts CRLF/CR line endings to LF, guarantees a single
|
||||
// trailing newline, and leaves the author's content otherwise untouched.
|
||||
func NormalizeMarkdown(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
normalized = strings.TrimRight(normalized, "\n") + "\n"
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ReadDoc returns the raw Markdown stored at data/docs/<slug>.doc.
|
||||
// A missing document yields ErrNotFound.
|
||||
func (e *Engine) ReadDoc(slug string) (string, error) {
|
||||
path, err := e.docPath(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||
}
|
||||
return "", fmt.Errorf("storage: read doc %q: %w", slug, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DeleteDoc permanently removes data/docs/<slug>.md together with the document's
|
||||
// comment file. Removing a document that does not exist yields ErrNotFound.
|
||||
func (e *Engine) DeleteDoc(slug string) error {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||
}
|
||||
return fmt.Errorf("storage: delete doc %q: %w", slug, err)
|
||||
}
|
||||
|
||||
// Cascade the comment file so a recreated slug does not inherit stale history.
|
||||
commentPath := e.commentPath(normalized)
|
||||
if err := os.Remove(commentPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DocMeta is the index entry describing one stored document.
|
||||
type DocMeta struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Size int64 `json:"size"`
|
||||
Comments int `json:"comments"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
var headingPattern = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$`)
|
||||
|
||||
// ExtractTitle returns the first Markdown heading in content, falling back to a
|
||||
// title-cased rendering of the slug.
|
||||
func ExtractTitle(content, slug string) string {
|
||||
if len(content) > maxTitleScanBytes {
|
||||
content = content[:maxTitleScanBytes]
|
||||
}
|
||||
if m := headingPattern.FindStringSubmatch(content); m != nil {
|
||||
if title := strings.TrimSpace(m[1]); title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
return TitleizeSlug(slug)
|
||||
}
|
||||
|
||||
// TitleizeSlug renders a slug as a human title: "core-service-api" becomes
|
||||
// "Core Service API".
|
||||
func TitleizeSlug(slug string) string {
|
||||
words := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
|
||||
for i, w := range words {
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(strings.ToLower(w))
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
words[i] = string(runes)
|
||||
}
|
||||
if len(words) == 0 {
|
||||
return slug
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// DocExists reports whether a document is present on disk.
|
||||
func (e *Engine) DocExists(slug string) (bool, error) {
|
||||
path, err := e.docPath(slug)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||||
}
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
// Stat returns index metadata for a single document.
|
||||
func (e *Engine) Stat(slug string) (DocMeta, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return DocMeta{}, err
|
||||
}
|
||||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return DocMeta{}, fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||
}
|
||||
return DocMeta{}, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||||
}
|
||||
|
||||
meta := DocMeta{Slug: normalized, Size: info.Size(), Modified: info.ModTime().UTC()}
|
||||
if head, err := os.ReadFile(path); err == nil {
|
||||
meta.Title = ExtractTitle(string(head), normalized)
|
||||
} else {
|
||||
meta.Title = TitleizeSlug(normalized)
|
||||
}
|
||||
if comments, err := e.ListComments(normalized); err == nil {
|
||||
meta.Comments = len(comments)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ListDocs indexes every Markdown document on disk, newest modification first.
|
||||
// It reads only the head of each file to recover the document title, so listing
|
||||
// hundreds of documents stays cheap; use ListDocsDetailed when comment counts are
|
||||
// needed as well.
|
||||
func (e *Engine) ListDocs() ([]DocMeta, error) { return e.listDocs(false) }
|
||||
|
||||
// ListDocsDetailed is ListDocs with per-document comment counts filled in.
|
||||
func (e *Engine) ListDocsDetailed() ([]DocMeta, error) { return e.listDocs(true) }
|
||||
|
||||
func (e *Engine) listDocs(withComments bool) ([]DocMeta, error) {
|
||||
entries, err := os.ReadDir(e.docsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []DocMeta{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage: list docs: %w", err)
|
||||
}
|
||||
|
||||
metas := make([]DocMeta, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, DocExt) || strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSuffix(name, DocExt)
|
||||
if !IsCanonicalSlug(slug) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta := DocMeta{
|
||||
Slug: slug,
|
||||
Title: e.readTitle(filepath.Join(e.docsDir, name), slug),
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().UTC(),
|
||||
}
|
||||
if withComments {
|
||||
if comments, err := e.ListComments(slug); err == nil {
|
||||
meta.Comments = len(comments)
|
||||
}
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
if metas[i].Modified.Equal(metas[j].Modified) {
|
||||
return metas[i].Slug < metas[j].Slug
|
||||
}
|
||||
return metas[i].Modified.After(metas[j].Modified)
|
||||
})
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
// Search returns documents whose slug or extracted title contains query,
|
||||
// case-insensitively. An empty query lists everything.
|
||||
func (e *Engine) Search(query string) ([]DocMeta, error) {
|
||||
metas, err := e.ListDocs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
if needle == "" {
|
||||
return metas, nil
|
||||
}
|
||||
matches := make([]DocMeta, 0, len(metas))
|
||||
for _, meta := range metas {
|
||||
if strings.Contains(strings.ToLower(meta.Slug), needle) ||
|
||||
strings.Contains(strings.ToLower(meta.Title), needle) {
|
||||
matches = append(matches, meta)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// readTitle recovers a document title from the head of the file, reading at most
|
||||
// maxTitleScanBytes so a huge document cannot slow the index down. Any failure
|
||||
// degrades to a title derived from the slug.
|
||||
func (e *Engine) readTitle(path, slug string) string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return TitleizeSlug(slug)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
head := make([]byte, maxTitleScanBytes)
|
||||
n, err := file.Read(head)
|
||||
if n <= 0 || (err != nil && n == 0) {
|
||||
return TitleizeSlug(slug)
|
||||
}
|
||||
return ExtractTitle(string(head[:n]), slug)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temporary file in the same directory,
|
||||
// fsyncs it, renames it into place, and fsyncs the parent directory so the
|
||||
// rename survives a crash. Readers therefore see either the old or the new file.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".godoc-tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("storage: write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("storage: sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage: close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage: chmod temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage: rename into place: %w", err)
|
||||
}
|
||||
if err := syncDir(dir); err != nil {
|
||||
return fmt.Errorf("storage: sync dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDir flushes a directory entry so a completed rename is durable.
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// SaveDoc writes content to data/docs/<slug>.md through the process-wide engine.
|
||||
func SaveDoc(slug, content string) (string, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.SaveDoc(slug, content)
|
||||
}
|
||||
|
||||
// ReadDoc reads data/docs/<slug>.md through the process-wide engine.
|
||||
func ReadDoc(slug string) (string, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.ReadDoc(slug)
|
||||
}
|
||||
|
||||
// DeleteDoc removes data/docs/<slug>.md through the process-wide engine.
|
||||
func DeleteDoc(slug string) error {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.DeleteDoc(slug)
|
||||
}
|
||||
|
||||
// ListDocs indexes documents through the process-wide engine.
|
||||
func ListDocs() ([]DocMeta, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.ListDocs()
|
||||
}
|
||||
578
backend/storage/engine_test.go
Normal file
578
backend/storage/engine_test.go
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
engine, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Core Service API", "core-service-api"},
|
||||
{"core-service-api", "core-service-api"},
|
||||
{" Spaced Out Title ", "spaced-out-title"},
|
||||
{"Deploy & Rollback", "deploy-rollback"},
|
||||
{"API_v2 (Internal)", "api-v2-internal"},
|
||||
{"Release 2024/Q1 -- Notes", "release-2024-q1-notes"},
|
||||
{"Überwachen der Dienste", "uberwachen-der-dienste"},
|
||||
{"Straße", "strase"},
|
||||
{"C++ & Rust: side by side", "c-rust-side-by-side"},
|
||||
{"emoji 🚀 launch", "emoji-launch"},
|
||||
{"multiple---hyphens", "multiple-hyphens"},
|
||||
{"trailing hyphen-", "trailing-hyphen"},
|
||||
{"---leading", "leading"},
|
||||
{"!!!", ""},
|
||||
{"", ""},
|
||||
{"日本語 title", "title"},
|
||||
{"snake_case_and__doubles", "snake-case-and-doubles"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := Slugify(tc.input); got != tc.want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugifyCapsLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("word ", 100)
|
||||
got := Slugify(long)
|
||||
if len(got) > maxSlugLen {
|
||||
t.Fatalf("slug length %d exceeds the %d cap", len(got), maxSlugLen)
|
||||
}
|
||||
if strings.HasSuffix(got, "-") {
|
||||
t.Fatalf("capped slug must not end in a hyphen: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSlug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := map[string]string{
|
||||
"Core Service API": "core-service-api",
|
||||
"core-service-api": "core-service-api",
|
||||
" Spaced ": "spaced",
|
||||
"Ünïcödé Slug": "unicode-slug",
|
||||
}
|
||||
for input, want := range valid {
|
||||
got, err := NormalizeSlug(input)
|
||||
if err != nil {
|
||||
t.Errorf("NormalizeSlug(%q) returned %v", input, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("NormalizeSlug(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
" ",
|
||||
"../etc/passwd",
|
||||
"..",
|
||||
"nested/path",
|
||||
"back\\slash",
|
||||
"null\x00byte",
|
||||
"!!!",
|
||||
strings.Repeat("x", 2*maxSlugLen+1),
|
||||
}
|
||||
for _, input := range invalid {
|
||||
if _, err := NormalizeSlug(input); !errors.Is(err, ErrInvalidSlug) {
|
||||
t.Errorf("NormalizeSlug(%q) should be rejected, got %v", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveReadDeleteDoc(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
slug, err := engine.SaveDoc("Core Service API", "# Core Service API\n\nBody.\n")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDoc: %v", err)
|
||||
}
|
||||
if slug != "core-service-api" {
|
||||
t.Fatalf("expected core-service-api, got %q", slug)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(engine.DocsDir(), "core-service-api.md")); err != nil {
|
||||
t.Fatalf("Markdown file missing: %v", err)
|
||||
}
|
||||
|
||||
content, err := engine.ReadDoc("Core Service API")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDoc: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "Body.") {
|
||||
t.Fatalf("unexpected content: %q", content)
|
||||
}
|
||||
|
||||
if _, err := engine.ReadDoc("never-saved"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
if err := engine.DeleteDoc("Core Service API"); err != nil {
|
||||
t.Fatalf("DeleteDoc: %v", err)
|
||||
}
|
||||
if _, err := engine.ReadDoc("core-service-api"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
if err := engine.DeleteDoc("core-service-api"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound on a second delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocCascadesComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.SaveDoc("doomed", "# Doomed\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.AppendComment("doomed", CommentInput{Author: "a", Role: "user", Content: "hi"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
commentPath := filepath.Join(engine.CommentsDir(), "doomed-comments.json")
|
||||
if _, err := os.Stat(commentPath); err != nil {
|
||||
t.Fatalf("expected the comment file to exist: %v", err)
|
||||
}
|
||||
|
||||
if err := engine.DeleteDoc("doomed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(commentPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected the comment file to be removed, stat returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDocNormalizesLineEndings(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.SaveDoc("crlf", "line one\r\nline two\r\n\r\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := engine.ReadDoc("crlf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(content, "\r") {
|
||||
t.Fatalf("carriage returns survived: %q", content)
|
||||
}
|
||||
if content != "line one\nline two\n" {
|
||||
t.Fatalf("unexpected normalization: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBadges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[Role]string{
|
||||
RoleUser: "🟢",
|
||||
RoleOperator: "🔵",
|
||||
RoleDeveloper: "🔴",
|
||||
}
|
||||
for role, badge := range cases {
|
||||
if role.Badge() != badge {
|
||||
t.Errorf("role %s badge = %q, want %q", role, role.Badge(), badge)
|
||||
}
|
||||
if !role.Valid() {
|
||||
t.Errorf("role %s should be valid", role)
|
||||
}
|
||||
}
|
||||
if Role("admin").Badge() != "" || Role("admin").Valid() {
|
||||
t.Error("an unknown role must have no badge and be invalid")
|
||||
}
|
||||
|
||||
for _, raw := range []string{"user", "USER", " User "} {
|
||||
if role, err := ParseRole(raw); err != nil || role != RoleUser {
|
||||
t.Errorf("ParseRole(%q) = %v, %v", raw, role, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseRole("root"); !errors.Is(err, ErrInvalidRole) {
|
||||
t.Errorf("expected ErrInvalidRole, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendCommentValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.AppendComment("valid", CommentInput{Role: "developer", Content: " "}); !errors.Is(err, ErrInvalidComment) {
|
||||
t.Errorf("blank content should be rejected, got %v", err)
|
||||
}
|
||||
if _, err := engine.AppendComment("valid", CommentInput{Role: "wizard", Content: "hello"}); !errors.Is(err, ErrInvalidRole) {
|
||||
t.Errorf("unknown role should be rejected, got %v", err)
|
||||
}
|
||||
if _, err := engine.AppendComment("../escape", CommentInput{Role: "user", Content: "hi"}); !errors.Is(err, ErrInvalidSlug) {
|
||||
t.Errorf("traversal slug should be rejected, got %v", err)
|
||||
}
|
||||
|
||||
comment, err := engine.AppendComment("valid", CommentInput{Role: "user", Content: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendComment: %v", err)
|
||||
}
|
||||
if comment.Author != "User" {
|
||||
t.Errorf("an omitted author should default to the role label, got %q", comment.Author)
|
||||
}
|
||||
if comment.Badge != "🟢" {
|
||||
t.Errorf("expected the user badge, got %q", comment.Badge)
|
||||
}
|
||||
if comment.ID == "" || comment.Timestamp.IsZero() {
|
||||
t.Error("expected an ID and a timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentsAreSortedAndDeduplicatedByID(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
for i, offset := range []int{30, 10, 20} {
|
||||
if _, err := engine.AppendComment("ordered", CommentInput{
|
||||
Author: fmt.Sprintf("user-%d", i),
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("comment %d", i),
|
||||
Timestamp: base.Add(time.Duration(offset) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
comments, err := engine.ListComments("ordered")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(comments) != 3 {
|
||||
t.Fatalf("expected 3 comments, got %d", len(comments))
|
||||
}
|
||||
for i := 1; i < len(comments); i++ {
|
||||
if comments[i].Timestamp.Before(comments[i-1].Timestamp) {
|
||||
t.Fatalf("comments are not in chronological order: %+v", comments)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(comments[0].Content) != "comment 1" {
|
||||
t.Fatalf("expected the earliest comment first, got %q", comments[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteComment(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
first, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "first"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed, err := engine.DeleteComment("prune", first.ID)
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("expected the comment to be removed, got %v / %v", removed, err)
|
||||
}
|
||||
removed, err = engine.DeleteComment("prune", "does-not-exist")
|
||||
if err != nil || removed {
|
||||
t.Fatalf("deleting an unknown ID must be a no-op, got %v / %v", removed, err)
|
||||
}
|
||||
|
||||
remaining, err := engine.ListComments("prune")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remaining) != 1 || strings.TrimSpace(remaining[0].Content) != "second" {
|
||||
t.Fatalf("unexpected remaining comments: %+v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndSearchDocs(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
for _, title := range []string{"Alpha Service", "Beta Service", "Gamma Gateway"} {
|
||||
if _, err := engine.SaveDoc(title, "# "+title+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := engine.AppendComment("alpha-service", CommentInput{Role: "user", Content: "hi"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all, err := engine.ListDocs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 documents, got %d", len(all))
|
||||
}
|
||||
// Titles come from the document's H1, not from a slug-derived guess.
|
||||
for _, doc := range all {
|
||||
if doc.Title != "Alpha Service" && doc.Title != "Beta Service" && doc.Title != "Gamma Gateway" {
|
||||
t.Fatalf("document %s has title %q; expected the H1 heading", doc.Slug, doc.Title)
|
||||
}
|
||||
}
|
||||
|
||||
detailed, err := engine.ListDocsDetailed()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, doc := range detailed {
|
||||
if doc.Slug == "alpha-service" && doc.Comments != 1 {
|
||||
t.Fatalf("expected 1 comment on alpha-service, got %d", doc.Comments)
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := engine.Search("gateway")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Slug != "gamma-gateway" {
|
||||
t.Fatalf("unexpected search hits: %+v", hits)
|
||||
}
|
||||
|
||||
// Temp files left by an interrupted write must never surface in the index.
|
||||
if err := os.WriteFile(filepath.Join(engine.DocsDir(), ".godoc-tmp-123"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(engine.DocsDir(), "not-markdown.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err = engine.ListDocs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("index must ignore temp and non-Markdown files, got %d entries", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTitleAndTitleize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
content string
|
||||
slug string
|
||||
want string
|
||||
}{
|
||||
{"# Core Service API\n\nbody", "core-service-api", "Core Service API"},
|
||||
{"## Only an H2\n\nbody", "only-an-h2", "Only an H2"},
|
||||
{"no heading here", "plain-slug", "Plain Slug"},
|
||||
{"", "empty-slug", "Empty Slug"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ExtractTitle(tc.content, tc.slug); got != tc.want {
|
||||
t.Errorf("ExtractTitle(%q, %q) = %q, want %q", tc.content, tc.slug, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAppendsLoseNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
const (
|
||||
writers = 50
|
||||
perDoc = 40
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
|
||||
for w := 0; w < writers; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
role := Roles()[worker%3]
|
||||
for n := 0; n < perDoc; n++ {
|
||||
if _, err := engine.AppendComment("hot", CommentInput{
|
||||
Author: fmt.Sprintf("worker-%02d", worker),
|
||||
Role: string(role),
|
||||
Content: fmt.Sprintf("note %d from %d", n, worker),
|
||||
}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent append failed: %v", err)
|
||||
}
|
||||
|
||||
comments, err := engine.ListComments("hot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(comments) != writers*perDoc {
|
||||
t.Fatalf("expected %d comments, got %d", writers*perDoc, len(comments))
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(comments))
|
||||
for _, comment := range comments {
|
||||
key := comment.Author + "|" + comment.Content
|
||||
if _, dup := seen[key]; dup {
|
||||
t.Fatalf("duplicate entry after concurrent writes: %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsNeverSeeTornWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
const marker = "SENTINEL-HEADER\n"
|
||||
body := strings.Repeat("payload line\n", 500)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 32)
|
||||
|
||||
for w := 0; w < 16; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 25; n++ {
|
||||
if _, err := engine.SaveDoc("live", marker+body); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
for r := 0; r < 16; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 100; n++ {
|
||||
content, err := engine.ReadDoc("live")
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
errs <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(content, marker) {
|
||||
errs <- fmt.Errorf("torn read: %q", content[:min(len(content), 40)])
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent access failed: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(engine.DocsDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
|
||||
t.Fatalf("atomic write leaked a temp file: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.SaveDoc("counted", "# Counted\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, role := range []Role{RoleUser, RoleUser, RoleOperator, RoleDeveloper, RoleDeveloper, RoleDeveloper} {
|
||||
if _, err := engine.AppendComment("counted", CommentInput{
|
||||
Author: fmt.Sprintf("author-%d", i),
|
||||
Role: string(role),
|
||||
Content: fmt.Sprintf("comment %d", i),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := engine.Stats("counted")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Total != 6 {
|
||||
t.Fatalf("expected 6 comments, got %d", stats.Total)
|
||||
}
|
||||
if stats.ByRole["developer"] != 3 || stats.ByRole["user"] != 2 || stats.ByRole["operator"] != 1 {
|
||||
t.Fatalf("unexpected tally: %+v", stats.ByRole)
|
||||
}
|
||||
if stats.MostActive != "developer" {
|
||||
t.Fatalf("expected developer as the most active role, got %q", stats.MostActive)
|
||||
}
|
||||
if stats.OldestAt == nil || stats.LatestAt == nil {
|
||||
t.Fatal("expected the time bounds to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSatisfiesDocumentedInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
exists, err := engine.DocExists("Core Service API")
|
||||
if err != nil || exists {
|
||||
t.Fatalf("DocExists before save = %v / %v", exists, err)
|
||||
}
|
||||
if _, err := engine.SaveDoc("Core Service API", "# Core Service API\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exists, err = engine.DocExists("core-service-api")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("DocExists after save = %v / %v", exists, err)
|
||||
}
|
||||
|
||||
meta, err := engine.Stat("core-service-api")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.Title != "Core Service API" {
|
||||
t.Fatalf("Stat title = %q", meta.Title)
|
||||
}
|
||||
if meta.Size <= 0 || meta.Modified.IsZero() {
|
||||
t.Fatalf("Stat returned incomplete metadata: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRootHonoursEnvironment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GO_DOC_DATA_DIR", dir)
|
||||
if got := ResolveRoot(); got != dir {
|
||||
t.Fatalf("ResolveRoot() = %q, want %q", got, dir)
|
||||
}
|
||||
|
||||
if _, err := New(" "); err == nil {
|
||||
t.Fatal("New must reject an empty root")
|
||||
}
|
||||
}
|
||||
295
backend/tests/bdd/documents.feature_test.go
Normal file
295
backend/tests/bdd/documents.feature_test.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// TestFeatureSlugifiedMarkdownStorage covers:
|
||||
//
|
||||
// Given a new application title
|
||||
// When it is saved
|
||||
// Then it should be slugified and written to a Markdown file
|
||||
func TestFeatureSlugifiedMarkdownStorage(t *testing.T) {
|
||||
Run(t, Feature{
|
||||
Name: "Slugified Markdown storage",
|
||||
Scenarios: []Scenario{
|
||||
{
|
||||
Name: "a new application title is slugified and written to a Markdown file",
|
||||
Given: Steps{
|
||||
{Text: `a new application title "Core Service API"`, Do: func(c *Context) error {
|
||||
return ExpectEqual(storage.Slugify("Core Service API"), "core-service-api", "slugified title")
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the title is saved", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nHandles billing.\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the engine reports the canonical slug", Do: func(c *Context) error {
|
||||
meta, err := c.Engine.Stat("Core Service API")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(meta.Slug, "core-service-api", "canonical slug")
|
||||
}},
|
||||
{Text: "data/docs/core-service-api.md exists", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("core-service-api"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errf("expected %s to exist", c.DocPath("core-service-api"))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the Markdown content is stored verbatim", Do: func(c *Context) error {
|
||||
content, err := c.RawDoc("core-service-api")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectContains(content, "# Core Service API", "stored markdown")
|
||||
}},
|
||||
{Text: "no file is created for the un-slugified title", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("Core Service API"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("a non-slugified filename was created")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "titles with punctuation, casing and diacritics are normalised",
|
||||
Given: Steps{
|
||||
{Text: "a batch of awkward application titles", Do: func(c *Context) error {
|
||||
cases := map[string]string{
|
||||
" Core Service API ": "core-service-api",
|
||||
"Deploy & Rollback": "deploy-rollback",
|
||||
"Überwachen der Dienste": "uberwachen-der-dienste",
|
||||
"API_v2 (Internal)": "api-v2-internal",
|
||||
"!!!": "",
|
||||
"Release 2024/Q1 -- Notes": "release-2024-q1-notes",
|
||||
"User Facing / End Users": "user-facing-end-users",
|
||||
"already-a-canonical-slug-1": "already-a-canonical-slug-1",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := storage.Slugify(input); got != want {
|
||||
return errf("Slugify(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "each title is saved", Do: func(c *Context) error {
|
||||
for _, title := range []string{"Deploy & Rollback", "Überwachen der Dienste", "API_v2 (Internal)"} {
|
||||
if _, err := c.Engine.SaveDoc(title, "# "+title+"\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "each slugified filename exists on disk", Do: func(c *Context) error {
|
||||
for _, slug := range []string{"deploy-rollback", "uberwachen-der-dienste", "api-v2-internal"} {
|
||||
exists, err := c.FileExists(c.DocPath(slug))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errf("expected data/docs/%s.md to exist", slug)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "saving through the API slugifies the slug taken from the URL",
|
||||
Given: Steps{
|
||||
{Text: "the API is running against a temporary data directory", Do: func(c *Context) error {
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a document is POSTed to /api/docs/Core Service API`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/Core%20Service%20API", "text/markdown", "# Core Service API\n",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 201); err != nil {
|
||||
return errf("%w (body: %s)", err, payload)
|
||||
}
|
||||
saved, err := Decode[struct {
|
||||
Slug string `json:"slug"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("slug", saved.Slug)
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the response reports the slugified slug", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[string]("slug"), "core-service-api", "response slug")
|
||||
}},
|
||||
{Text: "the Markdown file is on disk", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("core-service-api"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errf("data/docs/core-service-api.md was not created")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "GET /api/docs/core-service-api returns the raw Markdown", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/core-service-api", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
if contentType := response.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/markdown") {
|
||||
return errf("expected text/markdown, got %q", contentType)
|
||||
}
|
||||
return ExpectContains(string(payload), "# Core Service API", "markdown response")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "saving an existing document replaces its content wholesale",
|
||||
Given: Steps{
|
||||
{Text: "an existing document with two sections", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("release-runbook", "# Runbook\n\n## Deploy\n\nstep\n\n## Rollback\n\nstep\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the document is rewritten with a single section", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("POST", "/api/docs/release-runbook", "text/markdown", "# Runbook v2\n\n## Deploy only\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 200)
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the previous sections are gone", Do: func(c *Context) error {
|
||||
content, err := c.RawDoc("release-runbook")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(content, "## Rollback") {
|
||||
return errf("stale section survived the update: %q", content)
|
||||
}
|
||||
return ExpectContains(content, "## Deploy only", "updated markdown")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "deleting a document removes the file and its feedback",
|
||||
Given: Steps{
|
||||
{Text: "a document that already has comments", Do: func(c *Context) error {
|
||||
if _, err := c.Engine.SaveDoc("obsolete-service", "# Obsolete\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := c.Engine.AppendComment("obsolete-service", storage.CommentInput{
|
||||
Author: "ana",
|
||||
Role: "user",
|
||||
Content: "is this still needed?",
|
||||
})
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "DELETE /api/docs/obsolete-service is called", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("DELETE", "/api/docs/obsolete-service", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 204)
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the Markdown file is gone", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("obsolete-service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("data/docs/obsolete-service.md still exists")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the comment file is gone", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.CommentsPath("obsolete-service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("the feedback file was not cascaded")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "reading the deleted document returns 404", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("GET", "/api/docs/obsolete-service", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 404)
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "path traversal in a slug is rejected",
|
||||
Given: Steps{
|
||||
{Text: "a request for a traversal slug", Do: func(c *Context) error {
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `POST /api/docs/..%2F..%2Fetc%2Fpasswd is sent`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("POST", "/api/docs/..%2F..%2Fetc%2Fpasswd", "text/markdown", "nope")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("status", response.StatusCode)
|
||||
c.Remember("body", string(payload))
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the request is rejected with 400", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[int]("status"), 400, "status code")
|
||||
}},
|
||||
{Text: "nothing is written outside data/docs", Do: func(c *Context) error {
|
||||
for _, name := range []string{"etc", "passwd", ".."} {
|
||||
exists, err := c.FileExists(c.DocPath(name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("unexpected file created for slug %q", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
360
backend/tests/bdd/feedback.feature_test.go
Normal file
360
backend/tests/bdd/feedback.feature_test.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// TestFeatureBadgedCommentInjection covers:
|
||||
//
|
||||
// Given an existing documentation page
|
||||
// When a developer adds a comment with a developer badge
|
||||
// Then it should be appended to the JSON array and rendered with a red visual badge
|
||||
func TestFeatureBadgedCommentInjection(t *testing.T) {
|
||||
Run(t, Feature{
|
||||
Name: "Badged comment injection",
|
||||
Scenarios: []Scenario{
|
||||
{
|
||||
Name: "a developer comment is appended to the JSON array with a red badge",
|
||||
Given: Steps{
|
||||
{Text: "an existing documentation page for Core Service API", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nBilling endpoints.\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "a developer posts a comment through POST /api/docs/core-service-api/comments", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/core-service-api/comments", "application/json",
|
||||
`{"author":"ana","role":"Developer","content":"The retry budget is undocumented."}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 201); err != nil {
|
||||
return errf("%w (body: %s)", err, payload)
|
||||
}
|
||||
created, err := Decode[struct {
|
||||
Comment storage.Comment `json:"comment"`
|
||||
Badge string `json:"badge"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("comment", created.Comment)
|
||||
c.Remember("badge", created.Badge)
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the comment is stored with the developer role", Do: func(c *Context) error {
|
||||
comment := c.RecallAs[storage.Comment]("comment")
|
||||
return ExpectEqual(comment.Role, storage.RoleDeveloper, "stored role")
|
||||
}},
|
||||
{Text: "the comment carries the red 🔴 developer badge", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[string]("badge"), badgeContract[storage.RoleDeveloper], "response badge")
|
||||
}},
|
||||
{Text: "data/comments/core-service-api-comments.json is a JSON array containing it", Do: func(c *Context) error {
|
||||
raw, err := c.RawJSON("core-service-api")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimLeft(string(raw), " \n\t"), JSONArrayPrefix) {
|
||||
return errf("feedback file is not a JSON array: %q", string(raw))
|
||||
}
|
||||
comments, err := c.RawComments("core-service-api")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
return errf("expected 1 stored comment, got %d", len(comments))
|
||||
}
|
||||
stored := comments[0]
|
||||
if stored.ID == "" {
|
||||
return errf("stored comment has no ID")
|
||||
}
|
||||
if stored.Author != "ana" {
|
||||
return errf("expected author ana, got %q", stored.Author)
|
||||
}
|
||||
if !strings.Contains(stored.Content, "retry budget") {
|
||||
return errf("stored content mismatch: %q", stored.Content)
|
||||
}
|
||||
return ExpectEqual(stored.Badge, badgeContract[storage.RoleDeveloper], "stored badge")
|
||||
}},
|
||||
{Text: "the badge renders in the red developer colour #ef4444", Do: func(c *Context) error {
|
||||
role := c.RecallAs[storage.Comment]("comment").Role
|
||||
return ExpectEqual(role.Hex(), badgeHex[storage.RoleDeveloper], "badge colour")
|
||||
}},
|
||||
{Text: "GET /api/docs/core-service-api/comments returns the badged comment", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/core-service-api/comments", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
listed, err := Decode[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
Count int `json:"count"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if listed.Count != 1 {
|
||||
return errf("expected count 1, got %d", listed.Count)
|
||||
}
|
||||
return ExpectEqual(listed.Comments[0].Badge, "🔴", "listed badge")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "all three audience badges are applied to their own roles",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page with an existing user comment", Do: func(c *Context) error {
|
||||
if _, err := c.Engine.SaveDoc("deploy-pipeline", "# Deploy Pipeline\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := appendComment(c, "deploy-pipeline", storage.RoleUser, "sam", "The wizard is confusing")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "an operator and a developer comment are added", Do: func(c *Context) error {
|
||||
if _, err := appendComment(c, "deploy-pipeline", storage.RoleOperator, "robin", "Rollback takes 20 minutes"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := appendComment(c, "deploy-pipeline", storage.RoleDeveloper, "kit", "Add a --dry-run flag")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the JSON array holds all three comments in insertion order", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("deploy-pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 3 {
|
||||
return errf("expected 3 comments, got %d", len(comments))
|
||||
}
|
||||
want := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||
for i, role := range want {
|
||||
if comments[i].Role != role {
|
||||
return errf("comment %d: expected role %s, got %s", i, role, comments[i].Role)
|
||||
}
|
||||
if comments[i].Badge != badgeContract[role] {
|
||||
return errf("comment %d: expected badge %s, got %s", i, badgeContract[role], comments[i].Badge)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "each badge matches its role colour", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("deploy-pipeline")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if comment.Role.Hex() != badgeHex[comment.Role] {
|
||||
return errf("unexpected colour for %s", comment.Role)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "GET /api/docs/deploy-pipeline/stats aggregates by role", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/deploy-pipeline/stats", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := Decode[storage.CommentStats](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stats.Total != 3 {
|
||||
return errf("expected total 3, got %d", stats.Total)
|
||||
}
|
||||
for _, role := range []string{"user", "operator", "developer"} {
|
||||
if stats.ByRole[role] != 1 {
|
||||
return errf("expected 1 %s comment, got %d", role, stats.ByRole[role])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "the badge is derived server-side and cannot be spoofed by the client",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("trust-boundary", "# Trust Boundary\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a developer posts a comment claiming the user badge "🟢"`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/trust-boundary/comments", "application/json",
|
||||
`{"author":"mallory","role":"developer","content":"I am a user, honest","badge":"🟢"}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 201); err != nil {
|
||||
return errf("%w (body: %s)", err, payload)
|
||||
}
|
||||
c.Remember("payload", string(payload))
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the server overrides the claim with the developer badge", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("trust-boundary")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
return errf("expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
return ExpectEqual(comments[0].Badge, "🔴", "server-assigned badge")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "an unknown role is rejected with a 400",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("role-guard", "# Role Guard\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a comment is posted with role "root"`, Do: func(c *Context) error {
|
||||
response, payload, err := c.Do(
|
||||
"POST", "/api/docs/role-guard/comments", "application/json",
|
||||
`{"author":"mallory","role":"root","content":"let me in"}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("status", response.StatusCode)
|
||||
c.Remember("body", string(payload))
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the API answers 400 with the invalid_role code", Do: func(c *Context) error {
|
||||
if err := ExpectEqual(c.RecallAs[int]("status"), 400, "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectContains(c.RecallAs[string]("body"), "invalid_role", "error body")
|
||||
}},
|
||||
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.CommentsPath("role-guard"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("a feedback file was created for a rejected comment")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "an empty comment is rejected",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("empty-guard", "# Empty Guard\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: `a comment with blank content is posted`, Do: func(c *Context) error {
|
||||
response, _, err := c.Do(
|
||||
"POST", "/api/docs/empty-guard/comments", "application/json",
|
||||
`{"author":"ana","role":"operator","content":" "}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Remember("status", response.StatusCode)
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the API answers 400", Do: func(c *Context) error {
|
||||
return ExpectEqual(c.RecallAs[int]("status"), 400, "status")
|
||||
}},
|
||||
{Text: "no feedback file is created", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.CommentsPath("empty-guard"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("a feedback file was created for a rejected comment")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "a comment on an unknown slug still creates the feedback file",
|
||||
Given: Steps{
|
||||
{Text: "no document exists for the slug new-service", Do: func(c *Context) error {
|
||||
exists, err := c.FileExists(c.DocPath("new-service"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return errf("the document should not exist yet")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the first user comment is posted for new-service", Do: func(c *Context) error {
|
||||
response, _, err := c.Do(
|
||||
"POST", "/api/docs/new-service/comments", "application/json",
|
||||
`{"author":"jo","role":"user","content":"Is this documented?"}`,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 201)
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the feedback file holds the user badge 🟢", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("new-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
return errf("expected 1 comment, got %d", len(comments))
|
||||
}
|
||||
return ExpectEqual(comments[0].Badge, "🟢", "badge")
|
||||
}},
|
||||
{Text: "reading the comments of a slug with no feedback returns an empty list", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/never-seen/comments", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
listed, err := Decode[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(len(listed.Comments), 0, "comment count")
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
285
backend/tests/bdd/framework.go
Normal file
285
backend/tests/bdd/framework.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// Package bdd is a dependency-free BDD harness for the go-doc backend.
|
||||
//
|
||||
// Features are declared as Gherkin-shaped Given/When/Then scenarios and executed
|
||||
// against the real storage engine and a real HTTP server backed by a temporary
|
||||
// directory on disk. There are no mocks and no fakes: every Then step asserts on
|
||||
// bytes that were actually written to the filesystem.
|
||||
package bdd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-doc/router"
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// Step is a single Gherkin clause. Text is used verbatim as the subtest name, so
|
||||
// a failing run reads as a sentence.
|
||||
type Step struct {
|
||||
Text string
|
||||
Do func(c *Context) error
|
||||
}
|
||||
|
||||
func (s Step) run(c *Context) error {
|
||||
if err := s.Do(c); err != nil {
|
||||
return fmt.Errorf("%s: %w", s.Text, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Steps is an ordered clause list.
|
||||
type Steps []Step
|
||||
|
||||
func (s Steps) run(c *Context) error {
|
||||
for _, step := range s {
|
||||
if err := step.run(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scenario is one executable example.
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Given Steps
|
||||
When Steps
|
||||
Then Steps
|
||||
}
|
||||
|
||||
// Feature groups the scenarios for one business capability.
|
||||
type Feature struct {
|
||||
Name string
|
||||
Scenarios []Scenario
|
||||
}
|
||||
|
||||
// Context carries the world a scenario runs against: a private data directory, a
|
||||
// storage engine, and a live HTTP server. Steps share state through Remember and
|
||||
// Recall so a When clause can hand data to a Then clause.
|
||||
type Context struct {
|
||||
T *testing.T
|
||||
DataDir string
|
||||
Engine *storage.Engine
|
||||
Server *httptest.Server
|
||||
Client *http.Client
|
||||
|
||||
// StartedAt timestamps scenario creation so load steps can assert a budget.
|
||||
StartedAt time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
state map[string]any
|
||||
}
|
||||
|
||||
// NewContext provisions an isolated data directory and a live API server.
|
||||
func NewContext(t *testing.T) *Context {
|
||||
t.Helper()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("bdd: provision storage engine: %v", err)
|
||||
}
|
||||
|
||||
handler, err := router.NewWithConfig(router.Config{
|
||||
Engine: engine,
|
||||
Logger: quietLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("bdd: build router: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return &Context{
|
||||
T: t,
|
||||
StartedAt: time.Now(),
|
||||
DataDir: dataDir,
|
||||
Engine: engine,
|
||||
Server: server,
|
||||
Client: server.Client(),
|
||||
state: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Remember stores a value for later steps in the same scenario.
|
||||
func (c *Context) Remember(key string, value any) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.state[key] = value
|
||||
}
|
||||
|
||||
// Recall reads a remembered value, failing the step if it is absent or nil.
|
||||
func (c *Context) Recall(key string) any {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
value, ok := c.state[key]
|
||||
if !ok || value == nil {
|
||||
c.T.Fatalf("bdd: scenario state has no value for %q", key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// RecallAs is Recall with a type assertion.
|
||||
func (c *Context) RecallAs[T any](key string) T {
|
||||
value, ok := c.Recall(key).(T)
|
||||
if !ok {
|
||||
c.T.Fatalf("bdd: state %q is %T, not the requested type", key, c.Recall(key))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// DocPath is the on-disk location a slug must land on.
|
||||
func (c *Context) DocPath(slug string) string {
|
||||
return filepath.Join(c.DataDir, "docs", slug+".md")
|
||||
}
|
||||
|
||||
// CommentsPath is the on-disk location a slug's feedback must land on.
|
||||
func (c *Context) CommentsPath(slug string) string {
|
||||
return filepath.Join(c.DataDir, "comments", slug+"-comments.json")
|
||||
}
|
||||
|
||||
// RawDoc reads the Markdown file straight off disk, bypassing the API.
|
||||
func (c *Context) RawDoc(slug string) (string, error) {
|
||||
data, err := os.ReadFile(c.DocPath(slug))
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
// RawComments decodes the JSON array straight off disk, bypassing the API.
|
||||
func (c *Context) RawComments(slug string) ([]storage.Comment, error) {
|
||||
data, err := os.ReadFile(c.CommentsPath(slug))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var comments []storage.Comment
|
||||
if err := json.Unmarshal(data, &comments); err != nil {
|
||||
return nil, fmt.Errorf("feedback file is not a valid JSON array: %w", err)
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// RawJSON returns the undecoded bytes of the feedback file.
|
||||
func (c *Context) RawJSON(slug string) ([]byte, error) {
|
||||
return os.ReadFile(c.CommentsPath(slug))
|
||||
}
|
||||
|
||||
// Do performs an HTTP request against the live server and returns the response
|
||||
// together with its body.
|
||||
func (c *Context) Do(method, path, contentType string, body string) (*http.Response, []byte, error) {
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
request, err := http.NewRequest(method, c.Server.URL+path, reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response, err := c.Client.Do(request)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return response, nil, err
|
||||
}
|
||||
return response, payload, nil
|
||||
}
|
||||
|
||||
// Decode unmarshals a JSON response body.
|
||||
func Decode[T any](payload []byte) (T, error) {
|
||||
var out T
|
||||
err := json.Unmarshal(payload, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ExpectStatus fails the step unless the response carries the wanted status.
|
||||
func ExpectStatus(response *http.Response, want int) error {
|
||||
if response == nil {
|
||||
return fmt.Errorf("no response")
|
||||
}
|
||||
if response.StatusCode != want {
|
||||
return fmt.Errorf("expected HTTP %d, got %d", want, response.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpectContains fails the step unless haystack contains needle.
|
||||
func ExpectContains(haystack, needle, label string) error {
|
||||
if !strings.Contains(haystack, needle) {
|
||||
return fmt.Errorf("%s: expected to contain %q, got %q", label, needle, haystack)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExpectEqual fails the step unless got equals want.
|
||||
func ExpectEqual[T comparable](got, want T, label string) error {
|
||||
if got != want {
|
||||
return fmt.Errorf("%s: expected %v, got %v", label, want, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileExists reports whether path is present on disk.
|
||||
func (c *Context) FileExists(path string) (bool, error) {
|
||||
_, err := os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Run executes a feature: every scenario gets a fresh Context, and every clause
|
||||
// becomes its own subtest so failures point at the exact sentence that broke.
|
||||
func Run(t *testing.T, feature Feature) {
|
||||
t.Helper()
|
||||
|
||||
t.Run(feature.Name, func(t *testing.T) {
|
||||
for _, scenario := range feature.Scenarios {
|
||||
scenario := scenario
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
c := NewContext(t)
|
||||
|
||||
phase(t, "Given", c, scenario.Given)
|
||||
phase(t, "When", c, scenario.When)
|
||||
phase(t, "Then", c, scenario.Then)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func phase(t *testing.T, keyword string, c *Context, steps Steps) {
|
||||
t.Helper()
|
||||
for _, step := range steps {
|
||||
t.Run(keyword+" "+step.Text, func(t *testing.T) {
|
||||
if err := step.Do(c); err != nil {
|
||||
t.Fatalf("%s %s\n%s", keyword, step.Text, indent(err.Error()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func indent(text string) string {
|
||||
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = " " + line
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
61
backend/tests/bdd/helpers_test.go
Normal file
61
backend/tests/bdd/helpers_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
func errf(format string, args ...any) error {
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// Badge assertions shared by the feedback features, keyed to the role -> badge
|
||||
// contract mandated by the specification: User 🟢, Operator 🔵, Developer 🔴.
|
||||
var badgeContract = map[storage.Role]string{
|
||||
storage.RoleUser: "🟢",
|
||||
storage.RoleOperator: "🔵",
|
||||
storage.RoleDeveloper: "🔴",
|
||||
}
|
||||
|
||||
// Hex colours the frontend mirrors when it renders the badge.
|
||||
var badgeHex = map[storage.Role]string{
|
||||
storage.RoleUser: "#22c55e",
|
||||
storage.RoleOperator: "#3b82f6",
|
||||
storage.RoleDeveloper: "#ef4444",
|
||||
}
|
||||
|
||||
// JSONArrayPrefix is the first byte of the on-disk feedback file. The spec
|
||||
// requires the file to *be* the JSON array, so every write starts with "[".
|
||||
const JSONArrayPrefix = `[`
|
||||
|
||||
func appendComment(c *Context, slug string, role storage.Role, author, content string) (storage.Comment, error) {
|
||||
return c.Engine.AppendComment(slug, storage.CommentInput{
|
||||
Author: author,
|
||||
Role: string(role),
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// listDir returns the entry names of a directory, for temp-file leak checks.
|
||||
func listDir(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// truncate shortens a document for readable failure output.
|
||||
func truncate(text string) string {
|
||||
const limit = 120
|
||||
if len(text) <= limit {
|
||||
return text
|
||||
}
|
||||
return text[:limit] + "…"
|
||||
}
|
||||
417
backend/tests/bdd/load.feature_test.go
Normal file
417
backend/tests/bdd/load.feature_test.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// TestFeatureFileUpdatesUnderLoad covers:
|
||||
//
|
||||
// Given many documents and thousands of comments in flight
|
||||
// When they are written concurrently through the API
|
||||
// Then every update lands on disk with no torn or lost writes
|
||||
func TestFeatureFileUpdatesUnderLoad(t *testing.T) {
|
||||
Run(t, Feature{
|
||||
Name: "File updates under load",
|
||||
Scenarios: []Scenario{
|
||||
{
|
||||
Name: "concurrent comment injection loses no entry",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page with 400 concurrent feedback writers", Do: func(c *Context) error {
|
||||
_, err := c.Engine.SaveDoc("hot-service", "# Hot Service\n\n## For Operators\n\nScaling notes.\n")
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "400 comments are appended concurrently across all three roles", Do: func(c *Context) error {
|
||||
const writers = 400
|
||||
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
start := make(chan struct{})
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
<-start // release every writer at once
|
||||
role := roles[index%len(roles)]
|
||||
if _, err := appendComment(c, "hot-service", role,
|
||||
fmt.Sprintf("user-%03d", index),
|
||||
fmt.Sprintf("finding number %d", index),
|
||||
); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "all 400 comments are present in the JSON array", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 400 {
|
||||
return errf("expected 400 persisted comments, got %d", len(comments))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "no comment was lost to a read-modify-write race", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(comments))
|
||||
for _, comment := range comments {
|
||||
key := fmt.Sprintf("%s|%s", comment.Author, comment.Content)
|
||||
if _, duplicate := seen[key]; duplicate {
|
||||
return errf("duplicate entry survived: %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
if len(seen) != 400 {
|
||||
return errf("expected 400 distinct entries, got %d", len(seen))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "every comment carries the badge for its own role", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if comment.Badge != badgeContract[comment.Role] {
|
||||
return errf("comment %s has badge %q, want %q",
|
||||
comment.ID, comment.Badge, badgeContract[comment.Role])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the file on disk is a single valid JSON array, never a torn write", Do: func(c *Context) error {
|
||||
raw, err := c.RawJSON("hot-service")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") ||
|
||||
!strings.HasSuffix(strings.TrimSpace(string(raw)), "]") {
|
||||
return errf("feedback file is not a well-formed JSON array")
|
||||
}
|
||||
var decoded []storage.Comment
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
return errf("feedback file failed to decode: %w", err)
|
||||
}
|
||||
return ExpectEqual(len(decoded), 400, "decoded comment count")
|
||||
}},
|
||||
{Text: "the HTTP read path agrees with the file on disk", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/docs/hot-service/comments", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
listed, err := Decode[struct {
|
||||
Count int `json:"count"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(listed.Count, 400, "API comment count")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "readers never observe a partially written document while writers stream updates",
|
||||
Given: Steps{
|
||||
{Text: "a document with a long body", Do: func(c *Context) error {
|
||||
body := strings.Repeat("Sentinel paragraph for torn-read detection.\n\n", 400)
|
||||
_, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body)
|
||||
return err
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "20 writers and 40 readers hammer the API concurrently", Do: func(c *Context) error {
|
||||
const (
|
||||
writers = 20
|
||||
readers = 40
|
||||
rounds = 25
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers+readers)
|
||||
|
||||
for w := 0; w < writers; w++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
for round := 0; round < rounds; round++ {
|
||||
body := strings.Repeat(
|
||||
fmt.Sprintf("writer %d round %d payload line.\n\n", index, round), 200)
|
||||
if _, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
for r := 0; r < readers; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for round := 0; round < rounds*4; round++ {
|
||||
content, err := c.Engine.ReadDoc("live-doc")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(content, "# Live Doc\n\n") {
|
||||
errs <- errf("reader saw a truncated document header: %q", truncate(content))
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "every read returned a complete document", Do: func(c *Context) error {
|
||||
content, err := c.RawDoc("live-doc")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(content, "# Live Doc\n\n") {
|
||||
return errf("final document is malformed")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "no temporary file was left behind in data/docs", Do: func(c *Context) error {
|
||||
entries, err := listDir(c.DataDir + "/docs")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range entries {
|
||||
if strings.HasPrefix(name, ".godoc-tmp-") {
|
||||
return errf("atomic-write temp file leaked: %s", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "many documents can be seeded in parallel without cross-contamination",
|
||||
Given: Steps{
|
||||
{Text: "120 documents seeded across 8 workers with 25 comments each", Do: func(c *Context) error {
|
||||
const (
|
||||
docs = 120
|
||||
perDoc = 25
|
||||
numWorkers = 8
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, numWorkers)
|
||||
jobs := make(chan int, docs)
|
||||
|
||||
for i := 0; i < docs; i++ {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
|
||||
for w := 0; w < numWorkers; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
|
||||
for index := range jobs {
|
||||
title := fmt.Sprintf("Service Suite %03d Overview", index)
|
||||
slug := storage.Slugify(title)
|
||||
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n",
|
||||
title)
|
||||
if _, err := c.Engine.SaveDoc(title, body); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
for n := 0; n < perDoc; n++ {
|
||||
role := roles[n%len(roles)]
|
||||
if _, err := c.Engine.AppendComment(slug, storage.CommentInput{
|
||||
Author: fmt.Sprintf("w%d-u%d", worker, n),
|
||||
Role: string(role),
|
||||
Content: fmt.Sprintf("note %d for %s", n, slug),
|
||||
}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "the document index is read back", Do: func(c *Context) error {
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "all 120 slugified documents are indexed", Do: func(c *Context) error {
|
||||
metas, err := c.Engine.ListDocs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectEqual(len(metas), 120, "indexed document count")
|
||||
}},
|
||||
{Text: "each document owns exactly its own 25 comments", Do: func(c *Context) error {
|
||||
for index := 0; index < 120; index++ {
|
||||
slug := storage.Slugify(fmt.Sprintf("Service Suite %03d Overview", index))
|
||||
comments, err := c.RawComments(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 25 {
|
||||
return errf("%s: expected 25 comments, got %d", slug, len(comments))
|
||||
}
|
||||
for _, comment := range comments {
|
||||
if !strings.Contains(comment.Content, slug) {
|
||||
return errf("%s: comment from another document leaked in: %q", slug, comment.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the aggregate stats total 3000 comments", Do: func(c *Context) error {
|
||||
response, payload, err := c.Do("GET", "/api/stats", "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ExpectStatus(response, 200); err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := Decode[struct {
|
||||
Docs int `json:"docs"`
|
||||
Comments int `json:"comments"`
|
||||
}](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stats.Docs != 120 {
|
||||
return errf("expected 120 docs, got %d", stats.Docs)
|
||||
}
|
||||
return ExpectEqual(stats.Comments, 3000, "aggregate comment count")
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "a burst of HTTP comment posts is durable",
|
||||
Given: Steps{
|
||||
{Text: "a documentation page served over HTTP", Do: func(c *Context) error {
|
||||
response, _, err := c.Do("POST", "/api/docs/http-burst", "text/markdown", "# HTTP Burst\n")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ExpectStatus(response, 201)
|
||||
}},
|
||||
},
|
||||
When: Steps{
|
||||
{Text: "200 concurrent POSTs hit /api/docs/http-burst/comments", Do: func(c *Context) error {
|
||||
const posts = 200
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, posts)
|
||||
|
||||
for i := 0; i < posts; i++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
role := []string{"user", "operator", "developer"}[index%3]
|
||||
body := fmt.Sprintf(`{"author":"burst-%03d","role":"%s","content":"burst comment %d"}`, index, role, index)
|
||||
response, payload, err := c.Do("POST", "/api/docs/http-burst/comments", "application/json", body)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
if response.StatusCode != 201 {
|
||||
errs <- errf("POST %d returned %d: %s", index, response.StatusCode, payload)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
Then: Steps{
|
||||
{Text: "the feedback file holds all 200 posts", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("http-burst")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(comments) != 200 {
|
||||
return errf("expected 200 persisted comments, got %d", len(comments))
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "every comment has a unique ID and a role badge", Do: func(c *Context) error {
|
||||
comments, err := c.RawComments("http-burst")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := make(map[string]struct{}, len(comments))
|
||||
for _, comment := range comments {
|
||||
if _, duplicate := ids[comment.ID]; duplicate {
|
||||
return errf("duplicate comment ID %s", comment.ID)
|
||||
}
|
||||
ids[comment.ID] = struct{}{}
|
||||
if comment.Badge == "" {
|
||||
return errf("comment %s has no badge", comment.ID)
|
||||
}
|
||||
if comment.Timestamp.IsZero() {
|
||||
return errf("comment %s has no timestamp", comment.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{Text: "the write completes well inside a test timeout", Do: func(c *Context) error {
|
||||
// Guards against a regression to unbounded fsync storms: the whole
|
||||
// scenario must finish inside the 10 minute suite budget.
|
||||
if elapsed := time.Since(c.StartedAt); elapsed > 2*time.Minute {
|
||||
return errf("burst scenario took %s", elapsed)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
12
backend/tests/bdd/logger.go
Normal file
12
backend/tests/bdd/logger.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package bdd
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// quietLogger keeps the BDD output readable: the harness reports failures itself,
|
||||
// so the router's per-request access log is discarded.
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
220
backend/tests/docgen_import_test.go
Normal file
220
backend/tests/docgen_import_test.go
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// This file holds the end-to-end suite for the documentation importer: analyse a
|
||||
// real project tree on disk, render a page, merge it through the storage engine,
|
||||
// and read it back over HTTP. No mocks anywhere -- if the pipeline breaks, these
|
||||
// tests break.
|
||||
package tests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-doc/docgen"
|
||||
)
|
||||
|
||||
// getJSON fetches a resource as JSON, which is how the frontend reads pages.
|
||||
// The API negotiates with ?format=json rather than the Accept header.
|
||||
func (h *harness) getJSON(path string) map[string]any {
|
||||
h.t.Helper()
|
||||
if !strings.Contains(path, "?") {
|
||||
path += "?format=json"
|
||||
}
|
||||
payload := h.expectStatus(http.MethodGet, path, "", "", http.StatusOK)
|
||||
return decodeJSON[map[string]any](h.t, payload)
|
||||
}
|
||||
|
||||
func TestIntegrationDocgenImport(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
workspace := t.TempDir()
|
||||
writeFixtureProject(t, filepath.Join(workspace, "go-fixture"))
|
||||
|
||||
project, err := docgen.Analyze("go-fixture", filepath.Join(workspace, "go-fixture"))
|
||||
if err != nil {
|
||||
t.Fatalf("integration: analyse fixture: %v", err)
|
||||
}
|
||||
page, err := docgen.Render(project)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: render fixture: %v", err)
|
||||
}
|
||||
|
||||
slug, err := h.engine.SaveDoc(project.Name, page)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: import page: %v", err)
|
||||
}
|
||||
if slug != "go-fixture" {
|
||||
t.Fatalf("integration: expected slug go-fixture, got %q", slug)
|
||||
}
|
||||
|
||||
// The page lands on disk exactly once, wrapped in generated markers that
|
||||
// record which project it came from.
|
||||
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "go-fixture.md")))
|
||||
if !strings.HasPrefix(onDisk, "<!-- go-doc:generated:begin") {
|
||||
t.Errorf("imported page is missing its opening marker:\n%s", firstLineOf(onDisk))
|
||||
}
|
||||
if !strings.Contains(onDisk, "source=go-fixture") {
|
||||
t.Errorf("imported page does not record its source project:\n%s", firstLineOf(onDisk))
|
||||
}
|
||||
if strings.Count(onDisk, "go-doc:generated:end") != 1 {
|
||||
t.Errorf("imported page has %d closing markers, want 1",
|
||||
strings.Count(onDisk, "go-doc:generated:end"))
|
||||
}
|
||||
|
||||
// The API serves it under the title the index shows.
|
||||
doc := h.getJSON("/api/docs/go-fixture")
|
||||
if doc["title"] != "go-fixture" {
|
||||
t.Errorf("title = %v, want go-fixture", doc["title"])
|
||||
}
|
||||
content, _ := doc["content"].(string)
|
||||
if content != onDisk {
|
||||
t.Error("API content differs from the file on disk")
|
||||
}
|
||||
|
||||
// Every audience tab the reader keys off is present, in order.
|
||||
users := strings.Index(content, "## For Users")
|
||||
operators := strings.Index(content, "## For Operators")
|
||||
developers := strings.Index(content, "## For Developers")
|
||||
if users < 0 || operators < 0 || developers < 0 {
|
||||
t.Fatalf("audience sections missing: %d %d %d", users, operators, developers)
|
||||
}
|
||||
if !(users < operators && operators < developers) {
|
||||
t.Errorf("audience sections are out of order: %d %d %d", users, operators, developers)
|
||||
}
|
||||
|
||||
// Facts extracted from the source reached the page.
|
||||
for _, want := range []string{"| `GET` | `/healthz` | `health` |", "`DEMO_HTTP_PORT`", "`make build`"} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("imported page is missing extracted fact %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// It appears in the index.
|
||||
index := h.getJSON("/api/docs")
|
||||
docs, _ := index["docs"].([]any)
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("expected 1 indexed document, got %d", len(docs))
|
||||
}
|
||||
entry, _ := docs[0].(map[string]any)
|
||||
if entry["slug"] != "go-fixture" {
|
||||
t.Errorf("index slug = %v, want go-fixture", entry["slug"])
|
||||
}
|
||||
|
||||
// A comment attaches to a generated page like any other document.
|
||||
h.expectStatus(http.MethodPost, "/api/docs/go-fixture/comments",
|
||||
"application/json",
|
||||
`{"role":"operator","author":"sre","content":"The port table needs the compose port too."}`,
|
||||
http.StatusCreated)
|
||||
stats := h.getJSON("/api/docs/go-fixture/stats")
|
||||
if stats["total"] != float64(1) {
|
||||
t.Errorf("comment count = %v, want 1", stats["total"])
|
||||
}
|
||||
|
||||
// Re-importing identical content changes nothing, and prose a human added
|
||||
// outside the markers survives the sync.
|
||||
pagePath := filepath.Join(h.docsDir, "go-fixture.md")
|
||||
withFooter := onDisk + "\n## Notes from the team\n\nCheck the runbook before deploying.\n"
|
||||
if err := os.WriteFile(pagePath, []byte(withFooter), 0o644); err != nil {
|
||||
t.Fatalf("integration: write page: %v", err)
|
||||
}
|
||||
|
||||
merged, status, err := docgen.Merge(withFooter, page, false)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: re-merge: %v", err)
|
||||
}
|
||||
if status != docgen.StatusUnchanged {
|
||||
t.Errorf("re-importing an unchanged project reported %q, want unchanged", status)
|
||||
}
|
||||
if _, err := h.engine.SaveDoc(project.Name, merged); err != nil {
|
||||
t.Fatalf("integration: re-import: %v", err)
|
||||
}
|
||||
if reread := string(requireFile(t, pagePath)); !strings.Contains(reread, "Check the runbook before deploying.") {
|
||||
t.Error("re-import dropped hand-written prose")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationDocgenProtectsHandWrittenPages proves the importer never
|
||||
// overwrites a document that it did not generate.
|
||||
func TestIntegrationDocgenProtectsHandWrittenPages(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
const handWritten = "# go-doc\n\nWritten by a person, with no generated markers.\n"
|
||||
if _, err := h.engine.SaveDoc("go-doc", handWritten); err != nil {
|
||||
t.Fatalf("integration: seed hand-written page: %v", err)
|
||||
}
|
||||
|
||||
generated, err := docgen.Render(&docgen.Project{Name: "go-doc", Summary: "Machine written."})
|
||||
if err != nil {
|
||||
t.Fatalf("integration: render: %v", err)
|
||||
}
|
||||
|
||||
pagePath := filepath.Join(h.docsDir, "go-doc.md")
|
||||
existing := string(requireFile(t, pagePath))
|
||||
|
||||
merged, status, err := docgen.Merge(existing, generated, false)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: merge: %v", err)
|
||||
}
|
||||
if status != docgen.StatusSkipped {
|
||||
t.Errorf("merge status = %q, want skipped for an unmarked page", status)
|
||||
}
|
||||
if merged != existing {
|
||||
t.Error("a hand-written page was modified by the importer")
|
||||
}
|
||||
if _, err := h.engine.SaveDoc("go-doc", merged); err != nil {
|
||||
t.Fatalf("integration: save: %v", err)
|
||||
}
|
||||
if reread := string(requireFile(t, pagePath)); reread != handWritten {
|
||||
t.Errorf("hand-written page changed on disk:\n%s", reread)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegrationDocgenMissingProjectIsReportedNotIgnored covers the case the
|
||||
// importer must never paper over: a project directory that cannot be analysed.
|
||||
func TestIntegrationDocgenMissingProjectIsReportedNotIgnored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
if _, err := docgen.Analyze("go-absent", filepath.Join(workspace, "go-absent")); err == nil {
|
||||
t.Fatal("analysing a missing project should fail loudly")
|
||||
}
|
||||
}
|
||||
|
||||
// writeFixtureProject lays out a small project exercising the extractors the
|
||||
// importer depends on: routes, flags, environment, a module, a Makefile and a
|
||||
// README.
|
||||
func writeFixtureProject(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
|
||||
files := map[string]string{
|
||||
"README.md": "# Demo Service\n\nDemo answers questions and exposes a JSON API.\n",
|
||||
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire github.com/go-chi/chi/v5 v5.2.3\n",
|
||||
"Makefile": "build:\n\tgo build ./...\n",
|
||||
".env.example": "DEMO_HTTP_PORT=8080\n",
|
||||
"internal/server/routes.go": "package server\n\n" +
|
||||
"import \"net/http\"\n\n" +
|
||||
"func Routes(mux *http.ServeMux) {\n" +
|
||||
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
|
||||
"\tmux.HandleFunc(\"/api/items\", listItems)\n}\n",
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
path := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("integration: mkdir %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("integration: write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstLineOf(s string) string {
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
842
backend/tests/integration_test.go
Normal file
842
backend/tests/integration_test.go
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
// Package tests contains the end-to-end API suite.
|
||||
//
|
||||
// These are true integration tests: every request travels over HTTP to a live
|
||||
// server, every assertion inspects the real filesystem. There are no mocks, no
|
||||
// fakes and no in-memory doubles anywhere in this file -- if the flat-file engine
|
||||
// breaks, these tests break.
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-doc/router"
|
||||
"go-doc/storage"
|
||||
)
|
||||
|
||||
// harness wires a real storage engine to a real HTTP server over a temporary
|
||||
// data directory.
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
dataDir string
|
||||
engine *storage.Engine
|
||||
server *httptest.Server
|
||||
client *http.Client
|
||||
docsDir string
|
||||
notePath string
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
t.Helper()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: provision engine: %v", err)
|
||||
}
|
||||
|
||||
handler, err := router.NewWithConfig(router.Config{
|
||||
Engine: engine,
|
||||
Logger: discardLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("integration: build router: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return &harness{
|
||||
t: t,
|
||||
dataDir: dataDir,
|
||||
engine: engine,
|
||||
server: server,
|
||||
client: server.Client(),
|
||||
docsDir: filepath.Join(dataDir, "docs"),
|
||||
notePath: filepath.Join(dataDir, "comments"),
|
||||
}
|
||||
}
|
||||
|
||||
// request performs an HTTP call and returns the response with its body.
|
||||
func (h *harness) request(method, path, contentType, body string) (*http.Response, []byte) {
|
||||
h.t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, h.server.URL+path, reader)
|
||||
if err != nil {
|
||||
h.t.Fatalf("integration: build %s %s: %v", method, path, err)
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
h.t.Fatalf("integration: %s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
h.t.Fatalf("integration: read body of %s %s: %v", method, path, err)
|
||||
}
|
||||
return resp, payload
|
||||
}
|
||||
|
||||
func (h *harness) expectStatus(method, path, contentType, body string, want int) []byte {
|
||||
h.t.Helper()
|
||||
resp, payload := h.request(method, path, contentType, body)
|
||||
if resp.StatusCode != want {
|
||||
h.t.Fatalf("%s %s: expected HTTP %d, got %d (body: %s)", method, path, want, resp.StatusCode, payload)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func decodeJSON[T any](t *testing.T, payload []byte) T {
|
||||
t.Helper()
|
||||
var out T
|
||||
if err := json.Unmarshal(payload, &out); err != nil {
|
||||
t.Fatalf("integration: decode %s: %v", payload, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// requireFile asserts a file exists on the real filesystem and returns its bytes.
|
||||
func requireFile(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("integration: expected %s to exist: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func requireNoFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Fatalf("integration: expected %s to be absent", path)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("integration: stat %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationDocumentLifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationDocumentLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("health reports the live data root", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/health", "", "", http.StatusOK)
|
||||
health := decodeJSON[struct {
|
||||
Status string `json:"status"`
|
||||
DataRoot string `json:"data_root"`
|
||||
}](t, payload)
|
||||
|
||||
if health.Status != "ok" {
|
||||
t.Fatalf("expected status ok, got %q", health.Status)
|
||||
}
|
||||
if health.DataRoot != h.dataDir {
|
||||
t.Fatalf("expected data root %q, got %q", h.dataDir, health.DataRoot)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST creates a slugified Markdown file on disk", func(t *testing.T) {
|
||||
payload := h.expectStatus(
|
||||
"POST", "/api/docs/Core%20Service%20API", "text/markdown",
|
||||
"# Core Service API\n\nBilling and invoicing endpoints.\n",
|
||||
http.StatusCreated,
|
||||
)
|
||||
saved := decodeJSON[struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Created bool `json:"created"`
|
||||
}](t, payload)
|
||||
|
||||
if saved.Slug != "core-service-api" {
|
||||
t.Fatalf("expected slug core-service-api, got %q", saved.Slug)
|
||||
}
|
||||
if !saved.Created {
|
||||
t.Fatal("expected created=true on first write")
|
||||
}
|
||||
if saved.Title != "Core Service API" {
|
||||
t.Fatalf("expected the H1 to become the title, got %q", saved.Title)
|
||||
}
|
||||
|
||||
onDisk := requireFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
|
||||
if !bytes.Contains(onDisk, []byte("Billing and invoicing endpoints.")) {
|
||||
t.Fatalf("file content mismatch: %s", onDisk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET returns the raw Markdown with the right content type", func(t *testing.T) {
|
||||
resp, payload := h.request("GET", "/api/docs/core-service-api", "", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
|
||||
t.Fatalf("expected text/markdown, got %q", ct)
|
||||
}
|
||||
if !strings.HasPrefix(string(payload), "# Core Service API") {
|
||||
t.Fatalf("unexpected markdown body: %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET ?format=json returns the document envelope", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/core-service-api?format=json", "", "", http.StatusOK)
|
||||
doc := decodeJSON[struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Size int64 `json:"size"`
|
||||
}](t, payload)
|
||||
|
||||
if doc.Slug != "core-service-api" || doc.Title != "Core Service API" {
|
||||
t.Fatalf("unexpected envelope: %+v", doc)
|
||||
}
|
||||
if doc.Size <= 0 {
|
||||
t.Fatalf("expected a positive size, got %d", doc.Size)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("POST with a JSON body updates the file in place", func(t *testing.T) {
|
||||
h.expectStatus(
|
||||
"POST", "/api/docs/core-service-api", "application/json",
|
||||
`{"title":"Core Service API v2","content":"# Core Service API v2\n\nRewritten.\n"}`,
|
||||
http.StatusOK,
|
||||
)
|
||||
|
||||
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "core-service-api.md")))
|
||||
if strings.Contains(onDisk, "Billing and invoicing") {
|
||||
t.Fatalf("update did not replace the content wholesale: %s", onDisk)
|
||||
}
|
||||
if !strings.Contains(onDisk, "# Core Service API v2") {
|
||||
t.Fatalf("update missing from disk: %s", onDisk)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /api/docs indexes the document", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs", "", "", http.StatusOK)
|
||||
index := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
Docs []storage.DocMeta `json:"docs"`
|
||||
}](t, payload)
|
||||
|
||||
if index.Count != 1 || index.Docs[0].Slug != "core-service-api" {
|
||||
t.Fatalf("unexpected index: %+v", index)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search filters the index", func(t *testing.T) {
|
||||
h.expectStatus("POST", "/api/docs/Payments%20Gateway", "text/markdown", "# Payments Gateway\n", http.StatusCreated)
|
||||
|
||||
payload := h.expectStatus("GET", "/api/docs?q=payment", "", "", http.StatusOK)
|
||||
index := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
}](t, payload)
|
||||
if index.Count != 1 {
|
||||
t.Fatalf("expected exactly one search hit, got %d", index.Count)
|
||||
}
|
||||
|
||||
payload = h.expectStatus("GET", "/api/docs?q=nothing-matches", "", "", http.StatusOK)
|
||||
index = decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
}](t, payload)
|
||||
if index.Count != 0 {
|
||||
t.Fatalf("expected no hits, got %d", index.Count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE removes the file and cascades the feedback", func(t *testing.T) {
|
||||
h.expectStatus("POST", "/api/docs/core-service-api/comments", "application/json",
|
||||
`{"author":"ana","role":"developer","content":"delete me"}`, http.StatusCreated)
|
||||
requireFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
|
||||
|
||||
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNoContent)
|
||||
requireNoFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
|
||||
requireNoFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
|
||||
|
||||
h.expectStatus("GET", "/api/docs/core-service-api", "", "", http.StatusNotFound)
|
||||
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationCommentTracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationCommentTracking(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
h.expectStatus("POST", "/api/docs/Deploy%20Pipeline", "text/markdown", "# Deploy Pipeline\n", http.StatusCreated)
|
||||
|
||||
type created struct {
|
||||
Comment storage.Comment `json:"comment"`
|
||||
Badge string `json:"badge"`
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
role string
|
||||
author string
|
||||
content string
|
||||
badge string
|
||||
}{
|
||||
{"user", "sam", "The wizard confused me", "🟢"},
|
||||
{"Operator", "robin", "Rollback takes 20 minutes", "🔵"},
|
||||
{"DEVELOPER", "kit", "Add a --dry-run flag", "🔴"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run("role "+tc.role+" receives badge "+tc.badge, func(t *testing.T) {
|
||||
body := fmt.Sprintf(`{"author":%q,"role":%q,"content":%q}`, tc.author, tc.role, tc.content)
|
||||
payload := h.expectStatus("POST", "/api/docs/deploy-pipeline/comments", "application/json", body, http.StatusCreated)
|
||||
result := decodeJSON[created](t, payload)
|
||||
|
||||
if result.Badge != tc.badge {
|
||||
t.Fatalf("expected badge %q, got %q", tc.badge, result.Badge)
|
||||
}
|
||||
if result.Comment.Badge != tc.badge {
|
||||
t.Fatalf("expected the stored badge %q, got %q", tc.badge, result.Comment.Badge)
|
||||
}
|
||||
if result.Comment.ID == "" {
|
||||
t.Fatal("expected a generated comment ID")
|
||||
}
|
||||
if result.Comment.Timestamp.IsZero() {
|
||||
t.Fatal("expected a timestamp")
|
||||
}
|
||||
if string(result.Comment.Role) != strings.ToLower(tc.role) {
|
||||
t.Fatalf("expected the canonical role, got %q", result.Comment.Role)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("the feedback file is a JSON array in insertion order", func(t *testing.T) {
|
||||
raw := requireFile(t, filepath.Join(h.notePath, "deploy-pipeline-comments.json"))
|
||||
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
|
||||
t.Fatalf("expected a JSON array, got: %s", raw)
|
||||
}
|
||||
|
||||
var stored []storage.Comment
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("feedback file is not valid JSON: %v", err)
|
||||
}
|
||||
if len(stored) != 3 {
|
||||
t.Fatalf("expected 3 comments, got %d", len(stored))
|
||||
}
|
||||
for i, want := range []struct{ role, badge string }{
|
||||
{"user", "🟢"},
|
||||
{"operator", "🔵"},
|
||||
{"developer", "🔴"},
|
||||
} {
|
||||
if string(stored[i].Role) != want.role || stored[i].Badge != want.badge {
|
||||
t.Fatalf("comment %d: got %s/%s, want %s/%s", i, stored[i].Role, stored[i].Badge, want.role, want.badge)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET lists the comments with their badges", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
|
||||
listed := decodeJSON[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
Count int `json:"count"`
|
||||
}](t, payload)
|
||||
|
||||
if listed.Count != 3 || len(listed.Comments) != 3 {
|
||||
t.Fatalf("expected 3 comments, got %d", listed.Count)
|
||||
}
|
||||
if listed.Comments[2].Badge != "🔴" {
|
||||
t.Fatalf("expected the developer badge, got %q", listed.Comments[2].Badge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DELETE removes a single comment by ID", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
|
||||
listed := decodeJSON[struct {
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
}](t, payload)
|
||||
|
||||
target := listed.Comments[0].ID
|
||||
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNoContent)
|
||||
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNotFound)
|
||||
|
||||
remaining := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
}](t, h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK))
|
||||
if remaining.Count != 2 {
|
||||
t.Fatalf("expected 2 remaining comments, got %d", remaining.Count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stats aggregate by role", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/stats", "", "", http.StatusOK)
|
||||
stats := decodeJSON[storage.CommentStats](t, payload)
|
||||
|
||||
if stats.Total != 2 {
|
||||
t.Fatalf("expected total 2, got %d", stats.Total)
|
||||
}
|
||||
if stats.ByRole["operator"] != 1 || stats.ByRole["developer"] != 1 {
|
||||
t.Fatalf("unexpected role tally: %+v", stats.ByRole)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the role catalogue is exposed for the UI", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/roles", "", "", http.StatusOK)
|
||||
catalogue := decodeJSON[struct {
|
||||
Roles []struct {
|
||||
Role string `json:"role"`
|
||||
Badge string `json:"badge"`
|
||||
Label string `json:"label"`
|
||||
Hex string `json:"hex"`
|
||||
} `json:"roles"`
|
||||
}](t, payload)
|
||||
|
||||
want := map[string][3]string{
|
||||
"user": {"🟢", "User", "#22c55e"},
|
||||
"operator": {"🔵", "Operator", "#3b82f6"},
|
||||
"developer": {"🔴", "Developer", "#ef4444"},
|
||||
}
|
||||
if len(catalogue.Roles) != 3 {
|
||||
t.Fatalf("expected 3 roles, got %d", len(catalogue.Roles))
|
||||
}
|
||||
for _, role := range catalogue.Roles {
|
||||
expected, ok := want[role.Role]
|
||||
if !ok {
|
||||
t.Fatalf("unexpected role %q", role.Role)
|
||||
}
|
||||
if role.Badge != expected[0] || role.Label != expected[1] || role.Hex != expected[2] {
|
||||
t.Fatalf("role %s mismatch: %+v", role.Role, role)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationValidationAndErrors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationValidationAndErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("unknown document returns a structured 404", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/docs/does-not-exist", "", "", http.StatusNotFound)
|
||||
body := decodeJSON[struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Status int `json:"status"`
|
||||
}](t, payload)
|
||||
|
||||
if body.Error.Code != "not_found" || body.Status != http.StatusNotFound {
|
||||
t.Fatalf("unexpected error body: %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid role returns 400 invalid_role", func(t *testing.T) {
|
||||
h.expectStatus("POST", "/api/docs/guarded", "text/markdown", "# Guarded\n", http.StatusCreated)
|
||||
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
|
||||
`{"author":"mallory","role":"root","content":"escalate"}`, http.StatusBadRequest)
|
||||
|
||||
if !strings.Contains(string(payload), "invalid_role") {
|
||||
t.Fatalf("expected invalid_role, got %s", payload)
|
||||
}
|
||||
requireNoFile(t, filepath.Join(h.notePath, "guarded-comments.json"))
|
||||
})
|
||||
|
||||
t.Run("empty comment content returns 400 invalid_comment", func(t *testing.T) {
|
||||
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
|
||||
`{"author":"ana","role":"user","content":" "}`, http.StatusBadRequest)
|
||||
if !strings.Contains(string(payload), "invalid_comment") {
|
||||
t.Fatalf("expected invalid_comment, got %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400 invalid_body", func(t *testing.T) {
|
||||
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json", `{"author":`, http.StatusBadRequest)
|
||||
if !strings.Contains(string(payload), "invalid_body") {
|
||||
t.Fatalf("expected invalid_body, got %s", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("traversal slugs are refused", func(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/docs/..%2F..%2Fetc%2Fpasswd",
|
||||
"/api/docs/%2Fetc%2Fpasswd",
|
||||
} {
|
||||
h.expectStatus("POST", path, "text/markdown", "nope", http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized documents are refused", func(t *testing.T) {
|
||||
huge := strings.Repeat("a", int(router.DefaultMaxDocBytes)+1024)
|
||||
h.expectStatus("POST", "/api/docs/huge", "text/markdown", huge, http.StatusBadRequest)
|
||||
requireNoFile(t, filepath.Join(h.docsDir, "huge.md"))
|
||||
})
|
||||
|
||||
t.Run("unknown API routes return JSON 404, not an HTML page", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/nope", "", "", http.StatusNotFound)
|
||||
if !strings.Contains(string(payload), "not_found") {
|
||||
t.Fatalf("expected a JSON error, got %s", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationCORS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationCORS(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
t.Run("preflight is answered for the Vite dev server", func(t *testing.T) {
|
||||
resp, _ := h.request("OPTIONS", "/api/docs/core-service-api", "", "")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("expected 204, got %d", resp.StatusCode)
|
||||
}
|
||||
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
|
||||
t.Fatalf("expected a wildcard origin, got %q", origin)
|
||||
}
|
||||
methods := resp.Header.Get("Access-Control-Allow-Methods")
|
||||
for _, verb := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
|
||||
if !strings.Contains(methods, verb) {
|
||||
t.Fatalf("expected %s in %q", verb, methods)
|
||||
}
|
||||
}
|
||||
if headers := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(headers, "Content-Type") {
|
||||
t.Fatalf("expected Content-Type to be allowed, got %q", headers)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-origin reads carry the CORS header", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", h.server.URL+"/api/docs", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Origin", "http://127.0.0.1:5173")
|
||||
|
||||
resp, err := h.client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
|
||||
t.Fatalf("expected a CORS header, got %q", origin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an allow-list echoes only permitted origins", func(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := router.NewWithConfig(router.Config{
|
||||
Engine: engine,
|
||||
AllowedOrigins: []string{"http://127.0.0.1:5173"},
|
||||
Logger: discardLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
for origin, wantEchoed := range map[string]bool{
|
||||
"http://127.0.0.1:5173": true,
|
||||
"http://evil.example.com": false,
|
||||
} {
|
||||
req, err := http.NewRequest("GET", server.URL+"/api/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Origin", origin)
|
||||
|
||||
resp, err := server.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
got := resp.Header.Get("Access-Control-Allow-Origin")
|
||||
if wantEchoed && got != origin {
|
||||
t.Fatalf("expected %q to be echoed, got %q", origin, got)
|
||||
}
|
||||
if !wantEchoed && got == origin {
|
||||
t.Fatalf("origin %q should not have been echoed", origin)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationConcurrentLoad
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationConcurrentLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
const (
|
||||
docs = 40
|
||||
perDoc = 30
|
||||
commentsTotal = docs * perDoc
|
||||
)
|
||||
|
||||
t.Run("concurrent writers all land on disk", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, docs)
|
||||
|
||||
for d := 0; d < docs; d++ {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
title := fmt.Sprintf("Load Service %02d", index)
|
||||
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n", title)
|
||||
|
||||
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title), "text/markdown", body)
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
||||
errs <- fmt.Errorf("save %s returned %d: %s", title, resp.StatusCode, payload)
|
||||
return
|
||||
}
|
||||
|
||||
for n := 0; n < perDoc; n++ {
|
||||
role := []string{"user", "operator", "developer"}[n%3]
|
||||
comment := fmt.Sprintf(
|
||||
`{"author":"load-%02d","role":%q,"content":"comment %d"}`, index, role, n)
|
||||
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title)+"/comments", "application/json", comment)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
errs <- fmt.Errorf("comment on %s returned %d: %s", title, resp.StatusCode, payload)
|
||||
return
|
||||
}
|
||||
}
|
||||
}(d)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the filesystem holds every document", func(t *testing.T) {
|
||||
entries, err := os.ReadDir(h.docsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != docs {
|
||||
t.Fatalf("expected %d Markdown files, got %d", docs, len(entries))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the filesystem holds every comment", func(t *testing.T) {
|
||||
entries, err := os.ReadDir(h.notePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != docs {
|
||||
t.Fatalf("expected %d feedback files, got %d", docs, len(entries))
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, entry := range entries {
|
||||
raw, err := os.ReadFile(filepath.Join(h.notePath, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored []storage.Comment
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatalf("%s is not a valid JSON array: %v", entry.Name(), err)
|
||||
}
|
||||
total += len(stored)
|
||||
}
|
||||
if total != commentsTotal {
|
||||
t.Fatalf("expected %d comments across all files, got %d", commentsTotal, total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the API agrees with the filesystem", func(t *testing.T) {
|
||||
payload := h.expectStatus("GET", "/api/stats", "", "", http.StatusOK)
|
||||
stats := decodeJSON[struct {
|
||||
Docs int `json:"docs"`
|
||||
Comments int `json:"comments"`
|
||||
}](t, payload)
|
||||
|
||||
if stats.Docs != docs {
|
||||
t.Fatalf("expected %d docs, got %d", docs, stats.Docs)
|
||||
}
|
||||
if stats.Comments != commentsTotal {
|
||||
t.Fatalf("expected %d comments, got %d", commentsTotal, stats.Comments)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no temp files leaked and every comment is badged", func(t *testing.T) {
|
||||
entries, err := os.ReadDir(h.docsDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
|
||||
t.Fatalf("leaked atomic-write temp file: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
for d := 0; d < docs; d++ {
|
||||
slug := storage.Slugify(fmt.Sprintf("Load Service %02d", d))
|
||||
raw := requireFile(t, filepath.Join(h.notePath, slug+"-comments.json"))
|
||||
var stored []storage.Comment
|
||||
if err := json.Unmarshal(raw, &stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(stored) != perDoc {
|
||||
t.Fatalf("%s: expected %d comments, got %d", slug, perDoc, len(stored))
|
||||
}
|
||||
for _, comment := range stored {
|
||||
if comment.Badge != comment.Role.Badge() {
|
||||
t.Fatalf("%s: comment %s has badge %q for role %s", slug, comment.ID, comment.Badge, comment.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestIntegrationPersistenceAcrossRestart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestIntegrationPersistenceAcrossRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
|
||||
// First "process": write through the API.
|
||||
engine, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err := router.NewWithConfig(router.Config{Engine: engine, Logger: discardLogger()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := httptest.NewServer(handler)
|
||||
|
||||
post := func(method, path, contentType, body string) []byte {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, first.URL+path, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := first.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
t.Fatalf("%s %s returned %d: %s", method, path, resp.StatusCode, payload)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
post("POST", "/api/docs/Persistent%20Store", "text/markdown", "# Persistent Store\n\nSurvives restarts.\n")
|
||||
post("POST", "/api/docs/persistent-store/comments", "application/json",
|
||||
`{"author":"ana","role":"developer","content":"still here after a restart"}`)
|
||||
first.Close()
|
||||
|
||||
// Second "process": a brand new engine over the same directory.
|
||||
restarted, err := storage.New(dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler, err = router.NewWithConfig(router.Config{Engine: restarted, Logger: discardLogger()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := httptest.NewServer(handler)
|
||||
defer second.Close()
|
||||
|
||||
t.Run("the document survives", func(t *testing.T) {
|
||||
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store?format=json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
doc := decodeJSON[struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}](t, mustReadAll(resp))
|
||||
|
||||
if doc.Title != "Persistent Store" {
|
||||
t.Fatalf("unexpected title %q", doc.Title)
|
||||
}
|
||||
if !strings.Contains(doc.Content, "Survives restarts.") {
|
||||
t.Fatalf("unexpected content %q", doc.Content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the feedback survives with its badge", func(t *testing.T) {
|
||||
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store/comments")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
listed := decodeJSON[struct {
|
||||
Count int `json:"count"`
|
||||
Comments []storage.Comment `json:"comments"`
|
||||
}](t, mustReadAll(resp))
|
||||
|
||||
if listed.Count != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", listed.Count)
|
||||
}
|
||||
if listed.Comments[0].Badge != "🔴" {
|
||||
t.Fatalf("expected the developer badge, got %q", listed.Comments[0].Badge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustReadAll(resp *http.Response) []byte {
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestIntegrationTimingBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
start := time.Now()
|
||||
h.expectStatus("POST", "/api/docs/timing", "text/markdown", strings.Repeat("Line of content.\n\n", 500), http.StatusCreated)
|
||||
for i := 0; i < 100; i++ {
|
||||
h.expectStatus("POST", "/api/docs/timing/comments", "application/json",
|
||||
fmt.Sprintf(`{"author":"bench","role":"user","content":"note %d"}`, i), http.StatusCreated)
|
||||
}
|
||||
h.expectStatus("GET", "/api/docs/timing", "", "", http.StatusOK)
|
||||
|
||||
if elapsed := time.Since(start); elapsed > 30*time.Second {
|
||||
t.Fatalf("write path took %s, which is far too slow for a flat-file engine", elapsed)
|
||||
}
|
||||
}
|
||||
12
backend/tests/logger_test.go
Normal file
12
backend/tests/logger_test.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// discardLogger silences the router's request log so `go test -v` output stays
|
||||
// focused on assertions.
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
|
||||
}
|
||||
33
backend/webui/dist.placeholder/index.html
Normal file
33
backend/webui/dist.placeholder/index.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>go-doc · frontend not built</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #0b0f14;
|
||||
color: #cbd5e1;
|
||||
font: 16px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
main { max-width: 46rem; padding: 3rem; }
|
||||
h1 { color: #f8fafc; font-size: 1.6rem; }
|
||||
code { color: #7dd3fc; }
|
||||
ul { padding-left: 1.2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>go-doc API is running</h1>
|
||||
<p>This is the placeholder page shipped with the Go binary. The React bundle has not been compiled into it yet.</p>
|
||||
<ul>
|
||||
<li>Run <code>make frontend-run</code> for the Vite dev server on port 5173.</li>
|
||||
<li>Run <code>make build</code> to embed the production bundle into the binary.</li>
|
||||
</ul>
|
||||
<p>API: <code>/api/health</code> · <code>/api/docs</code> · <code>/api/docs/{slug}</code> · <code>/api/docs/{slug}/comments</code></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
14
backend/webui/dist/index.html
vendored
Normal file
14
backend/webui/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="go-doc — segmented documentation and audience feedback for users, operators and developers" />
|
||||
<title>Go Doc: Application Documentation tool.</title>
|
||||
<script type="module" crossorigin src="/assets/index-76KU_cS9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-fwxz_0Za.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
31
backend/webui/embed.go
Normal file
31
backend/webui/embed.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Package webui embeds the compiled React bundle produced by `make build`.
|
||||
//
|
||||
// The `dist` directory is populated by the build pipeline (npm run build copies
|
||||
// frontend/dist into backend/webui/dist). A placeholder index.html is checked
|
||||
// in so the backend always compiles and runs, even in a fresh checkout where
|
||||
// the frontend has not been built yet.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var assets embed.FS
|
||||
|
||||
// FS returns the root of the embedded bundle, or nil when the placeholder build
|
||||
// is still in place and no real frontend bundle has been compiled.
|
||||
func FS() fs.FS {
|
||||
sub, err := fs.Sub(assets, "dist")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := fs.Stat(sub, "index.html"); err != nil {
|
||||
return nil
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// Assets exposes the raw embedded filesystem, including the dist prefix.
|
||||
func Assets() fs.FS { return assets }
|
||||
Loading…
Add table
Add a link
Reference in a new issue