Skip to content

Commit

Permalink
Adding fix for GitHub issue 59 (microsoft#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
Nick Iodice committed May 16, 2020
1 parent 2e6b76e commit d70a5f2
Show file tree
Hide file tree
Showing 4 changed files with 261 additions and 104 deletions.
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 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)
}
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 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"`
}

0 comments on commit d70a5f2

Please sign in to comment.