198 lines
5.3 KiB
Go
198 lines
5.3 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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(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)
|
|
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 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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()
|
|
}
|