289 lines
9.2 KiB
Go
289 lines
9.2 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/events"
|
|
"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
|
|
events *events.Bus // nil: nothing is published
|
|
|
|
// 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, publishing mark changes to bus if it isn't nil.
|
|
func newOpportunityRoutes(
|
|
opportunityStore *store.Store, bus *events.Bus,
|
|
) *opportunityRoutes {
|
|
return &opportunityRoutes{
|
|
store: opportunityStore,
|
|
events: bus,
|
|
}
|
|
}
|
|
|
|
// 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. A
|
|
// change is published as an events.OpportunityMarksUpdated event; one that
|
|
// changes nothing is neither stored nor published.
|
|
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
|
|
before := marks
|
|
if !setMark(&marks, name, value) {
|
|
httpx.WriteProblem(
|
|
w,
|
|
r,
|
|
http.StatusNotFound,
|
|
fmt.Sprintf("no mark %q: marks are %v", name, markNames),
|
|
)
|
|
return
|
|
}
|
|
if marks == before {
|
|
httpx.WriteJSON(w, http.StatusOK, marks)
|
|
return
|
|
}
|
|
|
|
err = h.store.SetMarks(r.Context(), marks)
|
|
if err != nil {
|
|
h.fail(w, r, err)
|
|
return
|
|
}
|
|
|
|
httpx.WriteJSON(w, http.StatusOK, marks)
|
|
|
|
// Still under marksMu, so events are published in the order the
|
|
// changes were made.
|
|
if h.events != nil {
|
|
h.events.Publish(events.Event{
|
|
Type: events.OpportunityMarksUpdated,
|
|
Payload: 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, "")
|
|
}
|
|
}
|