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() }