Initial commit

This commit is contained in:
Daniel G. Taylor 2020-03-07 22:22:06 -08:00
commit 1d5a64a559
No known key found for this signature in database
GPG key ID: 7BD6DC99C9A87E22
13 changed files with 1010 additions and 0 deletions

0
.gitignore vendored Normal file
View file

7
LICENSE.md Normal file
View file

@ -0,0 +1,7 @@
Copyright 2020 Daniel G. Taylor
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

27
README.md Normal file
View file

@ -0,0 +1,27 @@
# Huma REST API Framework
A modern, simple & fast REST API framework for Go. The goals of this project are to provide:
- A modern REST API backend framework for Go
- Described by [OpenAPI 3](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) & [JSON Schema](https://json-schema.org/)
- First class support for middleware, JSON, and other features
- Documentation that can't get out of date
- High-quality developer tooling
Features include:
- Declarative interface on top of [Gin](https://github.com/gin-gonic/gin)
- Documentation
- Params
- Request body
- Responses (including errors)
- Annotated Go types for input and output models
- Documentation generation using [Redoc](https://github.com/Redocly/redoc)
This project was inspired by [FastAPI](https://fastapi.tiangolo.com/), [Gin](https://github.com/gin-gonic/gin), and countless others.
## Concepts & Example
REST APIs are composed of operations against resources and can include descriptions of various inputs and possible outputs. Huma uses standard Go types and a declarative API to capture those descriptions in order to provide a combination of idiomatic code, strong typing, and a rich ecosystem of tools for docs, mocks, generated SDK clients, and generated CLIs.
See the `example/main.go` file for a simple example.

48
example/main.go Normal file
View file

@ -0,0 +1,48 @@
package main
import (
"net/http"
"github.com/danielgtaylor/huma"
)
// EchoResponse message which echoes a value.
type EchoResponse struct {
Value string `json:"value" description:"The echoed back word"`
}
func main() {
r := huma.NewRouter(&huma.OpenAPI{
Title: "My API",
Version: "1.0.0",
})
r.Register(&huma.Operation{
ID: "echo",
Method: http.MethodPut,
Path: "/echo/:word",
Description: "Echo back an input word.",
Params: []*huma.Param{
huma.PathParam("word", "The word to echo back"),
huma.QueryParam("greet", "Return a greeting"),
},
Responses: []*huma.Response{
huma.ResponseJSON(http.StatusOK, "Successful echo response"),
huma.ResponseError(http.StatusBadRequest, "Invalid input"),
},
Handler: func(word string, greet bool) (int, *EchoResponse, *huma.ErrorModel) {
if word == "test" {
return http.StatusBadRequest, nil, &huma.ErrorModel{Message: "Value not allowed: test"}
}
v := word
if greet {
v = "Hello, " + word
}
return http.StatusOK, &EchoResponse{Value: v}, nil
},
})
r.Run("0.0.0.0:8888")
}

11
go.mod Normal file
View file

@ -0,0 +1,11 @@
module github.com/danielgtaylor/huma
go 1.13
require (
github.com/Jeffail/gabs v1.4.0
github.com/davecgh/go-spew v1.1.1
github.com/gin-gonic/gin v1.5.0
github.com/stretchr/testify v1.4.0
github.com/xeipuuv/gojsonschema v1.2.0
)

53
go.sum Normal file
View file

@ -0,0 +1,53 @@
github.com/Jeffail/gabs v1.4.0 h1://5fYRRTq1edjfIrQGvdkcd22pkYUrHZ5YC/H2GJVAo=
github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc=
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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.5.0 h1:fi+bqFAx/oLK54somfCtEZs9HeH1LHVoEPUgARpTqyc=
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM=
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo=
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8=
github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg=
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
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/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

180
openapi.go Normal file
View file

@ -0,0 +1,180 @@
package huma
import (
"fmt"
"reflect"
"strings"
"github.com/Jeffail/gabs"
"github.com/gin-gonic/gin"
)
// ErrorModel defines a basic error message
type ErrorModel struct {
Message string `json:"message"`
}
// ErrorInvalidModel defines an HTTP 400 Invalid response message
type ErrorInvalidModel struct {
Message string `json:"message"`
Errors []string `json:"errors"`
}
// Param describes an OpenAPI 3 parameter
type Param struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
In string `json:"in"`
Required bool `json:"required,omitempty"`
Schema *Schema `json:"schema,omitempty"`
typ reflect.Type
}
// PathParam returns a new required path parameter
func PathParam(name string, description string) *Param {
return &Param{
Name: name,
Description: description,
In: "path",
Required: true,
}
}
// QueryParam returns a new optional query string parameter
func QueryParam(name string, description string) *Param {
// TODO: support setting default value
return &Param{
Name: name,
Description: description,
In: "query",
}
}
// HeaderParam returns a new optional header parameter
func HeaderParam(name string, description string) *Param {
return &Param{
Name: name,
Description: description,
In: "header",
}
}
// Response describes an OpenAPI 3 response
type Response struct {
Description string
ContentType string
HTTPStatus uint16
Schema *Schema
}
// ResponseEmpty creates a new response with an empty body.
func ResponseEmpty(status uint16, description string) *Response {
return &Response{
Description: description,
HTTPStatus: status,
}
}
// ResponseJSON creates a new JSON response model.
func ResponseJSON(status uint16, description string) *Response {
return &Response{
Description: description,
ContentType: "application/json",
HTTPStatus: status,
}
}
// ResponseError creates a new error response model.
func ResponseError(status uint16, description string) *Response {
return &Response{
Description: description,
ContentType: "application/json",
HTTPStatus: status,
}
}
// Operation describes an OpenAPI 3 operation on a path
type Operation struct {
ID string
Method string
Path string
Description string
Params []*Param
RequestContentType string
RequestModel interface{}
RequestSchema *Schema
Responses []*Response
Handler interface{}
}
// OpenAPI describes the OpenAPI 3 API
type OpenAPI struct {
Title string
Version string
// Servers TODO
Paths map[string][]*Operation
}
// OpenAPIHandler returns a new handler function to generate an OpenAPI spec.
func OpenAPIHandler(api *OpenAPI) func(*gin.Context) {
return func(c *gin.Context) {
openapi := gabs.New()
openapi.Set("3.0.1", "openapi")
openapi.Set(api.Title, "info", "title")
openapi.Set(api.Version, "info", "version")
// spew.Dump(m.paths)
for path, operations := range api.Paths {
for _, op := range operations {
method := strings.ToLower(op.Method)
openapi.Set(op.ID, "paths", path, method, "operationId")
openapi.Set(op.Description, "paths", path, method, "description")
for _, param := range op.Params {
openapi.ArrayAppend(param, "paths", path, method, "parameters")
}
if op.RequestSchema != nil {
ct := op.RequestContentType
if ct == "" {
ct = "application/json"
}
openapi.Set(op.RequestSchema, "paths", path, method, "requestBody", "content", ct, "schema")
}
responses := make([]*Response, 0, len(op.Responses))
found400 := false
for _, resp := range op.Responses {
responses = append(responses, resp)
if resp.HTTPStatus == 400 {
found400 = true
}
}
if op.RequestSchema != nil && !found400 {
// Add a 400-level response in case parsing the request fails.
s, _ := GenerateSchema(reflect.ValueOf(ErrorInvalidModel{}).Type())
responses = append(responses, &Response{
Description: "Invalid input",
ContentType: "application/json",
HTTPStatus: 400,
Schema: s,
})
}
for _, resp := range op.Responses {
status := fmt.Sprintf("%v", resp.HTTPStatus)
openapi.Set(resp.Description, "paths", path, method, "responses", status, "description")
if resp.Schema != nil {
openapi.Set(resp.Schema, "paths", path, method, "responses", status, "content", resp.ContentType, "schema")
}
}
}
}
c.Data(200, "application/json; charset=utf-8", openapi.BytesIndent("", " "))
}
}

31
openapi_test.go Normal file
View file

@ -0,0 +1,31 @@
package huma
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPathParam(t *testing.T) {
p := PathParam("test", "desc")
assert.Equal(t, "test", p.Name)
assert.Equal(t, "desc", p.Description)
assert.Equal(t, "path", p.In)
assert.Equal(t, true, p.Required)
}
func TestQueryParam(t *testing.T) {
p := QueryParam("test", "desc")
assert.Equal(t, "test", p.Name)
assert.Equal(t, "desc", p.Description)
assert.Equal(t, "query", p.In)
assert.Equal(t, false, p.Required)
}
func TestHeaderParam(t *testing.T) {
p := HeaderParam("test", "desc")
assert.Equal(t, "test", p.Name)
assert.Equal(t, "desc", p.Description)
assert.Equal(t, "header", p.In)
assert.Equal(t, false, p.Required)
}

274
router.go Normal file
View file

@ -0,0 +1,274 @@
package huma
import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"net/http"
"reflect"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/xeipuuv/gojsonschema"
)
// ErrInvalidParamLocation is returned when the `in` field of the parameter
// is not a valid value.
var ErrInvalidParamLocation = errors.New("invalid parameter location")
func getParamValue(c *gin.Context, param *Param) (interface{}, error) {
var pstr string
switch param.In {
case "path":
pstr = c.Param(param.Name)
case "query":
pstr = c.Query(param.Name)
case "header":
pstr = c.GetHeader(param.Name)
default:
return nil, fmt.Errorf("%s: %w", param.In, ErrInvalidParamLocation)
}
if pstr == "" && !param.Required {
// Optional and not passed, so set it to its zero value.
return reflect.New(param.typ).Elem().Interface(), nil
}
var pv interface{}
switch param.typ.Kind() {
case reflect.Bool:
converted, err := strconv.ParseBool(pstr)
if err != nil {
return nil, err
}
pv = converted
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
converted, err := strconv.Atoi(pstr)
if err != nil {
return nil, err
}
pv = converted
case reflect.Float32:
converted, err := strconv.ParseFloat(pstr, 32)
if err != nil {
return nil, err
}
pv = converted
case reflect.Float64:
converted, err := strconv.ParseFloat(pstr, 64)
if err != nil {
return nil, err
}
pv = converted
default:
pv = pstr
}
return pv, nil
}
func getRequestBody(c *gin.Context, t reflect.Type, op *Operation) (interface{}, bool) {
val := reflect.New(t).Interface()
if op.RequestSchema != nil {
body, err := ioutil.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithError(500, err)
return nil, false
}
c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
loader := gojsonschema.NewGoLoader(op.RequestSchema)
doc := gojsonschema.NewBytesLoader(body)
s, err := gojsonschema.NewSchema(loader)
if err != nil {
c.AbortWithError(500, err)
return nil, false
}
result, err := s.Validate(doc)
if err != nil {
c.AbortWithError(500, err)
return nil, false
}
if !result.Valid() {
errors := []string{}
for _, desc := range result.Errors() {
errors = append(errors, fmt.Sprintf("%s", desc))
}
c.AbortWithStatusJSON(400, &ErrorInvalidModel{
Message: "Invalid input",
Errors: errors,
})
return nil, false
}
}
if err := c.ShouldBindJSON(val); err != nil {
c.AbortWithError(500, err)
return nil, false
}
return val, true
}
// Router handles API requests.
type Router struct {
api *OpenAPI
engine *gin.Engine
}
// NewRouter creates a new Huma router for handling API requests with
// default middleware and routes attached.
func NewRouter(api *OpenAPI) *Router {
r := &Router{
api: api,
engine: gin.Default(),
}
if r.api.Paths == nil {
r.api.Paths = make(map[string][]*Operation)
}
// Set up handlers for the auto-generated spec and docs.
r.engine.GET("/openapi.json", OpenAPIHandler(r.api))
r.engine.GET("/docs", func(c *gin.Context) {
c.Data(200, "text/html", []byte(fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<title>%s</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
<redoc spec-url='/openapi.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>`, r.api.Title)))
})
return r
}
// GinEngine returns the underlying low-level Gin engine.
func (r *Router) GinEngine() *gin.Engine {
return r.engine
}
// Use attaches middleware to the router.
func (r *Router) Use(middleware ...gin.HandlerFunc) {
r.engine.Use(middleware...)
}
// ServeHTTP conforms to the `http.Handler` interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.engine.ServeHTTP(w, req)
}
// Register a new operation.
func (r *Router) Register(op *Operation) {
// First, make sure the operation and handler make sense, as well as pre-
// generating any schemas for use later during request handling.
if err := op.validate(); err != nil {
panic(err)
}
// Add the operation to the list of operations for the path entry.
if r.api.Paths[op.Path] == nil {
r.api.Paths[op.Path] = make([]*Operation, 0, 1)
}
r.api.Paths[op.Path] = append(r.api.Paths[op.Path], op)
// Next, figure out which Gin function to call.
var f func(string, ...gin.HandlerFunc) gin.IRoutes
switch op.Method {
case "OPTIONS":
f = r.engine.OPTIONS
case "HEAD":
f = r.engine.HEAD
case "GET":
f = r.engine.GET
case "POST":
f = r.engine.POST
case "PUT":
f = r.engine.PUT
case "PATCH":
f = r.engine.PATCH
case "DELETE":
f = r.engine.DELETE
default:
panic("unsupported HTTP method")
}
// Then call it to register our handler function.
f(op.Path, func(c *gin.Context) {
method := reflect.ValueOf(op.Handler)
in := make([]reflect.Value, 0, method.Type().NumIn())
if method.Type().In(0).String() == "*gin.Context" {
fmt.Println("Found context")
in = append(in, reflect.ValueOf(c).Elem())
}
for _, param := range op.Params {
pv, err := getParamValue(c, param)
if err != nil {
// TODO expose error to user
c.AbortWithError(400, err)
return
}
in = append(in, reflect.ValueOf(pv))
}
if len(in) != method.Type().NumIn() {
// Parse body
i := len(in)
val, success := getRequestBody(c, method.Type().In(i), op)
if !success {
// Error was already handled in `getRequestBody`.
return
}
in = append(in, reflect.ValueOf(val))
if in[i].Kind() == reflect.Ptr {
in[i] = in[i].Elem()
}
}
out := method.Call(in)
// Find and return the first non-zero response.
// TODO: This breaks down with scalar types... hmmm.
status := out[0].Interface().(int)
var body interface{}
for i, o := range out[1:] {
if !o.IsZero() {
body = o.Interface()
r := op.Responses[i]
if strings.HasPrefix(r.ContentType, "application/json") {
c.JSON(status, body)
} else if strings.HasPrefix(r.ContentType, "application/yaml") {
c.YAML(status, body)
} else {
// TODO: type check that body is actually bytes
c.Data(status, r.ContentType, body.([]byte))
}
break
}
}
})
}
// Run the server.
func (r *Router) Run(addr string) {
r.engine.Run(addr)
}

105
router_test.go Normal file
View file

@ -0,0 +1,105 @@
package huma
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
)
func TestRouter(t *testing.T) {
type EchoResponse struct {
Value string `json:"value" description:"The echoed back word"`
}
r := NewRouter(&OpenAPI{
Title: "My API",
Version: "1.0.0",
})
r.Register(&Operation{
ID: "echo",
Method: http.MethodPut,
Path: "/echo/:word",
Description: "Echo back an input word.",
Params: []*Param{
PathParam("word", "The word to echo back"),
QueryParam("greet", "Return a greeting"),
},
Responses: []*Response{
ResponseJSON(http.StatusOK, "Successful echo response"),
ResponseError(http.StatusBadRequest, "Invalid input"),
},
Handler: func(word string, greet bool) (int, *EchoResponse, *ErrorModel) {
if word == "test" {
return http.StatusBadRequest, nil, &ErrorModel{Message: "Value not allowed: test"}
}
v := word
if greet {
v = "Hello, " + word
}
return http.StatusOK, &EchoResponse{Value: v}, nil
},
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/echo/world", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, `{"value":"world"}`+"\n", w.Body.String())
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPut, "/echo/world?greet=true", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, `{"value":"Hello, world"}`+"\n", w.Body.String())
w = httptest.NewRecorder()
req, _ = http.NewRequest(http.MethodPut, "/echo/world?greet=bad", nil)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestRouterRequestBody(t *testing.T) {
type EchoRequest struct {
Value string `json:"value"`
}
type EchoResponse struct {
Value string `json:"value" description:"The echoed back word"`
}
r := NewRouter(&OpenAPI{
Title: "My API",
Version: "1.0.0",
})
r.Register(&Operation{
ID: "echo",
Method: http.MethodPut,
Path: "/echo",
Description: "Echo back an input word.",
Responses: []*Response{
ResponseJSON(http.StatusOK, "Successful echo response"),
},
Handler: func(in *EchoRequest) (int, *EchoResponse) {
spew.Dump(in)
return http.StatusOK, &EchoResponse{Value: in.Value}
},
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/echo", bytes.NewBufferString(`{"value": "hello"}`))
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, `{"value":"hello"}`+"\n", w.Body.String())
}

112
schema.go Normal file
View file

@ -0,0 +1,112 @@
package huma
import (
"errors"
"reflect"
"strings"
)
// Schema represents a JSON Schema which can be generated from Go structs
type Schema struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Items *Schema `json:"items,omitempty"`
Properties map[string]*Schema `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
Format string `json:"format,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
}
// GenerateSchema creates a JSON schema for a Go type. Struct field tags
// can be used to provide additional metadata such as descriptions and
// validation.
func GenerateSchema(t reflect.Type) (*Schema, error) {
schema := &Schema{}
switch t.Kind() {
case reflect.Struct:
// TODO: support time and URI types
properties := make(map[string]*Schema)
required := make([]string, 0)
schema.Type = "object"
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
jsonTags := strings.Split(f.Tag.Get("json"), ",")
name := f.Name
if len(jsonTags) > 0 {
name = jsonTags[0]
}
s, err := GenerateSchema(f.Type)
if err != nil {
return nil, err
}
properties[name] = s
if d, ok := f.Tag.Lookup("description"); ok {
s.Description = d
}
if e, ok := f.Tag.Lookup("enum"); ok {
s.Enum = []interface{}{}
for _, v := range strings.Split(e, ",") {
// TODO: convert to correct type
s.Enum = append(s.Enum, v)
}
}
optional := false
for _, tag := range jsonTags[1:] {
if tag == "omitempty" {
optional = true
}
}
if !optional {
required = append(required, name)
}
}
if len(properties) > 0 {
schema.Properties = properties
}
if len(required) > 0 {
schema.Required = required
}
case reflect.Map:
// pass
case reflect.Slice, reflect.Array:
schema.Type = "array"
s, err := GenerateSchema(t.Elem())
if err != nil {
return nil, err
}
schema.Items = s
case reflect.Interface:
// pass
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return &Schema{
Type: "integer",
}, nil
case reflect.Float32, reflect.Float64:
return &Schema{Type: "number"}, nil
case reflect.Bool:
return &Schema{Type: "boolean"}, nil
case reflect.String:
return &Schema{Type: "string"}, nil
case reflect.Ptr:
return GenerateSchema(t.Elem())
default:
return nil, errors.New("unsupported type")
}
return schema, nil
}

69
schema_test.go Normal file
View file

@ -0,0 +1,69 @@
package huma
import (
"fmt"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
var types = []struct {
in interface{}
out string
}{
{false, "boolean"},
{0, "integer"},
{0.0, "number"},
{"hello", "string"},
{struct{}{}, "object"},
{[]string{"foo"}, "array"},
// TODO: map
}
func TestTypes(outer *testing.T) {
outer.Parallel()
for _, tt := range types {
local := tt
outer.Run(fmt.Sprintf("%v", tt.in), func(t *testing.T) {
t.Parallel()
s, err := GenerateSchema(reflect.ValueOf(local.in).Type())
assert.NoError(t, err)
assert.Equal(t, local.out, s.Type)
})
}
}
func TestRequiredFields(t *testing.T) {
type Example struct {
Optional string `json:"optional,omitempty"`
Required string `json:"required"`
}
s, err := GenerateSchema(reflect.ValueOf(Example{}).Type())
assert.NoError(t, err)
assert.Len(t, s.Properties, 2)
assert.NotContains(t, s.Required, "optional")
assert.Contains(t, s.Required, "required")
}
func TestRenameField(t *testing.T) {
type Example struct {
Foo string `json:"bar"`
}
s, err := GenerateSchema(reflect.ValueOf(Example{}).Type())
assert.NoError(t, err)
assert.Empty(t, s.Properties["foo"])
assert.NotEmpty(t, s.Properties["bar"])
}
func TestDescription(t *testing.T) {
type Example struct {
Foo string `json:"foo" description:"I am a test"`
}
s, err := GenerateSchema(reflect.ValueOf(Example{}).Type())
assert.NoError(t, err)
assert.Equal(t, "I am a test", s.Properties["foo"].Description)
}

93
validate.go Normal file
View file

@ -0,0 +1,93 @@
package huma
import (
"errors"
"reflect"
)
// ErrContextNotFirst is returned when a registered operation has a handler
// that takes a context but it is not the first parameter of the function.
var ErrContextNotFirst = errors.New("context should be first parameter")
// ErrParamsMustMatch is returned when a registered operation has a handler
// function that takes the wrong number of arguments.
var ErrParamsMustMatch = errors.New("handler function args must match registered params")
// ErrResponsesMustMatch is returned when the registered operation has a handler
// function that returns the wrong number of arguments.
var ErrResponsesMustMatch = errors.New("handler function return values must match registered responses")
// validate checks that the operation is well-formed (e.g. handler signature
// matches the given params) and generates schemas if needed.
func (o *Operation) validate() error {
method := reflect.ValueOf(o.Handler)
types := []reflect.Type{}
for i := 0; i < method.Type().NumIn(); i++ {
paramType := method.Type().In(i)
if paramType.String() == "*gin.Context" {
// Skip context parameter
if i != 0 {
return ErrContextNotFirst
}
continue
}
types = append(types, paramType)
}
if len(types) < len(o.Params) {
// Example: handler function takes 3 params, but 5 are described.
return ErrParamsMustMatch
}
requestBody := false
if len(types) == len(o.Params)+1 {
requestBody = true
} else if len(types) != len(o.Params) {
// Example: handler function takes 5 params, but 3 are described.
return ErrParamsMustMatch
}
for i, paramType := range types {
if i == len(types)-1 && requestBody {
// The last item has no associated param.
s, err := GenerateSchema(paramType)
if err != nil {
return err
}
o.RequestSchema = s
continue
}
p := o.Params[i]
p.typ = paramType
if p.Schema == nil {
// Auto-generate a schema for this parameter
s, err := GenerateSchema(paramType)
if err != nil {
return err
}
p.Schema = s
}
}
// Check that outputs match registered responses and add their type info
if method.Type().NumOut() != len(o.Responses)+1 {
return ErrResponsesMustMatch
}
for i, resp := range o.Responses {
respType := method.Type().Out(i + 1)
if resp.HTTPStatus != 204 && resp.Schema == nil {
s, err := GenerateSchema(respType)
if err != nil {
return err
}
resp.Schema = s
}
}
return nil
}