Skip to content

Commit

Permalink
Merge pull request #63 from calvinmclean/feature/improve-end-date-kv
Browse files Browse the repository at this point in the history
Improve use of EndDateable for GetAll filtering
  • Loading branch information
calvinmclean committed May 2, 2024
2 parents 66c4fb2 + e6e0013 commit afa42ab
Show file tree
Hide file tree
Showing 13 changed files with 126 additions and 75 deletions.
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,27 +113,31 @@ Check out some of the [examples](./examples) for examples of using the `babytest

## Storage

You can bring any storage backend to `babyapi` by implementing the `Storage` interface. By default, the API will use the built-in `MapStorage` which just uses an in-memory map.
You can bring any storage backend to `babyapi` by implementing the `Storage` interface. By default, the API will use the built-in `KVStorage` with the default configuration for in-memory map.

The `babyapi/storage/kv` package provides another generic `Storage` implementation using [`madflojo/hord`](https://github.com/madflojo/hord) to support a variety of key-value store backends. `babyapi/storage/kv` provides helper functions for initializing the `hord` client for Redis or file-based storage.
This storage implementation leverages [`madflojo/hord`](https://github.com/madflojo/hord) to support a variety of key-value store backends. Currently, the `babyapi/storage/kv` package provides helpers to create file or redis-based storage implementations.

```go
db, err := storage.NewFileDB(hashmap.Config{
db, err := kv.NewFileDB(hashmap.Config{
Filename: "storage.json",
})
db, err := storage.NewRedisDB(redis.Config{
db, err := kv.NewRedisDB(redis.Config{
Server: "localhost:6379",
})

api.SetStorage(storage.NewClient[*TODO](db, "TODO"))
api.SetStorage(babyapi.NewKVStorage[*TODO](db, "TODO"))
```

### EndDateable

The `babyapi.EndDateable` interface can be implemented to enable soft-delete with the `KVStorage`. This will set an end-date instead of permanently deleting a resource. Then, deleting it again will permanently delete. Also, the `GetAll` implementation will filter out end-dated resources unless the `end_dated` query parameter is set to enable getting end-dated resources.

## Extensions

`babyapi` provides an `Extension` interface that can be applied to any API with `api.ApplyExtension()`. Implementations of this interface create custom configurations and modifications that can be applied to multiple APIs. A few extensions are provided by the `babyapi/extensions` package:

- `HATEOAS`: "Hypertext as the engine of application state" is the [3rd and final level of REST API maturity](https://en.wikipedia.org/wiki/Richardson_Maturity_Model#Level_3:_Hypermedia_controls), making your API fully RESTful
- `KVStorage`: provide a few simple configurations to use the `babyapi/storage` package's KV storage client with a local file or Redis
- `KVStorage`: provide a few simple configurations to use the `KVStorage` client with a local file or Redis
- `HTMX`: HTMX expects 200 responses from DELETE requests, so this changes the response code

## Examples
Expand Down
3 changes: 2 additions & 1 deletion babyapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"sync"
"time"

"github.com/calvinmclean/babyapi/storage/kv"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
Expand Down Expand Up @@ -100,7 +101,7 @@ func NewAPI[T Resource](name, base string, instance func() T) *API[T] {
map[string]relatedAPI{},
nil,
nil,
MapStorage[T]{},
NewKVStorage[T](kv.NewDefaultDB(), name),
context.Background(),
make(chan struct{}, 1),
make(chan struct{}, 1),
Expand Down
8 changes: 7 additions & 1 deletion storage/kv/end_date.go → end_date.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package kv
package babyapi

import (
"fmt"
"net/url"
"time"
)

Expand All @@ -9,3 +11,7 @@ type EndDateable interface {
EndDated() bool
SetEndDate(time.Time)
}

func EndDatedQueryParam(value bool) url.Values {
return url.Values{"end_dated": []string{fmt.Sprint(value)}}
}
6 changes: 6 additions & 0 deletions examples/sql/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ require (
)

require (
github.com/FZambia/sentinel v1.1.1 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/go-chi/chi/v5 v5.0.10 // indirect
github.com/go-chi/render v1.0.3 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
github.com/madflojo/hord v0.2.2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
19 changes: 19 additions & 0 deletions examples/sql/go.sum
Original file line number Diff line number Diff line change
@@ -1,31 +1,47 @@
github.com/FZambia/sentinel v1.1.1 h1:0ovTimlR7Ldm+wR15GgO+8C2dt7kkn+tm3PQS+Qk3Ek=
github.com/FZambia/sentinel v1.1.1/go.mod h1:ytL1Am/RLlAoAXG6Kj5LNuw/TRRQrv2rt2FT26vP5gI=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
github.com/madflojo/hord v0.2.2 h1:ZUE6J6sIyrnZmxkjSIe7OkImZllhFQNRAj9EDcf8A+k=
github.com/madflojo/hord v0.2.2/go.mod h1:VX6MCau/8uOKiNCSl7igl03kh5TgBkQhRL9ypQcsCyo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tursodatabase/go-libsql v0.0.0-20240418131656-29cbe90b12a1 h1:Hi55olDHy1FmAgL6B5Ca/wFFZBbfEmF5tlgykPuZP20=
Expand All @@ -35,6 +51,9 @@ golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCR
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
Expand Down
2 changes: 1 addition & 1 deletion examples/storage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func main() {
panic(err)
}

api.SetStorage(kv.NewClient[*User](db, "User"))
api.SetStorage(babyapi.NewKVStorage[*User](db, "User"))

api.RunCLI()
}
2 changes: 1 addition & 1 deletion extensions/kv_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func (h KeyValueStorage[T]) Apply(api *babyapi.API[T]) error {
storageKeyPrefix = api.Name()
}

api.SetStorage(kv.NewClient[T](db, storageKeyPrefix))
api.SetStorage(babyapi.NewKVStorage[T](db, storageKeyPrefix))

return nil
}
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ require (
github.com/rs/xid v1.5.0
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa
golang.org/x/tools v0.15.0
)

Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
Expand Down
38 changes: 24 additions & 14 deletions storage/kv/client.go → kv_storage.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package kv
package babyapi

import (
"context"
Expand All @@ -9,28 +9,32 @@ import (
"strings"
"time"

"github.com/calvinmclean/babyapi"
"github.com/madflojo/hord"
)

// Client implements the babyapi.Storage interface for the provided type using hord.Database for the storage backend
type Client[T babyapi.Resource] struct {
// KVStorage implements the Storage interface for the provided type using hord.Database for the storage backend
//
// It allows soft-deleting if your type implements the kv.EndDateable interface. This means Delete will set the end-date
// to now and update in storage instead of deleting. If something is already end-dated, then it is hard-deleted. Also,
// the GetAll method will automatically read the 'end_dated' query param to determine if end-dated resources should
// be filtered out
type KVStorage[T Resource] struct {
prefix string
db hord.Database
}

// NewClient creates a new storage client for the specified type. It stores resources with keys prefixed by 'prefix'
func NewClient[T babyapi.Resource](db hord.Database, prefix string) babyapi.Storage[T] {
return &Client[T]{prefix, db}
// NewKVStorage creates a new storage client for the specified type. It stores resources with keys prefixed by 'prefix'
func NewKVStorage[T Resource](db hord.Database, prefix string) Storage[T] {
return &KVStorage[T]{prefix, db}
}

func (c *Client[T]) key(id string) string {
func (c *KVStorage[T]) key(id string) string {
return fmt.Sprintf("%s_%s", c.prefix, id)
}

// Delete will delete a resource by the key. If the resource implements EndDateable, it will first soft-delete by
// setting the EndDate to time.Now()
func (c *Client[T]) Delete(ctx context.Context, id string) error {
func (c *KVStorage[T]) Delete(ctx context.Context, id string) error {
key := c.key(id)

result, err := c.get(key)
Expand All @@ -54,19 +58,19 @@ func (c *Client[T]) Delete(ctx context.Context, id string) error {

// Get will use the provided key to read data from the data source. Then, it will Unmarshal
// into the generic type
func (c *Client[T]) Get(_ context.Context, id string) (T, error) {
func (c *KVStorage[T]) Get(_ context.Context, id string) (T, error) {
return c.get(c.key(id))
}

func (c *Client[T]) get(key string) (T, error) {
func (c *KVStorage[T]) get(key string) (T, error) {
if c.db == nil {
return *new(T), fmt.Errorf("error missing database connection")
}

dataBytes, err := c.db.Get(key)
if err != nil {
if errors.Is(hord.ErrNil, err) {
return *new(T), babyapi.ErrNotFound
return *new(T), ErrNotFound
}
return *new(T), fmt.Errorf("error getting data: %w", err)
}
Expand All @@ -82,7 +86,7 @@ func (c *Client[T]) get(key string) (T, error) {

// GetAll will use the provided prefix to read data from the data source. Then, it will use Get
// to read each element into the correct type
func (c *Client[T]) GetAll(_ context.Context, _ url.Values) ([]T, error) {
func (c *KVStorage[T]) GetAll(_ context.Context, query url.Values) ([]T, error) {
keys, err := c.db.Keys()
if err != nil {
return nil, fmt.Errorf("error getting keys: %w", err)
Expand All @@ -99,14 +103,20 @@ func (c *Client[T]) GetAll(_ context.Context, _ url.Values) ([]T, error) {
return nil, fmt.Errorf("error getting data: %w", err)
}

getEndDated := query.Get("end_dated") == "true"
endDateable, ok := any(result).(EndDateable)
if ok && !getEndDated && endDateable.EndDated() {
continue
}

results = append(results, result)
}

return results, nil
}

// Set marshals the provided item and writes it to the database
func (c *Client[T]) Set(_ context.Context, item T) error {
func (c *KVStorage[T]) Set(_ context.Context, item T) error {
asBytes, err := json.Marshal(item)
if err != nil {
return fmt.Errorf("error marshalling data: %w", err)
Expand Down
Loading

0 comments on commit afa42ab

Please sign in to comment.