Skip to content

Commit

Permalink
Add support for HTML responses and handle SSE
Browse files Browse the repository at this point in the history
- Use HTMLer to allow responding with HTML rendered responses
- Add helpers for handling SSE
- Add Done() too detect when an API is stopped
- Allow setting custom response codes per HTTP verb
- Allow access to the API's Storage interface
- Add HTMX TODO example
  • Loading branch information
calvinmclean committed Dec 4, 2023
1 parent 81efb14 commit 892b2cc
Show file tree
Hide file tree
Showing 7 changed files with 448 additions and 12 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,12 @@ 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> |
| | 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> |
| [HTMX TODO](./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></ul> |

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
28 changes: 27 additions & 1 deletion babyapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ type API[T Resource] struct {
customRoutes []chi.Route
customIDRoutes []chi.Route

responseWrapper func(T) render.Renderer
responseWrapper func(T) render.Renderer
getAllResponseWrapper func([]T) render.Renderer

getAllFilter func(*http.Request) FilterFunc[T]

Expand All @@ -44,6 +45,8 @@ type API[T Resource] struct {
onCreateOrUpdate func(*http.Request, T) *ErrResponse

parent relatedAPI

customResponseCodes map[string]int
}

// NewAPI initializes an API using the provided name, base URL path, and function to create a new instance of
Expand All @@ -61,11 +64,13 @@ func NewAPI[T Resource](name, base string, instance func() T) *API[T] {
nil,
nil,
func(r T) render.Renderer { return r },
nil,
func(*http.Request) FilterFunc[T] { return func(T) bool { return true } },
defaultBeforeAfter,
defaultBeforeAfter,
func(*http.Request, T) *ErrResponse { return nil },
nil,
map[string]int{},
}
}

Expand All @@ -79,6 +84,17 @@ func (a *API[T]) Name() string {
return a.name
}

// SetCustomResponseCode will override the default response codes for the specified HTTP verb
func (a *API[T]) SetCustomResponseCode(verb string, code int) {
a.customResponseCodes[verb] = code

Check warning on line 89 in babyapi.go

View check run for this annotation

Codecov / codecov/patch

babyapi.go#L89

Added line #L89 was not covered by tests
}

// SetGetAllResponseWrapper sets a function that can create a custom response for GetAll. This function will receive
// a slice of Resources from storage and must return a render.Renderer
func (a *API[T]) SetGetAllResponseWrapper(getAllResponder func([]T) render.Renderer) {
a.getAllResponseWrapper = getAllResponder
}

// SetOnCreateOrUpdate runs on POST, PATCH, and PUT requests before saving the created/updated resource.
// This is useful for adding more validations or performing tasks related to resources such as initializing
// schedules or sending events
Expand Down Expand Up @@ -142,6 +158,11 @@ func (a *API[T]) SetStorage(s Storage[T]) {
a.storage = s
}

// Storage returns the storage interface for the API so it can be used in custom routes or other use cases
func (a *API[T]) Storage() Storage[T] {
return a.storage

Check warning on line 163 in babyapi.go

View check run for this annotation

Codecov / codecov/patch

babyapi.go#L163

Added line #L163 was not covered by tests
}

// AddMiddlewares appends chi.Middlewares to existing middlewares
func (a *API[T]) AddMiddlewares(m chi.Middlewares) {
a.middlewares = append(a.middlewares, m...)
Expand Down Expand Up @@ -188,6 +209,11 @@ func (a *API[T]) Stop() {
a.quit <- os.Interrupt
}

// Done returns a channel that's closed when the API stops, similar to context.Done()
func (a *API[T]) Done() <-chan os.Signal {
return a.quit

Check warning on line 214 in babyapi.go

View check run for this annotation

Codecov / codecov/patch

babyapi.go#L214

Added line #L214 was not covered by tests
}

type beforeAfterFunc func(*http.Request) *ErrResponse

func defaultBeforeAfter(*http.Request) *ErrResponse {
Expand Down
88 changes: 88 additions & 0 deletions babyapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
Expand Down Expand Up @@ -576,3 +577,90 @@ func TestCLI(t *testing.T) {
})
}
}

type UnorderedList struct {
Items []*ListItem
}

func (ul *UnorderedList) Render(w http.ResponseWriter, r *http.Request) error {
return nil
}

func (ul *UnorderedList) HTML() string {
result := "<ul>\n"
for _, li := range ul.Items {
result += li.HTML() + "\n"
}
return result + "</ul>"
}

type ListItem struct {
babyapi.DefaultResource
Content string
}

func (d *ListItem) HTML() string {
return "<li>" + d.Content + "</li>"
}

func TestHTML(t *testing.T) {
api := babyapi.NewAPI[*ListItem]("Items", "/items", func() *ListItem { return &ListItem{} })

api.SetGetAllResponseWrapper(func(d []*ListItem) render.Renderer {
return &UnorderedList{d}
})

item1 := &ListItem{Content: "Item1"}

address, closer := babyapi.TestServe[*ListItem](t, api)
defer closer()

client := api.Client(address)

t.Run("PostItem", func(t *testing.T) {
t.Run("Successful", func(t *testing.T) {
var err error
item1, err = client.Post(context.Background(), item1)
require.NoError(t, err)
require.NotEqual(t, xid.NilID(), item1.GetID())
})
})

t.Run("GetItemHTML", func(t *testing.T) {
t.Run("Successful", func(t *testing.T) {
url, err := client.URL(item1.GetID())
require.NoError(t, err)

req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
require.NoError(t, err)
req.Header.Set("Accept", "text/html")

resp, err := client.MakeRequest(req, http.StatusOK)
require.NoError(t, err)

data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "<li>Item1</li>", string(data))
})
})

t.Run("GetAllItemsHTML", func(t *testing.T) {
t.Run("Successful", func(t *testing.T) {
url, err := client.URL("")
require.NoError(t, err)

req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
require.NoError(t, err)
req.Header.Set("Accept", "text/html")

resp, err := client.MakeRequest(req, http.StatusOK)
require.NoError(t, err)

data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, `<ul>
<li>Item1</li>
</ul>`, string(data))
})
})
}
Loading

0 comments on commit 892b2cc

Please sign in to comment.