Files
job-search/internal/mock/findATender/catalog.go
T
bwilliams 5cb3601e1f Added fetch service and a mock Find a Tender server
Fetching:
- Add `fetch service`, which runs a worker per enabled source until
  SIGINT/SIGTERM (internal/fetcher)
- Add util.Worker: runs a task on an interval with a since-checkpoint
  that only advances on success, and a shutdown timeout for in-flight
  work; plus AwaitTask/AwaitTaskE for calls that ignore cancellation
- Add fts_mock and sleeper sources
- Sleeper: sleeps periodically to emulate work being done. Can be
  instructed to block the shutdown signal or handle it cleanly

Mock FTS server (cmd/mock-fts, internal/mock/findATender):
- Serves GET /api/1.0/ocdsReleasePackages with FTS paging, query
  validation, UK local time handling and per-client rate limiting
- Generates fictional releases from an embedded catalog, moving
  procedures through planning, tender and award, and keeps them in a
  JSON Lines store that survives restarts
- Add logging.AccessLog for combined/common access logs
2026-10-09 18:02:32 +01:00

185 lines
5.1 KiB
Go

package mock
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"math/rand"
"sort"
"strings"
"git.avsdev.uk/Go/job-search/internal/mock/findATender/ocds"
)
// Method is a procurement method and its details, as published on a tender.
type Method struct {
Method string `json:"procurementMethod"`
Details string `json:"procurementMethodDetails,omitempty"`
}
// embeddedCatalog is data/catalog.json, compiled into the binary. Rebuild after
// changing the file.
//
//go:embed data/catalog.json
var embeddedCatalog []byte
// Catalog is the pool the generator draws from. It is read from
// ./data/catalog.json. Classifications holds every classification the subjects
// use, keyed "{scheme}-{id}" (e.g. "CPV-90513000"), and subjects and their
// items refer to entries by that key.
type Catalog struct {
Buyers []ocds.Organization `json:"buyers"`
Suppliers []ocds.Organization `json:"suppliers"`
Subjects []Subject `json:"subjects"`
Methods []Method `json:"methods"`
Classifications map[string]ocds.Classification `json:"classifications"`
}
// Subject is a tender as stored in the catalog: an ocds.Tender whose main
// classification and additionalClassifications, on the tender and on each
// item, are keys into Catalog.Classifications. The outer fields shadow the
// embedded ones of the same JSON name, so everything else is read as
// ocds.Tender.
type Subject struct {
ocds.Tender
Classification string `json:"classification,omitempty"`
AdditionalClassifications []string `json:"additionalClassifications,omitempty"`
Items []SubjectItem `json:"items,omitempty"`
}
// SubjectItem is an ocds.Item whose additionalClassifications are keys into
// Catalog.Classifications.
type SubjectItem struct {
ocds.Item
AdditionalClassifications []string `json:"additionalClassifications,omitempty"`
}
// LoadCatalog parses the embedded catalog and checks it is usable: every
// pool has at least one entry and every classification key resolves.
func LoadCatalog() (*Catalog, error) {
return parseCatalog(embeddedCatalog, "embedded catalog")
}
// parseCatalog decodes and validates catalog JSON; name labels any error.
func parseCatalog(b []byte, name string) (*Catalog, error) {
var c Catalog
if err := json.Unmarshal(b, &c); err != nil {
return nil, fmt.Errorf("decoding %s: %w", name, err)
}
if err := c.validate(); err != nil {
return nil, fmt.Errorf("invalid %s: %w", name, err)
}
return &c, nil
}
func (c *Catalog) validate() error {
var errs []error
for _, pool := range []struct {
name string
n int
}{
{"buyers", len(c.Buyers)},
{"suppliers", len(c.Suppliers)},
{"subjects", len(c.Subjects)},
{"methods", len(c.Methods)},
{"classifications", len(c.Classifications)},
} {
if pool.n == 0 {
errs = append(errs, fmt.Errorf("no %s", pool.name))
}
}
missing := map[string]bool{}
check := func(keys ...string) {
for _, k := range keys {
if _, ok := c.Classifications[k]; k != "" && !ok {
missing[k] = true
}
}
}
for _, s := range c.Subjects {
check(s.Classification)
check(s.AdditionalClassifications...)
for _, it := range s.Items {
check(it.AdditionalClassifications...)
}
}
if len(missing) > 0 {
keys := make([]string, 0, len(missing))
for k := range missing {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) > 5 {
keys = append(keys[:5], "...")
}
errs = append(errs, fmt.Errorf(
"%d classification key(s) not found in classifications: %s",
len(missing), strings.Join(keys, ", "),
))
}
return errors.Join(errs...)
}
// ---------- picking (always returns a copy the caller may modify) ----------
func (c *Catalog) buyer(r *rand.Rand) ocds.Organization {
return clone(c.Buyers[r.Intn(len(c.Buyers))])
}
func (c *Catalog) supplier(r *rand.Rand) ocds.Organization {
return clone(c.Suppliers[r.Intn(len(c.Suppliers))])
}
func (c *Catalog) method(r *rand.Rand) Method {
return c.Methods[r.Intn(len(c.Methods))]
}
// subject picks a subject and returns it as an ocds.Tender, with its
// classification keys resolved to full classifications.
func (c *Catalog) subject(r *rand.Rand) ocds.Tender {
s := clone(c.Subjects[r.Intn(len(c.Subjects))])
t := s.Tender
t.Classification = c.resolveOne(s.Classification)
t.AdditionalClassifications = c.resolve(s.AdditionalClassifications)
t.Items = nil
for _, it := range s.Items {
item := it.Item
item.AdditionalClassifications = c.resolve(it.AdditionalClassifications)
t.Items = append(t.Items, item)
}
return t
}
func (c *Catalog) resolveOne(key string) *ocds.Classification {
if cl, ok := c.Classifications[key]; ok {
return &cl
}
return nil
}
func (c *Catalog) resolve(keys []string) []ocds.Classification {
var out []ocds.Classification
for _, k := range keys {
if cl, ok := c.Classifications[k]; ok {
out = append(out, cl)
}
}
return out
}
// clone deep-copies a value through JSON, which is all the generated types need.
func clone[T any](v T) T {
var out T
b, err := json.Marshal(v)
if err == nil {
_ = json.Unmarshal(b, &out)
}
return out
}