mirror of
https://github.com/Fishwaldo/huma.git
synced 2025-03-15 11:21:42 +00:00
Initial commit
This commit is contained in:
commit
1d5a64a559
13 changed files with 1010 additions and 0 deletions
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
7
LICENSE.md
Normal file
7
LICENSE.md
Normal 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
27
README.md
Normal 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
48
example/main.go
Normal 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
11
go.mod
Normal 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
53
go.sum
Normal 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
180
openapi.go
Normal 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
31
openapi_test.go
Normal 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
274
router.go
Normal 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
105
router_test.go
Normal 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
112
schema.go
Normal 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
69
schema_test.go
Normal 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
93
validate.go
Normal 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
|
||||
}
|
Loading…
Add table
Reference in a new issue