diff --git a/internal/api/api.go b/internal/api/api.go index 581f3c5..decfde0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -20,7 +20,8 @@ type API struct { type Option func(*API) // 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 { return func(a *API) { a.store = opportunityStore @@ -46,10 +47,12 @@ func New(opts ...Option) *API { } // 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) { docsRoutes(router) if a.store != nil { newOpportunityRoutes(a.store, a.events).Routes(router) + newClassificationRoutes(a.store).Routes(router) } } diff --git a/internal/api/classifications.go b/internal/api/classifications.go new file mode 100644 index 0000000..be07f8a --- /dev/null +++ b/internal/api/classifications.go @@ -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) +} diff --git a/internal/api/classifications_test.go b/internal/api/classifications_test.go new file mode 100644 index 0000000..8d61ff3 --- /dev/null +++ b/internal/api/classifications_test.go @@ -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) + } +} diff --git a/internal/api/doc.go b/internal/api/doc.go index 7b4e78b..fb133d6 100644 --- a/internal/api/doc.go +++ b/internal/api/doc.go @@ -1,8 +1,8 @@ // Package api implements the REST API mounted under /api. New assembles it // (api.go): the opportunities, read-only apart from their marks -// (opportunities.go), and the API's own OpenAPI 3.1 description and docs -// viewer (openapi.go). New resources are added in New and Routes, so the -// server only mounts the API. +// (opportunities.go), their classifications (classifications.go), and the +// API's own OpenAPI 3.1 description and docs viewer (openapi.go). New +// 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 // handlers by go generate (swag v2, see the README's dev tools), into diff --git a/internal/api/opportunities_test.go b/internal/api/opportunities_test.go index 6a2da56..2c8cc3b 100644 --- a/internal/api/opportunities_test.go +++ b/internal/api/opportunities_test.go @@ -42,6 +42,15 @@ func opportunitiesServer(t *testing.T, count int, opts ...Option) string { UUIDType: "ocid", Title: fmt.Sprintf("Opportunity %d", i), 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{ { ReleaseID: "r1", diff --git a/internal/store/queries.go b/internal/store/queries.go index ff32c2a..5b56654 100644 --- a/internal/store/queries.go +++ b/internal/store/queries.go @@ -75,7 +75,9 @@ SELECT t.* FROM opportunities t WHERE t.uuid = ?` // 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 // filterOpportunityMark* fragments, which take none. A mark fragment is true @@ -213,6 +215,17 @@ INSERT INTO opportunity_classifications (opportunity_uuid, classification_id) VALUES (?, ?) 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 // opportunities. "IN (?)" is expanded by sqlx.In, which must be followed by // Rebind. c.* must map entirely onto types.ClassificationCode. diff --git a/internal/store/queries_test.go b/internal/store/queries_test.go index ab4421b..00fc38f 100644 --- a/internal/store/queries_test.go +++ b/internal/store/queries_test.go @@ -100,6 +100,9 @@ var checks = map[string]queryCheck{ "insertOpportunityClassification": { sql: insertOpportunityClassification, }, + "selectClassificationsInUse": { + sql: selectClassificationsInUse, + }, "selectClassificationsForOpportunities": { sql: selectClassificationsForOpportunities, in: true, diff --git a/internal/store/store.go b/internal/store/store.go index b7b57b1..6a24dfd 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -717,6 +717,21 @@ func (s *Store) ListReleases( 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 // opportunities from source, or the zero time if none are stored, so a // fetcher can carry on from where it left off. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 831918c..5c57d9e 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "path/filepath" + "strings" "testing" "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) + } +}