Files
job-search/internal/server/routes.go
T
bwilliams 70d62f280e Added the opportunities REST API, read-only apart from their marks, described in OpenAPI 3.1 with swag
GET /api/opportunities lists the stored opportunities, newest release first,
a page at a time (offset, and limit up to 100, default 50); has_more says
whether another page follows. GET /api/opportunities/{uuid} returns one with
its releases. PUT and DELETE /api/opportunities/{uuid}/marks/{mark} set and
clear one mark (hidden, skipped, interested, bid_submitted, won, lost) and
return them all; won and lost exclude each other (409). The API uses the
types in internal/types, and changing a mark is made atomic, as the store
replaces all of an opportunity's marks at once. The marks are found by
reflection: every bool field of types.OpportunityMarks, by its JSON name, so
a new mark needs no API change.

The API has one entry point, api.New, with its dependencies as options
(api.WithStore), and the server only mounts it under /api, keeping the
middleware. New resources are added inside internal/api. job-search serve
passes server.WithStore when database.file is set, opening the store before
the server; without one there are no opportunity routes.

The API is described by swag v2 annotations on the handlers. go generate
writes the OpenAPI 3.1 description (git-ignored, like the OCDS types), which
the server embeds and serves at /api/openapi.json, with Swagger UI at
/api/docs. A test checks the description and the routes match. swag is in
the README's dev tools, pinned to v2.0.0-rc6, and the build now needs it.
2026-10-16 13:13:44 +01:00

111 lines
3.4 KiB
Go

package server
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"git.avsdev.uk/Go/job-search/internal/config"
"git.avsdev.uk/Go/job-search/internal/httpx"
"git.avsdev.uk/Go/job-search/internal/middleware"
)
// routes builds the router. Middleware order (outermost first):
//
// RealIP -> RequestID -> Logger -> Recoverer -> [global rate limit + hooks]
// -> RequestChecks (host, body cap) -> DefaultHeaders
// /api : NoStore -> Compress -> [api rate limit + hooks] -> CSRF
// -> RequireJSON
// /ws : [ws rate limit + hooks] -> Origin check (hub)
func (s *Server) routes() (http.Handler, error) {
conf := s.conf
router := chi.NewRouter()
router.Use(
middleware.RealIP(middleware.RealIPOptions{
Trusted: s.trusted,
Headers: conf.Proxy.ClientIPHeaders,
StripUntrusted: conf.Proxy.StripUntrusted,
}),
middleware.RequestID,
middleware.Logger(middleware.WithLogger(s.untaggedLogger())),
middleware.Recoverer(middleware.WithLogger(s.untaggedLogger())),
)
router.Use(s.groupStack(GroupGlobal, conf.RateLimits.Global)...)
router.Use(
middleware.RequestChecks(*conf),
middleware.DefaultHeaders(conf.Headers),
)
compress, err := middleware.Compress()
if err != nil {
return nil, err
}
// API
router.Route("/api", func(apiRouter chi.Router) {
apiRouter.Use(middleware.NoStore, compress)
apiRouter.Use(s.groupStack(GroupAPI, conf.RateLimits.API)...)
apiRouter.Use(s.csrf.Protect, middleware.RequireJSON)
apiRouter.NotFound(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteProblem(w, r, http.StatusNotFound, "no such endpoint")
})
apiRouter.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteProblem(w, r, http.StatusMethodNotAllowed, "")
})
apiRouter.Get("/health", s.health)
if s.csrf.TokenEnabled() {
apiRouter.Get("/csrf", s.csrf.IssueHandler)
}
s.api.Routes(apiRouter)
})
// WebSocket (no compression middleware: permessage-deflate is separate)
webSocketStack := s.groupStack(GroupWebSocket, conf.WebSocket.ConnectionLimit)
router.With(webSocketStack...).Get("/ws", s.hub.ServeHTTP)
return router, nil
}
// groupStack returns the rate-limit hook (configured or overridden) followed
// by any user-supplied hook middleware for the group.
func (s *Server) groupStack(
group Group, rule config.JSCRateLimitRule,
) []Middleware {
var stack []Middleware
if hook, ok := s.limiters[group]; ok && hook.set {
if hook.limiter != nil {
stack = append(stack, middleware.RateLimit(hook.limiter, hook.key))
}
} else if rule.Enabled && rule.PerSecond > 0 {
limiter := middleware.NewKeyedLimiter(
s.backgroundCtx,
float64(rule.PerSecond),
max(rule.Burst, 1),
s.conf.RateLimits.IdleTTL,
)
stack = append(stack, middleware.RateLimit(limiter, middleware.KeyByIP))
}
return append(stack, s.hooks[group]...)
}
// health serves GET /api/health: the server's status and uptime, the number
// of WebSocket clients, and the client as the server sees it (after proxy
// processing).
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
client := middleware.Client(r)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"uptime": time.Since(s.started).Round(time.Second).String(),
"wsClients": s.hub.Count(),
"client": map[string]any{
"ip": client.IP.String(),
"scheme": client.Scheme,
"viaProxy": client.ViaProxy,
},
})
}