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 // location is the zone for updatedFrom/updatedTo without an offset; nil // means UTC. location *time.Location 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, location: 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"} { parsed, err := time.ParseInLocation(layout, value, location) if 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, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } query := r.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 + r.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.location) if err != nil { return page, fmt.Errorf("updatedFrom %w", err) } page.from = from } if value := query.Get("updatedTo"); value != "" { until, err := parseTime(value, s.location) if err != nil { return page, fmt.Errorf("updatedTo %w", err) } if query.Get("updatedFrom") != "" && until.Before(page.from) { return page, errors.New("updatedTo must not be before updatedFrom") } if until.Before(page.upper) { page.upper = until } } 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() }