Files
job-search/internal/mock/findatender/handler.go
T
bwilliams 465358609a Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names
Short and single-letter names in application code become full words (tests
keep theirs). Wrapped calls, signatures and composite literals put one item
per line, and one-line function bodies are expanded. Exported names and
groups of constants get doc comments, and comments refer to the renamed
identifiers.
2026-10-15 16:12:56 +01:00

219 lines
6.2 KiB
Go

package mock
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.avsdev.uk/Go/job-search/internal/mock/findatender/ocds"
)
// Paging and request parameters of the release package endpoint.
const (
// defaultLimit is the page size when the request sets no limit.
defaultLimit = 100
// maxLimit is the largest page size a request may ask for.
maxLimit = 100
// timeLayout is the documented layout of updatedFrom and updatedTo; see
// parseTime for the others accepted.
timeLayout = "2006-01-02T15:04:05"
// BasePath is the path the endpoint is served at.
BasePath = "/api/1.0/ocdsReleasePackages"
)
// server serves release packages from a Store; see NewHandler.
type server struct {
store *Store
baseURL string
loc *time.Location // zone for updatedFrom/updatedTo without an offset; nil means UTC
opts options
}
// NewHandler serves release packages from store. baseURL is the public base
// URL used in each package's uri and links.next. loc is the time zone that
// updatedFrom and updatedTo are read in when they carry no offset, as the
// documented yyyy-MM-ddTHH:mm:ss format doesn't (Europe/London for FTS).
// Stored release dates are UTC; comparisons are on absolute instants. It
// logs to slog.Default() unless given WithLogger.
func NewHandler(
store *Store,
baseURL string,
location *time.Location,
opts ...Option,
) http.HandlerFunc {
handler := &server{
store: store,
baseURL: baseURL,
loc: location,
opts: applyOptions(opts),
}
return handler.handle
}
// encodeCursor makes the opaque cursor for a next link that resumes at store
// position i.
func encodeCursor(i int) string {
return base64.RawURLEncoding.EncodeToString([]byte("idx:" + strconv.Itoa(i)))
}
// decodeCursor returns the release index in a cursor made by encodeCursor.
// Anything else, including a negative index, is an error.
func decodeCursor(cursor string) (int, error) {
decoded, err := base64.RawURLEncoding.DecodeString(cursor)
if err != nil || !strings.HasPrefix(string(decoded), "idx:") {
return 0, fmt.Errorf("invalid cursor")
}
index, err := strconv.Atoi(strings.TrimPrefix(string(decoded), "idx:"))
if err != nil || index < 0 {
return 0, fmt.Errorf("invalid cursor")
}
return index, nil
}
func badRequest(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
body := map[string]string{"error": msg}
json.NewEncoder(w).Encode(body) //nolint:errcheck,gosec // the client may have gone; nothing to do
}
// parseTime reads a query timestamp. Values without an offset (the documented
// yyyy-MM-ddTHH:mm:ss, or a plain date) are local time in location; values with an
// offset or Z (RFC 3339) mean exactly what they say.
func parseTime(value string, location *time.Location) (time.Time, error) {
if location == nil {
location = time.UTC
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
for _, layout := range []string{timeLayout, "2006-01-02"} {
if parsed, err := time.ParseInLocation(layout, value, location); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("must be in format yyyy-MM-ddTHH:mm:ss")
}
// pageQuery is a parsed release package request.
type pageQuery struct {
from, upper time.Time // releases dated in [from, upper]
start int // index of the first release to return
limit int
}
// handle answers a GET for a page of releases as an OCDS release package,
// with a links.next when more releases match.
func (s *server) handle(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
query := req.URL.Query()
page, err := s.parseQuery(query)
if err != nil {
badRequest(w, err.Error())
return
}
releases, next, more := s.store.Page(page.from, page.upper, page.start, page.limit)
releasePackage := ocds.ReleasePackage{
URI: s.baseURL + req.URL.RequestURI(),
Version: "1.1",
Extensions: ocds.DeclaredExtensions,
PublishedDate: ptr(time.Now().UTC().Truncate(time.Second)),
Publisher: &ocds.ReleasePackagePublisher{
Name: "OCDS Mock",
URI: s.baseURL,
},
Releases: releases,
}
if more {
releasePackage.Links = &ocds.ReleasePackageLinks{
Next: s.nextLink(query, next),
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
encoder.SetEscapeHTML(false)
if err := encoder.Encode(releasePackage); err != nil {
s.opts.logger("handler").Warn("writing response failed", "error", err)
}
}
// parseQuery reads and checks the query parameters. Its errors are meant for
// the client.
func (s *server) parseQuery(query url.Values) (pageQuery, error) {
page := pageQuery{
limit: defaultLimit,
upper: time.Now().UTC(),
}
if value := query.Get("limit"); value != "" {
number, err := strconv.Atoi(value)
if err != nil || number < 1 || number > maxLimit {
return page, fmt.Errorf("limit must be an integer between 1 and %d", maxLimit)
}
page.limit = number
}
if value := query.Get("updatedFrom"); value != "" {
from, err := parseTime(value, s.loc)
if err != nil {
return page, fmt.Errorf("updatedFrom %w", err)
}
page.from = from
}
if value := query.Get("updatedTo"); value != "" {
to, err := parseTime(value, s.loc)
if err != nil {
return page, fmt.Errorf("updatedTo %w", err)
}
if query.Get("updatedFrom") != "" && to.Before(page.from) {
return page, errors.New("updatedTo must not be before updatedFrom")
}
if to.Before(page.upper) {
page.upper = to
}
}
if cursor := query.Get("cursor"); cursor != "" {
index, err := decodeCursor(cursor)
if err != nil {
return page, err
}
page.start = index
}
return page, nil
}
// nextLink is the URL of the page starting at release index next: the same
// query with a new cursor.
func (s *server) nextLink(query url.Values, next int) string {
nextQuery := url.Values{}
for key, values := range query {
if key != "cursor" {
nextQuery[key] = values
}
}
nextQuery.Set("cursor", encodeCursor(next))
return s.baseURL + BasePath + "?" + nextQuery.Encode()
}