Skip to content

Commit

Permalink
Merge pull request #203 from danielgtaylor/go-1.22
Browse files Browse the repository at this point in the history
feat: experimental Go 1.22 ServeMux support
  • Loading branch information
danielgtaylor authored Jan 9, 2024
2 parents bcc4382 + 7f6db28 commit 3a7da12
Show file tree
Hide file tree
Showing 4 changed files with 312 additions and 0 deletions.
130 changes: 130 additions & 0 deletions adapters/humago/humago.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package humago

import (
"context"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"

"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/queryparam"
)

type goContext struct {
op *huma.Operation
r *http.Request
w http.ResponseWriter
}

func (c *goContext) Operation() *huma.Operation {
return c.op
}

func (c *goContext) Context() context.Context {
return c.r.Context()
}

func (c *goContext) Method() string {
return c.r.Method
}

func (c *goContext) Host() string {
return c.r.Host
}

func (c *goContext) URL() url.URL {
return *c.r.URL
}

func (c *goContext) Param(name string) string {
// For now, support Go 1.22+ while still compiling in older versions by
// checking for the existence of the new method.
// TODO: eventually remove when the minimum Go version goes to 1.22.
var v any = c.r
if pv, ok := v.(interface{ PathValue(string) string }); ok {
return pv.PathValue(name)
}
panic("requires Go 1.22+")
}

func (c *goContext) Query(name string) string {
return queryparam.Get(c.r.URL.RawQuery, name)
}

func (c *goContext) Header(name string) string {
return c.r.Header.Get(name)
}

func (c *goContext) EachHeader(cb func(name, value string)) {
for name, values := range c.r.Header {
for _, value := range values {
cb(name, value)
}
}
}

func (c *goContext) BodyReader() io.Reader {
return c.r.Body
}

func (c *goContext) GetMultipartForm() (*multipart.Form, error) {
err := c.r.ParseMultipartForm(8 * 1024)
return c.r.MultipartForm, err
}

func (c *goContext) SetReadDeadline(deadline time.Time) error {
return huma.SetReadDeadline(c.w, deadline)
}

func (c *goContext) SetStatus(code int) {
c.w.WriteHeader(code)
}

func (c *goContext) AppendHeader(name string, value string) {
c.w.Header().Add(name, value)
}

func (c *goContext) SetHeader(name string, value string) {
c.w.Header().Set(name, value)
}

func (c *goContext) BodyWriter() io.Writer {
return c.w
}

// NewContext creates a new Huma context from an HTTP request and response.
func NewContext(op *huma.Operation, r *http.Request, w http.ResponseWriter) huma.Context {
return &goContext{op: op, r: r, w: w}
}

type goAdapter struct {
router *http.ServeMux
}

func (a *goAdapter) Handle(op *huma.Operation, handler func(huma.Context)) {
a.router.HandleFunc(strings.ToUpper(op.Method)+" "+op.Path, func(w http.ResponseWriter, r *http.Request) {
handler(&goContext{op: op, r: r, w: w})
})
}

func (a *goAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.router.ServeHTTP(w, r)
}

// NewAdapter creates a new adapter for the given HTTP mux.
func NewAdapter(r *http.ServeMux) huma.Adapter {
return &goAdapter{router: r}
}

// New creates a new Huma API using an HTTP mux.
func New(r *http.ServeMux, config huma.Config) huma.API {
// Panic if Go version is less than 1.22
var v any = &http.Request{}
if _, ok := v.(interface{ PathValue(string) string }); !ok {
panic("This adapter requires Go 1.22+")
}
return huma.NewAPI(config, &goAdapter{router: r})
}
179 changes: 179 additions & 0 deletions adapters/humago/humago_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package humago

import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
"time"

"github.com/danielgtaylor/huma/v2"
)

var lastModified = time.Now()

func BenchmarkHumaV2Go(b *testing.B) {
type GreetingInput struct {
ID string `path:"id"`
ContentType string `header:"Content-Type"`
Num int `query:"num"`
Body struct {
Suffix string `json:"suffix" maxLength:"5"`
}
}

type GreetingOutput struct {
ETag string `header:"ETag"`
LastModified time.Time `header:"Last-Modified"`
Body struct {
Greeting string `json:"greeting"`
Suffix string `json:"suffix"`
Length int `json:"length"`
ContentType string `json:"content_type"`
Num int `json:"num"`
}
}

r := http.NewServeMux()
app := New(r, huma.DefaultConfig("Test", "1.0.0"))

huma.Register(app, huma.Operation{
OperationID: "greet",
Method: http.MethodPost,
Path: "/foo/{id}",
}, func(ctx context.Context, input *GreetingInput) (*GreetingOutput, error) {
resp := &GreetingOutput{}
resp.ETag = "abc123"
resp.LastModified = lastModified
resp.Body.Greeting = "Hello, " + input.ID + input.Body.Suffix
resp.Body.Suffix = input.Body.Suffix
resp.Body.Length = len(resp.Body.Greeting)
resp.Body.ContentType = input.ContentType
resp.Body.Num = input.Num
return resp, nil
})

reqBody := strings.NewReader(`{"suffix": "!"}`)
req, _ := http.NewRequest(http.MethodPost, "/foo/123?num=5", reqBody)
req.Header.Set("Content-Type", "application/json")
b.ResetTimer()
b.ReportAllocs()
w := httptest.NewRecorder()
for i := 0; i < b.N; i++ {
reqBody.Seek(0, 0)
w.Body.Reset()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatal(w.Body.String())
}
}
}

func BenchmarkRawGo(b *testing.B) {
type GreetingInput struct {
Suffix string `json:"suffix" maxLength:"5"`
}

type GreetingOutput struct {
Schema string `json:"$schema"`
Greeting string `json:"greeting"`
Suffix string `json:"suffix"`
Length int `json:"length"`
ContentType string `json:"content_type"`
Num int `json:"num"`
}

registry := huma.NewMapRegistry("#/components/schemas/",
func(t reflect.Type, hint string) string {
return t.Name()
})
schema := registry.Schema(reflect.TypeOf(GreetingInput{}), false, "")

strSchema := registry.Schema(reflect.TypeOf(""), false, "")
numSchema := registry.Schema(reflect.TypeOf(0), false, "")

r := http.NewServeMux()

r.HandleFunc("POST /foo/{id}", func(w http.ResponseWriter, r *http.Request) {
pb := huma.NewPathBuffer([]byte{}, 0)
res := &huma.ValidateResult{}

// Read and validate params
id := ""
var v any = r
if pv, ok := v.(interface{ PathValue(string) string }); ok {
id = pv.PathValue("id")
}
huma.Validate(registry, strSchema, pb, huma.ModeReadFromServer, id, res)

ct := r.Header.Get("Content-Type")
huma.Validate(registry, strSchema, pb, huma.ModeReadFromServer, ct, res)

num, err := strconv.Atoi(r.URL.Query().Get("num"))
if err != nil {
panic(err)
}
huma.Validate(registry, numSchema, pb, huma.ModeReadFromServer, num, res)

// Read and validate body
defer r.Body.Close()
data, err := io.ReadAll(r.Body)
if err != nil {
panic(err)
}

var tmp any
if err := json.Unmarshal(data, &tmp); err != nil {
panic(err)
}

huma.Validate(registry, schema, pb, huma.ModeWriteToServer, tmp, res)
if len(res.Errors) > 0 {
panic(res.Errors)
}

var input GreetingInput
if err := json.Unmarshal(data, &input); err != nil {
panic(err)
}

// Set up and write the response
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", "abc123")
w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat))
w.Header().Set("Link", "</schemas/GreetingOutput.json>; rel=\"describedBy\"")
w.WriteHeader(http.StatusOK)
resp := &GreetingOutput{}
resp.Schema = "/schemas/GreetingOutput.json"
resp.Greeting = "Hello, " + id + input.Suffix
resp.Suffix = input.Suffix
resp.Length = len(resp.Greeting)
resp.ContentType = ct
resp.Num = num
data, err = json.Marshal(resp)
if err != nil {
panic(err)
}
w.Write(data)
})

reqBody := strings.NewReader(`{"suffix": "!"}`)
req, _ := http.NewRequest(http.MethodPost, "/foo/123?num=5", reqBody)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
b.ResetTimer()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
reqBody.Seek(0, 0)
w.Body.Reset()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
b.Fatal(w.Body.String())
}
}
}
1 change: 1 addition & 0 deletions docs/docs/features/bring-your-own-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Adapters are in the [`adapters`](https://github.com/danielgtaylor/huma/tree/main
- [BunRouter](https://bunrouter.uptrace.dev/) via [`humabunrouter`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humabunrouter)
- [chi](https://github.com/go-chi/chi) via [`humachi`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humachi)
- [gin](https://gin-gonic.com/) via [`humagin`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humagin)
- [Go 1.22+ `http.ServeMux`](https://pkg.go.dev/net/http@master#ServeMux) via [`humago`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humago) (experimental, requires `go 1.22` in `go.mod`)
- [gorilla/mux](https://github.com/gorilla/mux) via [`humamux`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humamux)
- [httprouter](https://github.com/julienschmidt/httprouter) via [`humahttprouter`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humahttprouter)
- [Fiber](https://gofiber.io/) via [`humafiber`](https://pkg.go.dev/github.com/danielgtaylor/huma/v2/adapters/humafiber)
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/why/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ Be sure to check out the [benchmarks](./benchmarks.md)!

Huma is router-agnostic and includes support for a handful of popular routers and their middleware your organization may already be using today:

- [BunRouter](https://bunrouter.uptrace.dev/)
- [chi](https://github.com/go-chi/chi)
- [gin](https://gin-gonic.com/)
- [Go 1.22+ `http.ServeMux` (experimental)](https://pkg.go.dev/net/http@master#ServeMux)
- [gorilla/mux](https://github.com/gorilla/mux)
- [httprouter](https://github.com/julienschmidt/httprouter)
- [Fiber](https://gofiber.io/)
Expand Down

0 comments on commit 3a7da12

Please sign in to comment.