Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expand the possible time formats that can be unmarshalled from JSON payloads #60

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 0 additions & 37 deletions azuredevops/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
package azuredevops

import (
"encoding/json"
"strconv"
"time"

"github.com/google/uuid"
)
Expand Down Expand Up @@ -55,41 +53,6 @@ type ResourceAreaInfo struct {
Name *string `json:"name,omitempty"`
}

type Time struct {
Time time.Time
}

func (t *Time) UnmarshalJSON(b []byte) error {
t2 := time.Time{}
err := json.Unmarshal(b, &t2)

// ignore errors for 0001-01-01T00:00:00 dates. The Azure DevOps service
// returns default dates in a format that is invalid for a time.Time. The
// correct value would have a 'z' at the end to represent utc. We are going
// to ignore this error, and set the value to the default time.Time value.
// https://github.com/microsoft/azure-devops-go-api/issues/17
if err != nil {
if parseError, ok := err.(*time.ParseError); ok && parseError.Value == "\"0001-01-01T00:00:00\"" {
err = nil
}
}

t.Time = t2
return err
}

func (t *Time) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Time)
}

func (t Time) String() string {
return t.Time.String()
}

func (t Time) Equal(u Time) bool {
return t.Time.Equal(u.Time)
}

// ServerSystemError
type ServerSystemError struct {
ClassName *string `json:"className,omitempty"`
Expand Down
67 changes: 0 additions & 67 deletions azuredevops/models_test.go

This file was deleted.

99 changes: 99 additions & 0 deletions azuredevops/time.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package azuredevops

import (
"encoding/json"
"fmt"
"strings"
"time"
)

type Time struct {
Time time.Time
}

func tryUnmarshalJSON(b []byte) (time.Time, error) {
t := time.Time{}
err := json.Unmarshal(b, &t)

// ignore errors for 0001-01-01T00:00:00 dates. The Azure DevOps service
// returns default dates in a format that is invalid for a time.Time. The
// correct value would have a 'z' at the end to represent utc. We are going
// to ignore this error, and set the value to the default time.Time value.
// https://github.com/microsoft/azure-devops-go-api/issues/17
if err != nil {
if parseError, ok := err.(*time.ParseError); ok && parseError.Value == "\"0001-01-01T00:00:00\"" {
err = nil
}
}

return t, err
}

func makeTimeUnmarshalStrategyFunc(layout string) func(b []byte) (time.Time, error) {
return func(b []byte) (time.Time, error) {
s := strings.Trim(string(b), "\"")
if s == "null" {
return time.Time{}, nil
}
return time.Parse(layout, s)
}
}

// UnmarshalJSON attempts a variety of time marshalling strategies. The first one that works
// will be used. If none succeede, then an error is returned.
func (t *Time) UnmarshalJSON(b []byte) error {
tedchamb marked this conversation as resolved.
Show resolved Hide resolved

// The dates returned by the Azure DevOps services do not conform to any predictable
// format or standard. The strategy list here tries to account for as many permutations
// as possible by applying the following strategies:
//
// (1) Default Go time deserialization
// (2) Attempt each of the time formats defined as constants in the time library.
// These are documented here: https://golang.org/pkg/time/#pkg-constants
// (3) Special case times found to be returned by the Azure DevOps services
//
// The first "working" strategy is used.
strategies := []func([]byte) (time.Time, error){
tryUnmarshalJSON,
makeTimeUnmarshalStrategyFunc(time.ANSIC),
makeTimeUnmarshalStrategyFunc(time.UnixDate),
makeTimeUnmarshalStrategyFunc(time.RubyDate),
makeTimeUnmarshalStrategyFunc(time.RFC822),
makeTimeUnmarshalStrategyFunc(time.RFC822Z),
makeTimeUnmarshalStrategyFunc(time.RFC850),
makeTimeUnmarshalStrategyFunc(time.RFC1123),
makeTimeUnmarshalStrategyFunc(time.RFC1123Z),
makeTimeUnmarshalStrategyFunc(time.RFC3339),
makeTimeUnmarshalStrategyFunc(time.RFC3339Nano),
makeTimeUnmarshalStrategyFunc(time.Stamp),
makeTimeUnmarshalStrategyFunc(time.StampMilli),
makeTimeUnmarshalStrategyFunc(time.StampMicro),
makeTimeUnmarshalStrategyFunc(time.StampNano),
makeTimeUnmarshalStrategyFunc("2006-01-02T15:04:05.9999999"), // https://github.com/microsoft/azure-devops-go-api/issues/59
}

for _, strategy := range strategies {
parsedTime, err := strategy(b)
if err == nil {
t.Time = parsedTime
return nil
}
}

return fmt.Errorf("Unable to deserialize time (%s). %d strategies were attempted", string(b), len(strategies))
}

func (t *Time) MarshalJSON() ([]byte, error) {
return json.Marshal(t.Time)
}

func (t Time) String() string {
return t.Time.String()
}

func (t Time) Equal(u Time) bool {
return t.Time.Equal(u.Time)
}
162 changes: 162 additions & 0 deletions azuredevops/time_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package azuredevops

import (
"encoding/json"
"fmt"
"testing"
"time"

"github.com/google/uuid"
)

type TimeTestStruct struct {
T Time
}

func getJSONAsBytes(timeStr string) []byte {
return []byte(fmt.Sprintf("{ \"T\": \"%s\" }", timeStr))
}

func TestTime_MarshallingRoundTripForSupportedCases(t *testing.T) {
testCases := []struct {
name string
timeStr string
}{
{
"ANSIC",
"Mon Jan 2 15:04:05 2006",
}, {
"UnixDate",
"Mon Jan 2 15:04:05 MST 2006",
}, {
"RubyDate",
"Mon Jan 02 15:04:05 -0700 2006",
}, {
"RFC822",
"02 Jan 06 15:04 MST",
}, {
"RFC822Z",
"02 Jan 06 15:04 -0700",
}, {
"RFC850",
"Monday, 02-Jan-06 15:04:05 MST",
}, {
"RFC1123",
"Mon, 02 Jan 2006 15:04:05 MST",
}, {
"RFC1123Z",
"Mon, 02 Jan 2006 15:04:05 -0700",
}, {
"RFC3339_1",
"2006-01-02T15:04:05Z",
}, {
"RFC3339_2",
"2006-01-02T15:04:05+07:00",
}, {
"RFC3339Nano_1",
"2006-01-02T15:04:05.999999999Z",
}, {
"RFC3339Nano_2",
"2006-01-02T15:04:05.999999999+07:00",
}, {
"Stamp",
"Jan 2 15:04:05",
}, {
"StampMilli",
"Jan 2 15:04:05.000",
}, {
"StampMicro",
"Jan 2 15:04:05.000000",
}, {
"StampNano",
"Jan 2 15:04:05.000000000",
}, {
"GitHubIssue59",
"2006-01-02T15:04:05.9999999",
},
}

for _, testCase := range testCases {
expectedTime, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05+00:00")

t.Run(testCase.name, func(t *testing.T) {
s := TimeTestStruct{}
err := json.Unmarshal(getJSONAsBytes(testCase.timeStr), &s)
if err != nil {
t.Errorf("Unexpectedly could not unmarshal %s into a valid time struct: %+v", testCase.timeStr, err)
}

// Not all timestamps have the same year and timezone information. So we can pick a subset of properties to test
if expectedTime.Day() != s.T.Time.Day() {
t.Errorf("Expected day %+v but got %+v", expectedTime.Day(), s.T.Time.Day())
}

if expectedTime.Hour() != s.T.Time.Hour() {
t.Errorf("Expected hour %+v but got %+v", expectedTime.Hour(), s.T.Time.Hour())
}

if expectedTime.Minute() != s.T.Time.Minute() {
t.Errorf("Expected minute %+v but got %+v", expectedTime.Minute(), s.T.Time.Minute())
}
})
}
}

func TestModels_Unmarshal_Time(t *testing.T) {
text := []byte("{\"id\":\"d221ad31-3a7b-52c0-b71d-b255b1ff63ba\",\"time1\":\"0001-01-01T00:00:00\",\"time2\":\"2019-09-01T00:07:26Z\",\"int\":10,\"string\":\"test string\"}")
testModel := TestModel{}

testModel.Time1 = &Time{}
testModel.Time1.Time = time.Now() // this ensures we test the value is set back to default when issue #17 is hit.

err := json.Unmarshal(text, &testModel)
if err != nil {
t.Errorf("Error occurred during deserialization: %v", err)
}
if (testModel.Time1.Time != time.Time{}) {
t.Errorf("Expecting deserialized time to equal default time. Actual time: %v", testModel.Time1)
}

parsedTime, err := time.Parse(time.RFC3339, "2019-09-01T00:07:26Z")
if err != nil {
t.Errorf(err.Error())
}
if testModel.Time2.Time != parsedTime {
t.Errorf("Expected time: %v Actual time: %v", parsedTime, testModel.Time1.Time)
}
}

func TestModels_Marshal_Unmarshal_Time(t *testing.T) {
testModel1 := TestModel{}
testModel1.Time1 = &Time{}
testModel1.Time1.Time = time.Now()
b, err := json.Marshal(testModel1)
if err != nil {
t.Errorf(err.Error())
}

testModel2 := TestModel{}
err = json.Unmarshal(b, &testModel2)
if err != nil {
t.Errorf(err.Error())
}

if testModel1.Time1 != testModel1.Time1 {
t.Errorf("Expected time: %v Actual time: %v", testModel1.Time1, testModel1.Time2)
}

if testModel1.Time1.Time != testModel1.Time1.Time {
t.Errorf("Expected time: %v Actual time: %v", testModel1.Time1.Time, testModel1.Time2.Time)
}
}

type TestModel struct {
Id *uuid.UUID `json:"id,omitempty"`
Time1 *Time `json:"time1,omitempty"`
Time2 *Time `json:"time2,omitempty"`
Int *uint64 `json:"int,omitempty"`
String *string `json:"string,omitempty"`
}