Added api/classifications endpoint, requiring a new query
This commit is contained in:
1 parent
378ac59f41
commit
0e27d2b2f3
9 files changed
+186
-6
No files matched your search
+5
-2
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user