// Package api defines the JSON surface. // // DESIGN RULE: endpoints return purpose-built DTOs with explicit json // tags. They never marshal a module's return value directly. Go marshals // every exported field by default, so serializing a module value means one // added field silently becomes public. The allowlist lives here, in types // you can read in one screen, not in struct tags scattered across modules. package api import ( "fmt" "time" "gsg/internal/core" "gsg/internal/gitx" "gsg/internal/modules" ) // Repo is the per-repository document. Deliberately minimal. type Repo struct { Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description,omitempty"` Owner string `json:"owner,omitempty"` Section string `json:"section,omitempty"` CloneURL string `json:"clone_url,omitempty"` DefaultBranch string `json:"default_branch,omitempty"` LastCommit *string `json:"last_commit,omitempty"` // RFC3339, null when empty repo } func repoDoc(info core.RepoInfo, defaultRef string, last time.Time) Repo { d := Repo{ Slug: info.Slug, Title: info.Title, Description: info.Description, Owner: info.Owner, Section: info.Section, CloneURL: info.Clone, DefaultBranch: defaultRef, } if !last.IsZero() { s := last.UTC().Format(time.RFC3339) d.LastCommit = &s } return d } // RepoEndpoint -> /{repo}/api/repo.json func RepoEndpoint(c *core.Ctx) (any, error) { v, err := c.Mod("meta") if err != nil { return nil, err } m, ok := v.(modules.Meta) if !ok { return nil, fmt.Errorf("api: meta returned %T", v) } return repoDoc(m.RepoInfo, m.DefaultRef, m.LastCommit), nil } // Commit is the per-commit document. // // NOTE: no Subject, no Body. You said commit messages stay out of the JSON // surface, and because this is an explicit DTO rather than a marshalled // module value, that decision is enforced by the type -- adding a field to // gitx.Commit cannot leak it here. type Commit struct { SHA string `json:"sha"` Short string `json:"short"` When string `json:"authored"` Parents []string `json:"parents,omitempty"` Files int `json:"files_changed"` Added int `json:"lines_added"` Deleted int `json:"lines_deleted"` } // CommitEndpoint -> /{repo}/api/commit/{sha}.json func CommitEndpoint(c *core.Ctx) (any, error) { cv, err := c.Mod("commit") if err != nil { return nil, err } cm, ok := cv.(gitx.Commit) if !ok { return nil, fmt.Errorf("api: commit returned %T", cv) } doc := Commit{ SHA: cm.SHA, Short: cm.Short, When: cm.When.UTC().Format(time.RFC3339), Parents: cm.Parents, } dv, err := c.Mod("diff") if err != nil { return nil, err } if d, ok := dv.(gitx.Diff); ok { doc.Files = len(d.Stats) for _, f := range d.Stats { doc.Added += f.Added doc.Deleted += f.Deleted } } return doc, nil } // Index is the site-wide document. type Index struct { Site string `json:"site"` Generated string `json:"generated"` Repos []Repo `json:"repos"` } // IndexEndpoint -> /api/repos.json func IndexEndpoint(c *core.Ctx) (any, error) { v, err := c.Mod("repos") if err != nil { return nil, err } list, ok := v.([]modules.RepoSummary) if !ok { return nil, fmt.Errorf("api: repos returned %T", v) } idx := Index{ Site: c.Site.Name, Generated: time.Now().UTC().Format(time.RFC3339), Repos: make([]Repo, 0, len(list)), } for _, s := range list { idx.Repos = append(idx.Repos, repoDoc(s.RepoInfo, "", s.LastCommit)) } return idx, nil }