Skip to content

Commit

Permalink
middleware: add idempotency middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
leonklingele committed Dec 3, 2022
1 parent 77d1484 commit 979bbe3
Show file tree
Hide file tree
Showing 8 changed files with 701 additions and 0 deletions.
140 changes: 140 additions & 0 deletions middleware/idempotency/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Idempotency Middleware

Idempotency middleware for [Fiber](https://github.com/gofiber/fiber) allows for fault-tolerant APIs where duplicate requests — for example due to networking issues on the client-side — do not erroneously cause the same action performed multiple times on the server-side.

Refer to https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-02 for a better understanding.

## Table of Contents

- [Idempotency Middleware](#idempotency-middleware)
- [Table of Contents](#table-of-contents)
- [Signatures](#signatures)
- [Examples](#examples)
- [Default Config](#default-config)
- [Custom Config](#custom-config)
- [Config](#config)
- [Default Config](#default-config-1)

## Signatures

```go
func New(config ...Config) fiber.Handler
```

## Examples

First import the middleware from Fiber,

```go
import (
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/idempotency"
)
```

Then create a Fiber app with `app := fiber.New()`.

### Default Config

```go
app.Use(idempotency.New())
```

### Custom Config

```go
app.Use(idempotency.New(idempotency.Config{
Lifetime: 42 * time.Minute,
// ...
}))
```

### Config

```go
type Config struct {
// Next defines a function to skip this middleware when returned true.
//
// Optional. Default: nil
Next func(c fiber.Ctx) bool

// Lifetime is the maximum lifetime of an idempotency key.
//
// Optional. Default: 30 * time.Minute
Lifetime time.Duration

// KeyHeader is the name of the header that contains the idempotency key.
//
// Optional. Default: X-Idempotency-Key
KeyHeader string
// KeyHeader is the name of the header that contains the idempotency key.
//
// Optional. Default: a function which ensures the header is 36 characters long (size of UUID).
KeyHeaderValidate func(string) error

// KeepResponseHeaders is a list of headers that should be kept from the original response.
//
// Optional. Default: nil (to keep all headers)
KeepResponseHeaders []string

// Lock locks an idempotency key.
//
// Optional. Default: an in-memory locker for this process only
Lock Locker
// Storage stores response data by idempotency key.
//
// Optional. Default: an in-memory storage for this process only
Storage Storage

// MarshalFunc is the function used to marshal a Response to a []byte.
//
// Optional. Default: a marshal function which uses "encoding/gob" from the Go standard library.
MarshalFunc func(Response) ([]byte, error)
// UnmarshalFunc is the function used to unmarshal a []byte back to a Response.
//
// Optional. Default: a marshal function which uses "encoding/gob" from the Go standard library.
UnmarshalFunc func([]byte, *Response) error
}
```

### Default Config

```go
var ConfigDefault = Config{
Next: nil,

Lifetime: 30 * time.Minute,

KeyHeader: "X-Idempotency-Key",
KeyHeaderValidate: func(k string) error {
if l, wl := len(k), 36; l != wl { // UUID length is 36 chars
return fmt.Errorf("%w: invalid length: %d > %d", ErrInvalidIdempotencyKey, l, wl)
}

return nil
},

KeepResponseHeaders: nil,

Lock: NewMemoryLocker(),
Storage: NewMemoryStorage(),

MarshalFunc: func(res Response) ([]byte, error) {
var buf bytes.Buffer
if err := gob.
NewEncoder(&buf).
Encode(res); err != nil {
return nil, err
}
return buf.Bytes(), nil
},
UnmarshalFunc: func(val []byte, res *Response) error {
if err := gob.
NewDecoder(bytes.NewReader(val)).
Decode(res); err != nil {
return err
}
return nil
},
}
```
148 changes: 148 additions & 0 deletions middleware/idempotency/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package idempotency

import (
"bytes"
"encoding/gob"
"errors"
"fmt"
"time"

"github.com/gofiber/fiber/v3"
)

var (
ErrInvalidIdempotencyKey = errors.New("invalid idempotency key")
)

// Config defines the config for middleware.
type Config struct {
// Next defines a function to skip this middleware when returned true.
//
// Optional. Default: nil
Next func(c fiber.Ctx) bool

// Lifetime is the maximum lifetime of an idempotency key.
//
// Optional. Default: 30 * time.Minute
Lifetime time.Duration

// KeyHeader is the name of the header that contains the idempotency key.
//
// Optional. Default: X-Idempotency-Key
KeyHeader string
// KeyHeader is the name of the header that contains the idempotency key.
//
// Optional. Default: a function which ensures the header is 36 characters long (size of UUID).
KeyHeaderValidate func(string) error

// KeepResponseHeaders is a list of headers that should be kept from the original response.
//
// Optional. Default: nil (to keep all headers)
KeepResponseHeaders []string
keepResponseHeadersMap map[string]struct{}

// Lock locks an idempotency key.
//
// Optional. Default: an in-memory locker for this process only
Lock Locker
// Storage stores response data by idempotency key.
//
// Optional. Default: an in-memory storage for this process only
Storage Storage

// MarshalFunc is the function used to marshal a Response to a []byte.
//
// Optional. Default: a marshal function which uses "encoding/gob" from the Go standard library.
MarshalFunc func(*Response) ([]byte, error)
// UnmarshalFunc is the function used to unmarshal a []byte back to a Response.
//
// Optional. Default: a marshal function which uses "encoding/gob" from the Go standard library.
UnmarshalFunc func([]byte, *Response) error
}

// ConfigDefault is the default config
var ConfigDefault = Config{
Next: nil,

Lifetime: 30 * time.Minute,

KeyHeader: "X-Idempotency-Key",
KeyHeaderValidate: func(k string) error {
if l, wl := len(k), 36; l != wl { // UUID length is 36 chars
return fmt.Errorf("%w: invalid length: %d > %d", ErrInvalidIdempotencyKey, l, wl)
}

return nil
},

KeepResponseHeaders: nil,

Lock: NewMemoryLock(),
Storage: NewMemoryStorage(),

MarshalFunc: func(res *Response) ([]byte, error) {
var buf bytes.Buffer
if err := gob.
NewEncoder(&buf).
Encode(res); err != nil {
return nil, err
}
return buf.Bytes(), nil
},
UnmarshalFunc: func(val []byte, res *Response) error {
if err := gob.
NewDecoder(bytes.NewReader(val)).
Decode(res); err != nil {
return err
}
return nil
},
}

// Helper function to set default values
func configDefault(config ...Config) Config {
// Return default config if nothing provided
if len(config) < 1 {
return ConfigDefault
}

// Override default config
cfg := config[0]

// Set default values

if cfg.Next == nil {
cfg.Next = ConfigDefault.Next
}

if cfg.Lifetime.Nanoseconds() == 0 {
cfg.Lifetime = ConfigDefault.Lifetime
}

if cfg.KeyHeader == "" {
cfg.KeyHeader = ConfigDefault.KeyHeader
}
if cfg.KeyHeaderValidate == nil {
cfg.KeyHeaderValidate = ConfigDefault.KeyHeaderValidate
}

if cfg.KeepResponseHeaders != nil && len(cfg.KeepResponseHeaders) == 0 {
cfg.KeepResponseHeaders = ConfigDefault.KeepResponseHeaders
}

if cfg.Lock == nil {
cfg.Lock = ConfigDefault.Lock
}
if cfg.Storage == nil {
cfg.Storage = ConfigDefault.Storage
}

if cfg.MarshalFunc == nil {
cfg.MarshalFunc = ConfigDefault.MarshalFunc
}
if cfg.UnmarshalFunc == nil {
cfg.UnmarshalFunc = ConfigDefault.UnmarshalFunc
}

return cfg
}
Loading

0 comments on commit 979bbe3

Please sign in to comment.