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.
111 lines
3.4 KiB
Go
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,
|
|
},
|
|
})
|
|
}
|