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(firstNonEmpty(values.Get("page_size"), values.Get("limit"))) 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 } func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } // 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 }