diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e64ef0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin/* +coverage.* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..1269134 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,94 @@ +linters-settings: + funlen: + lines: 100 + statements: 450 + golint: + min-confidence: 0 + govet: + enable-all: true + disable: + - fieldalignment + - shadow + lll: + line-length: 80 + tab-width: 4 + maligned: + suggest-new: true + misspell: + locale: US + paralleltest: + ignore-missing: true + +linters: + disable-all: true + enable: + - asciicheck + - bodyclose + - depguard + - durationcheck + - errcheck + - errorlint + - exhaustive + - exportloopref + - funlen + - gochecknoinits + - goconst + - gocritic + - godot + - gofumpt + - goimports + - goprintffuncname + - gosec + - gosimple + - govet + - importas + - ineffassign + - lll + - misspell + - nakedret + - nilerr + - noctx + - nolintlint + - paralleltest + - prealloc + - predeclared + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - typecheck + - unconvert + - unparam + - unused + - wastedassign + - whitespace + +issues: + exclude: + - Using the variable on range scope `tt` in function literal + - Using the variable on range scope `tc` in function literal + exclude-rules: + - path: "_test\\.go" + linters: + - funlen + - dupl + - source: "^//go:generate " + linters: + - lll + - source: "`env:" + linters: + - lll + - source: "`json:" + linters: + - lll + - source: "`xml:" + linters: + - lll + - source: "`yaml:" + linters: + - lll + +run: + timeout: 2m + allow-parallel-runners: true + modules-download-mode: readonly diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8895cd7 --- /dev/null +++ b/Makefile @@ -0,0 +1,191 @@ +GOMODNAME := $(shell grep 'module' go.mod | sed -e 's/^module //') +SOURCES := $(shell find . -name "*.go" -or -name "go.mod" -or -name "go.sum" \ + -or -name "Makefile" -or -name "*.golden") + +# Verbose output +ifdef VERBOSE +V = -v +endif + +# +# Environment +# + +BINDIR := bin +TOOLDIR := $(BINDIR)/tools + +# Global environment variables for all targets +SHELL ?= /bin/bash +SHELL := env \ + GO111MODULE=on \ + GOBIN=$(CURDIR)/$(TOOLDIR) \ + CGO_ENABLED=1 \ + PATH='$(CURDIR)/$(BINDIR):$(CURDIR)/$(TOOLDIR):$(PATH)' \ + $(SHELL) + +# +# Defaults +# + +# Default target +.DEFAULT_GOAL := test + +# +# Tools +# + +# external tool +define tool # 1: binary-name, 2: go-import-path +TOOLS += $(TOOLDIR)/$(1) + +$(TOOLDIR)/$(1): Makefile + GOBIN="$(CURDIR)/$(TOOLDIR)" go install "$(2)" +endef + +$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc@latest)) +$(eval $(call tool,gofumpt,mvdan.cc/gofumpt@latest)) +$(eval $(call tool,goimports,golang.org/x/tools/cmd/goimports@latest)) +$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50)) +$(eval $(call tool,gomod,github.com/Helcaraxan/gomod@latest)) +$(eval $(call tool,mockgen,github.com/golang/mock/mockgen@v1.6.0)) + +.PHONY: tools +tools: $(TOOLS) + +# +# Development +# + +BENCH ?= . +TESTARGS ?= + +.PHONY: clean +clean: + rm -f $(TOOLS) + rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check + +.PHONY: test +test: + go test $(V) -count=1 -race $(TESTARGS) ./... + +.PHONY: test-deps +test-deps: + go test all + +.PHONY: lint +lint: $(TOOLDIR)/golangci-lint + golangci-lint $(V) run + +.PHONY: format +format: $(TOOLDIR)/goimports $(TOOLDIR)/gofumpt + goimports -w . && gofumpt -w . + +.SILENT: bench +.PHONY: bench +bench: + go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) ./... + +# +# Code Generation +# + +.PHONY: generate +generate: $(TOOLDIR)/mockgen + go generate ./... + +.PHONY: check-generate +check-generate: $(TOOLDIR)/mockgen + $(eval CHKDIR := $(shell mktemp -d)) + cp -av . "$(CHKDIR)" + make -C "$(CHKDIR)/" generate + ( diff -rN . "$(CHKDIR)" && rm -rf "$(CHKDIR)" ) || \ + ( rm -rf "$(CHKDIR)" && exit 1 ) + +# +# Coverage +# + +.PHONY: cov +cov: coverage.out + +.PHONY: cov-html +cov-html: coverage.out + go tool cover -html=./coverage.out + +.PHONY: cov-func +cov-func: coverage.out + go tool cover -func=./coverage.out + +coverage.out: $(SOURCES) + go test $(V) -count=1 -race \ + -covermode=atomic -coverprofile=./coverage.out ./... + +# +# Dependencies +# + +.PHONY: deps +deps: + go mod download + +.PHONY: deps-update +deps-update: + go get -u -t ./... + +.PHONY: deps-analyze +deps-analyze: $(TOOLDIR)/gomod + gomod analyze + +.PHONY: tidy +tidy: + go mod tidy $(V) + +.PHONY: verify +verify: + go mod verify + +.SILENT: check-tidy +.PHONY: check-tidy +check-tidy: + cp go.mod go.mod.tidy-check + cp go.sum go.sum.tidy-check + go mod tidy + ( \ + diff go.mod go.mod.tidy-check && \ + diff go.sum go.sum.tidy-check && \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum \ + ) || ( \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum; \ + exit 1 \ + ) + +# +# Documentation +# + +# Serve docs +.PHONY: docs +docs: $(TOOLDIR)/godoc + $(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/) + @godoc -http=127.0.0.1:6060 + +# +# Release +# + +.PHONY: new-version +new-version: check-npx + npx standard-version + +.PHONY: next-version +next-version: check-npx + npx standard-version --dry-run + +.PHONY: check-npx +check-npx: + $(if $(shell which npx),,\ + $(error No npx found in PATH, please install NodeJS)) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0edaaeb --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/jimeh/go-tyme + +go 1.18 + +require ( + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de + github.com/stretchr/testify v1.8.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..41c964d --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..177948a --- /dev/null +++ b/parse.go @@ -0,0 +1,27 @@ +package tyme + +import "github.com/araddon/dateparse" + +var ( + // RetryAmbiguousDateWithSwap is option available in dateparse. This var + // controls if Time's unmarshalers enables it or not. + RetryAmbiguousDateWithSwap = false + + // PreferMonthFirst is option available in dateparse. This var + // controls if Time's unmarshalers enables it or not. + PreferMonthFirst = false +) + +// Parse is a helper function to parse a wide range of string date and time formats using dateparse.ParseAny. +func Parse(s string) (Time, error) { + t, err := dateparse.ParseAny( + s, + dateparse.RetryAmbiguousDateWithSwap(RetryAmbiguousDateWithSwap), + dateparse.PreferMonthFirst(PreferMonthFirst), + ) + if err != nil { + return Time{}, err + } + + return Time(t), nil +} diff --git a/rfc3339.go b/rfc3339.go new file mode 100644 index 0000000..cc329c5 --- /dev/null +++ b/rfc3339.go @@ -0,0 +1,79 @@ +package tyme + +import ( + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// TimeRFC3339 is a wrapper around time.Time that implements JSON/YAML +// marshaling/unmarshaling interfaces. As opposed to Time, only RFC 3339 +// formatted input is accepted when unmarshaling. +// +// It marshals to a string in RFC 3339 format, with sub-second precision added +// if present. +// +// It will only unmarshal from a string in RFC 3339 format. Any other format +// will cause an unmarshaling error. +type TimeRFC3339 time.Time + +// RFC3339NanoJSON is the time.RFC3339Nano format with double quotes around it. +const RFC3339NanoJSON = `"` + time.RFC3339Nano + `"` + +// MarshalJSON implements the json.Marshaler interface, and formats the time as +// a JSON string in RFC 3339 format, with sub-second precision added if +// present. +func (t TimeRFC3339) MarshalJSON() ([]byte, error) { + return []byte(time.Time(t).Format(RFC3339NanoJSON)), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface, and parses a +// JSON string in RFC 3339 format, with sub-second precision added if +// present. +func (t *TimeRFC3339) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + nt, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return err + } + + *t = TimeRFC3339(nt) + + return err +} + +// MarshalYAML implements the yaml.Marshaler interface, and formats the time as +// a YAML timestamp type in RFC 3339 string format, with sub-second precision +// added if present. +func (t TimeRFC3339) MarshalYAML() (interface{}, error) { + return time.Time(t), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface, and parses a YAML +// timestamp or string formatted according to RFC 3339, with sub-second +// precision added if present. +func (t *TimeRFC3339) UnmarshalYAML(node *yaml.Node) error { + var nt time.Time + var err error + + switch node.Tag { + case "!!timestamp": + err = node.Decode(&nt) + case "!!str": + nt, err = time.Parse(time.RFC3339Nano, node.Value) + default: + return &yaml.TypeError{Errors: []string{"invalid time format"}} + } + if err != nil { + return err + } + + *t = TimeRFC3339(nt) + + return nil +} diff --git a/rfc3339_test.go b/rfc3339_test.go new file mode 100644 index 0000000..8c0f71a --- /dev/null +++ b/rfc3339_test.go @@ -0,0 +1,163 @@ +package tyme + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var timeRFC3339UnmarshalTestCases = []struct { + name string + s string + want time.Time +}{ + { + name: "UTC nanosecond precision", + s: `2022-10-29T14:40:34.934349003Z`, + want: utc.Round(time.Nanosecond), + }, + { + name: "UTC microsecond precision", + s: `2022-10-29T14:40:34.934349Z`, + want: utc.Round(time.Microsecond), + }, + { + name: "UTC millisecond precision", + s: `2022-10-29T14:40:34.934Z`, + want: utc.Round(time.Millisecond), + }, + { + name: "UTC second precision", + s: `2022-10-29T14:40:35Z`, + want: utc.Round(time.Second), + }, + { + name: "UTC minute precision", + s: `2022-10-29T14:41:00Z`, + want: utc.Round(time.Minute), + }, + { + name: "UTC+8 nanosecond precision", + s: `2022-10-29T22:40:34.934349003+08:00`, + want: utc8.Round(time.Nanosecond), + }, + { + name: "UTC+8 microsecond precision", + s: `2022-10-29T22:40:34.934349+08:00`, + want: utc8.Round(time.Microsecond), + }, + { + name: "UTC+8 millisecond precision", + s: `2022-10-29T22:40:34.934+08:00`, + want: utc8.Round(time.Millisecond), + }, + { + name: "UTC+8 second precision", + s: `2022-10-29T22:40:35+08:00`, + want: utc8.Round(time.Second), + }, + { + name: "UTC+8 minute precision", + s: `2022-10-29T22:41:00+08:00`, + want: utc8.Round(time.Minute), + }, +} + +func TestTimeRFC3339_MarshalUnmarshalJSON(t *testing.T) { + for _, tt := range timeMarshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := TimeRFC3339(tt.t) + + b, err := json.Marshal(t1) + require.NoError(t, err) + + var t2 TimeRFC3339 + err = json.Unmarshal(b, &t2) + require.NoError(t, err) + + assert.WithinDuration(t, tt.t, time.Time(t2), time.Nanosecond) + }) + } +} + +func TestTimeRFC3339_MarshalJSON(t *testing.T) { + for _, tt := range timeMarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := TimeRFC3339(tt.t) + + b, err := json.Marshal(t1) + require.NoError(t, err) + + assert.Equal(t, "\""+tt.want+"\"", string(b)) + }) + } +} + +func TestTimeRFC3339_UnmarshalJSON(t *testing.T) { + for _, tt := range timeRFC3339UnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + var got TimeRFC3339 + + err := json.Unmarshal([]byte("\""+tt.s+"\""), &got) + require.NoError(t, err) + + assert.WithinDuration(t, tt.want, time.Time(got), time.Nanosecond) + }) + } +} + +func TestTimeRFC3339_MarshalUnmarshalYAML(t *testing.T) { + for _, tt := range timeMarshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := TimeRFC3339(tt.t) + + b, err := yaml.Marshal(t1) + require.NoError(t, err) + + var t2 TimeRFC3339 + err = yaml.Unmarshal(b, &t2) + require.NoError(t, err) + + assert.WithinDuration(t, tt.t, time.Time(t2), time.Nanosecond) + }) + } +} + +func TestTimeRFC3339_MarshalYAML(t *testing.T) { + for _, tt := range timeMarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := TimeRFC3339(tt.t) + + b, err := yaml.Marshal(t1) + require.NoError(t, err) + + assert.Equal(t, tt.want+"\n", string(b)) + }) + } +} + +func TestTimeRFC3339_UnmarshalYAML(t *testing.T) { + for _, tt := range timeRFC3339UnmarshalTestCases { + t.Run(tt.name+" (YAML string type)", func(t *testing.T) { + var got TimeRFC3339 + + err := yaml.Unmarshal([]byte("\""+tt.s+"\""), &got) + require.NoError(t, err) + + assert.WithinDuration(t, tt.want, time.Time(got), time.Nanosecond) + }) + + t.Run(tt.name+" (YAML timestamp type)", func(t *testing.T) { + var got TimeRFC3339 + + err := yaml.Unmarshal([]byte(tt.s), &got) + require.NoError(t, err) + + assert.WithinDuration(t, tt.want, time.Time(got), time.Nanosecond) + }) + } +} diff --git a/time.go b/time.go new file mode 100644 index 0000000..70dfd58 --- /dev/null +++ b/time.go @@ -0,0 +1,75 @@ +package tyme + +import ( + "strconv" + "time" + + "gopkg.in/yaml.v3" +) + +// Time is a wrapper around time.Time that implements JSON and YAML +// marshaler and unmarshaler interfaces. +// +// It marshals to a string in RFC 3339 format, with sub-second +// precision added if present. +// +// It unmarshals from a wide range of string date and time formats, by using the +// dateparse package. +type Time time.Time + +// MarshalJSON implements the json.Marshaler interface, and formats the time as +// a JSON string in RFC 3339 format, with sub-second precision added if +// present. +func (t Time) MarshalJSON() ([]byte, error) { + return []byte(time.Time(t).Format(RFC3339NanoJSON)), nil +} + +// UnmarshalJSON implements the json.Unmarshaler interface, and parses a wide +// range of string date and time formats, by using the dateparse package. +func (t *Time) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + + nt, err := Parse(s) + if err != nil { + return err + } + + *t = nt + + return err +} + +// MarshalYAML implements the yaml.Marshaler interface, and formats the time as +// a YAML timestamp type in RFC 3339 string format, with sub-second precision +// added if present. +func (t Time) MarshalYAML() (interface{}, error) { + return time.Time(t), nil +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface, and parses a wide +// range of date and time formats, by using the dateparse package. +func (t *Time) UnmarshalYAML(node *yaml.Node) error { + var nt Time + var err error + + switch node.Tag { + case "!!timestamp": + var tt time.Time + err = node.Decode(&tt) + nt = Time(tt) + case "!!str": + nt, err = Parse(node.Value) + default: + return &yaml.TypeError{Errors: []string{"invalid time format"}} + } + if err != nil { + return err + } + + *t = nt + + return nil +} diff --git a/time_example_test.go b/time_example_test.go new file mode 100644 index 0000000..b1e2070 --- /dev/null +++ b/time_example_test.go @@ -0,0 +1,36 @@ +package tyme_test + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/jimeh/go-tyme" + "gopkg.in/yaml.v3" +) + +func ExampleTime_MarshalJSON() { + type Order struct { + Date tyme.Time `json:"date"` + } + t := time.Date(2006, 1, 2, 15, 4, 5, 999000000, time.UTC) + order := Order{Date: tyme.Time(t)} + b, _ := json.Marshal(order) + + fmt.Println(string(b)) + // Output: + // {"date":"2006-01-02T15:04:05.999Z"} +} + +func ExampleTime_MarshalYAML() { + type Order struct { + Date tyme.Time `yaml:"date"` + } + t := time.Date(2006, 1, 2, 15, 4, 5, 999000000, time.UTC) + order := Order{Date: tyme.Time(t)} + b, _ := yaml.Marshal(order) + + fmt.Println(string(b)) + // Output: + // date: 2006-01-02T15:04:05.999Z +} diff --git a/time_test.go b/time_test.go new file mode 100644 index 0000000..e0509b7 --- /dev/null +++ b/time_test.go @@ -0,0 +1,231 @@ +package tyme + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +var ( + loc = time.FixedZone("UTC+8", 8*60*60) + utc = time.Date(2022, 10, 29, 14, 40, 34, 934349003, time.UTC) + utc8 = utc.In(loc) +) + +var timeMarshalUnmarshalTestCases = []struct { + name string + t time.Time +}{ + { + name: "UTC nanosecond precision", + t: utc.Round(time.Nanosecond), + }, + { + name: "UTC microsecond precision", + t: utc.Round(time.Microsecond), + }, + { + name: "UTC millisecond precision", + t: utc.Round(time.Millisecond), + }, + { + name: "UTC second precision", + t: utc.Round(time.Second), + }, + { + name: "UTC minute precision", + t: utc.Round(time.Second), + }, + { + name: "UTC+8 nanosecond precision", + t: utc8.Round(time.Nanosecond), + }, + { + name: "UTC+8 microsecond precision", + t: utc8.Round(time.Microsecond), + }, + { + name: "UTC+8 millisecond precision", + t: utc8.Round(time.Millisecond), + }, + { + name: "UTC+8 second precision", + t: utc8.Round(time.Second), + }, + { + name: "UTC+8 minute precision", + t: utc8.Round(time.Second), + }, +} + +var timeMarshalTestCases = []struct { + name string + t time.Time + want string +}{ + { + name: "UTC nanosecond precision", + t: utc.Round(time.Nanosecond), + want: `2022-10-29T14:40:34.934349003Z`, + }, + { + name: "UTC microsecond precision", + t: utc.Round(time.Microsecond), + want: `2022-10-29T14:40:34.934349Z`, + }, + { + name: "UTC millisecond precision", + t: utc.Round(time.Millisecond), + want: `2022-10-29T14:40:34.934Z`, + }, + { + name: "UTC second precision", + t: utc.Round(time.Second), + want: `2022-10-29T14:40:35Z`, + }, + { + name: "UTC minute precision", + t: utc.Round(time.Minute), + want: `2022-10-29T14:41:00Z`, + }, + { + name: "UTC+8 nanosecond precision", + t: utc8.Round(time.Nanosecond), + want: `2022-10-29T22:40:34.934349003+08:00`, + }, + { + name: "UTC+8 microsecond precision", + t: utc8.Round(time.Microsecond), + want: `2022-10-29T22:40:34.934349+08:00`, + }, + { + name: "UTC+8 millisecond precision", + t: utc8.Round(time.Millisecond), + want: `2022-10-29T22:40:34.934+08:00`, + }, + { + name: "UTC+8 second precision", + t: utc8.Round(time.Second), + want: `2022-10-29T22:40:35+08:00`, + }, + { + name: "UTC+8 minute precision", + t: utc8.Round(time.Minute), + want: `2022-10-29T22:41:00+08:00`, + }, +} + +var timeUnmarshalTestCases = []struct { + name string + s string + want time.Time +}{ + {s: `1667054434934349003`, want: utc.Round(time.Nanosecond)}, + {s: `1667054434934349`, want: utc.Round(time.Microsecond)}, + {s: `1667054434934`, want: utc.Round(time.Millisecond)}, + {s: `1667054435`, want: utc.Round(time.Second)}, + {s: `20221029144035`, want: utc.Round(time.Second)}, + {s: `221029 14:40:35`, want: utc.Round(time.Second)}, + {s: `October 29th, 2022, 14:40:35`, want: utc.Round(time.Second)}, + {s: `2022-10-29 14:40:35`, want: utc.Round(time.Second)}, + {s: `2022-10-29T14:40:34.934349003Z`, want: utc.Round(time.Nanosecond)}, + {s: `2022-10-29T14:40:34.934349Z`, want: utc.Round(time.Microsecond)}, + {s: `2022-10-29T14:40:34.934Z`, want: utc.Round(time.Millisecond)}, + {s: `2022-10-29T14:40:35Z`, want: utc.Round(time.Second)}, + {s: `2022-10-29T14:41:00Z`, want: utc.Round(time.Minute)}, + {s: `2022-10-29T22:40:34.934349003+08:00`, want: utc8.Round(time.Nanosecond)}, + {s: `2022-10-29T22:40:34.934349+08:00`, want: utc8.Round(time.Microsecond)}, + {s: `2022-10-29T22:40:34.934+08:00`, want: utc8.Round(time.Millisecond)}, + {s: `2022-10-29T22:40:35+08:00`, want: utc8.Round(time.Second)}, + {s: `2022-10-29T22:41:00+08:00`, want: utc8.Round(time.Minute)}, +} + +func TestTime_MarshalUnmarshalJSON(t *testing.T) { + for _, tt := range timeMarshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := Time(tt.t) + + b, err := json.Marshal(t1) + require.NoError(t, err) + + var t2 Time + err = json.Unmarshal(b, &t2) + require.NoError(t, err) + + assert.WithinDuration(t, tt.t, time.Time(t2), time.Nanosecond) + }) + } +} + +func TestTime_MarshalJSON(t *testing.T) { + for _, tt := range timeMarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := Time(tt.t) + + b, err := json.Marshal(t1) + require.NoError(t, err) + + assert.Equal(t, "\""+tt.want+"\"", string(b)) + }) + } +} + +func TestTime_UnmarshalJSON(t *testing.T) { + for _, tt := range timeUnmarshalTestCases { + t.Run(tt.s, func(t *testing.T) { + var got Time + + err := json.Unmarshal([]byte("\""+tt.s+"\""), &got) + require.NoError(t, err) + + assert.WithinDuration(t, tt.want, time.Time(got), time.Nanosecond) + }) + } +} + +func TestTime_MarshalUnmarshalYAML(t *testing.T) { + for _, tt := range timeMarshalUnmarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := Time(tt.t) + + b, err := yaml.Marshal(t1) + require.NoError(t, err) + + var t2 Time + err = yaml.Unmarshal(b, &t2) + require.NoError(t, err) + + assert.WithinDuration(t, tt.t, time.Time(t2), time.Nanosecond) + }) + } +} + +func TestTime_MarshalYAML(t *testing.T) { + for _, tt := range timeMarshalTestCases { + t.Run(tt.name, func(t *testing.T) { + t1 := Time(tt.t) + + b, err := yaml.Marshal(t1) + require.NoError(t, err) + + assert.Equal(t, tt.want+"\n", string(b)) + }) + } +} + +func TestTime_UnmarshalYAML(t *testing.T) { + for _, tt := range timeUnmarshalTestCases { + t.Run(tt.s, func(t *testing.T) { + var got Time + + err := yaml.Unmarshal([]byte("\""+tt.s+"\""), &got) + require.NoError(t, err) + + assert.WithinDuration(t, tt.want, time.Time(got), time.Nanosecond) + }) + } +} diff --git a/tyme.go b/tyme.go new file mode 100644 index 0000000..c2ef4ce --- /dev/null +++ b/tyme.go @@ -0,0 +1,10 @@ +// Package tyme provides wrapper types around time.Time, with sensible JSON/YAML +// marshaling/unmarshaling support. +// +// Unmarshaling supports a wide variety of formats, automatically doing a best +// effort to understand the given input, thanks to using the +// github.com/araddon/dateparse package. +// +// Marshaling always produces a string in RFC 3339 format, by simply formatting +// the Time with the time.RFC3339Nano layout. +package tyme