feat(api): add relative time support to v2 scores endpoint
- Add parseRelativeTime function supporting "-3d", "-2h", "-30m" format - Update parseTimeRangeParams to handle Unix timestamps and relative times - Add unit tests with comprehensive coverage for all time formats - Document v2 API in API.md with examples and migration guide Enables intuitive time queries like from=-3d&to=-1h instead of Unix timestamps, improving developer experience for the enhanced v2 endpoint that supports 50k records vs legacy 10k limit.
This commit is contained in:
@@ -2,7 +2,9 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,6 +44,55 @@ type timeRangeParams struct {
|
||||
}
|
||||
|
||||
// parseTimeRangeParams parses and validates time range parameters
|
||||
// parseRelativeTime parses relative time expressions like "-3d", "-2h", "-30m"
|
||||
// Returns the absolute time relative to the provided base time (usually time.Now())
|
||||
func parseRelativeTime(relativeTimeStr string, baseTime time.Time) (time.Time, error) {
|
||||
if relativeTimeStr == "" {
|
||||
return time.Time{}, fmt.Errorf("empty time string")
|
||||
}
|
||||
|
||||
// Check if it's a regular Unix timestamp first
|
||||
if unixTime, err := strconv.ParseInt(relativeTimeStr, 10, 64); err == nil {
|
||||
return time.Unix(unixTime, 0), nil
|
||||
}
|
||||
|
||||
// Parse relative time format like "-3d", "-2h", "-30m", "-5s"
|
||||
re := regexp.MustCompile(`^(-?)(\d+)([dhms])$`)
|
||||
matches := re.FindStringSubmatch(relativeTimeStr)
|
||||
if len(matches) != 4 {
|
||||
return time.Time{}, fmt.Errorf("invalid time format, expected Unix timestamp or relative format like '-3d', '-2h', '-30m', '-5s'")
|
||||
}
|
||||
|
||||
sign := matches[1]
|
||||
valueStr := matches[2]
|
||||
unit := matches[3]
|
||||
|
||||
value, err := strconv.Atoi(valueStr)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("invalid numeric value: %s", valueStr)
|
||||
}
|
||||
|
||||
var duration time.Duration
|
||||
switch unit {
|
||||
case "s":
|
||||
duration = time.Duration(value) * time.Second
|
||||
case "m":
|
||||
duration = time.Duration(value) * time.Minute
|
||||
case "h":
|
||||
duration = time.Duration(value) * time.Hour
|
||||
case "d":
|
||||
duration = time.Duration(value) * 24 * time.Hour
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("invalid time unit: %s", unit)
|
||||
}
|
||||
|
||||
// Apply sign (negative means go back in time)
|
||||
if sign == "-" {
|
||||
return baseTime.Add(-duration), nil
|
||||
}
|
||||
return baseTime.Add(duration), nil
|
||||
}
|
||||
|
||||
func (srv *Server) parseTimeRangeParams(ctx context.Context, c echo.Context, server ntpdb.Server) (timeRangeParams, error) {
|
||||
log := logger.FromContext(ctx)
|
||||
|
||||
@@ -56,29 +107,28 @@ func (srv *Server) parseTimeRangeParams(ctx context.Context, c echo.Context, ser
|
||||
maxDataPoints: 50000, // default
|
||||
}
|
||||
|
||||
// Parse from timestamp (required)
|
||||
// Parse from timestamp (required) - supports Unix timestamps and relative time like "-3d"
|
||||
fromParam := c.QueryParam("from")
|
||||
if fromParam == "" {
|
||||
return timeRangeParams{}, echo.NewHTTPError(http.StatusBadRequest, "from parameter is required")
|
||||
}
|
||||
|
||||
fromSec, err := strconv.ParseInt(fromParam, 10, 64)
|
||||
now := time.Now()
|
||||
trParams.from, err = parseRelativeTime(fromParam, now)
|
||||
if err != nil {
|
||||
return timeRangeParams{}, echo.NewHTTPError(http.StatusBadRequest, "invalid from timestamp format")
|
||||
return timeRangeParams{}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid from parameter: %v", err))
|
||||
}
|
||||
trParams.from = time.Unix(fromSec, 0)
|
||||
|
||||
// Parse to timestamp (required)
|
||||
// Parse to timestamp (required) - supports Unix timestamps and relative time like "-1d"
|
||||
toParam := c.QueryParam("to")
|
||||
if toParam == "" {
|
||||
return timeRangeParams{}, echo.NewHTTPError(http.StatusBadRequest, "to parameter is required")
|
||||
}
|
||||
|
||||
toSec, err := strconv.ParseInt(toParam, 10, 64)
|
||||
trParams.to, err = parseRelativeTime(toParam, now)
|
||||
if err != nil {
|
||||
return timeRangeParams{}, echo.NewHTTPError(http.StatusBadRequest, "invalid to timestamp format")
|
||||
return timeRangeParams{}, echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("invalid to parameter: %v", err))
|
||||
}
|
||||
trParams.to = time.Unix(toSec, 0)
|
||||
|
||||
// Validate time range
|
||||
if trParams.from.Equal(trParams.to) || trParams.from.After(trParams.to) {
|
||||
|
||||
119
server/grafana_test.go
Normal file
119
server/grafana_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseRelativeTime(t *testing.T) {
|
||||
// Use a fixed base time for consistent testing
|
||||
baseTime := time.Date(2025, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Time
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "Unix timestamp",
|
||||
input: "1753500964",
|
||||
expected: time.Unix(1753500964, 0),
|
||||
},
|
||||
{
|
||||
name: "3 days ago",
|
||||
input: "-3d",
|
||||
expected: baseTime.Add(-3 * 24 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "2 hours ago",
|
||||
input: "-2h",
|
||||
expected: baseTime.Add(-2 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "30 minutes ago",
|
||||
input: "-30m",
|
||||
expected: baseTime.Add(-30 * time.Minute),
|
||||
},
|
||||
{
|
||||
name: "5 seconds ago",
|
||||
input: "-5s",
|
||||
expected: baseTime.Add(-5 * time.Second),
|
||||
},
|
||||
{
|
||||
name: "3 days in future",
|
||||
input: "3d",
|
||||
expected: baseTime.Add(3 * 24 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "1 hour in future",
|
||||
input: "1h",
|
||||
expected: baseTime.Add(1 * time.Hour),
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
input: "invalid",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid unit",
|
||||
input: "3x",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "no number",
|
||||
input: "-d",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseRelativeTime(tt.input, baseTime)
|
||||
|
||||
if tt.shouldError {
|
||||
if err == nil {
|
||||
t.Errorf("parseRelativeTime(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("parseRelativeTime(%q) unexpected error: %v", tt.input, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !result.Equal(tt.expected) {
|
||||
t.Errorf("parseRelativeTime(%q) = %v, expected %v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRelativeTimeEdgeCases(t *testing.T) {
|
||||
baseTime := time.Date(2025, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// Test large values
|
||||
result, err := parseRelativeTime("365d", baseTime)
|
||||
if err != nil {
|
||||
t.Errorf("parseRelativeTime('365d') unexpected error: %v", err)
|
||||
}
|
||||
expected := baseTime.Add(365 * 24 * time.Hour)
|
||||
if !result.Equal(expected) {
|
||||
t.Errorf("parseRelativeTime('365d') = %v, expected %v", result, expected)
|
||||
}
|
||||
|
||||
// Test zero values
|
||||
result, err = parseRelativeTime("0s", baseTime)
|
||||
if err != nil {
|
||||
t.Errorf("parseRelativeTime('0s') unexpected error: %v", err)
|
||||
}
|
||||
if !result.Equal(baseTime) {
|
||||
t.Errorf("parseRelativeTime('0s') = %v, expected %v", result, baseTime)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user