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.
351 lines
9.5 KiB
Go
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
|
|
}
|