diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e9c7a2e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: daily + open-pull-requests-limit: 10 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..94b4efe --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,14 @@ +name: update changelog + +on: + release: + types: [released] + +permissions: {} + +jobs: + changelog: + permissions: + contents: write + secrets: inherit + uses: bavix/.github/.github/workflows/changelog.yml@0.3.3 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..93676c9 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,26 @@ +name: golangci-lint +on: + push: + branches: + - master + pull_request: +permissions: + contents: read +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.25' ] + steps: + - uses: actions/checkout@v5 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.3.0 diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 0000000..e559dff --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,8 @@ +name: semgrep + +on: + pull_request: + +jobs: + semgrep: + uses: bavix/.github/.github/workflows/go-semgrep.yml@0.3.3 diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml new file mode 100644 index 0000000..eeb2897 --- /dev/null +++ b/.github/workflows/unit.yml @@ -0,0 +1,26 @@ +name: Unit +on: + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.24', '1.25' ] + steps: + - uses: actions/checkout@v5 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + - name: Test with Go + run: go test ./... + - name: Upload Go test results + uses: actions/upload-artifact@v4 + with: + name: Go-results-${{ matrix.go-version }} + path: TestResults-${{ matrix.go-version }}.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..146d688 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,35 @@ +version: "2" +linters: + default: all + disable: + - wsl + - wrapcheck + - revive + settings: + depguard: + rules: + main: + allow: + - $gostd + - github.com + - golang.org + - google.golang.org + lll: + line-length: 140 + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - Standard + - Default + - Prefix(github.com/gripmock) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ff8918f --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: * + +test: + go test -tags mock -race -cover ./... + +lint: + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.3.0 run --color always ${args} + +lint-fix: + make lint args=--fix \ No newline at end of file diff --git a/README.md b/README.md index 2b40412..eeb50b8 100644 --- a/README.md +++ b/README.md @@ -1 +1,77 @@ -# types \ No newline at end of file +# Gripmock Types + +A package of custom types for [Gripmock](https://github.com/bavix/gripmock) that solves JSON serialization/deserialization problems. + +## Problem + +In [issue #648](https://github.com/bavix/gripmock/issues/648), a problem was identified with deserializing string time values into `time.Duration`. When trying to use JSON like: + +```json +{ + "output": { + "delay": "100ms" + } +} +``` + +The following error occurred: +``` +json: cannot unmarshal number { into Go struct field Output.Delay of type time.Duration +``` + +## Solution + +The `types` package provides a custom `Duration` type that correctly handles string time values in JSON. + +## Available Types + +### Duration + +A custom type alias for `time.Duration` that provides JSON marshaling/unmarshaling support for string values. + +#### Supported formats + +Only string values are supported: +```json +{ + "delay": "100ms" +} +``` + +Valid duration formats: +- `"100ms"` - milliseconds +- `"2s"` - seconds +- `"1m"` - minutes +- `"1h"` - hours +- `"1h30m"` - combined formats + +#### Usage + +```go +type Output struct { + Delay types.Duration `json:"delay"` + Data map[string]interface{} `json:"data"` +} + +var output Output +json.Unmarshal([]byte(`{"delay": "100ms"}`), &output) + +// Access the duration value +duration := time.Duration(output.Delay) +``` + +## Installation + +```bash +go get github.com/gripmock/types +``` + +## Testing + +```bash +go test ./... +``` + +## License + +MIT License - see [LICENSE](LICENSE) file. \ No newline at end of file diff --git a/duration.go b/duration.go new file mode 100644 index 0000000..eb4afa5 --- /dev/null +++ b/duration.go @@ -0,0 +1,36 @@ +// Package types provides custom JSON types for Gripmock. +package types + +import ( + "encoding/json" + "time" +) + +// Duration is a custom type alias for time.Duration that provides +// JSON marshaling/unmarshaling support for string values like "100ms". +type Duration time.Duration + +// UnmarshalJSON implements json.Unmarshaler interface. +func (d *Duration) UnmarshalJSON(data []byte) error { + // Try to unmarshal as string first + var s string + + err := json.Unmarshal(data, &s) + if err == nil { + duration, err := time.ParseDuration(s) + if err != nil { + return err + } + + *d = Duration(duration) + + return nil + } + + return json.Unmarshal(data, (*time.Duration)(d)) +} + +// MarshalJSON implements json.Marshaler interface. +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} diff --git a/duration_test.go b/duration_test.go new file mode 100644 index 0000000..4372bad --- /dev/null +++ b/duration_test.go @@ -0,0 +1,78 @@ +package types_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/gripmock/types" +) + +func TestDuration_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + want time.Duration + wantErr bool + }{ + { + name: "string with ms", + input: `"100ms"`, + want: 100 * time.Millisecond, + wantErr: false, + }, + { + name: "string with seconds", + input: `"2s"`, + want: 2 * time.Second, + wantErr: false, + }, + { + name: "invalid duration string", + input: `"invalid"`, + want: 0, + wantErr: true, + }, + } + + for _, testCase := range tests { + // Go 1.22+ runs subtests in parallel safely without copying loop var. + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + var duration types.Duration + + err := json.Unmarshal([]byte(testCase.input), &duration) + + if (err != nil) != testCase.wantErr { + t.Errorf("Duration.UnmarshalJSON() error = %v, wantErr %v", err, testCase.wantErr) + + return + } + + if !testCase.wantErr && time.Duration(duration) != testCase.want { + t.Errorf("Duration.UnmarshalJSON() = %v, want %v", time.Duration(duration), testCase.want) + } + }) + } +} + +func TestDuration_MarshalJSON(t *testing.T) { + t.Parallel() + + duration := types.Duration(100 * time.Millisecond) + expected := `"100ms"` + + got, err := json.Marshal(duration) + if err != nil { + t.Errorf("Duration.MarshalJSON() error = %v", err) + + return + } + + if string(got) != expected { + t.Errorf("Duration.MarshalJSON() = %v, want %v", string(got), expected) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f9a5026 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/gripmock/types + +go 1.24