Skip to content

Commit

Permalink
Merge pull request #61 from calvinmclean/feature/improve-storage
Browse files Browse the repository at this point in the history
Improve and refactor Storage to improve potential implementations
  • Loading branch information
calvinmclean committed Apr 27, 2024
2 parents 86a1c7b + 482f7ec commit 01d6ab1
Show file tree
Hide file tree
Showing 17 changed files with 160 additions and 107 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Check out some of the [examples](./examples) for examples of using the `babytest

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.

The `babyapi/storage` 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` provides helper functions for initializing the `hord` client for Redis or file-based storage.
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.

```go
db, err := storage.NewFileDB(hashmap.Config{
Expand All @@ -138,15 +138,15 @@ api.SetStorage(storage.NewClient[*TODO](db, "TODO"))

## Examples

| | Description | Features |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [TODO list](./examples/todo/) | This example expands upon the base example to create a realistic TODO list application | <ul><li>Custom `PATCH` logic</li><li>Additional request validation</li><li>Automatically set `CreatedAt` field</li><li>Query parameter parsing to only show completed items</li></ul> |
| [Nested resources](./examples/nested/) | Demonstrates how to build APIs with nested/related resources. The root resource is an `Artist` which can have `Albums` and `MusicVideos`. Then, `Albums` can have `Songs` | <ul><li>Nested API resources</li><li>Custom `ResponseWrapper` to add fields from related resources</li></ul> |
| [Storage](./examples/storage/) | The example shows how to use the `babyapi/storage` package to implement persistent storage | <ul><li>Use `SetStorage` to use a custom storage implementation</li><li>Create a `hord` storage client using `babyapi/storage`</li></ul> |
| [TODO list with HTMX UI](./examples/todo-htmx/) | This is a more complex example that demonstrates an application with HTMX frontend. It uses server-sent events to automatically update with newly-created items | <ul><li>Implement `babyapi.HTMLer` for HTML responses</li><li>Set custom HTTP response codes per HTTP method</li><li>Use built-in helpers for handling server-sent events on a custom route</li><li>Use `SetOnCreateOrUpdate` to do additional actions on create</li><li>Handle HTML forms as input instead of JSON (which works automatically and required no changes)</li></ul> |
| [Event RSVP](./examples/event-rsvp/) | This is a more complex nested example that implements basic authentication, middlewares, and relationships between nested types. The app can be used to create `Events` and provide guests with a link to view details and RSVP | <ul><li>Demonstrates middlewares and nested resource relationships</li><li>Authentication</li><li>Custom non-CRUD endpoints</li><li>More complex HTML templating</li></ul> |
| [Multiple APIs](./examples/multiple-apis/) | This example shows how multiple top-level (or any level) sibling APIs can be served, and have CLI functionality, under one root API | <ul><li>Use `NewRootAPI` to create a root API</li><li>Add multiple children to create siblings</li> |
| [Background Worker](./examples/background-worker/) | This example shows how you can use `babyapi` in an application alongside background workers and have runtime control over all goroutines | <ul><li>Use `WithContext` to add a context to an API so the API will stop when the context is cancelled</li><li>Use `api.Done()` to have other goroutines stop when the API is stopped</li> |
| | Description | Features |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [TODO list](./examples/todo/) | This example expands upon the base example to create a realistic TODO list application | <ul><li>Custom `PATCH` logic</li><li>Additional request validation</li><li>Automatically set `CreatedAt` field</li><li>Query parameter parsing to only show completed items</li></ul> |
| [Nested resources](./examples/nested/) | Demonstrates how to build APIs with nested/related resources. The root resource is an `Artist` which can have `Albums` and `MusicVideos`. Then, `Albums` can have `Songs` | <ul><li>Nested API resources</li><li>Custom `ResponseWrapper` to add fields from related resources</li></ul> |
| [Storage](./examples/storage/) | The example shows how to use the `babyapi/storage` package to implement persistent storage | <ul><li>Use `SetStorage` to use a custom storage implementation</li><li>Create a `hord` storage client using `babyapi/storage`</li></ul> |
| [TODO list with HTMX UI](./examples/todo-htmx/) | This is a more complex example that demonstrates an application with HTMX frontend. It uses server-sent events to automatically update with newly-created items | <ul><li>Implement `babyapi.HTMLer` for HTML responses</li><li>Set custom HTTP response codes per HTTP method</li><li>Use built-in helpers for handling server-sent events on a custom route</li><li>Use `SetOnCreateOrUpdate` to do additional actions on create</li><li>Handle HTML forms as input instead of JSON (which works automatically and required no changes)</li></ul> |
| [Event RSVP](./examples/event-rsvp/) | This is a more complex nested example that implements basic authentication, middlewares, and relationships between nested types. The app can be used to create `Events` and provide guests with a link to view details and RSVP | <ul><li>Demonstrates middlewares and nested resource relationships</li><li>Authentication</li><li>Custom non-CRUD endpoints</li><li>More complex HTML templating</li></ul> |
| [Multiple APIs](./examples/multiple-apis/) | This example shows how multiple top-level (or any level) sibling APIs can be served, and have CLI functionality, under one root API | <ul><li>Use `NewRootAPI` to create a root API</li><li>Add multiple children to create siblings</li> |
| [Background Worker](./examples/background-worker/) | This example shows how you can use `babyapi` in an application alongside background workers and have runtime control over all goroutines | <ul><li>Use `WithContext` to add a context to an API so the API will stop when the context is cancelled</li><li>Use `api.Done()` to have other goroutines stop when the API is stopped</li> |

Also see a full example of an application implementing a REST API using `babyapi` in my [`automated-garden` project](https://github.com/calvinmclean/automated-garden/tree/main/garden-app).

Expand Down
12 changes: 7 additions & 5 deletions babyapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func NewAPI[T Resource](name, base string, instance func() T) *API[T] {
nil,
func(r T) render.Renderer { return r },
nil,
func(*http.Request) FilterFunc[T] { return func(T) bool { return true } },
func(*http.Request) FilterFunc[T] { return nil },
defaultBeforeAfter,
defaultBeforeAfter,
func(*http.Request, T) *ErrResponse { return nil },
Expand Down Expand Up @@ -228,7 +228,9 @@ func (a *API[T]) SetAfterDelete(after func(*http.Request) *ErrResponse) *API[T]
}

// SetGetAllFilter sets a function that can use the request context to create a filter for GetAll. Use this
// to introduce query parameters for filtering resources
// to introduce custom filtering after reading from storage. This should mostly be used with the default storage
// client options. If you are using a custom SQL or other query-based implementation, it is better to use the url.Values
// to create custom filtering
func (a *API[T]) SetGetAllFilter(f func(*http.Request) FilterFunc[T]) *API[T] {
a.panicIfReadOnly()

Expand Down Expand Up @@ -349,10 +351,10 @@ func (a *API[T]) Serve(address string) error {
select {
case <-a.Done():
case <-a.context.Done():
// if shutdown by context, need to close a.quit for a.Done()
close(a.quit)
}

close(a.quit)

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer func() {
cancel()
Expand Down Expand Up @@ -385,7 +387,7 @@ func (a *API[T]) Serve(address string) error {

// Stop will stop the API
func (a *API[T]) Stop() {
a.quit <- struct{}{}
close(a.quit)
<-a.shutdown
}

Expand Down
20 changes: 10 additions & 10 deletions babyapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,7 @@ func TestHTML(t *testing.T) {
client := api.Client(address)

t.Run("CreateItem", func(t *testing.T) {
err := api.Storage.Set(item1)
err := api.Storage.Set(context.Background(), item1)
require.NoError(t, err)
})

Expand Down Expand Up @@ -731,7 +731,7 @@ func TestServerSentEvents(t *testing.T) {
Content: "Item1",
}
t.Run("CreateItem", func(t *testing.T) {
err := api.Storage.Set(item1)
err := api.Storage.Set(context.Background(), item1)
require.NoError(t, err)
})

Expand Down Expand Up @@ -882,7 +882,7 @@ func TestAPIModifierErrors(t *testing.T) {
w := babytest.TestRequest[*Album](t, api, r)
require.Equal(t, http.StatusUnprocessableEntity, w.Result().StatusCode)

allAlbums, err := api.Storage.GetAll(nil)
allAlbums, err := api.Storage.GetAll(context.Background(), nil)
require.NoError(t, err)

require.Equal(t, 0, len(allAlbums))
Expand All @@ -903,7 +903,7 @@ func TestAPIModifierErrors(t *testing.T) {
w := babytest.TestRequest[*Album](t, api, r)
require.Equal(t, http.StatusUnprocessableEntity, w.Result().StatusCode)

allAlbums, err := api.Storage.GetAll(nil)
allAlbums, err := api.Storage.GetAll(context.Background(), nil)
require.NoError(t, err)

require.Greater(t, len(allAlbums), 0)
Expand All @@ -928,7 +928,7 @@ func TestAPIModifierErrors(t *testing.T) {
r.Header.Add("Content-Type", "application/json")
babytest.TestRequest[*Album](t, api, r)

allAlbums, err := api.Storage.GetAll(nil)
allAlbums, err := api.Storage.GetAll(context.Background(), nil)
require.NoError(t, err)

require.Greater(t, len(allAlbums), 0)
Expand All @@ -942,7 +942,7 @@ func TestAPIModifierErrors(t *testing.T) {

require.Equal(t, http.StatusUnprocessableEntity, w.Result().StatusCode)

allAlbums, err := api.Storage.GetAll(nil)
allAlbums, err := api.Storage.GetAll(context.Background(), nil)
require.NoError(t, err)

require.Equal(t, len(allAlbums), 1)
Expand All @@ -964,7 +964,7 @@ func TestAPIModifierErrors(t *testing.T) {
r.Header.Add("Content-Type", "application/json")
babytest.TestRequest[*Album](t, api, r)

allAlbums, err := api.Storage.GetAll(nil)
allAlbums, err := api.Storage.GetAll(context.Background(), nil)
require.NoError(t, err)

require.Greater(t, len(allAlbums), 0)
Expand All @@ -978,7 +978,7 @@ func TestAPIModifierErrors(t *testing.T) {

require.Equal(t, http.StatusUnprocessableEntity, w.Result().StatusCode)

allAlbums, err := api.Storage.GetAll(nil)
allAlbums, err := api.Storage.GetAll(context.Background(), nil)
require.NoError(t, err)
afterCount := len(allAlbums)

Expand Down Expand Up @@ -1448,15 +1448,15 @@ func TestClient(t *testing.T) {

t.Run("CustomResponseCodeSuccess", func(t *testing.T) {
client.SetCustomResponseCode(babyapi.MethodGetAll, http.StatusCreated)
resp, err := client.GetAll(context.Background(), "")
resp, err := client.GetAllAny(context.Background(), "")
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.Response.StatusCode)
})

t.Run("SetHTTPClient", func(t *testing.T) {
client.SetHTTPClient(http.DefaultClient)

_, err := client.GetAll(context.Background(), "")
_, err := client.GetAllAny(context.Background(), "")
require.NoError(t, err)
})
}
2 changes: 1 addition & 1 deletion cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (a *API[T]) Command() *cobra.Command {

func (a *API[T]) serveCmd(_ *cobra.Command, _ []string) error {
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
a.Stop()
Expand Down
15 changes: 15 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,21 @@ func (c *Client[T]) GetAllRequest(ctx context.Context, rawQuery string, parentID
return req, nil
}

// GetAllAny allows using GetAll when using a custom response wrapper
func (c *Client[T]) GetAllAny(ctx context.Context, rawQuery string, parentIDs ...string) (*Response[any], error) {
req, err := c.GetAllRequest(ctx, rawQuery, parentIDs...)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}

result, err := MakeRequest[any](req, c.client, c.customResponseCodes[MethodGetAll], c.requestEditor)
if err != nil {
return nil, fmt.Errorf("error getting all resources: %w", err)
}

return result, nil
}

// Put makes a PUT request to create/modify a resource by ID
func (c *Client[T]) Put(ctx context.Context, resource T, parentIDs ...string) (*Response[T], error) {
var body bytes.Buffer
Expand Down
Loading

0 comments on commit 01d6ab1

Please sign in to comment.