Find a Tender API client updates
- Convert OCDS releases to types.Opportunity (internal/types); FetchOnce and FetchAll now return opportunities, built from each ocid's latest release - Include each item's main classification as well as its additional ones - Return ErrUnexpectedStatus for non-200 responses and ErrRateLimitExceeded for a repeated 429; close discarded bodies - Stop fetchAll on a repeated next link and close each page's body once read rather than deferring in the loop - Default a zero Since to 24 hours ago - Merge date repair and releaseIDs into api.go - Filter --planning/--opportunities/--awards on each procedure's current stage, using OCDS release tags; --opportunities previously matched a nonexistent "opportunity" tag and returned nothing - Reject conflicting filters before fetching (cobra flag group)
This commit is contained in:
1 parent
69620e5f7d
commit
399eccf2de
8 files changed
+567
-86
No files matched your search
@@ -19,9 +19,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.avsdev.uk/Go/job-search/internal/cli"
|
"git.avsdev.uk/Go/job-search/internal/cli"
|
||||||
|
|||||||
+31
-14
@@ -9,17 +9,41 @@ import (
|
|||||||
|
|
||||||
"git.avsdev.uk/Go/job-search/internal/cli"
|
"git.avsdev.uk/Go/job-search/internal/cli"
|
||||||
"git.avsdev.uk/Go/job-search/internal/findATender"
|
"git.avsdev.uk/Go/job-search/internal/findATender"
|
||||||
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
|
"git.avsdev.uk/Go/job-search/internal/types"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func filter(reqs []ocds.Release, tag string) []ocds.Release {
|
// Release tags for each stage of a procedure, from the OCDS releaseTag
|
||||||
var resp []ocds.Release
|
// codelist. Cancellations are left out, as a cancelled procedure is no longer
|
||||||
for _, req := range reqs {
|
// at any stage.
|
||||||
if slices.Contains(req.Tag, tag) {
|
var stageTags = map[string][]string{
|
||||||
resp = append(resp, req)
|
"planning": {"planning", "planningUpdate"},
|
||||||
|
"opportunity": {"tender", "tenderAmendment", "tenderUpdate"},
|
||||||
|
"award": {
|
||||||
|
"award", "awardUpdate",
|
||||||
|
"contract", "contractUpdate", "contractAmendment",
|
||||||
|
"implementation", "implementationUpdate", "contractTermination",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter keeps the opportunities whose latest release is at stage. Releases
|
||||||
|
// are oldest first, so the last one is the procedure's current stage.
|
||||||
|
func filter(opps []*types.Opportunity, stage string) []*types.Opportunity {
|
||||||
|
var resp []*types.Opportunity
|
||||||
|
|
||||||
|
for _, opp := range opps {
|
||||||
|
if len(opp.Releases) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
latest := opp.Releases[len(opp.Releases)-1]
|
||||||
|
for _, tag := range latest.Tags {
|
||||||
|
if slices.Contains(stageTags[stage], tag.Tag) {
|
||||||
|
resp = append(resp, opp)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,14 +84,6 @@ func commandOnce(opts *cli.Options) *cobra.Command {
|
|||||||
opportunities, _ := cmd.Flags().GetBool("opportunities")
|
opportunities, _ := cmd.Flags().GetBool("opportunities")
|
||||||
awards, _ := cmd.Flags().GetBool("awards")
|
awards, _ := cmd.Flags().GetBool("awards")
|
||||||
|
|
||||||
if (planning && (opportunities || awards)) ||
|
|
||||||
(opportunities && (planning || awards)) ||
|
|
||||||
(awards && (planning || opportunities)) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"--planning, --opportunities and --awards are exclusive, only one can be provided",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
filterTag := ""
|
filterTag := ""
|
||||||
if planning {
|
if planning {
|
||||||
filterTag = "planning"
|
filterTag = "planning"
|
||||||
@@ -112,5 +128,6 @@ func commandOnce(opts *cli.Options) *cobra.Command {
|
|||||||
cmd.Flags().Bool(
|
cmd.Flags().Bool(
|
||||||
"ignore-rate-limit", false, "ignore the rate limit and fail if exceeded",
|
"ignore-rate-limit", false, "ignore the rate limit and fail if exceeded",
|
||||||
)
|
)
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("planning", "opportunities", "awards")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
+289
-19
@@ -1,12 +1,16 @@
|
|||||||
package findATender
|
package findATender
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
|
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
|
||||||
@@ -18,6 +22,7 @@ var ErrNoNext = errors.New("no next link")
|
|||||||
var ErrNoReleases = errors.New("no releases")
|
var ErrNoReleases = errors.New("no releases")
|
||||||
var ErrRateLimitExceeded = errors.New("rate limit has been exceeded")
|
var ErrRateLimitExceeded = errors.New("rate limit has been exceeded")
|
||||||
var ErrFetchCancelled = errors.New("fetch cancelled")
|
var ErrFetchCancelled = errors.New("fetch cancelled")
|
||||||
|
var ErrUnexpectedStatus = errors.New("unexpected response status")
|
||||||
|
|
||||||
type RequestOptions struct {
|
type RequestOptions struct {
|
||||||
Since time.Time
|
Since time.Time
|
||||||
@@ -25,6 +30,232 @@ type RequestOptions struct {
|
|||||||
AllowRateLimit bool
|
AllowRateLimit bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Utility methods for handling API irregularities -----------
|
||||||
|
type safeReleasePackage struct {
|
||||||
|
URI string `json:"uri,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Extensions []string `json:"extensions,omitempty"`
|
||||||
|
PublishedDate *string `json:"publishedDate,omitempty"`
|
||||||
|
Releases []json.RawMessage `json:"releases,omitempty"`
|
||||||
|
Publisher *ocds.ReleasePackagePublisher `json:"publisher,omitempty"`
|
||||||
|
License string `json:"license,omitempty"`
|
||||||
|
PublicationPolicy string `json:"publicationPolicy,omitempty"`
|
||||||
|
Links *ocds.ReleasePackageLinks `json:"links,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateOnly matches a calendar date with no time of day: "2006-10-02".
|
||||||
|
var dateOnly = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||||
|
|
||||||
|
// unmarshalLenientDates decodes raw json into v like json.Unmarshal, after
|
||||||
|
// repairing date fields that time.Time cannot parse.
|
||||||
|
func unmarshalLenientDates(raw json.RawMessage, v any) error {
|
||||||
|
fixed, err := repairDates(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(fixed, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairDates returns a copy of raw in which every date field holding a
|
||||||
|
// date-only value ("2006-10-02") is rewritten as midnight UTC
|
||||||
|
// ("2006-10-02T00:00:00Z"), and every date field holding an empty string is
|
||||||
|
// set to null. Values that already have a time, values in other fields, and
|
||||||
|
// dates that appear inside free text are left alone. If nothing needs
|
||||||
|
// repairing, raw is returned unchanged.
|
||||||
|
//
|
||||||
|
// A date field is one whose name is "date" or starts with "date", contains
|
||||||
|
// "Date", or ends with "Deadline".
|
||||||
|
func repairDates(raw json.RawMessage) (json.RawMessage, error) {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber() // keep numbers exactly as written, not as float64
|
||||||
|
|
||||||
|
var doc any
|
||||||
|
if err := dec.Decode(&doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("repairing dates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
|
||||||
|
return nil, errors.New(
|
||||||
|
"repairing dates: unexpected data after the JSON value",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !recursiveRepairDates(doc) {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recursiveRepairDates fixes date fields in v in place and reports whether it
|
||||||
|
// changed anything.
|
||||||
|
func recursiveRepairDates(v any) bool {
|
||||||
|
changed := false
|
||||||
|
switch v := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for key, val := range v {
|
||||||
|
if s, ok := val.(string); ok && isDateField(key) {
|
||||||
|
switch {
|
||||||
|
case dateOnly.MatchString(s):
|
||||||
|
v[key] = s + "T00:00:00Z"
|
||||||
|
changed = true
|
||||||
|
case s == "":
|
||||||
|
v[key] = nil
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if recursiveRepairDates(val) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, el := range v {
|
||||||
|
if recursiveRepairDates(el) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDateField(name string) bool {
|
||||||
|
return strings.HasPrefix(name, "date") ||
|
||||||
|
strings.Contains(name, "Date") ||
|
||||||
|
strings.HasSuffix(name, "Deadline")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseIDs reads just the ocid and the release id from a release, without
|
||||||
|
// decoding the rest of it. It reads the top-level fields in order and stops
|
||||||
|
// as soon as it has both, so an error later in the release (invalid JSON or a
|
||||||
|
// value that wouldn't fit the release struct) doesn't matter. Fields before
|
||||||
|
// them only need to be valid JSON.
|
||||||
|
func releaseIDs(raw json.RawMessage) (ocid, id string, err error) {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber() // a numeric id keeps its exact digits
|
||||||
|
|
||||||
|
if tok, err := dec.Token(); err != nil {
|
||||||
|
return "", "", fmt.Errorf("reading release: %w", err)
|
||||||
|
} else if tok != json.Delim('{') {
|
||||||
|
return "", "", errors.New("reading release: not a JSON object")
|
||||||
|
}
|
||||||
|
|
||||||
|
var haveOCID, haveID bool
|
||||||
|
for dec.More() {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return ocid, id, fmt.Errorf("reading release: %w", err)
|
||||||
|
}
|
||||||
|
switch key, _ := tok.(string); key {
|
||||||
|
case "ocid":
|
||||||
|
if err := dec.Decode(&ocid); err != nil {
|
||||||
|
return ocid, id, fmt.Errorf("reading release ocid: %w", err)
|
||||||
|
}
|
||||||
|
haveOCID = true
|
||||||
|
case "id":
|
||||||
|
if id, err = decodeReleaseID(dec); err != nil {
|
||||||
|
return ocid, id, err
|
||||||
|
}
|
||||||
|
haveID = true
|
||||||
|
default:
|
||||||
|
var skip json.RawMessage // must be valid JSON, but is otherwise ignored
|
||||||
|
if err := dec.Decode(&skip); err != nil {
|
||||||
|
return ocid, id, fmt.Errorf("reading release field %q: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if haveOCID && haveID {
|
||||||
|
return ocid, id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ocid, id, missingIDsError(haveOCID, haveID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeReleaseID decodes a release id. OCDS ids are strings, but a number is
|
||||||
|
// tolerated and kept as written.
|
||||||
|
func decodeReleaseID(dec *json.Decoder) (string, error) {
|
||||||
|
var v any
|
||||||
|
if err := dec.Decode(&v); err != nil {
|
||||||
|
return "", fmt.Errorf("reading release id: %w", err)
|
||||||
|
}
|
||||||
|
switch v := v.(type) {
|
||||||
|
case string:
|
||||||
|
return v, nil
|
||||||
|
case json.Number:
|
||||||
|
return v.String(), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("reading release id: unexpected %T", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// missingIDsError reports which of the ocid and release id were not found.
|
||||||
|
func missingIDsError(haveOCID, haveID bool) error {
|
||||||
|
switch {
|
||||||
|
case !haveOCID && !haveID:
|
||||||
|
return errors.New("reading release: no ocid or id")
|
||||||
|
case !haveOCID:
|
||||||
|
return errors.New("reading release: no ocid")
|
||||||
|
default:
|
||||||
|
return errors.New("reading release: no id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpacks an API response into an OCDS ReleasePackage. Handles API
|
||||||
|
// irregularities (malformed dates, missing fields, invalid releases, etc).
|
||||||
|
//
|
||||||
|
// This method takes ownership of the response body and closes it when done.
|
||||||
|
func safeUnpackReleases(response *resty.Response) (*ocds.ReleasePackage, error) {
|
||||||
|
var pkg safeReleasePackage
|
||||||
|
|
||||||
|
// Take ownership of the response body.
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(response.Body)
|
||||||
|
if err := decoder.Decode(&pkg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &ocds.ReleasePackage{
|
||||||
|
URI: pkg.URI,
|
||||||
|
Version: pkg.Version,
|
||||||
|
Extensions: pkg.Extensions,
|
||||||
|
Publisher: pkg.Publisher,
|
||||||
|
License: pkg.License,
|
||||||
|
PublicationPolicy: pkg.PublicationPolicy,
|
||||||
|
Links: pkg.Links,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correct PublishedDate of "" and "2006-01-02"
|
||||||
|
if pkg.PublishedDate != nil && len(*pkg.PublishedDate) > 0 {
|
||||||
|
t, err := time.Parse(time.RFC3339, *pkg.PublishedDate)
|
||||||
|
if err != nil {
|
||||||
|
t, err = time.Parse(time.DateOnly, *pkg.PublishedDate)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
out.PublishedDate = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rawRel := range pkg.Releases {
|
||||||
|
var release ocds.Release
|
||||||
|
err := unmarshalLenientDates(rawRel, &release)
|
||||||
|
if err != nil {
|
||||||
|
ocid, release_id, _ := releaseIDs(rawRel)
|
||||||
|
slog.Warn(fmt.Sprintf(
|
||||||
|
"Skipping release (%s/%s) due to error: %s", ocid, release_id, err,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Releases = append(out.Releases, release)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API interaction methods -----------
|
||||||
|
// Sleep the fetch worker until either the context is cancelled (shutdown) or
|
||||||
|
// the duration has expired. The duration is usually provided by the API - a
|
||||||
|
// nil duration will return immediately.
|
||||||
func rateSleep(ctx context.Context, duration time.Duration) error {
|
func rateSleep(ctx context.Context, duration time.Duration) error {
|
||||||
timer := time.NewTimer(duration)
|
timer := time.NewTimer(duration)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
@@ -37,6 +268,14 @@ func rateSleep(ctx context.Context, duration time.Duration) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check and close the response body - avoids double close issues
|
||||||
|
func closeBody(res *resty.Response) {
|
||||||
|
if res.Body != nil {
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually perform a single fetch against the API
|
||||||
func fetchURI(ft *FindATender, uri string) (*resty.Response, error) {
|
func fetchURI(ft *FindATender, uri string) (*resty.Response, error) {
|
||||||
withTrace := ft.config.Logging.LogLevel() <= slog.LevelDebug
|
withTrace := ft.config.Logging.LogLevel() <= slog.LevelDebug
|
||||||
|
|
||||||
@@ -69,6 +308,11 @@ func fetchURI(ft *FindATender, uri string) (*resty.Response, error) {
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform a fetch against the API. If the API returns with a rate limit
|
||||||
|
// reached response and allowRateLimit is true, then wait and retry like a good
|
||||||
|
// client. If allowRateLimit is false, return to the caller informing them of
|
||||||
|
// the rate limit exceeded error. If, after waiting, the API still thinks we
|
||||||
|
// have exceeded the rate limit, bail out anyway.
|
||||||
func (ft *FindATender) fetchURI(
|
func (ft *FindATender) fetchURI(
|
||||||
uri string,
|
uri string,
|
||||||
allowRateLimit bool,
|
allowRateLimit bool,
|
||||||
@@ -85,6 +329,8 @@ func (ft *FindATender) fetchURI(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if res.StatusCode() == 429 {
|
if res.StatusCode() == 429 {
|
||||||
|
closeBody(res)
|
||||||
|
|
||||||
if !allowRateLimit {
|
if !allowRateLimit {
|
||||||
return nil, ErrRateLimitExceeded
|
return nil, ErrRateLimitExceeded
|
||||||
}
|
}
|
||||||
@@ -106,16 +352,26 @@ func (ft *FindATender) fetchURI(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if res.StatusCode() == 429 {
|
||||||
|
closeBody(res)
|
||||||
|
return nil, ErrRateLimitExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.StatusCode() != 200 {
|
||||||
|
closeBody(res)
|
||||||
|
return nil, fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status())
|
||||||
}
|
}
|
||||||
|
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Perform a fetch against the API with some sanitised default options.
|
||||||
func (ft *FindATender) fetchWithOpts(
|
func (ft *FindATender) fetchWithOpts(
|
||||||
opts RequestOptions,
|
opts RequestOptions,
|
||||||
) (*resty.Response, error) {
|
) (*resty.Response, error) {
|
||||||
if opts.Since.IsZero() {
|
if opts.Since.IsZero() {
|
||||||
opts.Since = time.Now().AddDate(0, 0, 1)
|
opts.Since = time.Now().AddDate(0, 0, -1)
|
||||||
}
|
}
|
||||||
if opts.Limit == 0 {
|
if opts.Limit == 0 {
|
||||||
opts.Limit = 100
|
opts.Limit = 100
|
||||||
@@ -131,8 +387,11 @@ func (ft *FindATender) fetchWithOpts(
|
|||||||
return ft.fetchURI(uri, opts.AllowRateLimit)
|
return ft.fetchURI(uri, opts.AllowRateLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the response provided a "Next" link, perform another API call to get the
|
||||||
|
// next page. Error if there is no "Next" link - the caller can decide if they
|
||||||
|
// have attempted to fetch next in error or not.
|
||||||
func (ft *FindATender) fetchNext(
|
func (ft *FindATender) fetchNext(
|
||||||
pkg ocds.ReleasePackage,
|
pkg *ocds.ReleasePackage,
|
||||||
allowRateLimit bool,
|
allowRateLimit bool,
|
||||||
) (*resty.Response, error) {
|
) (*resty.Response, error) {
|
||||||
if pkg.Links == nil || len(pkg.Links.Next) == 0 {
|
if pkg.Links == nil || len(pkg.Links.Next) == 0 {
|
||||||
@@ -141,26 +400,27 @@ func (ft *FindATender) fetchNext(
|
|||||||
return ft.fetchURI(pkg.Links.Next, allowRateLimit)
|
return ft.fetchURI(pkg.Links.Next, allowRateLimit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make one call on the API. Do not follow any "Next" links.
|
||||||
func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
|
func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
|
||||||
res, err := ft.fetchWithOpts(opts)
|
res, err := ft.fetchWithOpts(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
var relPkg ocds.ReleasePackage
|
pkg, err := safeUnpackReleases(res)
|
||||||
decoder := json.NewDecoder(NewOCDSStreamCleaner(res.Body))
|
if err != nil {
|
||||||
if err := decoder.Decode(&relPkg); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(relPkg.Releases) == 0 {
|
if len(pkg.Releases) == 0 {
|
||||||
return nil, ErrNoReleases
|
return nil, ErrNoReleases
|
||||||
}
|
}
|
||||||
|
|
||||||
return relPkg.Releases, nil
|
return pkg.Releases, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make a call on the API. If there are any "Next" links, call them in
|
||||||
|
// succession until the end has been reached.
|
||||||
func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
||||||
var releases []ocds.Release
|
var releases []ocds.Release
|
||||||
|
|
||||||
@@ -168,30 +428,40 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
var relPkg ocds.ReleasePackage
|
pkg, err := safeUnpackReleases(res)
|
||||||
decoder := json.NewDecoder(NewOCDSStreamCleaner(res.Body))
|
if err != nil {
|
||||||
if err := decoder.Decode(&relPkg); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
releases = append(releases, relPkg.Releases...)
|
releases = append(releases, pkg.Releases...)
|
||||||
|
|
||||||
for true {
|
// Next links already followed, so a page that links back to an earlier
|
||||||
res, err := ft.fetchNext(relPkg, opts.AllowRateLimit)
|
// one cannot loop forever.
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for {
|
||||||
|
if pkg.Links != nil && len(pkg.Links.Next) > 0 {
|
||||||
|
if seen[pkg.Links.Next] {
|
||||||
|
slog.Warn(fmt.Sprintf(
|
||||||
|
"Stopping at repeated next link: %s", pkg.Links.Next,
|
||||||
|
))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
seen[pkg.Links.Next] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := ft.fetchNext(pkg, opts.AllowRateLimit)
|
||||||
if err == ErrNoNext {
|
if err == ErrNoNext {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(NewOCDSStreamCleaner(res.Body))
|
pkg, err = safeUnpackReleases(res)
|
||||||
if err := decoder.Decode(&relPkg); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
releases = append(releases, relPkg.Releases...)
|
releases = append(releases, pkg.Releases...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(releases) == 0 {
|
if len(releases) == 0 {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package findATender
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"regexp"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Global regex to identify and catch date-only fields
|
|
||||||
var invalidDateRegex = regexp.MustCompile(`:\s*"(\d{4}-\d{2}-\d{2})"\s*([,}\s])`)
|
|
||||||
var invalidPublishedDateRegex = regexp.MustCompile(`"publishedDate":\s*""`)
|
|
||||||
|
|
||||||
// The UK Government FindATender service doesn't strictly follow OCDS date
|
|
||||||
// format. Sometimes it provides dates only, sometimes it sends empty strings
|
|
||||||
// instead of nulls.
|
|
||||||
type FindATenderStreamCleaner struct {
|
|
||||||
reader *bufio.Reader
|
|
||||||
buffer bytes.Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOCDSStreamCleaner(r io.Reader) *FindATenderStreamCleaner {
|
|
||||||
return &FindATenderStreamCleaner{reader: bufio.NewReader(r)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (sc *FindATenderStreamCleaner) Read(p []byte) (int, error) {
|
|
||||||
if sc.buffer.Len() == 0 {
|
|
||||||
line, err := sc.reader.ReadBytes('\n')
|
|
||||||
if len(line) > 0 {
|
|
||||||
// Convert "2026-11-16" -> "2026-11-16T00:00:00Z"
|
|
||||||
line = invalidDateRegex.ReplaceAll(line, []byte(`:"$1T00:00:00Z"`))
|
|
||||||
// Convert {publishedDate:""} -> "{publishedDate:null}"
|
|
||||||
line = invalidPublishedDateRegex.ReplaceAll(line, []byte(`"publishedDate":null`))
|
|
||||||
sc.buffer.Write(line)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if sc.buffer.Len() == 0 {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sc.buffer.Read(p)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.avsdev.uk/Go/job-search/internal/config"
|
"git.avsdev.uk/Go/job-search/internal/config"
|
||||||
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
|
"git.avsdev.uk/Go/job-search/internal/types"
|
||||||
resty "resty.dev/v3"
|
resty "resty.dev/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +29,6 @@ func New(conf *config.JobSearchConfig, apiRoot string) FindATender {
|
|||||||
|
|
||||||
func (ft *FindATender) ReloadConfig(conf *config.JobSearchConfig) {
|
func (ft *FindATender) ReloadConfig(conf *config.JobSearchConfig) {
|
||||||
ft.config = conf
|
ft.config = conf
|
||||||
// TODO: any reloading required for configuration change handling
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ft *FindATender) With(ctx context.Context) *FindATender {
|
func (ft *FindATender) With(ctx context.Context) *FindATender {
|
||||||
@@ -49,27 +48,27 @@ func (ft *FindATender) Shutdown() {
|
|||||||
func (ft *FindATender) FetchOnce(
|
func (ft *FindATender) FetchOnce(
|
||||||
since time.Time,
|
since time.Time,
|
||||||
allowRateLimit bool,
|
allowRateLimit bool,
|
||||||
) ([]ocds.Release, error) {
|
) ([]*types.Opportunity, error) {
|
||||||
reqs, err := ft.fetchOnce(RequestOptions{
|
releases, err := ft.fetchOnce(RequestOptions{
|
||||||
Since: since,
|
Since: since,
|
||||||
AllowRateLimit: allowRateLimit,
|
AllowRateLimit: allowRateLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return reqs, nil
|
return Convert(releases), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ft *FindATender) FetchAll(
|
func (ft *FindATender) FetchAll(
|
||||||
since time.Time,
|
since time.Time,
|
||||||
allowRateLimit bool,
|
allowRateLimit bool,
|
||||||
) ([]ocds.Release, error) {
|
) ([]*types.Opportunity, error) {
|
||||||
reqs, err := ft.fetchAll(RequestOptions{
|
releases, err := ft.fetchAll(RequestOptions{
|
||||||
Since: since,
|
Since: since,
|
||||||
AllowRateLimit: allowRateLimit,
|
AllowRateLimit: allowRateLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return reqs, nil
|
return Convert(releases), nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package findATender
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.avsdev.uk/Go/job-search/internal/findATender/ocds"
|
||||||
|
"git.avsdev.uk/Go/job-search/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ocdsGetBuyerId(release *ocds.Release) string {
|
||||||
|
if release.Buyer != nil {
|
||||||
|
return string(release.Buyer.ID)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func ocdsGetBuyerName(release *ocds.Release) string {
|
||||||
|
if release.Buyer != nil {
|
||||||
|
return release.Buyer.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func ocdsConvertClassification(
|
||||||
|
classif *ocds.Classification,
|
||||||
|
) types.ClassificationCode {
|
||||||
|
return types.ClassificationCode{
|
||||||
|
IsCPV: strings.Compare(classif.Scheme, "CPV") == 0,
|
||||||
|
Code: string(classif.ID),
|
||||||
|
Classification: classif.Description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ocdsGetClassifications(release *ocds.Release) []types.ClassificationCode {
|
||||||
|
classifications := make([]types.ClassificationCode, 0)
|
||||||
|
if release.Tender == nil {
|
||||||
|
return classifications
|
||||||
|
}
|
||||||
|
add := func(classif *ocds.Classification) {
|
||||||
|
newClassif := ocdsConvertClassification(classif)
|
||||||
|
found := slices.ContainsFunc(
|
||||||
|
classifications, func(r types.ClassificationCode) bool {
|
||||||
|
return r.Code == newClassif.Code
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if !found {
|
||||||
|
classifications = append(classifications, newClassif)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if release.Tender.Classification != nil {
|
||||||
|
add(release.Tender.Classification)
|
||||||
|
}
|
||||||
|
for _, val := range release.Tender.Items {
|
||||||
|
if val.Classification != nil {
|
||||||
|
add(val.Classification)
|
||||||
|
}
|
||||||
|
for _, classif := range val.AdditionalClassifications {
|
||||||
|
add(&classif)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return classifications
|
||||||
|
}
|
||||||
|
|
||||||
|
func ocdsIsSMESuitable(release *ocds.Release) bool {
|
||||||
|
if release.Tender == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(release.Tender.Lots) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, lot := range release.Tender.Lots {
|
||||||
|
if lot.Suitability != nil {
|
||||||
|
if lot.Suitability.SME != nil && *lot.Suitability.SME {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func ocdsIsVCSESuitable(release *ocds.Release) bool {
|
||||||
|
if release.Tender == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(release.Tender.Lots) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, lot := range release.Tender.Lots {
|
||||||
|
if lot.Suitability != nil {
|
||||||
|
if lot.Suitability.Vcse != nil && *lot.Suitability.Vcse {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ocdsRelease(release *ocds.Release) types.OpportunityRelease {
|
||||||
|
tags := make([]types.OpportunityReleaseTag, 0)
|
||||||
|
for _, val := range release.Tag {
|
||||||
|
tags = append(tags, types.OpportunityReleaseTag{
|
||||||
|
UUID: release.OCID,
|
||||||
|
ReleaseID: release.ID,
|
||||||
|
Tag: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rel := types.OpportunityRelease{
|
||||||
|
UUID: release.OCID,
|
||||||
|
ReleaseID: release.ID,
|
||||||
|
Tags: tags,
|
||||||
|
ReleaseDate: release.Date,
|
||||||
|
}
|
||||||
|
|
||||||
|
reencoded, err := json.Marshal(release)
|
||||||
|
if err == nil {
|
||||||
|
rel.JSON = string(reencoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertOne(release *ocds.Release) *types.Opportunity {
|
||||||
|
t := &types.Opportunity{
|
||||||
|
UUID: release.OCID,
|
||||||
|
UUIDType: "ocid",
|
||||||
|
BuyerId: ocdsGetBuyerId(release),
|
||||||
|
BuyerName: ocdsGetBuyerName(release),
|
||||||
|
}
|
||||||
|
|
||||||
|
if release.Tender != nil {
|
||||||
|
t.Title = release.Tender.Title
|
||||||
|
t.Status = release.Tender.Status
|
||||||
|
t.Description = release.Tender.Description
|
||||||
|
t.Classifications = ocdsGetClassifications(release)
|
||||||
|
t.MainCategory = release.Tender.MainProcurementCategory
|
||||||
|
t.ProcurementMethod = release.Tender.ProcurementMethod
|
||||||
|
t.SMESuitable = ocdsIsSMESuitable(release)
|
||||||
|
t.VCSESuitable = ocdsIsVCSESuitable(release)
|
||||||
|
if release.Tender.TenderPeriod != nil {
|
||||||
|
t.StartDate = release.Tender.TenderPeriod.StartDate
|
||||||
|
t.EndDate = release.Tender.TenderPeriod.EndDate
|
||||||
|
}
|
||||||
|
if release.Tender.Value != nil && release.Tender.Value.Amount != nil {
|
||||||
|
t.GrossValue = *release.Tender.Value.Amount
|
||||||
|
}
|
||||||
|
t.HasDocuments = len(release.Tender.Documents) > 0
|
||||||
|
t.MultiLot = len(release.Tender.Lots) > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
t.LatestReleaseDate = release.Date
|
||||||
|
t.Releases = append(t.Releases, ocdsRelease(release))
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert groups releases by ocid into opportunities, in the order each ocid
|
||||||
|
// first appears. Each opportunity is built from its latest release (by date,
|
||||||
|
// or the later one in releases on a tie) and lists all of its releases oldest
|
||||||
|
// first.
|
||||||
|
func Convert(releases []ocds.Release) []*types.Opportunity {
|
||||||
|
var order []string
|
||||||
|
byOCID := make(map[string][]*ocds.Release)
|
||||||
|
|
||||||
|
for idx := range releases {
|
||||||
|
release := &releases[idx]
|
||||||
|
if _, found := byOCID[release.OCID]; !found {
|
||||||
|
order = append(order, release.OCID)
|
||||||
|
}
|
||||||
|
byOCID[release.OCID] = append(byOCID[release.OCID], release)
|
||||||
|
}
|
||||||
|
|
||||||
|
opportunities := make([]*types.Opportunity, 0, len(order))
|
||||||
|
for _, ocid := range order {
|
||||||
|
group := byOCID[ocid]
|
||||||
|
|
||||||
|
slices.SortStableFunc(group, func(a, b *ocds.Release) int {
|
||||||
|
return a.Date.Compare(b.Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
opportunity := convertOne(group[len(group)-1])
|
||||||
|
opportunity.Releases = make([]types.OpportunityRelease, 0, len(group))
|
||||||
|
for _, release := range group {
|
||||||
|
opportunity.Releases = append(opportunity.Releases, ocdsRelease(release))
|
||||||
|
}
|
||||||
|
opportunities = append(opportunities, opportunity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunities
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
type ClassificationCode struct {
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
IsCPV bool `db:"is_cpv" json:"is_cpv"`
|
||||||
|
Code string `db:"code" json:"code"`
|
||||||
|
Classification string `db:"classification" json:"classification"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Opportunity struct {
|
||||||
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
|
UUIDType string `db:"uuid_type" json:"uuid_type"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Description string `db:"description" json:"description"`
|
||||||
|
BuyerName string `db:"buyer_name" json:"buyer_name"`
|
||||||
|
Classifications []ClassificationCode `db:"-" json:"classifications"`
|
||||||
|
BuyerId string `db:"buyer_id" json:"buyer_id"`
|
||||||
|
MainCategory string `db:"main_category" json:"main_category"`
|
||||||
|
ProcurementMethod string `db:"procurement_method" json:"procurement_method"`
|
||||||
|
MultiLot bool `db:"multi_lot" json:"multi_lot"`
|
||||||
|
SMESuitable bool `db:"sme_suitable" json:"sme_suitable"`
|
||||||
|
VCSESuitable bool `db:"vcse_suitable" json:"vcse_suitable"`
|
||||||
|
StartDate *time.Time `db:"start_date" json:"start_date"`
|
||||||
|
EndDate *time.Time `db:"end_date" json:"end_date"`
|
||||||
|
GrossValue float64 `db:"gross_value" json:"gross_value"`
|
||||||
|
HasDocuments bool `db:"has_documents" json:"has_documents"`
|
||||||
|
LatestReleaseDate time.Time `db:"latest_release_date" json:"latest_release_date"`
|
||||||
|
Releases []OpportunityRelease `db:"-" json:"releases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpportunityRelease struct {
|
||||||
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
|
ReleaseID string `db:"release_id" json:"release_id"`
|
||||||
|
ReleaseDate time.Time `db:"release_date" json:"release_date"`
|
||||||
|
Tags []OpportunityReleaseTag `db:"-" json:"release_tags"`
|
||||||
|
JSON string `db:"data" json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpportunityReleaseTag struct {
|
||||||
|
UUID string `db:"uuid" json:"uuid"`
|
||||||
|
ReleaseID string `db:"release_id" json:"release_id"`
|
||||||
|
Tag string `db:"tag" json:"tag"`
|
||||||
|
}
|
||||||
Reference in new issue
Block a user