A => .gitignore +9 -0
@@ 1,9 @@
+# Build output
+/huntsman
+
+# Local config
+/config.yaml
+
+# Editor / OS
+*.swp
+.DS_Store
A => .golangci.yml +45 -0
@@ 1,45 @@
+version: "2"
+
+run:
+ timeout: 5m
+
+linters:
+ enable:
+ - misspell
+ - whitespace
+ - errcheck
+ - govet
+ - staticcheck
+ - ineffassign
+ - unused
+ - gosec
+ - gocritic
+ - revive
+ - unconvert
+ - unparam
+ - prealloc
+ - noctx
+ - bodyclose
+ settings:
+ gocritic:
+ enabled-tags:
+ - diagnostic
+ - style
+ - performance
+ disabled-checks:
+ # Provider/OSD structs are tiny config payloads that we copy by value
+ # for clarity. Pointer receivers would force nil checks for no gain.
+ - hugeParam
+ revive:
+ rules:
+ - name: exported
+ disabled: true
+
+formatters:
+ enable:
+ - gofmt
+ - goimports
+
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
A => Dockerfile +18 -0
@@ 1,18 @@
+FROM golang:1.26-alpine AS builder
+
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o huntsman ./cmd/server
+
+FROM alpine:3.20
+RUN apk --no-cache add ca-certificates tzdata
+
+WORKDIR /app
+COPY --from=builder /app/huntsman .
+COPY config.example.yaml /app/config.yaml
+
+EXPOSE 8080
+ENTRYPOINT ["/app/huntsman", "-config", "/app/config.yaml"]
A => Justfile +40 -0
@@ 1,40 @@
+binary := "huntsman"
+version := `git describe --tags --always 2>/dev/null || echo dev`
+commit := `git rev-parse --short HEAD 2>/dev/null || echo none`
+date := `date -u +%Y-%m-%dT%H:%M:%SZ`
+
+ldflags := "-X main.version=" + version + " -X main.commit=" + commit + " -X main.date=" + date
+
+default:
+ @just --list
+
+build:
+ go build -ldflags "{{ldflags}}" -o {{binary}} ./cmd/server
+
+run: build
+ ./{{binary}} -config config.yaml
+
+dev:
+ go run ./cmd/server -config config.yaml
+
+test:
+ go test -race ./...
+
+lint:
+ golangci-lint run ./...
+
+fmt:
+ gofmt -s -w .
+ goimports -w .
+
+tidy:
+ go mod tidy
+
+docker-build:
+ docker build -t {{binary}}:{{version}} .
+
+docker-run:
+ docker compose up -d --build
+
+docker-stop:
+ docker compose down
A => README.md +73 -0
@@ 1,73 @@
+# huntsman
+
+A small self-hosted HTTP service that acts as a multi-provider search router.
+
+It exposes a single OpenSearch description that you can register in your
+browser's address bar. When you type a query, a short prefix decides which
+upstream search engine handles it.
+
+## Why
+
+Browsers let you add at most one default search engine. If you regularly
+search Urban Dictionary, GitHub, and Steam, you end up adding three
+engines and learning three keywords per browser. This service collapses
+that into a single `<link rel="search">` and a unified prefix grammar
+that works in any browser, on any device.
+
+## Providers
+
+| Prefix | Provider | Upstream |
+| ------- | ----------------- | ------------------------------------- |
+| `ud` | Urban Dictionary | `urbandictionary.com/define.php` |
+| `gh` | GitHub | `github.com/search` |
+| `steam` | Steam Store | `store.steampowered.com/search` |
+
+Both `ud foo bar` and `ud:foo bar` work. With no recognized prefix the
+query falls through to the configured default provider.
+
+## Endpoints
+
+| Method | Path | Purpose |
+| ------ | ------------------------------- | --------------------------------------------- |
+| GET | `/` | Landing page advertising the OSD via `<link>` |
+| GET | `/search?q=...` | 302 redirect to the chosen provider |
+| GET | `/providers` | JSON list of registered providers |
+| GET | `/opensearch.xml` | Unified OSD pointing at `/search` |
+| GET | `/opensearch/{provider}.xml` | Per-provider OSD pointing directly upstream |
+| GET | `/healthz`, `/readyz` | Liveness & readiness probes |
+
+## Quick start
+
+```sh
+cp config.example.yaml config.yaml
+# edit server.public_url to match your deployment
+just dev
+# then open http://localhost:8080/ in a browser and add the search engine
+```
+
+## Configuration
+
+Config is YAML. Every field can be overridden by an environment variable
+prefixed `APP_` with `.` replaced by `_`, e.g. `APP_SERVER_PORT=9000`.
+
+```yaml
+server:
+ host: "0.0.0.0"
+ port: 8080
+ public_url: "https://search.example.com" # used in OSD XML
+search:
+ default_provider: gh # ud | gh | steam
+log:
+ level: info
+ format: human # human | json
+```
+
+## Build / run
+
+```sh
+just build # produces ./huntsman
+just test # unit tests
+just lint # golangci-lint
+just docker-build # container image
+just docker-run # docker compose up -d --build
+```
A => cmd/server/main.go +52 -0
@@ 1,52 @@
+// Command server runs the multi-provider search helper.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/app"
+)
+
+// Set by goreleaser / -ldflags.
+var (
+ version = "dev"
+ commit = "none"
+ date = "unknown"
+)
+
+func main() {
+ os.Exit(run())
+}
+
+func run() int {
+ configPath := flag.String("config", "config.yaml", "path to config file")
+ showVersion := flag.Bool("version", false, "print version and exit")
+ flag.Parse()
+
+ if *showVersion {
+ fmt.Printf("huntsman %s (commit %s, built %s)\n", version, commit, date)
+ return 0
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ a, err := app.New(ctx, *configPath, app.BuildInfo{Version: version, Commit: commit, Date: date})
+ if err != nil {
+ slog.Error("startup failed", scribe.Err(err))
+ return 1
+ }
+ if err := a.Run(ctx); err != nil {
+ slog.Error("run failed", scribe.Err(err))
+ return 1
+ }
+ return 0
+}
A => config.example.yaml +17 -0
@@ 1,17 @@
+server:
+ host: "0.0.0.0"
+ port: 8080
+ read_timeout: 15s
+ write_timeout: 15s
+ # Externally-visible URL of this service. Used in OpenSearch description
+ # XML so browsers know where to fetch the search template from.
+ public_url: "http://localhost:8080"
+
+log:
+ level: info # debug, info, warn, error
+ format: human # human, json
+
+search:
+ # Provider used when the query has no recognized prefix.
+ # One of: ud, gh, steam.
+ default_provider: gh
A => docker-compose.yml +10 -0
@@ 1,10 @@
+services:
+ app:
+ build: .
+ ports:
+ - "8080:8080"
+ environment:
+ APP_LOG_FORMAT: json
+ APP_LOG_LEVEL: info
+ APP_SERVER_PUBLIC_URL: "http://localhost:8080"
+ restart: unless-stopped
A => go.mod +27 -0
@@ 1,27 @@
+module sourcecraft.dev/bigbes/huntsman
+
+go 1.26.1
+
+require (
+ github.com/go-chi/chi/v5 v5.2.5
+ github.com/google/uuid v1.6.0
+ github.com/spf13/viper v1.21.0
+ go.bigb.es/auxilia v0.2.0
+)
+
+require (
+ github.com/dominikbraun/graph v0.23.0 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+ github.com/sagikazarmark/locafero v0.11.0 // indirect
+ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
+ github.com/soverenio/vanilla v0.0.0-20240904095435-e5a9e99f83ca // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/sys v0.42.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+)
A => go.sum +57 -0
@@ 1,57 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
+github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
+github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
+github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
+github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
+github.com/soverenio/vanilla v0.0.0-20240904095435-e5a9e99f83ca h1:rB4eZDa14pktnbTiEn9GpTt3b78e1l+kK35/qhoc/PI=
+github.com/soverenio/vanilla v0.0.0-20240904095435-e5a9e99f83ca/go.mod h1:EiaNftWfwv+c5RT1C2PXI9ZJjBJwtOymKQl56sC+ORE=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+go.bigb.es/auxilia v0.2.0 h1:qAkyDiNCihdUMqxiHOavAuqgtGNZ8ZkIwCFV6UcaM80=
+go.bigb.es/auxilia v0.2.0/go.mod h1:7TFf9RZZo0YbXQ3mY9GnirwxgVTa9HXaJBHr+1rU0LY=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
A => internal/app/app.go +80 -0
@@ 1,80 @@
+// Package app wires the application together using auxilia/steward for
+// lifecycle management. New constructs the steward Manager; Run drives
+// it through Inject -> Init -> Start, blocks on context cancellation,
+// then runs Stop and Destroy.
+package app
+
+import (
+ "context"
+ "time"
+
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/steward"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/config"
+)
+
+// BuildInfo carries linker-injected build metadata for /providers and logs.
+type BuildInfo struct {
+ Version string
+ Commit string
+ Date string
+}
+
+// stopTimeout caps how long graceful shutdown is allowed to take after
+// the run context cancels. Slightly longer than the per-server shutdown
+// budget so steward can finish ordering Stop calls.
+const stopTimeout = 35 * time.Second
+
+// App holds the steward Manager and the config used to build it.
+type App struct {
+ mgr *steward.Manager
+}
+
+// New loads config and assembles the dependency graph.
+func New(ctx context.Context, configPath string, info BuildInfo) (*App, error) {
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ return nil, culpa.Wrap(err, "load config")
+ }
+
+ mgr := steward.NewManager()
+ mgr.AddComponent(ctx,
+ steward.MustConfigurationAsset(*cfg),
+ steward.MustServiceAsset(&loggerWire{}),
+ steward.MustServiceAsset(&searchWire{}),
+ steward.MustServiceAsset(&healthWire{}),
+ steward.MustServiceAsset(&httpServer{Info: info}, steward.Root()),
+ )
+
+ if err := mgr.Inject(ctx); err != nil {
+ return nil, culpa.Wrap(err, "inject")
+ }
+ if err := mgr.Init(ctx); err != nil {
+ return nil, culpa.Wrap(err, "init")
+ }
+ return &App{mgr: mgr}, nil
+}
+
+// Run starts every service, blocks until ctx is cancelled, then runs
+// graceful shutdown via steward.Stop and Destroy.
+func (a *App) Run(ctx context.Context) error {
+ if err := a.mgr.Start(ctx); err != nil {
+ stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
+ defer cancel()
+ _ = a.mgr.Stop(stopCtx)
+ return culpa.Wrap(err, "start")
+ }
+
+ <-ctx.Done()
+
+ stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
+ defer cancel()
+ if err := a.mgr.Stop(stopCtx); err != nil {
+ return culpa.Wrap(err, "stop")
+ }
+ if err := a.mgr.Destroy(stopCtx); err != nil {
+ return culpa.Wrap(err, "destroy")
+ }
+ return nil
+}
A => internal/app/wires.go +121 -0
@@ 1,121 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/config"
+ "sourcecraft.dev/bigbes/huntsman/internal/domain/search"
+ "sourcecraft.dev/bigbes/huntsman/internal/platform/health"
+ "sourcecraft.dev/bigbes/huntsman/internal/platform/observability"
+ "sourcecraft.dev/bigbes/huntsman/internal/server"
+)
+
+// loggerWire builds the application's structured logger from config and
+// installs it as the slog default so package-level callers pick it up.
+type loggerWire struct {
+ Config config.Config `config:""`
+
+ Log *slog.Logger
+}
+
+func (w *loggerWire) Init(_ context.Context) error {
+ w.Log = observability.NewLogger(w.Config.Log.Level, w.Config.Log.Format)
+ slog.SetDefault(w.Log)
+ return nil
+}
+
+// searchWire constructs the search service and HTTP handler. Both are
+// kept on the wire so HTTP can inject the handler and other services
+// can call into the service if needed later.
+type searchWire struct {
+ Config config.Config `config:""`
+
+ Service *search.Service
+ Handler *search.Handler
+}
+
+func (w *searchWire) Init(_ context.Context) error {
+ svc, err := search.NewService(w.Config.Search.DefaultProvider, w.Config.Server.PublicURL)
+ if err != nil {
+ return culpa.Wrap(err, "build search service")
+ }
+ w.Service = svc
+ w.Handler = search.NewHandler(svc)
+ return nil
+}
+
+// healthWire wraps the health registry so steward owns its lifetime.
+// Add upstream probes by calling Health.Register from another service's
+// Init via inject:"".
+type healthWire struct {
+ Health *health.Health
+}
+
+func (w *healthWire) Init(_ context.Context) error {
+ w.Health = health.New()
+ return nil
+}
+
+// shutdownTimeout is how long http.Server.Shutdown is allowed to drain
+// in-flight requests before returning.
+const shutdownTimeout = 30 * time.Second
+
+// httpServer is the root steward service. It depends on logger, search,
+// and health wires (resolved by type). Init builds the chi router; Start
+// runs the listener in a goroutine; Stop drains via Shutdown.
+type httpServer struct {
+ Config config.Config `config:""`
+ Logger *loggerWire `inject:""`
+ Search *searchWire `inject:""`
+ Health *healthWire `inject:""`
+
+ Info BuildInfo
+
+ srv *http.Server
+}
+
+func (s *httpServer) Init(_ context.Context) error {
+ router := server.Routes(s.Logger.Log, s.Health.Health, s.Search.Handler)
+ addr := fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port)
+ s.srv = &http.Server{
+ Addr: addr,
+ Handler: router,
+ ReadTimeout: s.Config.Server.ReadTimeout,
+ WriteTimeout: s.Config.Server.WriteTimeout,
+ IdleTimeout: 60 * time.Second,
+ }
+ return nil
+}
+
+func (s *httpServer) Start(_ context.Context) error {
+ go func() {
+ s.Logger.Log.Info("server starting",
+ "addr", s.srv.Addr,
+ "version", s.Info.Version,
+ )
+ if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ s.Logger.Log.Error("server stopped unexpectedly",
+ scribe.Err(culpa.Wrap(err, "ListenAndServe")),
+ )
+ }
+ }()
+ return nil
+}
+
+func (s *httpServer) Stop(_ context.Context) error {
+ s.Logger.Log.Info("shutting down gracefully")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if err := s.srv.Shutdown(shutdownCtx); err != nil {
+ return culpa.Wrap(err, "http shutdown")
+ }
+ return nil
+}
A => internal/config/config.go +89 -0
@@ 1,89 @@
+// Package config defines the application configuration shape and validation.
+package config
+
+import (
+ "net/url"
+ "time"
+
+ "go.bigb.es/auxilia/culpa"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/domain/search"
+)
+
+// Config is the top-level configuration for huntsman.
+type Config struct {
+ Server ServerConfig `mapstructure:"server"`
+ Log LogConfig `mapstructure:"log"`
+ Search SearchConfig `mapstructure:"search"`
+}
+
+// ServerConfig configures the HTTP server.
+type ServerConfig struct {
+ Host string `mapstructure:"host"`
+ Port int `mapstructure:"port"`
+ ReadTimeout time.Duration `mapstructure:"read_timeout"`
+ WriteTimeout time.Duration `mapstructure:"write_timeout"`
+ // PublicURL is the externally-visible base URL of this service.
+ // It's embedded in OpenSearch description XML so browsers know where to
+ // fetch the search template. Example: "https://search.example.com".
+ PublicURL string `mapstructure:"public_url"`
+}
+
+// LogConfig configures structured logging.
+type LogConfig struct {
+ Level string `mapstructure:"level"`
+ Format string `mapstructure:"format"`
+}
+
+// SearchConfig controls search-routing behavior.
+type SearchConfig struct {
+ // DefaultProvider is the provider used when no prefix matches.
+ // Must be one of the registered provider IDs (see search.AllProviders).
+ DefaultProvider string `mapstructure:"default_provider"`
+}
+
+// Validate runs hand-written checks against the loaded config. Failures
+// carry an "INVALID_CONFIG" code so call sites can distinguish them.
+func (c *Config) Validate() error {
+ if c.Server.Host == "" {
+ return invalid("server.host is required")
+ }
+ if c.Server.Port < 1 || c.Server.Port > 65535 {
+ return invalid("server.port must be between 1 and 65535")
+ }
+ if c.Server.PublicURL == "" {
+ return invalid("server.public_url is required")
+ }
+ if u, err := url.Parse(c.Server.PublicURL); err != nil || u.Scheme == "" || u.Host == "" {
+ return invalid("server.public_url must be an absolute URL")
+ }
+
+ switch c.Log.Level {
+ case "debug", "info", "warn", "error":
+ default:
+ return invalid("log.level must be one of debug|info|warn|error")
+ }
+ switch c.Log.Format {
+ case "human", "json":
+ default:
+ return invalid("log.format must be one of human|json")
+ }
+
+ if !knownProvider(c.Search.DefaultProvider) {
+ return invalid("search.default_provider must be a known provider id")
+ }
+ return nil
+}
+
+func invalid(msg string) error {
+ return culpa.WithCode(culpa.New(msg), "INVALID_CONFIG")
+}
+
+func knownProvider(id string) bool {
+ for _, p := range search.AllProviders() {
+ if p.ID == id {
+ return true
+ }
+ }
+ return false
+}
A => internal/config/loader.go +47 -0
@@ 1,47 @@
+package config
+
+import (
+ "strings"
+ "time"
+
+ "github.com/spf13/viper"
+ "go.bigb.es/auxilia/culpa"
+)
+
+// Load reads YAML config from path, overlays APP_* env vars, and validates.
+//
+// Strict mode is enabled — unknown keys cause a load failure rather than
+// being silently ignored, which catches typos in production config.
+func Load(path string) (*Config, error) {
+ v := viper.New()
+ v.SetConfigFile(path)
+ v.SetConfigType("yaml")
+
+ v.SetEnvPrefix("APP")
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+
+ // Defaults — keep these in sync with config.example.yaml.
+ v.SetDefault("server.host", "0.0.0.0")
+ v.SetDefault("server.port", 8080)
+ v.SetDefault("server.read_timeout", 15*time.Second)
+ v.SetDefault("server.write_timeout", 15*time.Second)
+ v.SetDefault("log.level", "info")
+ v.SetDefault("log.format", "human")
+ v.SetDefault("search.default_provider", "gh")
+
+ if err := v.ReadInConfig(); err != nil {
+ return nil, culpa.Wrap(err, "read config")
+ }
+
+ var cfg Config
+ if err := v.UnmarshalExact(&cfg); err != nil {
+ return nil, culpa.Wrap(err, "unmarshal config")
+ }
+
+ if err := cfg.Validate(); err != nil {
+ return nil, culpa.Wrap(err, "validate config")
+ }
+
+ return &cfg, nil
+}
A => internal/config/loader_test.go +226 -0
@@ 1,226 @@
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// writeConfig drops the given YAML body into a temp file and returns its path.
+func writeConfig(t *testing.T, body string) string {
+ t.Helper()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+ if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+ return path
+}
+
+func TestLoadFullConfig(t *testing.T) {
+ path := writeConfig(t, `
+server:
+ host: "127.0.0.1"
+ port: 9001
+ read_timeout: 7s
+ write_timeout: 8s
+ public_url: "https://search.example.com"
+log:
+ level: debug
+ format: json
+search:
+ default_provider: steam
+`)
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.Server.Host != "127.0.0.1" {
+ t.Errorf("host = %q", cfg.Server.Host)
+ }
+ if cfg.Server.Port != 9001 {
+ t.Errorf("port = %d", cfg.Server.Port)
+ }
+ if cfg.Server.ReadTimeout != 7*time.Second {
+ t.Errorf("read_timeout = %v", cfg.Server.ReadTimeout)
+ }
+ if cfg.Server.WriteTimeout != 8*time.Second {
+ t.Errorf("write_timeout = %v", cfg.Server.WriteTimeout)
+ }
+ if cfg.Server.PublicURL != "https://search.example.com" {
+ t.Errorf("public_url = %q", cfg.Server.PublicURL)
+ }
+ if cfg.Log.Level != "debug" || cfg.Log.Format != "json" {
+ t.Errorf("log = %+v", cfg.Log)
+ }
+ if cfg.Search.DefaultProvider != "steam" {
+ t.Errorf("default_provider = %q", cfg.Search.DefaultProvider)
+ }
+}
+
+func TestLoadAppliesDefaults(t *testing.T) {
+ // Only public_url is required and has no default.
+ path := writeConfig(t, `
+server:
+ public_url: "http://localhost:8080"
+`)
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.Server.Host != "0.0.0.0" {
+ t.Errorf("default host = %q", cfg.Server.Host)
+ }
+ if cfg.Server.Port != 8080 {
+ t.Errorf("default port = %d", cfg.Server.Port)
+ }
+ if cfg.Server.ReadTimeout != 15*time.Second {
+ t.Errorf("default read_timeout = %v", cfg.Server.ReadTimeout)
+ }
+ if cfg.Log.Level != "info" || cfg.Log.Format != "human" {
+ t.Errorf("default log = %+v", cfg.Log)
+ }
+ if cfg.Search.DefaultProvider != "gh" {
+ t.Errorf("default provider = %q", cfg.Search.DefaultProvider)
+ }
+}
+
+func TestLoadEnvOverride(t *testing.T) {
+ path := writeConfig(t, `
+server:
+ public_url: "http://localhost:8080"
+`)
+ t.Setenv("APP_SERVER_PORT", "9090")
+ t.Setenv("APP_LOG_LEVEL", "warn")
+ t.Setenv("APP_SEARCH_DEFAULT_PROVIDER", "ud")
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if cfg.Server.Port != 9090 {
+ t.Errorf("env override port = %d", cfg.Server.Port)
+ }
+ if cfg.Log.Level != "warn" {
+ t.Errorf("env override log.level = %q", cfg.Log.Level)
+ }
+ if cfg.Search.DefaultProvider != "ud" {
+ t.Errorf("env override default_provider = %q", cfg.Search.DefaultProvider)
+ }
+}
+
+func TestLoadMissingFile(t *testing.T) {
+ _, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}
+
+func TestLoadInvalidYAML(t *testing.T) {
+ path := writeConfig(t, "this: is: not: valid: yaml:\n - [")
+ if _, err := Load(path); err == nil {
+ t.Fatal("expected parse error")
+ }
+}
+
+func TestLoadUnknownKeyRejected(t *testing.T) {
+ // UnmarshalExact rejects unknown keys, which catches typos.
+ path := writeConfig(t, `
+server:
+ public_url: "http://localhost:8080"
+ bogus_field: 42
+`)
+ _, err := Load(path)
+ if err == nil {
+ t.Fatal("expected error for unknown key")
+ }
+ if !strings.Contains(err.Error(), "bogus_field") && !strings.Contains(err.Error(), "unmarshal") {
+ t.Errorf("error should mention unknown key or unmarshal, got: %v", err)
+ }
+}
+
+func TestLoadValidationErrors(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ }{
+ {
+ name: "missing public_url",
+ body: `
+server:
+ host: "0.0.0.0"
+ port: 8080
+`,
+ },
+ {
+ name: "invalid public_url",
+ body: `
+server:
+ public_url: "not a url"
+`,
+ },
+ {
+ name: "port out of range",
+ body: `
+server:
+ public_url: "http://localhost:8080"
+ port: 70000
+`,
+ },
+ {
+ name: "unknown log level",
+ body: `
+server:
+ public_url: "http://localhost:8080"
+log:
+ level: shout
+`,
+ },
+ {
+ name: "unknown log format",
+ body: `
+server:
+ public_url: "http://localhost:8080"
+log:
+ format: yaml
+`,
+ },
+ {
+ name: "unknown default provider",
+ body: `
+server:
+ public_url: "http://localhost:8080"
+search:
+ default_provider: yahoo
+`,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := writeConfig(t, tc.body)
+ if _, err := Load(path); err == nil {
+ t.Fatal("expected validation error")
+ }
+ })
+ }
+}
+
+func TestValidateDirectly(t *testing.T) {
+ cfg := &Config{
+ Server: ServerConfig{Host: "0.0.0.0", Port: 8080, PublicURL: "http://localhost:8080"},
+ Log: LogConfig{Level: "info", Format: "human"},
+ Search: SearchConfig{DefaultProvider: "gh"},
+ }
+ if err := cfg.Validate(); err != nil {
+ t.Fatalf("Validate on good config: %v", err)
+ }
+
+ cfg.Search.DefaultProvider = ""
+ if err := cfg.Validate(); err == nil {
+ t.Fatal("expected validation error for empty default_provider")
+ }
+}
A => internal/domain/search/handler.go +116 -0
@@ 1,116 @@
+package search
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
+ "sourcecraft.dev/bigbes/huntsman/internal/pkg/httputil"
+)
+
+// Handler exposes the search service over HTTP.
+type Handler struct {
+ svc *Service
+}
+
+// NewHandler wires a Service into an HTTP handler.
+func NewHandler(svc *Service) *Handler {
+ return &Handler{svc: svc}
+}
+
+// RegisterRoutes attaches the search-related routes to a chi router.
+//
+// Routes:
+//
+// GET /search?q=... → 302 to the chosen provider
+// GET /providers → JSON list of providers
+// GET /opensearch.xml → unified OSD pointing at /search
+// GET /opensearch/{provider}.xml → OSD pointing directly at provider
+func (h *Handler) RegisterRoutes(r chi.Router) {
+ r.Get("/search", h.Search)
+ r.Get("/providers", h.ListProviders)
+ r.Get("/opensearch.xml", h.OpenSearchRouter)
+ r.Get("/opensearch/{provider}.xml", h.OpenSearchProvider)
+}
+
+// Search routes the query string parameter "q" to the matching provider
+// and returns a 302 redirect to the upstream search URL.
+func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query().Get("q")
+
+ provider, cleaned, err := h.svc.Route(q)
+ if err != nil {
+ httputil.Error(w, r, err)
+ return
+ }
+
+ target := provider.SearchURLFor(cleaned)
+ http.Redirect(w, r, target, http.StatusFound)
+}
+
+// providerView is the JSON shape returned by /providers.
+type providerView struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ HomeURL string `json:"home_url"`
+ OpenSearchURL string `json:"opensearch_url"`
+}
+
+// ListProviders returns the registered providers as JSON.
+func (h *Handler) ListProviders(w http.ResponseWriter, _ *http.Request) {
+ providers := h.svc.Providers()
+ out := make([]providerView, 0, len(providers))
+ for _, p := range providers {
+ out = append(out, providerView{
+ ID: p.ID,
+ Name: p.Name,
+ Description: p.Description,
+ HomeURL: p.HomeURL,
+ OpenSearchURL: h.svc.PublicURL() + "/opensearch/" + p.ID + ".xml",
+ })
+ }
+ httputil.OK(w, map[string]any{
+ "default": h.svc.defaultProvider,
+ "providers": out,
+ })
+}
+
+// OpenSearchRouter serves the unified OSD XML document, which makes the
+// browser send searches to /search where prefix routing kicks in.
+func (h *Handler) OpenSearchRouter(w http.ResponseWriter, r *http.Request) {
+ osd := DescriptionForRouter(h.svc.PublicURL())
+ body, err := Marshal(osd)
+ if err != nil {
+ httputil.Error(w, r, err)
+ return
+ }
+ writeOSD(w, body)
+}
+
+// OpenSearchProvider serves an OSD document for a single upstream provider,
+// useful when the user wants to add e.g. urbandictionary directly.
+func (h *Handler) OpenSearchProvider(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "provider")
+ provider, ok := h.svc.Lookup(id)
+ if !ok {
+ httputil.Error(w, r, apierror.NotFound("provider "+id))
+ return
+ }
+ body, err := Marshal(DescriptionForProvider(provider))
+ if err != nil {
+ httputil.Error(w, r, err)
+ return
+ }
+ writeOSD(w, body)
+}
+
+// writeOSD writes a marshaled OSD document with the correct MIME type.
+//
+// The opensearchdescription+xml MIME type is what browsers look for when
+// auto-detecting search engines via <link rel="search">.
+func writeOSD(w http.ResponseWriter, body []byte) {
+ w.Header().Set("Content-Type", "application/opensearchdescription+xml")
+ _, _ = w.Write(body)
+}
A => internal/domain/search/handler_test.go +171 -0
@@ 1,171 @@
+package search
+
+import (
+ "encoding/json"
+ "encoding/xml"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// newTestHandler builds a router with the search handler mounted at /.
+func newTestHandler(t *testing.T) http.Handler {
+ t.Helper()
+ svc, err := NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ r := chi.NewRouter()
+ NewHandler(svc).RegisterRoutes(r)
+ return r
+}
+
+func TestSearchRedirects(t *testing.T) {
+ h := newTestHandler(t)
+
+ cases := []struct {
+ name string
+ query string
+ wantLoc string
+ }{
+ {"unknown prefix uses default gh", "foo bar", "https://github.com/search?q=foo+bar"},
+ {"ud prefix routes to urban dictionary", "ud meme", "https://www.urbandictionary.com/define.php?term=meme"},
+ {"steam prefix routes to steam", "steam half life", "https://store.steampowered.com/search/?term=half+life"},
+ {"colon prefix", "ud:lit", "https://www.urbandictionary.com/define.php?term=lit"},
+ {"empty query goes to default home", "", "https://github.com/"},
+ {"bare prefix goes to provider home", "ud", "https://www.urbandictionary.com/"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/search?q="+encodeQ(tc.query), http.NoBody)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusFound {
+ t.Fatalf("status = %d, want 302", rr.Code)
+ }
+ if got := rr.Header().Get("Location"); got != tc.wantLoc {
+ t.Errorf("Location = %q\n want %q", got, tc.wantLoc)
+ }
+ })
+ }
+}
+
+// encodeQ percent-encodes spaces only — enough for these tests.
+func encodeQ(s string) string {
+ return strings.ReplaceAll(s, " ", "+")
+}
+
+func TestListProvidersJSON(t *testing.T) {
+ h := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/providers", http.NoBody))
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/json" {
+ t.Errorf("content-type = %q", got)
+ }
+
+ var body struct {
+ Default string `json:"default"`
+ Providers []struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ HomeURL string `json:"home_url"`
+ OpenSearchURL string `json:"opensearch_url"`
+ } `json:"providers"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ if body.Default != "gh" {
+ t.Errorf("default = %q", body.Default)
+ }
+ if len(body.Providers) != 3 {
+ t.Fatalf("expected 3 providers, got %d", len(body.Providers))
+ }
+ // Order should match AllProviders().
+ wantIDs := []string{"ud", "gh", "steam"}
+ for i, want := range wantIDs {
+ if body.Providers[i].ID != want {
+ t.Errorf("providers[%d].ID = %q, want %q", i, body.Providers[i].ID, want)
+ }
+ }
+ // Per-provider OSD URL is built from the public URL.
+ if body.Providers[0].OpenSearchURL != "https://example.com/opensearch/ud.xml" {
+ t.Errorf("opensearch_url = %q", body.Providers[0].OpenSearchURL)
+ }
+}
+
+func TestOpenSearchRouterXML(t *testing.T) {
+ h := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch.xml", http.NoBody))
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/opensearchdescription+xml" {
+ t.Errorf("content-type = %q", got)
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, "<?xml") {
+ t.Errorf("body missing XML prolog: %q", body)
+ }
+ if !strings.Contains(body, "https://example.com/search?q={searchTerms}") {
+ t.Errorf("body missing router template: %q", body)
+ }
+
+ var osd OpenSearchDescription
+ if err := xml.Unmarshal(rr.Body.Bytes(), &osd); err != nil {
+ t.Fatalf("unmarshal OSD: %v", err)
+ }
+ if osd.ShortName != "huntsman" {
+ t.Errorf("ShortName = %q", osd.ShortName)
+ }
+}
+
+func TestOpenSearchProviderXML(t *testing.T) {
+ h := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch/ud.xml", http.NoBody))
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+
+ var osd OpenSearchDescription
+ if err := xml.Unmarshal(rr.Body.Bytes(), &osd); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if osd.ShortName != "Urban Dictionary" {
+ t.Errorf("ShortName = %q", osd.ShortName)
+ }
+ if len(osd.URLs) != 1 || !strings.Contains(osd.URLs[0].Template, "{searchTerms}") {
+ t.Errorf("template missing {searchTerms}: %+v", osd.URLs)
+ }
+ if osd.Image == nil || osd.Image.Value == "" {
+ t.Errorf("expected Image with icon URL")
+ }
+}
+
+func TestOpenSearchProviderUnknown(t *testing.T) {
+ h := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch/yahoo.xml", http.NoBody))
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("content-type = %q", got)
+ }
+}
A => internal/domain/search/opensearch.go +96 -0
@@ 1,96 @@
+package search
+
+import (
+ "encoding/xml"
+ "fmt"
+ "strings"
+)
+
+// OpenSearchDescription is the root <OpenSearchDescription> element of an
+// OSD 1.1 document. Browsers (Firefox, Chromium) parse this to register
+// a search engine; the {searchTerms} placeholder in URL templates is
+// substituted by the browser at search time.
+//
+// Spec: https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md
+type OpenSearchDescription struct {
+ XMLName xml.Name `xml:"OpenSearchDescription"`
+ XMLNS string `xml:"xmlns,attr"`
+ ShortName string `xml:"ShortName"`
+ Description string `xml:"Description"`
+ InputEncoding string `xml:"InputEncoding"`
+ Image *Image `xml:"Image,omitempty"`
+ URLs []URL `xml:"Url"`
+ SearchForm string `xml:"SearchForm,omitempty"`
+ Developer string `xml:"Developer,omitempty"`
+ Attribution string `xml:"Attribution,omitempty"`
+ SyndicationRgt string `xml:"SyndicationRight,omitempty"`
+ AdultContent string `xml:"AdultContent,omitempty"`
+}
+
+// URL is a single <Url> element pointing at a search template.
+type URL struct {
+ Type string `xml:"type,attr"`
+ Template string `xml:"template,attr"`
+ Method string `xml:"method,attr,omitempty"`
+}
+
+// Image is a <Image> element for the search engine icon.
+type Image struct {
+ Height int `xml:"height,attr"`
+ Width int `xml:"width,attr"`
+ Type string `xml:"type,attr"`
+ Value string `xml:",chardata"`
+}
+
+const xmlNS = "http://a9.com/-/spec/opensearch/1.1/"
+
+// DescriptionForProvider builds an OSD document that points the browser
+// directly at the upstream provider — useful when the user wants to add
+// e.g. urbandictionary as a standalone search engine.
+func DescriptionForProvider(p Provider) OpenSearchDescription {
+ osd := OpenSearchDescription{
+ XMLNS: xmlNS,
+ ShortName: p.Name,
+ Description: p.Description,
+ InputEncoding: "UTF-8",
+ URLs: []URL{{
+ Type: "text/html",
+ // OpenSearch uses {searchTerms}; convert from our internal {q}.
+ Template: strings.ReplaceAll(p.SearchURL, "{q}", "{searchTerms}"),
+ }},
+ SearchForm: p.HomeURL,
+ }
+ if p.IconURL != "" {
+ osd.Image = &Image{Height: 16, Width: 16, Type: "image/x-icon", Value: p.IconURL}
+ }
+ return osd
+}
+
+// DescriptionForRouter builds an OSD document that points the browser at
+// this service's own /search endpoint, so prefix routing ("ud foo") works
+// from the browser's address bar.
+//
+// publicURL must be the externally-visible base URL (no trailing slash).
+func DescriptionForRouter(publicURL string) OpenSearchDescription {
+ publicURL = strings.TrimRight(publicURL, "/")
+ return OpenSearchDescription{
+ XMLNS: xmlNS,
+ ShortName: "huntsman",
+ Description: "Multi-provider search router (ud, gh, steam)",
+ InputEncoding: "UTF-8",
+ URLs: []URL{{
+ Type: "text/html",
+ Template: fmt.Sprintf("%s/search?q={searchTerms}", publicURL),
+ }},
+ SearchForm: publicURL + "/",
+ }
+}
+
+// Marshal serializes an OSD document with the standard XML prolog.
+func Marshal(osd OpenSearchDescription) ([]byte, error) {
+ body, err := xml.MarshalIndent(osd, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append([]byte(xml.Header), body...), nil
+}
A => internal/domain/search/opensearch_test.go +72 -0
@@ 1,72 @@
+package search
+
+import (
+ "encoding/xml"
+ "strings"
+ "testing"
+)
+
+func TestMarshalEmitsXMLProlog(t *testing.T) {
+ osd := DescriptionForRouter("https://example.com")
+ body, err := Marshal(osd)
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ if !strings.HasPrefix(string(body), "<?xml") {
+ t.Errorf("missing XML prolog: %q", body[:min(40, len(body))])
+ }
+ // Round-trip should succeed.
+ var got OpenSearchDescription
+ if err := xml.Unmarshal(body, &got); err != nil {
+ t.Fatalf("unmarshal round-trip: %v", err)
+ }
+ if got.ShortName != osd.ShortName {
+ t.Errorf("ShortName lost in round-trip: %q vs %q", got.ShortName, osd.ShortName)
+ }
+}
+
+func TestDescriptionForRouterStripsTrailingSlash(t *testing.T) {
+ osd := DescriptionForRouter("https://example.com///")
+ if osd.URLs[0].Template != "https://example.com/search?q={searchTerms}" {
+ t.Errorf("template = %q", osd.URLs[0].Template)
+ }
+ if osd.SearchForm != "https://example.com/" {
+ t.Errorf("SearchForm = %q", osd.SearchForm)
+ }
+}
+
+func TestDescriptionForProviderConvertsPlaceholder(t *testing.T) {
+ p := Provider{
+ ID: "x",
+ Name: "X",
+ SearchURL: "https://x.example/?q={q}",
+ HomeURL: "https://x.example/",
+ }
+ osd := DescriptionForProvider(p)
+ if osd.URLs[0].Template != "https://x.example/?q={searchTerms}" {
+ t.Errorf("template = %q", osd.URLs[0].Template)
+ }
+ if osd.Image != nil {
+ t.Errorf("Image should be nil when IconURL empty, got %+v", osd.Image)
+ }
+}
+
+func TestDescriptionForProviderIncludesIcon(t *testing.T) {
+ p := Provider{
+ ID: "x",
+ Name: "X",
+ SearchURL: "https://x.example/?q={q}",
+ HomeURL: "https://x.example/",
+ IconURL: "https://x.example/favicon.ico",
+ }
+ osd := DescriptionForProvider(p)
+ if osd.Image == nil {
+ t.Fatal("Image should not be nil")
+ }
+ if osd.Image.Value != p.IconURL {
+ t.Errorf("Image.Value = %q", osd.Image.Value)
+ }
+ if osd.Image.Width != 16 || osd.Image.Height != 16 {
+ t.Errorf("Image dimensions = %dx%d", osd.Image.Width, osd.Image.Height)
+ }
+}
A => internal/domain/search/providers.go +135 -0
@@ 1,135 @@
+// Package search implements the multi-provider search router.
+//
+// Three search providers are supported out of the box:
+//
+// - ud → Urban Dictionary
+// - gh → GitHub
+// - steam → Steam Store
+//
+// Each provider has a stable ID, a display name, and a URL template
+// where {q} is substituted with the URL-encoded query.
+package search
+
+import (
+ "net/url"
+ "strings"
+
+ "go.bigb.es/auxilia/culpa"
+)
+
+// Provider describes a single upstream search engine.
+type Provider struct {
+ ID string // short prefix, e.g. "ud"
+ Name string // human-readable name, e.g. "Urban Dictionary"
+ Description string // shown in OpenSearch description XML
+ SearchURL string // template containing the literal "{q}" placeholder
+ HomeURL string // upstream homepage, used as fallback
+ IconURL string // 16x16 favicon, optional
+}
+
+// SearchURLFor returns the upstream search URL for the given raw query.
+//
+// The query is trimmed and percent-encoded; an empty query returns the
+// provider's HomeURL so a stray /search?q= still lands somewhere useful.
+func (p Provider) SearchURLFor(q string) string {
+ q = strings.TrimSpace(q)
+ if q == "" {
+ return p.HomeURL
+ }
+ return strings.ReplaceAll(p.SearchURL, "{q}", url.QueryEscape(q))
+}
+
+// AllProviders returns the registered providers in stable order.
+//
+// The order is also the order they appear in /providers and OpenSearch
+// listings, so it's intentional rather than a map iteration.
+func AllProviders() []Provider {
+ return []Provider{
+ {
+ ID: "ud",
+ Name: "Urban Dictionary",
+ Description: "Search slang definitions on Urban Dictionary",
+ SearchURL: "https://www.urbandictionary.com/define.php?term={q}",
+ HomeURL: "https://www.urbandictionary.com/",
+ IconURL: "https://www.urbandictionary.com/favicon.ico",
+ },
+ {
+ ID: "gh",
+ Name: "GitHub",
+ Description: "Search repositories, code, and issues on GitHub",
+ SearchURL: "https://github.com/search?q={q}",
+ HomeURL: "https://github.com/",
+ IconURL: "https://github.com/favicon.ico",
+ },
+ {
+ ID: "steam",
+ Name: "Steam Store",
+ Description: "Search games on the Steam store",
+ SearchURL: "https://store.steampowered.com/search/?term={q}",
+ HomeURL: "https://store.steampowered.com/",
+ IconURL: "https://store.steampowered.com/favicon.ico",
+ },
+ }
+}
+
+// providerByID indexes AllProviders by ID for O(1) lookup.
+func providerByID() map[string]Provider {
+ all := AllProviders()
+ m := make(map[string]Provider, len(all))
+ for _, p := range all {
+ m[p.ID] = p
+ }
+ return m
+}
+
+// Lookup returns the provider with the given ID, or false if none matches.
+func Lookup(id string) (Provider, bool) {
+ p, ok := providerByID()[id]
+ return p, ok
+}
+
+// Route inspects a raw user query and decides which provider should handle it.
+//
+// The first whitespace-delimited token is treated as a possible prefix.
+// Both "ud foo" and "ud:foo" route to the urbandictionary provider with
+// the query "foo". If no token matches a provider ID, the default provider
+// receives the original query unchanged.
+//
+// Returns the matched provider and the cleaned query to forward.
+func Route(raw string, providers map[string]Provider, defaultID string) (Provider, string, error) {
+ def, ok := providers[defaultID]
+ if !ok {
+ return Provider{}, "", culpa.WithCode(
+ culpa.Errorf("default provider %q is not registered", defaultID),
+ "UNKNOWN_PROVIDER",
+ )
+ }
+
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return def, "", nil
+ }
+
+ // "ud:foo bar" → prefix "ud", rest "foo bar"
+ if i := strings.IndexByte(trimmed, ':'); i > 0 {
+ prefix := strings.ToLower(trimmed[:i])
+ if p, ok := providers[prefix]; ok {
+ return p, strings.TrimSpace(trimmed[i+1:]), nil
+ }
+ }
+
+ // "ud foo bar" → prefix "ud", rest "foo bar"
+ if i := strings.IndexByte(trimmed, ' '); i > 0 {
+ prefix := strings.ToLower(trimmed[:i])
+ if p, ok := providers[prefix]; ok {
+ return p, strings.TrimSpace(trimmed[i+1:]), nil
+ }
+ }
+
+ // Bare prefix like "ud" with no query — go to that provider's homepage.
+ if p, ok := providers[strings.ToLower(trimmed)]; ok {
+ return p, "", nil
+ }
+
+ return def, trimmed, nil
+}
A => internal/domain/search/providers_test.go +87 -0
@@ 1,87 @@
+package search
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRoute(t *testing.T) {
+ providers := providerByID()
+
+ cases := []struct {
+ name string
+ input string
+ wantID string
+ wantQuery string
+ }{
+ {"empty falls back to default", "", "gh", ""},
+ {"unknown prefix becomes part of query", "foo bar", "gh", "foo bar"},
+ {"space prefix routes to ud", "ud meme", "ud", "meme"},
+ {"colon prefix routes to gh", "gh:repo language:go", "gh", "repo language:go"},
+ {"steam prefix with multi-word query", "steam half life", "steam", "half life"},
+ {"bare prefix returns provider with empty query", "ud", "ud", ""},
+ {"prefix is case-insensitive", "UD bar", "ud", "bar"},
+ {"leading whitespace is stripped", " gh foo", "gh", "foo"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, q, err := Route(tc.input, providers, "gh")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.ID != tc.wantID {
+ t.Errorf("provider ID = %q, want %q", got.ID, tc.wantID)
+ }
+ if q != tc.wantQuery {
+ t.Errorf("query = %q, want %q", q, tc.wantQuery)
+ }
+ })
+ }
+}
+
+func TestRouteUnknownDefault(t *testing.T) {
+ if _, _, err := Route("anything", providerByID(), "nope"); err == nil {
+ t.Fatal("expected error for unknown default provider")
+ }
+}
+
+func TestSearchURLFor(t *testing.T) {
+ p, ok := Lookup("ud")
+ if !ok {
+ t.Fatal("ud provider missing")
+ }
+ url := p.SearchURLFor("hello world")
+ if !strings.Contains(url, "term=hello+world") {
+ t.Errorf("expected URL to contain encoded query, got %q", url)
+ }
+
+ if got := p.SearchURLFor(""); got != p.HomeURL {
+ t.Errorf("empty query should return HomeURL, got %q", got)
+ }
+}
+
+func TestDescriptionForRouter(t *testing.T) {
+ osd := DescriptionForRouter("https://example.com/")
+ if len(osd.URLs) != 1 {
+ t.Fatalf("expected 1 URL element, got %d", len(osd.URLs))
+ }
+ want := "https://example.com/search?q={searchTerms}"
+ if osd.URLs[0].Template != want {
+ t.Errorf("template = %q, want %q", osd.URLs[0].Template, want)
+ }
+}
+
+func TestDescriptionForProvider(t *testing.T) {
+ p, _ := Lookup("gh")
+ osd := DescriptionForProvider(p)
+ if len(osd.URLs) != 1 {
+ t.Fatalf("expected 1 URL element, got %d", len(osd.URLs))
+ }
+ if !strings.Contains(osd.URLs[0].Template, "{searchTerms}") {
+ t.Errorf("template should contain {searchTerms}, got %q", osd.URLs[0].Template)
+ }
+ if strings.Contains(osd.URLs[0].Template, "{q}") {
+ t.Errorf("template should not contain internal {q} placeholder")
+ }
+}
A => internal/domain/search/service.go +59 -0
@@ 1,59 @@
+package search
+
+// Service holds the registered providers and routing defaults.
+//
+// It's stateless apart from configuration, so a single instance is shared
+// across all HTTP handlers.
+type Service struct {
+ providers map[string]Provider
+ defaultProvider string
+ publicURL string
+}
+
+// NewService constructs a search service with the built-in provider set.
+//
+// defaultProvider must be the ID of one of the built-in providers; the
+// loader-time validator guarantees this, but we re-check at startup so a
+// programmer mistake fails loudly rather than silently picking something.
+func NewService(defaultProvider, publicURL string) (*Service, error) {
+ providers := providerByID()
+ if _, ok := providers[defaultProvider]; !ok {
+ return nil, ErrUnknownProvider{ID: defaultProvider}
+ }
+ return &Service{
+ providers: providers,
+ defaultProvider: defaultProvider,
+ publicURL: publicURL,
+ }, nil
+}
+
+// Providers returns all registered providers in the canonical order.
+func (s *Service) Providers() []Provider {
+ return AllProviders()
+}
+
+// Lookup returns a provider by ID, or false if not registered.
+func (s *Service) Lookup(id string) (Provider, bool) {
+ p, ok := s.providers[id]
+ return p, ok
+}
+
+// Route resolves a raw query into a (provider, cleaned-query) pair.
+func (s *Service) Route(raw string) (Provider, string, error) {
+ return Route(raw, s.providers, s.defaultProvider)
+}
+
+// PublicURL returns the externally-visible URL of this service, used to
+// build self-referential OpenSearch description documents.
+func (s *Service) PublicURL() string {
+ return s.publicURL
+}
+
+// ErrUnknownProvider is returned when a provider ID is referenced but not
+// registered. It's a typed error so callers can distinguish it from
+// validation failures higher up the stack.
+type ErrUnknownProvider struct{ ID string }
+
+func (e ErrUnknownProvider) Error() string {
+ return "unknown provider: " + e.ID
+}
A => internal/domain/search/service_test.go +56 -0
@@ 1,56 @@
+package search
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestNewServiceUnknownDefault(t *testing.T) {
+ _, err := NewService("yahoo", "https://example.com")
+ if err == nil {
+ t.Fatal("expected error for unknown default provider")
+ }
+ var unk ErrUnknownProvider
+ if !errors.As(err, &unk) {
+ t.Fatalf("expected ErrUnknownProvider, got %T: %v", err, err)
+ }
+ if unk.ID != "yahoo" {
+ t.Errorf("ID = %q", unk.ID)
+ }
+ if unk.Error() != "unknown provider: yahoo" {
+ t.Errorf("Error() = %q", unk.Error())
+ }
+}
+
+func TestServiceLookupAndProviders(t *testing.T) {
+ svc, err := NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ if got := svc.PublicURL(); got != "https://example.com" {
+ t.Errorf("PublicURL = %q", got)
+ }
+ if len(svc.Providers()) != 3 {
+ t.Errorf("expected 3 providers")
+ }
+ if _, ok := svc.Lookup("ud"); !ok {
+ t.Errorf("Lookup ud should succeed")
+ }
+ if _, ok := svc.Lookup("yahoo"); ok {
+ t.Errorf("Lookup yahoo should fail")
+ }
+}
+
+func TestServiceRouteDelegates(t *testing.T) {
+ svc, err := NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ p, q, err := svc.Route("ud foo")
+ if err != nil {
+ t.Fatalf("Route: %v", err)
+ }
+ if p.ID != "ud" || q != "foo" {
+ t.Errorf("got (%q, %q), want (ud, foo)", p.ID, q)
+ }
+}
A => internal/pkg/apierror/error.go +97 -0
@@ 1,97 @@
+// Package apierror models RFC 7807 Problem Details responses.
+//
+// Errors returned from handlers flow through FromError, which inspects
+// typed sentinel values and culpa details to build a Problem with the
+// right HTTP status. Anything unrecognized becomes a 500 with the
+// original message stripped from the response body.
+package apierror
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "go.bigb.es/auxilia/culpa"
+)
+
+// Problem implements RFC 7807 Problem Details for HTTP APIs.
+type Problem struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status int `json:"status"`
+ Detail string `json:"detail,omitempty"`
+ Code string `json:"code,omitempty"`
+}
+
+// Error makes Problem usable as an error itself for chained handling.
+func (p Problem) Error() string { return p.Title }
+
+// Write encodes the Problem as application/problem+json and writes it.
+func (p Problem) Write(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/problem+json")
+ w.WriteHeader(p.Status)
+ _ = json.NewEncoder(w).Encode(p)
+}
+
+// FromError maps any error onto a Problem. Internal errors (anything not
+// matched by a typed predicate below) collapse to a generic 500 so we
+// don't leak implementation details to the client.
+//
+// If the error chain carries culpa CodeDetail / PublicDetail, those
+// override the defaults so call sites can attach machine codes and
+// user-safe messages without defining new typed errors.
+func FromError(err error) Problem {
+ var p Problem
+ if errors.As(err, &p) {
+ applyCulpaOverrides(err, &p)
+ return p
+ }
+
+ var nf NotFoundError
+ if errors.As(err, &nf) {
+ p = Problem{Type: "about:blank", Title: "Not Found", Status: 404, Detail: nf.Error(), Code: "NOT_FOUND"}
+ applyCulpaOverrides(err, &p)
+ return p
+ }
+
+ var br BadRequestError
+ if errors.As(err, &br) {
+ p = Problem{Type: "about:blank", Title: "Bad Request", Status: 400, Detail: br.Error(), Code: "BAD_REQUEST"}
+ applyCulpaOverrides(err, &p)
+ return p
+ }
+
+ return Problem{Type: "about:blank", Title: "Internal Server Error", Status: 500}
+}
+
+// applyCulpaOverrides mutates p with any CodeDetail/PublicDetail attached
+// to the error chain. Public messages are intended for end users, so when
+// present they replace whatever the typed error produced.
+func applyCulpaOverrides(err error, p *Problem) {
+ var code culpa.CodeDetail
+ if culpa.FindDetail(err, &code) {
+ if s, ok := code.Code.(string); ok && s != "" {
+ p.Code = s
+ }
+ }
+ var pub culpa.PublicDetail
+ if culpa.FindDetail(err, &pub) && pub.Message != "" {
+ p.Detail = pub.Message
+ }
+}
+
+// NotFoundError signals a missing resource and maps to HTTP 404.
+type NotFoundError struct{ Resource string }
+
+func (e NotFoundError) Error() string { return e.Resource + " not found" }
+
+// NotFound is a convenience constructor for NotFoundError.
+func NotFound(resource string) error { return NotFoundError{Resource: resource} }
+
+// BadRequestError signals invalid input and maps to HTTP 400.
+type BadRequestError struct{ Detail string }
+
+func (e BadRequestError) Error() string { return e.Detail }
+
+// BadRequest is a convenience constructor for BadRequestError.
+func BadRequest(detail string) error { return BadRequestError{Detail: detail} }
A => internal/pkg/apierror/error_test.go +105 -0
@@ 1,105 @@
+package apierror
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestFromErrorPassesThroughProblem(t *testing.T) {
+ original := Problem{Title: "Teapot", Status: 418, Code: "TEAPOT"}
+ got := FromError(original)
+ if got != original {
+ t.Errorf("FromError(Problem) lost data: got %+v want %+v", got, original)
+ }
+}
+
+func TestFromErrorWrappedProblem(t *testing.T) {
+ original := Problem{Title: "Teapot", Status: 418}
+ wrapped := fmt.Errorf("outer: %w", original)
+ got := FromError(wrapped)
+ if got.Status != 418 {
+ t.Errorf("status = %d, want 418", got.Status)
+ }
+}
+
+func TestFromErrorNotFound(t *testing.T) {
+ p := FromError(NotFound("widget"))
+ if p.Status != 404 {
+ t.Errorf("status = %d, want 404", p.Status)
+ }
+ if p.Code != "NOT_FOUND" {
+ t.Errorf("code = %q", p.Code)
+ }
+ if p.Detail != "widget not found" {
+ t.Errorf("detail = %q", p.Detail)
+ }
+}
+
+func TestFromErrorBadRequest(t *testing.T) {
+ p := FromError(BadRequest("missing q"))
+ if p.Status != 400 {
+ t.Errorf("status = %d, want 400", p.Status)
+ }
+ if p.Code != "BAD_REQUEST" {
+ t.Errorf("code = %q", p.Code)
+ }
+ if p.Detail != "missing q" {
+ t.Errorf("detail = %q", p.Detail)
+ }
+}
+
+func TestFromErrorUnknownCollapsesTo500(t *testing.T) {
+ p := FromError(errors.New("secret database password leaked"))
+ if p.Status != 500 {
+ t.Errorf("status = %d, want 500", p.Status)
+ }
+ if p.Detail != "" {
+ t.Errorf("detail should be empty to avoid leaks, got %q", p.Detail)
+ }
+ if p.Title != "Internal Server Error" {
+ t.Errorf("title = %q", p.Title)
+ }
+}
+
+func TestProblemError(t *testing.T) {
+ p := Problem{Title: "Bad"}
+ if p.Error() != "Bad" {
+ t.Errorf("Error() = %q", p.Error())
+ }
+}
+
+func TestProblemWrite(t *testing.T) {
+ rr := httptest.NewRecorder()
+ p := Problem{Title: "Not Found", Status: 404, Detail: "thing missing", Code: "NOT_FOUND"}
+ p.Write(rr)
+
+ if rr.Code != 404 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("content-type = %q", got)
+ }
+
+ var decoded Problem
+ if err := json.Unmarshal(rr.Body.Bytes(), &decoded); err != nil {
+ t.Fatalf("decode body: %v", err)
+ }
+ if decoded != p {
+ t.Errorf("decoded = %+v, want %+v", decoded, p)
+ }
+}
+
+func TestNotFoundErrorMessage(t *testing.T) {
+ if got := (NotFoundError{Resource: "thing"}).Error(); got != "thing not found" {
+ t.Errorf("Error() = %q", got)
+ }
+}
+
+func TestBadRequestErrorMessage(t *testing.T) {
+ if got := (BadRequestError{Detail: "nope"}).Error(); got != "nope" {
+ t.Errorf("Error() = %q", got)
+ }
+}
A => internal/pkg/httputil/response.go +36 -0
@@ 1,36 @@
+// Package httputil contains thin JSON-response and error-routing helpers.
+package httputil
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
+)
+
+// JSON writes a JSON response with the given status code.
+func JSON(w http.ResponseWriter, status int, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if data != nil {
+ _ = json.NewEncoder(w).Encode(data)
+ }
+}
+
+// OK is the common 200 path.
+func OK(w http.ResponseWriter, data any) { JSON(w, http.StatusOK, data) }
+
+// Error converts err into a Problem response. Internal (5xx) errors are
+// logged with the original error message; client errors are not, since
+// they're not actionable for the operator.
+func Error(w http.ResponseWriter, r *http.Request, err error) {
+ problem := apierror.FromError(err)
+ if problem.Status >= 500 {
+ //nolint:gosec // G706: slog handlers escape attribute values, so r.URL.Path cannot inject newlines into log output.
+ slog.Error("internal error", "path", r.URL.Path, scribe.Err(err))
+ }
+ problem.Write(w)
+}
A => internal/pkg/httputil/response_test.go +75 -0
@@ 1,75 @@
+package httputil
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
+)
+
+func TestJSONStatusAndBody(t *testing.T) {
+ rr := httptest.NewRecorder()
+ JSON(rr, 201, map[string]int{"n": 7})
+
+ if rr.Code != 201 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/json" {
+ t.Errorf("content-type = %q", got)
+ }
+ var body map[string]int
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["n"] != 7 {
+ t.Errorf("body = %+v", body)
+ }
+}
+
+func TestJSONNilDataWritesNoBody(t *testing.T) {
+ rr := httptest.NewRecorder()
+ JSON(rr, 204, nil)
+ if rr.Code != 204 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if rr.Body.Len() != 0 {
+ t.Errorf("body should be empty, got %q", rr.Body.String())
+ }
+}
+
+func TestOK(t *testing.T) {
+ rr := httptest.NewRecorder()
+ OK(rr, map[string]string{"hello": "world"})
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+}
+
+func TestErrorMapsClientError(t *testing.T) {
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/foo", http.NoBody)
+ Error(rr, req, apierror.NotFound("widget"))
+ if rr.Code != 404 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("content-type = %q", got)
+ }
+}
+
+func TestErrorMapsInternalError(t *testing.T) {
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/foo", http.NoBody)
+ Error(rr, req, errors.New("boom"))
+ if rr.Code != 500 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ // Internal detail should not leak in the body.
+ if got := rr.Body.String(); strings.Contains(got, "boom") {
+ t.Errorf("response body leaks internal error: %q", got)
+ }
+}
A => internal/platform/health/health.go +78 -0
@@ 1,78 @@
+// Package health implements simple liveness and readiness probes.
+//
+// huntsman has no real backing services, so readiness == liveness
+// today, but the Health struct keeps the door open for plugging in real
+// checkers later (e.g. an upstream API ping) without touching the router.
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// Checker reports the current health of one subsystem.
+type Checker interface {
+ Check(ctx context.Context) error
+}
+
+// CheckerFunc adapts an ordinary function to the Checker interface.
+type CheckerFunc func(ctx context.Context) error
+
+// Check satisfies the Checker interface.
+func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
+
+// Health aggregates named checkers and exposes them as HTTP handlers.
+type Health struct {
+ mu sync.RWMutex
+ checkers map[string]Checker
+}
+
+// New constructs an empty Health registry.
+func New() *Health {
+ return &Health{checkers: make(map[string]Checker)}
+}
+
+// Register adds (or replaces) a named checker.
+func (h *Health) Register(name string, c Checker) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.checkers[name] = c
+}
+
+// Healthz is a liveness probe — does the process exist and respond?
+func (h *Health) Healthz(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+}
+
+// Readyz runs all registered checkers with a 5s timeout. Any failure
+// flips the response to 503 and lists per-checker status in the body.
+func (h *Health) Readyz(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ results := make(map[string]string, len(h.checkers))
+ allOK := true
+ for name, checker := range h.checkers {
+ if err := checker.Check(ctx); err != nil {
+ results[name] = err.Error()
+ allOK = false
+ } else {
+ results[name] = "ok"
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if allOK {
+ w.WriteHeader(http.StatusOK)
+ } else {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }
+ _ = json.NewEncoder(w).Encode(results)
+}
A => internal/platform/health/health_test.go +96 -0
@@ 1,96 @@
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHealthzAlwaysOK(t *testing.T) {
+ h := New()
+ rr := httptest.NewRecorder()
+ h.Healthz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/healthz", http.NoBody))
+
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if rr.Body.String() != "ok" {
+ t.Errorf("body = %q", rr.Body.String())
+ }
+}
+
+func TestReadyzNoCheckersIsOK(t *testing.T) {
+ h := New()
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(body) != 0 {
+ t.Errorf("expected empty results, got %v", body)
+ }
+}
+
+func TestReadyzAllPass(t *testing.T) {
+ h := New()
+ h.Register("db", CheckerFunc(func(_ context.Context) error { return nil }))
+ h.Register("cache", CheckerFunc(func(_ context.Context) error { return nil }))
+
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["db"] != "ok" || body["cache"] != "ok" {
+ t.Errorf("body = %v", body)
+ }
+}
+
+func TestReadyzOneFails(t *testing.T) {
+ h := New()
+ h.Register("db", CheckerFunc(func(_ context.Context) error { return nil }))
+ h.Register("upstream", CheckerFunc(func(_ context.Context) error { return errors.New("dns fail") }))
+
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+
+ if rr.Code != 503 {
+ t.Errorf("status = %d, want 503", rr.Code)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["db"] != "ok" {
+ t.Errorf("db = %q", body["db"])
+ }
+ if body["upstream"] != "dns fail" {
+ t.Errorf("upstream = %q", body["upstream"])
+ }
+}
+
+func TestRegisterReplaces(t *testing.T) {
+ h := New()
+ h.Register("svc", CheckerFunc(func(_ context.Context) error { return errors.New("first") }))
+ h.Register("svc", CheckerFunc(func(_ context.Context) error { return nil }))
+
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+ if rr.Code != 200 {
+ t.Errorf("status = %d, want 200 after replace", rr.Code)
+ }
+}
A => internal/platform/observability/logging.go +44 -0
@@ 1,44 @@
+// Package observability wires up logging.
+package observability
+
+import (
+ "log/slog"
+ "os"
+ "strings"
+
+ "go.bigb.es/auxilia/scribe"
+)
+
+// NewLogger builds a slog.Logger backed by an auxilia/scribe handler.
+//
+// "human" produces a colorized TintHandler suitable for terminals; "json"
+// produces a structured handler suitable for log aggregators. Unknown
+// formats fall back to "human" so a typo doesn't break logging entirely.
+func NewLogger(level, format string) *slog.Logger {
+ lvl := parseLevel(level)
+ opts := []scribe.Option{
+ scribe.WithWriter(os.Stdout),
+ scribe.WithLevel(lvl),
+ }
+
+ var handler slog.Handler
+ if strings.EqualFold(format, "json") {
+ handler = scribe.NewJSONHandler(opts...)
+ } else {
+ handler = scribe.NewTintHandler(opts...)
+ }
+ return slog.New(handler)
+}
+
+func parseLevel(level string) slog.Level {
+ switch strings.ToLower(level) {
+ case "debug":
+ return slog.LevelDebug
+ case "warn":
+ return slog.LevelWarn
+ case "error":
+ return slog.LevelError
+ default:
+ return slog.LevelInfo
+ }
+}
A => internal/platform/observability/logging_test.go +57 -0
@@ 1,57 @@
+package observability
+
+import (
+ "log/slog"
+ "testing"
+)
+
+func TestNewLoggerLevels(t *testing.T) {
+ cases := []struct {
+ level string
+ want slog.Level
+ }{
+ {"debug", slog.LevelDebug},
+ {"DEBUG", slog.LevelDebug},
+ {"info", slog.LevelInfo},
+ {"warn", slog.LevelWarn},
+ {"error", slog.LevelError},
+ {"unknown", slog.LevelInfo}, // fallback
+ {"", slog.LevelInfo}, // fallback
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.level, func(t *testing.T) {
+ lg := NewLogger(tc.level, "human")
+ if lg == nil {
+ t.Fatal("logger is nil")
+ }
+ // Probe whether the chosen level is enabled.
+ if !lg.Enabled(t.Context(), tc.want) {
+ t.Errorf("level %v should be enabled", tc.want)
+ }
+ // And one level below should be filtered out.
+ if tc.want > slog.LevelDebug {
+ if lg.Enabled(t.Context(), tc.want-4) {
+ t.Errorf("level below %v should be filtered", tc.want)
+ }
+ }
+ })
+ }
+}
+
+func TestNewLoggerFormatFallback(t *testing.T) {
+ // Any unrecognized format should not panic and should still return a logger.
+ lg := NewLogger("info", "yaml")
+ if lg == nil {
+ t.Fatal("logger is nil")
+ }
+}
+
+func TestNewLoggerJSONFormat(t *testing.T) {
+ // Just exercise the JSON path; we can't easily inspect the handler type
+ // without adapter, but we ensure it constructs without panic.
+ lg := NewLogger("info", "json")
+ if lg == nil {
+ t.Fatal("logger is nil")
+ }
+}
A => internal/server/middleware/logging.go +46 -0
@@ 1,46 @@
+package middleware
+
+import (
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+type responseWriter struct {
+ http.ResponseWriter
+ status int
+ size int
+}
+
+func (rw *responseWriter) WriteHeader(status int) {
+ rw.status = status
+ rw.ResponseWriter.WriteHeader(status)
+}
+
+func (rw *responseWriter) Write(b []byte) (int, error) {
+ n, err := rw.ResponseWriter.Write(b)
+ rw.size += n
+ return n, err
+}
+
+// Logging emits a single structured log line per request once the handler
+// returns. The status code defaults to 200 if the handler never explicitly
+// calls WriteHeader.
+func Logging(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()
+ rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rw, r)
+
+ logger.Info("http request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", rw.status,
+ "size", rw.size,
+ "duration", time.Since(start),
+ "request_id", GetRequestID(r.Context()),
+ )
+ })
+ }
+}
A => internal/server/middleware/logging_test.go +62 -0
@@ 1,62 @@
+package middleware
+
+import (
+ "bytes"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestLoggingEmitsLineWithStatusAndPath(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(201)
+ _, _ = w.Write([]byte("hello"))
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "POST", "/widgets", http.NoBody))
+
+ logged := buf.String()
+ for _, want := range []string{"http request", "method=POST", "path=/widgets", "status=201", "size=5"} {
+ if !strings.Contains(logged, want) {
+ t.Errorf("log line missing %q\nfull line: %s", want, logged)
+ }
+ }
+}
+
+func TestLoggingDefaultsStatusTo200(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // No explicit WriteHeader: should default to 200 in our log line.
+ _, _ = w.Write([]byte("ok"))
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+
+ if !strings.Contains(buf.String(), "status=200") {
+ t.Errorf("expected status=200 in log, got %q", buf.String())
+ }
+}
+
+func TestLoggingIncludesRequestID(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := RequestID(Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })))
+
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
+ req.Header.Set("X-Request-ID", "abc-123")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if !strings.Contains(buf.String(), "request_id=abc-123") {
+ t.Errorf("expected request_id in log, got %q", buf.String())
+ }
+}
A => internal/server/middleware/recovery.go +35 -0
@@ 1,35 @@
+package middleware
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/scribe"
+)
+
+// Recovery catches panics from downstream handlers, logs them with a stack
+// trace via culpa, and returns a generic 500 so a single bad handler can't
+// crash the process.
+func Recovery(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 {
+ err, ok := rec.(error)
+ if !ok {
+ err = fmt.Errorf("%v", rec)
+ }
+ err = culpa.Wrap(err, "panic recovered")
+ logger.Error("panic recovered",
+ "path", r.URL.Path,
+ scribe.Err(err),
+ )
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+ }()
+ next.ServeHTTP(w, r)
+ })
+ }
+}
A => internal/server/middleware/recovery_test.go +57 -0
@@ 1,57 @@
+package middleware
+
+import (
+ "bytes"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestRecoveryCatchesPanic(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Recovery(logger)(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ panic("boom")
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/explode", http.NoBody))
+
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want 500", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "internal server error") {
+ t.Errorf("body = %q", rr.Body.String())
+ }
+ logged := buf.String()
+ if !strings.Contains(logged, "panic recovered") {
+ t.Errorf("expected log to mention 'panic recovered', got %q", logged)
+ }
+ if !strings.Contains(logged, "/explode") {
+ t.Errorf("expected log to include path, got %q", logged)
+ }
+}
+
+func TestRecoveryPassesThroughWhenNoPanic(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Recovery(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusTeapot)
+ _, _ = w.Write([]byte("hi"))
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+
+ if rr.Code != http.StatusTeapot {
+ t.Errorf("status = %d, want 418", rr.Code)
+ }
+ if rr.Body.String() != "hi" {
+ t.Errorf("body = %q", rr.Body.String())
+ }
+ if buf.Len() != 0 {
+ t.Errorf("nothing should have been logged, got %q", buf.String())
+ }
+}
A => internal/server/middleware/requestid.go +36 -0
@@ 1,36 @@
+// Package middleware contains chi-compatible HTTP middleware for the server.
+package middleware
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/google/uuid"
+)
+
+type ctxKey string
+
+// RequestIDKey is the context key under which the request ID is stored.
+const RequestIDKey ctxKey = "request_id"
+
+// RequestID propagates an X-Request-ID header through the request, generating
+// a UUID when the client didn't send one. The header is always echoed back.
+func RequestID(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id := r.Header.Get("X-Request-ID")
+ if id == "" {
+ id = uuid.NewString()
+ }
+ ctx := context.WithValue(r.Context(), RequestIDKey, id)
+ w.Header().Set("X-Request-ID", id)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// GetRequestID returns the request ID stored in ctx, or "" if absent.
+func GetRequestID(ctx context.Context) string {
+ if id, ok := ctx.Value(RequestIDKey).(string); ok {
+ return id
+ }
+ return ""
+}
A => internal/server/middleware/requestid_test.go +64 -0
@@ 1,64 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRequestIDGeneratesWhenAbsent(t *testing.T) {
+ var seenInHandler string
+ h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ seenInHandler = GetRequestID(r.Context())
+ }))
+
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ got := rr.Header().Get("X-Request-ID")
+ if got == "" {
+ t.Fatal("X-Request-ID header should be set")
+ }
+ if got != seenInHandler {
+ t.Errorf("response header %q != context value %q", got, seenInHandler)
+ }
+ // Generated UUID should be 36 chars (8-4-4-4-12).
+ if len(got) != 36 {
+ t.Errorf("generated id length = %d, want 36", len(got))
+ }
+}
+
+func TestRequestIDPreservesIncoming(t *testing.T) {
+ const incoming = "trace-abc-123"
+ var seen string
+ h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ seen = GetRequestID(r.Context())
+ }))
+
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
+ req.Header.Set("X-Request-ID", incoming)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if seen != incoming {
+ t.Errorf("context id = %q, want %q", seen, incoming)
+ }
+ if got := rr.Header().Get("X-Request-ID"); got != incoming {
+ t.Errorf("response id = %q, want %q", got, incoming)
+ }
+}
+
+func TestGetRequestIDEmptyContext(t *testing.T) {
+ if got := GetRequestID(context.Background()); got != "" {
+ t.Errorf("expected empty string, got %q", got)
+ }
+}
+
+func TestGetRequestIDWrongType(t *testing.T) {
+ ctx := context.WithValue(context.Background(), RequestIDKey, 42) // not a string
+ if got := GetRequestID(ctx); got != "" {
+ t.Errorf("expected empty string, got %q", got)
+ }
+}
A => internal/server/router.go +65 -0
@@ 1,65 @@
+package server
+
+import (
+ "log/slog"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/domain/search"
+ "sourcecraft.dev/bigbes/huntsman/internal/platform/health"
+ "sourcecraft.dev/bigbes/huntsman/internal/server/middleware"
+)
+
+// Routes wires every HTTP route the service exposes.
+//
+// Keeping route registration in one file makes it easy to audit the
+// public surface area without grepping every handler package.
+func Routes(logger *slog.Logger, h *health.Health, searchHandler *search.Handler) http.Handler {
+ r := chi.NewRouter()
+
+ r.Use(middleware.RequestID)
+ r.Use(middleware.Recovery(logger))
+ r.Use(middleware.Logging(logger))
+
+ r.Get("/healthz", h.Healthz)
+ r.Get("/readyz", h.Readyz)
+
+ r.Get("/", indexHandler)
+
+ searchHandler.RegisterRoutes(r)
+
+ return r
+}
+
+// indexHandler advertises the unified OpenSearch document via a
+// <link rel="search"> tag, so browsers offer to add the engine when
+// the user visits the root URL.
+func indexHandler(w http.ResponseWriter, _ *http.Request) {
+ const body = `<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <title>huntsman</title>
+ <link rel="search"
+ type="application/opensearchdescription+xml"
+ title="huntsman"
+ href="/opensearch.xml">
+</head>
+<body>
+ <h1>huntsman</h1>
+ <p>Multi-provider search router. Use prefixes:</p>
+ <ul>
+ <li><code>ud <query></code> — Urban Dictionary</li>
+ <li><code>gh <query></code> — GitHub</li>
+ <li><code>steam <query></code> — Steam</li>
+ </ul>
+ <p>Endpoints: <code>/search?q=...</code>, <code>/providers</code>,
+ <code>/opensearch.xml</code>,
+ <code>/opensearch/{provider}.xml</code>.</p>
+</body>
+</html>
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(body))
+}
A => internal/server/router_test.go +87 -0
@@ 1,87 @@
+package server
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/domain/search"
+ "sourcecraft.dev/bigbes/huntsman/internal/platform/health"
+)
+
+func newTestRouter(t *testing.T) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ svc, err := search.NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ return Routes(logger, health.New(), search.NewHandler(svc))
+}
+
+func TestIndexHandler(t *testing.T) {
+ h := newTestRouter(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
+ t.Errorf("content-type = %q", got)
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, `<link rel="search"`) {
+ t.Errorf("body missing OSD link tag: %q", body)
+ }
+ if !strings.Contains(body, "huntsman") {
+ t.Errorf("body missing service name")
+ }
+}
+
+func TestRoutesMountAllExpectedPaths(t *testing.T) {
+ h := newTestRouter(t)
+
+ cases := []struct {
+ path string
+ wantStatus int
+ }{
+ {"/healthz", 200},
+ {"/readyz", 200},
+ {"/", 200},
+ {"/providers", 200},
+ {"/opensearch.xml", 200},
+ {"/opensearch/gh.xml", 200},
+ {"/search?q=foo", 302},
+ }
+ for _, tc := range cases {
+ t.Run(tc.path, func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", tc.path, http.NoBody))
+ if rr.Code != tc.wantStatus {
+ t.Errorf("%s: status = %d, want %d", tc.path, rr.Code, tc.wantStatus)
+ }
+ })
+ }
+}
+
+func TestRoutesAttachRequestIDMiddleware(t *testing.T) {
+ h := newTestRouter(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+ if rr.Header().Get("X-Request-ID") == "" {
+ t.Error("expected X-Request-ID header from middleware")
+ }
+}
+
+func TestRoutesUnknownPath404(t *testing.T) {
+ h := newTestRouter(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/no-such-thing", http.NoBody))
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", rr.Code)
+ }
+}