package api import ( "errors" "fmt" "net/http" "reflect" "strings" "sync" "time" "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. // // Batch is the hash of the newest change batch when the page was read, or // "" if none has been recorded: given as changed_after, it gets the // opportunities changed since. A page of changed opportunities is all of // them, so Limit is their number and HasMore is false. type OpportunityPage struct { Opportunities []types.Opportunity `json:"opportunities"` Offset int `json:"offset"` Limit int `json:"limit"` HasMore bool `json:"has_more"` Batch string `json:"batch"` } // 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, and released_from leaves out opportunities whose latest release // is before it. With changed_after, it serves the opportunities changed since // that batch instead (see listChanged). // // @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. batch // @Description is the newest change batch when the page was read. // @Description With changed_after (a batch from a page or an // @Description OpportunitiesUpdated event), the page is instead every // @Description opportunity changed since that batch (new, given new // @Description releases or with changed marks), and batch is the newest // @Description batch they include. offset and limit can't be given with // @Description changed_after. An unknown batch is 410 Gone: reload the // @Description whole list. released_from (RFC 3339, inclusive) leaves out // @Description opportunities whose latest release is before it, in a page // @Description or with changed_after. // @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) // @Param changed_after query string false "List the opportunities changed after this batch instead" // @Param released_from query string false "Only opportunities whose latest release is at or after this time" format(date-time) // @Success 200 {object} OpportunityPage // @Failure 400 {object} httpx.Problem "Invalid offset, limit or released_from, or offset or limit with changed_after" // @Failure 410 {object} httpx.Problem "Unknown batch" // @Failure 500 {object} httpx.Problem // @Router /opportunities [get] func (h *opportunityRoutes) list(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() releasedFrom, err := timeParam( "released_from", query.Get("released_from"), ) if err != nil { httpx.WriteProblem(w, r, http.StatusBadRequest, err.Error()) return } if query.Has("changed_after") { h.listChanged(w, r, releasedFrom) return } 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 } // Read first, so nothing changed while the page is read is missed by // asking for the changes after it. batch, err := h.store.LatestChangeBatch(r.Context()) if err != nil { httpx.WriteProblem(w, r, http.StatusInternalServerError, "") 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, ReleasedFrom: releasedFrom, }, ) if err != nil { httpx.WriteProblem(w, r, http.StatusInternalServerError, "") return } page := OpportunityPage{ Opportunities: opportunities, Offset: offset, Limit: limit, HasMore: len(opportunities) > limit, Batch: batch, } if page.HasMore { page.Opportunities = opportunities[:limit] } httpx.WriteJSON(w, http.StatusOK, page) } // listChanged serves GET /opportunities?changed_after=batch: every // opportunity changed after the change batch, as one page, with the newest // batch it includes, leaving out those whose latest release is before // releasedFrom unless it is zero. An unknown batch, such as one from another // database, is 410 Gone, as the client can't catch up from it. func (h *opportunityRoutes) listChanged( w http.ResponseWriter, r *http.Request, releasedFrom time.Time, ) { query := r.URL.Query() if query.Has("offset") || query.Has("limit") { httpx.WriteProblem( w, r, http.StatusBadRequest, "offset and limit can't be used with changed_after", ) return } opportunities, batch, err := h.store.ListChangedOpportunities( r.Context(), query.Get("changed_after"), releasedFrom, ) if errors.Is(err, store.ErrUnknownBatch) { httpx.WriteProblem( w, r, http.StatusGone, "unknown batch: reload the whole list", ) return } if err != nil { httpx.WriteProblem(w, r, http.StatusInternalServerError, "") return } httpx.WriteJSON(w, http.StatusOK, OpportunityPage{ Opportunities: opportunities, Limit: len(opportunities), Batch: batch, }) } // 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, "") } }