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.
268 lines
8.5 KiB
Go
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, "")
|
|
}
|
|
}
|