diff --git a/azuredevops/models.go b/azuredevops/models.go index 4193ee36..afeb127c 100644 --- a/azuredevops/models.go +++ b/azuredevops/models.go @@ -4,9 +4,7 @@ package azuredevops import ( - "encoding/json" "strconv" - "time" "github.com/google/uuid" ) @@ -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"` diff --git a/azuredevops/models_test.go b/azuredevops/models_test.go deleted file mode 100644 index 57413a42..00000000 --- a/azuredevops/models_test.go +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package azuredevops - -import ( - "encoding/json" - "github.com/google/uuid" - "testing" - "time" -) - -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"` -} diff --git a/azuredevops/time.go b/azuredevops/time.go new file mode 100644 index 00000000..16032b52 --- /dev/null +++ b/azuredevops/time.go @@ -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 makeTimeParseFunction(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 { + + // 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, + makeTimeParseFunction(time.ANSIC), + makeTimeParseFunction(time.UnixDate), + makeTimeParseFunction(time.RubyDate), + makeTimeParseFunction(time.RFC822), + makeTimeParseFunction(time.RFC822Z), + makeTimeParseFunction(time.RFC850), + makeTimeParseFunction(time.RFC1123), + makeTimeParseFunction(time.RFC1123Z), + makeTimeParseFunction(time.RFC3339), + makeTimeParseFunction(time.RFC3339Nano), + makeTimeParseFunction(time.Stamp), + makeTimeParseFunction(time.StampMilli), + makeTimeParseFunction(time.StampMicro), + makeTimeParseFunction(time.StampNano), + makeTimeParseFunction("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) +} diff --git a/azuredevops/time_test.go b/azuredevops/time_test.go new file mode 100644 index 00000000..9b5f34ec --- /dev/null +++ b/azuredevops/time_test.go @@ -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 getJSONBytes(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(getJSONBytes(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"` +}