Files
job-search/internal/api/opportunities.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

268 lines
8.5 KiB
Go

package api
import (
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"git.avsdev.uk/Go/job-search/internal/httpx"
"git.avsdev.uk/Go/job-search/internal/store"
"git.avsdev.uk/Go/job-search/internal/types"
)
// markIndex maps the name of each mark that can be set and cleared, as named
// in URLs, to the index of its field in types.OpportunityMarks. markNames
// lists the names in field order. Both are read from the struct (see
// findMarks), so a mark added to it can be changed over the API.
var markIndex, markNames = findMarks()
// OpportunityPage is a page of opportunities, as GET /opportunities returns
// it. HasMore reports that another page follows, at Offset+Limit.
type OpportunityPage struct {
Opportunities []types.Opportunity `json:"opportunities"`
Offset int `json:"offset"`
Limit int `json:"limit"`
HasMore bool `json:"has_more"`
}
// opportunityRoutes wires HTTP handlers to the opportunity store.
// Opportunities are read-only; only their marks can be changed.
type opportunityRoutes struct {
store *store.Store
// marksMu makes changing one mark (read the marks, change one, write
// them all back) atomic, so concurrent changes to different marks of an
// opportunity don't undo each other.
marksMu sync.Mutex
}
// newOpportunityRoutes returns the opportunity handlers for
// opportunityStore.
func newOpportunityRoutes(opportunityStore *store.Store) *opportunityRoutes {
return &opportunityRoutes{
store: opportunityStore,
}
}
// Routes registers the opportunity routes on router (mounted under /api).
func (h *opportunityRoutes) Routes(router chi.Router) {
router.Get("/opportunities", h.list)
router.Get("/opportunities/{uuid}", h.get)
router.Put("/opportunities/{uuid}/marks/{mark}", h.setMark)
router.Delete("/opportunities/{uuid}/marks/{mark}", h.clearMark)
}
// list serves GET /opportunities: a page of opportunities, newest release
// first, with their classifications and marks but not their releases. The
// page is set by the offset and limit (default 50, at most 100) query
// parameters.
//
// @Summary List opportunities
// @Description A page of opportunities, newest release first, with their
// @Description classifications and marks but not their releases. has_more
// @Description says whether another page follows, at offset+limit.
// @Tags opportunities
// @Produce json
// @Param offset query int false "Opportunities to skip" minimum(0) default(0)
// @Param limit query int false "Page size" minimum(1) maximum(100) default(50)
// @Success 200 {object} OpportunityPage
// @Failure 400 {object} httpx.Problem "Invalid offset or limit"
// @Failure 500 {object} httpx.Problem
// @Router /opportunities [get]
func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
offset, offsetErr := intParam(query.Get("offset"), 0, 0, 1<<30)
limit, limitErr := intParam(query.Get("limit"), 50, 1, 100)
err := errors.Join(offsetErr, limitErr)
if err != nil {
httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error())
return
}
// One more than the page, to tell whether another page follows.
opportunities, err := h.store.ListOpportunities(
r.Context(),
store.OpportunityFilter{
Limit: limit + 1,
Offset: offset,
},
)
if err != nil {
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
return
}
page := OpportunityPage{
Opportunities: opportunities,
Offset: offset,
Limit: limit,
HasMore: len(opportunities) > limit,
}
if page.HasMore {
page.Opportunities = opportunities[:limit]
}
httpx.WriteJSON(w, http.StatusOK, page)
}
// get serves GET /opportunities/{uuid}: one opportunity with its
// classifications, marks and releases.
//
// @Summary Get an opportunity
// @Description One opportunity with its classifications, marks and all of its
// @Description releases.
// @Tags opportunities
// @Produce json
// @Param uuid path string true "The opportunity's UUID (its OCID)"
// @Success 200 {object} types.Opportunity
// @Failure 404 {object} httpx.Problem "No such opportunity"
// @Failure 500 {object} httpx.Problem
// @Router /opportunities/{uuid} [get]
func (h *opportunityRoutes) get(w http.ResponseWriter, r *http.Request) {
opportunity, err := h.store.GetOpportunity(
r.Context(), chi.URLParam(r, "uuid"),
)
if err != nil {
h.fail(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, opportunity)
}
// setMark serves PUT /opportunities/{uuid}/marks/{mark}, setting the mark.
//
// @Summary Set a mark
// @Description Sets one of the opportunity's marks, keeping the others, and
// @Description returns all of them. Setting a mark that is already set
// @Description changes nothing. won and lost exclude each other.
// @Tags marks
// @Produce json
// @Security CSRF
// @Param uuid path string true "The opportunity's UUID (its OCID)"
// @Param mark path string true "The mark" Enums(hidden, skipped, interested, bid_submitted, won, lost)
// @Success 200 {object} types.OpportunityMarks
// @Failure 403 {object} httpx.Problem "No valid CSRF token"
// @Failure 404 {object} httpx.Problem "No such opportunity or mark"
// @Failure 409 {object} httpx.Problem "won and lost together"
// @Failure 500 {object} httpx.Problem
// @Router /opportunities/{uuid}/marks/{mark} [put]
func (h *opportunityRoutes) setMark(w http.ResponseWriter, r *http.Request) {
h.changeMark(w, r, true)
}
// clearMark serves DELETE /opportunities/{uuid}/marks/{mark}, clearing the
// mark.
//
// @Summary Clear a mark
// @Description Clears one of the opportunity's marks, keeping the others, and
// @Description returns all of them. Clearing a mark that isn't set changes
// @Description nothing.
// @Tags marks
// @Produce json
// @Security CSRF
// @Param uuid path string true "The opportunity's UUID (its OCID)"
// @Param mark path string true "The mark" Enums(hidden, skipped, interested, bid_submitted, won, lost)
// @Success 200 {object} types.OpportunityMarks
// @Failure 403 {object} httpx.Problem "No valid CSRF token"
// @Failure 404 {object} httpx.Problem "No such opportunity or mark"
// @Failure 500 {object} httpx.Problem
// @Router /opportunities/{uuid}/marks/{mark} [delete]
func (h *opportunityRoutes) clearMark(w http.ResponseWriter, r *http.Request) {
h.changeMark(w, r, false)
}
// changeMark sets the mark named in the URL to value, keeping the
// opportunity's other marks, and responds with all of its marks. Setting won
// while lost is set, or lost while won is, is refused with 409 Conflict.
func (h *opportunityRoutes) changeMark(
w http.ResponseWriter, r *http.Request, value bool,
) {
uuid, name := chi.URLParam(r, "uuid"), chi.URLParam(r, "mark")
h.marksMu.Lock()
defer h.marksMu.Unlock()
opportunity, err := h.store.GetOpportunity(r.Context(), uuid)
if err != nil {
h.fail(w, r, err)
return
}
marks := opportunity.Marks
marks.UUID = uuid
if !setMark(&marks, name, value) {
httpx.WriteProblem(
w,
r,
http.StatusNotFound,
fmt.Sprintf("no mark %q: marks are %v", name, markNames),
)
return
}
err = h.store.SetMarks(r.Context(), marks)
if err != nil {
h.fail(w, r, err)
return
}
httpx.WriteJSON(w, http.StatusOK, marks)
}
// findMarks returns the marks of types.OpportunityMarks: every bool field,
// named by its JSON tag, mapped to the field's index, and the names in field
// order.
func findMarks() (map[string]int, []string) {
index := map[string]int{}
var names []string
marksType := reflect.TypeFor[types.OpportunityMarks]()
for i := range marksType.NumField() {
field := marksType.Field(i)
if field.Type.Kind() != reflect.Bool {
continue
}
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
index[name] = i
names = append(names, name)
}
return index, names
}
// setMark sets the mark of marks named name (see markIndex) to value, and
// reports whether name is a mark.
func setMark(marks *types.OpportunityMarks, name string, value bool) bool {
i, ok := markIndex[name]
if !ok {
return false
}
reflect.ValueOf(marks).Elem().Field(i).SetBool(value)
return true
}
// fail writes the problem response for a store error.
func (h *opportunityRoutes) fail(
w http.ResponseWriter, r *http.Request, err error,
) {
switch {
case errors.Is(err, store.ErrNotFound):
httpx.WriteProblem(w, r, http.StatusNotFound, "opportunity not found")
case errors.Is(err, types.ErrWonAndLost):
httpx.WriteProblem(
w,
r,
http.StatusConflict,
"an opportunity cannot be both won and lost",
)
default:
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
}
}