199 lines
4.6 KiB
Go
199 lines
4.6 KiB
Go
package query
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultPage = 1
|
|
DefaultPageSize = 50
|
|
MaxPageSize = 500
|
|
|
|
dateLayout = "2006-01-02"
|
|
)
|
|
|
|
// ListParams holds normalized list query parameters.
|
|
type ListParams struct {
|
|
Page int
|
|
PageSize int
|
|
Q string
|
|
Sort string
|
|
From *time.Time
|
|
To *time.Time
|
|
}
|
|
|
|
// Offset returns the SQL/list offset derived from page and page_size.
|
|
func (p ListParams) Offset() int {
|
|
return (p.Page - 1) * p.PageSize
|
|
}
|
|
|
|
// Limit returns the page size as a fetch limit.
|
|
func (p ListParams) Limit() int {
|
|
return p.PageSize
|
|
}
|
|
|
|
// PaginationMeta describes list pagination in API responses.
|
|
type PaginationMeta struct {
|
|
Page int `json:"page"`
|
|
PageSize int `json:"page_size"`
|
|
Total *int64 `json:"total,omitempty"`
|
|
}
|
|
|
|
// Meta builds response pagination metadata for the current params.
|
|
func (p ListParams) Meta(total *int64) PaginationMeta {
|
|
return PaginationMeta{
|
|
Page: p.Page,
|
|
PageSize: p.PageSize,
|
|
Total: total,
|
|
}
|
|
}
|
|
|
|
// FieldDetail identifies a single invalid query parameter.
|
|
type FieldDetail struct {
|
|
Field string `json:"field"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ValidationError reports invalid query parameters using the standard API error shape.
|
|
type ValidationError struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
Details []FieldDetail `json:"details,omitempty"`
|
|
}
|
|
|
|
func (e *ValidationError) Error() string {
|
|
if len(e.Details) == 0 {
|
|
return e.Message
|
|
}
|
|
parts := make([]string, len(e.Details))
|
|
for i, d := range e.Details {
|
|
parts[i] = fmt.Sprintf("%s: %s", d.Field, d.Message)
|
|
}
|
|
return e.Message + ": " + strings.Join(parts, "; ")
|
|
}
|
|
|
|
// ParseDate parses a YYYY-MM-DD date in UTC at midnight.
|
|
func ParseDate(raw string) (time.Time, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return time.Time{}, fmt.Errorf("empty date")
|
|
}
|
|
t, err := time.ParseInLocation(dateLayout, raw, time.UTC)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("expected YYYY-MM-DD")
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// ParseList parses list query parameters from url.Values.
|
|
func ParseList(values url.Values) (ListParams, error) {
|
|
var params ListParams
|
|
var details []FieldDetail
|
|
|
|
page, pageErr := parsePage(values.Get("page"))
|
|
if pageErr != nil {
|
|
details = append(details, FieldDetail{Field: "page", Message: pageErr.Error()})
|
|
} else {
|
|
params.Page = page
|
|
}
|
|
|
|
pageSize, sizeErr := parsePageSize(values.Get("page_size"))
|
|
if sizeErr != nil {
|
|
details = append(details, FieldDetail{Field: "page_size", Message: sizeErr.Error()})
|
|
} else {
|
|
params.PageSize = pageSize
|
|
}
|
|
|
|
params.Q = strings.TrimSpace(values.Get("q"))
|
|
params.Sort = strings.TrimSpace(values.Get("sort"))
|
|
|
|
fromRaw := strings.TrimSpace(values.Get("from"))
|
|
toRaw := strings.TrimSpace(values.Get("to"))
|
|
|
|
if fromRaw != "" {
|
|
from, err := ParseDate(fromRaw)
|
|
if err != nil {
|
|
details = append(details, FieldDetail{Field: "from", Message: err.Error()})
|
|
} else {
|
|
params.From = &from
|
|
}
|
|
}
|
|
|
|
if toRaw != "" {
|
|
to, err := ParseDate(toRaw)
|
|
if err != nil {
|
|
details = append(details, FieldDetail{Field: "to", Message: err.Error()})
|
|
} else {
|
|
end := to.Add(24*time.Hour - time.Nanosecond)
|
|
params.To = &end
|
|
}
|
|
}
|
|
|
|
if params.From != nil && params.To != nil && params.From.After(*params.To) {
|
|
details = append(details, FieldDetail{
|
|
Field: "from",
|
|
Message: "must be on or before to",
|
|
})
|
|
}
|
|
|
|
if len(details) > 0 {
|
|
return ListParams{}, &ValidationError{
|
|
Code: "invalid_query_param",
|
|
Message: "invalid query parameters",
|
|
Details: details,
|
|
}
|
|
}
|
|
|
|
return params, nil
|
|
}
|
|
|
|
// ParseListRequest parses list query parameters from an HTTP request.
|
|
func ParseListRequest(r *http.Request) (ListParams, error) {
|
|
if r == nil {
|
|
return ListParams{}, &ValidationError{
|
|
Code: "invalid_query_param",
|
|
Message: "invalid query parameters",
|
|
Details: []FieldDetail{{Field: "request", Message: "missing request"}},
|
|
}
|
|
}
|
|
return ParseList(r.URL.Query())
|
|
}
|
|
|
|
func parsePage(raw string) (int, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return DefaultPage, nil
|
|
}
|
|
page, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("must be a positive integer")
|
|
}
|
|
if page < 1 {
|
|
return 0, fmt.Errorf("must be at least 1")
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
func parsePageSize(raw string) (int, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return DefaultPageSize, nil
|
|
}
|
|
size, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("must be a positive integer")
|
|
}
|
|
if size < 1 {
|
|
return 0, fmt.Errorf("must be at least 1")
|
|
}
|
|
if size > MaxPageSize {
|
|
return 0, fmt.Errorf("must be at most %d", MaxPageSize)
|
|
}
|
|
return size, nil
|
|
}
|