Skip to content

Commit 8a91c0f

Browse files
committed
feat: Added support for time formatted strings
The `time` format is used to specify that a JSON string should conform to the ISO-8601 standard for defining a time _without_ a date as a string. In this implementation a `time.Time` value is generated given this type of formatted string. https://json-schema.org/draft/2020-12/json-schema-validation.html#name-dates-times-and-duration Some extra work was required here to make this work since the `time.Time.UnmarshalJSON` implementation expects to only be given a full timestamps with both a date AND a time. If you gave it just "2023-01-02" you would get an error. To get around this a new `SerializableTime` type was added which knows to to properly marshal/unmarshal as a pure time. This breaks a bit from how the tool has worked before because this would now make it expected as an import in client code. The `SerializableTime` type transparently extends the `time.Time` type and just changes the JSON related methods.
1 parent 96ea421 commit 8a91c0f

File tree

7 files changed

+214
-1
lines changed

7 files changed

+214
-1
lines changed

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ This will create `schema1.go` (declared as `package myproject`) and `stuff/schem
6464
schema $id full import URL
6565
```
6666

67+
### Special types
68+
69+
In a few cases, special types are used to help with serializing/deserializing
70+
data frrom JSON. Namely a custom types is provided for the following semantic
71+
types:
72+
73+
* `SerializableDate`
74+
* `SerializableTime`
75+
76+
These types are needed because there is no native type provided by Go which
77+
properly handles them.
78+
6779
## Status
6880

6981
While not finished, go-jsonschema can be used today. Aside from some minor features, only specific validations remain to be fully implemented.
@@ -133,7 +145,7 @@ While not finished, go-jsonschema can be used today. Aside from some minor featu
133145
- [ ] `oneOf`
134146
- [ ] `not`
135147
- [ ] Semantic formats (§7.3)
136-
- [ ] Dates and times
148+
- [x] Dates and times
137149
- [ ] Email addresses
138150
- [ ] Hostnames
139151
- [ ] IP addresses

pkg/codegen/utils.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ func PrimitiveTypeFromJSONSchemaType(jsType, format string, pointer bool) (Type,
8383
},
8484
}
8585

86+
case "time":
87+
t = NamedType{
88+
Package: &Package{
89+
QualifiedName: "types",
90+
Imports: []Import{
91+
{
92+
QualifiedName: "github.com/atombender/go-jsonschema/types",
93+
},
94+
},
95+
},
96+
Decl: &TypeDecl{
97+
Name: "SerializableTime",
98+
},
99+
}
100+
86101
default:
87102
t = PrimitiveType{"string"}
88103
}

tests/data/core/time.go.output

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT.
2+
3+
package test
4+
5+
import "encoding/json"
6+
import "fmt"
7+
import "github.com/atombender/go-jsonschema/types"
8+
9+
type TimeMyObject struct {
10+
// MyTime corresponds to the JSON schema field "myTime".
11+
MyTime types.SerializableTime `json:"myTime" yaml:"myTime" mapstructure:"myTime"`
12+
}
13+
14+
// UnmarshalJSON implements json.Unmarshaler.
15+
func (j *TimeMyObject) UnmarshalJSON(b []byte) error {
16+
var raw map[string]interface{}
17+
if err := json.Unmarshal(b, &raw); err != nil {
18+
return err
19+
}
20+
if v, ok := raw["myTime"]; !ok || v == nil {
21+
return fmt.Errorf("field myTime in TimeMyObject: required")
22+
}
23+
type Plain TimeMyObject
24+
var plain Plain
25+
if err := json.Unmarshal(b, &plain); err != nil {
26+
return err
27+
}
28+
*j = TimeMyObject(plain)
29+
return nil
30+
}
31+
32+
type Time struct {
33+
// MyObject corresponds to the JSON schema field "myObject".
34+
MyObject *TimeMyObject `json:"myObject,omitempty" yaml:"myObject,omitempty" mapstructure:"myObject,omitempty"`
35+
}

tests/data/core/time.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-04/schema#",
3+
"id": "http://example.com/object",
4+
"type": "object",
5+
"title": "object",
6+
"properties": {
7+
"myObject": {
8+
"type": "object",
9+
"required": [
10+
"myTime"
11+
],
12+
"properties": {
13+
"myTime": {
14+
"type": "string",
15+
"format": "time"
16+
}
17+
}
18+
}
19+
}
20+
}
File renamed without changes.

tests/serializable_time_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package tests_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
"time"
8+
9+
"github.com/atombender/go-jsonschema/types"
10+
)
11+
12+
func TestSerializableTimeMarshalsToJSON(t *testing.T) {
13+
t.Parallel()
14+
15+
now := time.Now()
16+
timeValue := types.SerializableTime{
17+
Time: now,
18+
}
19+
20+
output, err := json.Marshal(timeValue)
21+
if err != nil {
22+
t.Fatalf("Unable to marshal SerializableTime as JSON: %v", err)
23+
}
24+
25+
stringifiedOutput := string(output)
26+
27+
expected := "\"" + now.Format("15:04:05") + "\""
28+
if stringifiedOutput != expected {
29+
t.Fatalf("Expected SerializableTime to marshal to %s but got %s", expected, stringifiedOutput)
30+
}
31+
}
32+
33+
func TestSerializableTimeUnmarshalsFromJSON(t *testing.T) {
34+
t.Parallel()
35+
36+
now := time.Date(0, 1, 1, 15, 0o4, 0o5, 0, time.UTC)
37+
38+
input := "\"" + now.Format("15:04:05") + "\""
39+
40+
var timeValue types.SerializableTime
41+
if err := json.Unmarshal([]byte(input), &timeValue); err != nil {
42+
t.Fatalf("Unable to unmarshal %s to a SerializableTime: %s", input, err)
43+
}
44+
45+
expected := types.SerializableTime{
46+
Time: now,
47+
}
48+
49+
if timeValue != expected {
50+
t.Fatalf("Expected SerializableTime to unmarshal to %s but got %s", expected, timeValue)
51+
}
52+
}
53+
54+
func TestSerializableTimeUnmarshalJSONReturnsErrorForInvalidString(t *testing.T) {
55+
t.Parallel()
56+
57+
testCases := []string{
58+
"",
59+
"15:04:05",
60+
"\"15\"",
61+
"\"15:04\"",
62+
"\"2023-01-02T03:04:05Z\"",
63+
}
64+
65+
for _, testCase := range testCases {
66+
testCase := testCase
67+
68+
t.Run(fmt.Sprintf("Given '%s' expected UnmarshalJSON to return an error", testCase), func(t *testing.T) {
69+
t.Parallel()
70+
71+
var timeValue types.SerializableTime
72+
if err := timeValue.UnmarshalJSON([]byte(testCase)); err == nil {
73+
t.Fatalf("Expected an error but got '%s'", timeValue)
74+
}
75+
})
76+
}
77+
}
78+
79+
func TestSerializableTimeUnmarshalJSONDoesNothingWhenGivenNull(t *testing.T) {
80+
t.Parallel()
81+
82+
var timeValue types.SerializableTime
83+
if err := timeValue.UnmarshalJSON([]byte("null")); err != nil {
84+
t.Fatalf("Given 'null' expected UnmarshalJSON to be no-op but got an error: %v", err)
85+
}
86+
87+
var zeroValue types.SerializableTime
88+
if timeValue != zeroValue {
89+
t.Fatalf("Given 'null' expected to stay at zero value but got %s", timeValue)
90+
}
91+
}

types/time.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package types
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
"time"
8+
)
9+
10+
var ErrTimeNotJSONString = errors.New("cannot parse non-string value as a time")
11+
12+
type SerializableTime struct {
13+
time.Time
14+
}
15+
16+
func (t SerializableTime) MarshalJSON() ([]byte, error) {
17+
return []byte("\"" + t.Format(time.TimeOnly) + "\""), nil
18+
}
19+
20+
func (t *SerializableTime) UnmarshalJSON(data []byte) error {
21+
stringifiedData := string(data)
22+
if stringifiedData == "null" {
23+
return nil
24+
}
25+
26+
if !strings.HasPrefix(stringifiedData, "\"") || !strings.HasSuffix(stringifiedData, "\"") {
27+
return ErrTimeNotJSONString
28+
}
29+
30+
timeWithoutQuotes := stringifiedData[1 : len(stringifiedData)-1]
31+
32+
parsedTime, err := time.Parse(time.TimeOnly, timeWithoutQuotes)
33+
if err != nil {
34+
return fmt.Errorf("unable to parse time from JSON: %w", err)
35+
}
36+
37+
t.Time = parsedTime
38+
39+
return nil
40+
}

0 commit comments

Comments
 (0)