ultisuite-backend/internal/api/query/query.go
R3D347HR4Y cd0a80f5e8 huhu
2026-05-25 13:52:27 +02:00

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
}