Skip to content

Commit

Permalink
feat: split transform/marshal, reorg tests & dogfood humatest
Browse files Browse the repository at this point in the history
  • Loading branch information
danielgtaylor committed Oct 27, 2023
1 parent 158ea48 commit 0a90edd
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 482 deletions.
25 changes: 16 additions & 9 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,15 @@ type API interface {
// send an `accept` header, then JSON is used.
Negotiate(accept string) (string, error)

// Marshal marshals the given value into the given writer. The content type
// is used to determine which format to use. Use `Negotiate` to get the
// content type from an accept header. TODO: update
Marshal(ctx Context, respKey string, contentType string, v any) error
// Transform runs the API transformers on the given value. The `status` is
// the key in the operation's `Responses` map that corresponds to the
// response being sent (e.g. "200" for a 200 OK response).
Transform(ctx Context, status string, v any) (any, error)

// Marshal marshals the given value into the given writer. The
// content type is used to determine which format to use. Use `Negotiate` to
// get the content type from an accept header.
Marshal(w io.Writer, contentType string, v any) error

// Unmarshal unmarshals the given data into the given value. The content type
Unmarshal(contentType string, data []byte, v any) error
Expand Down Expand Up @@ -193,16 +198,18 @@ func (a *api) Negotiate(accept string) (string, error) {
return ct, nil
}

func (a *api) Marshal(ctx Context, respKey string, ct string, v any) error {
func (a *api) Transform(ctx Context, status string, v any) (any, error) {
var err error

for _, t := range a.transformers {
v, err = t(ctx, respKey, v)
v, err = t(ctx, status, v)
if err != nil {
return err
return nil, err
}
}
return v, nil
}

func (a *api) Marshal(w io.Writer, ct string, v any) error {
f, ok := a.formats[ct]
if !ok {
start := strings.IndexRune(ct, '+') + 1
Expand All @@ -211,7 +218,7 @@ func (a *api) Marshal(ctx Context, respKey string, ct string, v any) error {
if !ok {
return fmt.Errorf("unknown content type: %s", ct)
}
return f.Marshal(ctx.BodyWriter(), v)
return f.Marshal(w, v)
}

func (a *api) UseMiddleware(middlewares ...func(ctx Context, next func(Context))) {
Expand Down
59 changes: 56 additions & 3 deletions api_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,69 @@
package huma
package huma_test

import (
"net/http"
"testing"

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/humatest"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/assert"
)

func TestBlankConfig(t *testing.T) {
adapter := &testAdapter{chi.NewMux()}
adapter := humatest.NewAdapter(chi.NewMux())

assert.NotPanics(t, func() {
NewAPI(Config{}, adapter)
huma.NewAPI(huma.Config{}, adapter)
})
}

// ExampleAdapter_handle demonstrates how to use the adapter directly
// instead of using the `huma.Register` convenience function to add a new
// operation and handler to the API.
//
// Note that you are responsible for defining all of the operation details,
// including the parameter and response definitions & schemas.
func ExampleAdapter_handle() {
// Create an adapter for your chosen router.
adapter := NewExampleAdapter(chi.NewMux())

// Register an operation with a custom handler.
adapter.Handle(&huma.Operation{
OperationID: "example-operation",
Method: "GET",
Path: "/example/{name}",
Summary: "Example operation",
Parameters: []*huma.Param{
{
Name: "name",
In: "path",
Description: "Name to return",
Required: true,
Schema: &huma.Schema{
Type: "string",
},
},
},
Responses: map[string]*huma.Response{
"200": {
Description: "OK",
Content: map[string]*huma.MediaType{
"text/plain": {
Schema: &huma.Schema{
Type: "string",
},
},
},
},
},
}, func(ctx huma.Context) {
// Get the `name` path parameter.
name := ctx.Param("name")

// Set the response content type, status code, and body.
ctx.SetHeader("Content-Type", "text/plain; charset=utf-8")
ctx.SetStatus(http.StatusOK)
ctx.BodyWriter().Write([]byte("Hello, " + name))
})
}
74 changes: 64 additions & 10 deletions cli_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,69 @@
package huma
package huma_test

import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"syscall"
"testing"
"time"

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)

func ExampleCLI() {
// First, define your input options.
type Options struct {
Debug bool `doc:"Enable debug logging"`
Host string `doc:"Hostname to listen on."`
Port int `doc:"Port to listen on." short:"p" default:"8888"`
}

// Then, create the CLI.
cli := huma.NewCLI(func(hooks huma.Hooks, opts *Options) {
fmt.Printf("Options are debug:%v host:%v port%v\n",
opts.Debug, opts.Host, opts.Port)

// Set up the router & API
router := chi.NewRouter()
api := humachi.New(router, huma.DefaultConfig("My API", "1.0.0"))

huma.Register(api, huma.Operation{
OperationID: "hello",
Method: http.MethodGet,
Path: "/hello",
}, func(ctx context.Context, input *struct{}) (*struct{}, error) {
// TODO: implement handler
return nil, nil
})

srv := &http.Server{
Addr: fmt.Sprintf("%s:%d", opts.Host, opts.Port),
Handler: router,
// TODO: Set up timeouts!
}

hooks.OnStart(func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
})

hooks.OnStop(func() {
srv.Shutdown(context.Background())
})
})

// Run the thing!
cli.Run()
}

func TestCLIPlain(t *testing.T) {
type Options struct {
Debug bool
Expand All @@ -20,7 +74,7 @@ func TestCLIPlain(t *testing.T) {
ingore bool
}

cli := NewCLI(func(hooks Hooks, options *Options) {
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
assert.Equal(t, true, options.Debug)
assert.Equal(t, "localhost", options.Host)
assert.Equal(t, 8001, options.Port)
Expand All @@ -46,7 +100,7 @@ func TestCLIAdvanced(t *testing.T) {
Port int `doc:"Port to listen on." short:"p" default:"8000"`
}

cli := NewCLI(func(hooks Hooks, options *Options) {
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
assert.Equal(t, true, options.Debug)
assert.Equal(t, "localhost", options.Host)
assert.Equal(t, 8001, options.Port)
Expand All @@ -73,7 +127,7 @@ func TestCLIHelp(t *testing.T) {
Port int
}

cli := NewCLI(func(hooks Hooks, options *Options) {
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
// Do nothing
})

Expand All @@ -91,14 +145,14 @@ func TestCLICommandWithOptions(t *testing.T) {
Debug bool
}

cli := NewCLI(func(hooks Hooks, options *Options) {
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
// Do nothing
})

wasSet := false
cli.Root().AddCommand(&cobra.Command{
Use: "custom",
Run: WithOptions(func(cmd *cobra.Command, args []string, options *Options) {
Run: huma.WithOptions(func(cmd *cobra.Command, args []string, options *Options) {
if options.Debug {
wasSet = true
}
Expand All @@ -116,7 +170,7 @@ func TestCLIShutdown(t *testing.T) {

started := false
stopping := make(chan bool, 1)
cli := NewCLI(func(hooks Hooks, options *Options) {
cli := huma.NewCLI(func(hooks huma.Hooks, options *Options) {
hooks.OnStart(func() {
started = true
<-stopping
Expand All @@ -142,7 +196,7 @@ func TestCLIBadType(t *testing.T) {
}

assert.Panics(t, func() {
NewCLI(func(hooks Hooks, options *Options) {})
huma.NewCLI(func(hooks huma.Hooks, options *Options) {})
})
}

Expand All @@ -156,10 +210,10 @@ func TestCLIBadDefaults(t *testing.T) {
}

assert.Panics(t, func() {
NewCLI(func(hooks Hooks, options *OptionsBool) {})
huma.NewCLI(func(hooks huma.Hooks, options *OptionsBool) {})
})

assert.Panics(t, func() {
NewCLI(func(hooks Hooks, options *OptionsInt) {})
huma.NewCLI(func(hooks huma.Hooks, options *OptionsInt) {})
})
}
6 changes: 5 additions & 1 deletion error.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ func WriteErr(api API, ctx Context, status int, msg string, errs ...error) error

ctx.SetHeader("Content-Type", ct)
ctx.SetStatus(status)
return api.Marshal(ctx, strconv.Itoa(status), ct, err)
tval, terr := api.Transform(ctx, strconv.Itoa(status), err)
if terr != nil {
return terr

Check warning on line 222 in error.go

View check run for this annotation

Codecov / codecov/patch

error.go#L222

Added line #L222 was not covered by tests
}
return api.Marshal(ctx.BodyWriter(), ct, tval)
}

// Status304NotModified returns a 304. This is not really an error, but
Expand Down
60 changes: 30 additions & 30 deletions error_test.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
package huma
package huma_test

import (
"fmt"
"net/http"
"net/http/httptest"
"testing"

"github.com/go-chi/chi/v5"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/humatest"
"github.com/stretchr/testify/assert"
)

// Ensure the default error models satisfy these interfaces.
var _ StatusError = (*ErrorModel)(nil)
var _ ContentTypeFilter = (*ErrorModel)(nil)
var _ ErrorDetailer = (*ErrorDetail)(nil)
var _ huma.StatusError = (*huma.ErrorModel)(nil)
var _ huma.ContentTypeFilter = (*huma.ErrorModel)(nil)
var _ huma.ErrorDetailer = (*huma.ErrorDetail)(nil)

func TestError(t *testing.T) {
err := &ErrorModel{
err := &huma.ErrorModel{
Status: 400,
Detail: "test err",
}

// Add some children.
err.Add(&ErrorDetail{
err.Add(&huma.ErrorDetail{
Message: "test detail",
Location: "body.foo",
Value: "bar",
Expand All @@ -44,42 +45,41 @@ func TestError(t *testing.T) {

func TestErrorResponses(t *testing.T) {
// NotModified has a slightly different signature.
assert.Equal(t, 304, Status304NotModified().GetStatus())
assert.Equal(t, 304, huma.Status304NotModified().GetStatus())

for _, item := range []struct {
constructor func(msg string, errs ...error) StatusError
constructor func(msg string, errs ...error) huma.StatusError
expected int
}{
{Error400BadRequest, 400},
{Error401Unauthorized, 401},
{Error403Forbidden, 403},
{Error404NotFound, 404},
{Error405MethodNotAllowed, 405},
{Error406NotAcceptable, 406},
{Error409Conflict, 409},
{Error410Gone, 410},
{Error412PreconditionFailed, 412},
{Error415UnsupportedMediaType, 415},
{Error422UnprocessableEntity, 422},
{Error429TooManyRequests, 429},
{Error500InternalServerError, 500},
{Error501NotImplemented, 501},
{Error502BadGateway, 502},
{Error503ServiceUnavailable, 503},
{Error504GatewayTimeout, 504},
{huma.Error400BadRequest, 400},
{huma.Error401Unauthorized, 401},
{huma.Error403Forbidden, 403},
{huma.Error404NotFound, 404},
{huma.Error405MethodNotAllowed, 405},
{huma.Error406NotAcceptable, 406},
{huma.Error409Conflict, 409},
{huma.Error410Gone, 410},
{huma.Error412PreconditionFailed, 412},
{huma.Error415UnsupportedMediaType, 415},
{huma.Error422UnprocessableEntity, 422},
{huma.Error429TooManyRequests, 429},
{huma.Error500InternalServerError, 500},
{huma.Error501NotImplemented, 501},
{huma.Error502BadGateway, 502},
{huma.Error503ServiceUnavailable, 503},
{huma.Error504GatewayTimeout, 504},
} {
err := item.constructor("test")
assert.Equal(t, item.expected, err.GetStatus())
}
}

func TestNegotiateError(t *testing.T) {
r := chi.NewMux()
api := NewTestAdapter(r, Config{})
_, api := humatest.New(t, huma.Config{})

req, _ := http.NewRequest("GET", "/", nil)
resp := httptest.NewRecorder()
ctx := &testContext{nil, req, resp}
ctx := humatest.NewContext(nil, req, resp)

assert.Error(t, WriteErr(api, ctx, 400, "bad request"))
assert.Error(t, huma.WriteErr(api, ctx, 400, "bad request"))
}
Loading

0 comments on commit 0a90edd

Please sign in to comment.