From 572d532b77e6362f3d6ea9df52244e0ff533fb09 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Sun, 30 Oct 2022 03:30:34 +0000 Subject: [PATCH] feat(tyme): add basic tyme package --- .gitignore | 2 + .golangci.yml | 94 ++++++++++++++++++ Makefile | 191 +++++++++++++++++++++++++++++++++++ go.mod | 14 +++ go.sum | 23 +++++ parse.go | 27 +++++ rfc3339.go | 79 +++++++++++++++ rfc3339_test.go | 163 ++++++++++++++++++++++++++++++ time.go | 75 ++++++++++++++ time_example_test.go | 36 +++++++ time_test.go | 231 +++++++++++++++++++++++++++++++++++++++++++ tyme.go | 10 ++ 12 files changed, 945 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 parse.go create mode 100644 rfc3339.go create mode 100644 rfc3339_test.go create mode 100644 time.go create mode 100644 time_example_test.go create mode 100644 time_test.go create mode 100644 tyme.go 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