Skip to content
quarry Artifact [f7494d29f3]
Login

Artifact [f7494d29f3]

Artifact f7494d29f3bb491f306391c2cb8a8db16500f54c8b216b2b4ab5e930bb66bf91:


// Package api owns RegisterAll — the one explicit wiring point where every
// op joins the registry (0003 §2: no init() magic; the wiring stays
// greppable) — plus the HTTP router that serves the registered ops. This
// is the sanctioned place Run closures over the data planes are built;
// the arch gate keeps every other consumer on the op side of the seam.
package api

import (
	"bytes"
	"context"
	"fmt"
	"html"
	"unicode/utf8"

	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/markup"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/zitcli"
)

// Deps carries what op implementations close over. Index may be nil (no
// store configured — `quarry spec` runs this way); ops that need it
// answer with an error at run time, and the spec stays complete either
// way.
type Deps struct {
	Version string
	Index   *index.Index
	Zit     *zitcli.Client // on-demand store reads (file lists, cat, diff)
}

type versionIn struct{}

type versionOut struct {
	Version string `json:"version"`
}

type timelineIn struct {
	Limit int `json:"limit" validate:"min=0,max=200"`
}

type timelineOut struct {
	Entries []index.TimelineEntry `json:"entries"`
	Count   int                   `json:"count"`
}

type wikiListIn struct{}

type wikiListOut struct {
	Pages []index.WikiTitle `json:"pages"`
	Count int               `json:"count"`
}

type wikiGetIn struct {
	Name string `json:"name" validate:"required,max=100"`
}

type wikiGetOut struct {
	Title    string `json:"title"`
	Date     string `json:"date"`
	User     string `json:"user"`
	Mimetype string `json:"mimetype"`
	Source   string `json:"source"`
	HTML     string `json:"html"`
}

type fileGetIn struct {
	Hash string `json:"hash" validate:"required,min=4,max=64"`
	Path string `json:"path" validate:"required,max=512"`
}

type fileGetOut struct {
	Hash    string `json:"hash"`
	Path    string `json:"path"`
	Size    int    `json:"size"`
	Binary  bool   `json:"binary"`
	Content string `json:"content"`
	HTML    string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched
}

type searchIn struct {
	Q     string `json:"q" validate:"required,max=200"`
	Limit int    `json:"limit" validate:"min=0,max=200"`
}

type searchOut struct {
	Results []index.SearchResult `json:"results"`
	Count   int                  `json:"count"`
}

type diffGetIn struct {
	Hash string `json:"hash" validate:"required,min=4,max=64"`
}

type diffGetOut struct {
	From    string             `json:"from"`
	To      string             `json:"to"`
	Files   []zitcli.DiffEntry `json:"files"`
	Unified string             `json:"unified"`
}

type checkinGetIn struct {
	Hash string `json:"hash" validate:"required,min=4,max=64"`
}

type checkinGetOut struct {
	Hash    string           `json:"hash"`
	Date    string           `json:"date"`
	User    string           `json:"user"`
	Comment string           `json:"comment"`
	Parents []string         `json:"parents"`
	Files   []zitcli.LsEntry `json:"files"`
}

type ticketListIn struct{}

// TicketSummary is the list row: id plus the two fields every tracker
// listing needs, pulled out of the folded state.
type ticketSummary struct {
	ID      string `json:"id"`
	Title   string `json:"title"`
	Status  string `json:"status"`
	Updated string `json:"updated"`
}

type ticketListOut struct {
	Tickets []ticketSummary `json:"tickets"`
	Count   int             `json:"count"`
}

type ticketGetIn struct {
	ID string `json:"id" validate:"required,min=4,max=40"`
}

type ticketGetOut struct {
	Ticket index.TicketState `json:"ticket"`
}

func fieldValue(fields []index.TicketFieldSet, name string) string {
	for _, f := range fields {
		if f.Name == name {
			return f.Value
		}
	}
	return ""
}

// resolver implements markup.Resolver over the index. Wiki names resolve
// to existing pages; hash prefixes resolve to uniquely matching
// check-ins. An unresolvable or ambiguous reference renders as plain
// text, never as a dead link.
type resolver struct{ ix *index.Index }

func (r resolver) ResolveHash(prefix string) string {
	if r.ix == nil {
		return ""
	}
	if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok {
		return "/checkin/" + e.Hash
	}
	if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok {
		return "/ticket/" + t.ID
	}
	return ""
}

func (r resolver) ResolveWiki(name string) string {
	if r.ix == nil {
		return ""
	}
	if _, ok, err := r.ix.WikiPage(name); err == nil && ok {
		return "/wiki/" + name
	}
	return ""
}

// RegisterAll wires every op.
func RegisterAll(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[versionIn, versionOut]{
		Name:  "meta.version",
		Doc:   "Report the quarry build version.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/version",
		Run: func(_ context.Context, _ versionIn) (versionOut, error) {
			return versionOut{Version: d.Version}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{
		Name:  "timeline.list",
		Doc:   "List check-ins, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/timeline",
		Run: func(_ context.Context, in timelineIn) (timelineOut, error) {
			if d.Index == nil {
				return timelineOut{}, fmt.Errorf("no store is being served")
			}
			limit := in.Limit
			if limit == 0 {
				limit = 50
			}
			entries, err := d.Index.Timeline(limit)
			if err != nil {
				return timelineOut{}, err
			}
			return timelineOut{Entries: entries, Count: len(entries)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{
		Name:  "wiki.list",
		Doc:   "List wiki pages with their latest edit.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/wiki",
		Run: func(_ context.Context, _ wikiListIn) (wikiListOut, error) {
			if d.Index == nil {
				return wikiListOut{}, fmt.Errorf("no store is being served")
			}
			pages, err := d.Index.WikiTitles()
			if err != nil {
				return wikiListOut{}, err
			}
			return wikiListOut{Pages: pages, Count: len(pages)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{
		Name:  "ticket.list",
		Doc:   "List tickets with their folded current state.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/ticket",
		Run: func(_ context.Context, _ ticketListIn) (ticketListOut, error) {
			if d.Index == nil {
				return ticketListOut{}, fmt.Errorf("no store is being served")
			}
			all, err := d.Index.Tickets()
			if err != nil {
				return ticketListOut{}, err
			}
			out := ticketListOut{Count: len(all)}
			for _, t := range all {
				out.Tickets = append(out.Tickets, ticketSummary{
					ID:      t.ID,
					Title:   fieldValue(t.Fields, "title"),
					Status:  fieldValue(t.Fields, "status"),
					Updated: t.Updated,
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{
		Name:  "ticket.get",
		Doc:   "Fetch one ticket: folded state plus change history.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/ticket/{id}",
		Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) {
			if d.Index == nil {
				return ticketGetOut{}, fmt.Errorf("no store is being served")
			}
			t, ok, err := d.Index.TicketByPrefix(in.ID)
			if err != nil {
				return ticketGetOut{}, err
			}
			if !ok {
				return ticketGetOut{}, fmt.Errorf("no unique ticket for %q", in.ID)
			}
			return ticketGetOut{Ticket: t}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[searchIn, searchOut]{
		Name:  "search.query",
		Doc:   "Full-text search over check-ins, wiki, and tickets.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/search",
		Run: func(_ context.Context, in searchIn) (searchOut, error) {
			if d.Index == nil {
				return searchOut{}, fmt.Errorf("no store is being served")
			}
			limit := in.Limit
			if limit == 0 {
				limit = 50
			}
			results, err := d.Index.Search(in.Q, limit)
			if err != nil {
				return searchOut{}, err
			}
			return searchOut{Results: results, Count: len(results)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{
		Name:  "checkin.diff",
		Doc:   "Diff one check-in against its primary parent.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/checkin/{hash}/diff",
		Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) {
			if d.Index == nil || d.Zit == nil {
				return diffGetOut{}, fmt.Errorf("no store is being served")
			}
			e, parents, ok, err := d.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return diffGetOut{}, err
			}
			if !ok {
				return diffGetOut{}, fmt.Errorf("no unique check-in for %q", in.Hash)
			}
			out := diffGetOut{To: e.Hash}
			if len(parents) == 0 {
				// A root check-in has nothing to diff against; its
				// whole file set is the change.
				files, err := d.Zit.Ls(e.Hash)
				if err != nil {
					return diffGetOut{}, err
				}
				for _, f := range files {
					out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name})
				}
				return out, nil
			}
			out.From = parents[0]
			unified, brief, err := d.Zit.Diff(out.From, e.Hash)
			if err != nil {
				return diffGetOut{}, err
			}
			out.Unified, out.Files = unified, brief
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{
		Name:  "file.get",
		Doc:   "Fetch one file's content at a check-in.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/file/{hash}/{path...}",
		Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) {
			if d.Index == nil || d.Zit == nil {
				return fileGetOut{}, fmt.Errorf("no store is being served")
			}
			e, _, ok, err := d.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return fileGetOut{}, err
			}
			if !ok {
				return fileGetOut{}, fmt.Errorf("no unique check-in for %q", in.Hash)
			}
			raw, err := d.Zit.Cat(in.Path, e.Hash)
			if err != nil {
				return fileGetOut{}, err
			}
			out := fileGetOut{Hash: e.Hash, Path: in.Path, Size: len(raw)}
			// Binary content stays out of the JSON surface; the page
			// says "binary" and the artifact read verb serves bytes.
			if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) {
				out.Content = string(raw)
				// Best-effort highlighting; sanitized like all rendered
				// HTML even though quarry generated it.
				if hl, ok := highlight.Code(raw, "", in.Path); ok {
					out.HTML = markup.Sanitize(hl)
				}
			} else {
				out.Binary = true
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{
		Name:  "checkin.get",
		Doc:   "Describe one check-in: metadata, parents, and its file set.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/checkin/{hash}",
		Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) {
			if d.Index == nil {
				return checkinGetOut{}, fmt.Errorf("no store is being served")
			}
			e, parents, ok, err := d.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return checkinGetOut{}, err
			}
			if !ok {
				return checkinGetOut{}, fmt.Errorf("no unique check-in for %q", in.Hash)
			}
			out := checkinGetOut{
				Hash: e.Hash, Date: e.Date, User: e.User,
				Comment: e.Comment, Parents: parents,
			}
			// File set is an on-demand zit read (0001 §2), not index
			// state — the index answers "which check-in", the store
			// answers "what does it hold".
			if d.Zit != nil {
				files, err := d.Zit.Ls(e.Hash)
				if err != nil {
					return checkinGetOut{}, err
				}
				out.Files = files
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{
		Name:  "wiki.get",
		Doc:   "Fetch one wiki page, rendered.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/wiki/{name}",
		Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) {
			if d.Index == nil {
				return wikiGetOut{}, fmt.Errorf("no store is being served")
			}
			page, ok, err := d.Index.WikiPage(in.Name)
			if err != nil {
				return wikiGetOut{}, err
			}
			if !ok {
				return wikiGetOut{}, fmt.Errorf("no wiki page %q", in.Name)
			}
			var rendered []byte
			switch page.Mimetype {
			case "", "text/x-markdown", "text/markdown":
				rendered, err = markup.Render(page.Body, resolver{ix: d.Index})
				if err != nil {
					return wikiGetOut{}, err
				}
			default:
				// Non-markdown wiki content renders as escaped text.
				rendered = []byte("<pre>" + html.EscapeString(string(page.Body)) + "</pre>")
			}
			return wikiGetOut{
				Title: page.Title, Date: page.Date, User: page.User,
				Mimetype: page.Mimetype, Source: string(page.Body),
				HTML: string(rendered),
			}, nil
		},
	})
}