Files
job-search/internal/mock/findATender/handler.go
T

192 lines
5.0 KiB
Go

package mock
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.avsdev.uk/Go/job-search/internal/mock/findATender/ocds"
)
const (
defaultLimit = 100
maxLimit = 100
timeLayout = "2006-01-02T15:04:05"
BasePath = "/api/1.0/ocdsReleasePackages"
)
type server struct {
store *Store
baseURL string
loc *time.Location // zone for updatedFrom/updatedTo without an offset; nil means UTC
}
// 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.
func NewHandler(
store *Store, baseURL string, loc *time.Location,
) http.HandlerFunc {
return (&server{store: store, baseURL: baseURL, loc: loc}).handle
}
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(c string) (int, error) {
b, err := base64.RawURLEncoding.DecodeString(c)
if err != nil || !strings.HasPrefix(string(b), "idx:") {
return 0, fmt.Errorf("invalid cursor")
}
n, err := strconv.Atoi(strings.TrimPrefix(string(b), "idx:"))
if err != nil || n < 0 {
return 0, fmt.Errorf("invalid cursor")
}
return n, nil
}
func badRequest(w http.ResponseWriter, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// parseTime reads a query timestamp. Values without an offset (the documented
// yyyy-MM-ddTHH:mm:ss, or a plain date) are local time in loc; values with an
// offset or Z (RFC 3339) mean exactly what they say.
func parseTime(v string, loc *time.Location) (time.Time, error) {
if loc == nil {
loc = time.UTC
}
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
for _, layout := range []string{timeLayout, "2006-01-02"} {
if t, err := time.ParseInLocation(layout, v, loc); err == nil {
return t, 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
}
func (s *server) handle(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
q := req.URL.Query()
pq, err := s.parseQuery(q)
if err != nil {
badRequest(w, err.Error())
return
}
releases, next, more := s.store.Page(pq.from, pq.upper, pq.start, pq.limit)
pkg := 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 {
pkg.Links = &ocds.ReleasePackageLinks{Next: s.nextLink(q, next)}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Access-Control-Allow-Origin", "*")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
enc.SetEscapeHTML(false)
if err := enc.Encode(pkg); err != nil {
log.Printf("handler: writing response: %v", err)
}
}
// parseQuery reads and checks the query parameters. Its errors are meant for
// the client.
func (s *server) parseQuery(q url.Values) (pageQuery, error) {
pq := pageQuery{limit: defaultLimit, upper: time.Now().UTC()}
if v := q.Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > maxLimit {
return pq, fmt.Errorf("limit must be an integer between 1 and %d", maxLimit)
}
pq.limit = n
}
if v := q.Get("updatedFrom"); v != "" {
t, err := parseTime(v, s.loc)
if err != nil {
return pq, fmt.Errorf("updatedFrom %w", err)
}
pq.from = t
}
if v := q.Get("updatedTo"); v != "" {
t, err := parseTime(v, s.loc)
if err != nil {
return pq, fmt.Errorf("updatedTo %w", err)
}
if q.Get("updatedFrom") != "" && t.Before(pq.from) {
return pq, errors.New("updatedTo must not be before updatedFrom")
}
if t.Before(pq.upper) {
pq.upper = t
}
}
if c := q.Get("cursor"); c != "" {
n, err := decodeCursor(c)
if err != nil {
return pq, err
}
pq.start = n
}
return pq, nil
}
// nextLink is the URL of the page starting at release index next: the same
// query with a new cursor.
func (s *server) nextLink(q url.Values, next int) string {
nq := url.Values{}
for k, vs := range q {
if k != "cursor" {
nq[k] = vs
}
}
nq.Set("cursor", encodeCursor(next))
return s.baseURL + BasePath + "?" + nq.Encode()
}