/* Copyright © 2026 Craig Williams 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 . */ // 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 /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 }