From 783841b91eafd678cb3895cfcc8dfd89f290ece7 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 21 May 2026 10:27:51 +0300 Subject: [PATCH] Initial commit: multi-provider search router --- .gitignore | 9 + .golangci.yml | 45 ++++ Dockerfile | 18 ++ Justfile | 40 ++++ README.md | 73 ++++++ cmd/server/main.go | 52 ++++ config.example.yaml | 17 ++ docker-compose.yml | 10 + go.mod | 27 +++ go.sum | 57 +++++ internal/app/app.go | 80 +++++++ internal/app/wires.go | 121 ++++++++++ internal/config/config.go | 89 +++++++ internal/config/loader.go | 47 ++++ internal/config/loader_test.go | 226 ++++++++++++++++++ internal/domain/search/handler.go | 116 +++++++++ internal/domain/search/handler_test.go | 171 +++++++++++++ internal/domain/search/opensearch.go | 96 ++++++++ internal/domain/search/opensearch_test.go | 72 ++++++ internal/domain/search/providers.go | 135 +++++++++++ internal/domain/search/providers_test.go | 87 +++++++ internal/domain/search/service.go | 59 +++++ internal/domain/search/service_test.go | 56 +++++ internal/pkg/apierror/error.go | 97 ++++++++ internal/pkg/apierror/error_test.go | 105 ++++++++ internal/pkg/httputil/response.go | 36 +++ internal/pkg/httputil/response_test.go | 75 ++++++ internal/platform/health/health.go | 78 ++++++ internal/platform/health/health_test.go | 96 ++++++++ internal/platform/observability/logging.go | 44 ++++ .../platform/observability/logging_test.go | 57 +++++ internal/server/middleware/logging.go | 46 ++++ internal/server/middleware/logging_test.go | 62 +++++ internal/server/middleware/recovery.go | 35 +++ internal/server/middleware/recovery_test.go | 57 +++++ internal/server/middleware/requestid.go | 36 +++ internal/server/middleware/requestid_test.go | 64 +++++ internal/server/router.go | 65 +++++ internal/server/router_test.go | 87 +++++++ 39 files changed, 2743 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Dockerfile create mode 100644 Justfile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 config.example.yaml create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/app/wires.go create mode 100644 internal/config/config.go create mode 100644 internal/config/loader.go create mode 100644 internal/config/loader_test.go create mode 100644 internal/domain/search/handler.go create mode 100644 internal/domain/search/handler_test.go create mode 100644 internal/domain/search/opensearch.go create mode 100644 internal/domain/search/opensearch_test.go create mode 100644 internal/domain/search/providers.go create mode 100644 internal/domain/search/providers_test.go create mode 100644 internal/domain/search/service.go create mode 100644 internal/domain/search/service_test.go create mode 100644 internal/pkg/apierror/error.go create mode 100644 internal/pkg/apierror/error_test.go create mode 100644 internal/pkg/httputil/response.go create mode 100644 internal/pkg/httputil/response_test.go create mode 100644 internal/platform/health/health.go create mode 100644 internal/platform/health/health_test.go create mode 100644 internal/platform/observability/logging.go create mode 100644 internal/platform/observability/logging_test.go create mode 100644 internal/server/middleware/logging.go create mode 100644 internal/server/middleware/logging_test.go create mode 100644 internal/server/middleware/recovery.go create mode 100644 internal/server/middleware/recovery_test.go create mode 100644 internal/server/middleware/requestid.go create mode 100644 internal/server/middleware/requestid_test.go create mode 100644 internal/server/router.go create mode 100644 internal/server/router_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e34bb3f415362510701d192dbbb8c2d4dd44cc76 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build output +/huntsman + +# Local config +/config.yaml + +# Editor / OS +*.swp +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000000000000000000000000000000000000..892e4e6ccf51fccc695f7054ea2ed3933fd00010 --- /dev/null +++ b/.golangci.yml @@ -0,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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..bea6be531fd11563fbecfbffca6cf8cc5cacce74 --- /dev/null +++ b/Dockerfile @@ -0,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"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000000000000000000000000000000000000..b82df4407bd223dbc3c978be7d090704edc788f4 --- /dev/null +++ b/Justfile @@ -0,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 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5f39916de46b6a5f0cfe94b76d5beeb9fa134b47 --- /dev/null +++ b/README.md @@ -0,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 `` 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 `` | +| 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 +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000000000000000000000000000000000000..b47cd3a58b9c9e7b7aa3823e0a46877ee9fd0e75 --- /dev/null +++ b/cmd/server/main.go @@ -0,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 +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1c08b3178d5d25770727c622ae29b3541b564650 --- /dev/null +++ b/config.example.yaml @@ -0,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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..994f0cc17c82d1c00aaba3d49ca8fa285c09dc1c --- /dev/null +++ b/docker-compose.yml @@ -0,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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..7faabeed7c163c098b0737c7002845ae59c74860 --- /dev/null +++ b/go.mod @@ -0,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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..8c6b5fa0b125b953b1d04e0dfc4ebd054d16d787 --- /dev/null +++ b/go.sum @@ -0,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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000000000000000000000000000000000000..289ab0a779edd8c4093fb1d6636285e5f898aff4 --- /dev/null +++ b/internal/app/app.go @@ -0,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 +} diff --git a/internal/app/wires.go b/internal/app/wires.go new file mode 100644 index 0000000000000000000000000000000000000000..85e58d93fd8b3eeecb6e6918f01287963a114c43 --- /dev/null +++ b/internal/app/wires.go @@ -0,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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..4f704e607655ce4fc5e8150f71d9422a58353d06 --- /dev/null +++ b/internal/config/config.go @@ -0,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 +} diff --git a/internal/config/loader.go b/internal/config/loader.go new file mode 100644 index 0000000000000000000000000000000000000000..6a502108b92bccdae3d93b026f8a1d4ad50860cc --- /dev/null +++ b/internal/config/loader.go @@ -0,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 +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000000000000000000000000000000000000..89d5b75c939dca739a46641326042c649d960101 --- /dev/null +++ b/internal/config/loader_test.go @@ -0,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") + } +} diff --git a/internal/domain/search/handler.go b/internal/domain/search/handler.go new file mode 100644 index 0000000000000000000000000000000000000000..8807d06e100d91d10b71e720cd767d0d70f72379 --- /dev/null +++ b/internal/domain/search/handler.go @@ -0,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 . +func writeOSD(w http.ResponseWriter, body []byte) { + w.Header().Set("Content-Type", "application/opensearchdescription+xml") + _, _ = w.Write(body) +} diff --git a/internal/domain/search/handler_test.go b/internal/domain/search/handler_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b1009a8864a7a14ac659cc00f595a1721c42d36b --- /dev/null +++ b/internal/domain/search/handler_test.go @@ -0,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, " 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 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 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 +} diff --git a/internal/domain/search/opensearch_test.go b/internal/domain/search/opensearch_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3014fc8d361d0b35f6aa7c446b1524cb961d86f0 --- /dev/null +++ b/internal/domain/search/opensearch_test.go @@ -0,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), " 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 +} diff --git a/internal/domain/search/providers_test.go b/internal/domain/search/providers_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7f224f22e41d684527213349758129d72fb5847d --- /dev/null +++ b/internal/domain/search/providers_test.go @@ -0,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") + } +} diff --git a/internal/domain/search/service.go b/internal/domain/search/service.go new file mode 100644 index 0000000000000000000000000000000000000000..1fc6cac60e66de74ed8de6b2b4f87d2976d24d6f --- /dev/null +++ b/internal/domain/search/service.go @@ -0,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 +} diff --git a/internal/domain/search/service_test.go b/internal/domain/search/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d39555840ceeb6155a8c9e3de474b1fd57ea621e --- /dev/null +++ b/internal/domain/search/service_test.go @@ -0,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) + } +} diff --git a/internal/pkg/apierror/error.go b/internal/pkg/apierror/error.go new file mode 100644 index 0000000000000000000000000000000000000000..f8f3156fc28bc0556e6cd0e1a87c094bd2eef58f --- /dev/null +++ b/internal/pkg/apierror/error.go @@ -0,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} } diff --git a/internal/pkg/apierror/error_test.go b/internal/pkg/apierror/error_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bc74cd4d257a5a16e82ce1c3808648bd50489756 --- /dev/null +++ b/internal/pkg/apierror/error_test.go @@ -0,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) + } +} diff --git a/internal/pkg/httputil/response.go b/internal/pkg/httputil/response.go new file mode 100644 index 0000000000000000000000000000000000000000..2bc72a5745ab41eda15714d387e0e9bd7ea865fd --- /dev/null +++ b/internal/pkg/httputil/response.go @@ -0,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) +} diff --git a/internal/pkg/httputil/response_test.go b/internal/pkg/httputil/response_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3ce51da6215069e489aecc2431b15663c4cf5d40 --- /dev/null +++ b/internal/pkg/httputil/response_test.go @@ -0,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) + } +} diff --git a/internal/platform/health/health.go b/internal/platform/health/health.go new file mode 100644 index 0000000000000000000000000000000000000000..3606d4763cc3abf75089a8ed906ebf84aab67691 --- /dev/null +++ b/internal/platform/health/health.go @@ -0,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) +} diff --git a/internal/platform/health/health_test.go b/internal/platform/health/health_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e6317cc48e0e8d97a8efc78a41f111b0bd095df0 --- /dev/null +++ b/internal/platform/health/health_test.go @@ -0,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) + } +} diff --git a/internal/platform/observability/logging.go b/internal/platform/observability/logging.go new file mode 100644 index 0000000000000000000000000000000000000000..d67951b2df625e660dfb2ef1fc79b6863dbe66a8 --- /dev/null +++ b/internal/platform/observability/logging.go @@ -0,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 + } +} diff --git a/internal/platform/observability/logging_test.go b/internal/platform/observability/logging_test.go new file mode 100644 index 0000000000000000000000000000000000000000..934b682af515043dae5347c9fd6af5300bfdeb71 --- /dev/null +++ b/internal/platform/observability/logging_test.go @@ -0,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") + } +} diff --git a/internal/server/middleware/logging.go b/internal/server/middleware/logging.go new file mode 100644 index 0000000000000000000000000000000000000000..c629790982fed33d4398fe8d6d935b806ff3dcaf --- /dev/null +++ b/internal/server/middleware/logging.go @@ -0,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()), + ) + }) + } +} diff --git a/internal/server/middleware/logging_test.go b/internal/server/middleware/logging_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9d06dc9137ce5a8bc736f0ee70390cf2651c1183 --- /dev/null +++ b/internal/server/middleware/logging_test.go @@ -0,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()) + } +} diff --git a/internal/server/middleware/recovery.go b/internal/server/middleware/recovery.go new file mode 100644 index 0000000000000000000000000000000000000000..cbefabdce6df3fd6685d3278ca93e6abe90f168d --- /dev/null +++ b/internal/server/middleware/recovery.go @@ -0,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) + }) + } +} diff --git a/internal/server/middleware/recovery_test.go b/internal/server/middleware/recovery_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b140cef67537369a89f2cc5005188cd679d610ec --- /dev/null +++ b/internal/server/middleware/recovery_test.go @@ -0,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()) + } +} diff --git a/internal/server/middleware/requestid.go b/internal/server/middleware/requestid.go new file mode 100644 index 0000000000000000000000000000000000000000..ed392d56534db51e9f9c7bbaa588765e59ba1524 --- /dev/null +++ b/internal/server/middleware/requestid.go @@ -0,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 "" +} diff --git a/internal/server/middleware/requestid_test.go b/internal/server/middleware/requestid_test.go new file mode 100644 index 0000000000000000000000000000000000000000..60f0a9ef770ddd219b8d64f1ce1518f8e0622a23 --- /dev/null +++ b/internal/server/middleware/requestid_test.go @@ -0,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) + } +} diff --git a/internal/server/router.go b/internal/server/router.go new file mode 100644 index 0000000000000000000000000000000000000000..7637b12e28863ccfb536c2782b1d31c49538b80c --- /dev/null +++ b/internal/server/router.go @@ -0,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 +// tag, so browsers offer to add the engine when +// the user visits the root URL. +func indexHandler(w http.ResponseWriter, _ *http.Request) { + const body = ` + + + + huntsman + + + +

huntsman

+

Multi-provider search router. Use prefixes:

+ +

Endpoints: /search?q=..., /providers, + /opensearch.xml, + /opensearch/{provider}.xml.

+ + +` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(body)) +} diff --git a/internal/server/router_test.go b/internal/server/router_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6b56c51dc297929ae2f26094006bde1f8321dd43 --- /dev/null +++ b/internal/server/router_test.go @@ -0,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, `