381 lines
10 KiB
Go
381 lines
10 KiB
Go
/*
|
|
Copyright © 2026 Craig Williams <craig@avsdev.uk>
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Lesser General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Lesser General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Lesser General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
// Mock of the Find a Tender (FTS) OCDS release package endpoint:
|
|
//
|
|
// GET /api/1.0/ocdsReleasePackages?updatedFrom=2026-10-06T00:00:00&limit=5
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
_ "time/tzdata"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/logging"
|
|
mock "git.avsdev.uk/Go/job-search/internal/mock/findatender"
|
|
)
|
|
|
|
// options holds the values of every command-line flag.
|
|
type options struct {
|
|
addr string
|
|
baseURL string
|
|
dataDir string
|
|
reset bool
|
|
backfill time.Duration
|
|
minGap time.Duration
|
|
maxGap time.Duration
|
|
rateLimit int
|
|
rateWindow time.Duration
|
|
accessLogPath string
|
|
logFormat string
|
|
timeZone string
|
|
}
|
|
|
|
const longHelp = `Mock of the Find a Tender (FTS) OCDS release package endpoint:
|
|
|
|
GET /api/1.0/ocdsReleasePackages?updatedFrom=2026-10-06T00:00:00&limit=5
|
|
|
|
Query parameters (mirroring the real service):
|
|
|
|
updatedFrom yyyy-MM-ddTHH:mm:ss releases updated at or after this time
|
|
updatedTo yyyy-MM-ddTHH:mm:ss releases updated at or before this time (optional)
|
|
limit 1..100 (default 100) page size
|
|
cursor opaque token from links.next
|
|
|
|
Like the real service, updatedFrom and updatedTo are UK local time
|
|
(Europe/London, so BST in summer); change this with --tz. Values with an
|
|
explicit offset or Z are taken as given. Release dates are stored and
|
|
returned in UTC.
|
|
|
|
Data: the catalog of buyers, suppliers, tender subjects, procurement methods
|
|
and classifications that releases are generated from is embedded in the
|
|
build from internal/mock/findatender/data/catalog.json. The mock stops with an
|
|
error if it is invalid.
|
|
|
|
Paging: each package's uri and links.next start with --base-url, which
|
|
clients follow as given. Without it, the base URL is taken from --addr, with
|
|
an unspecified host (":8080", "0.0.0.0", "::") as localhost. Set it when
|
|
clients reach the mock by another address, such as through a proxy or a
|
|
mapped container port.
|
|
|
|
Releases are generated from that catalog and appended to
|
|
<data-dir>/releases.jsonl, so they survive restarts. A background worker
|
|
publishes a new release every --min-gap to --max-gap. On startup the mock
|
|
fills any gap since the last stored release, going back at most --backfill
|
|
on a fresh data directory. Use --reset to discard stored releases.
|
|
|
|
Rate limiting: each client (by remote IP) may make --rate-limit requests per
|
|
--rate-window. Windows are fixed and anchored on the client's first request
|
|
of the current clock hour. Over the limit the mock returns 429 Too Many
|
|
Requests, a Retry-After header, and the body
|
|
"Rate limit of N exceeded. Please retry after X seconds."
|
|
|
|
Access logging: every request is written in Apache/nginx "combined" format
|
|
(or "common" with --log-format) to stdout, or to the --access-log file.
|
|
Startup and error messages go to stderr.`
|
|
|
|
func main() {
|
|
// Cancelled on Ctrl+C or SIGTERM; Cobra hands it to the command.
|
|
ctx, stop := signal.NotifyContext(
|
|
context.Background(), os.Interrupt, syscall.SIGTERM,
|
|
)
|
|
defer stop()
|
|
|
|
if err := newRootCmd().ExecuteContext(ctx); err != nil {
|
|
// Cobra has already printed the error.
|
|
stop()
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func newRootCmd() *cobra.Command {
|
|
opts := &options{}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "fts-mock",
|
|
Short: "Mock of the Find a Tender OCDS release package API",
|
|
Long: longHelp,
|
|
Args: cobra.NoArgs,
|
|
SilenceUsage: true,
|
|
Example: `
|
|
fts-mock --addr :8080
|
|
fts-mock --data-dir ./mock-data --reset
|
|
fts-mock --min-gap 5s --max-gap 10s --rate-limit 1000`,
|
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
|
err := opts.validate()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.baseURL == "" {
|
|
opts.baseURL, err = baseURLFromAddr(opts.addr)
|
|
}
|
|
|
|
return err
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return run(cmd.Context(), opts)
|
|
},
|
|
}
|
|
|
|
flags := cmd.Flags()
|
|
flags.SortFlags = false // show flags in the order defined here
|
|
|
|
flags.StringVar(&opts.addr, "addr", ":8080", "listen address")
|
|
flags.StringVar(
|
|
&opts.baseURL,
|
|
"base-url",
|
|
"",
|
|
"public base URL used in uri and links.next (default from --addr)",
|
|
)
|
|
flags.StringVar(
|
|
&opts.dataDir,
|
|
"data-dir",
|
|
"./mock-data",
|
|
"directory where releases.jsonl will be stored",
|
|
)
|
|
|
|
flags.BoolVar(
|
|
&opts.reset,
|
|
"reset",
|
|
false,
|
|
"delete stored releases and regenerate the --backfill history",
|
|
)
|
|
|
|
flags.DurationVar(
|
|
&opts.backfill,
|
|
"backfill",
|
|
72*time.Hour,
|
|
"history to generate when the data directory is empty",
|
|
)
|
|
flags.DurationVar(
|
|
&opts.minGap,
|
|
"min-gap",
|
|
30*time.Second,
|
|
"minimum time between generated releases",
|
|
)
|
|
flags.DurationVar(
|
|
&opts.maxGap,
|
|
"max-gap",
|
|
5*time.Minute,
|
|
"maximum time between generated releases",
|
|
)
|
|
|
|
flags.IntVar(
|
|
&opts.rateLimit, "rate-limit", 12, "requests allowed per window per client",
|
|
)
|
|
flags.DurationVar(
|
|
&opts.rateWindow, "rate-window", 5*time.Minute, "rate limit window length",
|
|
)
|
|
|
|
flags.StringVar(
|
|
&opts.accessLogPath,
|
|
"access-log",
|
|
"-",
|
|
`access log file, or "-" for stdout`,
|
|
)
|
|
flags.StringVar(
|
|
&opts.logFormat,
|
|
"log-format",
|
|
"combined",
|
|
`access log format: "combined" or "common"`,
|
|
)
|
|
flags.StringVar(
|
|
&opts.timeZone,
|
|
"tz",
|
|
"Europe/London",
|
|
`time zone for updatedFrom/updatedTo without an offset ("Local" for this machine's)`,
|
|
)
|
|
|
|
return cmd
|
|
}
|
|
|
|
// validate checks flag values before anything is started.
|
|
func (o *options) validate() error {
|
|
if o.logFormat != "combined" && o.logFormat != "common" {
|
|
return fmt.Errorf(
|
|
`--log-format must be "combined" or "common", got %q`, o.logFormat,
|
|
)
|
|
}
|
|
if o.minGap <= 0 || o.maxGap < o.minGap {
|
|
return errors.New("--min-gap must be positive and no larger than --max-gap")
|
|
}
|
|
if o.rateLimit < 1 || o.rateWindow <= 0 {
|
|
return errors.New(
|
|
"--rate-limit must be at least 1 and --rate-window positive",
|
|
)
|
|
}
|
|
if _, err := time.LoadLocation(o.timeZone); err != nil {
|
|
return fmt.Errorf("--tz: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// baseURLFromAddr returns the base URL clients reach a mock listening on
|
|
// addr by: http://host:port, with localhost for an unspecified host. Port 0
|
|
// is refused, as the port isn't known until the mock is listening.
|
|
func baseURLFromAddr(addr string) (string, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("--addr: %w", err)
|
|
}
|
|
if port == "0" {
|
|
return "", errors.New("--addr with port 0 needs --base-url")
|
|
}
|
|
|
|
switch host {
|
|
case "", "0.0.0.0", "::":
|
|
host = "localhost"
|
|
}
|
|
|
|
return "http://" + net.JoinHostPort(host, port), nil
|
|
}
|
|
|
|
// run starts the mock and blocks until ctx is cancelled or the server fails.
|
|
func run(ctx context.Context, opts *options) (err error) {
|
|
location, err := time.LoadLocation(opts.timeZone) // already validated
|
|
if err != nil {
|
|
return fmt.Errorf("--tz: %w", err)
|
|
}
|
|
|
|
if err = os.MkdirAll(opts.dataDir, 0o750); err != nil {
|
|
return fmt.Errorf("creating data directory: %w", err)
|
|
}
|
|
|
|
// Load the catalog.
|
|
catalog, err := mock.LoadCatalog()
|
|
if err != nil {
|
|
return fmt.Errorf("loading catalog: %w", err)
|
|
}
|
|
slog.Info(
|
|
"catalog loaded",
|
|
"buyers", len(catalog.Buyers),
|
|
"suppliers", len(catalog.Suppliers),
|
|
"subjects", len(catalog.Subjects),
|
|
"methods", len(catalog.Methods),
|
|
"classifications", len(catalog.Classifications),
|
|
)
|
|
|
|
// Open (or reset) the store.
|
|
storePath := filepath.Join(opts.dataDir, mock.ReleasesFile)
|
|
if opts.reset {
|
|
if err = os.Remove(storePath); err != nil &&
|
|
!errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("resetting store: %w", err)
|
|
}
|
|
slog.Info("store reset", "path", storePath)
|
|
}
|
|
store, err := mock.OpenStore(storePath)
|
|
if err != nil {
|
|
return fmt.Errorf("opening store: %w", err)
|
|
}
|
|
defer func() {
|
|
err = errors.Join(err, store.Close())
|
|
}()
|
|
|
|
// Backfill releases, then keep generating them.
|
|
generator := mock.NewGenerator(catalog, store, time.Now().UnixNano())
|
|
generated, err := generator.CatchUp(
|
|
time.Now(), opts.backfill, opts.minGap, opts.maxGap,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("backfilling releases: %w", err)
|
|
}
|
|
|
|
slog.Info("store ready", "releases", store.Len(), "generated", generated)
|
|
|
|
// The generator and server share a context that also ends if the server
|
|
// fails, so the generator is never left running after run returns.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
workerDone := make(chan struct{})
|
|
go func() {
|
|
defer close(workerDone)
|
|
generator.Run(ctx, opts.minGap, opts.maxGap)
|
|
}()
|
|
|
|
// Serve the API.
|
|
accessLogger, closeAccessLog, err := logging.OpenAccessLog(
|
|
opts.accessLogPath,
|
|
)
|
|
if err != nil {
|
|
cancel()
|
|
<-workerDone
|
|
return err
|
|
}
|
|
defer func() {
|
|
err = errors.Join(err, closeAccessLog())
|
|
}()
|
|
|
|
rateLimiter := mock.NewRateLimiter(opts.rateLimit, opts.rateWindow)
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(
|
|
mock.BasePath,
|
|
rateLimiter.Middleware(mock.NewHandler(store, opts.baseURL, location)),
|
|
)
|
|
|
|
httpServer := &http.Server{
|
|
Addr: opts.addr,
|
|
Handler: logging.AccessLog(
|
|
accessLogger, opts.logFormat == "combined", mux,
|
|
),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
go func() {
|
|
<-ctx.Done()
|
|
// ctx is done by now; the shutdown gets its own deadline.
|
|
shutdownCtx, cancelShutdown := context.WithTimeout(
|
|
context.WithoutCancel(ctx), 5*time.Second,
|
|
)
|
|
defer cancelShutdown()
|
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("shutdown failed", "error", err)
|
|
}
|
|
}()
|
|
|
|
slog.Info(
|
|
"mock listening",
|
|
"addr", opts.addr,
|
|
"base_url", opts.baseURL,
|
|
"path", mock.BasePath,
|
|
"time_zone", location.String(),
|
|
)
|
|
serveErr := httpServer.ListenAndServe()
|
|
|
|
cancel() // stop the worker (and the shutdown goroutine) if the server failed
|
|
<-workerDone
|
|
|
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
|
return fmt.Errorf("serving: %w", serveErr)
|
|
}
|
|
slog.Info("stopped")
|
|
|
|
return nil
|
|
}
|