diff --git a/README.md b/README.md index eed8a62..f4f296b 100644 --- a/README.md +++ b/README.md @@ -1 +1,37 @@ -# pkg \ No newline at end of file +# pkg + +A collection of small, standalone Go packages. + +Every package is a separate Go module with its own dependencies, so importing +one of them does not pull anything else in. + +## Modules + +| Module | Description | +|----------------------------|-------------------------------------------------------------------------------------------------| +| [closer](./closer) | Collection of cleanup functions, run once — explicitly, or on a context cancel or an OS signal. | +| [ctxkey](./ctxkey) | Unique, pointer-backed context keys. | +| [deferred](./deferred) | Helpers for the `defer` calls. | +| [dispatcher](./dispatcher) | Routing of keyed events to handlers, with priorities, propagation control and wildcards. | +| [protomap](./protomap) | Deserialization of protobuf messages by their descriptor name. | +| [strcase](./strcase) | Detection of the case of a string and conversion between the case styles. | +| [sync](./sync) | A `sync.Once` that keeps retrying until the guarded function succeeds. | +| [time](./time) | A duration type that is serialized as a human-readable string. | + +Each module has its own README with the description of the API and the +examples of the usage. + +## Usage + +The packages are imported by their module path: + +```go +import ( + "github.com/nbgrp/pkg/closer" + "github.com/nbgrp/pkg/strcase" +) +``` + +## License + +[BSD 3-Clause](./LICENSE) diff --git a/closer/README.md b/closer/README.md new file mode 100644 index 0000000..977a27d --- /dev/null +++ b/closer/README.md @@ -0,0 +1,172 @@ +# closer + +A small, dependency-free Go package for collecting cleanup functions and running +them exactly once — either explicitly, or automatically when a context is canceled +or an OS signal arrives. + +The package has two layers: + +- a set of package-level functions operating on a global closer, useful for + "process-wide" cleanup; +- the [`Closer`](./closer.go) interface and its implementation, useful when the + cleanup scope is explicit and must be bound to a particular context. + +## Public API + +### Types + +```go +type CloseFn func(ctx context.Context) error + +type Closer interface { + Adder + // SetContext sets context which will be passed into the close functions without cancel. + SetContext(ctx context.Context) + // Done returns signal channel. + Done() <-chan struct{} + // Err returns a joint error based on close functions errors. + Err() error + // CloseAll runs close functions in arbitrary order. + CloseAll() +} + +type Adder interface { + // Add adds close function. + Add(CloseFn) +} +``` + +- `CloseFn` is a cleanup function. It receives a context, but not the one the + closer was bound to — the cancellation is stripped away (see + [The close context](#the-close-context)). +- `Adder` is the registration half of `Closer`: it only knows how to register + a close function. +- `Closer` adds the lifecycle to it: setting the close context, waiting for the + cleanup to finish (`Done`), reading the result (`Err`) and triggering it + (`CloseAll`). + +### The global closer + +```go +func SetContext(ctx context.Context) +func Add(f CloseFn) +func Done() <-chan struct{} +func Err() error +func CloseAll() +``` + +These operate on a package-level closer that is created on initialization. They +are handy for libraries that need to register a cleanup without owning the +application's lifecycle: the application calls `closer.CloseAll()` on shutdown +and inspects `closer.Err()` afterwards. + +The global closer is created with `context.Background()`, so +`closer.SetContext(...)` is the way to give the close functions access to the +application context. + +### Creating a closer + +```go +func New(ctx context.Context, opts ...Option) (context.Context, *closer) +``` + +`New` returns the closer together with the context it is bound to. The context +is returned because it may be replaced by `New` itself: with `WithSignals` the +returned context is a signal-notifiable one, and it is this returned value — +not the one passed in — that has to be used by the application. + +```go +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +ctx, cl := closer.New(ctx, closer.WithContextCancel()) +defer cl.CloseAll() +``` + +`New` panics if `ctx` is nil. + +## Options + +```go +func WithContextCancel() Option +func WithSignals(signals ...os.Signal) Option +``` + +- `WithContextCancel` closes the closer automatically as soon as the context + passed to `New` is done. +- `WithSignals` does the same, but the context is first wrapped into a + signal-notifiable one, so that the closer is closed when any of the given + signals arrives. It implies `WithContextCancel`. + +```go +ctx, cl := closer.New(context.Background(), closer.WithSignals(syscall.SIGINT, syscall.SIGTERM)) + +cl.Add(func(ctx context.Context) error { + fmt.Println("shutting down") + return nil +}) + +<-ctx.Done() // the context is canceled on SIGINT/SIGTERM +<-cl.Done() // ... and the closer is closed right after that +``` + +## Closing + +`CloseAll` runs every registered close function and then closes the `Done` +channel. It is idempotent: only the first call has an effect, subsequent calls +return immediately. + +The close functions run **concurrently**, in an arbitrary order, and each of them +gets its own goroutine. There is no ordering guarantee between them, so a close +function must not depend on another one having finished. + +`Done` is closed after all close functions have returned, which makes it the +right place to wait for the cleanup to complete: + +```go +cl.CloseAll() +<-cl.Done() + +if err := cl.Err(); err != nil { + // at least one close function failed +} +``` + +## Errors + +`Err` returns the `errors.Join` of all errors returned by the close functions. +It returns `nil` while the closer is not closed yet, and `nil` after it has been +closed if every close function succeeded. + +Errors are collected, not short-circuited: a failing close function does not +prevent the others from running. + +## The close context + +Close functions receive a context that carries the **values** of the context +set on the closer, but is **not** canceled when that context is canceled +(the implementation uses `context.WithoutCancel`). + +This is what makes the automatic shutdown work: with `WithContextCancel` the +closer is closed exactly because the context is done, and passing that canceled +context to the cleanup functions would make them abort immediately. + +The context can be replaced at any time with `SetContext`: + +```go +cl.SetContext(context.WithValue(ctx, key, "value")) +``` + +The closer reads the context at the moment it is closed, so the last value set +before `CloseAll` is the one the close functions receive. + +## Concurrency + +`Add`, `SetContext`, `Done`, `Err` and `CloseAll` are safe for concurrent use. + +`Add` is lock-free: the functions are stored in an atomically swapped slice, so +registering a cleanup never blocks the caller. Registering a function after the +closer has been closed has no effect — the function is never called. + +`CloseAll` runs the close functions concurrently and waits for all of them +before closing the `Done` channel. diff --git a/closer/closer.go b/closer/closer.go index 0c57c8c..ab610fa 100644 --- a/closer/closer.go +++ b/closer/closer.go @@ -10,6 +10,11 @@ import ( "sync/atomic" ) +// CloseFn is a cleanup function registered in a Closer. +// +// The context passed to it carries the values of the context set on the +// Closer, but is detached from its cancellation, so that the cleanup is able +// to finish its work even after the context has been canceled. type CloseFn func(ctx context.Context) error // Closer cleans up resources. @@ -39,6 +44,7 @@ type closer struct { once sync.Once } +// global is the Closer used by the package-level functions. var _, global = New(context.Background()) // SetContext sets context into the global Closer. @@ -66,7 +72,14 @@ func CloseAll() { global.CloseAll() } -// New returns Closer implementation. +// New returns a Closer implementation together with the context it is bound to. +// +// The returned context is the one passed in, wrapped into a signal-notifiable +// context when [WithSignals] is used. When [WithContextCancel] or +// [WithSignals] is used, the closer is closed automatically as soon as that +// context is done. +// +// New panics if ctx is nil. func New(ctx context.Context, opts ...Option) (context.Context, *closer) { o := options{} for _, opt := range opts { @@ -98,6 +111,7 @@ func New(ctx context.Context, opts ...Option) (context.Context, *closer) { return ctx, c } +// SetContext sets the context passed to the close functions. func (c *closer) SetContext(ctx context.Context) { if ctx == nil { panic("cannot set nil context") @@ -105,6 +119,10 @@ func (c *closer) SetContext(ctx context.Context) { c.ctx.Store(&ctx) } +// Add registers a close function. +// +// The function is called exactly once, when the closer is closed. Registering +// a function in an already closed closer has no effect. func (c *closer) Add(f CloseFn) { var funcs []CloseFn old := c.funcs.Load() @@ -126,10 +144,14 @@ func (c *closer) Add(f CloseFn) { } } +// Done returns a channel that is closed after all close functions have finished. func (c *closer) Done() <-chan struct{} { return c.done } +// Err returns the joined error of the close functions. +// +// It returns nil until the closer is closed. func (c *closer) Err() error { select { case <-c.done: @@ -140,6 +162,12 @@ func (c *closer) Err() error { } } +// CloseAll runs all registered close functions concurrently and closes the done +// channel. +// +// It is safe to call it multiple times: only the first call has an effect. The +// close functions receive a context detached from the cancellation of the +// context set on the closer, so that they are able to complete their work. func (c *closer) CloseAll() { //nolint:gocognit c.once.Do(func() { defer close(c.done) diff --git a/closer/options.go b/closer/options.go index e9cf3b1..9a31187 100644 --- a/closer/options.go +++ b/closer/options.go @@ -11,6 +11,7 @@ type options struct { ctxCancel bool } +// Option configures a Closer created by [New]. type Option func(options) options // WithContextCancel allows to call closer CloseAll on context cancel implicitly. diff --git a/ctxkey/README.md b/ctxkey/README.md new file mode 100644 index 0000000..4aee398 --- /dev/null +++ b/ctxkey/README.md @@ -0,0 +1,101 @@ +# ctxkey + +A tiny, dependency-free Go package for creating unique context keys. + +`context.WithValue` accepts any comparable value as a key, but the [official +advice](https://go.dev/blog/context#Customizing-Context) is to define a dedicated +type for every key, so that keys from different packages cannot collide. This +package does that in one line, without the boilerplate of declaring a type per +key. + +## Public API + +```go +type Key interface { + fmt.Stringer +} + +func New(name string) Key +``` + +- `New` returns a new key with the given name. +- `Key` is an opaque interface: the only thing a caller can do with a key is + compare it with another key or read its string representation. + +## Usage + +```go +import ( + "context" + + "github.com/nbgrp/pkg/ctxkey" +) + +var userIDKey = ctxkey.New("user_id") + +func WithUserID(ctx context.Context, id int64) context.Context { + return context.WithValue(ctx, userIDKey, id) +} + +func UserID(ctx context.Context) (int64, bool) { + id, ok := ctx.Value(userIDKey).(int64) + return id, ok +} +``` + +The key is usually declared once, next to the accessor that uses it, and is then +passed around as an ordinary value. + +## Uniqueness + +Every call to `New` returns a distinct key. Two keys are equal only if they are +the same pointer, so a key created by `New("id")` in one package never matches +a key created by `New("id")` in another one — and it never matches a string +`"id"` either. + +```go +a := ctxkey.New("id") +b := ctxkey.New("id") + +fmt.Println(a == b) // false +ctx := context.WithValue(context.Background(), a, 1) +fmt.Println(ctx.Value(b)) // nil +fmt.Println(ctx.Value("id")) // nil +``` + +The name is only used for the string representation, so it does not have to be +unique. + +## String representation + +A key prints itself as `ctxkey ""`, which makes it recognizable in logs +and in `%+v` formatting of wrapped errors. + +```go +key := ctxkey.New("user_id") +fmt.Println(key) // ctxkey "user_id" +``` + +The `Key` interface embeds `fmt.Stringer`, so a key can be passed to any +formatting function directly. + +## Allocations + +A key is backed by a pointer to a private struct, so it fits into an `any` +without an extra allocation for the interface conversion: + +```go +var myKey = ctxkey.New("MyKey") +// 2 allocations: the context struct and the value +context.WithValue(ctx, myKey, someValue) +``` + +The common alternative — declaring a named integer type for the key — allocates +one extra time, because the key itself has to be boxed into an `any`: + +```go +type key int +var myKey key = 0 +// 3 allocations: the context struct, the key and the value +context.WithValue(ctx, myKey, someValue) +``` diff --git a/ctxkey/ctxkey.go b/ctxkey/ctxkey.go index 085caea..57e24bd 100644 --- a/ctxkey/ctxkey.go +++ b/ctxkey/ctxkey.go @@ -34,18 +34,29 @@ import ( "fmt" ) +// Key is a context key created by [New]. +// +// It is an opaque, unique value: two keys created by [New] are never equal, +// even if they were given the same name. type Key interface { fmt.Stringer } +// key is a pointer-backed context key. The pointer identity is what makes the +// key unique, the name is only used for the [fmt.Stringer] representation. type key struct { name string } +// New returns a new unique context key with the given name. +// +// The name is only used to make the key human-readable in logs and debug +// output; it does not affect the uniqueness of the key. func New(name string) Key { return &key{name} } +// String returns the human-readable representation of the key. func (k key) String() string { return `ctxkey "` + k.name + `"` } diff --git a/deferred/README.md b/deferred/README.md new file mode 100644 index 0000000..c8f6c9d --- /dev/null +++ b/deferred/README.md @@ -0,0 +1,76 @@ +# deferred + +A tiny, dependency-free Go package with helpers for the `defer` calls. + +The deferred calls have a few sharp edges of their own: a resource that has to +be closed without losing the close error, a cleanup that has to happen even if +the function panics, a value that has to be measured after the deferred +functions have run. The helpers here take care of such cases, so that they do +not have to be reinvented in every project. + +## Public API + +### `Close` + +```go +func Close(err *error, closer io.Closer) +``` + +`Close` closes the `closer` and joins the error it returns with the error +pointed to by `err`. + +It is meant to be used with `defer`, right after the resource is opened: + +```go +func process(path string) (err error) { + f, err := os.Open(path) + if err != nil { + return err + } + defer deferred.Close(&err, f) + + return doSomething(f) +} +``` + +Without the helper the close error would have to be handled explicitly, and the +usual `defer f.Close()` swallows it: + +```go +defer f.Close() // the error is dropped +``` + +#### Semantics + +- The closer is **always** closed, even if `err` is nil. +- If `err` is nil, the close error is discarded — the closer is still closed, + but the result is thrown away. +- Otherwise the close error is joined with the current value of `*err` using + `errors.Join`, so both errors are reported. + +```go +deferred.Close(&err, closer) +// err == nil -> err stays nil, the close error is discarded +// err == errInternal -> err becomes the join of errInternal and the close error +``` + +Since the error is joined, the result is never equal to one of its parts. Use +`errors.Is` to check for a particular error: + +```go +defer deferred.Close(&err, closer) +// ... +if errors.Is(err, os.ErrNotExist) { + // ... +} +``` + +#### Ordering + +`defer` runs the deferred calls in reverse order, so the resources are closed in +the opposite order to the one in which they were opened. This is the behavior +you usually want, and the helper does not change it. + +The helper is also safe to use with a closer that returns an error and a +function that returns one: both errors end up in the same joined error, and +neither of them is lost. diff --git a/dispatcher/README.md b/dispatcher/README.md index 3669738..a1624cf 100644 --- a/dispatcher/README.md +++ b/dispatcher/README.md @@ -51,8 +51,12 @@ type StopPropagationError struct { ``` A handler may return a `*StopPropagationError` to short-circuit dispatch and (optionally) -propagate an inner error. See each backend's documentation for the exact semantics — in -particular, `ModePriority` and `ModeConcurrent` interpret it differently. +propagate an inner error. The `StopPropagationError` itself is never reported by the +dispatch — it is only a signal. When the `Inner` error is nil, the dispatch reports no +error for that handler at all. + +See each backend's documentation for the exact semantics — in particular, `ModePriority` +and `ModeConcurrent` interpret it differently. ## Implementations @@ -106,7 +110,8 @@ Handlers are sorted in **descending** order of their `priority` and called seque Handlers registered with the same priority keep their registration order (sort is stable). Returning a `*dispatcher.StopPropagationError` from a handler stops the chain: handlers with lower priority are **not** called. If `StopPropagationError.Inner` is non-nil, it is -returned by `Dispatch`; otherwise `Dispatch` returns `nil`. +reported by `Dispatch`; otherwise the stop itself contributes no error, and `Dispatch` +returns the joined errors of the handlers that have run. ```go d, _ := trie.NewDispatcher(trie.WithMode(trie.ModePriority)) @@ -213,8 +218,9 @@ wildcard mark. Anything else is fixed at construction time. #### Errors `Dispatch` returns `errors.Join(errs...)` of every matched handler's return value (after -unwrapping `StopPropagationError` per the rules above). It returns `nil` only when every -handler returned `nil` (or a `StopPropagationError` without an `Inner`). +unwrapping `StopPropagationError` per the rules above). The `StopPropagationError` itself +never appears in the result. `Dispatch` returns `nil` only when every handler returned +`nil` (or a `StopPropagationError` without an `Inner`). A handler may short-circuit the chain in `ModePriority` by returning: diff --git a/dispatcher/dispatcher.go b/dispatcher/dispatcher.go index 777eebd..2d64169 100644 --- a/dispatcher/dispatcher.go +++ b/dispatcher/dispatcher.go @@ -6,10 +6,20 @@ import ( "context" ) +// StopPropagationError is returned by a handler to stop the dispatch chain. +// +// The handlers that are not called yet are skipped. The error itself is never +// reported by the dispatch, it is only a signal: the Inner error is reported +// in its place, and when the Inner error is nil, the dispatch reports no error +// at all. type StopPropagationError struct { + // Inner is the error the dispatch reports in place of the + // StopPropagationError itself. It may be nil. Inner error } +// Error returns the message of the error: "stop propagation", followed by the +// message of the Inner error, if there is one. func (e *StopPropagationError) Error() string { msg := "stop propagation" if e.Inner != nil { @@ -18,16 +28,27 @@ func (e *StopPropagationError) Error() string { return msg } +// Handler is a callback invoked on a matching dispatch. +// +// The payload is the one passed to the dispatch call. type Handler func(context.Context, ...any) error +// Dispatcher routes the payload to the handlers registered for the key. type Dispatcher interface { + // Dispatch calls the handlers registered for the key and returns their joined errors. Dispatch(ctx context.Context, key string, payload ...any) error } +// Listener registers the handlers for a key pattern. type Listener interface { + // Listen registers the handler for the key pattern and returns the function + // that cancels the registration. Listen(keyPattern string, handler Handler) (cancel func(), err error) } +// PriorityListener registers the handlers for a key pattern, with a priority. type PriorityListener interface { + // ListenWithPriority registers the handler for the key pattern with the given + // priority and returns the function that cancels the registration. ListenWithPriority(keyPattern string, handler Handler, priority int) (cancel func(), err error) } diff --git a/dispatcher/trie/trie.go b/dispatcher/trie/trie.go index d761dcc..b6643eb 100644 --- a/dispatcher/trie/trie.go +++ b/dispatcher/trie/trie.go @@ -14,10 +14,15 @@ import ( pkgdispatcher "github.com/nbgrp/pkg/dispatcher" ) +// mode is the way the matched handlers are executed. type mode int const ( + // ModePriority runs the handlers sequentially, in the descending order of + // their priority. ModePriority mode = iota + // ModeConcurrent runs all the matched handlers concurrently and waits for + // all of them to finish. ModeConcurrent ) @@ -27,32 +32,44 @@ type options struct { mode mode } +// Option configures a dispatcher created by [NewDispatcher]. type Option func(*options) +// WithKeySeparator sets the separator the keys are split by. +// The default is '.'. func WithKeySeparator(separator rune) Option { return func(opts *options) { opts.keySeparator = separator } } +// WithWildcardMark sets the mark that denotes a wildcard part of a key. +// The default is '*'. func WithWildcardMark(mark rune) Option { return func(opts *options) { opts.wildcardMark = mark } } +// WithMode sets the mode the matched handlers are executed in. +// The default is [ModePriority]. func WithMode(mode mode) Option { return func(opts *options) { opts.mode = mode } } +// nodeHandler is a handler registered on a node of the trie. type nodeHandler struct { handler pkgdispatcher.Handler priority int - deleted atomic.Bool + // deleted is set by the cancel function returned by the Listen methods. + // The handler stays in the trie until it is pruned on the next match. + deleted atomic.Bool } +// node is a part of the trie. It holds the handlers registered for the key +// ending with this part, and the nodes for the next parts. type node struct { children map[string]*node handlers []*nodeHandler @@ -60,6 +77,10 @@ type node struct { mu sync.Mutex } +// purgeDeleted removes the handlers that were cancelled from the node. +// +// It is called on the visited nodes only, so a cancelled handler is pruned +// when a dispatch walks through its node. func (n *node) purgeDeleted() { n.mu.Lock() defer n.mu.Unlock() @@ -80,6 +101,10 @@ type dispatcher struct { opts options } +// NewDispatcher returns a dispatcher with the given options. +// +// The default options are [ModePriority], a '.' key separator and a '*' +// wildcard mark. func NewDispatcher(opts ...Option) (*dispatcher, error) { o := options{ keySeparator: '.', @@ -95,10 +120,20 @@ func NewDispatcher(opts ...Option) (*dispatcher, error) { }, nil } +// Listen registers the handler for the key pattern and returns the function +// that cancels the registration. +// +// The key pattern must not be empty, must not start or end with the separator, +// must not contain two consecutive separators, and the handler must not be nil. func (d *dispatcher) Listen(keyPattern string, handler pkgdispatcher.Handler) (cancel func(), err error) { return d.ListenWithPriority(keyPattern, handler, 0) } +// ListenWithPriority registers the handler for the key pattern with the given +// priority and returns the function that cancels the registration. +// +// The higher the priority, the earlier the handler is called in [ModePriority]. +// The priority is ignored in [ModeConcurrent]. func (d *dispatcher) ListenWithPriority(keyPattern string, handler pkgdispatcher.Handler, priority int) (cancel func(), err error) { switch { case keyPattern == "": @@ -137,6 +172,11 @@ func (d *dispatcher) ListenWithPriority(keyPattern string, handler pkgdispatcher }, nil } +// Dispatch calls the handlers registered for the key and returns their joined +// errors. +// +// The handlers are called in the order that depends on the mode of the +// dispatcher, and the payload is passed to all of them. func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) error { //nolint:gocognit wm := string(d.opts.wildcardMark) var handlers []*nodeHandler @@ -187,9 +227,7 @@ func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) e for i, h := range handlers { err := h.handler(ctx, payload...) if stopPropagation, ok := errors.AsType[*pkgdispatcher.StopPropagationError](err); ok { - if stopPropagation.Inner != nil { - errs[i] = stopPropagation.Inner - } + errs[i] = stopPropagation.Inner break } errs[i] = err @@ -201,11 +239,7 @@ func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) e wg.Go(func() { err := h.handler(ctx, payload...) if stopPropagation, ok := errors.AsType[*pkgdispatcher.StopPropagationError](err); ok { - if stopPropagation.Inner != nil { - err = stopPropagation.Inner - } else { - err = nil - } + err = stopPropagation.Inner } errs[i] = err }) @@ -216,6 +250,10 @@ func (d *dispatcher) Dispatch(ctx context.Context, key string, payload ...any) e return errors.Join(errs...) } +// splitKey splits the key into the parts separated by the key separator. +// +// The empty parts are dropped, so a dispatched key with consecutive, leading +// or trailing separators is split into the same parts as a valid one. func (d *dispatcher) splitKey(key string) iter.Seq[string] { return strings.FieldsFuncSeq(key, func(r rune) bool { return r == d.opts.keySeparator diff --git a/protomap/README.md b/protomap/README.md new file mode 100644 index 0000000..efe82d1 --- /dev/null +++ b/protomap/README.md @@ -0,0 +1,130 @@ +# protomap + +A small Go package for deserializing protobuf messages dynamically — by their +full descriptor name, without knowing their concrete types at compile time. + +It is useful when the message type is not known statically: a plugin registry, a +configuration format, a proxy that relays messages of arbitrary types, or a test +harness that decodes payloads produced elsewhere. + +## Public API + +```go +var ErrNoPrototype = errors.New("no prototype for message") + +type Protomap[T proto.Message] map[protoreflect.FullName]func() T + +func From[T proto.Message](messages []T) Protomap[T] +func (p Protomap[T]) Unmarshal(fullName protoreflect.FullName, data []byte) (T, error) +``` + +- `Protomap` maps the full name of a protobuf message descriptor + (`protoreflect.FullName`) to a constructor of new instances of that message. +- `From` builds a `Protomap` from a slice of messages, using each message only + as a prototype: the registered constructor always returns a **new, empty** + instance of the same type. +- `Unmarshal` creates a new instance of the registered type and decodes the + given bytes into it. + +## Usage + +```go +package main + +import ( + "fmt" + + "github.com/nbgrp/pkg/protomap" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func main() { + p := protomap.From([]proto.Message{ + ×tamppb.Timestamp{}, + &structpb.Struct{}, + }) + + data, err := proto.Marshal(×tamppb.Timestamp{Seconds: 42}) + if err != nil { + panic(err) + } + + msg, err := p.Unmarshal("google.protobuf.Timestamp", data) + if err != nil { + panic(err) + } + + fmt.Println(msg.(*timestamppb.Timestamp).GetSeconds()) // 42 +} +``` + +`From` is normally called once, on startup, with the list of the message types +the application is able to handle. `Unmarshal` is then called for every incoming +payload, with the name of the message it is expected to contain. + +## Prototypes + +A prototype is used for two things: to obtain the full name of the message +descriptor, and to know the concrete Go type to instantiate. The prototype +itself is never returned to the caller — `Unmarshal` always creates a fresh +instance, so the decoded messages never share state. + +```go +src := ×tamppb.Timestamp{Seconds: 42} +p := protomap.From([]proto.Message{src}) + +first, _ := p.Unmarshal("google.protobuf.Timestamp", data) +second, _ := p.Unmarshal("google.protobuf.Timestamp", data) + +first.Seconds = 100 +fmt.Println(second.GetSeconds()) // 42 +``` + +## Building the map + +`From` accepts messages of different types in the same slice, as long as they +all implement the type parameter `T`. If the same message type occurs several +times, only a single prototype is registered for it. + +The map is usually built with `T = proto.Message`, which gives access to every +registered message type. A narrower type parameter gives a map that only +contains the messages of that type: + +```go +p := protomap.From([]*timestamppb.Timestamp{ + ×tamppb.Timestamp{}, +}) + +msg, err := p.Unmarshal("google.protobuf.Timestamp", data) +// msg is a *timestamppb.Timestamp, no type assertion needed +``` + +`From` panics if the slice contains a nil message or a message that is not used +as a pointer — a prototype that cannot be instantiated is a programming error, +and it is better to fail on startup than on the first incoming payload. The +prototype is also created eagerly for the same reason: a broken prototype is +reported when the map is built, not later, when a message is unmarshaled. + +## Errors + +`Unmarshal` returns an error wrapping `ErrNoPrototype` when the map has no +prototype for the requested name: + +```go +_, err := p.Unmarshal("google.protobuf.Unknown", data) +if errors.Is(err, protomap.ErrNoPrototype) { + // the message type is not registered +} +``` + +A decoding error is returned as is, wrapped with the name of the message for +context. In that case the message is returned too — it may be partially filled +with the data that was successfully parsed. + +## Concurrency + +`Protomap` is a plain map, so it must not be modified concurrently with +`Unmarshal`. Build it once, before it is used, and it is safe to read from +multiple goroutines. diff --git a/protomap/protomap.go b/protomap/protomap.go index 34b2714..1c6394a 100644 --- a/protomap/protomap.go +++ b/protomap/protomap.go @@ -77,7 +77,11 @@ func From[T proto.Message](messages []T) Protomap[T] { // registered under the given full descriptor name. // // It returns the new message instance and an error, if any. If the map has no -// prototype for the given name, the returned error wraps [ErrNoPrototype]. +// prototype for the given name, the returned error wraps [ErrNoPrototype] and +// the message is the zero value. +// +// On a decoding error the message is returned as well: it may be partially +// filled with the data that was successfully parsed. func (p Protomap[T]) Unmarshal(fullName protoreflect.FullName, data []byte) (msg T, err error) { protoFunc, ok := p[fullName] if !ok { diff --git a/strcase/README.md b/strcase/README.md new file mode 100644 index 0000000..c2f3a26 --- /dev/null +++ b/strcase/README.md @@ -0,0 +1,120 @@ +# strcase + +A small, dependency-free Go package for detecting the case of a string and +converting it between the common case styles. + +## Public API + +```go +type Mode int + +const ( + _ Mode = iota + FlatCase + CamelCase + SnakeCase + KebabCase +) + +func Detect(s string) Mode +func Convert(s string) Converter +func (c Converter) To(target Mode) string +func (c Mode) ConvertTo(target Mode, s string) string +``` + +- `Mode` is a case style. `FlatCase` is a lowercase string without separators (`helloworld`), + `CamelCase` capitalizes the word boundaries (`helloWorld`), `SnakeCase` separates the words with + underscores (`hello_world`), `KebabCase` separates them with dashes (`hello-world`). +- `Detect` returns the case of the given string. +- `Convert` detects the case and returns a `Converter` bound to that string. +- `Converter.To` converts the string to the target case. +- `Mode.ConvertTo` converts a string of a known case to the target case, for the + cases where the detection is not needed. + +## Usage + +```go +import ( + "fmt" + + "github.com/nbgrp/pkg/strcase" +) + +func main() { + fmt.Println(strcase.Convert("hello_world").To(strcase.CamelCase)) // helloWorld + fmt.Println(strcase.Convert("helloWorld").To(strcase.SnakeCase)) // hello_world + fmt.Println(strcase.Convert("hello-world").To(strcase.CamelCase)) // helloWorld +} +``` + +When the case of the string is already known, `Mode.ConvertTo` can be used +directly, skipping the detection: + +```go +fmt.Println(strcase.SnakeCase.ConvertTo(strcase.KebabCase, "hello_world")) // hello-world +``` + +## Detection + +`Detect` looks at the separators first, and then at the letters: + +- a string containing an underscore is a `SnakeCase` one; +- a string containing a dash is a `KebabCase` one; +- a string containing both lower and upper case letters is a `CamelCase` one; +- everything else is a `FlatCase` one. + +`FlatCase` is also the fallback for an empty string, and for a string without +any case marker at all. + +```go +strcase.Detect("hello_world") // strcase.SnakeCase +strcase.Detect("hello-world") // strcase.KebabCase +strcase.Detect("helloWorld") // strcase.CamelCase +strcase.Detect("HELLO_WORLD") // strcase.SnakeCase +strcase.Detect("helloworld") // strcase.FlatCase +strcase.Detect("") // strcase.FlatCase +``` + +Only the first 100 bytes of the string are inspected, so that the detection +stays cheap on long strings. A separator that appears later than that is not +taken into account. + +## Conversion + +The conversion is driven by the detected case of the source string: + +| source | `FlatCase` | `CamelCase` | `SnakeCase` | `KebabCase` | +|-----------------------------|--------------|--------------|---------------|---------------| +| `helloWorld` (`CamelCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `hello_world` (`SnakeCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `hello-world` (`KebabCase`) | `helloworld` | `helloWorld` | `hello_world` | `hello-world` | +| `helloworld` (`FlatCase`) | `helloworld` | `helloworld` | `helloworld` | `helloworld` | + +Two rules deserve a note: + +- Converting **from** `FlatCase` is a no-op: the case of such a string is + unknown, so there is nothing to split or to join. +- Converting **to** `CamelCase` from a separated case removes the separator and + capitalizes the following letter; converting from `CamelCase` splits the + string into words. + +## Abbreviations + +When a camel case string is split into words, the abbreviations are recognized +and kept together: + +```go +strcase.Convert("ReadJSONData").To(strcase.SnakeCase) // read_json_data +strcase.Convert("JSONData").To(strcase.SnakeCase) // json_data +strcase.Convert("PlanBDone").To(strcase.SnakeCase) // plan_b_done +``` + +A single letter is treated as a word of its own, and the conversion between two +separated cases (`SnakeCase` and `KebabCase`) only replaces the separator, so +the words themselves are left untouched. + +## Limitations + +The detection is a heuristic: it looks at the shape of the string, not at the +words it contains. A string without any separator and without any upper case +letter is indistinguishable from a `FlatCase` one, and is treated as such. diff --git a/strcase/strcase.go b/strcase/strcase.go index ed77c01..c71038a 100644 --- a/strcase/strcase.go +++ b/strcase/strcase.go @@ -1,5 +1,7 @@ // SPDX-License-Identifier: BSD-3-Clause +// Package strcase provides detection of the case of a string and conversion +// between the supported case styles. package strcase import ( @@ -7,13 +9,21 @@ import ( "unicode" ) +// Mode is a case style of a string. type Mode int const ( _ Mode = iota + // FlatCase is a lowercase string without any separator, e.g. "helloworld". + // It is also the fallback mode, used when the case of a string cannot be + // detected. FlatCase + // CamelCase is a string with capitalized word boundaries and no separator, + // e.g. "helloWorld". CamelCase + // SnakeCase is a lowercase string separated by underscores, e.g. "hello_world". SnakeCase + // KebabCase is a lowercase string separated by dashes, e.g. "hello-world". KebabCase ) @@ -24,11 +34,15 @@ const ( maxWordLength = 100 ) +// Converter converts a string from the case it was detected in to another one. type Converter struct { s string mode Mode } +// Convert detects the case of the given string and returns a converter for it. +// +// Use [Converter.To] to convert the string to another case. func Convert(s string) Converter { return Converter{ mode: Detect(s), @@ -36,10 +50,16 @@ func Convert(s string) Converter { } } +// To converts the string to the given target case. func (c Converter) To(target Mode) string { return c.mode.ConvertTo(target, c.s) } +// ConvertTo converts the string s from the c case to the target case. +// +// Converting a string to the case it already has returns it unchanged. +// [FlatCase] means that the case of the string is unknown, so converting from +// it is a no-op as well. func (c Mode) ConvertTo(target Mode, s string) string { switch { case c == target, c == FlatCase: @@ -53,6 +73,7 @@ func (c Mode) ConvertTo(target Mode, s string) string { } } +// sep returns the separator rune of the mode. func (c Mode) sep() rune { switch c { case SnakeCase: @@ -64,6 +85,18 @@ func (c Mode) sep() rune { } } +// Detect returns the case of the given string. +// +// The detection is based on the separators and on the presence of both lower +// and upper case letters: a string containing an underscore is a snake case +// one, a string containing a dash is a kebab case one, and a string with both +// lower and upper case letters is a camel case one. +// +// Only the first 100 bytes of the string are inspected, so that the detection +// stays cheap on long strings. +// +// An empty string, as well as a string without any case marker, is detected as +// [FlatCase]. func Detect(s string) Mode { if s == "" { return FlatCase // FlatCase used as a fallback value @@ -102,6 +135,8 @@ func Detect(s string) Mode { return FlatCase } +// toCamel removes the separator from the string and capitalizes the letter +// that follows it. func toCamel(s string, sep rune) string { if s == "" { return "" @@ -128,6 +163,10 @@ func toCamel(s string, sep rune) string { return sb.String() } +// fromCamel splits the camel case string into words and joins them with the +// given separator, lowercasing the result. +// +// The abbreviations are handled: "ReadJSONData" becomes "read_json_data". func fromCamel(s string, sep rune) string { if s == "" { return "" @@ -176,6 +215,10 @@ func fromCamel(s string, sep rune) string { return sb.String() } +// replaceSep replaces the fromSep separator with the toSep one. +// +// A noSep source separator means that the string has no separators at all and +// is returned as is; a noSep target separator removes the separators. func replaceSep(s string, fromSep, toSep rune) string { if fromSep == noSep { // flat case or other non-delimited string diff --git a/sync/README.md b/sync/README.md new file mode 100644 index 0000000..920d19b --- /dev/null +++ b/sync/README.md @@ -0,0 +1,89 @@ +# sync + +A small, dependency-free Go package with a `sync.Once` that keeps retrying until +the guarded function succeeds. + +## Public API + +```go +type SuccessfulOnce struct { + // contains unexported fields +} + +func (o *SuccessfulOnce) Do(fn func() error) error +``` + +`SuccessfulOnce` is a zero-value ready value: it needs no initialization and +must not be copied after the first use. + +## Usage + +```go +import ( + pkgSync "github.com/nbgrp/pkg/sync" +) + +var once pkgSync.SuccessfulOnce + +err := once.Do(func() error { + return connect() // retried until it succeeds +}) +``` + +The package is named `sync`, just like the standard library one, so it usually +has to be imported under an alias: + +```go +import ( + stdsync "sync" + + pkgSync "github.com/nbgrp/pkg/sync" +) +``` + +## Semantics + +`Do` calls `fn` until it returns a nil error: + +- while `fn` fails, its error is returned to the caller and `fn` is called + again by the next `Do` call; +- the first successful call is remembered, and every `Do` call made after it + returns nil without calling `fn` again. + +```go +var once sync.SuccessfulOnce + +// the first two calls fail, the third one succeeds +err := once.Do(func() error { return errors.New("boom") }) // boom +err = once.Do(func() error { return errors.New("boom") }) // boom +err = once.Do(func() error { return nil }) // nil +err = once.Do(func() error { return errors.New("boom") }) // nil, fn is not called +``` + +The function is only ever called with the arguments of the caller that won the +race to run it — it takes no arguments, so the state it needs is usually closed +over. + +## Comparison with `sync.Once` + +| | `sync.Once` | `SuccessfulOnce` | +|-------------------------|--------------|-----------------------| +| The function may fail | no | yes | +| Retries after a failure | no | yes, on the next call | +| Returns an error | no | yes | +| The function is called | exactly once | until it succeeds | + +`SuccessfulOnce` is the right choice when the guarded operation is idempotent +but may fail for transient reasons — connecting to a service, acquiring a lock +through an external system, initializing a resource. + +## Concurrency + +`Do` is safe for concurrent use. + +The calls are serialized: only one of them runs `fn` at a time, and the others +wait for it. If `fn` fails, the next waiter runs it in turn, so a failing +function is retried by the concurrent callers as well — one attempt per caller. + +The success flag is read without the lock on the fast path, so a `Do` call made +after a success returns immediately, without blocking. diff --git a/sync/once.go b/sync/once.go index c956ce6..e96cda8 100644 --- a/sync/once.go +++ b/sync/once.go @@ -8,12 +8,23 @@ import ( ) // SuccessfulOnce allows to run user function once successfully. +// +// Unlike the standard library sync.Once, the function is retried as long as it +// fails: the call is considered successful only when the function returns a nil +// error, and every call made after that is a no-op. type SuccessfulOnce struct { done atomic.Uint32 mu sync.Mutex } -// Do runs fn if previous fn call returned an error. +// Do runs fn until it succeeds. +// +// The function is called only while it has not succeeded yet. Its error is +// returned to the caller, and the next call retries it. Once fn has succeeded, +// Do returns nil without calling it again. +// +// The calls are serialized: while fn is running, the other callers wait for it +// to finish. func (o *SuccessfulOnce) Do(fn func() error) error { if o.done.Load() == 0 { return o.doSlow(fn) @@ -22,6 +33,7 @@ func (o *SuccessfulOnce) Do(fn func() error) error { return nil } +// doSlow runs fn under the lock, so that only one call is in flight at a time. func (o *SuccessfulOnce) doSlow(fn func() error) error { o.mu.Lock() defer o.mu.Unlock() diff --git a/time/README.md b/time/README.md new file mode 100644 index 0000000..018d4eb --- /dev/null +++ b/time/README.md @@ -0,0 +1,113 @@ +# time + +A tiny, dependency-free Go package with a duration type that is serialized as a +human-readable string instead of a number of nanoseconds. + +`time.Duration` is a plain `int64`, so it is marshaled to JSON as a bare +number — `1800000000000` instead of `30m0s`. This package provides a type that +keeps the readable form in both JSON and YAML. + +## Public API + +```go +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) +func (d *Duration) UnmarshalJSON(b []byte) error +func (d Duration) MarshalYAML() (interface{}, error) +func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error +``` + +`Duration` implements `json.Marshaler`, `json.Unmarshaler` and the YAML +marshaler and unmarshaler interfaces, so it works with `encoding/json` and with +`gopkg.in/yaml.v3` out of the box. + +## Usage + +```go +import ( + "encoding/json" + "fmt" + + pkgTime "github.com/nbgrp/pkg/time" +) + +type Config struct { + Timeout pkgTime.Duration `json:"timeout"` +} + +data, _ := json.Marshal(Config{Timeout: pkgTime.Duration(30 * time.Second)}) +// data == {"timeout":"30s"} +``` + +The package is named `time`, just like the standard library one, so it usually +has to be imported under an alias. + +## Serialization + +A duration is always written as a string, in the format of +[`time.Duration.String`](https://pkg.go.dev/time#Duration.String): + +```go +json.Marshal(pkgTime.Duration(0)) // "0s" +json.Marshal(pkgTime.Duration(30 * time.Second)) // "30s" +json.Marshal(pkgTime.Duration(5 * time.Hour)) // "5h0m0s" +``` + +The same holds for YAML: + +```go +yaml.Marshal(pkgTime.Duration(30 * time.Second)) // "30s\n" +``` + +## Deserialization + +Both a duration string and a number are accepted: + +| value | JSON | YAML | +|-----------|---------------------|---------------------| +| `"30s"` | 30 seconds | 30 seconds | +| `30` | 30 nanoseconds | 30 nanoseconds | +| `30.5` | 30 nanoseconds | 30 nanoseconds | +| `"1m30s"` | 1 minute 30 seconds | 1 minute 30 seconds | +| `true` | error | error | + +The number is always a number of **nanoseconds**, the same unit +`time.Duration` uses internally. + +Both decoders accept the numbers, although they decode them differently: JSON +decodes every number as a `float64`, while YAML decodes a whole number as an +`int` and a fractional one as a `float64`. A fractional number is truncated +towards zero. + +```yaml +timeout: 30s # ok, 30 seconds +timeout: 30 # ok, 30 nanoseconds +timeout: 30.5 # ok, 30 nanoseconds +``` + +When in doubt, use the string form: it is the one that is always accepted and +the one that is always written. + +## Errors + +An invalid value is reported as an error, and the duration is left unchanged: + +- a string that is not a valid duration produces the error of + [`time.ParseDuration`](https://pkg.go.dev/time#ParseDuration); +- a value that is neither a number nor a string produces an `invalid duration` + error that mentions the value and its type. + +## Comparison with `time.Duration` + +`Duration` is a defined type, not an alias, so it does not inherit the methods +of `time.Duration`. Convert it back when they are needed: + +```go +d := pkgTime.Duration(30 * time.Second) + +fmt.Println(time.Duration(d).Seconds()) // 30 +``` + +`Duration` is meant to be used in the configuration structures that are +serialized, not as a general-purpose replacement for `time.Duration`. diff --git a/time/time.go b/time/time.go index 4c7b91d..5d8c8e4 100644 --- a/time/time.go +++ b/time/time.go @@ -1,13 +1,20 @@ // SPDX-License-Identifier: BSD-3-Clause +// Package time provides a duration type that is serialized as a human-readable +// string instead of a number of nanoseconds. package time import ( "encoding/json" - "errors" + "fmt" "time" ) +// Duration is a [time.Duration] that is marshaled to JSON and YAML as a +// string, e.g. "1m30s". +// +// It is a defined type, not an alias, so the methods of [time.Duration] are +// not available on it: use a conversion to get them back. type Duration time.Duration var ( @@ -15,10 +22,16 @@ var ( _ json.Unmarshaler = (*Duration)(nil) ) +// MarshalJSON implements [json.Marshaler]. +// +// The duration is written as a string, e.g. "1m30s". func (d Duration) MarshalJSON() ([]byte, error) { return json.Marshal(time.Duration(d).String()) } +// UnmarshalJSON implements [json.Unmarshaler]. +// +// Both a duration string ("1m30s") and a number of nanoseconds are accepted. func (d *Duration) UnmarshalJSON(b []byte) error { var v interface{} if err := json.Unmarshal(b, &v); err != nil { @@ -34,10 +47,16 @@ func (d *Duration) UnmarshalJSON(b []byte) error { return nil } +// MarshalYAML implements the yaml.Marshaler interface. +// +// The duration is written as a string, e.g. "1m30s". func (d Duration) MarshalYAML() (interface{}, error) { return time.Duration(d).String(), nil } +// UnmarshalYAML implements the yaml.Unmarshaler interface. +// +// Both a duration string ("1m30s") and a number of nanoseconds are accepted. func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { var v interface{} if err := unmarshal(&v); err != nil { @@ -53,10 +72,19 @@ func (d *Duration) UnmarshalYAML(unmarshal func(interface{}) error) error { return nil } +// fromRaw converts a decoded JSON or YAML value into a Duration. +// +// A string is parsed as a duration, a number is treated as a number of +// nanoseconds. Any other value is rejected. +// +// The JSON decoder produces the numbers as float64, while the YAML decoder +// produces the whole numbers as int and the fractional ones as float64. func fromRaw(v interface{}) (Duration, error) { switch v := v.(type) { case float64: return Duration(time.Duration(v)), nil + case int: + return Duration(time.Duration(v)), nil case string: dur, err := time.ParseDuration(v) if err != nil { @@ -64,6 +92,6 @@ func fromRaw(v interface{}) (Duration, error) { } return Duration(dur), nil default: - return Duration(0), errors.New("invalid duration") + return Duration(0), fmt.Errorf("invalid duration: %v (%T)", v, v) } } diff --git a/time/time_test.go b/time/time_test.go index 19abc8d..a17b673 100644 --- a/time/time_test.go +++ b/time/time_test.go @@ -67,10 +67,20 @@ func TestDuration_UnmarshalJSON(t *testing.T) { want: Duration(4*time.Minute + 30*time.Second), }, { - name: "5 millisecond", + name: "5 milliseconds", d: []byte(`"5ms"`), want: Duration(5 * time.Millisecond), }, + { + name: "600 nanoseconds", + d: []byte(`600`), + want: Duration(600 * time.Nanosecond), + }, + { + name: "float 10 nanoseconds", + d: []byte(`10.5`), + want: Duration(10 * time.Nanosecond), + }, } for _, tt := range tests { tt := tt @@ -84,6 +94,57 @@ func TestDuration_UnmarshalJSON(t *testing.T) { } } +func TestDuration_UnmarshalJSON_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d []byte + wantErr string + }{ + { + name: "invalid duration string", + d: []byte(`"bogus"`), + wantErr: `time: invalid duration "bogus"`, + }, + { + name: "empty string", + d: []byte(`""`), + wantErr: `time: invalid duration ""`, + }, + { + name: "boolean", + d: []byte(`true`), + wantErr: "invalid duration: true (bool)", + }, + { + name: "object", + d: []byte(`{}`), + wantErr: "invalid duration: map[] (map[string]interface {})", + }, + { + name: "array", + d: []byte(`[]`), + wantErr: "invalid duration: [] ([]interface {})", + }, + { + name: "null", + d: []byte(`null`), + wantErr: "invalid duration: ()", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := Duration(time.Second) + err := json.Unmarshal(tt.d, &got) + require.EqualError(t, err, tt.wantErr) + assert.Equal(t, Duration(time.Second), got) + }) + } +} + func TestDuration_MarshalYAML(t *testing.T) { t.Parallel() @@ -138,10 +199,20 @@ func TestDuration_UnmarshalYAML(t *testing.T) { want: Duration(4*time.Minute + 30*time.Second), }, { - name: "5 millisecond", + name: "5 milliseconds", d: []byte(`5ms`), want: Duration(5 * time.Millisecond), }, + { + name: "600 nanoseconds", + d: []byte(`600`), + want: Duration(600 * time.Nanosecond), + }, + { + name: "float 10 nanoseconds", + d: []byte(`10.5`), + want: Duration(10 * time.Nanosecond), + }, } for _, tt := range tests { tt := tt @@ -154,3 +225,49 @@ func TestDuration_UnmarshalYAML(t *testing.T) { }) } } + +func TestDuration_UnmarshalYAML_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + d []byte + wantErr string + }{ + { + name: "invalid duration string", + d: []byte(`"bogus"`), + wantErr: `time: invalid duration "bogus"`, + }, + { + name: "empty string", + d: []byte(`""`), + wantErr: `time: invalid duration ""`, + }, + { + name: "boolean", + d: []byte(`true`), + wantErr: "invalid duration: true (bool)", + }, + { + name: "object", + d: []byte(`{}`), + wantErr: "invalid duration: map[] (map[string]interface {})", + }, + { + name: "array", + d: []byte(`[]`), + wantErr: "invalid duration: [] ([]interface {})", + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := Duration(time.Second) + err := yaml.Unmarshal(tt.d, &got) + require.EqualError(t, err, tt.wantErr) + assert.Equal(t, Duration(time.Second), got) + }) + } +}