Added api/classifications endpoint, requiring a new query

This commit is contained in:
bwilliams committed 2026-10-16 14:44:12 +01:00
1 parent 378ac59f41
commit 0e27d2b2f3
9 files changed
+186 -6

No files matched your search

+5 -2
View File
@@ -20,7 +20,8 @@ type API struct {
type Option func(*API) type Option func(*API)
// WithStore makes the API serve the opportunities in opportunityStore under // WithStore makes the API serve the opportunities in opportunityStore under
// /opportunities. Without it there are no opportunity routes. // /opportunities, and their classifications under /classifications. Without
// it there are neither.
func WithStore(opportunityStore *store.Store) Option { func WithStore(opportunityStore *store.Store) Option {
return func(a *API) { return func(a *API) {
a.store = opportunityStore a.store = opportunityStore
@@ -46,10 +47,12 @@ func New(opts ...Option) *API {
} }
// Routes registers the API's routes on router (mounted under /api): its // Routes registers the API's routes on router (mounted under /api): its
// description (see openapi.go), and the opportunities if it has a store. // description (see openapi.go), and the opportunities and classifications if
// it has a store.
func (a *API) Routes(router chi.Router) { func (a *API) Routes(router chi.Router) {
docsRoutes(router) docsRoutes(router)
if a.store != nil { if a.store != nil {
newOpportunityRoutes(a.store, a.events).Routes(router) newOpportunityRoutes(a.store, a.events).Routes(router)
newClassificationRoutes(a.store).Routes(router)
} }
} }
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"github.com/go-chi/chi/v5"
"git.avsdev.uk/Go/job-search/internal/httpx"
"git.avsdev.uk/Go/job-search/internal/store"
)
// classificationRoutes wires HTTP handlers to the classification codes in
// the opportunity store. They're read-only.
type classificationRoutes struct {
store *store.Store
}
// newClassificationRoutes returns the classification handlers for
// opportunityStore.
func newClassificationRoutes(
opportunityStore *store.Store,
) *classificationRoutes {
return &classificationRoutes{
store: opportunityStore,
}
}
// Routes registers the classification routes on router (mounted under
// /api).
func (h *classificationRoutes) Routes(router chi.Router) {
router.Get("/classifications", h.list)
}
// list serves GET /classifications: every classification code that at least
// one opportunity has, ordered by code.
//
// @Summary List classifications
// @Description Every classification code (CPV and others) that at least one
// @Description stored opportunity has, once each, ordered by code: the
// @Description codes worth filtering opportunities by.
// @Tags classifications
// @Produce json
// @Success 200 {array} types.ClassificationCode
// @Failure 500 {object} httpx.Problem
// @Router /classifications [get]
func (h *classificationRoutes) list(w http.ResponseWriter, r *http.Request) {
classifications, err := h.store.ListClassifications(r.Context())
if err != nil {
httpx.WriteProblem(w, r, http.StatusInternalServerError, "")
return
}
httpx.WriteJSON(w, http.StatusOK, classifications)
}
+41
View File
@@ -0,0 +1,41 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"git.avsdev.uk/Go/job-search/internal/types"
)
// The classifications the opportunities have are listed once each, by code.
func TestListClassifications(t *testing.T) {
url := opportunitiesServer(t, 3)
var classifications []types.ClassificationCode
status := request(t, "GET", url+"/classifications", &classifications)
if status != http.StatusOK {
t.Fatalf("status %d", status)
}
if len(classifications) != 2 ||
classifications[0].Code != "03000000" ||
classifications[1].Code != "72000000" ||
classifications[0].ID == 0 {
t.Errorf("got %+v; want 03000000 then 72000000, with ids", classifications)
}
}
// With no opportunities, the list is [] in JSON, not null.
func TestListClassificationsEmpty(t *testing.T) {
url := opportunitiesServer(t, 0)
res, err := http.Get(url + "/classifications")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var raw json.RawMessage
json.NewDecoder(res.Body).Decode(&raw)
if string(raw) != "[]" {
t.Errorf("body = %s; want []", raw)
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
// Package api implements the REST API mounted under /api. New assembles it // Package api implements the REST API mounted under /api. New assembles it
// (api.go): the opportunities, read-only apart from their marks // (api.go): the opportunities, read-only apart from their marks
// (opportunities.go), and the API's own OpenAPI 3.1 description and docs // (opportunities.go), their classifications (classifications.go), and the
// viewer (openapi.go). New resources are added in New and Routes, so the // API's own OpenAPI 3.1 description and docs viewer (openapi.go). New
// server only mounts the API. // resources are added in New and Routes, so the server only mounts the API.
// //
// The description is generated from the swag annotations on this package's // The description is generated from the swag annotations on this package's
// handlers by go generate (swag v2, see the README's dev tools), into // handlers by go generate (swag v2, see the README's dev tools), into
+9
View File
@@ -42,6 +42,15 @@ func opportunitiesServer(t *testing.T, count int, opts ...Option) string {
UUIDType: "ocid", UUIDType: "ocid",
Title: fmt.Sprintf("Opportunity %d", i), Title: fmt.Sprintf("Opportunity %d", i),
Source: "FindATender", Source: "FindATender",
// Two codes, shared: ocds-1, ocds-3... have 72000000, the
// others 03000000.
Classifications: []types.ClassificationCode{
{
IsCPV: true,
Code: []string{"03000000", "72000000"}[i%2],
Classification: []string{"Agriculture", "IT services"}[i%2],
},
},
Releases: []types.OpportunityRelease{ Releases: []types.OpportunityRelease{
{ {
ReleaseID: "r1", ReleaseID: "r1",
+14 -1
View File
@@ -75,7 +75,9 @@ SELECT t.* FROM opportunities t WHERE t.uuid = ?`
// ListOpportunities is assembled by listOpportunitiesQuery as: // ListOpportunities is assembled by listOpportunitiesQuery as:
// //
// listOpportunitiesSelect [WHERE filter AND filter ...] listOpportunitiesOrderAndPage // listOpportunitiesSelect
// [WHERE filter AND filter ...]
// listOpportunitiesOrderAndPage
// //
// Each filterOpportunity* fragment takes exactly one "?" argument, except // Each filterOpportunity* fragment takes exactly one "?" argument, except
// filterOpportunityMark* fragments, which take none. A mark fragment is true // filterOpportunityMark* fragments, which take none. A mark fragment is true
@@ -213,6 +215,17 @@ INSERT INTO opportunity_classifications (opportunity_uuid, classification_id)
VALUES (?, ?) VALUES (?, ?)
ON CONFLICT DO NOTHING` ON CONFLICT DO NOTHING`
// selectClassificationsInUse reads every classification code that at least
// one opportunity has, once each. c.* must map entirely onto
// types.ClassificationCode.
const selectClassificationsInUse = `
SELECT c.* FROM classification_codes c
WHERE EXISTS (
SELECT 1 FROM opportunity_classifications tc
WHERE tc.classification_id = c.id
)
ORDER BY c.code, c.id`
// selectClassificationsForOpportunities reads the classifications of several // selectClassificationsForOpportunities reads the classifications of several
// opportunities. "IN (?)" is expanded by sqlx.In, which must be followed by // opportunities. "IN (?)" is expanded by sqlx.In, which must be followed by
// Rebind. c.* must map entirely onto types.ClassificationCode. // Rebind. c.* must map entirely onto types.ClassificationCode.
+3
View File
@@ -100,6 +100,9 @@ var checks = map[string]queryCheck{
"insertOpportunityClassification": { "insertOpportunityClassification": {
sql: insertOpportunityClassification, sql: insertOpportunityClassification,
}, },
"selectClassificationsInUse": {
sql: selectClassificationsInUse,
},
"selectClassificationsForOpportunities": { "selectClassificationsForOpportunities": {
sql: selectClassificationsForOpportunities, sql: selectClassificationsForOpportunities,
in: true, in: true,
+15
View File
@@ -717,6 +717,21 @@ func (s *Store) ListReleases(
return releases, nil return releases, nil
} }
// ListClassifications returns every classification code that at least one
// stored opportunity has, ordered by code. A code no opportunity has any more
// isn't listed. The result is never nil.
func (s *Store) ListClassifications(
ctx context.Context,
) ([]types.ClassificationCode, error) {
classifications := []types.ClassificationCode{}
err := s.db.SelectContext(ctx, &classifications, selectClassificationsInUse)
if err != nil {
return nil, fmt.Errorf("listing classifications: %w", err)
}
return classifications, nil
}
// LatestReleaseDate returns the date of the newest release stored for the // LatestReleaseDate returns the date of the newest release stored for the
// opportunities from source, or the zero time if none are stored, so a // opportunities from source, or the zero time if none are stored, so a
// fetcher can carry on from where it left off. // fetcher can carry on from where it left off.
+42
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@@ -902,3 +903,44 @@ func TestSaveResultNewOpportunity(t *testing.T) {
) )
} }
} }
// ListClassifications lists the codes in use, once each and sorted, and not
// a code no opportunity has any more.
func TestListClassifications(t *testing.T) {
s := openStore(t)
ctx := context.Background()
got, err := s.ListClassifications(ctx)
if err != nil || got == nil || len(got) != 0 {
t.Fatalf("empty store: %v, %v; want an empty, non-nil list", got, err)
}
mustSave(t, s, opportunity("ocds-1", "First", 1, "r1"))
mustSave(t, s, opportunity("ocds-2", "Second", 1, "r1"))
codes := func() []string {
t.Helper()
got, err := s.ListClassifications(ctx)
if err != nil {
t.Fatal(err)
}
var listed []string
for _, classification := range got {
listed = append(listed, classification.Code)
}
return listed
}
if got := strings.Join(codes(), ","); got != "03000000,72000000" {
t.Fatalf("codes = %s; want 03000000,72000000", got)
}
// Newer releases replace both opportunities' classifications with one
// code; the other is still stored, but no longer listed.
for _, uuid := range []string{"ocds-1", "ocds-2"} {
newer := opportunity(uuid, "Newer", 2, "r2")
newer.Classifications = newer.Classifications[1:]
mustSave(t, s, newer)
}
if got := strings.Join(codes(), ","); got != "03000000" {
t.Errorf("after the change: codes = %s; want 03000000", got)
}
}