536 lines
15 KiB
Go
536 lines
15 KiB
Go
package findatender
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
resty "resty.dev/v3"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/findatender/ocds"
|
|
"git.avsdev.uk/Go/job-search/internal/logging"
|
|
)
|
|
|
|
// Errors returned when fetching from the API.
|
|
var (
|
|
// ErrNoNext means a release package has no next page link.
|
|
ErrNoNext = errors.New("findatender: no next link")
|
|
// ErrNoReleases means the API returned no releases.
|
|
ErrNoReleases = errors.New("findatender: no releases")
|
|
// ErrRateLimitExceeded means the API refused a request with 429 Too Many
|
|
// Requests, and waiting for it was not allowed or did not help.
|
|
ErrRateLimitExceeded = errors.New("findatender: rate limit has been exceeded")
|
|
// ErrFetchCancelled means the fetch's context was cancelled while waiting
|
|
// out a rate limit.
|
|
ErrFetchCancelled = errors.New("findatender: fetch cancelled")
|
|
// ErrUnexpectedStatus wraps an API response status other than 200 OK.
|
|
ErrUnexpectedStatus = errors.New("findatender: unexpected response status")
|
|
)
|
|
|
|
// RequestOptions selects the releases a fetch asks for.
|
|
type RequestOptions struct {
|
|
// Since asks for releases updated from this time. Zero means 24 hours ago.
|
|
Since time.Time
|
|
// Limit is the number of releases per page. Zero means 100.
|
|
Limit int
|
|
// AllowRateLimit waits out a 429 response and retries once, instead of
|
|
// failing with ErrRateLimitExceeded.
|
|
AllowRateLimit bool
|
|
}
|
|
|
|
// ---------- Utility methods for handling API irregularities ----------
|
|
|
|
// safeReleasePackage is ocds.ReleasePackage with the releases left undecoded,
|
|
// so safeUnpackReleases can repair or skip each one.
|
|
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 target like json.Unmarshal, after
|
|
// repairing date fields that time.Time cannot parse.
|
|
func unmarshalLenientDates(raw json.RawMessage, target any) error {
|
|
fixed, err := repairDates(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(fixed, target)
|
|
}
|
|
|
|
// 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) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber() // keep numbers exactly as written, not as float64
|
|
|
|
var doc any
|
|
if err := decoder.Decode(&doc); err != nil {
|
|
return nil, fmt.Errorf("repairing dates: %w", err)
|
|
}
|
|
|
|
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
|
|
return nil, errors.New(
|
|
"findatender: repairing dates: unexpected data after the JSON value",
|
|
)
|
|
}
|
|
|
|
if !recursiveRepairDates(doc) {
|
|
return raw, nil
|
|
}
|
|
|
|
return json.Marshal(doc)
|
|
}
|
|
|
|
// recursiveRepairDates fixes date fields in value in place and reports whether
|
|
// it changed anything.
|
|
func recursiveRepairDates(value any) bool {
|
|
changed := false
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, fieldValue := range typed {
|
|
if text, ok := fieldValue.(string); ok && isDateField(key) {
|
|
switch {
|
|
case dateOnly.MatchString(text):
|
|
typed[key] = text + "T00:00:00Z"
|
|
changed = true
|
|
case text == "":
|
|
typed[key] = nil
|
|
changed = true
|
|
}
|
|
continue
|
|
}
|
|
if recursiveRepairDates(fieldValue) {
|
|
changed = true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, element := range typed {
|
|
if recursiveRepairDates(element) {
|
|
changed = true
|
|
}
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// isDateField reports whether a field holds a date, by its name (see
|
|
// repairDates).
|
|
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) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber() // a numeric id keeps its exact digits
|
|
|
|
token, err := decoder.Token()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("reading release: %w", err)
|
|
}
|
|
if token != json.Delim('{') {
|
|
return "", "", errors.New("findatender: reading release: not a JSON object")
|
|
}
|
|
|
|
var haveOCID, haveID bool
|
|
for decoder.More() {
|
|
token, err := decoder.Token()
|
|
if err != nil {
|
|
return ocid, id, fmt.Errorf("reading release: %w", err)
|
|
}
|
|
switch key, _ := token.(string); key {
|
|
case "ocid":
|
|
if err := decoder.Decode(&ocid); err != nil {
|
|
return ocid, id, fmt.Errorf("reading release ocid: %w", err)
|
|
}
|
|
haveOCID = true
|
|
case "id":
|
|
if id, err = decodeReleaseID(decoder); err != nil {
|
|
return ocid, id, err
|
|
}
|
|
haveID = true
|
|
default:
|
|
var skip json.RawMessage // must be valid JSON, but is otherwise ignored
|
|
if err := decoder.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(decoder *json.Decoder) (string, error) {
|
|
var decoded any
|
|
if err := decoder.Decode(&decoded); err != nil {
|
|
return "", fmt.Errorf("reading release id: %w", err)
|
|
}
|
|
switch typed := decoded.(type) {
|
|
case string:
|
|
return typed, nil
|
|
case json.Number:
|
|
return typed.String(), nil
|
|
default:
|
|
return "", fmt.Errorf("findatender: reading release id: unexpected %T", typed)
|
|
}
|
|
}
|
|
|
|
// 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("findatender: reading release: no ocid or id")
|
|
case !haveOCID:
|
|
return errors.New("findatender: reading release: no ocid")
|
|
default:
|
|
return errors.New("findatender: reading release: no id")
|
|
}
|
|
}
|
|
|
|
// safeUnpackReleases unpacks an API response into an OCDS ReleasePackage,
|
|
// handling API irregularities (malformed dates, missing fields, invalid
|
|
// releases, etc).
|
|
//
|
|
// This method takes ownership of the response body and closes it when done.
|
|
func safeUnpackReleases(
|
|
logger *slog.Logger, response *resty.Response,
|
|
) (*ocds.ReleasePackage, error) {
|
|
var releasePackage safeReleasePackage
|
|
|
|
// Take ownership of the response body.
|
|
defer response.Body.Close() //nolint:errcheck // read only
|
|
|
|
decoder := json.NewDecoder(response.Body)
|
|
if err := decoder.Decode(&releasePackage); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := &ocds.ReleasePackage{
|
|
URI: releasePackage.URI,
|
|
Version: releasePackage.Version,
|
|
Extensions: releasePackage.Extensions,
|
|
Publisher: releasePackage.Publisher,
|
|
License: releasePackage.License,
|
|
PublicationPolicy: releasePackage.PublicationPolicy,
|
|
Links: releasePackage.Links,
|
|
}
|
|
|
|
// Correct PublishedDate of "" and "2006-01-02"
|
|
if releasePackage.PublishedDate != nil &&
|
|
*releasePackage.PublishedDate != "" {
|
|
published, err := time.Parse(time.RFC3339, *releasePackage.PublishedDate)
|
|
if err != nil {
|
|
published, err = time.Parse(time.DateOnly, *releasePackage.PublishedDate)
|
|
}
|
|
if err == nil {
|
|
out.PublishedDate = &published
|
|
}
|
|
}
|
|
|
|
for _, rawRelease := range releasePackage.Releases {
|
|
var release ocds.Release
|
|
err := unmarshalLenientDates(rawRelease, &release)
|
|
if err != nil {
|
|
ocid, releaseID, _ := releaseIDs(rawRelease)
|
|
logger.Warn(
|
|
"skipping unreadable release",
|
|
"ocid", ocid,
|
|
"release_id", releaseID,
|
|
"error", err,
|
|
)
|
|
logger.Debug(
|
|
"raw unreadable release",
|
|
"ocid", ocid,
|
|
"release_id", releaseID,
|
|
"release_raw", rawRelease,
|
|
)
|
|
continue
|
|
}
|
|
out.Releases = append(out.Releases, release)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// ---------- API interaction methods ----------
|
|
|
|
// rateSleep sleeps the fetch worker until either the context is cancelled
|
|
// (shutdown), returning ctx.Err(), or the duration has expired. The duration
|
|
// is usually provided by the API; a zero duration returns immediately.
|
|
func rateSleep(ctx context.Context, duration time.Duration) error {
|
|
timer := time.NewTimer(duration)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-timer.C:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// closeBody closes res's body if it has one, avoiding a double close.
|
|
func closeBody(res *resty.Response) {
|
|
if res.Body != nil {
|
|
res.Body.Close() //nolint:errcheck,gosec // read only
|
|
}
|
|
}
|
|
|
|
// fetchURI performs a single GET of uri, using findATender's context, and
|
|
// traces it when debug logging is on (logging.IsDebugEnabled). Unlike the
|
|
// method of the same name it does not check the response status.
|
|
func fetchURI(findATender *FindATender, uri string) (*resty.Response, error) {
|
|
withTrace := logging.IsDebugEnabled()
|
|
logger := findATender.logger()
|
|
|
|
logger.Info("fetching", "uri", uri)
|
|
|
|
ctx := findATender.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
req := findATender.httpClient.R()
|
|
req.SetContext(ctx)
|
|
res, err := req.SetTrace(withTrace).Get(uri)
|
|
|
|
if withTrace {
|
|
logTrace(logger, res)
|
|
}
|
|
|
|
return res, err
|
|
}
|
|
|
|
// logTrace logs res's trace, status and body at debug level. On a terminal
|
|
// the trace and body are printed as they are, as a field would escape them
|
|
// into one line; elsewhere they are fields, the trace as JSON for the JSON
|
|
// (file) logger.
|
|
func logTrace(logger *slog.Logger, res *resty.Response) {
|
|
if logging.WritesToTerminal(logger.Handler()) {
|
|
logger.Debug("fetched", "status", res.Status())
|
|
logging.PrintToTerminal(res.Request.TraceInfo().String())
|
|
logging.PrintToTerminal(res.String())
|
|
return
|
|
}
|
|
|
|
trace := res.Request.TraceInfo().String()
|
|
if logging.IsJSONHandler(logger.Handler()) {
|
|
trace = res.Request.TraceInfo().JSON()
|
|
}
|
|
if len(res.String()) <= 1024 {
|
|
logger.Debug(
|
|
"fetched",
|
|
"trace", trace,
|
|
"status", res.Status(),
|
|
"response", res.String(),
|
|
)
|
|
} else {
|
|
logger.Debug(
|
|
"fetched",
|
|
"trace", trace,
|
|
"status", res.Status(),
|
|
"response_length", len(res.String()),
|
|
)
|
|
}
|
|
}
|
|
|
|
// fetchURI performs 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(
|
|
uri string, allowRateLimit bool,
|
|
) (*resty.Response, error) {
|
|
res, err := fetchURI(ft, uri)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx := ft.ctx
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
if res.StatusCode() == 429 {
|
|
closeBody(res)
|
|
|
|
if !allowRateLimit {
|
|
return nil, ErrRateLimitExceeded
|
|
}
|
|
|
|
retryAfterText := res.Header().Get("Retry-After")
|
|
retryAfterSeconds, err := strconv.ParseInt(retryAfterText, 10, 32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unknown retry-after value: %w", err)
|
|
}
|
|
|
|
retryAfter := time.Duration(retryAfterSeconds) * time.Second
|
|
ft.logger().Warn(
|
|
"api rate limit hit, waiting as asked",
|
|
"retry_after", retryAfter,
|
|
)
|
|
if rateSleep(ctx, retryAfter) != nil {
|
|
return nil, ErrFetchCancelled
|
|
}
|
|
|
|
res, err = fetchURI(ft, uri)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
// fetchWithOpts performs a fetch against the API with some sanitised default
|
|
// options (see RequestOptions).
|
|
func (ft *FindATender) fetchWithOpts(
|
|
opts RequestOptions,
|
|
) (*resty.Response, error) {
|
|
if opts.Since.IsZero() {
|
|
opts.Since = time.Now().AddDate(0, 0, -1)
|
|
}
|
|
if opts.Limit == 0 {
|
|
opts.Limit = 100
|
|
}
|
|
|
|
uri := fmt.Sprintf(
|
|
"%s/ocdsReleasePackages?updatedFrom=%s&limit=%d",
|
|
ft.apiRoot,
|
|
opts.Since.Format("2006-01-02T15:04:05"),
|
|
opts.Limit,
|
|
)
|
|
|
|
return ft.fetchURI(uri, opts.AllowRateLimit)
|
|
}
|
|
|
|
// fetchNext fetches the page pkg's "Next" link points to. It returns
|
|
// ErrNoNext if there is no "Next" link; the caller can decide whether asking
|
|
// for it was an error.
|
|
func (ft *FindATender) fetchNext(
|
|
releasePackage *ocds.ReleasePackage, allowRateLimit bool,
|
|
) (*resty.Response, error) {
|
|
if releasePackage.Links == nil || releasePackage.Links.Next == "" {
|
|
return nil, ErrNoNext
|
|
}
|
|
return ft.fetchURI(releasePackage.Links.Next, allowRateLimit)
|
|
}
|
|
|
|
// fetchOnce makes one call on the API. It does not follow any "Next" links.
|
|
func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
|
|
res, err := ft.fetchWithOpts(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
releasePackage, err := safeUnpackReleases(ft.logger(), res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(releasePackage.Releases) == 0 {
|
|
return nil, ErrNoReleases
|
|
}
|
|
|
|
return releasePackage.Releases, nil
|
|
}
|
|
|
|
// fetchAll makes a call on the API. If there are any "Next" links, it calls
|
|
// them in succession until the end has been reached.
|
|
func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
|
var releases []ocds.Release
|
|
|
|
res, err := ft.fetchWithOpts(opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
releasePackage, err := safeUnpackReleases(ft.logger(), res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
releases = append(releases, releasePackage.Releases...)
|
|
|
|
// Next links already followed, so a page that links back to an earlier
|
|
// one cannot loop forever.
|
|
seen := make(map[string]bool)
|
|
for {
|
|
if releasePackage.Links != nil && releasePackage.Links.Next != "" {
|
|
if seen[releasePackage.Links.Next] {
|
|
ft.logger().Warn(
|
|
"stopping at repeated next link",
|
|
"link", releasePackage.Links.Next,
|
|
)
|
|
break
|
|
}
|
|
seen[releasePackage.Links.Next] = true
|
|
}
|
|
|
|
res, err := ft.fetchNext(releasePackage, opts.AllowRateLimit)
|
|
if errors.Is(err, ErrNoNext) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
releasePackage, err = safeUnpackReleases(ft.logger(), res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
releases = append(releases, releasePackage.Releases...)
|
|
}
|
|
|
|
if len(releases) == 0 {
|
|
return nil, ErrNoReleases
|
|
}
|
|
|
|
return releases, nil
|
|
}
|