From dd4bc50f7842bb4baf6e565f6712028644c4d98a Mon Sep 17 00:00:00 2001 From: "Gerasimos (Makis) Maropoulos" Date: Fri, 12 Aug 2022 00:05:44 +0300 Subject: [PATCH] improvements on the x/jsonx/iso8601 time format --- x/jsonx/iso8601.go | 99 ++++++++++++++++++++++++++++++++++++++++- x/jsonx/iso8601_test.go | 81 +++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 x/jsonx/iso8601_test.go diff --git a/x/jsonx/iso8601.go b/x/jsonx/iso8601.go index c5d22b8e..8fc26512 100644 --- a/x/jsonx/iso8601.go +++ b/x/jsonx/iso8601.go @@ -1,18 +1,34 @@ package jsonx import ( + "database/sql/driver" + "errors" "fmt" "strconv" "strings" "time" ) +var fixedEastUTCLocations = make(map[int]*time.Location) + +func registerFixedEastUTCLocation(name string, secondsFromUTC int) { + loc := time.FixedZone(name, secondsFromUTC) + fixedEastUTCLocations[secondsFromUTC] = loc +} + +func init() { + registerFixedEastUTCLocation("EEST", 3*60*60) // + 3 hours. +} + const ( // ISO8601Layout holds the time layout for the the javascript iso time. // Read more at: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString. ISO8601Layout = "2006-01-02T15:04:05" // ISO8601ZLayout same as ISO8601Layout but with the timezone suffix. ISO8601ZLayout = "2006-01-02T15:04:05Z" + // ISO8601ZUTCOffsetLayout ISO 8601 format, with full time and zone with UTC offset. + // Example: 2022-08-10T03:21:00.000000+03:00. + ISO8601ZUTCOffsetLayout = "2006-01-02T15:04:05.999999Z07:00" ) // ISO8601 describes a time compatible with javascript time format. @@ -29,7 +45,28 @@ func ParseISO8601(s string) (ISO8601, error) { err error ) - if s[len(s)-1] == 'Z' { + if idx := strings.LastIndexFunc(s, startUTCOffsetIndexFunc); idx > 20 /* should have some distance, e.g. 26 */ { + length := parseSignedOffset(s[idx:]) + + if idx+1 > idx+length || len(s) <= idx+length+1 { + return ISO8601{}, fmt.Errorf("ISO8601: invalid timezone format: %s", s[idx:]) + } + + offsetText := s[idx+1 : idx+length] + offset, parseErr := strconv.Atoi(offsetText) + if parseErr != nil { + return ISO8601{}, err + } + + // E.g. offset of +0300 is returned as 10800 which is - (3 * 60 * 60). + secondsEastUTC := offset * 60 * 60 + + if loc, ok := fixedEastUTCLocations[secondsEastUTC]; ok { // Specific (fixed) zone. + tt, err = time.ParseInLocation(ISO8601ZUTCOffsetLayout, s, loc) + } else { // Local or UTC. + tt, err = time.Parse(ISO8601ZUTCOffsetLayout, s) + } + } else if s[len(s)-1] == 'Z' { tt, err = time.Parse(ISO8601ZLayout, s) } else { tt, err = time.Parse(ISO8601Layout, s) @@ -39,7 +76,7 @@ func ParseISO8601(s string) (ISO8601, error) { return ISO8601{}, err } - return ISO8601(tt.UTC()), nil + return ISO8601(tt), nil } // UnmarshalJSON parses the "b" into ISO8601 time. @@ -90,6 +127,11 @@ func (t ISO8601) String() string { return tt.Format(ISO8601Layout) } +// Value returns the database value of time.Time. +func (t ISO8601) Value() (driver.Value, error) { + return time.Time(t), nil +} + // Scan completes the sql driver.Scanner interface. func (t *ISO8601) Scan(src interface{}) error { switch v := src.(type) { @@ -104,6 +146,8 @@ func (t *ISO8601) Scan(src interface{}) error { return err } *t = tt + case []byte: + return t.Scan(string(v)) case nil: *t = ISO8601(time.Time{}) default: @@ -112,3 +156,54 @@ func (t *ISO8601) Scan(src interface{}) error { return nil } + +// parseSignedOffset parses a signed timezone offset (e.g. "+03" or "-04"). +// The function checks for a signed number in the range -23 through +23 excluding zero. +// Returns length of the found offset string or 0 otherwise. +// +// Language internal function. +func parseSignedOffset(value string) int { + sign := value[0] + if sign != '-' && sign != '+' { + return 0 + } + x, rem, err := leadingInt(value[1:]) + + // fail if nothing consumed by leadingInt + if err != nil || value[1:] == rem { + return 0 + } + if x > 23 { + return 0 + } + return len(value) - len(rem) +} + +var errLeadingInt = errors.New("ISO8601: time: bad [0-9]*") // never printed. + +// leadingInt consumes the leading [0-9]* from s. +// +// Language internal function. +func leadingInt(s string) (x uint64, rem string, err error) { + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + if x > 1<<63/10 { + // overflow + return 0, "", errLeadingInt + } + x = x*10 + uint64(c) - '0' + if x > 1<<63 { + // overflow + return 0, "", errLeadingInt + } + } + return x, s[i:], nil +} + +func startUTCOffsetIndexFunc(char rune) bool { + return char == '+' || char == '-' +} diff --git a/x/jsonx/iso8601_test.go b/x/jsonx/iso8601_test.go new file mode 100644 index 00000000..cba8defd --- /dev/null +++ b/x/jsonx/iso8601_test.go @@ -0,0 +1,81 @@ +package jsonx + +import ( + "encoding/json" + "testing" + "time" +) + +func TestISO8601(t *testing.T) { + data := `{"start": "2021-08-20T10:05:01", "end": "2021-12-01T17:05:06", "nothing": null, "empty": ""}` + v := struct { + Start ISO8601 `json:"start"` + End ISO8601 `json:"end"` + Nothing ISO8601 `json:"nothing"` + Empty ISO8601 `json:"empty"` + }{} + err := json.Unmarshal([]byte(data), &v) + if err != nil { + t.Fatal(err) + } + + if !v.Nothing.IsZero() { + t.Fatalf("expected 'nothing' to be zero but got: %v", v.Nothing) + } + + if !v.Empty.IsZero() { + t.Fatalf("expected 'empty' to be zero but got: %v", v.Empty) + } + + loc := time.UTC + + if expected, got := time.Date(2021, time.August, 20, 10, 5, 1, 0, loc), v.Start.ToTime(); expected != got { + t.Fatalf("expected 'start' to be: %v but got: %v", expected, got) + } + + if expected, got := time.Date(2021, time.December, 1, 17, 5, 6, 0, loc), v.End.ToTime(); expected != got { + t.Fatalf("expected 'start' to be: %v but got: %v", expected, got) + } +} + +func TestISO8601WithZoneUTCOffset(t *testing.T) { + data := `{"start": "2022-08-10T03:21:00.000000+03:00", "end": "2022-08-10T09:49:00.000000+03:00", "nothing": null, "empty": ""}` + v := struct { + Start ISO8601 `json:"start"` + End ISO8601 `json:"end"` + Nothing ISO8601 `json:"nothing"` + Empty ISO8601 `json:"empty"` + }{} + err := json.Unmarshal([]byte(data), &v) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + // t.Logf("Start: %s, location: %s\n", v.Start.String(), v.Start.ToTime().Location().String()) + + if !v.Nothing.IsZero() { + t.Fatalf("expected 'nothing' to be zero but got: %v", v.Nothing) + } + + if !v.Empty.IsZero() { + t.Fatalf("expected 'empty' to be zero but got: %v", v.Empty) + } + + loc := time.FixedZone("EEST", 10800) + + if expected, got := time.Date(2022, time.August, 10, 3, 21, 0, 0, loc).String(), v.Start.ToTime().String(); expected != got { + t.Fatalf("expected 'start' string to be: %v but got: %v", expected, got) + } + + if expected, got := time.Date(2022, time.August, 10, 9, 49, 0, 0, loc).String(), v.End.ToTime().String(); expected != got { + t.Fatalf("expected 'start' string to be: %v but got: %v", expected, got) + } + + if expected, got := time.Date(2022, time.August, 10, 3, 21, 0, 0, loc), v.Start.ToTime().In(loc); expected != got { + t.Fatalf("expected 'start' to be: %v but got: %v", expected, got) + } + + if expected, got := time.Date(2022, time.August, 10, 9, 49, 0, 0, loc), v.End.ToTime().In(loc); expected != got { + t.Fatalf("expected 'start' to be: %v but got: %v", expected, got) + } +}