Files
job-search/cmd/mock-fts/main.go
T
bwilliams 67215a6aaa Added CODE_CONVENTIONS.md and linters to enforce it, and applied it across the codebase
CODE_CONVENTIONS.md sets out the conventions for Go code in this
repository: naming, layout, comments, errors, logging, configuration and
tests. .golangci.json enables the linters that check the parts that can be
checked (each limited to the rules the conventions use); the rest is
applied by hand.

Applied across the codebase:
- Naming: full-word names in application code, initialisms in one case
  (BuyerID, PrintJSON, PrintYAML, ocdsGetBuyerID), no names shadowing
  built-ins (min/max are minGap/maxGap), mustRaw renamed rawJSON as it
  returns nil rather than panicking, and config.ConfigLevelToLogLevel
  renamed LevelToLogLevel.
- Layout: lines kept to 80 characters where possible, assignments taken
  out of if and for statements that don't fit, wrapped arguments all on
  one line or one per line, steps separated by blank lines, no else after
  a return, function and struct literals wrapped.
- Comments: doc comments on every exported name (including each section's
  Map, Flatten, TypeOf, ValueOf and SetValue), section banners, British
  spelling.
- Errors: a created error starts with its package name, a wrapping error
  adds only what was being done, sentinels in one commented var block,
  errors.Is and errors.As in place of == and type assertions, and no
  err := shadowing of a named result. That fixes the mock server losing
  its access log's close error.
- Logging: the fetcher passes its workers an untagged logger, so
  findatender's lines are tagged with their own component rather than
  with fetcher's as well; cmd/mock-fts logs "stopped" with slog.
- Configuration: util.WithTimeout (moved from the websocket package)
  applies a timeout of 0 or less as no limit, used for the websocket
  write and ping, TCP close and tcpserver shutdown timeouts; every setting
  documents what 0 or empty means, including the rate limit rule's keys
  in internal/config/README.md.
- Tests: related tests merged into table-driven ones with named cases.

Separately from CODE_CONVENTIONS.md, internal/store/README.md gains a soft
SQL style convention for the queries in internal/store/queries.go, and the
queries are reformatted to follow it.
2026-10-15 16:26:42 +01:00

351 lines
9.5 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"
"log/slog"
"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.
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 {
return opts.validate()
},
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",
"http://localhost:8080",
"public base URL used in uri and links.next",
)
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
}
// 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.
out := os.Stdout
if opts.accessLogPath != "-" {
var logFile *os.File
logFile, err = os.OpenFile(
opts.accessLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600,
)
if err != nil {
cancel()
<-workerDone
return fmt.Errorf("opening access log: %w", err)
}
defer func() {
err = errors.Join(err, logFile.Close())
}()
out = logFile
}
accessLogger := log.New(out, "", 0)
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,
"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
}