From 10c5999226297872a98b631b912223e5bcc5f642 Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Thu, 9 Jul 2020 14:10:47 -0700 Subject: [PATCH 01/21] chore(ktracer/update-dep): update references to point to istreamlabs --- benchmark/README.md | 2 +- benchmark/huma/main.go | 2 +- examples/echo/main.go | 2 +- examples/hello/main.go | 2 +- examples/readme/main.go | 4 ++-- examples/store/main.go | 4 ++-- examples/test/main.go | 2 +- examples/test/main_test.go | 2 +- examples/timeout/main.go | 2 +- examples/unsafe/main.go | 4 ++-- go.mod | 2 +- huma_readme_test.go | 4 ++-- humatest/humatest.go | 2 +- humatest/humatest_test.go | 4 ++-- memstore/memstore.go | 4 ++-- memstore/memstore_test.go | 2 +- openapi.go | 2 +- openapi_test.go | 2 +- options.go | 2 +- router.go | 4 ++-- router_test.go | 2 +- validate.go | 2 +- 22 files changed, 29 insertions(+), 29 deletions(-) diff --git a/benchmark/README.md b/benchmark/README.md index 02c1727c..a9f3c036 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -7,7 +7,7 @@ This folder contains four implementations of the same service using the followin - [FastAPI](https://github.com/tiangolo/fastapi) (Python, popular framework) - [Gin](https://github.com/gin-gonic/gin) (Go, popular framework) - [Echo](https://echo.labstack.com/) (Go, popular framework) -- [Huma](https://github.com/danielgtaylor/huma) (Go, this project) +- [Huma](https://github.com/istreamlabs/huma) (Go, this project) The [wrk](https://github.com/wg/wrk) benchmarking tool is used to make requests against each implementation for 10 seconds with 10 concurrent workers. The results on a 2017 MacBook Pro are shown below: diff --git a/benchmark/huma/main.go b/benchmark/huma/main.go index 8653548b..e2a2101c 100644 --- a/benchmark/huma/main.go +++ b/benchmark/huma/main.go @@ -4,7 +4,7 @@ import ( "net/http" "strings" - "github.com/danielgtaylor/huma" + "github.com/istreamlabs/huma" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" ) diff --git a/examples/echo/main.go b/examples/echo/main.go index bf9ef672..d355d066 100644 --- a/examples/echo/main.go +++ b/examples/echo/main.go @@ -3,7 +3,7 @@ package main import ( "net/http" - "github.com/danielgtaylor/huma" + "github.com/istreamlabs/huma" ) // EchoResponse message which echoes a value. diff --git a/examples/hello/main.go b/examples/hello/main.go index a31b8f78..34090e15 100644 --- a/examples/hello/main.go +++ b/examples/hello/main.go @@ -1,7 +1,7 @@ package main import ( - "github.com/danielgtaylor/huma" + "github.com/istreamlabs/huma" ) func main() { diff --git a/examples/readme/main.go b/examples/readme/main.go index 6996cbfd..b7fed54b 100644 --- a/examples/readme/main.go +++ b/examples/readme/main.go @@ -5,8 +5,8 @@ import ( "sync" "time" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/schema" ) // NoteSummary is used to list notes. It does not include the (potentially) diff --git a/examples/store/main.go b/examples/store/main.go index d8200e4b..274e8dec 100644 --- a/examples/store/main.go +++ b/examples/store/main.go @@ -3,8 +3,8 @@ package main import ( "time" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/memstore" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/memstore" ) // Note represents a sticky note. diff --git a/examples/test/main.go b/examples/test/main.go index 5d7c0977..fea87edd 100644 --- a/examples/test/main.go +++ b/examples/test/main.go @@ -1,6 +1,6 @@ package main -import "github.com/danielgtaylor/huma" +import "github.com/istreamlabs/huma" func routes(r *huma.Router) { // Register a single test route that returns a text/plain response. diff --git a/examples/test/main_test.go b/examples/test/main_test.go index 94400d55..d690ba51 100644 --- a/examples/test/main_test.go +++ b/examples/test/main_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/danielgtaylor/huma/humatest" + "github.com/istreamlabs/huma/humatest" "github.com/stretchr/testify/assert" ) diff --git a/examples/timeout/main.go b/examples/timeout/main.go index 66faac29..7c4f75b6 100644 --- a/examples/timeout/main.go +++ b/examples/timeout/main.go @@ -5,7 +5,7 @@ import ( "net/http" "time" - "github.com/danielgtaylor/huma" + "github.com/istreamlabs/huma" ) func main() { diff --git a/examples/unsafe/main.go b/examples/unsafe/main.go index f60b1d43..4df65675 100644 --- a/examples/unsafe/main.go +++ b/examples/unsafe/main.go @@ -4,8 +4,8 @@ import ( "net/http" "reflect" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/schema" ) // Item stores some value. diff --git a/go.mod b/go.mod index 074084a8..338f91cb 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/danielgtaylor/huma +module github.com/istreamlabs/huma go 1.13 diff --git a/huma_readme_test.go b/huma_readme_test.go index 4cc50324..19a5f1d8 100644 --- a/huma_readme_test.go +++ b/huma_readme_test.go @@ -5,8 +5,8 @@ import ( "sync" "time" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/schema" ) // NoteSummary is used to list notes. It does not include the (potentially) diff --git a/humatest/humatest.go b/humatest/humatest.go index 60a52378..f714dffd 100644 --- a/humatest/humatest.go +++ b/humatest/humatest.go @@ -5,7 +5,7 @@ package humatest import ( "testing" - "github.com/danielgtaylor/huma" + "github.com/istreamlabs/huma" "github.com/gin-gonic/gin" "go.uber.org/zap" "go.uber.org/zap/zapcore" diff --git a/humatest/humatest_test.go b/humatest/humatest_test.go index 407666a2..21b63f4a 100644 --- a/humatest/humatest_test.go +++ b/humatest/humatest_test.go @@ -5,8 +5,8 @@ import ( "net/http/httptest" "testing" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/humatest" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/humatest" "github.com/stretchr/testify/assert" ) diff --git a/memstore/memstore.go b/memstore/memstore.go index a07b41c4..59e579b9 100644 --- a/memstore/memstore.go +++ b/memstore/memstore.go @@ -13,8 +13,8 @@ import ( "sync" "time" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/schema" "github.com/fatih/structs" "github.com/gosimple/slug" "github.com/mitchellh/copystructure" diff --git a/memstore/memstore_test.go b/memstore/memstore_test.go index f60e5261..c6be4c79 100644 --- a/memstore/memstore_test.go +++ b/memstore/memstore_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/danielgtaylor/huma/humatest" + "github.com/istreamlabs/huma/humatest" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) diff --git a/openapi.go b/openapi.go index c73429d9..9787e947 100644 --- a/openapi.go +++ b/openapi.go @@ -8,7 +8,7 @@ import ( "time" "github.com/Jeffail/gabs" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma/schema" "github.com/gin-gonic/gin" "gopkg.in/yaml.v2" ) diff --git a/openapi_test.go b/openapi_test.go index e5650a2c..e606dcf6 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma/schema" "github.com/getkin/kin-openapi/openapi3" "github.com/stretchr/testify/assert" ) diff --git a/options.go b/options.go index dc4eb69a..4112a4f7 100644 --- a/options.go +++ b/options.go @@ -6,7 +6,7 @@ import ( "time" "github.com/Jeffail/gabs" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma/schema" "github.com/gin-gonic/gin" ) diff --git a/router.go b/router.go index 595c5c0a..95a8306b 100644 --- a/router.go +++ b/router.go @@ -44,7 +44,7 @@ // assert.Equal(t, "pong", w.Body.String()) // } // -// See https://github.com/danielgtaylor/huma#readme for more high-level feature +// See https://github.com/istreamlabs/huma#readme for more high-level feature // docs with examples. package huma @@ -63,7 +63,7 @@ import ( "sync" "time" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma/schema" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/spf13/cobra" diff --git a/router_test.go b/router_test.go index d5f08cda..a6c10d6f 100644 --- a/router_test.go +++ b/router_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma/schema" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" diff --git a/validate.go b/validate.go index 69ee192c..685d6ab8 100644 --- a/validate.go +++ b/validate.go @@ -9,7 +9,7 @@ import ( "strings" "time" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma/schema" "github.com/gosimple/slug" ) From 9c5287b8d2a17eca6f229c9509c61ab1fdae0885 Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Thu, 9 Jul 2020 14:14:08 -0700 Subject: [PATCH 02/21] update go.mod post-"go test"(ktracer/update-dep): --- go.mod | 1 + 1 file changed, 1 insertion(+) diff --git a/go.mod b/go.mod index 338f91cb..d67e80a5 100644 --- a/go.mod +++ b/go.mod @@ -22,4 +22,5 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 go.uber.org/zap v1.10.0 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v2 v2.2.8 ) From f1389fa5b246e2d2c99fc5c1601e75e2da8fbf0f Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Thu, 9 Jul 2020 17:10:01 -0700 Subject: [PATCH 03/21] renamed file to trigger GitHub Action inclusion --- .github/workflows/{ci.yaml => continuous-integration.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{ci.yaml => continuous-integration.yaml} (100%) diff --git a/.github/workflows/ci.yaml b/.github/workflows/continuous-integration.yaml similarity index 100% rename from .github/workflows/ci.yaml rename to .github/workflows/continuous-integration.yaml From f0cc34f77af723e34caf9b7fd42d0829e1e11dde Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Thu, 9 Jul 2020 17:13:46 -0700 Subject: [PATCH 04/21] chore(ktracer/update-dep): move it back --- .github/workflows/{continuous-integration.yaml => ci.yaml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{continuous-integration.yaml => ci.yaml} (100%) diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/ci.yaml similarity index 100% rename from .github/workflows/continuous-integration.yaml rename to .github/workflows/ci.yaml From 98eb248b384739181fcaa9edfacc8af242b62271 Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Fri, 10 Jul 2020 09:50:23 -0700 Subject: [PATCH 05/21] chore(ktracer/update-dep): update badges, README to point to istreamlabs repo --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 72e8edc1..1066b626 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Huma Rest API Framework](https://user-images.githubusercontent.com/106826/78105564-51102780-73a6-11ea-99ff-84d6c1b3e8df.png) -[![HUMA Powered](https://img.shields.io/badge/Powered%20By-HUMA-f40273)](https://huma.rocks/) [![CI](https://github.com/danielgtaylor/huma/workflows/CI/badge.svg?branch=master)](https://github.com/danielgtaylor/huma/actions?query=workflow%3ACI+branch%3Amaster++) [![codecov](https://codecov.io/gh/danielgtaylor/huma/branch/master/graph/badge.svg)](https://codecov.io/gh/danielgtaylor/huma) [![Docs](https://godoc.org/github.com/danielgtaylor/huma?status.svg)](https://pkg.go.dev/github.com/danielgtaylor/huma?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/danielgtaylor/huma)](https://goreportcard.com/report/github.com/danielgtaylor/huma) +[![HUMA Powered](https://img.shields.io/badge/Powered%20By-HUMA-f40273)](https://huma.rocks/) [![CI](https://github.com/istreamlabs/huma/workflows/CI/badge.svg?branch=main)](https://github.com/istreamlabs/huma/actions?query=workflow%3ACI+branch%3Amain++) [![codecov](https://codecov.io/gh/istreamlabs/huma/branch/main/graph/badge.svg)](https://codecov.io/gh/istreamlabs/huma) [![Docs](https://godoc.org/github.com/istreamlabs/huma?status.svg)](https://pkg.go.dev/github.com/istreamlabs/huma?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/istreamlabs/huma)](https://goreportcard.com/report/github.com/istreamlabs/huma) A modern, simple, fast & opinionated REST API framework for Go with batteries included. Pronounced IPA: [/'hjuːmɑ/](https://en.wiktionary.org/wiki/Wiktionary:International_Phonetic_Alphabet). The goals of this project are to provide: @@ -38,12 +38,12 @@ Features include: - Set via e.g. `-p 8000`, `--port=8000`, or `SERVICE_PORT=8000` - Connection timeouts & graceful shutdown built-in - Generates OpenAPI JSON for access to a rich ecosystem of tools - - Mocks with [API Sprout](https://github.com/danielgtaylor/apisprout) + - Mocks with [API Sprout](https://github.com/istreamlabs/apisprout) - SDKs with [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) - CLIs with [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator) - And [plenty](https://openapi.tools/) [more](https://apis.guru/awesome-openapi3/category.html) -This project was inspired by [FastAPI](https://fastapi.tiangolo.com/), [Gin](https://github.com/gin-gonic/gin), and countless others. Look at the [benchmarks](https://github.com/danielgtaylor/huma/tree/master/benchmark) to see how Huma compares. +This project was inspired by [FastAPI](https://fastapi.tiangolo.com/), [Gin](https://github.com/gin-gonic/gin), and countless others. Look at the [benchmarks](https://github.com/istreamlabs/huma/tree/main/benchmark) to see how Huma compares. Logo & branding designed by [Kari Taylor](https://www.kari.photography/). @@ -70,8 +70,8 @@ import ( "sync" "time" - "github.com/danielgtaylor/huma" - "github.com/danielgtaylor/huma/schema" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/schema" ) // NoteSummary is used to list notes. It does not include the (potentially) @@ -868,12 +868,12 @@ TODO The Go standard library provides useful testing utilities and Huma routers implement the [`http.Handler`](https://golang.org/pkg/net/http/#Handler) interface they expect. Huma also provides a `humatest` package with utilities for creating test routers capable of e.g. capturing logs. -You can see an example in the [`examples/test`](https://github.com/danielgtaylor/huma/tree/master/examples/test) directory: +You can see an example in the [`examples/test`](https://github.com/istreamlabs/huma/tree/main/examples/test) directory: ```go package main -import "github.com/danielgtaylor/huma" +import "github.com/istreamlabs/huma" func routes(r *huma.Router) { // Register a single test route that returns a text/plain response. @@ -902,7 +902,7 @@ import ( "net/http/httptest" "testing" - "github.com/danielgtaylor/huma/humatest" + "github.com/istreamlabs/huma/humatest" "github.com/stretchr/testify/assert" ) From 346681091293336ced54a4716a1f3bcd433b2345 Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Fri, 10 Jul 2020 09:56:57 -0700 Subject: [PATCH 06/21] apisprout is not an istreamlabs tool --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1066b626..f94821b1 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Features include: - Set via e.g. `-p 8000`, `--port=8000`, or `SERVICE_PORT=8000` - Connection timeouts & graceful shutdown built-in - Generates OpenAPI JSON for access to a rich ecosystem of tools - - Mocks with [API Sprout](https://github.com/istreamlabs/apisprout) + - Mocks with [API Sprout](https://github.com/danielgtaylor/apisprout) - SDKs with [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) - CLIs with [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator) - And [plenty](https://openapi.tools/) [more](https://apis.guru/awesome-openapi3/category.html) From 2bdcc5fe5a97f4d03cd638a56686a48e4ececc31 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Tue, 21 Jul 2020 13:20:10 -0700 Subject: [PATCH 07/21] fix: return correct status to other middleware --- middleware.go | 4 ++++ middleware_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/middleware.go b/middleware.go index 588cc144..4141b70f 100644 --- a/middleware.go +++ b/middleware.go @@ -418,6 +418,10 @@ func (w *contentEncodingWriter) WriteHeader(code int) { w.status = code } +func (w *contentEncodingWriter) Status() int { + return w.status +} + func (w *contentEncodingWriter) Close() { if !w.wroteHeader { w.ResponseWriter.WriteHeader(w.status) diff --git a/middleware_test.go b/middleware_test.go index 29ff29d9..2a2572b6 100644 --- a/middleware_test.go +++ b/middleware_test.go @@ -237,3 +237,28 @@ func TestContentEncodingCompressedMultiWrite(t *testing.T) { decoded, _ := ioutil.ReadAll(gr) assert.Equal(t, 2250, len(decoded)) } + +func TestContentEncodingError(t *testing.T) { + var status int + + r := NewTestRouter(t) + r.GinEngine().Use(ContentEncodingMiddleware()) + r.GinEngine().Use(func(c *gin.Context) { + c.Next() + + // Other middleware should be able to read the response status + status = c.Writer.Status() + }) + r.GinEngine().GET("/", func(c *gin.Context) { + c.Writer.WriteHeader(http.StatusNotFound) + c.Writer.Write([]byte("some text")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Accept-Encoding", "gzip") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, status) + assert.Equal(t, http.StatusNotFound, w.Result().StatusCode) +} From 7f17cb0c78ec0e4f4d33ab1d538303fe7970cff3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2020 19:41:53 +0000 Subject: [PATCH 08/21] chore(deps): bump uvicorn from 0.11.3 to 0.11.7 in /benchmark/fastapi Bumps [uvicorn](https://github.com/encode/uvicorn) from 0.11.3 to 0.11.7. - [Release notes](https://github.com/encode/uvicorn/releases) - [Changelog](https://github.com/encode/uvicorn/blob/master/CHANGELOG.md) - [Commits](https://github.com/encode/uvicorn/compare/0.11.3...0.11.7) Signed-off-by: dependabot[bot] --- benchmark/fastapi/Pipfile.lock | 45 ++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/benchmark/fastapi/Pipfile.lock b/benchmark/fastapi/Pipfile.lock index fe6e6f0e..3ca201b9 100644 --- a/benchmark/fastapi/Pipfile.lock +++ b/benchmark/fastapi/Pipfile.lock @@ -18,10 +18,10 @@ "default": { "click": { "hashes": [ - "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", - "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" ], - "version": "==7.1.1" + "version": "==7.1.2" }, "fastapi": { "hashes": [ @@ -58,22 +58,25 @@ }, "pydantic": { "hashes": [ - "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752", - "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04", - "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3", - "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f", - "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21", - "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed", - "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f", - "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d", - "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab", - "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df", - "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11", - "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf", - "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f", - "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac" + "sha256:1783c1d927f9e1366e0e0609ae324039b2479a1a282a98ed6a6836c9ed02002c", + "sha256:2dc946b07cf24bee4737ced0ae77e2ea6bc97489ba5a035b603bd1b40ad81f7e", + "sha256:2de562a456c4ecdc80cf1a8c3e70c666625f7d02d89a6174ecf63754c734592e", + "sha256:36dbf6f1be212ab37b5fda07667461a9219c956181aa5570a00edfb0acdfe4a1", + "sha256:3fa799f3cfff3e5f536cbd389368fc96a44bb30308f258c94ee76b73bd60531d", + "sha256:40d765fa2d31d5be8e29c1794657ad46f5ee583a565c83cea56630d3ae5878b9", + "sha256:418b84654b60e44c0cdd5384294b0e4bc1ebf42d6e873819424f3b78b8690614", + "sha256:4900b8820b687c9a3ed753684337979574df20e6ebe4227381d04b3c3c628f99", + "sha256:530d7222a2786a97bc59ee0e0ebbe23728f82974b1f1ad9a11cd966143410633", + "sha256:54122a8ed6b75fe1dd80797f8251ad2063ea348a03b77218d73ea9fe19bd4e73", + "sha256:6c3f162ba175678218629f446a947e3356415b6b09122dcb364e58c442c645a7", + "sha256:b49c86aecde15cde33835d5d6360e55f5e0067bb7143a8303bf03b872935c75b", + "sha256:b5b3489cb303d0f41ad4a7390cf606a5f2c7a94dcba20c051cd1c653694cb14d", + "sha256:cf3933c98cb5e808b62fae509f74f209730b180b1e3c3954ee3f7949e083a7df", + "sha256:eb75dc1809875d5738df14b6566ccf9fd9c0bcde4f36b72870f318f16b9f5c20", + "sha256:f769141ab0abfadf3305d4fcf36660e5cf568a666dd3efab7c3d4782f70946b1", + "sha256:f8af9b840a9074e08c0e6dc93101de84ba95df89b267bf7151d74c553d66833b" ], - "version": "==1.4" + "version": "==1.6.1" }, "starlette": { "hashes": [ @@ -84,11 +87,11 @@ }, "uvicorn": { "hashes": [ - "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd", - "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c" + "sha256:1d46a22cc55a52f5567e0c66f000ae56f26263e44cef59b7c885bf10f487ce6e", + "sha256:b50f7f4c0c499c9b8d0280924cfbd24b90ba02456e3dc80934b9a786a291f09f" ], "index": "pypi", - "version": "==0.11.3" + "version": "==0.11.7" }, "uvloop": { "hashes": [ From 73f91240914cd5d8d5701b340fd617cf9e2cfa1f Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Wed, 29 Jul 2020 14:12:16 -0700 Subject: [PATCH 09/21] fix(ktracer/bp-171-nilvalue): add meaningful error message if param has nil-type --- validate.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/validate.go b/validate.go index 685d6ab8..6bb1a7e8 100644 --- a/validate.go +++ b/validate.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/istreamlabs/huma/schema" "github.com/gosimple/slug" + "github.com/istreamlabs/huma/schema" ) // ErrAPIInvalid is returned when validating the OpenAPI top-level fields @@ -146,7 +146,11 @@ func (o *openAPIOperation) validate(method, path string) { if !(handler.NumIn() == totalIn || (method != http.MethodGet && handler.NumIn() == totalIn+1)) || handler.NumOut() != totalOut { expected := "func(" for _, dep := range o.dependencies { - expected += "? " + reflect.ValueOf(dep.handler).Type().String() + ", " + val := reflect.ValueOf(dep.handler) + if !val.IsValid() { + panic(fmt.Errorf("dependency %s is not a valid type: %w", dep.handler, ErrParamInvalid)) + } + expected += "? " + val.Type().String() + ", " } for _, param := range o.params { expected += param.Name + " ?, " From 0c4c65d492d7a6bd08acbde070793b5f77384974 Mon Sep 17 00:00:00 2001 From: Kristina Tracer Date: Wed, 29 Jul 2020 17:15:40 -0700 Subject: [PATCH 10/21] chore(ktracer/bp-171-nilvalue): add test --- validate_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/validate_test.go b/validate_test.go index a33602c6..729957b7 100644 --- a/validate_test.go +++ b/validate_test.go @@ -59,6 +59,20 @@ func TestOperationHandlerInput(t *testing.T) { }) } +func TestOperationBadHandler(t *testing.T) { + r := NewTestRouter(t) + + assert.Panics(t, func() { + r.Resource("/", + SimpleDependency(nil), + ResponseText(200, "Test"), + ).Get("Test", func(pa *string, b int) string { + // Wrong number of inputs! + return "boom" + }) + }) +} + func TestOperationHandlerOutput(t *testing.T) { r := NewTestRouter(t) From 9c9f11e1467b1f9530053c89003b45fb600747e5 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Wed, 30 Sep 2020 11:29:46 -0700 Subject: [PATCH 11/21] chore: code drop merge from upstream --- README.md | 1016 ++++++++--------- benchmark/fiber/main.go | 48 + benchmark/go.mod | 10 + benchmark/go.sum | 447 ++++++++ benchmark/huma/main.go | 49 +- cli.go | 147 --- cli/cli.go | 168 +++ cli/cli_test.go | 34 + cli_test.go | 24 - context.go | 239 ++++ context_test.go | 244 ++++ dependency.go | 249 ---- dependency_test.go | 129 --- docs.go | 82 +- docs_test.go | 22 +- models.go => error.go | 33 +- error_test.go | 20 + examples/echo/echo.go | 77 ++ examples/echo/main.go | 38 - examples/hello/main.go | 18 - examples/minimal/minimal.go | 20 + examples/notes/notes.go | 103 ++ examples/readme/main.go | 97 -- examples/store/main.go | 29 - examples/test/main.go | 21 - examples/test/service.go | 27 + .../test/{main_test.go => service_test.go} | 0 examples/timeout/{main.go => timeout.go} | 24 +- examples/unsafe/main.go | 39 - go.mod | 35 +- go.sum | 312 +++-- huma_readme_test.go | 95 -- humatest/humatest.go | 21 +- humatest/humatest_test.go | 27 +- memstore/memstore.go | 449 -------- memstore/memstore_test.go | 196 ---- middleware.go | 520 --------- middleware/encoding.go | 173 +++ middleware/encoding_test.go | 144 +++ middleware/logger.go | 152 +++ middleware/logger_test.go | 14 + middleware/middleware.go | 54 + middleware/middleware_test.go | 21 + middleware/minimal.go | 45 + middleware/minimal_test.go | 51 + middleware/opentracing.go | 49 + middleware/recovery.go | 133 +++ middleware/recovery_test.go | 84 ++ middleware_test.go | 284 ----- negotiation/negotiation.go | 50 + negotiation/negotiation_test.go | 19 + openapi.go | 454 +------- openapi_test.go | 182 --- operation.go | 249 ++++ options.go | 587 ---------- resolver.go | 391 +++++++ resolver_test.go | 37 + resource.go | 218 ++-- resource_test.go | 235 ---- response.go | 92 ++ responses/responses.go | 135 +++ responses/responses_test.go | 92 ++ router.go | 809 +++---------- router_test.go | 746 +++--------- schema/schema.go | 424 +++---- schema/schema_test.go | 5 + validate.go | 271 ----- validate_test.go | 175 --- 68 files changed, 4915 insertions(+), 6569 deletions(-) create mode 100644 benchmark/fiber/main.go create mode 100644 benchmark/go.mod create mode 100644 benchmark/go.sum delete mode 100644 cli.go create mode 100644 cli/cli.go create mode 100644 cli/cli_test.go delete mode 100644 cli_test.go create mode 100644 context.go create mode 100644 context_test.go delete mode 100644 dependency.go delete mode 100644 dependency_test.go rename models.go => error.go (59%) create mode 100644 error_test.go create mode 100644 examples/echo/echo.go delete mode 100644 examples/echo/main.go delete mode 100644 examples/hello/main.go create mode 100644 examples/minimal/minimal.go create mode 100644 examples/notes/notes.go delete mode 100644 examples/readme/main.go delete mode 100644 examples/store/main.go delete mode 100644 examples/test/main.go create mode 100644 examples/test/service.go rename examples/test/{main_test.go => service_test.go} (100%) rename examples/timeout/{main.go => timeout.go} (50%) delete mode 100644 examples/unsafe/main.go delete mode 100644 huma_readme_test.go delete mode 100644 memstore/memstore.go delete mode 100644 memstore/memstore_test.go delete mode 100644 middleware.go create mode 100644 middleware/encoding.go create mode 100644 middleware/encoding_test.go create mode 100644 middleware/logger.go create mode 100644 middleware/logger_test.go create mode 100644 middleware/middleware.go create mode 100644 middleware/middleware_test.go create mode 100644 middleware/minimal.go create mode 100644 middleware/minimal_test.go create mode 100644 middleware/opentracing.go create mode 100644 middleware/recovery.go create mode 100644 middleware/recovery_test.go delete mode 100644 middleware_test.go create mode 100644 negotiation/negotiation.go create mode 100644 negotiation/negotiation_test.go delete mode 100644 openapi_test.go create mode 100644 operation.go delete mode 100644 options.go create mode 100644 resolver.go create mode 100644 resolver_test.go delete mode 100644 resource_test.go create mode 100644 response.go create mode 100644 responses/responses.go create mode 100644 responses/responses_test.go delete mode 100644 validate.go delete mode 100644 validate_test.go diff --git a/README.md b/README.md index 637ed693..c1e01828 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,7 @@ A modern, simple, fast & opinionated REST API framework for Go with batteries in Features include: - HTTP, HTTPS (TLS), and [HTTP/2](https://http2.github.io/) built-in - - Let's Encrypt auto-updating certificates via `--autotls` -- Declarative interface on top of [Gin](https://github.com/gin-gonic/gin) +- Declarative interface on top of [Chi](https://github.com/go-chi/chi) - Operation & model documentation - Request params (path, query, or header) - Request body @@ -25,17 +24,16 @@ Features include: - Default (optional) middleware - [RFC8631](https://tools.ietf.org/html/rfc8631) service description & docs links - Automatic recovery from panics with traceback & request logging - - Automatically handle CORS headers - Structured logging middleware using [Zap](https://github.com/uber-go/zap) - Automatic handling of `Prefer: return=minimal` from [RFC 7240](https://tools.ietf.org/html/rfc7240#section-4.2) + - [OpenTracing](https://opentracing.io/) for requests and errors - Per-operation request size limits & timeouts with sane defaults - [Content negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation) between server and client - - Support for GZip ([RFC 1952](https://tools.ietf.org/html/rfc1952)) & Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding via the `Accept-Encoding` header. + - Support for gzip ([RFC 1952](https://tools.ietf.org/html/rfc1952)) & Brotli ([RFC 7932](https://tools.ietf.org/html/rfc7932)) content encoding via the `Accept-Encoding` header. - Support for JSON ([RFC 8259](https://tools.ietf.org/html/rfc8259)), YAML, and CBOR ([RFC 7049](https://tools.ietf.org/html/rfc7049)) content types via the `Accept` header. - Annotated Go types for input and output models - Generates JSON Schema from Go types - Automatic input model validation & error handling -- Dependency injection for loggers, datastores, etc - Documentation generation using [RapiDoc](https://mrin9.github.io/RapiDoc/), [ReDoc](https://github.com/Redocly/redoc), or [SwaggerUI](https://swagger.io/tools/swagger-ui/) - CLI built-in, configured via arguments or environment variables - Set via e.g. `-p 8000`, `--port=8000`, or `SERVICE_PORT=8000` @@ -43,269 +41,117 @@ Features include: - Generates OpenAPI JSON for access to a rich ecosystem of tools - Mocks with [API Sprout](https://github.com/danielgtaylor/apisprout) - SDKs with [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator) - - CLIs with [OpenAPI CLI Generator](https://github.com/danielgtaylor/openapi-cli-generator) + - CLI with [Restish](https://rest.sh/) - And [plenty](https://openapi.tools/) [more](https://apis.guru/awesome-openapi3/category.html) -This project was inspired by [FastAPI](https://fastapi.tiangolo.com/), [Gin](https://github.com/gin-gonic/gin), and countless others. Look at the [benchmarks](https://github.com/istreamlabs/huma/tree/main/benchmark) to see how Huma compares. +This project was inspired by [FastAPI](https://fastapi.tiangolo.com/). Look at the [benchmarks](https://github.com/istreamlabs/huma/tree/main/benchmark) to see how Huma compares. Logo & branding designed by [Kari Taylor](https://www.kari.photography/). -# Concepts & Example +# Example -REST APIs are composed of operations against resources and can include descriptions of various inputs and possible outputs. For each operation you will typically provide info like: - -- HTTP method & path (e.g. `GET /items/{id}`) -- User-friendly description text -- Input path, query, or header parameters -- Input request body model, if appropriate -- Response header names and descriptions -- Response status code, content type, and output model - -Huma uses standard Go types and a declarative API to capture those descriptions in order to provide a combination of a simple interface and idiomatic code leveraging Go's speed and strong typing. - -Let's start by taking a quick look at a note-taking REST API. You can list notes, get a note's contents, create or update notes, and delete notes from an in-memory store. Each of the operations is registered with the router and descibes its inputs and outputs. You can view the full working example below: +Here is a complete basic hello world example in Huma, that shows how to initialize a Huma app complete with CLI & default middleware, declare a resource with an operation, and define its handler function. ```go package main import ( "net/http" - "sync" - "time" "github.com/istreamlabs/huma" - "github.com/istreamlabs/huma/schema" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/responses" ) -// NoteSummary is used to list notes. It does not include the (potentially) -// large note content. -type NoteSummary struct { - ID string `json:"id" doc:"Note ID"` - Created time.Time `json:"created" doc:"Created date/time as ISO8601"` -} - -// Note records some content text for later reference. -type Note struct { - Created time.Time `json:"created" readOnly:"true" doc:"Created date/time as ISO8601"` - Content string `json:"content" doc:"Note content"` -} - -// We'll use an in-memory DB (a goroutine-safe map). Don't do this in -// production code! -var memoryDB = sync.Map{} - func main() { - // Create a new router and give our API a title and version. - r := huma.NewRouter("Notes API", "1.0.0", - huma.DevServer("http://localhost:8888"), - ) - - notes := r.Resource("/v1/notes") - notes.List("Returns a list of all notes", func() []*NoteSummary { - // Create a list of summaries from all the notes. - summaries := make([]*NoteSummary, 0) - - memoryDB.Range(func(k, v interface{}) bool { - summaries = append(summaries, &NoteSummary{ - ID: k.(string), - Created: v.(*Note).Created, - }) - return true - }) - - return summaries + // Create a new router & CLI with default middleware. + app := cli.NewRouter("Minimal Example", "1.0.0") + + // Declare the root resource and a GET operation on it. + app.Resource("/").Get("get-root", "Get a short text message", + // The only response is HTTP 200 with text/plain + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + // This is he handler function for the operation. Write the response. + ctx.Header().Set("Content-Type", "text/plain") + ctx.Write([]byte("Hello, world")) }) - // Set up a custom schema to limit identifier values. - idSchema := schema.Schema{Pattern: "^[a-zA-Z0-9._-]{1,32}$"} - - // Add an `id` path parameter to create a note resource. - note := notes.With(huma.PathParam("id", "Note ID", huma.Schema(idSchema))) - - notFound := huma.ResponseError(http.StatusNotFound, "Note not found") - - note.Put("Create or update a note", func(id string, n *Note) bool { - // Set the created time to now and then save the note in the DB. - n.Created = time.Now() - memoryDB.Store(id, n) - - // Empty responses don't have a body, so you can just return `true`. - return true - }) - - note.With(notFound).Get("Get a note by its ID", - func(id string) (*huma.ErrorModel, *Note) { - if n, ok := memoryDB.Load(id); ok { - // Note with that ID exists! - return nil, n.(*Note) - } - - return &huma.ErrorModel{ - Message: "Note " + id + " not found", - }, nil - }, - ) - - note.With(notFound).Delete("Delete a note by its ID", - func(id string) (*huma.ErrorModel, bool) { - if _, ok := memoryDB.Load(id); ok { - // Note with that ID exists! - memoryDB.Delete(id) - return nil, true - } - - return &huma.ErrorModel{ - Message: "Note " + id + " not found", - }, false - }, - ) - - // Run the app! - r.Run() + // Run the CLI. When passed no arguments, it starts the server. + app.Run() } ``` -Save this file as `notes/main.go`. Run it and then try to access the API with [HTTPie-Go](https://github.com/nojima/httpie-go) (or curl): +You can test it with `go run hello.go` and make a sample request using [Restish](https://rest.sh/) (or `curl`). By default, Huma runs on port `8888`: ```sh -# Grab reflex to enable reloading the server on code changes: -$ go get github.com/cespare/reflex - -# Grab HTTPie-go for making requests -$ go get -u github.com/nojima/httpie-go/cmd/ht - -# Run the server (default host/port is 0.0.0.0:8888, see --help for options) -$ reflex -s go run notes/main.go - -# Make some requests (in another tab) -$ ht put :8888/v1/notes/test1 content="Some content for note 1" -HTTP/1.1 204 No Content -Date: Sat, 07 Mar 2020 22:22:06 GMT - -$ ht put :8888/v1/notes/test2 content="Some content for note 2" -HTTP/1.1 204 No Content -Date: Sat, 07 Mar 2020 22:22:06 GMT - -# Parameter validation works too! -$ ht put :8888/v1/notes/@bad content="Some content for an invalid note" -HTTP/1.1 400 Bad Request -Content-Length: 97 -Content-Type: application/json; charset=utf-8 -Date: Sat, 07 Mar 2020 22:22:06 GMT - -{ - "errors": [ - "(root): Does not match pattern '^[a-zA-Z0-9._-]{1,32}$'" - ], - "message": "Invalid input" -} - -# List all the notes -$ ht :8888/v1/notes -HTTP/1.1 200 OK -Content-Length: 122 -Content-Type: application/json; charset=utf-8 -Date: Sat, 07 Mar 2020 22:22:06 GMT - -[ - { - "created": "2020-03-07T22:22:06-07:00", - "id": "test1" - }, - { - "created": "2020-03-07T22:22:06-07:00", - "id": "test2" - } -] +# Get the message from the server +$ restish :8888 +Hello, world ``` -The server works and responds as expected. There are also some neat extras built-in. If you go to http://localhost:8888/docs in a browser, you will see auto-generated interactive documentation: - -Screen Shot 2020-03-31 at 11 22 55 PM - -The documentation is generated from the OpenAPI 3 spec file that is available at http://localhost:8888/openapi.json. You can also access this spec without running the server: +Even though the example is tiny you can also see some generated documentation at http://localhost:8888/docs. -```sh -# Save the OpenAPI 3 spec to a file. -$ go run notes/main.go openapi notes.json -``` - -Combine the above with [openapi-cli-generator](https://github.com/danielgtaylor/openapi-cli-generator) and [huma-build](https://github.com/danielgtaylor/huma-build) and you get the following out of the box: +See the examples directory for more complete examples. -- Small, efficient deployment Docker image with your service -- Auto-generated service documentation -- Auto-generated SDKs for any language -- Auto-generated cross-platform zero-dependency CLI +- [Minimal](./examples/minimal/minimal.go) (a minimal "hello world") +- [Echo](./examples/echo/echo.go) (echo input back to the user with validation) +- [Notes](./examples/notes/notes.go) (note-taking API) +- [Timeout](./examples/timeout/timeout.go) (show third-party request timing out) +- [Test](./examples/test/service.go) (how to write a test) # Documentation -Official Go package documentation can always be found at https://pkg.go.dev/github.com/danielgtaylor/huma. Below is an introduction to the various features available in Huma. +Official Go package documentation can always be found at https://pkg.go.dev/github.com/istreamlabs/huma. Below is an introduction to the various features available in Huma. > :whale: Hi there! I'm the happy Huma whale here to provide help. You'll see me leave helpful tips down below. -## Constructors & Options +## The Router -Huma uses the [functional options](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) paradigm when creating a router, resource, operation, parameter, etc. Functional options were chosen due to an exponential explosion of constructor functions and the complexity of the problem space. They come with several advantages: - -- Friendly APIs with sane defaults -- Extensible without breaking clients or polluting the global namespace with too many constructors -- Options are immutable, reusable, and composable - -They are easy to use and look like this: +The Huma router is the entrypoint to your service or application. There are a couple of ways to create it, depending on what level of customization you need. ```go -// Add a parameter with an example -huma.PathParam("id", "Resource identifier", huma.Example("abc123")) -``` +// Simplest way to get started, which creats a router and a CLI with default +// middleware attached. Note that the CLI is a router. +app := cli.NewRouter("API Name", "1.0.0") -Most text editors will auto-complete and show only the available options, which is an improvement over e.g. accepting `interface{}`. +// Doing the same as above by hand: +router := huma.New("API Name", "1.0.0") +app := cli.New(router) +middleware.Defaults(app) -### Extending & Composition - -Functional options can be wrapped to extend the set of available options. For example: - -```go -// IDParam creates a new path parameter limited to characters and a length that -// is allowed for resource identifiers. -func IDParam(name, description string) huma.DependencyOption { - s := schema.Schema{Pattern: "^[a-zA-Z0-9_-]{3,20}"} - - return huma.PathParam(name, description, huma.Schema(s)) -} +// Start the CLI after adding routes: +app.Run() ``` -You can also compose multiple options into one, e.g by using `huma.ResourceOptions(..)` or one of the other related functions: +You can also skip using the built-in `cli` package: ```go -// CommonOptions sets up common options for every operation. -func CommonOptions() huma.ResourceOption { - return huma.ResourceOptions( - huma.Tags("some-tag"), - huma.HeaderParam("customer", "Customer name", "", huma.Internal()), - huma.ResponseError(http.StatusInternalServerError, "Server error"), - ) -} +// Create and start a new router by hand: +router := huma.New("API Name", "1.0.0") +router.Middleware(middleware.DefaultChain) +router.Listen("127.0.0.1:8888") ``` ## Resources -Huma APIs are composed of resources and sub-resources attached to a router. A resource refers to a unique URI on which operations can be performed. Huma resources can have dependencies, security requirements, parameters, response headers, and responses attached to them which are all applied to every operation and sub-resource. +Huma APIs are composed of resources and sub-resources attached to a router. A resource refers to a unique URI on which operations can be performed. Huma resources can have middleware attached to them, which run before operation handlers. ```go -r := huma.NewRouter("My API", "1.0.0") +// Create a resource at a given path. +notes := app.Resource("/notes") -// Create a resource at a given path -notes := r.Resource("/notes") +// Add a middleware to all operations under `/notes`. +notes.Middleware(MyMiddleware()) // Create another resource that includes a path parameter: /notes/{id} -note := notes.With(huma.PathParam("id", "Note ID")) +// Paths look like URI templates and use wrap parameters in curly braces. +note := notes.SubResource("/{id}") -// Create a sub-resource at /notes/{id}/likes +// Create a sub-resource at /notes/{id}/likes. sub := note.SubResource("/likes") ``` -The `With(...)` function is very powerful and can accept dependencies, security requirements, parameters, response headers, and response description options. It returns a copy of the resource with those values applied. - > :whale: Resources should be nouns, and plural if they return more than one item. Good examples: `/notes`, `/likes`, `/users`, `/videos`, etc. ## Operations @@ -313,7 +159,6 @@ The `With(...)` function is very powerful and can accept dependencies, security Operations perform an action on a resource using an HTTP method verb. The following verbs are available: - Head -- List (alias for Get) - Get - Post - Put @@ -321,168 +166,289 @@ Operations perform an action on a resource using an HTTP method verb. The follow - Delete - Options -Operations can take dependencies, parameters, & request bodies and produce response headers and responses. These are each discussed in more detail below. - -If you don't provide a response description, then one is generated for you based on the response type with the following rules: - -- Boolean: If true, returns `HTTP 204 No Content` -- String: If not empty, returns `HTTP 200 OK` with content type `text/plain` -- Slice, map, struct pointer: If not `nil`, marshal to JSON and return `HTTP 200 OK` with content type `application/json` +Operations can take inputs in the form of path, query, and header parameters and/or request bodies. They must declare what response status codes, content types, and structures they return. -If you need any customization beyond the above then you must provide a response description. +Every operation has a handler function and takes at least a `huma.Context`, described in further detail below: ```go -r := huma.NewRouter("My API", "1.0.0") - -// Create a resource -notes := r.Resource("/notes") - -// Create the operation with an auto-generated response. -notes.Get("Get a list of all notes", func () []*NoteSummary { - // Implementation goes here -}) - -// Manually provide the response. This is equivalent to the above, but allows -// you to add additional options like allowed response headers. -notes.With( - huma.ResponseJSON(http.StatusOK, "Success"), -).Get("Get a list of all notes", func () []*NoteSummary { - // Implementation goes here +app.Resource("/op").Get("get-op", "Example operation", + // Response declaration goes here! +).Run(func (ctx huma.Context) { + // Handler implementation goes here! }) ``` > :whale: Operations map an HTTP action verb to a resource. You might `POST` a new note or `GET` a user. Sometimes the mapping is less obvious and you can consider using a sub-resource. For example, rather than unliking a post, maybe you `DELETE` the `/posts/{id}/likes` resource. -## Handler Functions +## Context -The basic structure of a Huma handler function looks like this, with most arguments being optional and dependent on the declaritively described operation: +As seen above, every handler function gets at least a `huma.Context`, which combines an `http.ResponseWriter` for creating responses, a `context.Context` for cancellation/timeouts, and some convenience functions. Any library that can use either of these interfaces will work with a Huma context object. Some examples: ```go -func (deps..., params..., requestModel) (headers..., responseModels...) +// Calling third-party libraries that might take too long +results := mydb.Fetch(ctx, "some query") + +// Write an HTTP response +ctx.Header().Set("Content-Type", "text/plain") +ctx.WriteHeader(http.StatusNotFound) +ctx.Write([]byte("Could not find foo")) ``` -Dependencies, parameters, headers, and models are covered in more detail in the following sections. For now this gives an idea of how to write handler functions based on the inputs and outputs of your operation. +> :whale: Since you can write data to the response multiple times, the context also supports streaming responses. Just remember to set (or remove) the timeout. + +## Responses -For example, the most basic "Hello world" that takes no parameters and returns a greeting message might look like this: +In order to keep the documentation & service specification up to date with the code, you **must** declare the responses that your handler may return. This includes declaring the content type, any headers it might return, and what model it returns (if any). The `responses` package helps with declaring well-known responses with the right code/docs/model and corresponds to the statuses in the `http` package, e.g. `resposes.OK()` will create a response with the `http.StatusOK` status code. ```go -func () string { return "Hello, world" } -``` +// Response structures are just normal Go structs +type Thing struct { + Name string `json:"name"` +} -Another example: you have an `id` parameter input and return a response model to be marshalled as JSON: +// ... initialization code goes here ... + +things := app.Resource("/things") +things.Get("list-things", "Get a list of things", + // Declare a successful response that returns a slice of things + responses.OK().Headers("Foo").Model([]Thing{}), + // Errors automatically set the right status, content type, and model for you. + responses.InternalServerError(), +).Run(func(ctx huma.Context) { + // This works because the `Foo` header was declared above. + ctx.Header().Set("Foo", "Some value") + + // The `WriteModel` convenience method handles content negotiation and + // serializaing the response for you. + ctx.WriteModel(http.StatusOK, []Thing{ + Thing{Name: "Test1"}, + Thing{Name: "Test2"}, + }) -```go -func (id string) *MyModel { return &MyModel{ID: id} } + // Alternatively, you can write an error + ctx.WriteError(http.StatusInternalServerError, "Some message") +}) ``` -> :whale: Confused about what a handler should look like? Just run your service and it'll print out an approximate handler function when it panics. +If you try to set a response status code or header that was not declared you will get a runtime error. If you try to call `WriteModel` or `WriteError` more than once then you will get an error because the writer is considered closed after those methods. -## Parameters +### Errors -Huma supports three types of parameters: +Errors use [RFC 7807](https://tools.ietf.org/html/rfc7807) and return a structure that looks like: -- Required path parameters, e.g. `/things/{thingId}` -- Optional query string parameters, e.g. `/things?q=filter` -- Optional header parameters, e.g. `X-MyHeader: my-value` +```json +{ + "status": 504, + "title": "Gateway Timeout", + "detail": "Problem with HTTP request", + "errors": [ + { + "message": "Get \"https://httpstat.us/418?sleep=5000\": context deadline exceeded" + } + ] +} +``` -Optional parameters require a default value. +The `errors` field is optional and may contain more details about which specific errors occurred. -Here is an example of an `id` parameter: +It is recommended to return exhaustive errors whenever possible to prevent user frustration with having to keep retrying a bad request and getting back a different error. The context has `AddError` and `HasError()` functions for this: ```go -r.Resource("/notes", - huma.PathParam("id", "Note ID"), - huma.ResponseError(404, "Note was not found"), - huma.ResponseJSON(200, "Success"), -). -Get("Get a note by its ID", func(id string) (*huma.ErrorModel, *Note) { - // Implementation goes here +app.Resource("/exhaustive").Get("exhaustive", "Exhastive errors example", + responses.OK(), + responses.BadRequest(), +).Run(func(ctx huma.Context) { + for i := 0; i < 5; i++ { + // Use AddError to add multiple error details to the response. + ctx.AddError(fmt.Errorf("Error %d", i)) + } + + // Check if the context has had any errors added yet. + if ctx.HasError() { + // Use WriteError to set the actual status code, top-level message, and + // any additional errors. This sends the response. + ctx.WriteError(http.StatusBadRequest, "Bad input") + return + } }) ``` -You can also declare parameters with additional validation logic by using the `schema` module: +## Request Inputs + +Requests can have parameters and/or a body as input to the handler function. Like responses, inputs use standard Go structs but the tags are different. Here are the available tags: + +| Tag | Description | Example | +| -------- | ---------------------------------- | ------------------------ | +| `path` | Name of the path parameter | `path:"thing-id"` | +| `query` | Name of the query string parameter | `query:"q"` | +| `header` | Name of the header parameter | `header:"Authorization"` | + +The following types are supported out of the box: + +| Type | Example Inputs | +| ------------------- | ---------------------- | +| `bool` | `true`, `false` | +| `[u]int[16/32/64]` | `1234`, `5`, `-1` | +| `float32/64` | `1.234`, `1.0` | +| `string` | `hello`, `t` | +| `time.Time` | `2020-01-01T12:00:00Z` | +| slice, e.g. `[]int` | `1,2,3`, `tag1,tag2` | + +For example, if the parameter is a query param and the type is `[]string` it might look like `?tags=tag1,tag2` in the URI. + +The special struct field `Body` will be treated as the input request body and can refer to another struct or you can embed a struct inline. + +Here is an example: ```go -s := schema.Schema{ - MinLength: 1, - MaxLength: 32, +type MyInputBody struct { + Name string `json:"name"` +} + +type MyInput struct { + ThingID string `path:"thing-id" doc:"Example path parameter"` + QueryParam int `query:"q" doc:"Example query string parameter"` + HeaderParam string `header:"Foo" doc:"Example header parameter"` + Body MyInputBody `doc:"Example request body"` } -huma.PathParam("id", "Note ID", huma.Schema(s)) +// ... Later you use the inputs + +// Declare a resource with a path parameter that matches the input struct. This +// is needed because path parameter positions matter in the URL. +thing := app.Resource("/things/{thing-id}") + +// Next, declare the handler with an input argument. +thing.Get("get-thing", "Get a single thing", + responses.NoContent(), +).Run(func(ctx huma.Context, input MyInput) { + fmt.Printf("Thing ID: %s\n", input.ThingID) + fmt.Printf("Query param: %s\n", input.QueryParam) + fmt.Printf("Header param: %s\n", input.HeaderParam) + fmt.Printf("Body name: %s\n", input.Body.Name) +}) ``` -Once a parameter is declared it will get parsed, validated, and then sent to your handler function. If parsing or validation fails, the client gets a 400-level HTTP error. +Try a request against the service like: -> :whale: If a proxy is providing e.g. authentication or rate-limiting and exposes additional internal-only information then use the internal parameters like `huma.HeaderParam("UserID", "Parsed user from the auth system", "nobody", huma.Internal())`. Internal parameters are never included in the generated OpenAPI 3 spec or documentation. +```sh +# Restish example +$ restish :8888/things/abc123?q=3 -H "Foo: bar" name: Kari +``` -## Request & Response Models +### Parameter & Body Validation -Request and response models are just plain Go structs with optional tags to annotate additional validation logic for their fields. From the notes API example above: +All supported JSON Schema tags work for parameters and body fields. Validation happens before the request handler is called, and if needed an error response is returned. For example: ```go -// Note records some content text for later reference. -type Note struct { - Created time.Time `readOnly:"true"` - Content string +type MyInput struct { + ThingID string `path:"thing-id" pattern:"^th-[0-9a-z]+$" doc:"..."` + QueryParam int `query:"q" minimum:"1" doc:"..."` } ``` -The `Note` struct has two fields which will get serialized to JSON. The `Created` field has a special tag `readOnly` set which means it will not get used for write operations like `PUT /notes/{id}`. +See "Validation" for more info. -This struct provides enough information to create JSON Schema for the OpenAPI 3 spec. You can provide as much or as little information and validation as you like. +### Input Composition -### Request Model - -Request models are used by adding a new input argument that is a pointer to a struct to your handler function as the last argument. For example: +Because inputs are just Go structs, they are composable and reusable. For example: ```go -r.Resource("/notes", huma.PathParam("id", "Note ID")). - Put("Create or update a note", - // Handler without an input body looks like: - func(id string) bool { - // Implementation goes here - }, +type AuthParam struct { + Authorization string `header:"Authorization"` +} + +type PaginationParams struct { + Cursor string `query:"cursor"` + Limit int `query:"limit"` +} - // Handler with an input body looks like: - func(id string, note *Note) bool { - // Implementation goes here - }, - ) +// ... Later in the code +app.Resource("/things").Get("list-things", "List things", + responses.NoContent(), +).Run(func (ctx huma.Context, input struct { + AuthParam + PaginationParams +}) { + fmt.Printf("Auth: %s, Cursor: %s, Limit: %d\n", input.Authorization, input.Cursor, input.Limit) +}) ``` -The presence of the `note *Note` argument tells Huma to parse the request body and validate it against the generated JSON Schema for the `Note` struct. +### Input Streaming + +It's possible to support input body streaming for large inputs by declaring your body as an `io.Reader`: -### Response Model +```go +type StreamingBody struct { + Body io.Reader +} +``` -Response models are used by adding a response to the list of possible responses along with a new function return value that is a pointer to your struct. You can specify multiple different response models. +You probably want to combine this with custom timeouts, or removing them altogether. ```go -r.Resource("/notes", - huma.ResponseError(http.NotFound, "Not found"), - huma.ResponseJSON(http.StatusOK, "Success")). -Get("Description", func() (*huma.ErrorModel, *Note) { - // Implementation goes here -}) +op := app.Resource("/streaming").Post("post-stream", "Write streamed data", + responses.NoContent(), +) +op.NoBodyReadTimeout() +op.Run(...) ``` -Whichever model is not `nil` will get sent back to the client. +### Resolvers + +Sometimes the built-in validation isn't sufficient for your use-case, or you want to do something more complex with the incoming request object. This is where resolvers come in. -Empty responses, e.g. a `204 No Content` or `304 Not Modified` are also supported by setting a `ContentType` of `""` (the default zero value). Use `huma.Response` paired with a simple boolean to return a response without a body. Passing `false` acts like `nil` for models and prevents that response from being sent. +Any input struct can be a resolver by implementing the `huma.Resolver` interface, including embedded structs. Each resolver takes the current context and the incoming request. For example: ```go -r.Resource("/notes", - huma.Response(http.StatusNoContent, "This should have no body")). -Get("description", func() bool { - return true +// MyInput demonstrates inputs/transformation +type MyInput struct { + Host string + Name string `query:"name"` +} + +func (m *MyInput) Resolve(ctx huma.Context, r *http.Request) { + // Get request info you don't normally have access to. + m.Host = r.Host + + // Transformations or other data validation + m.Name = strings.Title(m.Name) +} + +// Then use it like any other input struct: +app.Resource("/things").Get("list-things", "Get a filtered list of things", + responses.NoContent(), +).Run(func(ctx huma.Context, input MyInput) { + fmt.Printf("Host: %s\n", input.Host) + fmt.Printf("Name: %s\n", input.Name) }) -}, ``` -> :whale: In some cases Huma can [auto-generate a resonse model](#operations) for you. +It is recommended that you do not save the request. Whenever possible, use existing mechanisms for describing your input so that it becomes part of the OpenAPI description. + +#### Resolver Errors -### Model Tags +Resolvers can set errors as needed and Huma will automatically return a 400-level error response before calling your handler. This makes resolvers a good place to run additional complex validation steps so you can provide the user with a set of exhaustive errors. -Go struct tags are used to annotate the model with information that gets turned into [JSON Schema](https://json-schema.org/) for documentation and validation. +```go +type MyInput struct { + Host string +} + +func (m *MyInput) Resolve(ctx huma.Context, r *http.Request) { + if m.Host = r.Hostname; m.Host == "localhost" { + ctx.AddError(&huma.ErrorDetail{ + Message: "Invalid value!", + Location: "request.host", + Value: m.Host, + }) + } +} +``` + +## Validation + +Go struct tags are used to annotate inputs/output structs with information that gets turned into [JSON Schema](https://json-schema.org/) for documentation and validation. The standard `json` tag is supported and can be used to rename a field and mark fields as optional using `omitempty`. The following additional tags are supported on model fields: @@ -511,155 +477,75 @@ The standard `json` tag is supported and can be used to rename a field and mark | `writeOnly` | Sent in the request only | `writeOnly:"true"` | | `deprecated` | This field is deprecated | `deprecated:"true"` | -### Response Headers - -Response headers must be defined before they can be sent back to a client. This includes several steps: +Parameters have some additional validation tags: -1. Describe the response header (name & description) -2. Specify which responses may send this header -3. Add the header to the handler function return values +| Tag | Description | Example | +| ---------- | ------------------------------ | ----------------- | +| `internal` | Internal-only (not documented) | `internal:"true"` | -For example: - -```go -r.Resource("/notes", - huma.ResponseHeader("expires", "Expiration date for this content"), - huma.ResponseText(http.StatusOK, "Success", huma.Headers("expires")) -).Get("description", func() (string, string) { - expires := time.Now().Add(7 * 24 * time.Hour).MarshalText() - return expires, "Hello!" -}) -``` - -You can make use of named return values with a naked return to disambiguate complex functions: - -```go -func() (expires string, message string) { - expires = time.Now().Add(7 * 24 * time.Hour).MarshalText() - message = "Hello!" - return -}, -``` - -> :whale: If you forget to declare a response header for a particular response and then try to set it when returning that response it will **not** be sent to the client and an error will be logged. - -## Dependencies - -Huma includes a dependency injection system that can be used to pass additional arguments to operation handler functions. You can register global dependencies (ones that do not change from request to request) or contextual dependencies (ones that change with each request). - -Global dependencies are created by just setting some value, while contextual dependencies are implemented using a function that returns the value of the form `func (deps..., params...) (headers..., *YourType, error)` where the value you want injected is of `*YourType` and the function arguments can be any previously registered dependency types or one of the hard-coded types: +## Middleware -- `huma.ConnDependency()` the current `http.Request` connection (returns `net.Conn`) -- `huma.ContextDependency()` the current `http.Request` context (returns `context.Context`) -- `huma.GinContextDependency()` the current Gin request context (returns `*gin.Context`) -- `huma.OperationIDDependency()` the current operation ID (returns `string`) +Standard [Go HTTP middleware](https://justinas.org/writing-http-middleware-in-go) is supported. It can be attached to the main router/app or to individual resources, but **must** be added _before_ operation handlers are added. ```go -// Register a new database connection dependency -db := huma.SimpleDependency(db.NewConnection()) +// Middleware from some library +app.Middleware(somelibrary.New()) -// Register a new request logger dependency. This is contextual because we -// will print out the requester's IP address with each log message. -type MyLogger struct { - Info: func(msg string), -} +// Custom middleware +app.Middleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func (w http.ResponseWriter, r *http.Request) { + // Request phase, do whatever you want before next middleware or handler + // gets called. + fmt.Println("Request coming in") -logger := huma.Dependency( - huma.GinContextDependency(), - func(c *gin.Context) (*MyLogger, error) { - return &MyLogger{ - Info: func(msg string) { - fmt.Printf("%s [ip:%s]\n", msg, c.Request.RemoteAddr) - }, - }, nil - }, -) + // Call the next middleware/handler + next.ServeHTTP(w, r) -// Use them in any handler by adding them to both `Depends` and the list of -// handler function arguments. -r.Resource("/foo").With( - db, logger -).Get("doc", func(db *db.Connection, log *MyLogger) string { - log.Info("test") - item := db.Fetch("query") - return item.ID -}) -``` - -When creating a new dependency you can use `huma.DependencyOptions` to group multiple options: - -```go -logger := huma.Dependency(huma.DependencyOptions( - huma.GinContextDependency(), - huma.OperationIDDependency(), -), func (c *gin.Context, operationID string) (*MyLogger, error) { - return ... + // Response phase, after handler has run. + fmt.Println("Response going out!") + }) }) ``` -> :whale: Note that global dependencies cannot be functions. You can wrap them in a struct as a workaround if needed. - -## Custom Gin - -You can create a Huma router instance with a custom Gin instance. This lets you set up custom middleware, CORS configurations, logging, etc. - -```go -// The following two are equivalent: -// Default settings: -r := huma.NewRouter("My API", "1.0.0") - -// And manual settings: -g := gin.New() -g.Use(huma.Recovery()) -g.Use(huma.LogMiddleware()) -g.Use(cors.Default()) -g.Use(huma.PreferMinimalMiddleware()) -g.Use(huma.ServiceLinkMiddleware()) -g.NoRoute(huma.Handler404()) -r := huma.NewRouter("My API", "1.0.0", huma.WithGin(g)) -``` +When using the `cli.NewRouter` convenience method, a set of default middleware is added for you. See `middleware.DefaultChain` for more info. -## Custom CORS Handler +### Enabling OpenTracing -If you would like CORS preflight requests to allow specific headers, do the following: +[OpenTracing](https://opentracing.io/) support is built-in, but you have to tell the global tracer where to send the information, otherwise it acts as a no-op. For example, if you use [DataDog APM](https://www.datadoghq.com/blog/opentracing-datadog-cncf/) and have the agent configured wherever you deploy your service: ```go -// CORS: Allow non-standard headers "Authorization" and "X-My-Header" in preflight requests -cfg := cors.DefaultConfig() -cfg.AllowAllOrigins = true -cfg.AllowHeaders = append(cfg.AllowHeaders, "Authorization", "X-My-Header") - -// And manual settings: -r := huma.NewRouter("My API", "1.0.0", huma.CORSHandler(cors.New(cfg))) -``` +import ( + "github.com/opentracing/opentracing-go" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentracer" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) -## Custom HTTP Server +func main() { + t := opentracer.New(tracer.WithAgentAddr("host:port")) + defer tracer.Stop() -You can have full control over the `http.Server` that is created. + // Set it as a Global Tracer + opentracing.SetGlobalTracer(t) -```go -// Set low timeouts to kick off slow clients. -s := &http.Server{ - ReadTimeout: 5 * time.Seconds, - WriteTimeout: 5 * time.Seconds, - Handler: r + app := cli.NewRouter("My Cool Service", "1.0.0") + // register routes here + app.Run() } - -r := huma.NewRouter("My API", "1.0.0", huma.HTTPServer(s)) - -r.Run() ``` -### Timeouts, Deadlines, & Cancellation +### Timeouts, Deadlines, Cancellation & Limits + +Huma provides utilities to prevent long-running handlers and issues with huge request bodies and slow clients with sane defaults out of the box. -By default, only a `ReadHeaderTimeout` of _10 seconds_ and an `IdleTimeout` of _15 seconds_ are set at the server level. This allows large request and response bodies to be sent without fear of timing out in the default config, as well as the use of WebSockets. +#### Context Timeouts Set timeouts and deadlines on the request context and pass that along to libraries to prevent long-running handlers. For example: ```go -r.Resource("/timeout", - huma.ContextDependency(), -).Get("timeout example", func(ctx context.Context) string { +app.Resource("/timeout").Get("timeout", "Timeout example", + responses.String(http.StatusOK), + responses.GatewayTimeout(), +).Run(func(ctx huma.Context) { // Add a timeout to the context. No request should take longer than 2 seconds newCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -672,119 +558,144 @@ r.Resource("/timeout", // deadline of 2 seconds is shorter than the request duration of 5 seconds. _, err := http.DefaultClient.Do(req) if err != nil { - return err.Error() + ctx.WriteError(http.StatusGatewayTimeout, "Problem with HTTP request", err) + return } - return "success" + ctx.Write([]byte("success!")) }) ``` -### Request Body Timeouts +#### Request Timeouts -By default any handler which takes in a request body parameter will have a read timeout of 15 seconds set on it. If set to nonzero for a handler which does **not** take a body, then the timeout will be set on the underlying connection before calling your handler. +By default, a `ReadHeaderTimeout` of _10 seconds_ and an `IdleTimeout` of _15 seconds_ are set at the server level and apply to every incoming request. -When triggered, the server sends a 408 Request Timeout as JSON with a message containing the time waited. +Each operation's individual read timeout defaults to _15 seconds_ and can be changed as needed. This enables large request and response bodies to be sent without fear of timing out, as well as the use of WebSockets, in an opt-in fashion with sane defaults. + +When using the built-in model processing and the timeout is triggered, the server sends an error as JSON with a message containing the time waited. ```go type Input struct { - ID string + ID string `json:"id"` } -r := huma.NewRouter("My API", "1.0.0") +app := cli.NewRouter("My API", "1.0.0") +foo := app.Resource("/foo") // Limit to 5 seconds -r.Resource("/foo", huma.BodyReadTimeout(5 * time.Second)).Post( - "Create item", func(input *Input) string { - return "Hello, " + input.ID - }) +create := foo.Post("create-item", "Create a new item", + responses.NoContent(), +) +create.BodyReadTimeout(5 * time.Second) +create.Run(func (ctx huma.Context, input Input) { + // Do something here. +}) ``` You can also access the underlying TCP connection and set deadlines manually: ```go -r.Resource("/foo", huma.ConnDependency()).Get(func (conn net.Conn) string { +create.Run(func (ctx huma.Context, input struct { + Body io.Reader +}) { + // Get the connection. + conn := huma.GetConn(ctx) + // Set a new deadline on connection reads. conn.SetReadDeadline(time.Now().Add(600 * time.Second)) // Read all the data from the request. - data, err := ioutil.ReadAll(c.Request.Body) + data, err := ioutil.ReadAll(input.Body) if err != nil { + // If a timeout occurred, this will be a net.Error with `err.Timeout()` + // returning true. panic(err) } - // Do something with the data... - return fmt.Sprintf("Read %d bytes", len(data)) + // Do something with data here... }) ``` -> :whale: Set to `-1` in order to disable the timeout. +> :whale: Use `NoBodyReadTimeout()` to disable the default. -### Request Body Size Limits +#### Request Body Size Limits By default each operation has a 1 MiB reqeuest body size limit. -When triggered, the server sends a 413 Request Entity Too Large as JSON with a message containing the maximum body size for this operation. +When using the built-in model processing and the timeout is triggered, the server sends an error as JSON with a message containing the maximum body size for this operation. ```go -r := huma.NewRouter("My API", "1.0.0") +app := cli.NewRouter("My API", "1.0.0") +create := app.Resource("/foo").Post("create-item", "Create a new item", + responses.NoContent(), +) // Limit set to 10 MiB -r.Resource("/foo", MaxBodyBytes(10 * 1024 * 1024)).Get(...) +create.MaxBodyBytes(10 * 1024 * 1024) +create.Run(func (ctx huma.Context, input Input) { + // Body is guaranteed to be 10MiB or less here. +}) ``` -> :whale: Set to `-1` in order to disable the check, allowing for unlimited request body size for e.g. large streaming file uploads. +> :whale: Use `NoMaxBodyBytes()` to disable the default. ## Logging -Huma provides a Zap-based contextual structured logger built-in. You can access it via the `huma.LogDependency()` which returns a `*zap.SugaredLogger`. It requires the use of the `huma.LogMiddleware(...)`, which is included by default. If you provide a custom Gin instance you should include the middleware. +Huma provides a Zap-based contextual structured logger as part of the default middleware stack. You can access it via the `middleware.GetLogger(ctx)` which returns a `*zap.SugaredLogger`. It requires the use of the `middleware.Logger`, which is included by default when using either `cli.NewRouter` or `middleware.Defaults`. ```go -r.Resource("/test", - huma.LogDependency(), -).Get("Logger test", func(log *zap.SugaredLogger) string { - log.Info("I'm using the logger!") - return "Hello, world" +app := cli.NewRouter("Logging Example", "1.0.0") + +app.Resource("/log").Get("log", "Log example", + responses.NoContent(), +).Run(func (ctx huma.Context) { + logger := middleware.GetLogger(ctx) + logger.Info("Hello, world!") }) ``` -## Customizing Logging - -Logging is completely customizable. +Manual setup: ```go -// Create your own logger, or use the Huma built-in: -l, err := huma.NewLogger() -if err != nil { - panic(err) -} +router := huma.New("Loggin Example", "1.0.0") +app := cli.New(router) + +app.Middleware(middleware.Logger) +middleware.AddLoggerOptions(app) + +// Rest is same as above... +``` + +You can also modify the base logger as needed. Set this up _before_ adding any routes. Note that the function returns a low-level `Logger`, not a `SugaredLogger`. -// Update the logger somehow with your custom logic. -l = l.With(zap.String("some", "value")) +```go +middleware.NewLogger = func() (*zap.Logger, error) { + l, err := middleware.NewDefaultLogger() + if err != nil { + return nil, err + } -// Set up the router with the default settings and your custom logger. -g := gin.New() -g.Use(gin.Recovery()) -g.Use(cors.Default()) -g.Use(huma.LogMiddleware(l)) + // Add your own global tags. + l = l.With(zap.String("env", "prod")) -r := huma.NewRouter("My API", "1.0.0", huma.WithGin(g)) + return l, nil +} ``` ## Lazy-loading at Server Startup -You can register functions to run before the server starts, allowing for things like lazy-loading dependencies. +You can register functions to run before the server starts, allowing for things like lazy-loading dependencies. It is safe to call this method multiple times. ```go var db *mongo.Client -r := huma.NewRouter("My API", "1.0.0", - huma.PreStart(func() { - // Connect to the datastore - var err error - db, err = mongo.Connect(context.Background(), - options.Client().ApplyURI("...")) - }) -) +app := cli.NewRouter("My API", "1.0.0") +app.PreStart(func() { + // Connect to the datastore + var err error + db, err = mongo.Connect(context.Background(), + options.Client().ApplyURI("...")) +}) ``` > :whale: This is especially useful for external dependencies and if any custom CLI commands are set up. For example, you may not want to require a database to run `my-service openapi my-api.json`. @@ -794,50 +705,45 @@ r := huma.NewRouter("My API", "1.0.0", You can choose between [RapiDoc](https://mrin9.github.io/RapiDoc/), [ReDoc](https://github.com/Redocly/redoc), or [SwaggerUI](https://swagger.io/tools/swagger-ui/) to auto-generate documentation. Simply set the documentation handler on the router: ```go -r := huma.NewRouter("My API", "1.0.0", huma.DocsHandler(huma.ReDocHandler("My API"))) +app := cli.NewRouter("My API", "1.0.0") +app.DocsHandler(huma.ReDocHandler("My API")) ``` > :whale: Pass a custom handler function to have even more control for branding or browser authentication. ## Custom OpenAPI Fields -You can set custom OpenAPI fields via the `Extra` field in the `OpenAPI` and `Operation` structs. - -```go -r := huma.NewRouter("My API", "1.0.0", huma.Extra(map[string]interface{}{ - "x-something": "some-value", -})) -``` - -Use the OpenAPI hook for additional customization. It gives you a `*gab.Container` instance that represents the root of the OpenAPI document. +Use the OpenAPI hook for OpenAPI customization. It gives you a `*gabs.Container` instance that represents the root of the OpenAPI document. ```go func modify(openapi *gabs.Container) { openapi.Set("value", "paths", "/test", "get", "x-foo") } -r := huma.NewRouter("My API", "1.0.0", huma.OpenAPIHook(modify)) +app := cli.NewRouter("My API", "1.0.0") +app.OpenAPIHook(modify) ``` -> :whale: See the [OpenAPI 3 spec](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md) for everything that can be set. +> :whale: See the [OpenAPI 3 spec](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md) for everything that can be set. ## Custom CLI Arguments -You can add additional CLI arguments, e.g. for additional logging tags. Use the `AddGlobalFlag` function along with the `viper` module to get the parsed value. +The `cli` package provides a convenience layer to create a simple CLI for your server, which lets a user set the host, port, TLS settings, etc when running your service. + +You can add additional CLI arguments, e.g. for additional logging tags. Use the `Flag` method along with the `viper` module to get the parsed value. ```go -r := huma.NewRouter("My API", "1.0.0", - // Add a long arg (--env), short (-e), description & default - huma.GlobalFlag("env", "e", "Environment", "local") -) +app := cli.NewRouter("My API", "1.0.0") -r.Resource("/current_env").Text(http.StatusOK, "Success").Get( - "Return the current environment", - func() string { - // The flag is automatically bound to viper settings. - return viper.GetString("env") - }, -) +// Add a long arg (--env), short (-e), description & default +app.Flag("env", "e", "Environment", "local") + +r.Resource("/current_env").Get("get-env", "Get current env", + responses.String(http.StatusOK), +).Run(func(ctx huma.Context) { + // The flag is automatically bound to viper settings using the same name. + ctx.Write([]byte(viper.GetString("env"))) +}) ``` Then run the service: @@ -850,21 +756,9 @@ $ go run yourservice.go --env=prod ## Custom CLI Commands -You can access the root `cobra.Command` via `r.Root()` and add new custom commands via `r.Root().AddCommand(...)`. The `openapi` sub-command is one such example in the default setup. - -> :whale: You can also overwite `r.Root().Run` to completely customize how you run the server. - -## Middleware - -You can make use of any Gin-compatible middleware via the `GinMiddleware()` router option. - -```go -r := huma.NewRouter("My API", "1.0.0", huma.GinMiddleware(gin.Logger())) -``` - -## HTTP/2 Setup +You can access the root `cobra.Command` via `app.Root()` and add new custom commands via `app.Root().AddCommand(...)`. The `openapi` sub-command is one such example in the default setup. -TODO +> :whale: You can also overwite `app.Root().Run` to completely customize how you run the server. Or just ditch the `cli` package completely. ## Testing @@ -875,24 +769,30 @@ You can see an example in the [`examples/test`](https://github.com/istreamlabs/h ```go package main -import "github.com/istreamlabs/huma" +import ( + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/responses" +) func routes(r *huma.Router) { // Register a single test route that returns a text/plain response. - r.Resource("/test").Get("Test route", func() string { - return "Hello, test!" + r.Resource("/test").Get("test", "Test route", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Write([]byte("Hello, test!")) }) } func main() { // Create the router. - r := huma.NewRouter("Test", "1.0.0") + app := cli.NewRouter("Test", "1.0.0") // Register routes. - routes(r) + routes(app.Router) // Run the service. - r.Run() + app.Run() } ``` @@ -924,18 +824,62 @@ func TestHandler(t *testing.T) { } ``` -# How it Works +# Design + +General Huma design principles: + +- HTTP/2 and streaming out of the box +- Describe inputs/outputs and keep docs up to date +- Generate OpenAPI for automated tooling +- Re-use idiomatic Go concepts whenever possible +- Encourage good behavior, e.g. exhaustive errors + +## High-level design + +The high-level design centers around a `Router` object. + +- CLI (optional) + - Router + - []Middleware + - []Resource + - URI path + - []Middleware + - []Operations + - HTTP method + - Inputs / outputs + - Go structs with tags + - Handler function + +## Router Selection + +- Why not Gin? Lots of stars on GitHub, but... Overkill, non-standard handlers & middlware, weird debug mode. +- Why not fasthttp? Fiber? Not fully HTTP compliant, no HTTP/2, no streaming request/response support. +- Why not httprouter? Non-standard handlers, no middleware. +- HTTP/2 means HTTP pipelining benchmarks don't really matter. + +Ultimately using Chi because: + +- Fast router with support for parameterized paths & middleware +- Standard HTTP handlers +- Standard HTTP middleware -Huma's philosophy is to make it harder to make mistakes by providing tools that reduce duplication and encourage practices which make it hard to forget to update some code. +### Compatibility -An example of this is how handler functions **must** declare all headers that they return and which responses may send those headers. You simply **cannot** return from the function without considering the values of each of those headers. If you set one that isn't appropriate for the response you return, Huma will let you know. +Huma tries to be compatible with as many Go libraries as possible by using standard interfaces and idiomatic concepts. -How does it work? Huma asks that you give up one compile-time static type check for handler function signatures and instead let it be a runtime startup check. It's simple enough that even the most basic unit test will invoke the runtime check, giving you most of the security you would from static typing. +- Standard middleware `func(next http.Handler) http.Handler` +- Standard context `huma.Context` is a `context.Context` +- Standard HTTP writer `huma.Context` is an `http.ResponseWriter` that can check against declared response codes and models. +- Standard streaming support via the `io.Reader` and `io.Writer` interfaces -Using a small amount of reflection, Huma can verify the function signatures, inject dependencies and parameters, and handle responses and headers as well as making sure that they all match the declared operation. +## Compromises -By strictly enforcing this runtime interface you get several advantages. No more out of date API description. No more out of date documenatation. No more out of date SDKs or CLIs. Your entire ecosystem of tooling is driven off of one simple backend implementation. Stuff just works. +Given the features of Go, the desire to strictly keep the code and docs/tools in sync, and a desire to be developer-friendly and quick to start using, Huma makes some necessary compromises. -More docs coming soon. +- Struct tags are used as metadata for fields to support things like JSON Schema-style validation. There are no compile-time checks for these, but basic linter support. +- Handler functions registration uses `interface{}` to support any kind of input struct. +- Response registration takes an _instance_ of your type since you can't pass types in Go. +- Many checks happen at service startup rather than compile-time. Luckily the most basic unit test that creates a router should catch these. +- `ctx.WriteModel` and `ctx.WriteError` do checks at runtime and can be at least partially bypassed with `ctx.Write` by design. We trade looser checks for a nicer interface and more compatibility. > :whale: Thanks for reading! diff --git a/benchmark/fiber/main.go b/benchmark/fiber/main.go new file mode 100644 index 00000000..2d1aa439 --- /dev/null +++ b/benchmark/fiber/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "strconv" + "strings" + + "github.com/gofiber/fiber" + "github.com/gofiber/fiber/middleware" +) + +// Item tracks the price of a good. +type Item struct { + ID int `json:"id"` + Name string `json:"name"` + Price float32 `json:"price"` + IsOffer bool `json:"is_offer,omitempty"` +} + +func main() { + app := fiber.New() + app.Use(middleware.Recover()) + + d := func(c *fiber.Ctx) string { + return strings.Split(c.Get("authorization"), " ")[0] + } + + app.Get("/items/:id", func(c *fiber.Ctx) { + tmp := c.Params("id") + id, err := strconv.Atoi(tmp) + if err != nil { + c.Status(500) + return + } + + authInfo := d(c) + + c.Set("x-authinfo", authInfo) + c.Status(200) + c.JSON(&Item{ + ID: id, + Name: "Hello", + Price: 1.25, + IsOffer: false, + }) + }) + + app.Listen("127.0.0.1:8000") +} diff --git a/benchmark/go.mod b/benchmark/go.mod new file mode 100644 index 00000000..bdb4e94a --- /dev/null +++ b/benchmark/go.mod @@ -0,0 +1,10 @@ +module github.com/istreamlabs/huma/benchmark + +go 1.14 + +require ( + github.com/istreamlabs/huma v0.0.0-20200821183705-0c275cfd3c4a + github.com/gin-gonic/gin v1.5.0 + github.com/gofiber/fiber v1.13.3 + github.com/labstack/echo/v4 v4.1.15 +) diff --git a/benchmark/go.sum b/benchmark/go.sum new file mode 100644 index 00000000..7afd5ef0 --- /dev/null +++ b/benchmark/go.sum @@ -0,0 +1,447 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc= +github.com/Jeffail/gabs/v2 v2.6.0 h1:WdCnGaDhNa4LSRTMwhLZzJ7SRDXjABNP13SOKvCpL5w= +github.com/Jeffail/gabs/v2 v2.6.0/go.mod h1:xCn81vdHKxFUuWWAaD5jCTQDNPBMh5pPs9IJ+NcziBI= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= +github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/istreamlabs/huma v0.0.0-20200821183705-0c275cfd3c4a h1:Y8W74tIZW4lwYdboTq36W9/1xlmFndtJTwI4PCF0994= +github.com/istreamlabs/huma v0.0.0-20200821183705-0c275cfd3c4a/go.mod h1:/fJWZuR8MP0WTxpPj0PVje+THtOlfd6tH9s8iVbVQdA= +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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= +github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= +github.com/getkin/kin-openapi v0.3.0/go.mod h1:W8dhxZgpE84ciM+VIItFqkmZ4eHtuomrdIHtASQIqi0= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk= +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/autotls v0.0.0-20200314141124-cc69476aef2a/go.mod h1:GTnUDNd5zRw/BceSPHICHoH9fNaTSPHsFTU72wuK0YE= +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-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +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/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gofiber/fiber v1.13.3 h1:14kBTW1+n5mNIJZqibsbIdb+yQdC5argcbe9vE7Nz+o= +github.com/gofiber/fiber v1.13.3/go.mod h1:KxRvVkqzfZOO6A7mBu+j7ncX2AcT6Sm6F7oeGR3Kgmw= +github.com/gofiber/utils v0.0.9 h1:Bu4grjEB4zof1TtpmPCG6MeX5nGv8SaQfzaUgjkf3H8= +github.com/gofiber/utils v0.0.9/go.mod h1:9J5aHFUIjq0XfknT4+hdSMG6/jzfaAgCu4HEbWDeBlo= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +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/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= +github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.10.7 h1:7rix8v8GpI3ZBb0nSozFRgbtXKv+hOe+qfEpZqybrAg= +github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/labstack/echo/v4 v4.1.15 h1:4aE6KfJC+wCnMjODwcpeEGWGsRfszxZMwB3QVTECj2I= +github.com/labstack/echo/v4 v4.1.15/go.mod h1:GWO5IBVzI371K8XJe50CSvHjQCafK6cw8R/moLhEU6o= +github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.2 h1:znVR8Q4g7/WlcvsxLBRWvo+vtFJUAbDn3w+Yak2xVMI= +github.com/magiconair/properties v1.8.2/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg= +github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw= +github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.3.4 h1:8q6vk3hthlpb2SouZcnBVKboxWQWMDNF38bwholZrJc= +github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs= +github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= +github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.15.1 h1:eRb5jzWhbCn/cGu3gNJMcOfPUfXgXCcQIOHjh9ajAS8= +github.com/valyala/fasthttp v1.15.1/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4= +github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a h1:0R4NLDRDZX6JcmhJgXi5E4b8Wg84ihbmUKp/GvSPEzc= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +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/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/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= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= +go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9 h1:pNX+40auqi2JqRfOP1akLGtYcn15TUbkhwuCO3foqqM= +golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980 h1:OjiUf46hAmXblsZdnoSXsEUSKU8r1UEzcL5RVZ4gO9Y= +golang.org/x/sys v0.0.0-20200602225109-6fdc65e7d980/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +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/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.60.1 h1:P5y5shSkb0CFe44qEeMBgn8JLow09MP17jlJHanke5g= +gopkg.in/ini.v1 v1.60.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/benchmark/huma/main.go b/benchmark/huma/main.go index e2a2101c..0b79e660 100644 --- a/benchmark/huma/main.go +++ b/benchmark/huma/main.go @@ -5,8 +5,9 @@ import ( "strings" "github.com/istreamlabs/huma" - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/middleware" + "github.com/istreamlabs/huma/responses" ) // Item tracks the price of a good. @@ -17,34 +18,30 @@ type Item struct { IsOffer bool `json:"is_offer,omitempty"` } +type Input struct { + AuthInfo string + ID int `path:"id"` +} + +func (i *Input) Resolve(ctx huma.Context, r *http.Request) { + i.AuthInfo = strings.Split(r.Header.Get("Authorization"), " ")[0] +} + func main() { - gin.SetMode(gin.ReleaseMode) - g := gin.New() - g.Use(huma.Recovery()) - g.Use(cors.Default()) - g.Use(huma.PreferMinimalMiddleware()) - - r := huma.NewRouter("Benchmark", "1.0.0", huma.Gin(g)) - - d := huma.Dependency( - huma.HeaderParam("authorization", "Auth header", ""), - func(auth string) (string, error) { - return strings.Split(auth, " ")[0], nil - }, - ) - - r.Resource("/items", d, - huma.PathParam("id", "The item's unique ID"), - huma.ResponseHeader("x-authinfo", "..."), - huma.ResponseJSON(http.StatusOK, "Successful hello response", huma.Headers("x-authinfo")), - ).Get("Huma benchmark test", func(authInfo string, id int) (string, *Item) { - return authInfo, &Item{ - ID: id, + app := cli.New(huma.New("Benchmark", "1.0.0")) + app.Middleware(middleware.Recovery, middleware.ContentEncoding) + + app.Resource("/items/{id}").Get("get", "Huma benchmark test", + responses.OK().Headers("x-authinfo").Model(Item{}), + ).Run(func(ctx huma.Context, input Input) { + ctx.Header().Set("x-authinfo", input.AuthInfo) + ctx.WriteModel(http.StatusOK, &Item{ + ID: input.ID, Name: "Hello", Price: 1.24, IsOffer: false, - } + }) }) - r.Run() + app.Run() } diff --git a/cli.go b/cli.go deleted file mode 100644 index b02d7ff0..00000000 --- a/cli.go +++ /dev/null @@ -1,147 +0,0 @@ -package huma - -import ( - "context" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "os" - "os/signal" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/gin-gonic/autotls" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "go.uber.org/zap/zapcore" -) - -// GlobalFlag adds a new global flag on the root command. -func GlobalFlag(name, short, description string, defaultValue interface{}) RouterOption { - return &routerOption{func(r *Router) { - viper.SetDefault(name, defaultValue) - - flags := r.root.PersistentFlags() - switch v := defaultValue.(type) { - case bool: - flags.BoolP(name, short, viper.GetBool(name), description) - case int, int16, int32, int64, uint16, uint32, uint64: - flags.IntP(name, short, viper.GetInt(name), description) - case float32, float64: - flags.Float64P(name, short, viper.GetFloat64(name), description) - default: - flags.StringP(name, short, fmt.Sprintf("%v", v), description) - } - viper.BindPFlag(name, flags.Lookup(name)) - }} -} - -// Root returns the router's root command. -func (r *Router) Root() *cobra.Command { - return r.root -} - -// setupCLI sets up the CLI commands. -func (r *Router) setupCLI() { - viper.SetEnvPrefix("SERVICE") - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - viper.AutomaticEnv() - - r.root = &cobra.Command{ - Use: filepath.Base(os.Args[0]), - Version: r.api.Version, - Run: func(cmd *cobra.Command, args []string) { - // Call any pre-start functions. - for _, f := range r.prestart { - f() - } - - if viper.GetBool("debug") { - if logLevel != nil { - logLevel.SetLevel(zapcore.DebugLevel) - } - } - - // Start the server. - go func() { - // Start either an HTTP or HTTPS server based on whether TLS cert/key - // paths were given or Let's Encrypt is used. - autoTLS := viper.GetString("autotls") - if autoTLS != "" { - domains := strings.Split(autoTLS, ",") - if err := autotls.Run(r, domains...); err != nil && err != http.ErrServerClosed { - panic(err) - } - return - } - - cert := viper.GetString("cert") - key := viper.GetString("key") - if cert == "" && key == "" { - if err := r.Listen(fmt.Sprintf("%s:%v", viper.Get("host"), viper.Get("port"))); err != nil && err != http.ErrServerClosed { - panic(err) - } - return - } - - if cert != "" && key != "" { - if err := r.ListenTLS(fmt.Sprintf("%s:%v", viper.Get("host"), viper.Get("port")), cert, key); err != nil && err != http.ErrServerClosed { - panic(err) - } - return - } - - panic("must pass key and cert for TLS") - }() - - // Handle graceful shutdown. - quit := make(chan os.Signal) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - fmt.Println("Gracefully shutting down the server...") - - ctx, cancel := context.WithTimeout(context.Background(), viper.GetDuration("grace-period")*time.Second) - defer cancel() - r.Shutdown(ctx) - }, - } - - r.root.AddCommand(&cobra.Command{ - Use: "openapi FILENAME.json", - Short: "Get OpenAPI spec", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - // Get the OpenAPI route from the server. - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) - r.ServeHTTP(w, req) - - if w.Result().StatusCode != 200 { - panic(w.Body.String()) - } - - // Dump the response to a file. - ioutil.WriteFile(args[0], append(w.Body.Bytes(), byte('\n')), 0644) - - fmt.Printf("Successfully wrote OpenAPI JSON to %s\n", args[0]) - }, - }) - - flags := []RouterOption{ - GlobalFlag("host", "", "Hostname", "0.0.0.0"), - GlobalFlag("port", "p", "Port", 8888), - GlobalFlag("cert", "", "SSL certificate file path", ""), - GlobalFlag("key", "", "SSL key file path", ""), - GlobalFlag("autotls", "", "Let's Encrypt automatic TLS domains (ignores port)", ""), - GlobalFlag("debug", "d", "Enable debug logs", false), - GlobalFlag("grace-period", "", "Graceful shutdown wait duration in seconds", 20), - } - - for _, flag := range flags { - flag.ApplyRouter(r) - } -} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 00000000..ed4baa59 --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,168 @@ +package cli + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/middleware" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CLI provides a command line interface to a Huma router. +type CLI struct { + *huma.Router + + // Root entrypoint command + root *cobra.Command + + // Functions to run before the server starts up. + prestart []func() +} + +// NewRouter creates a new router, new CLI, sets the default middlware, and +// returns the CLI/router as a convenience function. +func NewRouter(docs, version string) *CLI { + // Create the router and CLI + r := huma.New(docs, version) + app := New(r) + + // Set up the default middleware + middleware.Defaults(app) + + return app +} + +// New creates a new CLI instance from an existing router. +func New(router *huma.Router) *CLI { + viper.SetEnvPrefix("SERVICE") + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + viper.AutomaticEnv() + + app := &CLI{ + Router: router, + } + + app.root = &cobra.Command{ + Use: filepath.Base(os.Args[0]), + Version: app.GetVersion(), + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Starting %s %s on %s:%v\n", app.GetTitle(), app.GetVersion(), viper.Get("host"), viper.Get("port")) + + // Call any pre-start functions. + for _, f := range app.prestart { + f() + } + + // Start the server. + go func() { + // Start either an HTTP or HTTPS server based on whether TLS cert/key + // paths were given or Let's Encrypt is used. + cert := viper.GetString("cert") + key := viper.GetString("key") + if cert == "" && key == "" { + if err := app.Listen(fmt.Sprintf("%s:%v", viper.Get("host"), viper.Get("port"))); err != nil && err != http.ErrServerClosed { + panic(err) + } + return + } + + if cert != "" && key != "" { + if err := app.ListenTLS(fmt.Sprintf("%s:%v", viper.Get("host"), viper.Get("port")), cert, key); err != nil && err != http.ErrServerClosed { + panic(err) + } + return + } + + panic("must pass key and cert for TLS") + }() + + // Handle graceful shutdown. + quit := make(chan os.Signal) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + fmt.Println("Gracefully shutting down the server...") + + ctx, cancel := context.WithTimeout(context.Background(), viper.GetDuration("grace-period")*time.Second) + defer cancel() + app.Shutdown(ctx) + }, + } + + app.root.AddCommand(&cobra.Command{ + Use: "openapi FILENAME.json", + Short: "Get OpenAPI spec", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Get the OpenAPI route from the server. + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + if w.Result().StatusCode != 200 { + panic(w.Body.String()) + } + + // Dump the response to a file. + ioutil.WriteFile(args[0], append(w.Body.Bytes(), byte('\n')), 0644) + + fmt.Printf("Successfully wrote OpenAPI JSON to %s\n", args[0]) + }, + }) + + app.Flag("host", "", "Hostname", "0.0.0.0") + app.Flag("port", "p", "Port", 8888) + app.Flag("cert", "", "SSL certificate file path", "") + app.Flag("key", "", "SSL key file path", "") + app.Flag("grace-period", "", "Graceful shutdown wait duration in seconds", 20) + + return app +} + +// Root returns the CLI's root command. Use this to add flags and custom +// commands to the CLI. +func (c *CLI) Root() *cobra.Command { + return c.root +} + +// Flag adds a new global flag on the root command of this router. +func (c *CLI) Flag(name, short, description string, defaultValue interface{}) { + viper.SetDefault(name, defaultValue) + + flags := c.root.PersistentFlags() + switch v := defaultValue.(type) { + case bool: + flags.BoolP(name, short, viper.GetBool(name), description) + case int, int16, int32, int64, uint16, uint32, uint64: + flags.IntP(name, short, viper.GetInt(name), description) + case float32, float64: + flags.Float64P(name, short, viper.GetFloat64(name), description) + default: + flags.StringP(name, short, fmt.Sprintf("%v", v), description) + } + viper.BindPFlag(name, flags.Lookup(name)) +} + +// PreStart registers a function to run before the server starts but after +// command line arguments have been parsed. +func (c *CLI) PreStart(f func()) { + c.prestart = append(c.prestart, f) +} + +// Run runs the CLI. +func (c *CLI) Run() { + if err := c.root.Execute(); err != nil { + panic(err) + } +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 00000000..abecad45 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,34 @@ +package cli + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCLI(t *testing.T) { + app := NewRouter("Test API", "1.0.0") + + started := false + app.PreStart(func() { + started = true + }) + + go func() { + // Let the OS pick a random port. + os.Setenv("SERVICE_PORT", "0") + os.Setenv("SERVICE_HOST", "127.0.0.1") + app.Root().Run(nil, []string{}) + }() + + time.Sleep(10 * time.Millisecond) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + app.Shutdown(ctx) + + assert.Equal(t, true, started) +} diff --git a/cli_test.go b/cli_test.go deleted file mode 100644 index b9b5181d..00000000 --- a/cli_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package huma - -import ( - "context" - "os" - "testing" - "time" -) - -func TestServerShutdown(t *testing.T) { - r := NewTestRouter(t) - - go func() { - // Let the OS pick a random port. - os.Setenv("SERVICE_PORT", "0") - r.Root().Run(nil, []string{}) - }() - - time.Sleep(10 * time.Millisecond) - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - r.Shutdown(ctx) -} diff --git a/context.go b/context.go new file mode 100644 index 00000000..ea353eeb --- /dev/null +++ b/context.go @@ -0,0 +1,239 @@ +package huma + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/fxamacker/cbor/v2" + "github.com/goccy/go-yaml" + "github.com/istreamlabs/huma/negotiation" +) + +// ContextFromRequest returns a Huma context for a request, useful for +// accessing high-level convenience functions from e.g. middleware. +func ContextFromRequest(w http.ResponseWriter, r *http.Request) Context { + return &hcontext{ + Context: r.Context(), + ResponseWriter: w, + r: r, + } +} + +// Context provides a request context and response writer with convenience +// functions for error and model marshaling in handler functions. +type Context interface { + context.Context + http.ResponseWriter + + // AddError adds a new error to the list of errors for this request. + AddError(err error) + + // HasError returns true if at least one error has been added to the context. + HasError() bool + + // WriteError writes out an HTTP status code, friendly error message, and + // optionally a set of error details set with `AddError` and/or passed in. + WriteError(status int, message string, errors ...error) + + // WriteModel writes out an HTTP status code and marshalled model based on + // content negotiation (e.g. JSON or CBOR). This must match the registered + // response status code & type. + WriteModel(status int, model interface{}) +} + +type hcontext struct { + context.Context + http.ResponseWriter + r *http.Request + errors []error + op *Operation + closed bool +} + +func (c *hcontext) AddError(err error) { + c.errors = append(c.errors, err) +} + +func (c *hcontext) HasError() bool { + return len(c.errors) > 0 +} + +func (c *hcontext) WriteHeader(status int) { + if c.op != nil { + allowed := []string{} + for _, r := range c.op.responses { + if r.status == status { + for _, h := range r.headers { + allowed = append(allowed, h) + } + } + } + + // Check that all headers were allowed to be sent. + for name := range c.Header() { + found := false + + for _, h := range allowed { + if strings.ToLower(name) == strings.ToLower(h) { + found = true + break + } + } + + if !found { + panic(fmt.Errorf("Response header %s is not declared for %s %s with status code %d (allowed: %s)", name, c.r.Method, c.r.URL.Path, status, allowed)) + } + } + } + + c.ResponseWriter.WriteHeader(status) +} + +func (c *hcontext) Write(data []byte) (int, error) { + if c.closed { + panic(fmt.Errorf("Trying to write to response after WriteModel or WriteError for %s %s", c.r.Method, c.r.URL.Path)) + } + + return c.ResponseWriter.Write(data) +} + +func (c *hcontext) WriteError(status int, message string, errors ...error) { + if c.closed { + panic(fmt.Errorf("Trying to write to response after WriteModel or WriteError for %s %s", c.r.Method, c.r.URL.Path)) + } + + details := []*ErrorDetail{} + + c.errors = append(c.errors, errors...) + for _, err := range c.errors { + if d, ok := err.(ErrorDetailer); ok { + details = append(details, d.ErrorDetail()) + } else { + details = append(details, &ErrorDetail{Message: err.Error()}) + } + } + + model := &ErrorModel{ + Title: http.StatusText(status), + Status: status, + Detail: message, + Errors: details, + } + + // Select content type and transform it to the appropriate error type. + ct := selectContentType(c.r) + switch ct { + case "application/cbor": + ct = "application/problem+cbor" + case "", "application/json": + ct = "application/problem+json" + case "application/yaml", "application/x-yaml": + ct = "application/problem+yaml" + } + + c.writeModel(ct, status, model) +} + +func (c *hcontext) WriteModel(status int, model interface{}) { + if c.closed { + panic(fmt.Errorf("Trying to write to response after WriteModel or WriteError for %s %s", c.r.Method, c.r.URL.Path)) + } + + // Get the negotiated content type the client wants and we are willing to + // provide. + ct := selectContentType(c.r) + + c.writeModel(ct, status, model) +} + +func (c *hcontext) writeModel(ct string, status int, model interface{}) { + // Is this allowed? Find the right response. + if c.op != nil { + responses := []Response{} + names := []string{} + statuses := []string{} + for _, r := range c.op.responses { + statuses = append(statuses, fmt.Sprintf("%d", r.status)) + if r.status == status { + responses = append(responses, r) + if r.model != nil { + names = append(names, r.model.Name()) + } + } + } + + if len(responses) == 0 { + panic(fmt.Errorf("HTTP status %d not allowed for %s %s, expected one of %s", status, c.r.Method, c.r.URL.Path, statuses)) + } + + found := false + for _, r := range responses { + if r.model == reflect.TypeOf(model) { + found = true + break + } + } + + if !found { + panic(fmt.Errorf("Invalid model %s, expecting %s for %s %s", reflect.TypeOf(model), strings.Join(names, ", "), c.r.Method, c.r.URL.Path)) + } + } + + // Do the appropriate encoding. + var encoded []byte + var err error + if strings.HasPrefix(ct, "application/json") || strings.HasSuffix(ct, "+json") { + encoded, err = json.Marshal(model) + if err != nil { + panic(fmt.Errorf("Unable to marshal JSON: %w", err)) + } + } else if strings.HasPrefix(ct, "application/yaml") || strings.HasPrefix(ct, "application/x-yaml") || strings.HasSuffix(ct, "+yaml") { + encoded, err = yaml.Marshal(model) + if err != nil { + panic(fmt.Errorf("Unable to marshal YAML: %w", err)) + } + } else if strings.HasPrefix(ct, "application/cbor") || strings.HasSuffix(ct, "+cbor") { + opts := cbor.CanonicalEncOptions() + opts.Time = cbor.TimeRFC3339Nano + opts.TimeTag = cbor.EncTagRequired + mode, err := opts.EncMode() + if err != nil { + panic(fmt.Errorf("Unable to marshal CBOR: %w", err)) + } + encoded, err = mode.Marshal(model) + if err != nil { + panic(fmt.Errorf("Unable to marshal JSON: %w", err)) + } + } + + // Encoding succeeded, write the data! + c.Header().Set("Content-Type", ct) + c.WriteHeader(status) + c.Write(encoded) + c.closed = true +} + +// selectContentType selects the best availalable content type via content +// negotiation with the client, defaulting to JSON. +func selectContentType(r *http.Request) string { + ct := "application/json" + + if accept := r.Header.Get("Accept"); accept != "" { + best := negotiation.SelectQValue(accept, []string{ + "application/cbor", + "application/json", + "application/yaml", + "application/x-yaml", + }) + + if best != "" { + ct = best + } + } + + return ct +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 00000000..c773edb7 --- /dev/null +++ b/context_test.go @@ -0,0 +1,244 @@ +package huma + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fxamacker/cbor" + "github.com/goccy/go-yaml" + "github.com/stretchr/testify/assert" +) + +func TestGetContextFromRequest(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(context.WithValue(r.Context(), contextKey("foo"), "bar")) + ctx := ContextFromRequest(w, r) + assert.Equal(t, "bar", ctx.Value(contextKey("foo"))) + }) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/", nil) + handler(w, r) +} + +func TestContentNegotiation(t *testing.T) { + type Response struct { + Value string `json:"value"` + } + + app := newTestRouter() + + app.Resource("/negotiated").Get("test", "Test", + NewResponse(200, "desc").Model(Response{}), + ).Run(func(ctx Context) { + ctx.WriteModel(http.StatusOK, Response{ + Value: "Hello, world!", + }) + }) + + var parsed Response + expected := Response{ + Value: "Hello, world!", + } + + // No preference + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/negotiated", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer JSON + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/negotiated", nil) + req.Header.Set("Accept", "application/json") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + err = json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer YAML + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/negotiated", nil) + req.Header.Set("Accept", "application/yaml") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/yaml", w.Header().Get("Content-Type")) + err = yaml.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer CBOR + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/negotiated", nil) + req.Header.Set("Accept", "application/cbor") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/cbor", w.Header().Get("Content-Type")) + err = cbor.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.EqualValues(t, expected, parsed) +} + +func TestErrorNegotiation(t *testing.T) { + app := newTestRouter() + + app.Resource("/error").Get("test", "Test", + NewResponse(400, "desc").Model(&ErrorModel{}), + ).Run(func(ctx Context) { + ctx.AddError(fmt.Errorf("some error")) + ctx.AddError(&ErrorDetail{ + Message: "Invalid value", + Location: "body.field", + Value: "test", + }) + ctx.WriteError(http.StatusBadRequest, "test error") + }) + + var parsed ErrorModel + expected := ErrorModel{ + Status: http.StatusBadRequest, + Title: http.StatusText(http.StatusBadRequest), + Detail: "test error", + Errors: []*ErrorDetail{ + {Message: "some error"}, + { + Message: "Invalid value", + Location: "body.field", + Value: "test", + }, + }, + } + + // No preference + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/error", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer JSON + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/error", nil) + req.Header.Set("Accept", "application/json") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + err = json.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) + + // Prefer YAML + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/error", nil) + req.Header.Set("Accept", "application/yaml") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+yaml", w.Header().Get("Content-Type")) + err = yaml.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.EqualValues(t, expected, parsed) + + // Prefer CBOR + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/error", nil) + req.Header.Set("Accept", "application/cbor") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "application/problem+cbor", w.Header().Get("Content-Type")) + err = cbor.Unmarshal(w.Body.Bytes(), &parsed) + assert.NoError(t, err) + assert.Equal(t, expected, parsed) +} + +func TestInvalidModel(t *testing.T) { + type R1 struct { + Foo string `json:"foo"` + } + + type R2 struct { + Bar string `json:"bar"` + } + + app := newTestRouter() + + app.Resource("/bad-status").Get("test", "Test", + NewResponse(http.StatusOK, "desc").Model(R1{}), + ).Run(func(ctx Context) { + ctx.WriteModel(http.StatusNoContent, R2{Bar: "blah"}) + }) + + app.Resource("/bad-model").Get("test", "Test", + NewResponse(http.StatusOK, "desc").Model(R1{}), + ).Run(func(ctx Context) { + ctx.WriteModel(http.StatusOK, R2{Bar: "blah"}) + }) + + assert.Panics(t, func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/bad-status", nil) + app.ServeHTTP(w, req) + }) + + assert.Panics(t, func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/bad-model", nil) + app.ServeHTTP(w, req) + }) +} + +func TestInvalidHeader(t *testing.T) { + app := newTestRouter() + + app.Resource("/").Get("test", "Test", + NewResponse(http.StatusNoContent, "desc").Headers("Extra"), + ).Run(func(ctx Context) { + // Typo in the header should not be allowed + ctx.Header().Set("Extra2", "some-value") + ctx.WriteHeader(http.StatusNoContent) + }) + + assert.Panics(t, func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + app.ServeHTTP(w, req) + }) +} + +func TestWriteAfterClose(t *testing.T) { + app := newTestRouter() + + app.Resource("/").Get("test", "Test", + NewResponse(http.StatusBadRequest, "desc").Model(&ErrorModel{}), + ).Run(func(ctx Context) { + ctx.WriteError(http.StatusBadRequest, "some error") + // Second write should fail + ctx.WriteError(http.StatusBadRequest, "some error") + }) + + assert.Panics(t, func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + app.ServeHTTP(w, req) + }) +} diff --git a/dependency.go b/dependency.go deleted file mode 100644 index ede327e5..00000000 --- a/dependency.go +++ /dev/null @@ -1,249 +0,0 @@ -package huma - -import ( - "errors" - "fmt" - "net" - "reflect" - - "github.com/gin-gonic/gin" -) - -// ErrDependencyInvalid is returned when registering a dependency fails. -var ErrDependencyInvalid = errors.New("dependency invalid") - -// openAPIDependency represents a handler function dependency and its associated -// inputs and outputs. Value can be either a struct pointer (global dependency) -// or a `func(dependencies, params) (headers, struct pointer, error)` style -// function. -type openAPIDependency struct { - dependencies []*openAPIDependency - params []*openAPIParam - responseHeaders []*openAPIResponseHeader - handler interface{} -} - -// newDependency returns a dependency with the given option and a handler -// function. -func newDependency(option DependencyOption, handler interface{}) *openAPIDependency { - d := &openAPIDependency{ - dependencies: make([]*openAPIDependency, 0), - params: make([]*openAPIParam, 0), - responseHeaders: make([]*openAPIResponseHeader, 0), - handler: handler, - } - - if option != nil { - option.applyDependency(d) - } - - return d -} - -var contextDependency openAPIDependency -var ginContextDependency openAPIDependency -var operationIDDependency openAPIDependency - -// ContextDependency returns a dependency for the current request's -// `context.Context`. This is useful for timeouts & cancellation. -func ContextDependency() DependencyOption { - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, &contextDependency) - }} -} - -// GinContextDependency returns a dependency for the current request's -// `*gin.Context`. -func GinContextDependency() DependencyOption { - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, &ginContextDependency) - }} -} - -// OperationIDDependency returns a dependency for the current `*huma.Operation`. -func OperationIDDependency() DependencyOption { - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, &operationIDDependency) - }} -} - -// ConnDependency returns the underlying `net.Conn` for the current request. -func ConnDependency() DependencyOption { - dep := newDependency(GinContextDependency(), - func(c *gin.Context) (net.Conn, error) { - return getConn(c.Request), nil - }) - - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, dep) - }} -} - -// validate that the dependency deps/params/headers match the function -// signature or that the value is not a function. -func (d *openAPIDependency) validate(returnType reflect.Type) { - if d == &contextDependency || d == &ginContextDependency || d == &operationIDDependency { - // Hard-coded known dependencies. These are special and have no value. - return - } - - if d.handler == nil { - panic(fmt.Errorf("handler must be set: %w", ErrDependencyInvalid)) - } - - v := reflect.ValueOf(d.handler) - - if v.Kind() != reflect.Func { - if returnType != nil && returnType != v.Type() && !v.Type().Implements(returnType) { - panic(fmt.Errorf("return type should be %s but got %s: %w", v.Type(), returnType, ErrDependencyInvalid)) - } - - // This is just a static value. It shouldn't have params/headers/etc. - if len(d.params) > 0 { - panic(fmt.Errorf("global dependency should not have params: %w", ErrDependencyInvalid)) - } - - if len(d.responseHeaders) > 0 { - panic(fmt.Errorf("global dependency should not set headers: %w", ErrDependencyInvalid)) - } - - return - } - - fn := v.Type() - lenArgs := len(d.dependencies) + len(d.params) - if fn.NumIn() != lenArgs { - // TODO: generate suggested func signature - panic(fmt.Errorf("function signature should have %d args but got %s: %w", lenArgs, fn, ErrDependencyInvalid)) - } - - for _, dep := range d.dependencies { - dep.validate(nil) - } - - for i, p := range d.params { - p.validate(fn.In(len(d.dependencies) + i)) - } - - lenReturn := len(d.responseHeaders) + 2 - if fn.NumOut() != lenReturn { - panic(fmt.Errorf("function should return %d values but got %d: %w", lenReturn, fn.NumOut(), ErrDependencyInvalid)) - } - - for i, h := range d.responseHeaders { - h.validate(fn.Out(i)) - } -} - -// allParams returns all parameters for all dependencies in the graph of this -// dependency in depth-first order without duplicates. -func (d *openAPIDependency) allParams() []*openAPIParam { - params := []*openAPIParam{} - seen := map[*openAPIParam]bool{} - - for _, p := range d.params { - seen[p] = true - params = append(params, p) - } - - for _, d := range d.dependencies { - for _, p := range d.allParams() { - if _, ok := seen[p]; !ok { - seen[p] = true - - params = append(params, p) - } - } - } - - return params -} - -// allResponseHeaders returns all response headers for all dependencies in -// the graph of this dependency in depth-first order without duplicates. -func (d *openAPIDependency) allResponseHeaders() []*openAPIResponseHeader { - headers := []*openAPIResponseHeader{} - seen := map[*openAPIResponseHeader]bool{} - - for _, h := range d.responseHeaders { - seen[h] = true - headers = append(headers, h) - } - - for _, d := range d.dependencies { - for _, h := range d.allResponseHeaders() { - if _, ok := seen[h]; !ok { - seen[h] = true - - headers = append(headers, h) - } - } - } - - return headers -} - -// resolve the value of the dependency. Returns (response headers, value, error). -func (d *openAPIDependency) resolve(c *gin.Context, op *openAPIOperation) (map[string]string, interface{}, error) { - // Identity dependencies are first. Just return if it's one of them. - if d == &contextDependency { - return nil, c.Request.Context(), nil - } - - if d == &ginContextDependency { - return nil, c, nil - } - - if d == &operationIDDependency { - return nil, op.id, nil - } - - v := reflect.ValueOf(d.handler) - if v.Kind() != reflect.Func { - // Not a function, just return the global value. - return nil, d.handler, nil - } - - // Generate the input arguments - in := make([]reflect.Value, 0, v.Type().NumIn()) - headers := map[string]string{} - - // Resolve each sub-dependency - for _, dep := range d.dependencies { - dHeaders, dVal, err := dep.resolve(c, op) - if err != nil { - return nil, nil, err - } - - for h, hv := range dHeaders { - headers[h] = hv - } - - in = append(in, reflect.ValueOf(dVal)) - } - - // Get each input parameter - for _, param := range d.params { - v, ok := getParamValue(c, param) - if !ok { - return nil, nil, fmt.Errorf("could not get param value") - } - - in = append(in, reflect.ValueOf(v)) - } - - // Call the function. - out := v.Call(in) - - if last := out[len(out)-1]; !last.IsNil() { - // There was an error! - return nil, nil, last.Interface().(error) - } - - // Get the headers & response value. - for i, h := range d.responseHeaders { - headers[h.Name] = out[i].Interface().(string) - } - - return headers, out[len(d.responseHeaders)].Interface(), nil -} diff --git a/dependency_test.go b/dependency_test.go deleted file mode 100644 index 08681f93..00000000 --- a/dependency_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package huma - -import ( - "context" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -func TestGlobalDepEmpty(t *testing.T) { - d := openAPIDependency{} - - typ := reflect.TypeOf(123) - - assert.Panics(t, func() { - d.validate(typ) - }) -} - -func TestGlobalDepWrongType(t *testing.T) { - d := openAPIDependency{ - handler: "test", - } - - typ := reflect.TypeOf(123) - - assert.Panics(t, func() { - d.validate(typ) - }) -} - -func TestDepContext(t *testing.T) { - d := openAPIDependency{ - dependencies: []*openAPIDependency{ - &contextDependency, - }, - handler: func(ctx context.Context) (context.Context, error) { return ctx, nil }, - } - - mock, _ := gin.CreateTestContext(nil) - mock.Request = httptest.NewRequest("GET", "/", nil) - - typ := reflect.TypeOf(mock) - d.validate(typ) - - _, v, err := d.resolve(mock, &openAPIOperation{}) - assert.NoError(t, err) - assert.Equal(t, v, mock.Request.Context()) -} - -func TestDepGinContext(t *testing.T) { - d := openAPIDependency{ - dependencies: []*openAPIDependency{ - &ginContextDependency, - }, - handler: func(c *gin.Context) (*gin.Context, error) { return c, nil }, - } - - mock, _ := gin.CreateTestContext(nil) - - typ := reflect.TypeOf(mock) - d.validate(typ) - - _, v, err := d.resolve(mock, &openAPIOperation{}) - assert.NoError(t, err) - assert.Equal(t, v, mock) -} - -func TestDepOperationID(t *testing.T) { - d := openAPIDependency{ - dependencies: []*openAPIDependency{ - &operationIDDependency, - }, - handler: func(id string) (string, error) { return id, nil }, - } - - mock := &openAPIOperation{ - id: "test-id", - } - - typ := reflect.TypeOf(mock) - d.validate(typ) - - _, v, err := d.resolve(&gin.Context{}, mock) - assert.NoError(t, err) - assert.Equal(t, v, "test-id") -} -func TestDepFuncWrongArgs(t *testing.T) { - d := &openAPIDependency{} - - Dependency(HeaderParam("foo", "desc", ""), func() (string, error) { - return "", nil - }).applyDependency(d) - - assert.Panics(t, func() { - d.validate(reflect.TypeOf("")) - }) -} - -func TestDepFunc(t *testing.T) { - d := openAPIDependency{ - handler: func(xin string) (string, string, error) { - return "xout", "value", nil - }, - } - - DependencyOptions( - HeaderParam("x-in", "desc", ""), - ResponseHeader("x-out", "desc"), - ).applyDependency(&d) - - c := &gin.Context{ - Request: &http.Request{ - Header: http.Header{ - "x-in": []string{"xin"}, - }, - }, - } - - d.validate(reflect.TypeOf("")) - h, v, err := d.resolve(c, &openAPIOperation{}) - assert.NoError(t, err) - assert.Equal(t, "xout", h["x-out"]) - assert.Equal(t, "value", v) -} diff --git a/docs.go b/docs.go index c3cce820..7dce3514 100644 --- a/docs.go +++ b/docs.go @@ -2,9 +2,8 @@ package huma import ( "fmt" + "net/http" "strings" - - "github.com/gin-gonic/gin" ) // splitDocs will split a single string out into a title/description combo. @@ -19,10 +18,11 @@ func splitDocs(docs string) (title, desc string) { return } -// rapiDocTemplate is the template used to generate the RapiDoc. It needs two args to render: -// 1. the title -// 2. the path to the openapi.yaml file -var rapiDocTemplate = ` +// RapiDocHandler renders documentation using RapiDoc. +func RapiDocHandler(router *Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fmt.Sprintf(` %s @@ -38,12 +38,15 @@ var rapiDocTemplate = ` nav-accent-color="#47afe8" > -` +`, router.GetTitle(), router.OpenAPIPath()))) + }) +} -// reDocTemplate is the template used to generate the ReDoc. It needs two args to render: -// 1. the title -// 2. the path to the openapi.yaml file -var reDocTemplate = ` +// ReDocHandler renders documentation using ReDoc. +func ReDocHandler(router *Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fmt.Sprintf(` %s @@ -56,12 +59,15 @@ var reDocTemplate = ` -` +`, router.GetTitle(), router.OpenAPIPath()))) + }) +} -// swaggerUITemplate is the template used to generate the SwaggerUI. It needs two args to render: -// 1. the title -// 2. the path to the openapi.yaml file -var swaggerUITemplate = ` +// SwaggerUIHandler renders documentation using Swagger UI. +func SwaggerUIHandler(router *Router) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.Write([]byte(fmt.Sprintf(` @@ -118,46 +124,6 @@ var swaggerUITemplate = ` } -` - -// RapiDocString generates the RapiDoc. It needs two args to render: -// 1. the title -// 2. the path to the openapi.yaml file -func RapiDocString(pageTitle, openapiPath string) string { - return fmt.Sprintf(rapiDocTemplate, pageTitle, openapiPath) -} - -// ReDocString generates the RapiDoc. It needs two args to render: -// 1. the title -// 2. the path to the openapi.yaml file -func ReDocString(pageTitle, openapiPath string) string { - return fmt.Sprintf(reDocTemplate, pageTitle, openapiPath) -} - -// SwaggerUIDocString generates the RapiDoc. It needs two args to render: -// 1. the title -// 2. the path to the openapi.yaml file -func SwaggerUIDocString(pageTitle, openapiPath string) string { - return fmt.Sprintf(swaggerUITemplate, pageTitle, openapiPath) -} - -// RapiDocHandler renders documentation using RapiDoc. -func RapiDocHandler(pageTitle string) Handler { - return func(c *gin.Context) { - c.Data(200, "text/html", []byte(RapiDocString(pageTitle, "/openapi.json"))) - } -} - -// ReDocHandler renders documentation using ReDoc. -func ReDocHandler(pageTitle string) Handler { - return func(c *gin.Context) { - c.Data(200, "text/html", []byte(ReDocString(pageTitle, "/openapi.json"))) - } -} - -// SwaggerUIHandler renders documentation using Swagger UI. -func SwaggerUIHandler(pageTitle string) Handler { - return func(c *gin.Context) { - c.Data(200, "text/html", []byte(SwaggerUIDocString(pageTitle, "/openapi.json"))) - } +`, router.GetTitle(), router.OpenAPIPath()))) + }) } diff --git a/docs_test.go b/docs_test.go index 9305e9ae..fdc5a5b2 100644 --- a/docs_test.go +++ b/docs_test.go @@ -1,7 +1,6 @@ package huma import ( - "fmt" "net/http" "net/http/httptest" "testing" @@ -11,24 +10,31 @@ import ( var handlers = []struct { name string - handler Handler + handler http.Handler }{ - {"RapiDoc", RapiDocHandler("Test API")}, - {"ReDoc", ReDocHandler("Test API")}, - {"SwaggerUI", SwaggerUIHandler("Test API")}, + {"RapiDoc", RapiDocHandler(New("Test API", "1.0.0"))}, + {"ReDoc", ReDocHandler(New("Test API", "1.0.0"))}, + {"SwaggerUI", SwaggerUIHandler(New("Test API", "1.0.0"))}, } func TestDocHandlers(outer *testing.T) { for _, tt := range handlers { local := tt - outer.Run(fmt.Sprintf("%v", tt.name), func(t *testing.T) { - r := NewTestRouter(t, DocsHandler(local.handler)) + outer.Run(local.name, func(t *testing.T) { + app := newTestRouter() + app.DocsHandler(local.handler) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodGet, "/docs", nil) - r.ServeHTTP(w, req) + app.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) }) } } + +func TestSplitDocs(t *testing.T) { + title, desc := splitDocs("One two\nthree\nfour five") + assert.Equal(t, "One two", title) + assert.Equal(t, "three\nfour five", desc) +} diff --git a/models.go b/error.go similarity index 59% rename from models.go rename to error.go index 9e2fa8c9..d23314cf 100644 --- a/models.go +++ b/error.go @@ -1,6 +1,30 @@ package huma -// ErrorModel defines a basic error message +import "fmt" + +// ErrorDetailer returns error details for responses & debugging. +type ErrorDetailer interface { + ErrorDetail() *ErrorDetail +} + +// ErrorDetail provides details about a specific error. +type ErrorDetail struct { + Message string `json:"message,omitempty" doc:"Error message text"` + Location string `json:"location,omitempty" doc:"Where the error occured, e.g. 'body.items[3].tags' or 'path.thing-id'"` + Value interface{} `json:"value,omitempty" doc:"The value at the given location"` +} + +// Error returns the error message / satisfies the `error` interface. +func (e *ErrorDetail) Error() string { + return fmt.Sprintf("%s (%s: %v)", e.Message, e.Location, e.Value) +} + +// ErrorDetail satisfies the `ErrorDetailer` interface. +func (e *ErrorDetail) ErrorDetail() *ErrorDetail { + return e +} + +// ErrorModel defines a basic error message model. type ErrorModel struct { // Type is a URI to get more information about the error type. Type string `json:"type,omitempty" format:"uri" default:"about:blank" example:"https://example.com/errors/example" doc:"A URI reference to human-readable documentation for the error."` @@ -15,8 +39,7 @@ type ErrorModel struct { Detail string `json:"detail,omitempty" example:"Property foo is required but is missing." doc:"A human-readable explanation specific to this occurrence of the problem."` // Instance is a URI to get more info about this error occurence. Instance string `json:"instance,omitempty" format:"uri" example:"https://example.com/error-log/abc123" doc:"A URI reference that identifies the specific occurence of the problem."` - // Errors provides an optional mechanism of passing additional error detail - // strings as a list, which tends to display better than a large multi-line - // string with many errors. - Errors []string `json:"errors,omitempty" doc:"Optional list of individual error details"` + // Errors provides an optional mechanism of passing additional error details + // as a list. + Errors []*ErrorDetail `json:"errors,omitempty" doc:"Optional list of individual error details"` } diff --git a/error_test.go b/error_test.go new file mode 100644 index 00000000..a7748709 --- /dev/null +++ b/error_test.go @@ -0,0 +1,20 @@ +package huma + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestErrorDetailAsError(t *testing.T) { + d := ErrorDetail{ + Message: "foo", + Location: "bar", + Value: "baz", + } + + rendered := d.Error() + assert.Contains(t, rendered, "foo") + assert.Contains(t, rendered, "bar") + assert.Contains(t, rendered, "baz") +} diff --git a/examples/echo/echo.go b/examples/echo/echo.go new file mode 100644 index 00000000..d8f76847 --- /dev/null +++ b/examples/echo/echo.go @@ -0,0 +1,77 @@ +package main + +import ( + "net/http" + "strings" + + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/responses" +) + +// Standard middleware is supported, works with streaming. Useful for stuff +// like compression, request IDs, panic recovery/logging, etc. +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Request-ID", "abc123") + next.ServeHTTP(w, r) + }) +} + +// EchoRequest is the request input +type EchoRequest struct { + Word string `path:"word" doc:"The word to echo back. Cannot be 'test'."` + Greet bool `query:"greet" doc:"Return a greeting" default:"false"` + Foo int `query:"foo" enum:"1,3,5,7" doc:"..."` +} + +// Resolve does additional validation / transformation +func (e *EchoRequest) Resolve(ctx huma.Context, r *http.Request) { + // Extra validation for returning exhaustive errors. Huma handles returning + // the actual errors after resolving all request dependencies. + if e.Word == "test" { + ctx.AddError(&huma.ErrorDetail{ + Message: "disallowed word value", + Location: "path.word", + Value: e.Word, + }) + } + + // Post-processing of fields. You can also access the raw request for + // anything you can't model via tags. + e.Word = strings.ToLower(e.Word) +} + +// EchoResponse message which echoes a value. +type EchoResponse struct { + Value string `json:"value"` + Foo int `json:"foo,omitempty"` +} + +func main() { + app := cli.NewRouter("My API", "1.0.0") + + app.Middleware(requestIDMiddleware) + + app.Resource("/echo/{word}"). + //WithTags("echo-tag"). + Get("echo", "Echo back an input word", + responses.OK().Headers("Etag").Model(EchoResponse{}), + responses.BadRequest(), + ). + //WithDeadline(30 * time.Second). + Run(func(ctx huma.Context, input EchoRequest) { + v := input.Word + if input.Greet { + v = "Hello, " + v + } + + ctx.Header().Set("ETag", `W/"foo"`) + ctx.WriteModel(http.StatusOK, EchoResponse{ + Value: v, + Foo: input.Foo, + }) + }) + + app.Run() +} diff --git a/examples/echo/main.go b/examples/echo/main.go deleted file mode 100644 index d355d066..00000000 --- a/examples/echo/main.go +++ /dev/null @@ -1,38 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/istreamlabs/huma" -) - -// EchoResponse message which echoes a value. -type EchoResponse struct { - Value string `json:"value" description:"The echoed back word"` -} - -func main() { - r := huma.NewRouter("My API", "1.0.0") - - r.Resource("/echo", - huma.PathParam("word", "The word to echo back"), - huma.QueryParam("greet", "Return a greeting", false), - huma.ResponseError(http.StatusBadRequest, "Invalid input"), - huma.ResponseJSON(http.StatusOK, "Successful echo response"), - ).Put("Echo back an input word", - func(word string, greet bool) (*huma.ErrorModel, *EchoResponse) { - if word == "test" { - return &huma.ErrorModel{Detail: "Value not allowed: test"}, nil - } - - v := word - if greet { - v = "Hello, " + word - } - - return nil, &EchoResponse{Value: v} - }, - ) - - r.Run() -} diff --git a/examples/hello/main.go b/examples/hello/main.go deleted file mode 100644 index 34090e15..00000000 --- a/examples/hello/main.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "github.com/istreamlabs/huma" -) - -func main() { - // Create a new router and give our API a title and version. - r := huma.NewRouter("Hello API", "1.0.0") - - // Create the "hello" operation via `GET /hello`. - r.Resource("/hello").Get("Basic hello world", func() string { - return "Hello, world\n" - }) - - // Start the server on http://localhost:8888/ - r.Run() -} diff --git a/examples/minimal/minimal.go b/examples/minimal/minimal.go new file mode 100644 index 00000000..48dd77c3 --- /dev/null +++ b/examples/minimal/minimal.go @@ -0,0 +1,20 @@ +package main + +import ( + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/responses" +) + +func main() { + app := cli.NewRouter("Minimal Example", "1.0.0") + + app.Resource("/").Get("get-root", "Get a short text message", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Header().Set("Content-Type", "text/plain") + ctx.Write([]byte("Hello, world")) + }) + + app.Run() +} diff --git a/examples/notes/notes.go b/examples/notes/notes.go new file mode 100644 index 00000000..d96d0ca3 --- /dev/null +++ b/examples/notes/notes.go @@ -0,0 +1,103 @@ +package main + +import ( + "net/http" + "sync" + "time" + + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/middleware" + "github.com/istreamlabs/huma/responses" +) + +// NoteSummary is used to list notes. It does not include the (potentially) +// large note content. +type NoteSummary struct { + ID string `json:"id" doc:"Note ID"` + Created time.Time `json:"created" doc:"Created date/time as ISO8601"` +} + +// NoteIDParam gets the note ID from the URI path. +type NoteIDParam struct { + NoteID string `path:"note-id" pattern:"^[a-zA-Z0-9._-]{1,32}$"` +} + +// Note records some content text for later reference. +type Note struct { + Created time.Time `json:"created" readOnly:"true" doc:"Created date/time as ISO8601"` + Content string `json:"content" doc:"Note content"` +} + +// We'll use an in-memory DB (a goroutine-safe map). Don't do this in +// production code! +var memoryDB = sync.Map{} + +func main() { + // Create a new router and give our API a title and version. + app := cli.NewRouter("Notes API", "1.0.0") + app.ServerLink("Development server", "http://localhost:8888") + + notes := app.Resource("/v1/notes") + notes.Get("list-notes", "Returns a list of all notes", + responses.OK().Model([]*NoteSummary{}), + ).Run(func(ctx huma.Context) { + // Create a list of summaries from all the notes. + summaries := make([]*NoteSummary, 0) + + memoryDB.Range(func(k, v interface{}) bool { + summaries = append(summaries, &NoteSummary{ + ID: k.(string), + Created: v.(Note).Created, + }) + return true + }) + + ctx.WriteModel(http.StatusOK, summaries) + }) + + // Add an `id` path parameter to create a note resource. + note := notes.SubResource("/{note-id}") + + note.Put("put-note", "Create or update a note", + responses.NoContent(), + ).Run(func(ctx huma.Context, input struct { + NoteIDParam + Body Note + }) { + // Set the created time to now and then save the note in the DB. + input.Body.Created = time.Now() + middleware.GetLogger(ctx).Info("Creating a new note") + memoryDB.Store(input.NoteID, input.Body) + }) + + note.Get("get-note", "Get a note by its ID", + responses.OK().Model(Note{}), + responses.NotFound(), + ).Run(func(ctx huma.Context, input NoteIDParam) { + if n, ok := memoryDB.Load(input.NoteID); ok { + // Note with that ID exists! + ctx.WriteModel(http.StatusOK, n.(Note)) + return + } + + ctx.WriteError(http.StatusNotFound, "Note "+input.NoteID+" not found") + }) + + note.Delete("delete-note", "Delete a note by its ID", + responses.NoContent(), + responses.NotFound(), + ).Run(func(ctx huma.Context, input NoteIDParam) { + if _, ok := memoryDB.Load(input.NoteID); ok { + // Note with that ID exists! + memoryDB.Delete(input.NoteID) + ctx.WriteHeader(http.StatusNoContent) + return + } + + ctx.WriteError(http.StatusNotFound, "Note "+input.NoteID+" not found") + }) + + // Run the app! + app.Run() +} diff --git a/examples/readme/main.go b/examples/readme/main.go deleted file mode 100644 index b7fed54b..00000000 --- a/examples/readme/main.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "net/http" - "sync" - "time" - - "github.com/istreamlabs/huma" - "github.com/istreamlabs/huma/schema" -) - -// NoteSummary is used to list notes. It does not include the (potentially) -// large note content. -type NoteSummary struct { - ID string `json:"id" doc:"Note ID"` - Created time.Time `json:"created" doc:"Created date/time as ISO8601"` -} - -// Note records some content text for later reference. -type Note struct { - Created time.Time `json:"created" readOnly:"true" doc:"Created date/time as ISO8601"` - Content string `json:"content" doc:"Note content"` -} - -// We'll use an in-memory DB (a goroutine-safe map). Don't do this in -// production code! -var memoryDB = sync.Map{} - -func main() { - // Create a new router and give our API a title and version. - r := huma.NewRouter("Notes API", "1.0.0", - huma.DevServer("http://localhost:8888"), - ) - - notes := r.Resource("/v1/notes") - notes.List("Returns a list of all notes", func() []*NoteSummary { - // Create a list of summaries from all the notes. - summaries := make([]*NoteSummary, 0) - - memoryDB.Range(func(k, v interface{}) bool { - summaries = append(summaries, &NoteSummary{ - ID: k.(string), - Created: v.(*Note).Created, - }) - return true - }) - - return summaries - }) - - // Set up a custom schema to limit identifier values. - idSchema := schema.Schema{Pattern: "^[a-zA-Z0-9._-]{1,32}$"} - - // Add an `id` path parameter to create a note resource. - note := notes.With(huma.PathParam("id", "Note ID", huma.Schema(idSchema))) - - notFound := huma.ResponseError(http.StatusNotFound, "Note not found") - - note.Put("Create or update a note", func(id string, n *Note) bool { - // Set the created time to now and then save the note in the DB. - n.Created = time.Now() - memoryDB.Store(id, n) - - // Empty responses don't have a body, so you can just return `true`. - return true - }) - - note.With(notFound).Get("Get a note by its ID", - func(id string) (*huma.ErrorModel, *Note) { - if n, ok := memoryDB.Load(id); ok { - // Note with that ID exists! - return nil, n.(*Note) - } - - return &huma.ErrorModel{ - Detail: "Note " + id + " not found", - }, nil - }, - ) - - note.With(notFound).Delete("Delete a note by its ID", - func(id string) (*huma.ErrorModel, bool) { - if _, ok := memoryDB.Load(id); ok { - // Note with that ID exists! - memoryDB.Delete(id) - return nil, true - } - - return &huma.ErrorModel{ - Detail: "Note " + id + " not found", - }, false - }, - ) - - // Run the app! - r.Run() -} diff --git a/examples/store/main.go b/examples/store/main.go deleted file mode 100644 index 274e8dec..00000000 --- a/examples/store/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "time" - - "github.com/istreamlabs/huma" - "github.com/istreamlabs/huma/memstore" -) - -// Note represents a sticky note. -type Note struct { - ID string `json:"id" doc:"Note ID"` - Created time.Time `json:"created" readOnly:"true" doc:"Created date/time as ISO8601"` - Content string `json:"content" doc:"Note contents as Markdown"` -} - -// OnCreate is called before a new Note gets created and stored. -func (n *Note) OnCreate() { - n.Created = time.Now().UTC() -} - -func main() { - r := huma.NewRouter("Adapter API", "1.0.0") - - store := memstore.New() - store.AutoResource(r.Resource("/notes"), &Note{}) - - r.Run() -} diff --git a/examples/test/main.go b/examples/test/main.go deleted file mode 100644 index fea87edd..00000000 --- a/examples/test/main.go +++ /dev/null @@ -1,21 +0,0 @@ -package main - -import "github.com/istreamlabs/huma" - -func routes(r *huma.Router) { - // Register a single test route that returns a text/plain response. - r.Resource("/test").Get("Test route", func() string { - return "Hello, test!" - }) -} - -func main() { - // Create the router. - r := huma.NewRouter("Test", "1.0.0") - - // Register routes. - routes(r) - - // Run the service. - r.Run() -} diff --git a/examples/test/service.go b/examples/test/service.go new file mode 100644 index 00000000..6f9472e9 --- /dev/null +++ b/examples/test/service.go @@ -0,0 +1,27 @@ +package main + +import ( + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/responses" +) + +func routes(r *huma.Router) { + // Register a single test route that returns a text/plain response. + r.Resource("/test").Get("test", "Test route", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Write([]byte("Hello, test!")) + }) +} + +func main() { + // Create the router. + app := cli.NewRouter("Test", "1.0.0") + + // Register routes. + routes(app.Router) + + // Run the service. + app.Run() +} diff --git a/examples/test/main_test.go b/examples/test/service_test.go similarity index 100% rename from examples/test/main_test.go rename to examples/test/service_test.go diff --git a/examples/timeout/main.go b/examples/timeout/timeout.go similarity index 50% rename from examples/timeout/main.go rename to examples/timeout/timeout.go index 7c4f75b6..93c22f3e 100644 --- a/examples/timeout/main.go +++ b/examples/timeout/timeout.go @@ -6,15 +6,19 @@ import ( "time" "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/cli" + "github.com/istreamlabs/huma/responses" ) func main() { - r := huma.NewRouter("Timeout Example", "1.0.0") - - r.Resource("/timeout", - huma.ContextDependency(), - ).Get("timeout example", func(ctx context.Context) string { - // Add a timeout to the context. No request should take longer than 2 seconds + app := cli.NewRouter("Timeout Example", "1.0.0") + + app.Resource("/timeout").Get("timeout", "Timeout example", + responses.String(http.StatusOK), + responses.InternalServerError(), + ).Run(func(ctx huma.Context) { + // Add a timeout to the context. No outgoing request should take longer + // than 2 seconds or we abort. newCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() @@ -26,11 +30,13 @@ func main() { // deadline of 2 seconds is shorter than the request duration of 5 seconds. _, err := http.DefaultClient.Do(req) if err != nil { - return err.Error() + ctx.WriteError(http.StatusInternalServerError, "Problem with HTTP request", err) + return } - return "success" + // Success case, which we never get to. + ctx.Write([]byte("success!")) }) - r.Run() + app.Run() } diff --git a/examples/unsafe/main.go b/examples/unsafe/main.go deleted file mode 100644 index 4df65675..00000000 --- a/examples/unsafe/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "net/http" - "reflect" - - "github.com/istreamlabs/huma" - "github.com/istreamlabs/huma/schema" -) - -// Item stores some value. -type Item struct { - ID string `json:"id"` - Value int32 `json:"value"` -} - -func main() { - r := huma.NewRouter("Unsafe Test", "1.0.0") - - // Generate response schema for docs. - s, _ := schema.Generate(reflect.TypeOf(Item{})) - - r.Resource("/unsafe", - huma.PathParam("id", "desc"), - huma.ResponseJSON(http.StatusOK, "doc", huma.Schema(*s)), - ).Get("doc", huma.UnsafeHandler(func(inputs ...interface{}) []interface{} { - // Get the ID, which is the first input and will be a string since it's - // a path parameter. - id := inputs[0].(string) - - // Return an item with the passed in ID. - return []interface{}{&Item{ - ID: id, - Value: 123, - }} - })) - - r.Run() -} diff --git a/go.mod b/go.mod index 42db27e1..45fdc8db 100644 --- a/go.mod +++ b/go.mod @@ -3,26 +3,29 @@ module github.com/istreamlabs/huma go 1.13 require ( - github.com/Jeffail/gabs v1.4.0 + github.com/Jeffail/gabs/v2 v2.6.0 github.com/andybalholm/brotli v1.0.0 - github.com/fatih/structs v1.1.0 + github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/fxamacker/cbor v1.5.1 github.com/fxamacker/cbor/v2 v2.2.0 - github.com/getkin/kin-openapi v0.3.0 - github.com/gin-contrib/cors v1.3.1 - github.com/gin-gonic/autotls v0.0.0-20200314141124-cc69476aef2a - github.com/gin-gonic/gin v1.5.0 - github.com/gosimple/slug v1.9.0 - github.com/labstack/echo/v4 v4.1.15 + github.com/go-chi/chi v4.1.2+incompatible + github.com/goccy/go-yaml v1.8.1 + github.com/magiconair/properties v1.8.2 // indirect github.com/mattn/go-isatty v0.0.12 - github.com/mitchellh/copystructure v1.0.0 - github.com/mitchellh/mapstructure v1.1.2 - github.com/pelletier/go-toml v1.4.0 // indirect - github.com/pkg/errors v0.8.1 // indirect - github.com/spf13/cobra v0.0.6 - github.com/spf13/viper v1.4.0 + github.com/mitchellh/mapstructure v1.3.3 // indirect + github.com/opentracing/opentracing-go v1.2.0 + github.com/pelletier/go-toml v1.8.0 // indirect + github.com/spf13/afero v1.3.4 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/cobra v1.0.0 + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.7.1 github.com/stretchr/testify v1.5.1 + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonschema v1.2.0 - go.uber.org/zap v1.10.0 + go.uber.org/zap v1.15.0 + golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/yaml.v2 v2.2.8 + gopkg.in/ini.v1 v1.60.1 // indirect ) diff --git a/go.sum b/go.sum index 8330a8c7..003d18dd 100644 --- a/go.sum +++ b/go.sum @@ -1,135 +1,192 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Jeffail/gabs v1.4.0 h1://5fYRRTq1edjfIrQGvdkcd22pkYUrHZ5YC/H2GJVAo= -github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Jeffail/gabs/v2 v2.6.0 h1:WdCnGaDhNa4LSRTMwhLZzJ7SRDXjABNP13SOKvCpL5w= +github.com/Jeffail/gabs/v2 v2.6.0/go.mod h1:xCn81vdHKxFUuWWAaD5jCTQDNPBMh5pPs9IJ+NcziBI= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/istreamlabs/huma v0.0.0-20200929212356-8ae23b5a4469 h1:o17kcs/XYtsFR6AjbVFZrJPkQcm3Ky0sQD1rfNiJo00= +github.com/istreamlabs/huma v0.0.0-20200929212356-8ae23b5a4469/go.mod h1:Kkc78WiMWoAh8JNES7zjd4S8J4m7ky7aSWo5m80tCCw= 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/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor v1.5.1 h1:XjQWBgdmQyqimslUh5r4tUGmoqzHmBFQOImkWGi2awg= +github.com/fxamacker/cbor v1.5.1/go.mod h1:3aPGItF174ni7dDzd6JZ206H8cmr4GDNBGpPa971zsU= github.com/fxamacker/cbor/v2 v2.2.0 h1:6eXqdDDe588rSYAi1HfZKbx6YYQO4mxQ9eC6xYpU/JQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= -github.com/getkin/kin-openapi v0.3.0 h1:xsQ4mA20YJDMgIHdHqMKZ66QUe6/hi+x6yLsTTz8xyQ= -github.com/getkin/kin-openapi v0.3.0/go.mod h1:W8dhxZgpE84ciM+VIItFqkmZ4eHtuomrdIHtASQIqi0= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/cors v1.3.1 h1:doAsuITavI4IOcd0Y19U4B+O0dNWihRyX//nn4sEmgA= -github.com/gin-contrib/cors v1.3.1/go.mod h1:jjEJ4268OPZUcU7k9Pm653S7lXUGcqMADzFA61xsmDk= -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/autotls v0.0.0-20200314141124-cc69476aef2a h1:xdM4UWm9hrdOPTMTTXRNDAsln3LdXr0Ry1XRFUpbpjc= -github.com/gin-gonic/autotls v0.0.0-20200314141124-cc69476aef2a/go.mod h1:GTnUDNd5zRw/BceSPHICHoH9fNaTSPHsFTU72wuK0YE= -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-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= -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/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/goccy/go-yaml v1.8.1 h1:JuZRFlqLM5cWF6A+waL8AKVuCcqvKOuhJtUQI+L3ez0= +github.com/goccy/go-yaml v1.8.1/go.mod h1:wS4gNoLalDSJxo/SpngzPQ2BN4uuZVLCmbM4S3vd4+Y= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -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/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= -github.com/gosimple/slug v1.9.0 h1:r5vDcYrFz9BmfIAMC829un9hq7hKM4cHUrsv36LbEqs= -github.com/gosimple/slug v1.9.0/go.mod h1:AMZ+sOVe65uByN3kgEyf9WEBKBCSS+dJjMX9x4vDJbg= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= -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/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/labstack/echo/v4 v4.1.15 h1:4aE6KfJC+wCnMjODwcpeEGWGsRfszxZMwB3QVTECj2I= -github.com/labstack/echo/v4 v4.1.15/go.mod h1:GWO5IBVzI371K8XJe50CSvHjQCafK6cw8R/moLhEU6o= -github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= -github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= -github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= -github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.2 h1:znVR8Q4g7/WlcvsxLBRWvo+vtFJUAbDn3w+Yak2xVMI= +github.com/magiconair/properties v1.8.2/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -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/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/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/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= -github.com/pelletier/go-toml v1.4.0 h1:u3Z1r+oOXJIkxqw34zVhyPgjBsm6X2wn21NWs/HfSeg= -github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo= +github.com/pelletier/go-toml v1.8.0 h1:Keo9qb7iRJs2voHvunFtuuYFsbWeOBh8/P9v/kVMFtw= +github.com/pelletier/go-toml v1.8.0/go.mod h1:D6yutnOGMveHEPV7VQOuvI/gXY61bv+9bAOTRnLElKs= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= @@ -139,50 +196,59 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= -github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.3.4 h1:8q6vk3hthlpb2SouZcnBVKboxWQWMDNF38bwholZrJc= +github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs= -github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= +github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng= +github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= +github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= +github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= 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/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 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/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4= -github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 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/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/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= @@ -190,71 +256,171 @@ github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQ github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= +go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw= -golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc h1:NCy3Ohtk6Iny5V/reW2Ktypo4zIpWBdRJ1uFMjBxdg8= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 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/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 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/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.60.1 h1:P5y5shSkb0CFe44qEeMBgn8JLow09MP17jlJHanke5g= +gopkg.in/ini.v1 v1.60.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/huma_readme_test.go b/huma_readme_test.go deleted file mode 100644 index 19a5f1d8..00000000 --- a/huma_readme_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package huma_test - -import ( - "net/http" - "sync" - "time" - - "github.com/istreamlabs/huma" - "github.com/istreamlabs/huma/schema" -) - -// NoteSummary is used to list notes. It does not include the (potentially) -// large note content. -type NoteSummary struct { - ID string - Created time.Time -} - -// Note records some content text for later reference. -type Note struct { - Created time.Time `readOnly:"true"` - Content string -} - -// We'll use an in-memory DB (a goroutine-safe map). Don't do this in -// production code! -var memoryDB = sync.Map{} - -func Example_readme() { - // Create a new router and give our API a title and version. - r := huma.NewRouter("Notes API", "1.0.0") - - notes := r.Resource("/notes") - notes.List("Returns a list of all notes", func() []*NoteSummary { - // Create a list of summaries from all the notes. - summaries := make([]*NoteSummary, 0) - - memoryDB.Range(func(k, v interface{}) bool { - summaries = append(summaries, &NoteSummary{ - ID: k.(string), - Created: v.(*Note).Created, - }) - return true - }) - - return summaries - }) - - // Set up a custom schema to limit identifier values. - idSchema := schema.Schema{Pattern: "^[a-zA-Z0-9._-]{1,32}$"} - - // Add an `id` path parameter to create a note resource. - note := notes.With(huma.PathParam("id", "Note ID", huma.Schema(idSchema))) - - notFound := huma.ResponseError(http.StatusNotFound, "Note not found") - - note.Put("Create or update a note", func(id string, n *Note) bool { - // Set the created time to now and then save the note in the DB. - n.Created = time.Now() - memoryDB.Store(id, n) - - // Empty responses don't have a body, so you can just return `true`. - return true - }) - - note.With(notFound).Get("Get a note by its ID", - func(id string) (*huma.ErrorModel, *Note) { - if n, ok := memoryDB.Load(id); ok { - // Note with that ID exists! - return nil, n.(*Note) - } - - return &huma.ErrorModel{ - Detail: "Note " + id + " not found", - }, nil - }, - ) - - note.With(notFound).Delete("Delete a note by its ID", - func(id string) (*huma.ErrorModel, bool) { - if _, ok := memoryDB.Load(id); ok { - // Note with that ID exists! - memoryDB.Delete(id) - return nil, true - } - - return &huma.ErrorModel{ - Detail: "Note " + id + " not found", - }, false - }, - ) - - // Run the app! - r.Run() -} diff --git a/humatest/humatest.go b/humatest/humatest.go index f714dffd..56e4efeb 100644 --- a/humatest/humatest.go +++ b/humatest/humatest.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/istreamlabs/huma" - "github.com/gin-gonic/gin" + "github.com/istreamlabs/huma/middleware" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest" @@ -16,22 +16,23 @@ import ( // NewRouter creates a new test router. It includes a logger attached to the // test so if it fails you will see additional output. There is no recovery // middleware so panics will get caught by the test runner. -func NewRouter(t testing.TB, options ...huma.RouterOption) *huma.Router { - r, _ := NewRouterObserver(t, options...) +func NewRouter(t testing.TB) *huma.Router { + r, _ := NewRouterObserver(t) return r } // NewRouterObserver creates a new router and a log output observer for testing // log output at "debug" level and above during requests. -func NewRouterObserver(t testing.TB, options ...huma.RouterOption) (*huma.Router, *observer.ObservedLogs) { +func NewRouterObserver(t testing.TB) (*huma.Router, *observer.ObservedLogs) { core, logs := observer.New(zapcore.DebugLevel) - l := zaptest.NewLogger(t, zaptest.WrapOptions(zap.WrapCore(func(zapcore.Core) zapcore.Core { return core }))) - g := gin.New() - g.Use(huma.LogMiddleware(huma.Logger(l))) + router := huma.New("Test API", "1.0.0") + router.Middleware(middleware.DefaultChain) - // Passed-in options may override our custom Gin instance. - options = append([]huma.RouterOption{huma.Gin(g)}, options...) + middleware.NewLogger = func() (*zap.Logger, error) { + l := zaptest.NewLogger(t, zaptest.WrapOptions(zap.WrapCore(func(zapcore.Core) zapcore.Core { return core }))) + return l, nil + } - return huma.NewRouter("Test API", "1.0.0", options...), logs + return router, logs } diff --git a/humatest/humatest_test.go b/humatest/humatest_test.go index 21b63f4a..bdb92a94 100644 --- a/humatest/humatest_test.go +++ b/humatest/humatest_test.go @@ -7,6 +7,7 @@ import ( "github.com/istreamlabs/huma" "github.com/istreamlabs/huma/humatest" + "github.com/istreamlabs/huma/responses" "github.com/stretchr/testify/assert" ) @@ -18,8 +19,10 @@ func Example() { r := humatest.NewRouter(t) // Set up routes & handlers. - r.Resource("/test").Get("Test get", func() string { - return "Hello, test!" + r.Resource("/test").Get("test", "Test get", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Write([]byte("Hello, test!")) }) // Make a test request. @@ -30,7 +33,21 @@ func Example() { assert.Equal(t, "Hello, test!", w.Body.String()) } -func TestNewRouter(t *testing.T) { - // Should not panic - humatest.NewRouter(t, huma.DevServer("http://localhost:8888")) +func TestPackage(t *testing.T) { + // Create the test router. Logs will be hidden unless the test fails. + r := humatest.NewRouter(t) + + // Set up routes & handlers. + r.Resource("/test").Get("test", "Test get", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Write([]byte("Hello, test!")) + }) + + // Make a test request. + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Hello, test!", w.Body.String()) } diff --git a/memstore/memstore.go b/memstore/memstore.go deleted file mode 100644 index 59e579b9..00000000 --- a/memstore/memstore.go +++ /dev/null @@ -1,449 +0,0 @@ -// Package memstore provides an in-memory data store for auto-resources. -package memstore - -import ( - "crypto/sha1" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "reflect" - "strings" - "sync" - "time" - - "github.com/istreamlabs/huma" - "github.com/istreamlabs/huma/schema" - "github.com/fatih/structs" - "github.com/gosimple/slug" - "github.com/mitchellh/copystructure" - "github.com/mitchellh/mapstructure" -) - -var ( - // ErrAutoResourceInvalid is used when the resource input is invalid. - ErrAutoResourceInvalid = errors.New("AutoResource invalid") -) - -type createHook interface { - OnCreate() -} - -func fillMap(i interface{}, m map[string]interface{}) { - s := structs.New(i) - s.TagName = "json" - s.FillMap(m) -} - -func etagValue(etag string) string { - etag = strings.Trim(etag, " ") - etag = strings.TrimPrefix(etag, "W/") - etag = strings.Trim(etag, `"`) - return etag -} - -// etagCompare returns true if the current ETag matches any in the values set. -// values should be input from a header like `If-Match` and looks like -// `W/"abc123", "def456", ...`. -func etagCompare(values, current string) bool { - for _, check := range strings.Split(values, ",") { - if etagValue(check) == current { - return true - } - } - return false -} - -func checkConditionals(existing map[string]interface{}, ifMatch, ifNoneMatch string, ifUnmodified, ifModified time.Time) error { - existingETag := "" - if existing != nil { - existingETag = existing["etag"].(string) - } - - if ifMatch != "" { - if existing == nil { - return fmt.Errorf("No existing item to compare ETags") - } - - if ifMatch != "*" && !etagCompare(ifMatch, existingETag) { - return fmt.Errorf("ETag %s did not match: %s", existingETag, ifMatch) - } - } - - if ifNoneMatch != "" { - if existing != nil { - if etagValue(ifNoneMatch) == "*" { - return fmt.Errorf("Item already exists with ETag %s", existingETag) - } - - if etagCompare(ifNoneMatch, existingETag) { - return fmt.Errorf("ETag %s matched: %s", existingETag, ifNoneMatch) - } - } - } - - // ETag checks replace date checks if present, see: - // https://tools.ietf.org/html/rfc7232#section-3.4 - if ifMatch == "" && ifNoneMatch == "" && !ifUnmodified.IsZero() { - if existing == nil { - return fmt.Errorf("No existing item to compare modification date") - } - - modified := existing["modified"].(time.Time) - if modified.After(ifUnmodified) { - return fmt.Errorf("Item modified after %v: last-modified at %v", ifUnmodified, modified) - } - } - - if ifMatch == "" && ifNoneMatch == "" && !ifModified.IsZero() { - modified := existing["modified"].(time.Time) - if modified.Before(ifModified) { - return fmt.Errorf("Item not modified since %s: last-modified: %s", ifModified, modified) - } - } - - return nil -} - -type config struct { - single string - plural string -} - -// Option sets an option for the auto-resource. -type Option func(*config) - -// Name manually sets the single and plural variants of the resource name. If -// not passed, the names are generated from the path. -func Name(single, plural string) Option { - return func(c *config) { - c.single = single - c.plural = plural - } -} - -// New creates a new memory store. -func New() *MemoryStore { - return &MemoryStore{} -} - -// MemoryStore uses an in-memory data store to create automatic CRUD operation -// handlers based on a Go struct. -type MemoryStore struct { - // db is a goroutine-safe map where the keys are the collection names and - // the values are another map of id => stored item. - db sync.Map - - // mu is a datastore-global lock used when atomic operations aren't available, - // such as when you need to read, check, then write. - mu sync.Mutex -} - -// collection returns the goroutine-safe map for the given collection name. -func (m *MemoryStore) collection(name string) *sync.Map { - c, _ := m.db.LoadOrStore(name, &sync.Map{}) - return c.(*sync.Map) -} - -// validate panics if the input struct is invalid. -func (m *MemoryStore) validate(typ reflect.Type) { - if typ.Kind() != reflect.Struct { - panic(fmt.Errorf("must use struct but got %s: %w", typ, ErrAutoResourceInvalid)) - } - - if id, ok := typ.FieldByName("ID"); !ok { - panic(fmt.Errorf("ID field required: %w", ErrAutoResourceInvalid)) - } else if id.Type.Kind() != reflect.String { - panic(fmt.Errorf("ID must be string, but got %v: %w", id.Type, ErrAutoResourceInvalid)) - } else { - if t, ok := id.Tag.Lookup("json"); !ok { - panic(fmt.Errorf("ID missing json tag: %w", ErrAutoResourceInvalid)) - } else if t != "id" { - panic(fmt.Errorf("ID must marshal as `id` but got %s: %w", t, ErrAutoResourceInvalid)) - } - } -} - -// AutoResource creates a new resource backed by this memory store. By default, -// this method will generate CRUD-style operations agains the given data -// structure. The data structure must have an ID string field that marshals to -// an `id` JSON property. -func (m *MemoryStore) AutoResource(r *huma.Resource, dataStructure interface{}, options ...Option) *huma.Resource { - // Validate input structure for ID and other fields - typ := reflect.TypeOf(dataStructure) - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - } - - m.validate(typ) - - // Setup defaults and then process any passed options. - parts := strings.Split(r.Path(), "/") - name := strings.ToLower(parts[len(parts)-1]) - cfg := &config{ - single: strings.TrimRight(name, "s"), - plural: name, - } - - for _, option := range options { - option(cfg) - } - - // Figure out default summary fields - // TODO... get from struct tags or via passed option? For now default to - // the most useful fields to identify the object. - summaryFields := []string{"id", "modified", "etag"} - - // Collection name is based on the current path. - collectionName := slug.Make(r.Path()) - - // Get a schema from the internal model (struct). - rs, _ := schema.GenerateWithMode(typ, schema.ModeRead, nil) - ws, _ := schema.GenerateWithMode(typ, schema.ModeWrite, nil) - - // Remove any existing path params from the external model. These will still - // get set in the data store and hooks can read them, but we don't send - // them over the wire. - for _, p := range r.PathParams() { - rs.RemoveProperty(p) - ws.RemoveProperty(p) - } - - li, _ := copystructure.Copy(rs) - listItem := li.(*schema.Schema) - - propModified := false - propETag := false - for name := range listItem.Properties { - switch name { - case "modified": - propModified = true - case "etag": - propETag = true - } - } - - if !propModified { - listItem.Properties["modified"] = &schema.Schema{ - Type: "string", - Format: "date-time", - Description: "Last modified datetime as ISO8601", - } - } - - if !propETag { - listItem.Properties["etag"] = &schema.Schema{ - Type: "string", - Description: "Computed content hash", - } - } - - // TODO: recursively make everything optional - listItem.Required = []string{} - - r.With( - // TODO: filters? - huma.QueryParam("fields", "List of fields to include", summaryFields), - // TODO: pagination - huma.ResponseJSON(http.StatusOK, "Success", huma.Schema(schema.Schema{ - Type: "array", - Items: listItem, - })), - ).List("List "+cfg.plural, huma.UnsafeHandler(func(inputs ...interface{}) []interface{} { - i := len(r.PathParams()) - fields := inputs[i].([]string) - - items := make([]interface{}, 0) - c := m.collection(collectionName) - - c.Range(func(key, value interface{}) bool { - item := make(map[string]interface{}) - - // TODO: JSON-path style field selection for nested objects - for _, field := range fields { - item[field] = value.(map[string]interface{})[field] - } - - items = append(items, item) - return true - }) - - return []interface{}{items} - })) - - item := r.With(huma.PathParam(cfg.single+"Id", "Item ID")) - rs.RemoveProperty("id") - ws.RemoveProperty("id") - - item.With( - huma.HeaderParam("if-none-match", "ETag-based conditional get", ""), - huma.HeaderParam("if-modified-since", "Only return if modified", time.Time{}), - huma.ResponseHeader("last-modified", "Last modified date/time"), - huma.ResponseHeader("etag", "Etag content hash"), - huma.ResponseError(http.StatusNotFound, "Not found"), - huma.Response(http.StatusNotModified, "Not modified", huma.Headers("last-modified", "etag")), - huma.ResponseJSON(http.StatusOK, "Success", huma.Schema(*rs), huma.Headers("last-modified", "etag")), - ).Get("Get a "+cfg.single, huma.UnsafeHandler(func(inputs ...interface{}) []interface{} { - i := len(r.PathParams()) - id := inputs[i].(string) - ifNoneMatch := inputs[i+1].(string) - ifModified := inputs[i+2].(time.Time) - - var lastModified string - var etag string - var error404 *huma.ErrorModel - var notModified bool - var item interface{} - - c := m.collection(collectionName) - - // TODO: compute composite ID from any previous path params. - loaded, ok := c.Load(id) - if !ok { - error404 = &huma.ErrorModel{ - Detail: cfg.single + " not found with ID: " + id, - } - return []interface{}{lastModified, etag, error404, notModified, item} - } - - // Copy the data to return (except id/headers) - itemMap := loaded.(map[string]interface{}) - - // Set headers - lastModified = itemMap["modified"].(time.Time).Format(http.TimeFormat) - etag = `W/"` + itemMap["etag"].(string) + `"` - - if err := checkConditionals(itemMap, "", ifNoneMatch, time.Time{}, ifModified); err != nil { - notModified = true - return []interface{}{lastModified, etag, error404, notModified, item} - } - - // Create the response structure. - ret := make(map[string]interface{}) - for k, v := range itemMap { - if k == "id" || k == "modified" || k == "etag" { - continue - } - ret[k] = v - } - item = ret - - return []interface{}{lastModified, etag, error404, notModified, item} - })) - - item.With( - huma.HeaderParam("if-match", "Etag-based conditional update", ""), - huma.HeaderParam("if-none-match", "Etag-based conditional update. Use `*` to mean any possible value.", ""), - huma.HeaderParam("if-unmodified-since", "Date-based conditional update", time.Time{}), - huma.RequestSchema(ws), - huma.ResponseHeader("last-modified", "Last modified date/time"), - huma.ResponseHeader("etag", "Etag content hash"), - huma.ResponseJSON(http.StatusPreconditionFailed, "Conditional update failed"), - huma.Response(http.StatusNoContent, "Success", huma.Headers("last-modified", "etag")), - ).Put("Create or update "+cfg.single, huma.UnsafeHandler(func(inputs ...interface{}) []interface{} { - i := len(r.PathParams()) - id := inputs[i].(string) - ifMatch := inputs[i+1].(string) - ifNoneMatch := inputs[i+2].(string) - ifUnmodified := inputs[i+3].(time.Time) - item := inputs[i+4].(map[string]interface{}) - - var lastModified string - var etag string - var errorPrecondition *huma.ErrorModel - var success bool - - if _, ok := dataStructure.(createHook); ok { - t := reflect.TypeOf(dataStructure) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - tmp := reflect.New(t).Interface() - mapstructure.Decode(item, &tmp) - tmp.(createHook).OnCreate() - fillMap(tmp, item) - } - - c := m.collection(collectionName) - - if ifMatch != "" || ifNoneMatch != "" || !ifUnmodified.IsZero() { - m.mu.Lock() - defer m.mu.Unlock() - var existing map[string]interface{} - if e, ok := c.Load(id); ok { - existing = e.(map[string]interface{}) - } - if err := checkConditionals(existing, ifMatch, ifNoneMatch, ifUnmodified, time.Time{}); err != nil { - errorPrecondition = &huma.ErrorModel{ - Detail: err.Error(), - } - return []interface{}{lastModified, etag, errorPrecondition, success} - } - } - - // Set the ID from the path parameter - item["id"] = id - - // Generate an etag hash. - encoded, _ := json.Marshal(item) - sum := sha1.Sum(encoded) - newETag := base64.RawURLEncoding.EncodeToString(sum[:]) - - // Update modified time & etag hash - item["modified"] = time.Now().UTC() - item["etag"] = newETag - - c.Store(id, item) - - lastModified = item["modified"].(time.Time).Format(http.TimeFormat) - etag = `W/"` + newETag + `"` - success = true - return []interface{}{lastModified, etag, errorPrecondition, success} - })) - - // TODO: patch - - item.With( - huma.HeaderParam("if-match", "Etag-based conditional update header", ""), - huma.HeaderParam("if-unmodified-since", "Date-based conditional update", time.Time{}), - huma.ResponseError(http.StatusNotFound, cfg.single+" not found"), - huma.ResponseError(http.StatusPreconditionFailed, "Conditional delete failed"), - huma.Response(http.StatusNoContent, "Success"), - ).Delete("Delete a "+cfg.single, huma.UnsafeHandler(func(inputs ...interface{}) []interface{} { - i := len(r.PathParams()) - id := inputs[i].(string) - ifMatch := inputs[i+1].(string) - ifUnmodified := inputs[i+2].(time.Time) - - var error404 *huma.ErrorModel - var errorEtag *huma.ErrorModel - var success bool - - c := m.collection(collectionName) - - m.mu.Lock() - defer m.mu.Unlock() - item, ok := c.Load(id) - - if !ok { - error404 = &huma.ErrorModel{ - Detail: fmt.Sprintf("%s not found: %s", cfg.single, id), - } - return []interface{}{error404, errorEtag, success} - } - - if err := checkConditionals(item.(map[string]interface{}), ifMatch, "", ifUnmodified, time.Time{}); err != nil { - errorEtag = &huma.ErrorModel{ - Detail: err.Error(), - } - return []interface{}{error404, errorEtag, success} - } - - c.Delete(id) - success = true - return []interface{}{error404, errorEtag, success} - })) - - return item -} diff --git a/memstore/memstore_test.go b/memstore/memstore_test.go deleted file mode 100644 index c6be4c79..00000000 --- a/memstore/memstore_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package memstore - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/istreamlabs/huma/humatest" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -func init() { - gin.SetMode(gin.ReleaseMode) -} - -func TestAutoBadType(t *testing.T) { - r := humatest.NewRouter(t).Resource("/tests") - store := New() - - assert.Panics(t, func() { - store.AutoResource(r, "whoops") - }) -} - -func TestAutoMissingID(t *testing.T) { - type Data struct { - Content string `json:"content"` - } - - r := humatest.NewRouter(t).Resource("/tests") - store := New() - - assert.Panics(t, func() { - store.AutoResource(r, Data{}) - }) -} - -func TestAutoIDType(t *testing.T) { - type Data struct { - ID int32 `json:"id"` - } - - r := humatest.NewRouter(t).Resource("/tests") - store := New() - - assert.Panics(t, func() { - store.AutoResource(r, Data{}) - }) -} - -func TestAutoIDMissingJSON(t *testing.T) { - type Data struct { - ID string - } - - r := humatest.NewRouter(t).Resource("/tests") - store := New() - - assert.Panics(t, func() { - store.AutoResource(r, Data{}) - }) -} - -func TestAutoIDJSONTag(t *testing.T) { - type Data struct { - ID string `json:"other"` - } - - r := humatest.NewRouter(t).Resource("/tests") - store := New() - - assert.Panics(t, func() { - store.AutoResource(r, Data{}) - }) -} - -func TestPointer(t *testing.T) { - type Data struct { - ID string `json:"id"` - } - - r := humatest.NewRouter(t) - store := New() - - // Should not panic - store.AutoResource(r.Resource("/tests"), &Data{}) -} - -func TestRename(t *testing.T) { - type Data struct { - ID string `json:"id"` - } - - r := humatest.NewRouter(t) - store := New() - - // Should not panic - store.AutoResource(r.Resource("/tests"), Data{}, - Name("item", "items"), - ) - - // TODO: check OpenAPI naming -} - -type Data struct { - ID string `json:"id"` - Created time.Time `json:"created" readOnly:"true"` - Content string `json:"content"` -} - -func (d *Data) OnCreate() { - d.Created = time.Now().UTC() -} - -func TestAuto(t *testing.T) { - r := humatest.NewRouter(t) - store := New() - - store.AutoResource(r.Resource("/tests"), &Data{}) - - // Create some items - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPut, "/tests/test1", strings.NewReader(`{"content": "test1"}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodPut, "/tests/test2", strings.NewReader(`{"content": "test2"}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) - - // List all items - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/tests", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - result := []interface{}{} - err := json.Unmarshal(w.Body.Bytes(), &result) - assert.NoError(t, err) - assert.Len(t, result, 2) - - // Update an item - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodPut, "/tests/test1", strings.NewReader(`{"content": "updated"}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) - - // Get with bad ID - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/tests/invalid", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNotFound, w.Code) - - // Get an item - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/tests/test1", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - item := map[string]interface{}{} - err = json.Unmarshal(w.Body.Bytes(), &item) - assert.NoError(t, err) - assert.Equal(t, "updated", item["content"]) - etag := w.Header().Get("etag") - - // TODO: conditional update failure? - - // Delete with invalid ID - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodDelete, "/tests/invalid", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNotFound, w.Code) - - // Delete with incorrect ETag - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodDelete, "/tests/test1", nil) - req.Header.Add("if-match", `"bad-value"`) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusPreconditionFailed, w.Code) - - // Delete with correct ETag - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodDelete, "/tests/test1", nil) - req.Header.Add("if-match", etag) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) - - // Unconditional delete - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodDelete, "/tests/test2", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) -} diff --git a/middleware.go b/middleware.go deleted file mode 100644 index 8c86e9d0..00000000 --- a/middleware.go +++ /dev/null @@ -1,520 +0,0 @@ -package huma - -import ( - "bytes" - "compress/gzip" - "fmt" - "io" - "io/ioutil" - "net/http" - "net/http/httputil" - "os" - "path" - "runtime/debug" - "strconv" - "strings" - "sync" - "time" - - "github.com/andybalholm/brotli" - "github.com/gin-gonic/gin" - "github.com/mattn/go-isatty" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" -) - -var logLevel *zap.AtomicLevel - -// MaxLogBodyBytes logs at most this many bytes of any request body during a -// panic when using the recovery middleware. Defaults to 10KiB. -var MaxLogBodyBytes int64 = 10 * 1024 - -// Middleware is a type alias used to group Gin middleware functions. -type Middleware = gin.HandlerFunc - -// Handler is a type alias used to group Gin handler functions. -type Handler = gin.HandlerFunc - -// bufferedReadCloser will read and buffer up to max bytes into buf. Additional -// reads bypass the buffer. -type bufferedReadCloser struct { - reader io.ReadCloser - buf *bytes.Buffer - max int64 -} - -// newBufferedReadCloser returns a new BufferedReadCloser that wraps reader -// and reads up to max bytes into the buffer. -func newBufferedReadCloser(reader io.ReadCloser, buffer *bytes.Buffer, max int64) *bufferedReadCloser { - return &bufferedReadCloser{ - reader: reader, - buf: buffer, - max: max, - } -} - -// Read data into p. Returns number of bytes read and an error, if any. -func (r *bufferedReadCloser) Read(p []byte) (n int, err error) { - // Read from the underlying reader like normal. - n, err = r.reader.Read(p) - - // If buffer isn't full, add to it. - length := int64(r.buf.Len()) - if length < r.max { - if length+int64(n) < r.max { - r.buf.Write(p[:n]) - } else { - r.buf.Write(p[:int64(n)-(r.max-length)]) - } - } - - return -} - -// Close the underlying reader. -func (r *bufferedReadCloser) Close() error { - return r.reader.Close() -} - -// Recovery prints stack traces on panic when used with the logging middleware. -func Recovery() Middleware { - bufPool := sync.Pool{ - New: func() interface{} { - return new(bytes.Buffer) - }, - } - - return func(c *gin.Context) { - var buf *bytes.Buffer - - // Reset the body so other middleware/handlers can use it. - if c.Request.Body != nil { - // Get a buffer that the body will be read into. - buf = bufPool.Get().(*bytes.Buffer) - defer bufPool.Put(buf) - - c.Request.Body = newBufferedReadCloser(c.Request.Body, buf, MaxLogBodyBytes) - } - - // Recovering comes *after* the above so the buffer is not returned to - // the pool until after we print out its contents. - defer func() { - if err := recover(); err != nil { - // The body might have been read or partially read, so replace it - // with a clean reader to dump out up to maxBodyBytes with the error. - if buf != nil && buf.Len() != 0 { - c.Request.Body = ioutil.NopCloser(buf) - } else if c.Request.Body != nil { - defer c.Request.Body.Close() - c.Request.Body = ioutil.NopCloser(io.LimitReader(c.Request.Body, MaxLogBodyBytes)) - } - - request, _ := httputil.DumpRequest(c.Request, true) - - if l, ok := c.Get("log"); ok { - if log, ok := l.(*zap.SugaredLogger); ok { - if e, ok := err.(error); ok { - log = log.With(zap.Error(e)) - } else { - log = log.With(zap.Any("error", err)) - } - - log.With(zap.String("request", string(request))).Error("Caught panic") - } else { - fmt.Printf("Caught panic: %v\n%s\n\nFrom request:\n%s", err, debug.Stack(), string(request)) - } - } - - abortWithError(c, http.StatusInternalServerError, "") - } - }() - - c.Next() - } -} - -// NewLogger returns a new low-level `*zap.Logger` instance. If the current -// terminal is a TTY, it will try ot use colored output automatically. -func NewLogger() (*zap.Logger, error) { - if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { - config := zap.NewDevelopmentConfig() - logLevel = &config.Level - config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder - config.EncoderConfig.EncodeTime = iso8601UTCTimeEncoder - return config.Build() - } - - config := zap.NewProductionConfig() - config.EncoderConfig.EncodeTime = iso8601UTCTimeEncoder - logLevel = &config.Level - return config.Build() -} - -// A UTC variation of ZapCore.ISO8601TimeEncoder with millisecond precision -func iso8601UTCTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { - enc.AppendString(t.UTC().Format("2006-01-02T15:04:05.000Z")) -} - -// LogOption is used to set optional configuration for logging. -type LogOption func(*zap.Logger) *zap.Logger - -// Logger sets the Zap logger to use. If not given, a default one will be -// created instead. Use this as an override. -func Logger(log *zap.Logger) LogOption { - return func(l *zap.Logger) *zap.Logger { - return log - } -} - -// LogField adds a key/value pair to the logger's tag fields. -func LogField(name, value string) LogOption { - return func(l *zap.Logger) *zap.Logger { - return l.With(zap.String(name, value)) - } -} - -// LogMiddleware creates a new middleware to set a tagged `*zap.SugarLogger` in the -// Gin context under the `log` key. It debug logs request info. If passed `nil` -// for the logger, then it creates one. If the current terminal is a TTY, it -// will try to use colored output automatically. -func LogMiddleware(options ...LogOption) Middleware { - var err error - var l *zap.Logger - if l, err = NewLogger(); err != nil { - panic(err) - } - - // Add any additional tags that were passed. - for _, option := range options { - l = option(l) - } - - return func(c *gin.Context) { - start := time.Now() - contextLog := l.With( - zap.String("method", c.Request.Method), - zap.String("template", c.FullPath()), - zap.String("path", c.Request.URL.RequestURI()), - zap.String("ip", c.ClientIP()), - ) - c.Set("log", contextLog.Sugar()) - - c.Next() - - contextLog = contextLog.With( - zap.Int("status", c.Writer.Status()), - zap.Duration("duration", time.Since(start)), - ) - - if len(c.Errors) > 0 { - for _, e := range c.Errors { - contextLog.Error("Error", zap.Error(e.Err)) - } - } - - contextLog.Debug("Request") - } -} - -// LogDependency returns a dependency that resolves to a `*zap.SugaredLogger` -// for the current request. This dependency *requires* the use of -// `LogMiddleware` and will error if the logger is not in the request context. -func LogDependency() DependencyOption { - dep := newDependency(DependencyOptions( - GinContextDependency(), - OperationIDDependency(), - ), func(c *gin.Context, opID string) (*zap.SugaredLogger, error) { - l, ok := c.Get("log") - if !ok { - return nil, fmt.Errorf("missing logger in context") - } - sl := l.(*zap.SugaredLogger).With("operation", opID) - return sl, nil - }) - - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, dep) - }} -} - -// Handler404 will return JSON responses for 404 errors. -func Handler404() Handler { - return func(c *gin.Context) { - if c.Request.URL.Path == "/" { - // Special case: just return an HTTP 204 for the root rather than an error - // if no custom root handler has been defined. This can be combined with - // the ServiceLinkMiddleware to provide service description links. - c.Status(http.StatusNoContent) - return - } - - c.Header("content-type", "application/problem+json") - c.JSON(http.StatusNotFound, &ErrorModel{ - Status: http.StatusNotFound, - Title: http.StatusText(http.StatusNotFound), - Detail: "Requested: " + c.Request.Method + " " + c.Request.URL.RequestURI(), - }) - } -} - -type minimalWriter struct { - gin.ResponseWriter - w http.ResponseWriter -} - -func (w *minimalWriter) Write(data []byte) (int, error) { - if w.Status() == http.StatusNoContent { - return 0, nil - } - - return w.ResponseWriter.Write(data) -} - -func (w *minimalWriter) WriteHeader(statusCode int) { - if statusCode >= 200 && statusCode < 300 { - statusCode = http.StatusNoContent - } - - w.ResponseWriter.WriteHeader(statusCode) -} - -// PreferMinimalMiddleware will remove the response body and return 204 No -// Content for any 2xx response where the request had the Prefer: return=minimal -// set on the request. -func PreferMinimalMiddleware() Middleware { - return func(c *gin.Context) { - // Wrap the response writer - if c.GetHeader("prefer") == "return=minimal" { - c.Writer = &minimalWriter{ResponseWriter: c.Writer} - } - - c.Next() - } -} - -// AddServiceLinks adds RFC 8631 `service-desc` and `service-doc` link -// relations to the response. Safe to call multiple times and after a link -// header has already been set (it will append to it). -func AddServiceLinks(c *gin.Context) { - link := c.Writer.Header().Get("link") - docsPrefix, exists := c.Get("docsPrefix") - if !exists { - docsPrefix = "" - } - if link != "" { - link += ", " - } - link += fmt.Sprintf(`<%s/openapi.json>; rel="service-desc", <%s/docs>; rel="service-doc"`, docsPrefix, docsPrefix) - c.Header("link", link) -} - -// ServiceLinkMiddleware addds RFC 8631 `service-desc` and `service-doc` link -// relations to the root response of the API. -func ServiceLinkMiddleware() Middleware { - return func(c *gin.Context) { - if c.Request.URL.Path == "/" { - AddServiceLinks(c) - } - c.Next() - } -} - -// selectQValue selects and returns the best value from the allowed set -// given a header with optional quality values, as you would get for an -// Accept or Accept-Encoding header. The *first* item in allowed is preferred -// if there is a tie. If nothing matches, returns an empty string. -func selectQValue(header string, allowed []string) string { - formats := strings.Split(header, ",") - best := "" - bestQ := 0.0 - for _, format := range formats { - parts := strings.Split(format, ";") - name := strings.Trim(parts[0], " \t") - - found := false - for _, n := range allowed { - if n == name { - found = true - break - } - } - - if !found { - // Skip formats we don't support. - continue - } - - // Default weight to 1 if no value is passed. - q := 1.0 - if len(parts) > 1 { - trimmed := strings.Trim(parts[1], " \t") - if strings.HasPrefix(trimmed, "q=") { - q, _ = strconv.ParseFloat(trimmed[2:], 64) - } - } - - // Prefer the first one if there is a tie. - if q > bestQ || (q == bestQ && name == allowed[0]) { - bestQ = q - best = name - } - } - - return best -} - -const gzipEncoding = "gzip" -const brotliEncoding = "br" - -var supportedEncodings []string = []string{brotliEncoding, gzipEncoding} -var compressDenyList []string = []string{".gif", ".png", ".jpg", ".jpeg", ".zip", ".gz", ".bz2"} - -type contentEncodingWriter struct { - gin.ResponseWriter - status int - encoding string - buf *bytes.Buffer - writer io.Writer - minSize int - gzPool *sync.Pool - brPool *sync.Pool - wroteHeader bool -} - -func (w *contentEncodingWriter) Write(data []byte) (int, error) { - if w.writer != nil { - // We are writing compressed data. - return w.writer.Write(data) - } - - // Buffer the data until we can decide whether to compress it or not. - w.buf.Write(data) - - cl, _ := strconv.Atoi(w.Header().Get("Content-Length")) - if cl >= w.minSize || w.buf.Len() >= w.minSize { - // We reached our minimum compression size. Set the writer, write the buffer - // and make sure to set the correct headers. - switch w.encoding { - case gzipEncoding: - gz := w.gzPool.Get().(*gzip.Writer) - gz.Reset(w.ResponseWriter) - w.writer = gz - case brotliEncoding: - br := w.brPool.Get().(*brotli.Writer) - br.Reset(w.ResponseWriter) - w.writer = br - } - w.Header().Set("Content-Encoding", w.encoding) - w.Header().Set("Vary", "Accept-Encoding") - w.ResponseWriter.WriteHeader(w.status) - w.wroteHeader = true - bufData := w.buf.Bytes() - w.buf.Reset() - return w.writer.Write(bufData) - } - - // Not sure yet whether this should be compressed. - return len(data), nil -} - -func (w *contentEncodingWriter) WriteHeader(code int) { - w.Header().Del("Content-Length") - w.status = code -} - -func (w *contentEncodingWriter) Status() int { - return w.status -} - -func (w *contentEncodingWriter) Close() { - if !w.wroteHeader { - w.ResponseWriter.WriteHeader(w.status) - } - - if w.buf.Len() > 0 { - w.ResponseWriter.Write(w.buf.Bytes()) - } - - if w.writer != nil { - if wc, ok := w.writer.(io.WriteCloser); ok { - wc.Close() - } - - // Return the writer to the pool so it can be reused. - switch w.encoding { - case gzipEncoding: - w.gzPool.Put(w.writer) - case brotliEncoding: - w.brPool.Put(w.writer) - } - } -} - -// ContentEncodingMiddleware uses content negotiation with the client to pick -// an appropriate encoding (compression) method and transparently encodes -// the response. Supports GZip and Brotli. -func ContentEncodingMiddleware() Middleware { - // Use pools to reduce allocations. We use a byte buffer to temporarily store - // some of each response in order to determine whether compression should - // be applied. The others are just re-using the GZip and Brotli compressors. - bufPool := sync.Pool{ - New: func() interface{} { - return new(bytes.Buffer) - }, - } - - gzPool := sync.Pool{ - New: func() interface{} { - return gzip.NewWriter(ioutil.Discard) - }, - } - - brPool := sync.Pool{ - New: func() interface{} { - return brotli.NewWriter(ioutil.Discard) - }, - } - - return func(c *gin.Context) { - if ext := path.Ext(c.Request.URL.Path); ext != "" { - for _, deny := range compressDenyList { - if ext == deny { - // This is a file type we should not try to compress. - c.Next() - return - } - } - } - - if ac := c.Request.Header.Get("Accept-Encoding"); ac != "" { - best := selectQValue(ac, supportedEncodings) - - if best != "" { - buf := bufPool.Get().(*bytes.Buffer) - buf.Reset() - defer bufPool.Put(buf) - - cew := &contentEncodingWriter{ - ResponseWriter: c.Writer, - encoding: best, - buf: buf, - gzPool: &gzPool, - brPool: &brPool, - - // minSize of the body at which compression is enabled. Internet MTU - // size is 1500 bytes, so anything smaller will still require sending - // at least that size. 1400 seems to be a sane default. - minSize: 1400, - } - // Since we aren't sure if we will be compressing the response (due - // to size), here we trigger a call to close the writer after all - // writes have completed. This will send the status/headers and flush - // any buffers as needed. - defer cew.Close() - c.Writer = cew - } - } - - c.Next() - } -} diff --git a/middleware/encoding.go b/middleware/encoding.go new file mode 100644 index 00000000..5e2ea651 --- /dev/null +++ b/middleware/encoding.go @@ -0,0 +1,173 @@ +package middleware + +import ( + "bytes" + "compress/gzip" + "io" + "io/ioutil" + "net/http" + "path" + "strconv" + "sync" + + "github.com/andybalholm/brotli" + "github.com/istreamlabs/huma/negotiation" +) + +const gzipEncoding = "gzip" +const brotliEncoding = "br" + +var supportedEncodings []string = []string{brotliEncoding, gzipEncoding} +var compressDenyList []string = []string{".gif", ".png", ".jpg", ".jpeg", ".zip", ".gz", ".bz2"} + +type contentEncodingWriter struct { + http.ResponseWriter + status int + encoding string + buf *bytes.Buffer + writer io.Writer + minSize int + gzPool *sync.Pool + brPool *sync.Pool + wroteHeader bool +} + +func (w *contentEncodingWriter) Write(data []byte) (int, error) { + if w.writer != nil { + // We are writing compressed data. + return w.writer.Write(data) + } + + // Buffer the data until we can decide whether to compress it or not. + w.buf.Write(data) + + cl, _ := strconv.Atoi(w.Header().Get("Content-Length")) + if cl >= w.minSize || w.buf.Len() >= w.minSize { + // We reached our minimum compression size. Set the writer, write the buffer + // and make sure to set the correct headers. + switch w.encoding { + case gzipEncoding: + gz := w.gzPool.Get().(*gzip.Writer) + gz.Reset(w.ResponseWriter) + w.writer = gz + case brotliEncoding: + br := w.brPool.Get().(*brotli.Writer) + br.Reset(w.ResponseWriter) + w.writer = br + } + w.Header().Set("Content-Encoding", w.encoding) + w.Header().Set("Vary", "Accept-Encoding") + w.ResponseWriter.WriteHeader(w.status) + w.wroteHeader = true + bufData := w.buf.Bytes() + w.buf.Reset() + return w.writer.Write(bufData) + } + + // Not sure yet whether this should be compressed. + return len(data), nil +} + +func (w *contentEncodingWriter) WriteHeader(code int) { + w.Header().Del("Content-Length") + w.status = code +} + +func (w *contentEncodingWriter) Close() { + if !w.wroteHeader { + w.ResponseWriter.WriteHeader(w.status) + } + + if w.buf.Len() > 0 { + w.ResponseWriter.Write(w.buf.Bytes()) + } + + if w.writer != nil { + if wc, ok := w.writer.(io.WriteCloser); ok { + wc.Close() + } + + // Return the writer to the pool so it can be reused. + switch w.encoding { + case gzipEncoding: + w.gzPool.Put(w.writer) + case brotliEncoding: + w.brPool.Put(w.writer) + } + } +} + +// ContentEncoding uses content negotiation with the client to pick +// an appropriate encoding (compression) method and transparently encodes +// the response. Supports GZip and Brotli. +func ContentEncoding(next http.Handler) http.Handler { + // Use pools to reduce allocations. We use a byte buffer to temporarily store + // some of each response in order to determine whether compression should + // be applied. The others are just re-using the GZip and Brotli compressors. + bufPool := sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } + + gzPool := sync.Pool{ + New: func() interface{} { + return gzip.NewWriter(ioutil.Discard) + }, + } + + brPool := sync.Pool{ + New: func() interface{} { + return brotli.NewWriter(ioutil.Discard) + }, + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ext := path.Ext(r.URL.Path); ext != "" { + for _, deny := range compressDenyList { + if ext == deny { + // This is a file type we should not try to compress. + next.ServeHTTP(w, r) + return + } + } + } + + if ac := r.Header.Get("Accept-Encoding"); ac != "" { + best := negotiation.SelectQValue(ac, supportedEncodings) + + if best != "" { + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + defer bufPool.Put(buf) + + cew := &contentEncodingWriter{ + ResponseWriter: w, + status: http.StatusOK, + encoding: best, + buf: buf, + gzPool: &gzPool, + brPool: &brPool, + + // minSize of the body at which compression is enabled. Internet MTU + // size is 1500 bytes, so anything smaller will still require sending + // at least that size (including headers). Google's research at + // http://dev.chromium.org/spdy/spdy-whitepaper suggests headers + // are at least 200 bytes and average 700-800 bytes. If we assume + // an average 30% compression ratio and 500 bytes of headers, then + // (1400 * 0.7) + 500 = 1480 bytes, just about the minimum MTU. + minSize: 1400, + } + + // Since we aren't sure if we will be compressing the response (due + // to size), here we trigger a call to close the writer after all + // writes have completed. This will send the status/headers and flush + // any buffers as needed. + defer cew.Close() + w = cew + } + } + + next.ServeHTTP(w, r) + }) +} diff --git a/middleware/encoding_test.go b/middleware/encoding_test.go new file mode 100644 index 00000000..feca074c --- /dev/null +++ b/middleware/encoding_test.go @@ -0,0 +1,144 @@ +package middleware + +import ( + "compress/gzip" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/andybalholm/brotli" + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/responses" + "github.com/stretchr/testify/assert" +) + +func TestContentEncodingTooSmall(t *testing.T) { + app, _ := newTestRouter(t) + app.Resource("/").Get("root", "test", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Write([]byte("Short string")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Accept-Encoding", "gzip, br") + app.ServeHTTP(w, req) + + assert.Equal(t, w.Result().StatusCode, http.StatusOK) + assert.Equal(t, "", w.Result().Header.Get("Content-Encoding")) + assert.Equal(t, "Short string", w.Body.String()) +} + +func TestContentEncodingIgnoredPath(t *testing.T) { + app, _ := newTestRouter(t) + app.Resource("/foo.png").Get("root", "test", + responses.OK().ContentType("image/png"), + ).Run(func(ctx huma.Context) { + ctx.Header().Set("Content-Type", "image/png") + ctx.Write([]byte("fake png")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/foo.png", nil) + req.Header.Add("Accept-Encoding", "gzip, br") + app.ServeHTTP(w, req) + + assert.Equal(t, w.Result().StatusCode, http.StatusOK) + assert.Equal(t, "", w.Result().Header.Get("Content-Encoding")) + assert.Equal(t, "fake png", w.Body.String()) +} + +func TestContentEncodingCompressed(t *testing.T) { + app, _ := newTestRouter(t) + app.Resource("/").Get("root", "test", + responses.OK(), + ).Run(func(ctx huma.Context) { + ctx.Write(make([]byte, 1500)) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Accept-Encoding", "gzip, br") + app.ServeHTTP(w, req) + + assert.Equal(t, w.Result().StatusCode, http.StatusOK) + assert.Equal(t, "br", w.Result().Header.Get("Content-Encoding")) + assert.Less(t, len(w.Body.String()), 1500) + + br := brotli.NewReader(w.Body) + decoded, _ := ioutil.ReadAll(br) + assert.Equal(t, 1500, len(decoded)) +} + +func TestContentEncodingCompressedPick(t *testing.T) { + app, _ := newTestRouter(t) + app.Resource("/").Get("root", "test", + responses.OK(), + ).Run(func(ctx huma.Context) { + ctx.Write(make([]byte, 1500)) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Accept-Encoding", "gzip, br; q=0.9, deflate") + app.ServeHTTP(w, req) + + assert.Equal(t, w.Result().StatusCode, http.StatusOK) + assert.Equal(t, "gzip", w.Result().Header.Get("Content-Encoding")) + assert.Less(t, len(w.Body.String()), 1500) +} + +func TestContentEncodingCompressedMultiWrite(t *testing.T) { + app, _ := newTestRouter(t) + app.Resource("/").Get("root", "test", + responses.OK(), + ).Run(func(ctx huma.Context) { + buf := make([]byte, 750) + ctx.Write(buf) + ctx.Write(buf) + ctx.Write(buf) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Accept-Encoding", "gzip") + app.ServeHTTP(w, req) + + assert.Equal(t, w.Result().StatusCode, http.StatusOK) + assert.Equal(t, "gzip", w.Result().Header.Get("Content-Encoding")) + assert.Less(t, len(w.Body.String()), 2250) + + gr, _ := gzip.NewReader(w.Body) + decoded, _ := ioutil.ReadAll(gr) + assert.Equal(t, 2250, len(decoded)) +} + +func TestContentEncodingError(t *testing.T) { + var status int + + app, _ := newTestRouter(t) + app.Middleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wrapped := &statusRecorder{ResponseWriter: w} + next.ServeHTTP(wrapped, r) + status = wrapped.status + }) + }) + + app.Resource("/").Get("root", "test", + responses.OK(), + ).Run(func(ctx huma.Context) { + ctx.WriteHeader(http.StatusNotFound) + ctx.Write([]byte("some text")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.Header.Add("Accept-Encoding", "gzip") + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, status) + assert.Equal(t, http.StatusNotFound, w.Result().StatusCode) +} diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 00000000..fa17cecf --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,152 @@ +package middleware + +import ( + "context" + "net/http" + "os" + "time" + + "github.com/go-chi/chi" + "github.com/mattn/go-isatty" + "github.com/opentracing/opentracing-go" + "github.com/spf13/viper" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type contextKey string + +var logContextKey contextKey = "huma-middleware-logger" +var logConfig zap.Config + +// LogLevel sets the current Zap root logger's level when using the logging +// middleware. This can be changed dynamically at runtime. +var LogLevel *zap.AtomicLevel + +// LogTracePrefix is used to prefix OpenTracing trace and span ID key names in +// emitted log message tag names. Use this to integrate with DataDog and other +// tracing service providers. +var LogTracePrefix = "dd." + +// NewDefaultLogger returns a new low-level `*zap.Logger` instance. If the +// current terminal is a TTY, it will try ot use colored output automatically. +func NewDefaultLogger() (*zap.Logger, error) { + if LogLevel != nil { + // Only set up the config once. The level will control all loggers. + return logConfig.Build() + } + + if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) { + logConfig = zap.NewDevelopmentConfig() + logConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + } else { + logConfig = zap.NewProductionConfig() + } + + logConfig.EncoderConfig.EncodeTime = iso8601UTCTimeEncoder + LogLevel = &logConfig.Level + return logConfig.Build() +} + +// NewLogger is a function that returns a new logger instance to use with +// the logger middleware. +var NewLogger func() (*zap.Logger, error) = NewDefaultLogger + +// A UTC variation of ZapCore.ISO8601TimeEncoder with millisecond precision +func iso8601UTCTimeEncoder(t time.Time, enc zapcore.PrimitiveArrayEncoder) { + enc.AppendString(t.UTC().Format("2006-01-02T15:04:05.000Z")) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(statusCode int) { + r.status = statusCode + r.ResponseWriter.WriteHeader(statusCode) +} + +// Logger creates a new middleware to set a tagged `*zap.SugarLogger` in the +// request context. It debug logs request info. If the current terminal is a +// TTY, it will try to use colored output automatically. +func Logger(next http.Handler) http.Handler { + var err error + var l *zap.Logger + if l, err = NewLogger(); err != nil { + panic(err) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + chiCtx := chi.RouteContext(r.Context()) + + contextLog := l.With( + zap.String("http.version", r.Proto), + zap.String("http.method", r.Method), + // The route pattern isn't filled out until *after* the handler runs... + // zap.String("http.template", chiCtx.RoutePattern()), + zap.String("http.url", r.URL.String()), + zap.String("network.client.ip", r.RemoteAddr), + ) + + if span := opentracing.SpanFromContext(r.Context()); span != nil { + // We have a span context, so log its info to help with correlation. + if sc, ok := span.Context().(spanContext); ok { + contextLog = contextLog.With( + zap.Uint64(LogTracePrefix+"trace_id", sc.TraceID()), + zap.Uint64(LogTracePrefix+"span_id", sc.SpanID()), + ) + } + } + + r = r.WithContext(context.WithValue(r.Context(), logContextKey, contextLog.Sugar())) + nw := &statusRecorder{ResponseWriter: w} + + next.ServeHTTP(nw, r) + + contextLog = contextLog.With( + zap.String("http.template", chiCtx.RoutePattern()), + zap.Int("http.status_code", nw.status), + zap.Duration("duration", time.Since(start)), + ) + + if nw.status < 500 { + contextLog.Debug("Request") + } else { + contextLog.Error("Request") + } + }) +} + +// AddLoggerOptions adds command line options for enabling debug logging. +func AddLoggerOptions(app Flagger) { + // Add the debug flag to enable more logging + app.Flag("debug", "d", "Enable debug logs", false) + + // Add pre-start handler + app.PreStart(func() { + if viper.GetBool("debug") { + if LogLevel != nil { + LogLevel.SetLevel(zapcore.DebugLevel) + } + } + }) +} + +// GetLogger returns the contextual logger for the current request. If no +// logger is present, it returns a no-op logger so no nil check is required. +func GetLogger(ctx context.Context) *zap.SugaredLogger { + log := ctx.Value(logContextKey) + if log != nil { + return log.(*zap.SugaredLogger) + } + + return zap.NewNop().Sugar() +} + +// SetLogger sets the contextual logger for the current request. +func SetLogger(r *http.Request, logger *zap.SugaredLogger) *http.Request { + return r.WithContext(context.WithValue(r.Context(), logContextKey, logger)) +} diff --git a/middleware/logger_test.go b/middleware/logger_test.go new file mode 100644 index 00000000..688edb2d --- /dev/null +++ b/middleware/logger_test.go @@ -0,0 +1,14 @@ +package middleware + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewLogger(t *testing.T) { + // Make sure it returns a logger + l, err := NewDefaultLogger() + assert.NoError(t, err) + assert.NotNil(t, l) +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 00000000..ca0d9dca --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/go-chi/chi" + "go.uber.org/zap" +) + +// Middlewarer lets you add middlewares +type Middlewarer interface { + Middleware(middlewares ...func(next http.Handler) http.Handler) +} + +// Flagger lets you create command line flags and functions that use them. +type Flagger interface { + Flag(name string, short string, description string, defaultValue interface{}) + PreStart(f func()) +} + +// DefaultChain sets up the default middlewares conveniently chained together +// into a single easy-to-add handler. +func DefaultChain(next http.Handler) http.Handler { + // Note: logger goes before recovery so that recovery can use it. We don't + // expect the logger to cause panics. + return chi.Chain( + OpenTracing, + Logger, + Recovery(func(ctx context.Context, err error, request string) { + log := GetLogger(ctx) + log = log.With(zap.Error(err)) + log.With( + zap.String("http.request", request), + zap.String("http.template", chi.RouteContext(ctx).RoutePattern()), + ).Error("Caught panic") + }), + ContentEncoding, + PreferMinimal, + ).Handler(next) +} + +// Defaults sets up the default middleware. This convenience function adds the +// `DefaultChain` to the router and adds the `--debug` option for logging to +// the CLI if app is a CLI. +func Defaults(app Middlewarer) { + // Add the default middleware chain. + app.Middleware(DefaultChain) + + // Add the command line options. + if flagger, ok := app.(Flagger); ok { + AddLoggerOptions(flagger) + } +} diff --git a/middleware/middleware_test.go b/middleware/middleware_test.go new file mode 100644 index 00000000..8c807cf7 --- /dev/null +++ b/middleware/middleware_test.go @@ -0,0 +1,21 @@ +package middleware + +import ( + "net/http" + "testing" +) + +type fakeApp struct{} + +func (a *fakeApp) Middleware(middlewares ...func(next http.Handler) http.Handler) {} + +func (a *fakeApp) Flag(name string, short string, description string, defaultValue interface{}) {} + +func (a *fakeApp) PreStart(f func()) { + f() +} + +func TestDefaults(t *testing.T) { + app := &fakeApp{} + Defaults(app) +} diff --git a/middleware/minimal.go b/middleware/minimal.go new file mode 100644 index 00000000..02f013d2 --- /dev/null +++ b/middleware/minimal.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "net/http" +) + +type minimalWriter struct { + http.ResponseWriter + status int +} + +func (w *minimalWriter) Write(data []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + + if w.status == http.StatusNoContent { + return 0, nil + } + + return w.ResponseWriter.Write(data) +} + +func (w *minimalWriter) WriteHeader(statusCode int) { + if statusCode >= 200 && statusCode < 300 && statusCode != 201 { + statusCode = http.StatusNoContent + } + + w.status = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +// PreferMinimal will remove the response body and return 204 No +// Content for any 2xx response where the request had the Prefer: return=minimal +// set on the request. +func PreferMinimal(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Wrap the response writer + if r.Header.Get("Prefer") == "return=minimal" { + w = &minimalWriter{ResponseWriter: w} + } + + next.ServeHTTP(w, r) + }) +} diff --git a/middleware/minimal_test.go b/middleware/minimal_test.go new file mode 100644 index 00000000..8e209731 --- /dev/null +++ b/middleware/minimal_test.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/responses" + "github.com/stretchr/testify/assert" +) + +func TestPreferMinimalMiddleware(t *testing.T) { + app, _ := newTestRouter(t) + + app.Resource("/test").Get("id", "desc", + responses.OK().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.Write([]byte("Hello, test")) + }) + + app.Resource("/non200").Get("id", "desc", + responses.BadRequest().ContentType("text/plain"), + ).Run(func(ctx huma.Context) { + ctx.WriteHeader(http.StatusBadRequest) + ctx.Write([]byte("Error details")) + }) + + // Normal request + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.NotEmpty(t, w.Body.String()) + + // Prefer minimal should return 204 No Content + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Add("prefer", "return=minimal") + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Empty(t, w.Body.String()) + + // Prefer minimal which can still return non-200 response bodies + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/non200", nil) + req.Header.Add("prefer", "return=minimal") + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.NotEmpty(t, w.Body.String()) +} diff --git a/middleware/opentracing.go b/middleware/opentracing.go new file mode 100644 index 00000000..8971b983 --- /dev/null +++ b/middleware/opentracing.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + + "github.com/go-chi/chi" + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/ext" +) + +type spanContext interface { + // SpanID returns the span ID that this context is carrying. + SpanID() uint64 + + // TraceID returns the trace ID that this context is carrying. + TraceID() uint64 +} + +// OpenTracing provides a middleware for cross-service tracing support. +func OpenTracing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tracer := opentracing.GlobalTracer() + + // Get any incoming tracing context via HTTP headers & create the span. + ctx, _ := tracer.Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(r.Header)) + span := tracer.StartSpan("http.request", ext.RPCServerOption(ctx)) + defer span.Finish() + + // Set basic HTTP info + ext.HTTPMethod.Set(span, r.Method) + ext.HTTPUrl.Set(span, r.URL.String()) + ext.Component.Set(span, "huma") + span.SetTag("span.type", "web") + + // Update context & continue the middleware chain. + r = r.WithContext(opentracing.ContextWithSpan(r.Context(), span)) + ws := statusRecorder{ResponseWriter: w} + next.ServeHTTP(&ws, r) + + // If we have a Chi route template, save it + if chictx := chi.RouteContext(r.Context()); chictx != nil { + span.SetTag("resource.name", chictx.RoutePattern()) + span.SetOperationName(r.Method + " " + chictx.RoutePattern()) + } + + // Save the status code + ext.HTTPStatusCode.Set(span, uint16(ws.status)) + }) +} diff --git a/middleware/recovery.go b/middleware/recovery.go new file mode 100644 index 00000000..9459e6c8 --- /dev/null +++ b/middleware/recovery.go @@ -0,0 +1,133 @@ +package middleware + +import ( + "bytes" + "context" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "net/http/httputil" + "runtime/debug" + "sync" + + "github.com/istreamlabs/huma" + "github.com/opentracing/opentracing-go" + "github.com/opentracing/opentracing-go/ext" +) + +// MaxLogBodyBytes logs at most this many bytes of any request body during a +// panic when using the recovery middleware. Defaults to 10KiB. Changing this +// value changes the amount of potential memory used for *each* incoming +// request, so change it carefully and complement the change with load testing +// because larger values can have a detrimental effect on the server. +var MaxLogBodyBytes int64 = 10 * 1024 + +// bufferedReadCloser will read and buffer up to max bytes into buf. Additional +// reads bypass the buffer. +type bufferedReadCloser struct { + reader io.ReadCloser + buf *bytes.Buffer + max int64 +} + +// newBufferedReadCloser returns a new BufferedReadCloser that wraps reader +// and reads up to max bytes into the buffer. +func newBufferedReadCloser(reader io.ReadCloser, buffer *bytes.Buffer, max int64) *bufferedReadCloser { + return &bufferedReadCloser{ + reader: reader, + buf: buffer, + max: max, + } +} + +// Read data into p. Returns number of bytes read and an error, if any. +func (r *bufferedReadCloser) Read(p []byte) (n int, err error) { + // Read from the underlying reader like normal. + n, err = r.reader.Read(p) + + // If buffer isn't full, add to it. + length := int64(r.buf.Len()) + if length < r.max { + if length+int64(n) < r.max { + r.buf.Write(p[:n]) + } else { + r.buf.Write(p[:int64(n)-(r.max-length)]) + } + } + + return +} + +// Close the underlying reader. +func (r *bufferedReadCloser) Close() error { + return r.reader.Close() +} + +// PanicFunc defines a function to run after a panic, which allows you to set +// up custom logging, metrics, etc. +type PanicFunc func(ctx context.Context, err error, request string) + +// Recovery prints stack traces on panic when used with the logging middleware. +func Recovery(onPanic PanicFunc) func(http.Handler) http.Handler { + bufPool := sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var buf *bytes.Buffer + + // Reset the body so other middleware/handlers can use it. + if r.Body != nil { + // Get a buffer that the body will be read into. + buf = bufPool.Get().(*bytes.Buffer) + defer bufPool.Put(buf) + + r.Body = newBufferedReadCloser(r.Body, buf, MaxLogBodyBytes) + } + + // Recovering comes *after* the above so the buffer is not returned to + // the pool until after we print out its contents. This deferred func + // is used to recover from panics and deliberately left in-line. + defer func() { + if err := recover(); err != nil { + // The body might have been read or partially read, so replace it + // with a clean reader to dump out up to maxBodyBytes with the error. + if buf != nil && buf.Len() != 0 { + r.Body = ioutil.NopCloser(buf) + } else if r.Body != nil { + defer r.Body.Close() + r.Body = ioutil.NopCloser(io.LimitReader(r.Body, MaxLogBodyBytes)) + } + + request, _ := httputil.DumpRequest(r, true) + + if _, ok := err.(error); !ok { + err = fmt.Errorf("%v", err) + } + + if onPanic != nil { + onPanic(r.Context(), err.(error), string(request)) + } else { + // Fall back to the standard library logger. + log.Printf("Caught panic: %v\n%s\n\nFrom request:\n%s", err, debug.Stack(), string(request)) + } + + // If OpenTracing is enabled, augment the span with error info + if span := opentracing.SpanFromContext(r.Context()); span != nil { + span.SetTag(string(ext.Error), err) + } + + ctx := huma.ContextFromRequest(w, r) + ctx.WriteError(http.StatusInternalServerError, "Unrecoverable internal server error") + } + }() + + next.ServeHTTP(w, r) + }) + } +} diff --git a/middleware/recovery_test.go b/middleware/recovery_test.go new file mode 100644 index 00000000..21e70769 --- /dev/null +++ b/middleware/recovery_test.go @@ -0,0 +1,84 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/istreamlabs/huma" + "github.com/istreamlabs/huma/responses" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest" + "go.uber.org/zap/zaptest/observer" +) + +func newTestRouter(t testing.TB) (*huma.Router, *observer.ObservedLogs) { + core, logs := observer.New(zapcore.DebugLevel) + + router := huma.New("Test API", "1.0.0") + router.Middleware(DefaultChain) + + NewLogger = func() (*zap.Logger, error) { + l := zaptest.NewLogger(t, zaptest.WrapOptions(zap.WrapCore(func(zapcore.Core) zapcore.Core { return core }))) + return l, nil + } + + return router, logs +} + +func TestRecoveryMiddleware(t *testing.T) { + app, _ := newTestRouter(t) + + app.Resource("/panic").Get("panic", "Panic recovery test", + responses.NoContent(), + ).Run(func(ctx huma.Context) { + panic(fmt.Errorf("Some error")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/panic", nil) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) +} + +func TestRecoveryMiddlewareString(t *testing.T) { + app, _ := newTestRouter(t) + + app.Resource("/panic").Get("panic", "Panic recovery test", + responses.NoContent(), + ).Run(func(ctx huma.Context) { + panic("Some error") + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/panic", nil) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) +} + +func TestRecoveryMiddlewareLogBody(t *testing.T) { + app, log := newTestRouter(t) + + app.Resource("/panic").Put("panic", "Panic recovery test", + responses.NoContent(), + ).Run(func(ctx huma.Context, input struct { + Body struct { + Foo string `json:"foo"` + } + }) { + panic(fmt.Errorf("Some error")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/panic", strings.NewReader(`{"foo": "bar"}`)) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) + assert.Contains(t, log.All()[0].ContextMap()["http.request"], `{"foo": "bar"}`) +} diff --git a/middleware_test.go b/middleware_test.go deleted file mode 100644 index 0605565c..00000000 --- a/middleware_test.go +++ /dev/null @@ -1,284 +0,0 @@ -package huma - -import ( - "compress/gzip" - "fmt" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/andybalholm/brotli" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -func TestRecoveryMiddleware(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(Recovery()) - - r.Resource("/panic").Get("Panic recovery test", func() string { - panic(fmt.Errorf("Some error")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/panic", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) -} - -func TestRecoveryMiddlewareString(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(Recovery()) - - r.Resource("/panic").Get("Panic recovery test", func() string { - panic("Some error") - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/panic", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) -} - -func TestRecoveryMiddlewareLogBody(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(Recovery()) - - r.Resource("/panic").Put("Panic recovery test", func(in map[string]string) string { - panic(fmt.Errorf("Some error")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPut, "/panic", strings.NewReader(`{"foo": "bar"}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) -} - -func TestPreferMinimalMiddleware(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(PreferMinimalMiddleware()) - - r.Resource("/test").Get("desc", func() string { - return "Hello, test" - }) - - r.Resource("/non200", ResponseText(http.StatusBadRequest, "desc")).Get("desc", func() string { - return "Error details" - }) - - // Normal request - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/test", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.NotEmpty(t, w.Body.String()) - - // Prefer minimal should return 204 No Content - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test", nil) - req.Header.Add("prefer", "return=minimal") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code) - assert.Empty(t, w.Body.String()) - - // Prefer minimal which can still return non-200 response bodies - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/non200", nil) - req.Header.Add("prefer", "return=minimal") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - assert.NotEmpty(t, w.Body.String()) -} - -func TestHandler404(t *testing.T) { - g := gin.New() - g.NoRoute(Handler404()) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/notfound", nil) - g.ServeHTTP(w, req) - assert.Equal(t, w.Result().StatusCode, http.StatusNotFound) - assert.Equal(t, "application/problem+json", w.Result().Header.Get("content-type")) -} - -func TestServiceLinks(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ServiceLinkMiddleware()) - r.GinEngine().NoRoute(Handler404()) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - r.ServeHTTP(w, req) - assert.Equal(t, w.Result().StatusCode, http.StatusNoContent) - assert.Contains(t, w.Result().Header.Get("link"), "service-desc") - assert.Contains(t, w.Result().Header.Get("link"), "service-doc") -} - -func TestServiceLinksExists(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ServiceLinkMiddleware()) - r.GinEngine().NoRoute(Handler404()) - r.GinEngine().GET("/", func(c *gin.Context) { - c.Header("link", `<>; rel=self`) - AddServiceLinks(c) - c.Data(http.StatusOK, "text/plain", []byte("Hello")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - r.ServeHTTP(w, req) - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Contains(t, w.Result().Header.Get("link"), "service-desc") - assert.Contains(t, w.Result().Header.Get("link"), "service-doc") - assert.Contains(t, w.Result().Header.Get("link"), "; rel=self`) - AddServiceLinks(c) - c.Data(http.StatusOK, "text/plain", []byte("Hello")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/prefix", nil) - r.ServeHTTP(w, req) - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Contains(t, w.Result().Header.Get("link"), "/prefix/openapi.json") - assert.Contains(t, w.Result().Header.Get("link"), "/prefix/docs") -} - -func TestContentEncodingTooSmall(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ContentEncodingMiddleware()) - r.Resource("/").Get("test", func() string { - return "Short string" - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Add("Accept-Encoding", "gzip, br") - r.ServeHTTP(w, req) - - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Equal(t, "", w.Result().Header.Get("Content-Encoding")) - assert.Equal(t, "Short string", w.Body.String()) -} - -func TestContentEncodingIgnoredPath(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ContentEncodingMiddleware()) - r.Resource("/foo.png").Get("test", func() string { - return "fake png" - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/foo.png", nil) - req.Header.Add("Accept-Encoding", "gzip, br") - r.ServeHTTP(w, req) - - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Equal(t, "", w.Result().Header.Get("Content-Encoding")) - assert.Equal(t, "fake png", w.Body.String()) -} - -func TestContentEncodingCompressed(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ContentEncodingMiddleware()) - r.Resource("/").Get("test", func() string { - // Highly compressable 1500 zero bytes. - buf := make([]byte, 1500) - return string(buf) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Add("Accept-Encoding", "gzip, br") - r.ServeHTTP(w, req) - - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Equal(t, "br", w.Result().Header.Get("Content-Encoding")) - assert.Less(t, len(w.Body.String()), 1500) - - br := brotli.NewReader(w.Body) - decoded, _ := ioutil.ReadAll(br) - assert.Equal(t, 1500, len(decoded)) -} - -func TestContentEncodingCompressedPick(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ContentEncodingMiddleware()) - r.Resource("/").Get("test", func() string { - // Highly compressable 1500 zero bytes. - buf := make([]byte, 1500) - return string(buf) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Add("Accept-Encoding", "gzip, br; q=0.9, deflate") - r.ServeHTTP(w, req) - - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Equal(t, "gzip", w.Result().Header.Get("Content-Encoding")) - assert.Less(t, len(w.Body.String()), 1500) -} - -func TestContentEncodingCompressedMultiWrite(t *testing.T) { - r := NewTestRouter(t) - r.GinEngine().Use(ContentEncodingMiddleware()) - r.GinEngine().GET("/", func(c *gin.Context) { - buf := make([]byte, 750) - // Making writes past the MTU boundary should still work. - c.Writer.Write(buf) - c.Writer.Write(buf) - c.Writer.Write(buf) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Add("Accept-Encoding", "gzip") - r.ServeHTTP(w, req) - - assert.Equal(t, w.Result().StatusCode, http.StatusOK) - assert.Equal(t, "gzip", w.Result().Header.Get("Content-Encoding")) - assert.Less(t, len(w.Body.String()), 2250) - - gr, _ := gzip.NewReader(w.Body) - decoded, _ := ioutil.ReadAll(gr) - assert.Equal(t, 2250, len(decoded)) -} - -func TestContentEncodingError(t *testing.T) { - var status int - - r := NewTestRouter(t) - r.GinEngine().Use(ContentEncodingMiddleware()) - r.GinEngine().Use(func(c *gin.Context) { - c.Next() - - // Other middleware should be able to read the response status - status = c.Writer.Status() - }) - r.GinEngine().GET("/", func(c *gin.Context) { - c.Writer.WriteHeader(http.StatusNotFound) - c.Writer.Write([]byte("some text")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Add("Accept-Encoding", "gzip") - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, status) - assert.Equal(t, http.StatusNotFound, w.Result().StatusCode) -} diff --git a/negotiation/negotiation.go b/negotiation/negotiation.go new file mode 100644 index 00000000..81c5ed36 --- /dev/null +++ b/negotiation/negotiation.go @@ -0,0 +1,50 @@ +package negotiation + +import ( + "strconv" + "strings" +) + +// SelectQValue selects and returns the best value from the allowed set +// given a header with optional quality values, as you would get for an +// Accept or Accept-Encoding header. The *first* item in allowed is preferred +// if there is a tie. If nothing matches, returns an empty string. +func SelectQValue(header string, allowed []string) string { + formats := strings.Split(header, ",") + best := "" + bestQ := 0.0 + for _, format := range formats { + parts := strings.Split(format, ";") + name := strings.Trim(parts[0], " \t") + + found := false + for _, n := range allowed { + if n == name { + found = true + break + } + } + + if !found { + // Skip formats we don't support. + continue + } + + // Default weight to 1 if no value is passed. + q := 1.0 + if len(parts) > 1 { + trimmed := strings.Trim(parts[1], " \t") + if strings.HasPrefix(trimmed, "q=") { + q, _ = strconv.ParseFloat(trimmed[2:], 64) + } + } + + // Prefer the first one if there is a tie. + if q > bestQ || (q == bestQ && name == allowed[0]) { + bestQ = q + best = name + } + } + + return best +} diff --git a/negotiation/negotiation_test.go b/negotiation/negotiation_test.go new file mode 100644 index 00000000..0219c7af --- /dev/null +++ b/negotiation/negotiation_test.go @@ -0,0 +1,19 @@ +package negotiation + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAccept(t *testing.T) { + assert.Equal(t, "b", SelectQValue("a; q=0.5, b;q=1.0,c; q=0.3", []string{"a", "b", "d"})) +} + +func TestAcceptBest(t *testing.T) { + assert.Equal(t, "b", SelectQValue("a; q=1.0, b;q=1.0,c; q=0.3", []string{"b", "a"})) +} + +func TestNoMatch(t *testing.T) { + assert.Equal(t, "", SelectQValue("a; q=1.0, b;q=1.0,c; q=0.3", []string{"d", "e"})) +} diff --git a/openapi.go b/openapi.go index 6b702071..fb85461c 100644 --- a/openapi.go +++ b/openapi.go @@ -1,18 +1,22 @@ package huma import ( - "fmt" - "net/http" - "reflect" - "strings" - "time" - - "github.com/Jeffail/gabs" "github.com/istreamlabs/huma/schema" - "github.com/gin-gonic/gin" - "gopkg.in/yaml.v2" ) +// oaContact describes contact information for this API. +type oaContact struct { + Name string `json:"name,omitempty"` + URL string `json:"url,omitempty"` + Email string `json:"email,omitempty"` +} + +// oaServer describes an OpenAPI 3 API server location +type oaServer struct { + URL string `json:"url"` + Description string `json:"description,omitempty"` +} + // paramLocation describes where in the HTTP request the parameter comes from. type paramLocation string @@ -23,445 +27,17 @@ const ( inHeader paramLocation = "header" ) -// openAPIParam describes an OpenAPI 3 parameter -type openAPIParam struct { +// oaParam describes an OpenAPI 3 parameter +type oaParam struct { Name string `json:"name"` Description string `json:"description,omitempty"` In paramLocation `json:"in"` Required bool `json:"required,omitempty"` Schema *schema.Schema `json:"schema,omitempty"` Deprecated bool `json:"deprecated,omitempty"` - Example interface{} `json:"example,omitempty"` Explode *bool `json:"explode,omitempty"` // Internal params are excluded from the OpenAPI document and can set up // params sent between a load balander / proxy and the service internally. Internal bool `json:"-"` - - def interface{} - typ reflect.Type -} - -// newOpenAPIParam returns a new parameter instance. -func newOpenAPIParam(name, description string, in paramLocation, options ...ParamOption) *openAPIParam { - p := &openAPIParam{ - Name: name, - Description: description, - In: in, - } - - if in == inQuery { - p.Explode = new(bool) - } - - for _, option := range options { - option.applyParam(p) - } - - return p -} - -// openAPIResponse describes an OpenAPI 3 response -type openAPIResponse struct { - Description string - ContentType string - StatusCode int - Schema *schema.Schema - Headers []string -} - -// newOpenAPIResponse returns a new response instance. -func newOpenAPIResponse(statusCode int, description string, options ...ResponseOption) *openAPIResponse { - r := &openAPIResponse{ - StatusCode: statusCode, - Description: description, - } - - for _, option := range options { - option.applyResponse(r) - } - - return r -} - -// openAPIResponseHeader describes a response header -type openAPIResponseHeader struct { - Name string `json:"-"` - Description string `json:"description,omitempty"` - Schema *schema.Schema `json:"schema,omitempty"` -} - -// openAPISecurityRequirement defines the security schemes and scopes required to use -// an operation. -type openAPISecurityRequirement map[string][]string - -// openAPIOperation describes an OpenAPI 3 operation on a path -type openAPIOperation struct { - *openAPIDependency - id string - summary string - description string - tags []string - security []openAPISecurityRequirement - requestContentType string - requestSchema *schema.Schema - responses []*openAPIResponse - extra map[string]interface{} - - // maxBodyBytes limits the size of the request body that will be read before - // an error is returned. Defaults to 1MiB if set to zero. Set to -1 for - // unlimited. - maxBodyBytes int64 - - // bodyReadTimeout sets the duration until reading the body is given up and - // aborted with an error. Defaults to 15 seconds if the body is automatically - // read and parsed into a struct, otherwise unset. Set to -1 for unlimited. - bodyReadTimeout time.Duration -} - -// newOperation creates a new operation with the given options applied. -func newOperation(options ...OperationOption) *openAPIOperation { - op := &openAPIOperation{ - openAPIDependency: &openAPIDependency{ - dependencies: make([]*openAPIDependency, 0), - params: make([]*openAPIParam, 0), - responseHeaders: make([]*openAPIResponseHeader, 0), - }, - tags: make([]string, 0), - security: make([]openAPISecurityRequirement, 0), - responses: make([]*openAPIResponse, 0), - extra: make(map[string]interface{}), - } - - for _, option := range options { - option.applyOperation(op) - } - - return op -} - -// Copy creates a new shallow copy of the operation. New arrays are created for -// e.g. parameters so they can be safely appended. Existing params are not -// deeply copied and should not be modified. -func (o *openAPIOperation) Copy() *openAPIOperation { - extraCopy := map[string]interface{}{} - - for k, v := range o.extra { - extraCopy[k] = v - } - - newOp := &openAPIOperation{ - openAPIDependency: &openAPIDependency{ - dependencies: append([]*openAPIDependency{}, o.dependencies...), - params: append([]*openAPIParam{}, o.params...), - responseHeaders: append([]*openAPIResponseHeader{}, o.responseHeaders...), - handler: o.handler, - }, - id: o.id, - summary: o.summary, - description: o.description, - tags: append([]string{}, o.tags...), - security: append([]openAPISecurityRequirement{}, o.security...), - requestContentType: o.requestContentType, - requestSchema: o.requestSchema, - responses: append([]*openAPIResponse{}, o.responses...), - extra: extraCopy, - maxBodyBytes: o.maxBodyBytes, - bodyReadTimeout: o.bodyReadTimeout, - } - - return newOp -} - -// With applies options to the operation. It makes it easy to set up new params, -// responese headers, responses, etc. It always creates a new copy. -func (o *openAPIOperation) With(options ...OperationOption) *openAPIOperation { - copy := o.Copy() - - for _, option := range options { - option.applyOperation(copy) - } - - return copy -} - -// allParams returns a list of all the parameters for this operation, including -// those for dependencies. -func (o *openAPIOperation) allParams() []*openAPIParam { - params := []*openAPIParam{} - seen := map[*openAPIParam]bool{} - - for _, p := range o.params { - seen[p] = true - params = append(params, p) - } - - for _, d := range o.dependencies { - for _, p := range d.allParams() { - if _, ok := seen[p]; !ok { - seen[p] = true - - params = append(params, p) - } - } - } - - return params -} - -// allResponseHeaders returns a list of all the parameters for this operation, -// including those for dependencies. -func (o *openAPIOperation) allResponseHeaders() []*openAPIResponseHeader { - headers := []*openAPIResponseHeader{} - seen := map[*openAPIResponseHeader]bool{} - - for _, h := range o.responseHeaders { - seen[h] = true - headers = append(headers, h) - } - - for _, d := range o.dependencies { - for _, h := range d.allResponseHeaders() { - if _, ok := seen[h]; !ok { - seen[h] = true - - headers = append(headers, h) - } - } - } - - return headers -} - -// unsafe returns true if the operation's handler was made with UnsafeHandler. -func (o *openAPIOperation) unsafe() bool { - if _, ok := o.handler.(*unsafeHandler); ok { - return true - } - - return false -} - -// openAPIServer describes an OpenAPI 3 API server location -type openAPIServer struct { - URL string `json:"url"` - Description string `json:"description,omitempty"` -} - -// openAPIContact information for this API. -type openAPIContact struct { - Name string `json:"name"` - URL string `json:"url"` - Email string `json:"email"` -} - -// openAPIOAuthFlow describes the URLs and scopes to get tokens via a specific flow. -type openAPIOAuthFlow struct { - AuthorizationURL string `json:"authorizationUrl"` - TokenURL string `json:"tokenUrl"` - RefreshURL string `json:"refreshUrl,omitempty"` - Scopes map[string]string `json:"scopes"` -} - -// openAPIOAuthFlows describes the configuration for each flow type. -type openAPIOAuthFlows struct { - Implicit *openAPIOAuthFlow `json:"implicit,omitempty"` - Password *openAPIOAuthFlow `json:"password,omitempty"` - ClientCredentials *openAPIOAuthFlow `json:"clientCredentials,omitempty"` - AuthorizationCode *openAPIOAuthFlow `json:"authorizationCode,omitempty"` -} - -// openAPISecurityScheme describes the auth mechanism(s) for this API. -type openAPISecurityScheme struct { - Type string `json:"type"` - Description string `json:"description,omitempty"` - Name string `json:"name,omitempty"` - In string `json:"in,omitempty"` - Scheme string `json:"scheme,omitempty"` - BearerFormat string `json:"bearerFormat,omitempty"` - Flows *openAPIOAuthFlows `json:"flows,omitempty"` - OpenIDConnectURL string `json:"openIdConnectUrl,omitempty"` -} - -// openAPI describes the openAPI 3 API -type openAPI struct { - Title string - Version string - Description string - Contact *openAPIContact - Servers []*openAPIServer - SecuritySchemes map[string]*openAPISecurityScheme - Security []openAPISecurityRequirement - Paths map[string]map[string]*openAPIOperation - - // Extra allows setting extra keys in the OpenAPI root structure. - Extra map[string]interface{} - - // Hook is a function to add to or modify the OpenAPI document before - // returning it when accessing `GET /openapi.json`. - Hook func(*gabs.Container) -} - -// OpenAPI returns a representation of the OpenAPI document describing this -// router. Registered hooks are called before returning the data. -func (r *Router) OpenAPI() *gabs.Container { - respSchema400, _ := schema.Generate(reflect.ValueOf(ErrorModel{}).Type()) - - api := r.api - openapi := gabs.New() - - for k, v := range api.Extra { - openapi.Set(v, k) - } - - openapi.Set("3.0.1", "openapi") - openapi.Set(api.Title, "info", "title") - openapi.Set(api.Version, "info", "version") - - if api.Description != "" { - openapi.Set(api.Description, "info", "description") - } - - if api.Contact != nil { - openapi.Set(api.Contact, "info", "contact") - } - - if len(api.Servers) > 0 { - openapi.Set(api.Servers, "servers") - } - - if len(api.SecuritySchemes) > 0 { - openapi.Set(api.SecuritySchemes, "components", "securitySchemes") - } - - if len(api.Security) > 0 { - openapi.Set(api.Security, "security") - } - - for path, methods := range api.Paths { - if strings.Contains(path, ":") { - // Convert from gin-style params to OpenAPI-style params - path = paramRe.ReplaceAllString(path, "{$1$2}") - } - - for method, op := range methods { - method := strings.ToLower(method) - - for k, v := range op.extra { - openapi.Set(v, "paths", path, method, k) - } - - openapi.Set(op.id, "paths", path, method, "operationId") - if op.summary != "" { - openapi.Set(op.summary, "paths", path, method, "summary") - } - openapi.Set(op.description, "paths", path, method, "description") - if len(op.tags) > 0 { - openapi.Set(op.tags, "paths", path, method, "tags") - } - - if len(op.security) > 0 { - openapi.Set(op.security, "paths", path, method, "security") - } - - for _, param := range op.allParams() { - if param.Internal { - // Skip internal-only parameters. - continue - } - 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([]*openAPIResponse, 0, len(op.responses)) - found400 := false - for _, resp := range op.responses { - responses = append(responses, resp) - if resp.StatusCode == http.StatusBadRequest { - found400 = true - } - } - - if op.requestSchema != nil && !found400 { - // Add a 400-level response in case parsing the request fails. - responses = append(responses, &openAPIResponse{ - Description: "Invalid input", - ContentType: "application/json", - StatusCode: http.StatusBadRequest, - Schema: respSchema400, - }) - } - - headerMap := map[string]*openAPIResponseHeader{} - for _, header := range op.allResponseHeaders() { - headerMap[header.Name] = header - } - - for _, resp := range responses { - status := fmt.Sprintf("%v", resp.StatusCode) - openapi.Set(resp.Description, "paths", path, method, "responses", status, "description") - - headers := make([]string, 0, len(resp.Headers)) - seen := map[string]bool{} - for _, name := range resp.Headers { - headers = append(headers, name) - seen[name] = true - } - for _, dep := range op.dependencies { - for _, header := range dep.allResponseHeaders() { - if _, ok := seen[header.Name]; !ok { - headers = append(headers, header.Name) - seen[header.Name] = true - } - } - } - - for _, name := range headers { - header := headerMap[name] - openapi.Set(header, "paths", path, method, "responses", status, "headers", header.Name) - } - - if resp.Schema != nil { - openapi.Set(resp.Schema, "paths", path, method, "responses", status, "content", resp.ContentType, "schema") - } - } - - } - } - - if api.Hook != nil { - api.Hook(openapi) - } - - return openapi -} - -// openAPIHandlerJSON returns a new handler function to generate an OpenAPI spec. -func openAPIHandlerJSON(r *Router) gin.HandlerFunc { - return func(c *gin.Context) { - openapi := r.OpenAPI() - c.Data(200, "application/vnd.oai.openapi+json", openapi.BytesIndent("", " ")) - } -} - -// openAPIHandlerYAML returns a new handler function to generate an OpenAPI spec. -func openAPIHandlerYAML(r *Router) gin.HandlerFunc { - return func(c *gin.Context) { - openapi := r.OpenAPI() - var tmp interface{} - if err := yaml.Unmarshal(openapi.Bytes(), &tmp); err != nil { - abortWithError(c, http.StatusInternalServerError, err.Error()) - return - } - - c.Header("content-type", "application/vnd.oai.openapi") - c.YAML(200, tmp) - } } diff --git a/openapi_test.go b/openapi_test.go deleted file mode 100644 index e606dcf6..00000000 --- a/openapi_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package huma - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/istreamlabs/huma/schema" - "github.com/getkin/kin-openapi/openapi3" - "github.com/stretchr/testify/assert" -) - -var paramFuncsTable = []struct { - n string - param OperationOption - name string - description string - in paramLocation - required bool - internal bool - def interface{} - example interface{} -}{ - {"PathParam", PathParam("test", "desc"), "test", "desc", inPath, true, false, nil, nil}, - {"PathParamSchema", PathParam("test", "desc", Schema(schema.Schema{})), "test", "desc", inPath, true, false, nil, nil}, - {"PathParamExample", PathParam("test", "desc", Example(123)), "test", "desc", inPath, true, false, nil, 123}, - {"QueryParam", QueryParam("test", "desc", "def"), "test", "desc", inQuery, false, false, "def", nil}, - {"QueryParamSchema", QueryParam("test", "desc", "def", Schema(schema.Schema{})), "test", "desc", inQuery, false, false, "def", nil}, - {"QueryParamExample", QueryParam("test", "desc", "def", Example("foo")), "test", "desc", inQuery, false, false, "def", "foo"}, - {"QueryParamInternal", QueryParam("test", "desc", "def", Internal()), "test", "desc", inQuery, false, true, "def", nil}, - {"HeaderParam", HeaderParam("test", "desc", "def"), "test", "desc", inHeader, false, false, "def", nil}, - {"HeaderParamSchema", HeaderParam("test", "desc", "def", Schema(schema.Schema{})), "test", "desc", inHeader, false, false, "def", nil}, - {"HeaderParamExample", HeaderParam("test", "desc", "def", Example("foo")), "test", "desc", inHeader, false, false, "def", "foo"}, - {"HeaderParamInternal", HeaderParam("test", "desc", "def", Internal()), "test", "desc", inHeader, false, true, "def", nil}, -} - -func TestParamFuncs(outer *testing.T) { - for _, tt := range paramFuncsTable { - local := tt - outer.Run(fmt.Sprintf("%v", tt.n), func(t *testing.T) { - op := newOperation() - local.param.applyOperation(op) - param := op.params[0] - assert.Equal(t, local.name, param.Name) - assert.Equal(t, local.description, param.Description) - assert.Equal(t, local.in, param.In) - assert.Equal(t, local.required, param.Required) - assert.Equal(t, local.internal, param.Internal) - assert.Equal(t, local.def, param.def) - assert.Equal(t, local.example, param.Example) - }) - } -} - -var responseFuncsTable = []struct { - n string - resp OperationOption - statusCode int - description string - headers []string - contentType string -}{ - {"ResponseEmpty", Response(204, "desc", Headers("head1", "head2")), 204, "desc", []string{"head1", "head2"}, ""}, - {"ResponseText", ResponseText(200, "desc", Headers("head1", "head2")), 200, "desc", []string{"head1", "head2"}, "application/json"}, - {"ResponseJSON", ResponseJSON(200, "desc", Headers("head1", "head2")), 200, "desc", []string{"head1", "head2"}, "application/json"}, - {"ResponseError", ResponseJSON(200, "desc", Headers("head1", "head2")), 200, "desc", []string{"head1", "head2"}, "application/json"}, -} - -func TestResponseFuncs(outer *testing.T) { - for _, tt := range responseFuncsTable { - local := tt - outer.Run(fmt.Sprintf("%v", tt.n), func(t *testing.T) { - op := newOperation() - local.resp.applyOperation(op) - resp := op.responses[0] - assert.Equal(t, local.statusCode, resp.StatusCode) - assert.Equal(t, local.description, resp.Description) - assert.Equal(t, local.headers, resp.Headers) - }) - } -} - -var serverFuncsTable = []struct { - n string - option RouterOption - url string - description string -}{ - {"DevServer", DevServer("url"), "url", "Development server"}, - {"ProdServer", ProdServer("url"), "url", "Production server"}, -} - -func TestServerFuncs(outer *testing.T) { - for _, tt := range serverFuncsTable { - local := tt - outer.Run(fmt.Sprintf("%v", tt.n), func(t *testing.T) { - r := NewTestRouter(t, local.option) - assert.Equal(t, local.url, r.api.Servers[0].URL) - assert.Equal(t, local.description, r.api.Servers[0].Description) - }) - } -} - -var securityFuncsTable = []struct { - n string - option RouterOption - typ string - name string - in string - scheme string - bearerFormat string -}{ - {"BasicAuth", BasicAuth("test"), "http", "", "", "basic", ""}, - {"APIKeyAuth", APIKeyAuth("test", "name", "in"), "apiKey", "name", "in", "", ""}, - {"JWTBearerAuth", JWTBearerAuth("test"), "http", "", "", "bearer", "JWT"}, -} - -func TestSecurityFuncs(outer *testing.T) { - for _, tt := range securityFuncsTable { - local := tt - outer.Run(fmt.Sprintf("%v", tt.n), func(t *testing.T) { - r := NewTestRouter(t, local.option) - assert.Equal(t, local.typ, r.api.SecuritySchemes["test"].Type) - assert.Equal(t, local.name, r.api.SecuritySchemes["test"].Name) - assert.Equal(t, local.in, r.api.SecuritySchemes["test"].In) - assert.Equal(t, local.scheme, r.api.SecuritySchemes["test"].Scheme) - assert.Equal(t, local.bearerFormat, r.api.SecuritySchemes["test"].BearerFormat) - }) - } -} - -func TestOpenAPIHandler(t *testing.T) { - type HelloRequest struct { - Name string `json:"name" example:"world"` - } - - type HelloResponse struct { - Message string `json:"message" example:"Hello, world"` - } - - r := NewTestRouter(t, - ContactEmail("Support", "support@example.com"), - DevServer("http://localhost:8888"), - BasicAuth("basic"), - Extra("x-foo", "bar"), - ) - - dep1 := Dependency(DependencyOptions( - QueryParam("q", "Test query param", ""), - ResponseHeader("dep", "description"), - ), func(q string) (string, string, error) { - return "header", "foo", nil - }) - - dep2 := Dependency(dep1, func(q string) (string, error) { - return q, nil - }) - - r.Resource("/hello", - dep2, - SecurityRef("basic"), - QueryParam("greet", "Whether to greet or not", false), - HeaderParam("user", "User from auth token", "", Internal()), - ResponseHeader("etag", "Content hash for caching"), - ResponseJSON(200, "Successful response", Headers("etag")), - Extra("x-foo", "bar"), - ).Put("Get a welcome message", func(q string, greet bool, user string, body *HelloRequest) (string, *HelloResponse) { - return "etag", &HelloResponse{ - Message: "Hello", - } - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) - r.ServeHTTP(w, req) - - // Confirm it loads without errors. - data := w.Body.Bytes() - _, err := openapi3.NewSwaggerLoader().LoadSwaggerFromData(data) - assert.NoError(t, err, string(data)) -} diff --git a/operation.go b/operation.go new file mode 100644 index 00000000..a3e159ac --- /dev/null +++ b/operation.go @@ -0,0 +1,249 @@ +package huma + +import ( + "fmt" + "net/http" + "reflect" + "strings" + "time" + + "github.com/Jeffail/gabs/v2" + "github.com/istreamlabs/huma/schema" +) + +// Operation represents an operation (an HTTP verb, e.g. GET / PUT) against +// a resource attached to a router. +type Operation struct { + resource *Resource + method string + id string + summary string + description string + params map[string]oaParam + requestContentType string + requestSchema *schema.Schema + responses []Response + maxBodyBytes int64 + bodyReadTimeout time.Duration +} + +func newOperation(resource *Resource, method, id, docs string, responses []Response) *Operation { + summary, desc := splitDocs(docs) + return &Operation{ + resource: resource, + method: method, + id: id, + summary: summary, + description: desc, + responses: responses, + // 1 MiB body limit by default + maxBodyBytes: 1024 * 1024, + // 15 second timeout by default + bodyReadTimeout: 15 * time.Second, + } +} + +func (o *Operation) toOpenAPI() *gabs.Container { + doc := gabs.New() + + doc.Set(o.id, "operationId") + if o.summary != "" { + doc.Set(o.summary, "summary") + } + if o.description != "" { + doc.Set(o.description, "description") + } + + // Request params + for _, param := range o.params { + if param.Internal { + // Skip documenting internal-only params. + continue + } + + doc.ArrayAppend(param, "parameters") + } + + // Request body + if o.requestSchema != nil { + ct := o.requestContentType + if ct == "" { + ct = "application/json" + } + doc.Set(o.requestSchema, "requestBody", "content", ct, "schema") + } + + // responses + for _, resp := range o.responses { + status := fmt.Sprintf("%v", resp.status) + doc.Set(resp.description, "responses", status, "description") + + headers := resp.headers + for _, name := range headers { + // TODO: get header description from shared registry + //header := headerMap[name] + header := name + doc.Set(header, "responses", status, "headers", name, "description") + + typ := "string" + for _, param := range o.params { + if param.In == inHeader && param.Name == name { + if param.Schema.Type != "" { + typ = param.Schema.Type + } + break + } + } + doc.Set(typ, "responses", status, "headers", name, "schema", "type") + } + + if resp.model != nil { + schema, err := schema.GenerateWithMode(resp.model, schema.ModeRead, nil) + if err != nil { + panic(err) + } + doc.Set(schema, "responses", status, "content", resp.contentType, "schema") + } + } + + return doc +} + +// MaxBodyBytes sets the max number of bytes that the request body size may be +// before the request is cancelled. The default is 1MiB. +func (o *Operation) MaxBodyBytes(size int64) { + o.maxBodyBytes = size +} + +// NoMaxBody removes the body byte limit, which is 1MiB by default. Use this +// if you expect to stream the input request or need to handle very large +// request bodies. +func (o *Operation) NoMaxBody() { + o.maxBodyBytes = 0 +} + +// BodyReadTimeout sets the amount of time a request can spend reading the +// body, after which it times out and the request is cancelled. The default +// is 15 seconds. +func (o *Operation) BodyReadTimeout(duration time.Duration) { + o.bodyReadTimeout = duration +} + +// NoBodyReadTimeout removes the body read timeout, which is 15 seconds by +// default. Use this if you expect to stream the input request or need to +// handle very large request bodies. +func (o *Operation) NoBodyReadTimeout() { + o.bodyReadTimeout = 0 +} + +// Run registers the handler function for this operation. It should be of the +// form: `func (ctx huma.Context)` or `func (ctx huma.Context, input)` where +// input is your input struct describing the input parameters and/or body. +func (o *Operation) Run(handler interface{}) { + if reflect.ValueOf(handler).Kind() != reflect.Func { + panic(fmt.Errorf("Handler must be a function taking a huma.Context and optionally a user-defined input struct, but got: %s for %s %s", handler, o.method, o.resource.path)) + } + + var register func(string, http.HandlerFunc) + + switch o.method { + case http.MethodPost: + register = o.resource.mux.Post + case http.MethodHead: + register = o.resource.mux.Head + case http.MethodGet: + register = o.resource.mux.Get + case http.MethodPut: + register = o.resource.mux.Put + case http.MethodPatch: + register = o.resource.mux.Patch + case http.MethodDelete: + register = o.resource.mux.Delete + default: + panic(fmt.Errorf("Unknown HTTP verb: %s", o.method)) + } + + t := reflect.TypeOf(handler) + if t.Kind() == reflect.Func && t.NumIn() > 1 { + var err error + input := t.In(1) + + // Get parameters + o.params = getParamInfo(input) + for k, v := range o.params { + if v.In == inPath { + // Confirm each declared input struct path parameter is actually a part + // of the declared resource path. + if !strings.Contains(o.resource.path, "{"+k+"}") { + panic(fmt.Errorf("Parameter '%s' not in URI path: %s", k, o.resource.path)) + } + } + } + + // Get body if present. + if body, ok := input.FieldByName("Body"); ok { + o.requestSchema, err = schema.GenerateWithMode(body.Type, schema.ModeWrite, nil) + if err != nil { + panic(fmt.Errorf("unable to generate JSON schema: %w", err)) + } + } + + // It's possible for the inputs to generate a 400, so add it if it wasn't + // explicitly defined. + found400 := false + for _, r := range o.responses { + if r.status == http.StatusBadRequest { + found400 = true + break + } + } + + if !found400 { + o.responses = append(o.responses, NewResponse(http.StatusBadRequest, http.StatusText(http.StatusBadRequest)).Model(&ErrorModel{})) + } + } + + // Future improvement idea: use a sync.Pool for the input structure to save + // on allocations if the struct has a Reset() method. + + register("/", func(w http.ResponseWriter, r *http.Request) { + // Limit the request body size and set a read timeout. + if r.Body != nil { + if o.maxBodyBytes > 0 { + r.Body = http.MaxBytesReader(w, r.Body, o.maxBodyBytes) + } + + if conn := GetConn(r.Context()); o.bodyReadTimeout > 0 && conn != nil { + conn.SetReadDeadline(time.Now().Add(o.bodyReadTimeout)) + } + } + + ctx := &hcontext{ + Context: r.Context(), + ResponseWriter: w, + r: r, + op: o, + } + + // If there is no input struct (just a context), then the call is simple. + if simple, ok := handler.(func(Context)); ok { + simple(ctx) + return + } + + // Otherwise, create a new input struct instance and populate it. + v := reflect.ValueOf(handler) + inputType := v.Type().In(1) + input := reflect.New(inputType) + + setFields(ctx, ctx.r, input, inputType) + if ctx.HasError() { + ctx.WriteError(http.StatusBadRequest, "Error while parsing input parameters") + return + } + + // Call the handler with the context and newly populated input struct. + in := []reflect.Value{reflect.ValueOf(ctx), input.Elem()} + reflect.ValueOf(handler).Call(in) + }) +} diff --git a/options.go b/options.go deleted file mode 100644 index 49aa28fc..00000000 --- a/options.go +++ /dev/null @@ -1,587 +0,0 @@ -package huma - -import ( - "fmt" - "net/http" - "time" - - "github.com/Jeffail/gabs" - "github.com/istreamlabs/huma/schema" - "github.com/gin-gonic/gin" -) - -// RouterOption sets an option on the router or OpenAPI top-level structure. -type RouterOption interface { - ApplyRouter(r *Router) -} - -// routerOption is a shorthand struct used to create API options easily. -type routerOption struct { - handler func(*Router) -} - -// ApiUIDocType represents the type of UI presentation for the API docs: Rapi, ReDoc, or Swagger -type ApiUIDocType int - -const ( - RAPIDOCTYPE ApiUIDocType = 1 + iota - REDOCTYPE - SWAGGERDOCTYPE -) - -func (o *routerOption) ApplyRouter(router *Router) { - o.handler(router) -} - -// RouterOptions composes together a set of options into one. -func RouterOptions(options ...RouterOption) RouterOption { - return &routerOption{func(r *Router) { - for _, option := range options { - option.ApplyRouter(r) - } - }} -} - -// ResourceOption sets an option on the resource to be used in sub-resources -// and operations. -type ResourceOption interface { - ApplyResource(r *Resource) -} - -// resourceOption is a shorthand struct used to create resource options easily. -type resourceOption struct { - handler func(*Resource) -} - -func (o *resourceOption) ApplyResource(r *Resource) { - o.handler(r) -} - -// ResourceOptions composes together a set of options into one. -func ResourceOptions(options ...ResourceOption) ResourceOption { - return &resourceOption{func(r *Resource) { - for _, option := range options { - option.ApplyResource(r) - } - }} -} - -// OperationOption sets an option on an operation or resource object. -type OperationOption interface { - ResourceOption - applyOperation(o *openAPIOperation) -} - -// operationOption is a shorthand struct used to create operation options -// easily. Options created with it can be applied to either operations or -// resources. -type operationOption struct { - handler func(*openAPIOperation) -} - -func (o *operationOption) ApplyResource(r *Resource) { - o.handler(r.openAPIOperation) -} - -func (o *operationOption) applyOperation(op *openAPIOperation) { - o.handler(op) -} - -// OperationOptions composes together a set of options into one. -func OperationOptions(options ...OperationOption) OperationOption { - return &operationOption{func(o *openAPIOperation) { - for _, option := range options { - option.applyOperation(o) - } - }} -} - -// DependencyOption sets an option on a dependency, operation, or resource -// object. -type DependencyOption interface { - OperationOption - applyDependency(d *openAPIDependency) -} - -// dependencyOption is a shorthand struct used to create dependency options -// easily. Options created with it can be applied to dependencies, operations, -// and resources. -type dependencyOption struct { - handler func(*openAPIDependency) -} - -func (o *dependencyOption) ApplyResource(r *Resource) { - o.handler(r.openAPIDependency) -} - -func (o *dependencyOption) applyOperation(op *openAPIOperation) { - o.handler(op.openAPIDependency) -} - -func (o *dependencyOption) applyDependency(d *openAPIDependency) { - o.handler(d) -} - -// DependencyOptions composes together a set of options into one. -func DependencyOptions(options ...DependencyOption) DependencyOption { - return &dependencyOption{func(d *openAPIDependency) { - for _, option := range options { - option.applyDependency(d) - } - }} -} - -// ParamOption sets an option on an OpenAPI parameter. -type ParamOption interface { - applyParam(*openAPIParam) -} - -type paramOption struct { - apply func(*openAPIParam) -} - -func (o *paramOption) applyParam(p *openAPIParam) { - o.apply(p) -} - -// ResponseHeaderOption sets an option on an OpenAPI response header. -type ResponseHeaderOption interface { - applyResponseHeader(*openAPIResponseHeader) -} - -// ResponseOption sets an option on an OpenAPI response. -type ResponseOption interface { - applyResponse(*openAPIResponse) -} - -type responseOption struct { - apply func(*openAPIResponse) -} - -func (o *responseOption) applyResponse(r *openAPIResponse) { - o.apply(r) -} - -// sharedOption sets an option on any combination of objects. -type sharedOption struct { - Set func(v interface{}) -} - -func (o *sharedOption) ApplyRouter(r *Router) { - o.Set(r) -} - -func (o *sharedOption) ApplyResource(r *Resource) { - o.Set(r) -} - -func (o *sharedOption) applyOperation(op *openAPIOperation) { - o.Set(op) -} - -func (o *sharedOption) applyParam(p *openAPIParam) { - o.Set(p) -} - -func (o *sharedOption) applyResponseHeader(r *openAPIResponseHeader) { - o.Set(r) -} - -func (o *sharedOption) applyResponse(r *openAPIResponse) { - o.Set(r) -} - -// Schema manually sets a JSON Schema on the object. If the top-level `type` is -// blank then the type will be guessed from the handler function. If no schema -// is set then one will be generated for you. -func Schema(s schema.Schema) interface { - ParamOption - ResponseHeaderOption - ResponseOption -} { - // Note: schema is pass by value rather than a pointer to prevent - // issues with modification after being passed. - return &sharedOption{func(v interface{}) { - switch cast := v.(type) { - case *openAPIParam: - cast.Schema = &s - case *openAPIResponseHeader: - cast.Schema = &s - case *openAPIResponse: - cast.Schema = &s - } - }} -} - -// SecurityRef adds a security reference by name with optional scopes. -func SecurityRef(name string, scopes ...string) interface { - RouterOption - OperationOption -} { - if scopes == nil { - scopes = []string{} - } - - return &sharedOption{ - Set: func(v interface{}) { - req := openAPISecurityRequirement{name: scopes} - - switch cast := v.(type) { - case *Router: - cast.api.Security = append(cast.api.Security, req) - case *Resource: - cast.security = append(cast.security, req) - case *openAPIOperation: - cast.security = append(cast.security, req) - } - }, - } -} - -// Extra sets extra values in the generated OpenAPI 3 spec. -func Extra(pairs ...interface{}) interface { - RouterOption - OperationOption -} { - extra := map[string]interface{}{} - - if len(pairs)%2 > 0 { - panic(fmt.Errorf("requires key-value pairs but got: %v", pairs)) - } - - for i := 0; i < len(pairs); i += 2 { - k := pairs[i].(string) - v := pairs[i+1] - extra[k] = v - } - - return &sharedOption{ - Set: func(v interface{}) { - var x map[string]interface{} - - switch cast := v.(type) { - case *Router: - x = cast.api.Extra - case *Resource: - x = cast.extra - case *openAPIOperation: - x = cast.extra - } - - for k, v := range extra { - x[k] = v - } - }, - } -} - -// ProdServer sets the production server URL on the API. -func ProdServer(url string) RouterOption { - return &routerOption{func(r *Router) { - r.api.Servers = append(r.api.Servers, &openAPIServer{url, "Production server"}) - }} -} - -// DevServer sets the development server URL on the API. -func DevServer(url string) RouterOption { - return &routerOption{func(r *Router) { - r.api.Servers = append(r.api.Servers, &openAPIServer{url, "Development server"}) - }} -} - -// ContactFull sets the API contact information. -func ContactFull(name, url, email string) RouterOption { - return &routerOption{func(r *Router) { - r.api.Contact = &openAPIContact{name, url, email} - }} -} - -// ContactURL sets the API contact name & URL information. -func ContactURL(name, url string) RouterOption { - return &routerOption{func(r *Router) { - r.api.Contact = &openAPIContact{Name: name, URL: url} - }} -} - -// ContactEmail sets the API contact name & email information. -func ContactEmail(name, email string) RouterOption { - return &routerOption{func(r *Router) { - r.api.Contact = &openAPIContact{Name: name, Email: email} - }} -} - -// DocsRoutePrefix enables the API documentation to be available from `prefix/{docs, openapi.yaml}` -func DocsRoutePrefix(prefix string) RouterOption { - return &routerOption{func(r *Router) { - r.docsPrefix = prefix - }} -} - -// BasicAuth adds a named HTTP Basic Auth security scheme. -func BasicAuth(name string) RouterOption { - return &routerOption{func(r *Router) { - r.api.SecuritySchemes[name] = &openAPISecurityScheme{ - Type: "http", - Scheme: "basic", - } - }} -} - -// APIKeyAuth adds a named pre-shared API key security scheme. The location of -// the API key parameter is defined with `in` and can be one of `query`, -// `header`, or `cookie`. -func APIKeyAuth(name, keyName, in string) RouterOption { - return &routerOption{func(r *Router) { - r.api.SecuritySchemes[name] = &openAPISecurityScheme{ - Type: "apiKey", - Name: keyName, - In: in, - } - }} -} - -// JWTBearerAuth adds a named JWT bearer auth scheme using the Authorization -// header. -func JWTBearerAuth(name string) RouterOption { - return &routerOption{func(r *Router) { - r.api.SecuritySchemes[name] = &openAPISecurityScheme{ - Type: "http", - Scheme: "bearer", - BearerFormat: "JWT", - } - }} -} - -// Gin replaces the underlying Gin engine for this router. -func Gin(engine *gin.Engine) RouterOption { - return &routerOption{func(r *Router) { - r.engine = engine - }} -} - -// GinMiddleware attaches middleware to the router. -func GinMiddleware(middleware ...gin.HandlerFunc) RouterOption { - return &routerOption{func(r *Router) { - r.engine.Use(middleware...) - }} -} - -// PreStart registers a function to run before server start. Multiple can be -// passed and they will run in the order they were added. -func PreStart(f func()) RouterOption { - return &routerOption{func(r *Router) { - r.prestart = append(r.prestart, f) - }} -} - -// HTTPServer sets a custom `http.Server`. This can be used to set custom -// server-wide timeouts for example. -func HTTPServer(server *http.Server) RouterOption { - return &routerOption{func(r *Router) { - r.server = server - }} -} - -// DocsHandler sets the documentation rendering handler function. You can -// use `huma.RapiDocHandler`, `huma.ReDocHandler`, `huma.SwaggerUIHandler`, or -// provide your own (e.g. with custom auth or branding). -// -// DEPRECATED! Use `DocsDomType` instead! -func DocsHandler(f Handler) RouterOption { - fmt.Println("This option is deprecated, use `DocsDomType` instead") - return &routerOption{func(r *Router) { - r.docsHandler = f - }} -} - -// DocsDomType sets the presentation for the docs UI. Valid values are: -// RAPIDOCTYPE (default), REDOCTYPE, or SWAGGERDOCTYPE -func DocsDomType(t ApiUIDocType) RouterOption { - return &routerOption{func(r *Router) { - r.docsDomType = t - }} -} - -// CORSHandler sets the CORS handler function. This can be used to set custom -// domains, headers, auth, etc. If not given, then a default CORS handler is -// used instead. -func CORSHandler(f Handler) RouterOption { - return &routerOption{func(r *Router) { - r.corsHandler = f - }} -} - -// OpenAPIHook registers a function to be called after the OpenAPI spec is -// generated but before being sent to the client. -func OpenAPIHook(f func(*gabs.Container)) RouterOption { - return &routerOption{func(r *Router) { - r.api.Hook = f - }} -} - -// SimpleDependency adds a new dependency with just a value or function. -func SimpleDependency(handler interface{}) DependencyOption { - dep := &openAPIDependency{ - handler: handler, - } - - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, dep) - }} -} - -// Dependency adds a dependency. -func Dependency(option DependencyOption, handler interface{}) DependencyOption { - dep := newDependency(option, handler) - return &dependencyOption{func(d *openAPIDependency) { - d.dependencies = append(d.dependencies, dep) - }} -} - -// Example sets an example value, used for documentation and mocks. -func Example(value interface{}) ParamOption { - return ¶mOption{func(p *openAPIParam) { - p.Example = value - }} -} - -// Internal marks this parameter as internal-only, meaning it will not be -// included in the OpenAPI 3 JSON. Useful for things like auth headers set -// by a load balancer / gateway that never get seen by end-users. -func Internal() ParamOption { - return ¶mOption{func(p *openAPIParam) { - p.Internal = true - }} -} - -// Deprecated marks this parameter as deprecated. -func Deprecated() ParamOption { - return ¶mOption{func(p *openAPIParam) { - p.Deprecated = true - }} -} - -func newParamOption(name, description string, required bool, def interface{}, in paramLocation, options ...ParamOption) DependencyOption { - p := newOpenAPIParam(name, description, in, options...) - p.Required = required - p.def = def - - return &dependencyOption{func(d *openAPIDependency) { - d.params = append(d.params, p) - }} -} - -// PathParam adds a new required path parameter -func PathParam(name string, description string, options ...ParamOption) DependencyOption { - return newParamOption(name, description, true, nil, inPath, options...) -} - -// QueryParam returns a new optional query string parameter -func QueryParam(name string, description string, defaultValue interface{}, options ...ParamOption) DependencyOption { - return newParamOption(name, description, false, defaultValue, inQuery, options...) -} - -// HeaderParam returns a new optional header parameter -func HeaderParam(name string, description string, defaultValue interface{}, options ...ParamOption) DependencyOption { - return newParamOption(name, description, false, defaultValue, inHeader, options...) -} - -// ResponseHeader returns a new response header -func ResponseHeader(name, description string) DependencyOption { - r := &openAPIResponseHeader{ - Name: name, - Description: description, - } - - return &dependencyOption{func(d *openAPIDependency) { - d.responseHeaders = append(d.responseHeaders, r) - }} -} - -// OperationID manually sets the operation's unique ID. If not set, it will -// be auto-generated from the resource path and operation verb. -func OperationID(id string) OperationOption { - return &operationOption{func(o *openAPIOperation) { - o.id = id - }} -} - -// Tags sets one or more text tags on the operation. -func Tags(values ...string) OperationOption { - return &operationOption{func(o *openAPIOperation) { - o.tags = append(o.tags, values...) - }} -} - -// RequestContentType sets the request content type on the operation. -func RequestContentType(name string) OperationOption { - return &operationOption{func(o *openAPIOperation) { - o.requestContentType = name - }} -} - -// RequestSchema sets the request body schema on the operation. -func RequestSchema(schema *schema.Schema) OperationOption { - return &operationOption{func(o *openAPIOperation) { - o.requestSchema = schema - }} -} - -// ContentType sets the content type for this response. If blank, an empty -// response is returned. -func ContentType(value string) ResponseOption { - return &responseOption{func(r *openAPIResponse) { - r.ContentType = value - }} -} - -// Headers sets a list of allowed response headers. -func Headers(values ...string) ResponseOption { - return &responseOption{func(r *openAPIResponse) { - r.Headers = values - }} -} - -// Response adds a new response to the operation. -func Response(statusCode int, description string, options ...ResponseOption) OperationOption { - r := newOpenAPIResponse(statusCode, description, options...) - - return &operationOption{func(o *openAPIOperation) { - o.responses = append(o.responses, r) - }} -} - -// ResponseText adds a new string response to the operation. Alias for -func ResponseText(statusCode int, description string, options ...ResponseOption) OperationOption { - options = append(options, ContentType("text/plain")) - return Response(statusCode, description, options...) -} - -// ResponseJSON adds a new JSON response model to the operation. -func ResponseJSON(statusCode int, description string, options ...ResponseOption) OperationOption { - options = append(options, ContentType("application/json")) - return Response(statusCode, description, options...) -} - -// ResponseError adds a new error response model. This uses the RFC7807 -// application/problem+json response content type. -func ResponseError(statusCode int, description string, options ...ResponseOption) OperationOption { - options = append(options, ContentType("application/problem+json")) - return Response(statusCode, description, options...) -} - -// MaxBodyBytes sets the max number of bytes read from a request body before -// the handler aborts and returns an error. Applies to all sub-resources. -func MaxBodyBytes(value int64) OperationOption { - return &operationOption{func(o *openAPIOperation) { - o.maxBodyBytes = value - }} -} - -// BodyReadTimeout sets the duration after which the read is aborted and an -// error is returned. -func BodyReadTimeout(value time.Duration) OperationOption { - return &operationOption{func(o *openAPIOperation) { - o.bodyReadTimeout = value - }} -} diff --git a/resolver.go b/resolver.go new file mode 100644 index 00000000..b34fe891 --- /dev/null +++ b/resolver.go @@ -0,0 +1,391 @@ +package huma + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net" + "net/http" + "reflect" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi" + "github.com/istreamlabs/huma/schema" + "github.com/xeipuuv/gojsonschema" +) + +var timeType = reflect.TypeOf(time.Time{}) +var readerType = reflect.TypeOf((*io.Reader)(nil)).Elem() + +// Resolver provides a way to resolve input values from a request or to post- +// process input values in some way, including additional validation beyond +// what is possible with JSON Schema alone. If any errors are added to the +// context, then the client will get a 400 Bad Request response. +type Resolver interface { + Resolve(ctx Context, r *http.Request) +} + +// Checks if data validates against the given schema. Returns false on failure. +func validAgainstSchema(ctx *hcontext, label string, schema *schema.Schema, data []byte) bool { + defer func() { + // Catch panics from the `gojsonschema` library. + if err := recover(); err != nil { + ctx.AddError(&ErrorDetail{ + Message: fmt.Errorf("unable to validate against schema: %w", err.(error)).Error(), + Location: label, + Value: string(data), + }) + + // TODO: log error? + } + }() + + // TODO: load and pre-cache schemas once per operation + loader := gojsonschema.NewGoLoader(schema) + doc := gojsonschema.NewBytesLoader(data) + s, err := gojsonschema.NewSchema(loader) + if err != nil { + panic(err) + } + result, err := s.Validate(doc) + if err != nil { + panic(err) + } + + if !result.Valid() { + for _, desc := range result.Errors() { + // Note: some descriptions start with the context location so we trim + // those off to prevent duplicating data. (e.g. see the enum error) + ctx.AddError(&ErrorDetail{ + Message: strings.TrimLeft(desc.Description(), desc.Context().String()+" "), + Location: label + strings.TrimLeft(desc.Field(), "(root)"), + Value: desc.Value(), + }) + } + return false + } + + return true +} + +// parseParamValue parses and returns a value from its string representation +// based on the given type/format info. +func parseParamValue(ctx Context, location string, name string, typ reflect.Type, timeFormat string, pstr string) interface{} { + var pv interface{} + switch typ.Kind() { + case reflect.Bool: + converted, err := strconv.ParseBool(pstr) + if err != nil { + ctx.AddError(&ErrorDetail{ + Message: "cannot parse boolean", + Location: location + "." + name, + Value: pstr, + }) + return nil + } + 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 { + ctx.AddError(&ErrorDetail{ + Message: "cannot parse integer", + Location: location + "." + name, + Value: pstr, + }) + return nil + } + pv = reflect.ValueOf(converted).Convert(typ).Interface() + case reflect.Float32: + converted, err := strconv.ParseFloat(pstr, 32) + if err != nil { + ctx.AddError(&ErrorDetail{ + Message: "cannot parse float", + Location: location + "." + name, + Value: pstr, + }) + return nil + } + pv = float32(converted) + case reflect.Float64: + converted, err := strconv.ParseFloat(pstr, 64) + if err != nil { + ctx.AddError(&ErrorDetail{ + Message: "cannot parse float", + Location: location + "." + name, + Value: pstr, + }) + return nil + } + pv = converted + case reflect.Slice: + if len(pstr) > 1 && pstr[0] == '[' { + pstr = pstr[1 : len(pstr)-1] + } + slice := reflect.MakeSlice(typ, 0, 0) + for i, item := range strings.Split(pstr, ",") { + if itemValue := parseParamValue(ctx, fmt.Sprintf("%s[%d]", location, i), name, typ.Elem(), timeFormat, item); itemValue != nil { + slice = reflect.Append(slice, reflect.ValueOf(itemValue)) + } else { + // Keep going to check other array items for vailidity. + continue + } + } + pv = slice.Interface() + default: + if typ == timeType { + dt, err := time.Parse(timeFormat, pstr) + if err != nil { + ctx.AddError(&ErrorDetail{ + Message: "cannot parse time", + Location: location + "." + name, + Value: pstr, + }) + return nil + } + pv = dt + } else { + pv = pstr + } + } + + return pv +} + +func setFields(ctx *hcontext, req *http.Request, input reflect.Value, t reflect.Type) { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + if input.Kind() == reflect.Ptr { + input = input.Elem() + } + + if t.Kind() != reflect.Struct { + panic("not a struct") + } + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + inField := input.Field(i) + + if f.Anonymous { + // Embedded struct + setFields(ctx, req, inField, f.Type) + continue + } + + if _, ok := f.Tag.Lookup("body"); ok || f.Name == "Body" { + // Special case: body field is a reader for streaming + if f.Type == readerType { + inField.Set(reflect.ValueOf(req.Body)) + continue + } + + // Check if a content-length has been sent. If it's too big then there + // is no need to waste time reading. + if length := req.Header.Get("Content-Length"); length != "" { + if l, err := strconv.ParseInt(length, 10, 64); err == nil { + if l > ctx.op.maxBodyBytes { + ctx.AddError(&ErrorDetail{ + Message: fmt.Sprintf("Request body too large, limit = %d bytes", ctx.op.maxBodyBytes), + Location: "body", + Value: length, + }) + continue + } + } + } + + // Load the body (read/unmarshal). + data, err := ioutil.ReadAll(req.Body) + if err != nil { + if strings.Contains(err.Error(), "request body too large") { + ctx.AddError(&ErrorDetail{ + Message: fmt.Sprintf("Request body too large, limit = %d bytes", ctx.op.maxBodyBytes), + Location: "body", + }) + } else if e, ok := err.(net.Error); ok && e.Timeout() { + ctx.AddError(&ErrorDetail{ + Message: fmt.Sprintf("Request body took too long to read: timed out after %v", ctx.op.bodyReadTimeout), + Location: "body", + }) + } else { + panic(err) + } + continue + } + + if ctx.op.requestSchema != nil && ctx.op.requestSchema.HasValidation() { + if !validAgainstSchema(ctx, "body.", ctx.op.requestSchema, data) { + continue + } + } + + err = json.Unmarshal(data, inField.Addr().Interface()) + if err != nil { + panic(err) + } + continue + } + + var pv string + var pname string + var location string + timeFormat := time.RFC3339Nano + if v, ok := f.Tag.Lookup("default"); ok { + pv = v + } + + if name, ok := f.Tag.Lookup("path"); ok { + pname = name + location = "path" + if v := chi.URLParam(req, name); v != "" { + pv = v + } + } + + if name, ok := f.Tag.Lookup("query"); ok { + pname = name + location = "query" + if v := req.URL.Query().Get(name); v != "" { + pv = v + } + } + + if name, ok := f.Tag.Lookup("header"); ok { + pname = name + location = "header" + // TODO: get combined rather than first header? + if v := req.Header.Get(name); v != "" { + pv = v + } + + // Some headers have special time formats that aren't ISO8601/RFC3339. + lowerName := strings.ToLower(name) + if lowerName == "if-modified-since" || lowerName == "if-unmodified-since" { + timeFormat = http.TimeFormat + } + } + + if pv != "" { + // Parse value into the right type. + parsed := parseParamValue(ctx, location, pname, f.Type, timeFormat, pv) + if parsed == nil { + // At least one error, just keep going trying to parse other fields. + continue + } + + if oap, ok := ctx.op.params[pname]; ok { + s := oap.Schema + if s.HasValidation() { + data := pv + if s.Type == "string" { + // Strings are special in that we don't expect users to provide them + // with quotes, so wrap them here for the parser that does the + // validation step below. + data = `"` + data + `"` + } else if s.Type == "array" { + // Array type needs to have `[` and `]` added. + if s.Items.Type == "string" { + // Same as above, quote each item. + data = `"` + strings.Join(strings.Split(data, ","), `","`) + `"` + } + if len(data) > 0 && data[0] != '[' { + data = "[" + data + "]" + } + } + + if !validAgainstSchema(ctx, location+"."+pname, s, []byte(data)) { + continue + } + } + } + + inField.Set(reflect.ValueOf(parsed)) + } + } + + // Resolve after all other fields are set so the resolver can use them, + // and also so that any embedded structs are resolved first. + if input.CanInterface() { + if resolver, ok := input.Addr().Interface().(Resolver); ok { + resolver.Resolve(ctx, req) + } + } +} + +// getParamInfo recursively gets info about params from an input struct. It +// returns a map of parameter name => parameter object. +func getParamInfo(t reflect.Type) map[string]oaParam { + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + if t.Kind() != reflect.Struct { + panic("not a struct") + } + + params := map[string]oaParam{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + + if f.Anonymous { + // Embedded struct + for k, v := range getParamInfo(f.Type) { + params[k] = v + } + continue + } + + p := oaParam{} + + if name, ok := f.Tag.Lookup("path"); ok { + p.Name = name + p.In = inPath + p.Required = true + } + + if name, ok := f.Tag.Lookup("query"); ok { + p.Name = name + p.In = inQuery + p.Explode = new(bool) + } + + if name, ok := f.Tag.Lookup("header"); ok { + p.Name = name + p.In = inHeader + } + + if p.Name == "" { + // This is not a known param. May be filled in later by a resolver so + // we shouldn't touch it. Skip! + continue + } + + if doc, ok := f.Tag.Lookup("doc"); ok { + p.Description = doc + } + + if deprecated, ok := f.Tag.Lookup("deprecated"); ok { + p.Deprecated = deprecated == "true" + } + + if internal, ok := f.Tag.Lookup("internal"); ok { + p.Internal = internal == "true" + } + + _, _, s, err := schema.GenerateFromField(f, schema.ModeRead) + if err != nil { + panic(err) + } + p.Schema = s + + params[p.Name] = p + } + + return params +} diff --git a/resolver_test.go b/resolver_test.go new file mode 100644 index 00000000..33db7bdf --- /dev/null +++ b/resolver_test.go @@ -0,0 +1,37 @@ +package huma + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestExhaustiveErrors(t *testing.T) { + type Input struct { + BoolParam bool `query:"bool"` + IntParam int `query:"int"` + Float32Param float32 `query:"float32"` + Float64Param float64 `query:"float64"` + Tags []int `query:"tags"` + Time time.Time `query:"time"` + Body struct { + Value int `json:"value" minimum:"5"` + } + } + + app := newTestRouter() + + app.Resource("/").Get("test", "Test").Run(func(ctx Context, input Input) { + // Do nothing + }) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/?bool=bad&int=bad&float32=bad&float64=bad&tags=1,2,bad&time=bad", strings.NewReader(`{"value": 1}`)) + app.ServeHTTP(w, r) + + assert.JSONEq(t, `{"title":"Bad Request","status":400,"detail":"Error while parsing input parameters","errors":[{"message":"cannot parse boolean","location":"query.bool","value":"bad"},{"message":"cannot parse integer","location":"query.int","value":"bad"},{"message":"cannot parse float","location":"query.float32","value":"bad"},{"message":"cannot parse float","location":"query.float64","value":"bad"},{"message":"cannot parse integer","location":"query[2].tags","value":"bad"},{"message":"unable to validate against schema: invalid character 'b' looking for beginning of value","location":"query.tags","value":"[1,2,bad]"},{"message":"cannot parse time","location":"query.time","value":"bad"},{"message":"Must be greater than or equal to 5","location":"body.value","value":1}]}`, w.Body.String()) +} diff --git a/resource.go b/resource.go index d4312c91..8f663f56 100644 --- a/resource.go +++ b/resource.go @@ -2,194 +2,104 @@ package huma import ( "net/http" - "reflect" "strings" + + "github.com/Jeffail/gabs/v2" + "github.com/go-chi/chi" ) -// Resource describes a REST resource at a given URI path. Resources are -// typically created from a router or as a sub-resource of an existing resource. +// Resource represents an API resource attached to a router at a specific path +// (URI template). Resources can have operations or subresources attached to +// them. type Resource struct { - *openAPIOperation - router *Router path string -} - -// NewResource creates a new resource with the given router and path. All -// dependencies, security requirements, params, headers, and responses are -// empty. -func NewResource(router *Router, path string, options ...ResourceOption) *Resource { - r := &Resource{ - openAPIOperation: newOperation(), - router: router, - path: path, - } - - for _, option := range options { - option.ApplyResource(r) - } + mux chi.Router + router *Router - return r -} + subResources []*Resource + operations []*Operation -// Copy the resource. New arrays are created for dependencies, security -// requirements, params, response headers, and responses but the underlying -// pointer values themselves are the same. -func (r *Resource) Copy() *Resource { - return &Resource{ - openAPIOperation: r.openAPIOperation.Copy(), - router: r.router, - path: r.path, - } + tags []string } -// With returns a copy of this resource with the given dependencies, security -// requirements, params, response headers, or responses added to it. -func (r *Resource) With(options ...ResourceOption) *Resource { - c := r.Copy() +func (r *Resource) toOpenAPI() *gabs.Container { + doc := gabs.New() - for _, option := range options { - option.ApplyResource(c) + for _, sub := range r.subResources { + doc.Merge(sub.toOpenAPI()) } - return c -} - -// Path returns the generated path including any path parameters. -func (r *Resource) Path() string { - generated := r.path - - for _, p := range r.params { - if p.In == "path" { - component := "{" + p.Name + "}" - if !strings.Contains(generated, component) { - if !strings.HasSuffix(generated, "/") { - generated += "/" - } - generated += component - } - } + for _, op := range r.operations { + doc.Set(op.toOpenAPI(), r.path, strings.ToLower(op.method)) } - return generated + return doc } -// PathParams returns the name of all path parameters. -func (r *Resource) PathParams() []string { - params := make([]string, len(r.params)) - - for i, p := range r.params { - params[i] = p.Name - } +// Operation creates a new HTTP operation with the given method at this resource. +func (r *Resource) Operation(method, operationID, docs string, responses ...Response) *Operation { + op := newOperation(r, method, operationID, docs, responses) + r.operations = append(r.operations, op) - return params + return op } -// SubResource creates a new resource at the given path, which is appended -// to the existing resource path after adding any existing path parameters. -func (r *Resource) SubResource(path string, options ...ResourceOption) *Resource { - // Apply all existing params to the path. - newPath := r.Path() - - // Apply the new passed-in path component. - if !strings.HasSuffix(newPath, "/") { - newPath += "/" - } - if strings.HasPrefix(path, "/") { - path = path[1:] - } - newPath += path - - // Clone the resource and update the path. - c := r.With(options...) - c.path = newPath - - return c +// Post creates a new HTTP POST operation at this resource. +func (r *Resource) Post(operationID, docs string, responses ...Response) *Operation { + return r.Operation(http.MethodPost, operationID, docs, responses...) } -// Operation adds the operation to this resource's router with all the -// combined deps, security requirements, params, headers, responses, etc. -func (r *Resource) operation(method string, docs string, handler interface{}) { - summary, desc := splitDocs(docs) - - // Copy the operation and set new fields. - op := r.openAPIOperation.Copy() - op.summary = summary - op.description = desc - - op.handler = handler - if op.handler != nil { - // Only apply auto-response if it's *not* an unsafe handler. - if !op.unsafe() { - t := reflect.TypeOf(op.handler) - if t.NumOut() == len(op.responseHeaders)+len(op.responses)+1 { - rtype := t.Out(t.NumOut() - 1) - switch rtype.Kind() { - case reflect.Bool: - op = op.With(Response(http.StatusNoContent, "Success")) - case reflect.String: - op = op.With(ResponseText(http.StatusOK, "Success")) - default: - op = op.With(ResponseJSON(http.StatusOK, "Success")) - } - } - } - } - - // Update path with any required path parameters if they are not yet present. - allParams := append([]*openAPIParam{}, r.params...) - allParams = append(allParams, op.params...) - path := r.path - for _, p := range allParams { - if p.In == "path" { - component := "{" + p.Name + "}" - if !strings.Contains(path, component) { - if !strings.HasSuffix(path, "/") { - path += "/" - } - path += component - } - } - } - - r.router.register(method, path, op) +// Head creates a new HTTP HEAD operation at this resource. +func (r *Resource) Head(operationID, docs string, responses ...Response) *Operation { + return r.Operation(http.MethodHead, operationID, docs, responses...) } -// Head creates an HTTP HEAD operation on the resource. -func (r *Resource) Head(docs string, handler interface{}) { - r.operation(http.MethodHead, docs, handler) +// Get creates a new HTTP GET operation at this resource. +func (r *Resource) Get(operationID, docs string, responses ...Response) *Operation { + return r.Operation(http.MethodGet, operationID, docs, responses...) } -// List is an alias for `Get`. -func (r *Resource) List(docs string, handler interface{}) { - r.Get(docs, handler) +// Put creates a new HTTP PUT operation at this resource. +func (r *Resource) Put(operationID, docs string, responses ...Response) *Operation { + return r.Operation(http.MethodPut, operationID, docs, responses...) } -// Get creates an HTTP GET operation on the resource. -func (r *Resource) Get(docs string, handler interface{}) { - r.operation(http.MethodGet, docs, handler) +// Patch creates a new HTTP PATCH operation at this resource. +func (r *Resource) Patch(operationID, docs string, responses ...Response) *Operation { + return r.Operation(http.MethodPatch, operationID, docs, responses...) } -// Post creates an HTTP POST operation on the resource. -func (r *Resource) Post(docs string, handler interface{}) { - r.operation(http.MethodPost, docs, handler) +// Delete creates a new HTTP DELETE operation at this resource. +func (r *Resource) Delete(operationID, docs string, responses ...Response) *Operation { + return r.Operation(http.MethodDelete, operationID, docs, responses...) } -// Put creates an HTTP PUT operation on the resource. -func (r *Resource) Put(docs string, handler interface{}) { - r.operation(http.MethodPut, docs, handler) +// Middleware adds a new standard middleware to this resource, so it will +// apply to requests at the resource's path (including any subresources). +// Middleware can also be applied at the router level to apply to all requests. +func (r *Resource) Middleware(middlewares ...func(next http.Handler) http.Handler) { + r.mux.Use(middlewares...) } -// Patch creates an HTTP PATCH operation on the resource. -func (r *Resource) Patch(docs string, handler interface{}) { - r.operation(http.MethodPatch, docs, handler) -} +// SubResource creates a new resource attached to this resource. The passed +// path will be appended to the resource's existing path. The path can +// include parameters, e.g. `/things/{thing-id}`. Each resource path must +// be unique. +func (r *Resource) SubResource(path string) *Resource { + sub := &Resource{ + path: r.path + path, + mux: r.mux.Route(path, nil), + subResources: []*Resource{}, + operations: []*Operation{}, + tags: append([]string{}, r.tags...), + } + + r.subResources = append(r.subResources, sub) -// Delete creates an HTTP DELETE operation on the resource. -func (r *Resource) Delete(docs string, handler interface{}) { - r.operation(http.MethodDelete, docs, handler) + return sub } -// Options creates an HTTP OPTIONS operation on the resource. -func (r *Resource) Options(docs string, handler interface{}) { - r.operation(http.MethodOptions, docs, handler) +// Tags appends to the list of tags, used for documentation. +func (r *Resource) Tags(names ...string) { + r.tags = append(r.tags, names...) } diff --git a/resource_test.go b/resource_test.go deleted file mode 100644 index 2be1e87a..00000000 --- a/resource_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package huma - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNewResourceOption(t *testing.T) { - r := NewResource(nil, "/test", PathParam("id", "desc")) - - assert.NotEmpty(t, r.params) -} - -func TestResourceCopy(t *testing.T) { - r1 := NewResource(nil, "/test") - r2 := r1.Copy() - - assert.NotSame(t, r1.dependencies, r2.dependencies) - assert.NotSame(t, r1.params, r2.params) - assert.NotSame(t, r1.responseHeaders, r2.responseHeaders) - assert.NotSame(t, r1.responses, r2.responses) -} - -func TestResourceWithDep(t *testing.T) { - dep1 := SimpleDependency("dep1") - dep2 := SimpleDependency("dep2") - - r1 := NewResource(nil, "/test") - r2 := r1.With(dep1) - r3 := r1.With(dep2) - - assert.NotEmpty(t, r2.dependencies) - assert.NotEmpty(t, r3.dependencies) - - assert.NotSame(t, r2.dependencies[0], r3.dependencies[0]) -} - -func TestResourceWithSecurity(t *testing.T) { - sec1 := SecurityRef("sec1") - sec2 := SecurityRef("sec2") - - r1 := NewResource(nil, "/test") - r2 := r1.With(sec1) - r3 := r1.With(sec2) - - assert.NotEmpty(t, r2.security) - assert.NotEmpty(t, r3.security) - - assert.NotSame(t, r2.security[0], r3.security[0]) -} - -func TestResourceWithParam(t *testing.T) { - param1 := PathParam("p1", "desc") - param2 := PathParam("p2", "desc") - - r1 := NewResource(nil, "/test") - r2 := r1.With(param1) - r3 := r1.With(param2) - - assert.NotEmpty(t, r2.params) - assert.NotEmpty(t, r3.params) - - assert.NotSame(t, r2.params[0], r3.params[0]) - - assert.Equal(t, "/test/{p1}", r2.Path()) - assert.Equal(t, "/test/{p2}", r3.Path()) -} - -func TestResourceWithHeader(t *testing.T) { - header1 := ResponseHeader("h1", "desc") - header2 := ResponseHeader("h2", "desc") - - r1 := NewResource(nil, "/test") - r2 := r1.With(header1) - r3 := r1.With(header2) - - assert.NotEmpty(t, r2.responseHeaders) - assert.NotEmpty(t, r3.responseHeaders) - - assert.NotSame(t, r2.responseHeaders[0], r3.responseHeaders[0]) -} - -func TestResourceWithResponse(t *testing.T) { - resp1 := ResponseText(200, "desc") - resp2 := ResponseText(201, "desc2") - - r1 := NewResource(nil, "/test") - r2 := r1.With(resp1) - r3 := r1.With(resp2) - - assert.NotEmpty(t, r2.responses) - assert.NotEmpty(t, r3.responses) - - assert.NotSame(t, r2.responses[0], r3.responses[0]) -} - -func TestSubResource(t *testing.T) { - r1 := NewResource(nil, "/tests").With(PathParam("testId", "desc")) - r2 := r1.SubResource("/results", PathParam("resultId", "desc")) - r3 := r2.With(PathParam("format", "desc")) - - assert.Equal(t, "/tests/{testId}", r1.Path()) - assert.Equal(t, "/tests/{testId}/results/{resultId}", r2.Path()) - assert.Equal(t, "/tests/{testId}/results/{resultId}/{format}", r3.Path()) -} - -func TestResourceWithAddedParam(t *testing.T) { - r := NewTestRouter(t) - res := NewResource(r, "/test") - res. - With( - PathParam("q", "desc"), - ResponseText(http.StatusOK, "desc")). - Get("desc", - func(q string) string { - return q - }, - ) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/test/hello", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "hello", w.Body.String()) -} - -var resourceFuncsTest = []string{ - "Head", "List", "Get", "Post", "Put", "Patch", "Delete", "Options", -} - -func TestResourceFuncs(outer *testing.T) { - for _, tt := range resourceFuncsTest { - local := tt - outer.Run(fmt.Sprintf("%v", tt), func(t *testing.T) { - r := NewTestRouter(t) - res := NewResource(r, "/test") - - var f func(string, interface{}) - - switch local { - case "Head": - f = res.Head - case "List": - f = res.List - case "Get": - f = res.Get - case "Post": - f = res.Post - case "Put": - f = res.Put - case "Patch": - f = res.Patch - case "Delete": - f = res.Delete - case "Options": - f = res.Options - default: - panic("invalid case " + local) - } - - // Registering it should not panic. - f("desc", func() bool { - return true - }) - }) - } -} - -func TestResourceAutoJSON(t *testing.T) { - r := NewTestRouter(t) - - type MyResponse struct{} - - // Registering the handler should not panic - r.Resource("/test").Get("desc", func() *MyResponse { - return &MyResponse{} - }) - - assert.Equal(t, http.StatusOK, r.api.Paths["/test"][http.MethodGet].responses[0].StatusCode) - assert.Equal(t, "application/json", r.api.Paths["/test"][http.MethodGet].responses[0].ContentType) -} - -func TestResourceAutoText(t *testing.T) { - r := NewTestRouter(t) - - // Registering the handler should not panic - r.Resource("/test").Get("desc", func() string { - return "Hello, world" - }) - - assert.Equal(t, http.StatusOK, r.api.Paths["/test"][http.MethodGet].responses[0].StatusCode) - assert.Equal(t, "text/plain", r.api.Paths["/test"][http.MethodGet].responses[0].ContentType) -} - -func TestResourceAutoNoContent(t *testing.T) { - r := NewTestRouter(t) - - // Registering the handler should not panic - r.Resource("/test").Get("desc", func() bool { - return true - }) - - assert.Equal(t, http.StatusNoContent, r.api.Paths["/test"][http.MethodGet].responses[0].StatusCode) - assert.Equal(t, "", r.api.Paths["/test"][http.MethodGet].responses[0].ContentType) -} - -func TestResourceGetPathParams(t *testing.T) { - r := NewTestRouter(t) - - res := r.Resource("/test", PathParam("foo", "desc"), PathParam("bar", "desc")) - - assert.Equal(t, []string{"foo", "bar"}, res.PathParams()) -} - -func TestResourceUnsafeHandler(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/unsafe").Get("doc", UnsafeHandler(func(inputs ...interface{}) []interface{} { - return []interface{}{true} - })) - }) - - assert.NotPanics(t, func() { - r.Resource("/unsafe", - Response(http.StatusNoContent, "doc"), - ).Get("doc", UnsafeHandler(func(inputs ...interface{}) []interface{} { - return []interface{}{true} - })) - }) -} diff --git a/response.go b/response.go new file mode 100644 index 00000000..222da0e4 --- /dev/null +++ b/response.go @@ -0,0 +1,92 @@ +package huma + +import ( + "reflect" + "strings" +) + +// Response describes an HTTP response that can be returned from an operation. +type Response struct { + description string + status int + contentType string + headers []string + model reflect.Type +} + +// NewResponse creates a new response representation. +func NewResponse(status int, description string) Response { + return Response{ + status: status, + description: description, + } +} + +// GetStatus returns the response's HTTP status code. +func (r Response) GetStatus() int { + return r.status +} + +// ContentType sets the response's content type header. +func (r Response) ContentType(ct string) Response { + return Response{ + description: r.description, + status: r.status, + contentType: ct, + headers: r.headers, + model: r.model, + } +} + +// Headers returns a new response with the named headers added. Sending +// headers to the client is optional, but they must be named here before +// you can send them. +func (r Response) Headers(names ...string) Response { + headers := r.headers + if headers == nil { + headers = []string{} + } + + return Response{ + description: r.description, + status: r.status, + contentType: r.contentType, + headers: append(headers, names...), + model: r.model, + } +} + +// Model returns a new response with the given model representing the body. +// Because Go cannot pass types, `bodyModel` should be an instance of the +// response body. +func (r Response) Model(bodyModel interface{}) Response { + // Add a content type if none has been set. We prefer JSON since it's easy to + // represent in OpenAPI. Content negotiation means we also support other + // content types which the client can dynamically request. + ct := r.contentType + if ct == "" { + ct = "application/json" + } + + // Allow the `Content-Type` header if not already allowed. + found := false + for _, h := range r.headers { + if strings.ToLower(h) == "content-type" { + found = true + break + } + } + + headers := r.headers + if !found { + headers = append(headers, "Content-Type") + } + + return Response{ + description: r.description, + status: r.status, + contentType: ct, + headers: headers, + model: reflect.TypeOf(bodyModel), + } +} diff --git a/responses/responses.go b/responses/responses.go new file mode 100644 index 00000000..e026dd6a --- /dev/null +++ b/responses/responses.go @@ -0,0 +1,135 @@ +package responses + +import ( + "net/http" + + "github.com/istreamlabs/huma" +) + +func newResponse(status int) huma.Response { + return huma.NewResponse(status, http.StatusText(status)) +} + +var response func(int) huma.Response = newResponse + +func errorResponse(status int) huma.Response { + return response(status). + ContentType("application/json"). + Model(&huma.ErrorModel{}) +} + +// OK HTTP 200 response. +func OK() huma.Response { + return response(http.StatusOK) +} + +// Created HTTP 201 response. +func Created() huma.Response { + return response(http.StatusCreated) +} + +// Accepted HTTP 202 response. +func Accepted() huma.Response { + return response(http.StatusAccepted) +} + +// NoContent HTTP 204 response. +func NoContent() huma.Response { + return response(http.StatusNoContent) +} + +// MovedPermanently HTTP 301 response. +func MovedPermanently() huma.Response { + return response(http.StatusMovedPermanently) +} + +// Found HTTP 302 response. +func Found() huma.Response { + return response(http.StatusFound) +} + +// NotModified HTTP 304 response. +func NotModified() huma.Response { + return response(http.StatusNotModified) +} + +// TemporaryRedirect HTTP 307 response. +func TemporaryRedirect() huma.Response { + return response(http.StatusTemporaryRedirect) +} + +// PermanentRedirect HTTP 308 response. +func PermanentRedirect() huma.Response { + return response(http.StatusPermanentRedirect) +} + +// BadRequest HTTP 400 response with a structured error body (e.g. JSON). +func BadRequest() huma.Response { + return errorResponse(http.StatusBadRequest) +} + +// Unauthorized HTTP 401 response with a structured error body (e.g. JSON). +func Unauthorized() huma.Response { + return errorResponse(http.StatusUnauthorized) +} + +// Forbidden HTTP 403 response with a structured error body (e.g. JSON). +func Forbidden() huma.Response { + return errorResponse(http.StatusForbidden) +} + +// NotFound HTTP 404 response with a structured error body (e.g. JSON). +func NotFound() huma.Response { + return errorResponse(http.StatusNotFound) +} + +// RequestTimeout HTTP 408 response with a structured error body (e.g. JSON). +func RequestTimeout() huma.Response { + return errorResponse(http.StatusRequestTimeout) +} + +// Conflict HTTP 409 response with a structured error body (e.g. JSON). +func Conflict() huma.Response { + return errorResponse(http.StatusConflict) +} + +// PreconditionFailed HTTP 412 response with a structured error body (e.g. JSON). +func PreconditionFailed() huma.Response { + return errorResponse(http.StatusPreconditionFailed) +} + +// RequestEntityTooLarge HTTP 413 response with a structured error body (e.g. JSON). +func RequestEntityTooLarge() huma.Response { + return errorResponse(http.StatusRequestEntityTooLarge) +} + +// PreconditionRequired HTTP 428 response with a structured error body (e.g. JSON). +func PreconditionRequired() huma.Response { + return errorResponse(http.StatusPreconditionRequired) +} + +// InternalServerError HTTP 500 response with a structured error body (e.g. JSON). +func InternalServerError() huma.Response { + return errorResponse(http.StatusInternalServerError) +} + +// BadGateway HTTP 502 response with a structured error body (e.g. JSON). +func BadGateway() huma.Response { + return errorResponse(http.StatusBadGateway) +} + +// ServiceUnavailable HTTP 503 response with a structured error body (e.g. JSON). +func ServiceUnavailable() huma.Response { + return errorResponse(http.StatusServiceUnavailable) +} + +// GatewayTimeout HTTP 504 response with a structured error body (e.g. JSON). +func GatewayTimeout() huma.Response { + return errorResponse(http.StatusGatewayTimeout) +} + +// String HTTP response with the given status code and `text/plain` content +// type. +func String(status int) huma.Response { + return response(status).ContentType("text/plain") +} diff --git a/responses/responses_test.go b/responses/responses_test.go new file mode 100644 index 00000000..bcf59a94 --- /dev/null +++ b/responses/responses_test.go @@ -0,0 +1,92 @@ +package responses + +import ( + "net/http" + "reflect" + "runtime" + "strings" + "testing" + + "github.com/istreamlabs/huma" + "github.com/stretchr/testify/assert" +) + +var funcs = struct { + Responses []func() huma.Response +}{ + Responses: []func() huma.Response{ + OK, + Created, + Accepted, + NoContent, + MovedPermanently, + Found, + NotModified, + TemporaryRedirect, + PermanentRedirect, + BadRequest, + Unauthorized, + Forbidden, + NotFound, + RequestTimeout, + Conflict, + PreconditionFailed, + RequestEntityTooLarge, + PreconditionRequired, + InternalServerError, + BadGateway, + ServiceUnavailable, + GatewayTimeout, + }, +} + +func TestResponses(t *testing.T) { + var status int + response = func(s int) huma.Response { + status = s + return newResponse(s) + } + + table := map[string]int{} + for _, s := range []int{ + http.StatusOK, + http.StatusCreated, + http.StatusAccepted, + http.StatusNoContent, + http.StatusMovedPermanently, + http.StatusFound, + http.StatusNotModified, + http.StatusTemporaryRedirect, + http.StatusPermanentRedirect, + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusNotFound, + http.StatusRequestTimeout, + http.StatusConflict, + http.StatusPreconditionFailed, + http.StatusRequestEntityTooLarge, + http.StatusPreconditionRequired, + http.StatusInternalServerError, + http.StatusBadGateway, + http.StatusServiceUnavailable, + http.StatusGatewayTimeout, + } { + table[strings.Replace(http.StatusText(s), " ", "", -1)] = s + } + + for _, f := range funcs.Responses { + parts := strings.Split(runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name(), ".") + name := parts[len(parts)-1] + t.Run(name, func(t *testing.T) { + f() + + // The response we created has the right status code given the creation + // func name. + assert.Equal(t, table[name], status) + }) + } + + String(http.StatusOK) + assert.Equal(t, 200, status) +} diff --git a/router.go b/router.go index 31bf3da5..4995f1d9 100644 --- a/router.go +++ b/router.go @@ -1,696 +1,174 @@ -// Package huma is a modern, simple, fast & opinionated REST API framework. -// Based on OpenAPI 3 and JSON Schema so it can be used to automatically -// generate an OpenAPI spec, interactive documentation, client SDKs in many -// languages, and a CLI for scripting. Pronounced IPA: /'hjuːmɑ/. -// -// Start by creating a `Router` and attaching resources and operations to -// it: -// -// // Create a new router -// r := huma.NewRouter("Ping API", "1.0.0") -// -// // Add a simple ping/pong -// r.Resource("/ping").Get("Ping", func() string { -// return "pong" -// }) -// -// // Run it! -// r.Run() -// -// Now you can access the API, generated documentation, and API description: -// -// # Access the API -// $ curl http://localhost:8888/hello -// -// # Read the generated documentation -// $ open http://localhost:8888/docs -// -// # See the OpenAPI 3 spec -// $ curl http://localhost:8888/openapi.json -// -// You can add tests with the help of the `humatest` module: -// -// func TestPint(t *testing.T) { -// r := humatest.NewRouter(t) -// -// // Add your service routes to the test router. -// registerRoutes(r) -// -// // Make a test request. -// w := httptest.NewRecorder() -// req, _ := http.NewRequest(http.MethodGet, "/ping", nil) -// r.ServeHTTP(w, req) -// assert.Equal(t, http.StatusOK, w.Code) -// assert.Equal(t, "pong", w.Body.String()) -// } -// -// See https://github.com/istreamlabs/huma#readme for more high-level feature -// docs with examples. package huma import ( - "bytes" "context" - "errors" "fmt" - "io/ioutil" "net" "net/http" - "os" - "reflect" - "strconv" - "strings" "sync" "time" - "github.com/fxamacker/cbor/v2" - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - "github.com/istreamlabs/huma/schema" - "github.com/spf13/cobra" - "github.com/xeipuuv/gojsonschema" - "go.uber.org/zap" + "github.com/Jeffail/gabs/v2" + "github.com/go-chi/chi" ) -// ErrInvalidParamLocation is returned when the `in` field of the parameter -// is not a valid value. -var ErrInvalidParamLocation = errors.New("invalid parameter location") +type contextKey string // connContextKey is used to get/set the underlying `net.Conn` from a request // context value. -var connContextKey = struct{}{} +var connContextKey contextKey = "huma-request-conn" -var timeType = reflect.TypeOf(time.Time{}) - -type unsafeHandler struct { - handler func(inputs ...interface{}) []interface{} -} - -// UnsafeHandler is used to register programmatic handlers without argument -// count and type checking. This is useful for libraries that want to -// programmatically create new resources/operations. Using UnsafeHandler outside -// of that use-case is discouraged. -// -// The function's inputs are the ordered resolved dependencies, parsed -// parameters, and potentially an input body for PUT/POST requests that have -// a request schema defined. The output is a slice of response headers and -// response models. -// -// When using UnsafeHandler, you must manually define schemas for request -// and response bodies. They will be unmarshalled as `interface{}` when -// passed to the handler. -func UnsafeHandler(handler func(inputs ...interface{}) []interface{}) interface{} { - return &unsafeHandler{handler} -} - -// getConn gets the underlying `net.Conn` from a request. -func getConn(r *http.Request) net.Conn { - conn := r.Context().Value(connContextKey) +// GetConn gets the underlying `net.Conn` from a context. +func GetConn(ctx context.Context) net.Conn { + conn := ctx.Value(connContextKey) if conn != nil { return conn.(net.Conn) } return nil } -// abortWithError is a convenience function for setting an error on a Gin -// context with a detail string and optional error strings. -func abortWithError(c *gin.Context, status int, detail string, errors ...string) { - c.Header("content-type", "application/problem+json") - c.AbortWithStatusJSON(status, &ErrorModel{ - Status: status, - Title: http.StatusText(status), - Detail: detail, - Errors: errors, - }) -} - -// Checks if data validates against the given schema. Returns false on failure. -func validAgainstSchema(c *gin.Context, label string, schema *schema.Schema, data []byte) bool { - defer func() { - // Catch panics from the `gojsonschema` library. - if err := recover(); err != nil { - abortWithError(c, http.StatusBadRequest, "Invalid input: "+label, err.(error).Error()+": "+string(data)) - } - }() - - loader := gojsonschema.NewGoLoader(schema) - doc := gojsonschema.NewBytesLoader(data) - s, err := gojsonschema.NewSchema(loader) - if err != nil { - panic(err) - } - result, err := s.Validate(doc) - if err != nil { - panic(err) - } +// Router is the entrypoint to your API. +type Router struct { + mux *chi.Mux + resources []*Resource + + title string + version string + description string + contact oaContact + servers []oaServer + // securitySchemes + // security + + // Documentation handler function + docsPrefix string + docsHandler http.Handler + docsAreSetup bool - if !result.Valid() { - errors := []string{} - for _, desc := range result.Errors() { - errors = append(errors, fmt.Sprintf("%s", desc)) - } - abortWithError(c, http.StatusBadRequest, "Invalid input: "+label, errors...) - return false - } + // Tracks the currently running server for graceful shutdown. + server *http.Server + serverLock sync.Mutex - return true + // Allows modification of the generated OpenAPI. + openapiHook func(*gabs.Container) } -func parseParamValue(c *gin.Context, name string, typ reflect.Type, timeFormat string, pstr string) (interface{}, bool) { - var pv interface{} - switch typ.Kind() { - case reflect.Bool: - converted, err := strconv.ParseBool(pstr) - if err != nil { - abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse boolean for param %s: %s", name, pstr)) - return nil, false - } - 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 { - abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse integer for param %s: %s", name, pstr)) - return nil, false - } - pv = reflect.ValueOf(converted).Convert(typ).Interface() - case reflect.Float32: - converted, err := strconv.ParseFloat(pstr, 32) - if err != nil { - abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse float for param %s: %s", name, pstr)) - return nil, false - } - pv = float32(converted) - case reflect.Float64: - converted, err := strconv.ParseFloat(pstr, 64) - if err != nil { - abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse float for param %s: %s", name, pstr)) - return nil, false - } - pv = converted - case reflect.Slice: - if len(pstr) > 1 && pstr[0] == '[' { - pstr = pstr[1 : len(pstr)-1] - } - slice := reflect.MakeSlice(typ, 0, 0) - for _, item := range strings.Split(pstr, ",") { - if itemValue, ok := parseParamValue(c, name, typ.Elem(), timeFormat, item); ok { - slice = reflect.Append(slice, reflect.ValueOf(itemValue)) - } else { - // Error is already handled, just return. - return nil, false - } - } - pv = slice.Interface() - default: - if typ == timeType { - dt, err := time.Parse(timeFormat, pstr) - if err != nil { - abortWithError(c, http.StatusBadRequest, fmt.Sprintf("cannot parse time for param %s: %s", name, pstr)) - return nil, false - } - pv = dt - } else { - pv = pstr - } - } - - return pv, true -} +// OpenAPI returns an OpenAPI 3 representation of the API, which can be +// modified as needed and rendered to JSON via `.String()`. +func (r *Router) OpenAPI() *gabs.Container { + doc := gabs.New() -func getParamValue(c *gin.Context, param *openAPIParam) (interface{}, bool) { - var pstr string - timeFormat := time.RFC3339Nano - - switch param.In { - case inPath: - pstr = c.Param(param.Name) - case inQuery: - pstr = c.Query(param.Name) - if pstr == "" { - return param.def, true - } - case inHeader: - pstr = c.GetHeader(param.Name) - if pstr == "" { - return param.def, true - } + doc.Set("3.0.3", "openapi") + doc.Set(r.title, "info", "title") + doc.Set(r.version, "info", "version") - // Some headers have special time formats that aren't ISO8601/RFC3339. - lowerName := strings.ToLower(param.Name) - if lowerName == "if-modified-since" || lowerName == "if-unmodified-since" { - timeFormat = http.TimeFormat - } - default: - panic(fmt.Errorf("%s: %w", param.In, ErrInvalidParamLocation)) + if r.contact.Name != "" || r.contact.Email != "" || r.contact.URL != "" { + doc.Set(r.contact, "info", "contact") } - if param.Schema.HasValidation() { - data := pstr - if param.Schema.Type == "string" { - // Strings are special in that we don't expect users to provide them - // with quotes, so wrap them here for the parser that does the - // validation step below. - data = `"` + data + `"` - } else if param.Schema.Type == "array" { - // Array type needs to have `[` and `]` added. - if param.Schema.Items.Type == "string" { - // Same as above, quote each item. - data = `"` + strings.Join(strings.Split(data, ","), `","`) + `"` - } - if len(data) > 0 && data[0] != '[' { - data = "[" + data + "]" - } - } - if !validAgainstSchema(c, param.Name, param.Schema, []byte(data)) { - return nil, false - } + if r.description != "" { + doc.Set(r.description, "info", "description") } - pv, ok := parseParamValue(c, param.Name, param.typ, timeFormat, pstr) - if !ok { - return nil, false + paths, _ := doc.Object("paths") + for _, res := range r.resources { + paths.Merge(res.toOpenAPI()) } - return pv, true -} - -func getRequestBody(c *gin.Context, t reflect.Type, op *openAPIOperation) (interface{}, bool) { - var val interface{} - - if t != nil { - // If we have a type, then use it. Otherwise the body will unmarshal into - // a generic `map[string]interface{}` or `[]interface{}`. - val = reflect.New(t).Interface() - } - - if op.requestSchema != nil { - body, err := ioutil.ReadAll(c.Request.Body) - if err != nil { - if strings.Contains(err.Error(), "request body too large") { - abortWithError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("Request body too large, limit = %d bytes", op.maxBodyBytes)) - } else if e, ok := err.(net.Error); ok && e.Timeout() { - abortWithError(c, http.StatusRequestTimeout, fmt.Sprintf("Request body took too long to read: timed out after %v", op.bodyReadTimeout)) - } else { - panic(err) - } - return nil, false - } - - c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) - - if !validAgainstSchema(c, "request body", op.requestSchema, body) { - // Error already handled, just return. - return nil, false - } + if r.openapiHook != nil { + r.openapiHook(doc) } - if err := c.ShouldBindJSON(val); err != nil { - panic(err) - } - - return val, true + return doc } -// Router handles API requests. -type Router struct { - api *openAPI - engine *gin.Engine - root *cobra.Command - prestart []func() - docsHandler Handler - docsDomType ApiUIDocType - docsPrefix string - corsHandler Handler - - // Tracks the currently running server for graceful shutdown. - server *http.Server - serverLock sync.Mutex +// Contact sets the API's contact information. +func (r *Router) Contact(name, email, url string) { + r.contact.Name = name + r.contact.Email = email + r.contact.URL = url } -// NewRouter creates a new Huma router for handling API requests with -// default middleware and routes attached. The `docs` and `version` arguments -// will be used to set the title/description and version of the OpenAPI spec. -// If `docs` is multiline, the first line is used for the title and all other -// lines are used for the description. Pass options to customize the created -// router and OpenAPI. -func NewRouter(docs, version string, options ...RouterOption) *Router { - // Setup default Gin instance with our middleware. - g := gin.New() - g.Use(Recovery()) - g.Use(LogMiddleware()) - g.Use(PreferMinimalMiddleware()) - g.Use(ServiceLinkMiddleware()) - g.Use(ContentEncodingMiddleware()) - g.NoRoute(Handler404()) - - title, desc := splitDocs(docs) - - // Create the default router. - r := &Router{ - api: &openAPI{ - Title: title, - Description: desc, - Version: version, - Servers: make([]*openAPIServer, 0), - SecuritySchemes: make(map[string]*openAPISecurityScheme, 0), - Security: make([]openAPISecurityRequirement, 0), - Paths: make(map[string]map[string]*openAPIOperation), - Extra: make(map[string]interface{}), - }, - engine: g, - prestart: []func(){}, - docsHandler: RapiDocHandler(title), - docsDomType: RAPIDOCTYPE, - docsPrefix: "", - corsHandler: cors.Default(), - } - - r.setupCLI() - - // Apply any passed options. - for _, option := range options { - option.ApplyRouter(r) - } - - // Apply CORS handler *after* options in case a custom Gin Engine is passed. - r.GinEngine().Use(func(c *gin.Context) { - r.corsHandler(c) - }) - - // We need to ensure that if the docs have a prefixed path, - // that the ServiceLinkMiddleware can reflect the true path to the docs - r.GinEngine().Use(func(c *gin.Context) { - c.Set("docsPrefix", r.docsPrefix) +// ServerLink adds a new server link to this router for documentation. +func (r *Router) ServerLink(description, uri string) { + r.servers = append(r.servers, oaServer{ + Description: description, + URL: uri, }) +} - // Validate the router/API setup. - if err := r.api.validate(); err != nil { - panic(err) +// Resource creates a new resource attached to this router at the given path. +// The path can include parameters, e.g. `/things/{thing-id}`. Each resource +// path must be unique. +func (r *Router) Resource(path string) *Resource { + res := &Resource{ + path: path, + mux: r.mux.Route(path, nil), + subResources: []*Resource{}, + operations: []*Operation{}, + tags: []string{}, + router: r, } - // Set up handlers for the auto-generated spec and docs. - openapiJsonPath := fmt.Sprintf("%s/openapi.json", r.docsPrefix) - r.engine.GET(openapiJsonPath, openAPIHandlerJSON(r)) - r.engine.GET(fmt.Sprintf("%s/openapi.yaml", r.docsPrefix), openAPIHandlerYAML(r)) - - r.engine.GET(fmt.Sprintf("%s/docs", r.docsPrefix), func(c *gin.Context) { - docsPayload := "" - switch r.docsDomType { - case RAPIDOCTYPE: - docsPayload = RapiDocString(title, openapiJsonPath) - case SWAGGERDOCTYPE: - docsPayload = SwaggerUIDocString(title, openapiJsonPath) - case REDOCTYPE: - docsPayload = ReDocString(title, openapiJsonPath) - } - c.Data(200, "text/html", []byte(docsPayload)) - }) + r.resources = append(r.resources, res) - // If downloads like a CLI or SDKs are available, serve them automatically - // so you can reference them from e.g. the docs. - if _, err := os.Stat("downloads"); err == nil { - r.engine.Static("/downloads", "downloads") - } - - return r + return res } -// GinEngine returns the underlying low-level Gin engine. -func (r *Router) GinEngine() *gin.Engine { - return r.engine +// Middleware adds a new standard middleware to this router at the root, +// so it will apply to all requests. Middleware can also be applied at the +// resource level. +func (r *Router) Middleware(middlewares ...func(next http.Handler) http.Handler) { + r.mux.Use(middlewares...) } -// ServeHTTP conforms to the `http.Handler` interface. -func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.engine.ServeHTTP(w, req) +// OpenAPIPath returns the server path to the OpenAPI JSON. +func (r *Router) OpenAPIPath() string { + return r.docsPrefix + "/openapi.json" } -// Resource creates a new resource at the given path with the given -// dependencies, parameters, response headers, and responses defined. -func (r *Router) Resource(path string, options ...ResourceOption) *Resource { - return NewResource(r, path).With(options...) +// DocsPrefix sets the path prefix for where the OpenAPI JSON and documentation +// are hosted. +func (r *Router) DocsPrefix(path string) { + r.docsPrefix = path } -// register a new operation. -func (r *Router) register(method, path string, op *openAPIOperation) { - // First, make sure the operation and handler make sense, as well as pre- - // generating any schemas for use later during request handling. - op.validate(method, path) +// DocsHandler sets the http.Handler to render documentation. It defaults to +// using RapiDoc. +func (r *Router) DocsHandler(handler http.Handler) { + r.docsHandler = handler +} - // Add the operation to the list of operations for the path entry. - if r.api.Paths[path] == nil { - r.api.Paths[path] = make(map[string]*openAPIOperation) - } +// OpenAPIHook provides a function to run after generating the OpenAPI document +// allowing you to modify it as needed. +func (r *Router) OpenAPIHook(hook func(*gabs.Container)) { + r.openapiHook = hook +} - r.api.Paths[path][method] = op - - // Next, figure out which Gin function to call. - var f func(string, ...gin.HandlerFunc) gin.IRoutes - - switch 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 " + method) +// Set up the docs & OpenAPI routes. +func (r *Router) setupDocs() { + // Register the docs handlers if needed. + if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, r.OpenAPIPath()) { + r.mux.Get(r.OpenAPIPath(), func(w http.ResponseWriter, req *http.Request) { + spec := r.OpenAPI() + w.Header().Set("Content-Type", "application/vnd.oai.openapi+json") + w.Write(spec.Bytes()) + }) } - if strings.Contains(path, "{") { - // Convert from OpenAPI-style parameters to gin-style params - path = paramRe.ReplaceAllString(path, ":$1$2") + if !r.mux.Match(chi.NewRouteContext(), http.MethodGet, "/docs") { + r.mux.Get(r.docsPrefix+"/docs", r.docsHandler.ServeHTTP) } - // Then call it to register our handler function. - f(path, func(c *gin.Context) { - var method reflect.Value - if op.unsafe() { - method = reflect.ValueOf(op.handler.(*unsafeHandler).handler) - } else { - method = reflect.ValueOf(op.handler) - } - - in := make([]reflect.Value, 0, len(op.dependencies)+len(op.params)+1) - - // Limit the body size - if c.Request.Body != nil { - maxBody := op.maxBodyBytes - if maxBody == 0 { - // 1 MiB default - maxBody = 1024 * 1024 - } - - // -1 is a special value which means set no limit. - if maxBody != -1 { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBody) - } - } - - // Process any dependencies first. - for _, dep := range op.dependencies { - headers, value, err := dep.resolve(c, op) - if err != nil { - if !c.IsAborted() { - // Nothing else has handled the error, so treat it like a general - // internal server error. - abortWithError(c, http.StatusInternalServerError, "Couldn't get dependency") - } - } - - for k, v := range headers { - c.Header(k, v) - } - - in = append(in, reflect.ValueOf(value)) - } - - for _, param := range op.params { - pv, ok := getParamValue(c, param) - if !ok { - // Error has already been handled. - return - } - - in = append(in, reflect.ValueOf(pv)) - } - - readTimeout := op.bodyReadTimeout - if op.requestSchema != nil { - if readTimeout == 0 { - // Default to 15s when reading/parsing/validating automatically. - readTimeout = 15 * time.Second - } - - if conn := getConn(c.Request); readTimeout > 0 && conn != nil { - conn.SetReadDeadline(time.Now().Add(readTimeout)) - } - - // Parse body - i := len(in) - - var bodyType reflect.Type - if op.unsafe() { - bodyType = reflect.TypeOf(map[string]interface{}{}) - } else { - bodyType = method.Type().In(i) - } - - b, success := getRequestBody(c, bodyType, op) - if !success { - // Error was already handled in `getRequestBody`. - return - } - bval := reflect.ValueOf(b) - if bval.Kind() == reflect.Ptr { - bval = bval.Elem() - } - in = append(in, bval) - } else if readTimeout > 0 { - // We aren't processing the input, but still set the timeout. - if conn := getConn(c.Request); conn != nil { - conn.SetReadDeadline(time.Now().Add(readTimeout)) - } - } - - out := method.Call(in) - - if op.unsafe() { - // Normal handlers return multiple values. Unsafe handlers return one - // single list of response values. Here we convert. - newOut := make([]reflect.Value, out[0].Len()) - - for i := 0; i < out[0].Len(); i++ { - newOut[i] = out[0].Index(i) - } - - out = newOut - } - - // Find and return the first non-zero response. The status code comes - // from the registered `huma.Response` struct. - // This breaks down with scalar types... so they need to be passed - // as a pointer and we'll dereference it automatically. - for i, o := range out[len(op.responseHeaders):] { - if o.Kind() == reflect.Interface { - // Unsafe handlers return slices of interfaces and IsZero will never - // evaluate to true on items within them. Instead, pull out the - // underlying data which may or may not be zero. - o = o.Elem() - } - - if !o.IsZero() { - body := o.Interface() - - r := op.responses[i] - - // Set response headers - for j, header := range op.responseHeaders { - value := out[j] - - found := false - for _, name := range r.Headers { - if name == header.Name { - found = true - break - } - } - - if !found { - if !value.IsZero() { - // Log an error to be fixed later if using the logging middleware. - if l, ok := c.Get("log"); ok { - if log, ok := l.(*zap.SugaredLogger); ok { - log.Errorf("Header '%s' with value '%v' set on a response that did not declare it", header.Name, value) - } - } - } - // Skip this header as the response doesn't list it. - continue - } - - if !value.IsZero() { - v := value.Interface() - if value.Kind() == reflect.Ptr { - v = value.Elem().Interface() - } - c.Header(header.Name, fmt.Sprintf("%v", v)) - } - } - - if r.ContentType == "" { - // No body allowed, e.g. for HTTP 204. - c.Status(r.StatusCode) - break - } - - if err, ok := body.(*ErrorModel); ok { - // This is an error response. Automatically set some values if missing. - if err.Status == 0 { - err.Status = r.StatusCode - } - - if err.Title == "" { - err.Title = http.StatusText(r.StatusCode) - } - } - - ct := r.ContentType - - // Allow content-negotiation to override the default for known structured - // responses. - if strings.Contains(ct, "json") || strings.Contains(ct, "yaml") || strings.Contains(ct, "cbor") { - if accept := c.GetHeader("Accept"); accept != "" { - best := selectQValue(accept, []string{"application/cbor", "application/json", "application/yaml", "application/x-yaml"}) - if best != "" { - ct = best - } - } - } - - if strings.HasPrefix(ct, "application/json") || strings.HasSuffix(ct, "+json") { - c.JSON(r.StatusCode, body) - } else if strings.HasPrefix(ct, "application/yaml") || strings.HasPrefix(ct, "application/x-yaml") || strings.HasSuffix(ct, "+yaml") { - c.YAML(r.StatusCode, body) - } else if strings.HasPrefix(ct, "application/cbor") || strings.HasSuffix(ct, "+cbor") { - opts := cbor.CanonicalEncOptions() - opts.Time = cbor.TimeRFC3339Nano - opts.TimeTag = cbor.EncTagRequired - mode, err := opts.EncMode() - if err != nil { - panic(err) - } - m, err := mode.Marshal(body) - if err != nil { - panic(err) - } - c.Data(r.StatusCode, ct, m) - } else { - if o.Kind() == reflect.Ptr { - // This is a pointer to something, so we derefernce it and get - // its value before converting to a string because Printf will - // by default print pointer addresses instead of their value. - body = o.Elem().Interface() - } - c.Data(r.StatusCode, ct, []byte(fmt.Sprintf("%v", body))) - } - break - } - } - }) + r.docsAreSetup = true } func (r *Router) listen(addr, certFile, keyFile string) error { + // Setup docs on startup so we can fail fast if the handler is broken in + // some way. + r.setupDocs() + + // Start the server. r.serverLock.Lock() if r.server == nil { r.server = &http.Server{ @@ -724,7 +202,7 @@ func (r *Router) listen(addr, certFile, keyFile string) error { return r.server.ListenAndServe() } -// Listen for new connections. +// Listen starts the server listening on the specified `host:port` address. func (r *Router) Listen(addr string) error { return r.listen(addr, "", "") } @@ -734,6 +212,16 @@ func (r *Router) ListenTLS(addr, certFile, keyFile string) error { return r.listen(addr, certFile, keyFile) } +// ServeHTTP handles an incoming request and is compatible with the standard +// library `http` package. +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if !r.docsAreSetup { + r.setupDocs() + } + + r.mux.ServeHTTP(w, req) +} + // Shutdown gracefully shuts down the server. func (r *Router) Shutdown(ctx context.Context) error { r.serverLock.Lock() @@ -745,9 +233,58 @@ func (r *Router) Shutdown(ctx context.Context) error { return r.server.Shutdown(ctx) } -// Run executes the router command. -func (r *Router) Run() { - if err := r.root.Execute(); err != nil { - panic(err) +// GetTitle returns the server API title. +func (r *Router) GetTitle() string { + return r.title +} + +// GetVersion returns the server version. +func (r *Router) GetVersion() string { + return r.version +} + +// New creates a new Huma router to which you can attach resources, +// operations, middleware, etc. +func New(docs, version string) *Router { + title, desc := splitDocs(docs) + + r := &Router{ + mux: chi.NewRouter(), + resources: []*Resource{}, + title: title, + description: desc, + version: version, + servers: []oaServer{}, } + + r.docsHandler = RapiDocHandler(r) + + // Error handlers + r.mux.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := ContextFromRequest(w, r) + ctx.WriteError(http.StatusNotFound, fmt.Sprintf("Cannot find %s", r.URL.String())) + })) + + r.mux.MethodNotAllowed(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := ContextFromRequest(w, r) + ctx.WriteError(http.StatusMethodNotAllowed, fmt.Sprintf("No handler for method %s", r.Method)) + })) + + // Automatically add links to OpenAPI and docs. + r.Middleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + next.ServeHTTP(w, req) + + if req.URL.Path == "/" { + link := w.Header().Get("link") + if link != "" { + link += ", " + } + link += `<` + r.OpenAPIPath() + `>; rel="service-desc", <` + r.docsPrefix + `/docs>; rel="service-doc"` + w.Header().Set("link", link) + } + }) + }) + + return r } diff --git a/router_test.go b/router_test.go index 52b11713..1886f087 100644 --- a/router_test.go +++ b/router_test.go @@ -2,540 +2,155 @@ package huma import ( "bytes" - "context" - "fmt" + "io" + "io/ioutil" "net/http" "net/http/httptest" - "os" - "reflect" "strings" "testing" "time" - "github.com/istreamlabs/huma/schema" - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "go.uber.org/zap/zaptest" - "go.uber.org/zap/zaptest/observer" ) -func init() { - gin.SetMode(gin.TestMode) - os.Setenv("SERVICE_HOST", "127.0.0.1") +func newTestRouter() *Router { + app := New("Test API", "1.0.0") + return app } -func ExampleNewRouter_customGin() { - g := gin.New() - // ...Customize your gin instance... - - r := NewRouter("Example API", "1.0.0", Gin(g)) - r.Resource("/").Get("doc", func() string { return "Custom Gin" }) -} - -func ExampleNewRouter() { - r := NewRouter("Example API", "1.0.0", - DevServer("http://localhost:8888"), - ContactEmail("Support", "support@example.com"), - ) - - r.Resource("/hello").Get("doc", func() string { return "Hello" }) -} - -func NewTestRouter(t *testing.T, options ...RouterOption) *Router { - l := zaptest.NewLogger(t) - g := gin.New() - g.Use(LogMiddleware(Logger(l))) - - return NewRouter("Test API", "1.0.0", append([]RouterOption{Gin(g)}, options...)...) -} - -type helloResponse struct { - Message string `json:"message"` -} - -func BenchmarkGin(b *testing.B) { - g := gin.New() - g.GET("/hello", func(c *gin.Context) { - c.JSON(200, &helloResponse{ - Message: "Hello, world", - }) - }) - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/hello", nil) - g.ServeHTTP(w, req) - } -} - -func BenchmarkHuma(b *testing.B) { - r := NewRouter("Benchmark test", "1.0.0", Gin(gin.New())) - r.Resource("/hello").Get("Greet the world", func() *helloResponse { - return &helloResponse{ - Message: "Hello, world", - } - }) - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/hello", nil) - r.ServeHTTP(w, req) - } -} - -func BenchmarkGinComplex(b *testing.B) { - dep1 := "dep1" - dep2 := func(c *gin.Context) string { - _ = c.GetHeader("x-foo") - return "dep2" - } - dep3 := func(c *gin.Context) (string, string) { - return "xbar", "dep3" - } - - g := gin.New() - g.GET("/hello", func(c *gin.Context) { - _ = dep1 - _ = dep2(c) - h, _ := dep3(c) - - c.Header("x-bar", h) - - name := c.Query("name") - if name == "test" { - c.JSON(400, &ErrorModel{ - Detail: "Name cannot be test", - }) - } - if name == "" { - name = "world" - } - - c.Header("x-baz", "xbaz") - c.JSON(200, &helloResponse{ - Message: "Hello, " + name, - }) - }) - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/hello", nil) - g.ServeHTTP(w, req) - } -} - -func BenchmarkHumaComplex(b *testing.B) { - r := NewRouter("Benchmark test", "1.0.0", Gin(gin.New())) - - dep1 := SimpleDependency("dep1") - - dep2 := Dependency(DependencyOptions( - ContextDependency(), dep1, HeaderParam("x-foo", "desc", ""), - ), func(c *gin.Context, d1 string, xfoo string) (string, error) { - return "dep2", nil - }) - - dep3 := Dependency(DependencyOptions( - dep1, ResponseHeader("x-bar", "desc"), - ), func(d1 string) (string, string, error) { - return "xbar", "dep3", nil - }) - - r.Resource("/hello", dep1, dep2, dep3, - QueryParam("name", "desc", "world"), - ResponseHeader("x-baz", "desc"), - ResponseJSON(200, "Return a greeting", Headers("x-baz")), - ResponseError(500, "desc"), - ).Get("Greet the world", func(c *gin.Context, d2, d3, name string) (string, *helloResponse, *ErrorModel) { - if name == "test" { - return "", nil, &ErrorModel{ - Detail: "Name cannot be test", - } - } - - return "xbaz", &helloResponse{ - Message: "Hello, " + name, - }, nil - }) - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/hello?name=Daniel", nil) - r.ServeHTTP(w, req) - } -} - -func TestRouterDefault(t *testing.T) { - // Just test we can create it without panic. - _ = NewTestRouter(t) -} - -func TestRouterConfigurableCors(t *testing.T) { - cfg := cors.DefaultConfig() - cfg.AllowAllOrigins = true - cfg.AllowHeaders = append(cfg.AllowHeaders, "Authorization", "X-My-Header") - - r := NewTestRouter(t, CORSHandler(cors.New(cfg))) - - type PongResponse struct { - Value string `json:"value" description:"The echoed back word"` - } - - r.Resource("/ping", - ResponseJSON(http.StatusOK, "Successful echo response"), - ResponseError(http.StatusBadRequest, "Invalid input"), - ).Get("ping", func() (*PongResponse, *ErrorModel) { - - return &PongResponse{Value: "pong"}, nil - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodOptions, "/ping", nil) - req.Header.Add("Origin", "blah") - r.ServeHTTP(w, req) - - assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) - allowedHeaders := w.Header().Get("Access-Control-Allow-Headers") - assert.Equal(t, true, strings.Contains(allowedHeaders, "Authorization")) - assert.Equal(t, true, strings.Contains(allowedHeaders, "X-My-Header")) - -} - -func TestRouter(t *testing.T) { - type EchoResponse struct { - Value string `json:"value" description:"The echoed back word"` - } - - r := NewTestRouter(t) - - r.Resource("/echo", - PathParam("word", "The word to echo back"), - QueryParam("greet", "Return a greeting", false), - ResponseJSON(http.StatusOK, "Successful echo response"), - ResponseError(http.StatusBadRequest, "Invalid input"), - ).Put("Echo back an input word.", func(word string, greet bool) (*EchoResponse, *ErrorModel) { - if word == "test" { - return nil, &ErrorModel{Detail: "Value not allowed: test"} - } - - v := word - if greet { - v = "Hello, " + word - } - - return &EchoResponse{Value: v}, nil - }) +func TestRouterServiceLink(t *testing.T) { + r := newTestRouter() 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) - - // Check spec & docs routes - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/openapi.json", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/docs", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) -} - -func TestRouterDocsPrefix(t *testing.T) { - - r := NewRouter("api", "v", DocsRoutePrefix("/prefix")) - r.Resource("/hello").Get("doc", func() string { return "Hello" }) - - // Check spec & docs routes - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/prefix/openapi.json", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/prefix/docs", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), "prefix/openapi") -} - -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 := NewTestRouter(t) - - r.Resource("/echo").Put("Echo back an input word.", func(in *EchoRequest) *EchoResponse { - return &EchoResponse{Value: in.Value} - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPut, "/echo", bytes.NewBufferString(`{"value": 123}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodPut, "/echo", bytes.NewBufferString(`{"value": "hello"}`)) + req, _ := http.NewRequest(http.MethodGet, "/", nil) r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, `{"value":"hello"}`+"\n", w.Body.String()) + assert.Contains(t, w.Header().Get("Link"), `; rel="service-desc"`) + assert.Contains(t, w.Header().Get("Link"), `; rel="service-doc"`) } -func TestRouterScalarResponse(t *testing.T) { - r := NewTestRouter(t) - - r.Resource("/hello").Put("Say hello", func() string { - return "hello" +func TestRouterHello(t *testing.T) { + r := New("Test", "1.0.0") + r.Resource("/test").Get("test", "Test", + NewResponse(http.StatusNoContent, "test"), + ).Run(func(ctx Context) { + ctx.WriteHeader(http.StatusNoContent) }) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPut, "/hello", nil) + req, _ := http.NewRequest(http.MethodGet, "/test", nil) r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "hello", w.Body.String()) + // Assert the response is as expected. + assert.Equal(t, http.StatusNoContent, w.Code) } -func TestRouterZeroScalarResponse(t *testing.T) { - r := NewTestRouter(t) - - r.Resource("/bool").Put("Bool response", func() *bool { - resp := false - return &resp - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPut, "/bool", nil) - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "false\n", w.Body.String()) -} +func TestStreamingInput(t *testing.T) { + r := New("Test", "1.0.0") + r.Resource("/stream").Post("stream", "Stream test", + NewResponse(http.StatusNoContent, "test"), + NewResponse(http.StatusInternalServerError, "error"), + ).Run(func(ctx Context, input struct { + Body io.Reader + }) { + _, err := ioutil.ReadAll(input.Body) + if err != nil { + ctx.WriteError(http.StatusInternalServerError, "Problem reading input", err) + } -func TestRouterResponseHeaders(t *testing.T) { - r := NewTestRouter(t) - - r.Resource("/test", - ResponseHeader("Etag", "Identifies a specific version of this resource"), - ResponseHeader("X-Test", "Custom test header"), - ResponseHeader("X-Missing", "Won't get sent"), - ResponseText(http.StatusOK, "Successful test", Headers("Etag", "X-Test", "X-Missing")), - ResponseError(http.StatusBadRequest, "Error example", Headers("X-Test")), - ).Get("Test operation", func() (etag string, xTest *string, xMissing string, success string, fail string) { - test := "test" - return "\"abc123\"", &test, "", "hello", "" + ctx.WriteHeader(http.StatusNoContent) }) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/test", nil) + body := bytes.NewReader(make([]byte, 1024)) + req, _ := http.NewRequest(http.MethodPost, "/stream", body) r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "hello", w.Body.String()) - assert.Equal(t, "\"abc123\"", w.Header().Get("Etag")) - assert.Equal(t, "test", w.Header().Get("X-Test")) - assert.Equal(t, "", w.Header().Get("X-Missing")) + assert.Equal(t, http.StatusNoContent, w.Code) } -func TestRouterDependencies(t *testing.T) { - r := NewTestRouter(t) - - type DB struct { - Get func() string +func TestModelInputOutput(t *testing.T) { + type Response struct { + Category string `json:"category"` + Hidden bool `json:"hidden"` + Auth string `json:"auth"` + ID string `json:"id"` + Age int `json:"age"` } - // Datastore is a global dependency, set by value. - db := &DB{ - Get: func() string { - return "Hello, " - }, - } - - type Logger struct { - Log func(msg string) - } - - // Logger is a contextual instance from the gin request context. - captured := "" - log := Dependency(GinContextDependency(), func(c *gin.Context) (*Logger, error) { - return &Logger{ - Log: func(msg string) { - captured = fmt.Sprintf("%s [uri:%s]", msg, c.FullPath()) - }, - }, nil - }) - - r.Resource("/hello", - GinContextDependency(), - SimpleDependency(db), - log, - QueryParam("name", "Your name", ""), - ).Get("Basic hello world", func(c *gin.Context, db *DB, l *Logger, name string) string { - if name == "" { - name = c.Request.RemoteAddr + r := New("Test", "1.0.0") + r.Resource("/players/{category}").Post("player", "Create player", + NewResponse(http.StatusOK, "test").Model(Response{}), + ).Run(func(ctx Context, input struct { + Category string `path:"category"` + Hidden bool `query:"hidden"` + Auth string `header:"Authorization"` + Body struct { + ID string `json:"id"` + Age int `json:"age" minimum:"16"` } - l.Log("Hello logger!") - return db.Get() + name - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/hello?name=foo", nil) - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "Hello logger! [uri:/hello]", captured) -} - -func TestRouterBadHeader(t *testing.T) { - core, logs := observer.New(zapcore.InfoLevel) - l := zaptest.NewLogger(t, zaptest.WrapOptions(zap.WrapCore(func(zapcore.Core) zapcore.Core { return core }))) - g := gin.New() - g.Use(LogMiddleware(Logger(l))) - r := NewRouter("Test API", "1.0.0", Gin(g)) - r.Resource("/test", ResponseHeader("foo", "desc"), ResponseError(http.StatusBadRequest, "desc", Headers("foo"))).Get("desc", func() (string, *ErrorModel, string) { - return "header-value", nil, "response" - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/test", nil) - r.ServeHTTP(w, req) - - assert.Equal(t, http.StatusOK, w.Code) - assert.NotEmpty(t, logs.FilterMessageSnippet("did not declare").All()) -} - -func TestRouterParams(t *testing.T) { - r := NewTestRouter(t) - - r.Resource("/test", - PathParam("id", "desc"), - QueryParam("i", "desc", int16(0)), - QueryParam("f32", "desc", float32(0.0)), - QueryParam("f64", "desc", 0.0), - QueryParam("schema", "desc", "test", Schema(schema.Schema{Pattern: "^a-z+$"})), - QueryParam("items", "desc", []int{}), - QueryParam("start", "desc", time.Time{}), - ).Get("desc", func(id string, i int16, f32 float32, f64 float64, schema string, items []int, start time.Time) string { - return fmt.Sprintf("%s %v %v %v %v %v %v", id, i, f32, f64, schema, items, start) + }) { + ctx.WriteModel(http.StatusOK, Response{ + Category: input.Category, + Hidden: input.Hidden, + Auth: input.Auth, + ID: input.Body.ID, + Age: input.Body.Age, + }) }) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/test/someId?i=1&f32=1.0&f64=123.45&items=1,2,3&start=2020-01-01T12:00:00Z", nil) + body := bytes.NewReader([]byte(`{"id": "abc123", "age": 25}`)) + req, _ := http.NewRequest(http.MethodPost, "/players/fps?hidden=true", body) + req.Header.Set("Authorization", "dummy") r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Equal(t, "someId 1 1 123.45 test [1 2 3] 2020-01-01 12:00:00 +0000 UTC", w.Body.String()) - // Arrays can be sent as JSON arrays - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?items=[1,2,3]", nil) - r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) + assert.JSONEq(t, `{ + "category": "fps", + "hidden": true, + "auth": "dummy", + "id": "abc123", + "age": 25 + }`, w.Body.String()) - // Failure parsing tests - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?i=bad", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - + // Should be able to get OpenAPI describing this API with its resource, + // operation, schema, etc. w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?f32=bad", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?f64=bad", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?schema=foo1", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?items=1,2,bad", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?start=bad", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) - - // Invalid Go number should return an error, may support these in the future. - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/someId?items=1e10", nil) + req, _ = http.NewRequest(http.MethodGet, "/openapi.json", nil) r.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) -} - -func TestInvalidParamLocation(t *testing.T) { - r := NewTestRouter(t) - test := r.Resource("/test", PathParam("name", "desc")) - test.params[len(test.params)-1].In = "bad'" - - assert.Panics(t, func() { - test.Get("desc", func(test string) string { - return "Hello, test!" - }) - }) -} - -func TestEmptyShutdownPanics(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Shutdown(context.TODO()) - }) + assert.Equal(t, http.StatusOK, w.Code) } func TestTooBigBody(t *testing.T) { - r := NewTestRouter(t) + app := newTestRouter() type Input struct { - ID string + Body struct { + ID string `json:"id"` + } } - r.Resource("/test", MaxBodyBytes(5)).Put("desc", func(input *Input) string { - return "hello, " + input.ID + op := app.Resource("/test").Put("put", "desc", + NewResponse(http.StatusNoContent, "desc"), + ) + op.MaxBodyBytes(5) + op.Run(func(ctx Context, input Input) { + // Do nothing... }) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPut, "/test", strings.NewReader(`{"id": "foo"}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Request body too large") + + // With content length + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPut, "/test", strings.NewReader(`{"id": "foo"}`)) + req.Header.Set("Content-Length", "13") + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "Request body too large") } @@ -560,157 +175,68 @@ func (r *slowReader) Read(p []byte) (int, error) { } func TestBodySlow(t *testing.T) { - r := NewTestRouter(t) + app := newTestRouter() type Input struct { - ID string + Body struct { + ID string + } } - r.Resource("/test", BodyReadTimeout(1)).Put("desc", func(input *Input) string { - return "hello, " + input.ID + op := app.Resource("/test").Put("put", "desc", + NewResponse(http.StatusNoContent, "desc"), + ) + op.BodyReadTimeout(1 * time.Millisecond) + op.Run(func(ctx Context, input Input) { + // Do nothing... }) w := httptest.NewRecorder() req, _ := http.NewRequest(http.MethodPut, "/test", &slowReader{}) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusRequestTimeout, w.Code) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "timed out") } -func TestRouterUnsafeHandler(t *testing.T) { - r := NewTestRouter(t) - - type Item struct { - ID string `json:"id" readOnly:"true"` - Value int `json:"value"` - } - - readSchema, _ := schema.GenerateWithMode(reflect.TypeOf(Item{}), schema.ModeRead, nil) - writeSchema, _ := schema.GenerateWithMode(reflect.TypeOf(Item{}), schema.ModeWrite, nil) - - items := map[string]Item{} - - res := r.Resource("/test", PathParam("id", "doc")) - - // Write handler - res.With( - RequestSchema(writeSchema), - Response(http.StatusNoContent, "doc"), - ).Put("doc", UnsafeHandler(func(inputs ...interface{}) []interface{} { - id := inputs[0].(string) - item := inputs[1].(map[string]interface{}) - - items[id] = Item{ - ID: id, - Value: int(item["value"].(float64)), - } +func TestErrorHandlers(t *testing.T) { + app := newTestRouter() - return []interface{}{true} - })) - - // Read handler - res.With( - ResponseJSON(http.StatusOK, "doc", Schema(*readSchema)), - ).Get("doc", UnsafeHandler(func(inputs ...interface{}) []interface{} { - id := inputs[0].(string) - - return []interface{}{items[id]} - })) - - // Create an item - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodPut, "/test/some-id", strings.NewReader(`{"value": 123}`)) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) - - // Read the item - w = httptest.NewRecorder() - req, _ = http.NewRequest(http.MethodGet, "/test/some-id", nil) - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) -} - -func TestContentNegotiationYAML(t *testing.T) { - r := NewTestRouter(t) - - type TestResponse struct { - Hello string `json:"hello"` - } - - r.Resource("/").Get("test", func() *TestResponse { - return &TestResponse{ - Hello: "world", - } + app.Resource("/").Get("root", "desc", + NewResponse(http.StatusNoContent, "desc"), + ).Run(func(ctx Context) { + // Do nothing }) w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Accept", "application/yaml") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Header().Get("Content-Type"), "yaml") - assert.Equal(t, "hello: world\n", w.Body.String()) -} - -func TestContentNegotiationCBOR(t *testing.T) { - r := NewTestRouter(t) - - type TestResponse struct { - Hello string `json:"hello"` - } + req, _ := http.NewRequest(http.MethodGet, "/notfound", nil) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), "/notfound") - r.Resource("/").Get("test", func() *TestResponse { - return &TestResponse{ - Hello: "world", - } - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Accept", "application/cbor") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Header().Get("Content-Type"), "cbor") + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodPut, "/", nil) + app.ServeHTTP(w, req) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) + assert.Equal(t, "application/problem+json", w.Header().Get("Content-Type")) + assert.Contains(t, w.Body.String(), "PUT") } -func TestContentNegotiationStar(t *testing.T) { - r := NewTestRouter(t) - - type TestResponse struct { - Hello string `json:"hello"` +func TestInvalidPathParam(t *testing.T) { + type Input struct { + ThingID string `path:"thing-if"` } - r.Resource("/").Get("test", func() *TestResponse { - return &TestResponse{ - Hello: "world", - } - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Accept", "*") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Header().Get("Content-Type"), "json") -} - -func TestContentNegotiationMultiple(t *testing.T) { - r := NewTestRouter(t) - - type TestResponse struct { - Hello string `json:"hello"` - } + app := newTestRouter() - r.Resource("/").Get("test", func() *TestResponse { - return &TestResponse{ - Hello: "world", - } + // The router has no middleware, so no panic recovery will happen. This lets + // us test via a simple assertion that it would panic, and the actual test + // to ensure a 5xx error happens in the `middleware` package instead. + assert.Panics(t, func() { + app.Resource("/things/{thing-id}").Get("get", "Test", + NewResponse(http.StatusNoContent, "desc"), + ).Run(func(ctx Context, input Input) { + // Do nothing + }) }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest(http.MethodGet, "/", nil) - req.Header.Set("Accept", "application/cbor, application/yaml") - r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Header().Get("Content-Type"), "cbor") } diff --git a/schema/schema.go b/schema/schema.go index 232cf4ea..b9fa3c7a 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -109,6 +109,7 @@ type Schema struct { ReadOnly bool `json:"readOnly,omitempty"` WriteOnly bool `json:"writeOnly,omitempty"` Deprecated bool `json:"deprecated,omitempty"` + ContentEncoding string `json:"contentEncoding,omitempty"` } // HasValidation returns true if at least one validator is set on the schema. @@ -170,6 +171,208 @@ func getFields(typ reflect.Type) []reflect.StructField { return fields } +// GenerateFromField generates a schema for a single struct field. It returns +// the computed field name, whether it is optional, its schema, and any error +// which may have occurred. +func GenerateFromField(f reflect.StructField, mode Mode) (string, bool, *Schema, error) { + jsonTags := strings.Split(f.Tag.Get("json"), ",") + name := strings.ToLower(f.Name) + if len(jsonTags) > 0 && jsonTags[0] != "" { + name = jsonTags[0] + } + + if name == "-" { + // Skip deliberately filtered out items + return name, false, nil, nil + } + + s, err := GenerateWithMode(f.Type, mode, nil) + if err != nil { + return name, false, nil, err + } + + if tag, ok := f.Tag.Lookup("description"); ok { + s.Description = tag + } + + if tag, ok := f.Tag.Lookup("doc"); ok { + s.Description = tag + } + + if tag, ok := f.Tag.Lookup("format"); ok { + s.Format = tag + } + + if tag, ok := f.Tag.Lookup("enum"); ok { + s.Enum = []interface{}{} + for _, v := range strings.Split(tag, ",") { + parsed, err := getTagValue(s, f.Type, v) + if err != nil { + return name, false, nil, err + } + s.Enum = append(s.Enum, parsed) + } + } + + if tag, ok := f.Tag.Lookup("default"); ok { + v, err := getTagValue(s, f.Type, tag) + if err != nil { + return name, false, nil, err + } + + s.Default = v + } + + if tag, ok := f.Tag.Lookup("example"); ok { + v, err := getTagValue(s, f.Type, tag) + if err != nil { + return name, false, nil, err + } + + s.Example = v + } + + if tag, ok := f.Tag.Lookup("minimum"); ok { + min, err := strconv.ParseFloat(tag, 64) + if err != nil { + return name, false, nil, err + } + s.Minimum = &min + } + + if tag, ok := f.Tag.Lookup("exclusiveMinimum"); ok { + min, err := strconv.ParseFloat(tag, 64) + if err != nil { + return name, false, nil, err + } + s.ExclusiveMinimum = &min + } + + if tag, ok := f.Tag.Lookup("maximum"); ok { + max, err := strconv.ParseFloat(tag, 64) + if err != nil { + return name, false, nil, err + } + s.Maximum = &max + } + + if tag, ok := f.Tag.Lookup("exclusiveMaximum"); ok { + max, err := strconv.ParseFloat(tag, 64) + if err != nil { + return name, false, nil, err + } + s.ExclusiveMaximum = &max + } + + if tag, ok := f.Tag.Lookup("multipleOf"); ok { + mof, err := strconv.ParseFloat(tag, 64) + if err != nil { + return name, false, nil, err + } + s.MultipleOf = mof + } + + if tag, ok := f.Tag.Lookup("minLength"); ok { + min, err := strconv.ParseUint(tag, 10, 64) + if err != nil { + return name, false, nil, err + } + s.MinLength = &min + } + + if tag, ok := f.Tag.Lookup("maxLength"); ok { + max, err := strconv.ParseUint(tag, 10, 64) + if err != nil { + return name, false, nil, err + } + s.MaxLength = &max + } + + if tag, ok := f.Tag.Lookup("pattern"); ok { + s.Pattern = tag + + if _, err := regexp.Compile(s.Pattern); err != nil { + return name, false, nil, err + } + } + + if tag, ok := f.Tag.Lookup("minItems"); ok { + min, err := strconv.ParseUint(tag, 10, 64) + if err != nil { + return name, false, nil, err + } + s.MinItems = &min + } + + if tag, ok := f.Tag.Lookup("maxItems"); ok { + max, err := strconv.ParseUint(tag, 10, 64) + if err != nil { + return name, false, nil, err + } + s.MaxItems = &max + } + + if tag, ok := f.Tag.Lookup("uniqueItems"); ok { + if !(tag == "true" || tag == "false") { + return name, false, nil, fmt.Errorf("%s uniqueItems: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) + } + s.UniqueItems = tag == "true" + } + + if tag, ok := f.Tag.Lookup("minProperties"); ok { + min, err := strconv.ParseUint(tag, 10, 64) + if err != nil { + return name, false, nil, err + } + s.MinProperties = &min + } + + if tag, ok := f.Tag.Lookup("maxProperties"); ok { + max, err := strconv.ParseUint(tag, 10, 64) + if err != nil { + return name, false, nil, err + } + s.MaxProperties = &max + } + + if tag, ok := f.Tag.Lookup("nullable"); ok { + if !(tag == "true" || tag == "false") { + return name, false, nil, fmt.Errorf("%s nullable: boolean should be true or false but got %s: %w", f.Name, tag, ErrSchemaInvalid) + } + s.Nullable = tag == "true" + } + + if tag, ok := f.Tag.Lookup("readOnly"); ok { + if !(tag == "true" || tag == "false") { + return name, false, nil, fmt.Errorf("%s readOnly: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) + } + s.ReadOnly = tag == "true" + } + + if tag, ok := f.Tag.Lookup("writeOnly"); ok { + if !(tag == "true" || tag == "false") { + return name, false, nil, fmt.Errorf("%s writeOnly: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) + } + s.WriteOnly = tag == "true" + } + + if tag, ok := f.Tag.Lookup("deprecated"); ok { + if !(tag == "true" || tag == "false") { + return name, false, nil, fmt.Errorf("%s deprecated: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) + } + s.Deprecated = tag == "true" + } + + optional := false + for _, tag := range jsonTags[1:] { + if tag == "omitempty" { + optional = true + } + } + + return name, optional, s, nil +} + // GenerateWithMode creates a JSON schema for a Go type. Struct field // tags can be used to provide additional metadata such as descriptions and // validation. The mode can be all, read, or write. In read or write mode @@ -202,13 +405,12 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error schema.AdditionalProperties = false for _, f := range getFields(t) { - jsonTags := strings.Split(f.Tag.Get("json"), ",") - name := strings.ToLower(f.Name) - if len(jsonTags) > 0 && jsonTags[0] != "" { - name = jsonTags[0] + name, optional, s, err := GenerateFromField(f, mode) + if err != nil { + return nil, err } - if name == "-" { + if s == nil { // Skip deliberately filtered out items continue } @@ -219,200 +421,16 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error continue } - s, err := GenerateWithMode(f.Type, mode, nil) - if err != nil { - return nil, err - } - properties[name] = s - - if tag, ok := f.Tag.Lookup("description"); ok { - s.Description = tag - } - - if tag, ok := f.Tag.Lookup("doc"); ok { - s.Description = tag - } - - if tag, ok := f.Tag.Lookup("format"); ok { - s.Format = tag - } - - if tag, ok := f.Tag.Lookup("enum"); ok { - s.Enum = []interface{}{} - for _, v := range strings.Split(tag, ",") { - parsed, err := getTagValue(s, f.Type, v) - if err != nil { - return nil, err - } - s.Enum = append(s.Enum, parsed) - } - } - - if tag, ok := f.Tag.Lookup("default"); ok { - v, err := getTagValue(s, f.Type, tag) - if err != nil { - return nil, err - } - - s.Default = v - } - - if tag, ok := f.Tag.Lookup("example"); ok { - v, err := getTagValue(s, f.Type, tag) - if err != nil { - return nil, err - } - - s.Example = v - } - - if tag, ok := f.Tag.Lookup("minimum"); ok { - min, err := strconv.ParseFloat(tag, 64) - if err != nil { - return nil, err - } - s.Minimum = &min - } - - if tag, ok := f.Tag.Lookup("exclusiveMinimum"); ok { - min, err := strconv.ParseFloat(tag, 64) - if err != nil { - return nil, err - } - s.ExclusiveMinimum = &min - } - - if tag, ok := f.Tag.Lookup("maximum"); ok { - max, err := strconv.ParseFloat(tag, 64) - if err != nil { - return nil, err - } - s.Maximum = &max - } - - if tag, ok := f.Tag.Lookup("exclusiveMaximum"); ok { - max, err := strconv.ParseFloat(tag, 64) - if err != nil { - return nil, err - } - s.ExclusiveMaximum = &max - } - - if tag, ok := f.Tag.Lookup("multipleOf"); ok { - mof, err := strconv.ParseFloat(tag, 64) - if err != nil { - return nil, err - } - s.MultipleOf = mof - } - - if tag, ok := f.Tag.Lookup("minLength"); ok { - min, err := strconv.ParseUint(tag, 10, 64) - if err != nil { - return nil, err - } - s.MinLength = &min - } - - if tag, ok := f.Tag.Lookup("maxLength"); ok { - max, err := strconv.ParseUint(tag, 10, 64) - if err != nil { - return nil, err - } - s.MaxLength = &max - } - - if tag, ok := f.Tag.Lookup("pattern"); ok { - s.Pattern = tag - - if _, err := regexp.Compile(s.Pattern); err != nil { - return nil, err - } - } - - if tag, ok := f.Tag.Lookup("minItems"); ok { - min, err := strconv.ParseUint(tag, 10, 64) - if err != nil { - return nil, err - } - s.MinItems = &min - } - - if tag, ok := f.Tag.Lookup("maxItems"); ok { - max, err := strconv.ParseUint(tag, 10, 64) - if err != nil { - return nil, err - } - s.MaxItems = &max - } - - if tag, ok := f.Tag.Lookup("uniqueItems"); ok { - if !(tag == "true" || tag == "false") { - return nil, fmt.Errorf("%s uniqueItems: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) - } - s.UniqueItems = tag == "true" - } - - if tag, ok := f.Tag.Lookup("minProperties"); ok { - min, err := strconv.ParseUint(tag, 10, 64) - if err != nil { - return nil, err - } - s.MinProperties = &min - } - - if tag, ok := f.Tag.Lookup("maxProperties"); ok { - max, err := strconv.ParseUint(tag, 10, 64) - if err != nil { - return nil, err - } - s.MaxProperties = &max - } - - if tag, ok := f.Tag.Lookup("nullable"); ok { - if !(tag == "true" || tag == "false") { - return nil, fmt.Errorf("%s nullable: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) - } - s.Nullable = tag == "true" - } - - if tag, ok := f.Tag.Lookup("readOnly"); ok { - if !(tag == "true" || tag == "false") { - return nil, fmt.Errorf("%s readOnly: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) - } - s.ReadOnly = tag == "true" - - if s.ReadOnly && mode == ModeWrite { - delete(properties, name) - continue - } + if s.ReadOnly && mode == ModeWrite { + continue } - if tag, ok := f.Tag.Lookup("writeOnly"); ok { - if !(tag == "true" || tag == "false") { - return nil, fmt.Errorf("%s writeOnly: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) - } - s.WriteOnly = tag == "true" - - if s.WriteOnly && mode == ModeRead { - delete(properties, name) - continue - } + if s.WriteOnly && mode == ModeRead { + continue } - if tag, ok := f.Tag.Lookup("deprecated"); ok { - if !(tag == "true" || tag == "false") { - return nil, fmt.Errorf("%s deprecated: boolean should be true or false: %w", f.Name, ErrSchemaInvalid) - } - s.Deprecated = tag == "true" - } + properties[name] = s - optional := false - for _, tag := range jsonTags[1:] { - if tag == "omitempty" { - optional = true - } - } if !optional { required = append(required, name) } @@ -434,12 +452,18 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error } schema.AdditionalProperties = s case reflect.Slice, reflect.Array: - schema.Type = "array" - s, err := GenerateWithMode(t.Elem(), mode, nil) - if err != nil { - return nil, err + if t.Elem().Kind() == reflect.Uint8 { + // Special case: `[]byte` should be a Base-64 string. + schema.Type = "string" + schema.ContentEncoding = "base64" + } else { + schema.Type = "array" + s, err := GenerateWithMode(t.Elem(), mode, nil) + if err != nil { + return nil, err + } + schema.Items = s } - schema.Items = s case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: schema.Type = "integer" schema.Format = "int32" @@ -467,6 +491,8 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error schema.Type = "string" case reflect.Ptr: return GenerateWithMode(t.Elem(), mode, schema) + case reflect.Interface: + // Interfaces can be any type. default: return nil, fmt.Errorf("unsupported type %s from %s", t.Kind(), t) } diff --git a/schema/schema_test.go b/schema/schema_test.go index 1f16f02e..e391e05b 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -33,13 +33,18 @@ var types = []struct { }{ {false, "boolean", ""}, {0, "integer", "int32"}, + {int64(0), "integer", "int64"}, + {uint64(0), "integer", "int64"}, + {float32(0), "number", "float"}, {0.0, "number", "double"}, + {F(0.0), "number", "double"}, {"hello", "string", ""}, {struct{}{}, "object", ""}, {[]string{"foo"}, "array", ""}, {net.IP{}, "string", "ipv4"}, {time.Time{}, "string", "date-time"}, {url.URL{}, "string", "uri"}, + {[]byte{}, "string", ""}, // TODO: map } diff --git a/validate.go b/validate.go deleted file mode 100644 index 6bb1a7e8..00000000 --- a/validate.go +++ /dev/null @@ -1,271 +0,0 @@ -package huma - -import ( - "errors" - "fmt" - "net/http" - "reflect" - "regexp" - "strings" - "time" - - "github.com/gosimple/slug" - "github.com/istreamlabs/huma/schema" -) - -// ErrAPIInvalid is returned when validating the OpenAPI top-level fields -// has failed. -var ErrAPIInvalid = errors.New("invalid API") - -// ErrOperationInvalid is returned when validating an operation has failed. -var ErrOperationInvalid = errors.New("invalid operation") - -// ErrParamInvalid is returned when validating the parameter has failed. -var ErrParamInvalid = errors.New("invalid parameter") - -var paramRe = regexp.MustCompile(`:([^/]+)|{([^}]+)}`) -var versionRe = regexp.MustCompile(`^/v[0-9]+`) - -// validate the top-level API -func (a *openAPI) validate() error { - if a.Title == "" { - return fmt.Errorf("title is required: %w", ErrAPIInvalid) - } - - if a.Version == "" { - return fmt.Errorf("version is required: %w", ErrAPIInvalid) - } - - return nil -} - -// validate the parameter and generate schemas -func (p *openAPIParam) validate(t reflect.Type) { - switch p.In { - case inPath, inQuery, inHeader: - default: - panic(fmt.Errorf("parameter %s location invalid: %s", p.Name, p.In)) - } - - if t == nil { - // Unknown type for unsafe handlers defaults to `string` for path params - // and to the given default value's type for everything else. - if p.def != nil { - t = reflect.TypeOf(p.def) - } else { - t = reflect.TypeOf("") - } - } - - if p.typ != nil && p.typ != t { - panic(fmt.Errorf("parameter %s declared as %s was previously declared as %s: %w", p.Name, t, p.typ, ErrParamInvalid)) - } - - if p.def != nil { - dt := reflect.ValueOf(p.def).Type() - if t != dt { - panic(fmt.Errorf("parameter %s declared as %s has default of type %s: %w", p.Name, t, dt, ErrParamInvalid)) - } - } - - if p.Example != nil { - et := reflect.ValueOf(p.Example).Type() - if t != et { - panic(fmt.Errorf("parameter %s declared as %s has example of type %s: %w", p.Name, t, et, ErrParamInvalid)) - } - } - - p.typ = t - - if p.Schema == nil || p.Schema.Type == "" { - s, err := schema.GenerateWithMode(p.typ, schema.ModeWrite, p.Schema) - if err != nil { - panic(fmt.Errorf("parameter %s schema generation error: %w", p.Name, err)) - } - p.Schema = s - - if p.def != nil { - if t, ok := p.def.(time.Time); ok { - // Time defaults are only included if they are not the zero time. - if !t.IsZero() { - p.Schema.Default = p.def - } - } else { - p.Schema.Default = p.def - } - } - - if p.Example != nil { - // Some tools have better support for the param example, others for the - // schema example, so we include it in both. - p.Schema.Example = p.Example - } - } -} - -// validate the header and generate schemas -func (h *openAPIResponseHeader) validate(t reflect.Type) { - if t == nil { - // Unsafe handlers default to string headers - t = reflect.TypeOf("") - } - - if h.Schema == nil { - // Generate the schema from the handler function types. - s, err := schema.GenerateWithMode(t, schema.ModeRead, nil) - if err != nil { - panic(fmt.Errorf("response header %s schema generation error: %w", h.Name, err)) - } - h.Schema = s - } -} - -// validate checks that the operation is well-formed (e.g. handler signature -// matches the given params) and generates schemas if needed. -func (o *openAPIOperation) validate(method, path string) { - prefix := method + " " + path + ":" - - if o.summary == "" && o.description == "" { - panic(fmt.Errorf("%s summary or description field required: %w", prefix, ErrOperationInvalid)) - } - - if len(o.responses) == 0 { - panic(fmt.Errorf("%s at least one response is required: %w", prefix, ErrOperationInvalid)) - } - - if o.handler == nil { - panic(fmt.Errorf("%s handler is required: %w", prefix, ErrOperationInvalid)) - } - - handler := reflect.ValueOf(o.handler).Type() - - validateHandler := !o.unsafe() - if validateHandler { - totalIn := len(o.dependencies) + len(o.params) - totalOut := len(o.responseHeaders) + len(o.responses) - if !(handler.NumIn() == totalIn || (method != http.MethodGet && handler.NumIn() == totalIn+1)) || handler.NumOut() != totalOut { - expected := "func(" - for _, dep := range o.dependencies { - val := reflect.ValueOf(dep.handler) - if !val.IsValid() { - panic(fmt.Errorf("dependency %s is not a valid type: %w", dep.handler, ErrParamInvalid)) - } - expected += "? " + val.Type().String() + ", " - } - for _, param := range o.params { - expected += param.Name + " ?, " - } - expected = strings.TrimRight(expected, ", ") - expected += ") (" - for _, h := range o.responseHeaders { - expected += h.Name + " ?, " - } - for _, r := range o.responses { - expected += fmt.Sprintf("*Response%d, ", r.StatusCode) - } - expected = strings.TrimRight(expected, ", ") - expected += ")" - - panic(fmt.Errorf("%s expected handler %s but found %s: %w", prefix, expected, handler, ErrOperationInvalid)) - } - } - - if o.id == "" { - verb := method - - // Try to detect calls returning lists of things. - if validateHandler && handler.NumOut() > 0 { - k := handler.Out(0).Kind() - if k == reflect.Array || k == reflect.Slice { - verb = "list" - } - } - - // Remove variables from path so they aren't in the generated name. - path := paramRe.ReplaceAllString(path, "") - - // Remove version at the beginning of the path if present - path = versionRe.ReplaceAllString(path, "") - - o.id = slug.Make(verb + path) - } - - for i, dep := range o.dependencies { - if validateHandler { - paramType := handler.In(i) - - // Catch common errors. - if paramType.String() == "gin.Context" { - panic(fmt.Errorf("%s gin.Context should be pointer *gin.Context: %w", prefix, ErrOperationInvalid)) - } - - dep.validate(paramType) - } else { - dep.validate(nil) - } - } - - types := []reflect.Type{} - if validateHandler { - for i := len(o.dependencies); i < handler.NumIn(); i++ { - paramType := handler.In(i) - - switch paramType.String() { - case "gin.Context", "*gin.Context": - panic(fmt.Errorf("%s expected param but found gin.Context: %w", prefix, ErrOperationInvalid)) - } - - types = append(types, paramType) - } - } else { - for i := 0; i < len(o.params); i++ { - types = append(types, nil) - } - } - - requestBody := false - if len(types) == len(o.params)+1 { - requestBody = true - } - - for i, paramType := range types { - if i == len(types)-1 && requestBody { - // The last item has no associated param. It is a request body. - if o.requestSchema == nil { - s, err := schema.GenerateWithMode(paramType, schema.ModeWrite, nil) - if err != nil { - panic(fmt.Errorf("%s request body schema generation error: %w", prefix, err)) - } - o.requestSchema = s - } - continue - } - - p := o.params[i] - p.validate(paramType) - } - - for i, header := range o.responseHeaders { - if validateHandler { - header.validate(handler.Out(i)) - } else { - header.validate(nil) - } - } - - for i, resp := range o.responses { - if validateHandler { - respType := handler.Out(len(o.responseHeaders) + i) - // HTTP 204 explicitly forbids a response body. We model this with an - // empty content type. - if resp.ContentType != "" && resp.Schema == nil { - // Generate the schema from the handler function types. - s, err := schema.GenerateWithMode(respType, schema.ModeRead, nil) - if err != nil { - panic(fmt.Errorf("%s response %d schema generation error: %w", prefix, resp.StatusCode, err)) - } - resp.Schema = s - } - } - } -} diff --git a/validate_test.go b/validate_test.go deleted file mode 100644 index 729957b7..00000000 --- a/validate_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package huma - -import ( - "net/http" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" -) - -func TestParamTimeDefault(t *testing.T) { - p := openAPIParam{ - Name: "test", - In: inQuery, - def: time.Time{}, - } - p.validate(nil) - assert.Nil(t, p.Schema.Default) - - p2 := openAPIParam{ - Name: "test", - In: inQuery, - def: time.Now(), - } - p2.validate(nil) - assert.NotNil(t, p2.Schema.Default) -} - -func TestOperationDescriptionRequired(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.register(http.MethodGet, "/", &openAPIOperation{}) - }) -} - -func TestOperationResponseRequired(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.register(http.MethodGet, "/", &openAPIOperation{ - description: "Test", - }) - }) -} - -func TestOperationHandlerInput(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - SimpleDependency("test"), - ResponseText(200, "Test"), - ).Get("Test", func() string { - // Wrong number of inputs! - return "fails" - }) - }) -} - -func TestOperationBadHandler(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - SimpleDependency(nil), - ResponseText(200, "Test"), - ).Get("Test", func(pa *string, b int) string { - // Wrong number of inputs! - return "boom" - }) - }) -} - -func TestOperationHandlerOutput(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - ResponseHeader("x-test", "Test"), - ResponseText(200, "Test", Headers("x-test")), - ).Get("Test", func() string { - // Wrong number of outputs! - return "fails" - }) - }) -} - -func TestOperationListAutoID(t *testing.T) { - r := NewTestRouter(t) - - r.Resource("/items").Get("Test", func() []string { - return []string{"test"} - }) - - o := r.api.Paths["/items"][http.MethodGet] - - assert.Equal(t, "list-items", o.id) -} - -func TestOperationContextPointer(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - GinContextDependency(), - ).Get("Test", func(c gin.Context) string { - return "test" - }) - }) -} - -func TestOperationInvalidDep(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - SimpleDependency(nil), - ).Get("Test", func(o openAPIOperation) string { - return "test" - }) - }) -} - -func TestOperationParamDep(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - QueryParam("foo", "Test", ""), - ).Get("Test", func(c *gin.Context) string { - return "test" - }) - }) -} - -func TestOperationParamRedeclare(t *testing.T) { - r := NewTestRouter(t) - - param := QueryParam("foo", "Test", 0) - - r.Resource("/a", param).Get("Test", func(p int) string { return "a" }) - - // Redeclare param `p` as a string while it was an int above. - assert.Panics(t, func() { - r.Resource("/b", param).Get("Test", func(p string) string { return "b" }) - }) -} - -func TestOperationParamExampleType(t *testing.T) { - r := NewTestRouter(t) - - assert.Panics(t, func() { - r.Resource("/", - QueryParam("foo", "Test", "", Example(123)), - ).Get("Test", func(p string) string { - return "test" - }) - }) -} - -func TestOperationParamExampleSchema(t *testing.T) { - r := NewTestRouter(t) - - p := QueryParam("foo", "Test", 0, Example(123)) - - r.Resource("/", p).Get("Test", func(p int) string { - return "test" - }) - - param := r.api.Paths["/"][http.MethodGet].params[0] - - assert.Equal(t, 123, param.Schema.Example) -} From 66fb93b2561cfa16eddb90ff83ebdb163a59760b Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Fri, 2 Oct 2020 11:36:29 -0700 Subject: [PATCH 12/21] fix: include tags in rendered OpenAPI --- resource.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/resource.go b/resource.go index 8f663f56..1eede6bd 100644 --- a/resource.go +++ b/resource.go @@ -30,7 +30,13 @@ func (r *Resource) toOpenAPI() *gabs.Container { } for _, op := range r.operations { - doc.Set(op.toOpenAPI(), r.path, strings.ToLower(op.method)) + opValue := op.toOpenAPI() + + if len(r.tags) > 0 { + opValue.Set(r.tags, "tags") + } + + doc.Set(opValue, r.path, strings.ToLower(op.method)) } return doc From 51b47cab4d0c9ded2eb902c473646ea6a27519e5 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Thu, 19 Nov 2020 16:43:16 -0800 Subject: [PATCH 13/21] fix: better handling of array examples --- schema/schema.go | 26 +++++++++++++++++++++++++- schema/schema_test.go | 22 ++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/schema/schema.go b/schema/schema.go index b9fa3c7a..48819d1f 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -54,18 +54,42 @@ func F(value float64) *float64 { // getTagValue returns a value of the schema's type for the given tag string. // Uses JSON parsing if the schema is not a string. func getTagValue(s *Schema, t reflect.Type, value string) (interface{}, error) { + // Special case: strings don't need quotes. if s.Type == "string" { return value, nil } + // Special case: array of strings with comma-separated values and no quotes. + if s.Type == "array" && s.Items != nil && s.Items.Type == "string" && value[0] != '[' { + values := []string{} + for _, s := range strings.Split(value, ",") { + values = append(values, strings.TrimSpace(s)) + } + return values, nil + } + var v interface{} if err := json.Unmarshal([]byte(value), &v); err != nil { return nil, err } + vv := reflect.ValueOf(v) tv := reflect.TypeOf(v) if v != nil && tv != t { - if !tv.ConvertibleTo(t) { + if tv.Kind() == reflect.Slice { + // Slices can't be cast due to the different layouts. Instead, we make a + // new instance of the destination slice, and convert each value in + // the original to the new type. + tmp := reflect.MakeSlice(t, 0, vv.Len()) + for i := 0; i < vv.Len(); i++ { + if !vv.Index(i).Elem().Type().ConvertibleTo(t.Elem()) { + return nil, fmt.Errorf("unable to convert %v to %v: %w", vv.Index(i).Interface(), t.Elem(), ErrSchemaInvalid) + } + + tmp = reflect.Append(tmp, vv.Index(i).Elem().Convert(t.Elem())) + } + v = tmp.Interface() + } else if !tv.ConvertibleTo(t) { return nil, fmt.Errorf("unable to convert %v to %v: %w", tv, t, ErrSchemaInvalid) } diff --git a/schema/schema_test.go b/schema/schema_test.go index e391e05b..45e40090 100644 --- a/schema/schema_test.go +++ b/schema/schema_test.go @@ -582,3 +582,25 @@ func TestEmbedded(t *testing.T) { assert.Len(t, s.Properties, 3) assert.Equal(t, "integer", s.Properties["b"].Type) } + +func TestStringArrayExample(t *testing.T) { + type Foo struct { + A []string `json:"a" example:"[\"a\",\"b\",\"c\"]"` + B []string `json:"b" example:"a, b, c"` + } + + s, err := Generate(reflect.TypeOf(Foo{})) + assert.NoError(t, err) + + assert.Equal(t, s.Properties["a"].Example, []string{"a", "b", "c"}) + assert.Equal(t, s.Properties["b"].Example, []string{"a", "b", "c"}) +} + +func TestExampleBadJSON(t *testing.T) { + type Foo struct { + A []string `json:"a" example:"[\"a\", 1]"` + } + + _, err := Generate(reflect.TypeOf(Foo{})) + assert.Error(t, err) +} From 81e7ab242fc4ff603c8b4cbdf717b66891444fa5 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Mon, 30 Nov 2020 09:40:33 -0800 Subject: [PATCH 14/21] chore: constant type names --- schema/schema.go | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/schema/schema.go b/schema/schema.go index 48819d1f..0e22e6e6 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -32,6 +32,16 @@ const ( ModeWrite ) +// JSON Schema type constants +const ( + TypeBoolean = "boolean" + TypeInteger = "integer" + TypeNumber = "number" + TypeString = "string" + TypeArray = "array" + TypeObject = "object" +) + var ( timeType = reflect.TypeOf(time.Time{}) ipType = reflect.TypeOf(net.IP{}) @@ -55,12 +65,12 @@ func F(value float64) *float64 { // Uses JSON parsing if the schema is not a string. func getTagValue(s *Schema, t reflect.Type, value string) (interface{}, error) { // Special case: strings don't need quotes. - if s.Type == "string" { + if s.Type == TypeString { return value, nil } // Special case: array of strings with comma-separated values and no quotes. - if s.Type == "array" && s.Items != nil && s.Items.Type == "string" && value[0] != '[' { + if s.Type == TypeArray && s.Items != nil && s.Items.Type == TypeString && value[0] != '[' { values := []string{} for _, s := range strings.Split(value, ",") { values = append(values, strings.TrimSpace(s)) @@ -410,7 +420,7 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error if t == ipType { // Special case: IP address. - return &Schema{Type: "string", Format: "ipv4"}, nil + return &Schema{Type: TypeString, Format: "ipv4"}, nil } switch t.Kind() { @@ -418,14 +428,14 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error // Handle special cases. switch t { case timeType: - return &Schema{Type: "string", Format: "date-time"}, nil + return &Schema{Type: TypeString, Format: "date-time"}, nil case uriType: - return &Schema{Type: "string", Format: "uri"}, nil + return &Schema{Type: TypeString, Format: "uri"}, nil } properties := make(map[string]*Schema) required := make([]string, 0) - schema.Type = "object" + schema.Type = TypeObject schema.AdditionalProperties = false for _, f := range getFields(t) { @@ -469,7 +479,7 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error } case reflect.Map: - schema.Type = "object" + schema.Type = TypeObject s, err := GenerateWithMode(t.Elem(), mode, nil) if err != nil { return nil, err @@ -478,10 +488,10 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error case reflect.Slice, reflect.Array: if t.Elem().Kind() == reflect.Uint8 { // Special case: `[]byte` should be a Base-64 string. - schema.Type = "string" + schema.Type = TypeString schema.ContentEncoding = "base64" } else { - schema.Type = "array" + schema.Type = TypeArray s, err := GenerateWithMode(t.Elem(), mode, nil) if err != nil { return nil, err @@ -489,30 +499,30 @@ func GenerateWithMode(t reflect.Type, mode Mode, schema *Schema) (*Schema, error schema.Items = s } case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32: - schema.Type = "integer" + schema.Type = TypeInteger schema.Format = "int32" case reflect.Int64: - schema.Type = "integer" + schema.Type = TypeInteger schema.Format = "int64" case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: // Unsigned integers can't be negative. - schema.Type = "integer" + schema.Type = TypeInteger schema.Format = "int32" schema.Minimum = F(0.0) case reflect.Uint64: - schema.Type = "integer" + schema.Type = TypeInteger schema.Format = "int64" schema.Minimum = F(0.0) case reflect.Float32: - schema.Type = "number" + schema.Type = TypeNumber schema.Format = "float" case reflect.Float64: - schema.Type = "number" + schema.Type = TypeNumber schema.Format = "double" case reflect.Bool: - schema.Type = "boolean" + schema.Type = TypeBoolean case reflect.String: - schema.Type = "string" + schema.Type = TypeString case reflect.Ptr: return GenerateWithMode(t.Elem(), mode, schema) case reflect.Interface: From 59e246d476b7bdb382f15babb9896069d70ce32e Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Fri, 18 Dec 2020 16:21:01 -0800 Subject: [PATCH 15/21] feat: use refs for response models in generated OpenAPI --- openapi.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ openapi_test.go | 48 ++++++++++++++++++++++++++++++++++++++++ operation.go | 9 +++----- resource.go | 6 ++--- router.go | 9 +++++++- 5 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 openapi_test.go diff --git a/openapi.go b/openapi.go index fb85461c..0f2a479d 100644 --- a/openapi.go +++ b/openapi.go @@ -1,6 +1,9 @@ package huma import ( + "fmt" + "reflect" + "github.com/istreamlabs/huma/schema" ) @@ -41,3 +44,59 @@ type oaParam struct { // params sent between a load balander / proxy and the service internally. Internal bool `json:"-"` } + +type oaComponents struct { + Schemas map[string]*schema.Schema `json:"schemas,omitempty"` +} + +func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) string { + // Try to determine the type's name. + name := t.Name() + if name == "" && t.Kind() == reflect.Ptr { + // Take the name of the pointed-to type. + name = t.Elem().Name() + } + if name == "" && t.Kind() == reflect.Slice { + // Take the name of the type in the array and append "List" to it. + tmp := t.Elem() + if tmp.Kind() == reflect.Ptr { + tmp = tmp.Elem() + } + name = tmp.Name() + if name != "" { + name += "List" + } + } + if name == "" { + // No luck, fall back to the passed-in hint. Better than nothing. + name = hint + } + + s, err := schema.GenerateWithMode(t, mode, nil) + if err != nil { + panic(err) + } + + orig := name + num := 1 + for { + if c.Schemas[name] == nil { + // No existing schema, we are the first! + break + } + + if reflect.DeepEqual(c.Schemas[name], s) { + // Existing schema matches! + break + } + + // If we are here, then an existing schema doesn't match and this is a new + // type. So we will rename it in a deterministic fashion. + num++ + name = fmt.Sprintf("%s%d", orig, num) + } + + c.Schemas[name] = s + + return "#/components/schemas/" + name +} diff --git a/openapi_test.go b/openapi_test.go new file mode 100644 index 00000000..25343ae3 --- /dev/null +++ b/openapi_test.go @@ -0,0 +1,48 @@ +package huma + +import ( + "reflect" + "testing" + + "github.com/istreamlabs/huma/schema" + "github.com/stretchr/testify/assert" +) + +type componentFoo struct { + Field string `json:"field"` + Another string `json:"another" readOnly:"true"` +} + +type componentBar struct { + Field string `json:"field"` +} + +func TestComponentSchemas(t *testing.T) { + components := oaComponents{ + Schemas: map[string]*schema.Schema{}, + } + + // Adding two different versions of the same component. + ref := components.AddSchema(reflect.TypeOf(&componentFoo{}), schema.ModeRead, "hint") + assert.Equal(t, ref, "#/components/schemas/componentFoo") + assert.NotNil(t, components.Schemas["componentFoo"]) + + ref = components.AddSchema(reflect.TypeOf(&componentFoo{}), schema.ModeWrite, "hint") + assert.Equal(t, ref, "#/components/schemas/componentFoo2") + assert.NotNil(t, components.Schemas["componentFoo2"]) + + // Re-adding the second should not create a third. + ref = components.AddSchema(reflect.TypeOf(&componentFoo{}), schema.ModeWrite, "hint") + assert.Equal(t, ref, "#/components/schemas/componentFoo2") + assert.Nil(t, components.Schemas["componentFoo3"]) + + // Adding a list of pointers to a struct. + ref = components.AddSchema(reflect.TypeOf([]*componentBar{}), schema.ModeAll, "hint") + assert.Equal(t, ref, "#/components/schemas/componentBarList") + assert.NotNil(t, components.Schemas["componentBarList"]) + + // Adding an anonymous empty struct, should use the hint. + ref = components.AddSchema(reflect.TypeOf(struct{}{}), schema.ModeAll, "hint") + assert.Equal(t, ref, "#/components/schemas/hint") + assert.NotNil(t, components.Schemas["hint"]) +} diff --git a/operation.go b/operation.go index a3e159ac..babeea63 100644 --- a/operation.go +++ b/operation.go @@ -43,7 +43,7 @@ func newOperation(resource *Resource, method, id, docs string, responses []Respo } } -func (o *Operation) toOpenAPI() *gabs.Container { +func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container { doc := gabs.New() doc.Set(o.id, "operationId") @@ -98,11 +98,8 @@ func (o *Operation) toOpenAPI() *gabs.Container { } if resp.model != nil { - schema, err := schema.GenerateWithMode(resp.model, schema.ModeRead, nil) - if err != nil { - panic(err) - } - doc.Set(schema, "responses", status, "content", resp.contentType, "schema") + ref := components.AddSchema(resp.model, schema.ModeRead, o.id) + doc.Set(ref, "responses", status, "content", resp.contentType, "schema", "$ref") } } diff --git a/resource.go b/resource.go index 1eede6bd..b9498422 100644 --- a/resource.go +++ b/resource.go @@ -22,15 +22,15 @@ type Resource struct { tags []string } -func (r *Resource) toOpenAPI() *gabs.Container { +func (r *Resource) toOpenAPI(components *oaComponents) *gabs.Container { doc := gabs.New() for _, sub := range r.subResources { - doc.Merge(sub.toOpenAPI()) + doc.Merge(sub.toOpenAPI(components)) } for _, op := range r.operations { - opValue := op.toOpenAPI() + opValue := op.toOpenAPI(components) if len(r.tags) > 0 { opValue.Set(r.tags, "tags") diff --git a/router.go b/router.go index 4995f1d9..c83f03ad 100644 --- a/router.go +++ b/router.go @@ -10,6 +10,7 @@ import ( "github.com/Jeffail/gabs/v2" "github.com/go-chi/chi" + "github.com/istreamlabs/huma/schema" ) type contextKey string @@ -70,11 +71,17 @@ func (r *Router) OpenAPI() *gabs.Container { doc.Set(r.description, "info", "description") } + components := &oaComponents{ + Schemas: map[string]*schema.Schema{}, + } + paths, _ := doc.Object("paths") for _, res := range r.resources { - paths.Merge(res.toOpenAPI()) + paths.Merge(res.toOpenAPI(components)) } + doc.Set(components, "components") + if r.openapiHook != nil { r.openapiHook(doc) } From 68c4145c3b0c005cd277f5b8bb05453d03da9633 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Tue, 22 Dec 2020 10:00:43 -0800 Subject: [PATCH 16/21] feat: security & autoconfiguration --- autoconfig.go | 20 +++++++++++ openapi.go | 20 ++++++++++- router.go | 91 ++++++++++++++++++++++++++++++++++++++-------- router_test.go | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 15 deletions(-) create mode 100644 autoconfig.go diff --git a/autoconfig.go b/autoconfig.go new file mode 100644 index 00000000..f0c7f750 --- /dev/null +++ b/autoconfig.go @@ -0,0 +1,20 @@ +package huma + +// AutoConfigVar represents a variable given by the user when prompted during +// auto-configuration setup of an API. +type AutoConfigVar struct { + Description string `json:"description,omitempty"` + Example string `json:"example,omitempty"` + Default interface{} `json:"default,omitempty"` + Enum []interface{} `json:"enum,omitempty"` +} + +// AutoConfig holds an API's automatic configuration settings for the CLI. These +// are advertised via OpenAPI extension and picked up by the CLI to make it +// easier to get started using an API. +type AutoConfig struct { + Security string `json:"security"` + Headers map[string]string `json:"headers,omitempty"` + Prompt map[string]AutoConfigVar `json:"prompt,omitempty"` + Params map[string]string `json:"params"` +} diff --git a/openapi.go b/openapi.go index 0f2a479d..796ec4cf 100644 --- a/openapi.go +++ b/openapi.go @@ -46,7 +46,8 @@ type oaParam struct { } type oaComponents struct { - Schemas map[string]*schema.Schema `json:"schemas,omitempty"` + Schemas map[string]*schema.Schema `json:"schemas,omitempty"` + SecuritySchemes map[string]oaSecurityScheme `json:"securitySchemes,omitempty"` } func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) string { @@ -100,3 +101,20 @@ func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) return "#/components/schemas/" + name } + +type oaFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty"` +} + +type oaFlows struct { + ClientCredentials *oaFlow `json:"clientCredentials,omitempty"` + AuthorizationCode *oaFlow `json:"authorizationCode,omitempty"` +} + +type oaSecurityScheme struct { + Type string `json:"type"` + Scheme string `json:"scheme,omitempty"` + Flows oaFlows `json:"flows,omitempty"` +} diff --git a/router.go b/router.go index c83f03ad..d64e83f0 100644 --- a/router.go +++ b/router.go @@ -33,13 +33,15 @@ type Router struct { mux *chi.Mux resources []*Resource - title string - version string - description string - contact oaContact - servers []oaServer - // securitySchemes - // security + title string + version string + description string + contact oaContact + servers []oaServer + securitySchemes map[string]oaSecurityScheme + security map[string][]string + + autoConfig *AutoConfig // Documentation handler function docsPrefix string @@ -72,7 +74,8 @@ func (r *Router) OpenAPI() *gabs.Container { } components := &oaComponents{ - Schemas: map[string]*schema.Schema{}, + Schemas: map[string]*schema.Schema{}, + SecuritySchemes: r.securitySchemes, } paths, _ := doc.Object("paths") @@ -82,6 +85,14 @@ func (r *Router) OpenAPI() *gabs.Container { doc.Set(components, "components") + if len(r.security) > 0 { + doc.Set(r.security, "security") + } + + if r.autoConfig != nil { + doc.Set(r.autoConfig, "x-cli-config") + } + if r.openapiHook != nil { r.openapiHook(doc) } @@ -104,6 +115,56 @@ func (r *Router) ServerLink(description, uri string) { }) } +// GatewayBasicAuth documents that the API gateway handles auth using HTTP Basic. +func (r *Router) GatewayBasicAuth(name string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "http", + Scheme: "basic", + } +} + +// GatewayClientCredentials documents that the API gateway handles auth using +// OAuth2 client credentials (pre-shared secret). +func (r *Router) GatewayClientCredentials(name, tokenURL string, scopes map[string]string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "oauth2", + Flows: oaFlows{ + ClientCredentials: &oaFlow{ + TokenURL: tokenURL, + Scopes: scopes, + }, + }, + } +} + +// GatewayAuthCode documents that the API gateway handles auth using +// OAuth2 authorization code (user login). +func (r *Router) GatewayAuthCode(name, authorizeURL, tokenURL string, scopes map[string]string) { + r.securitySchemes[name] = oaSecurityScheme{ + Type: "oauth2", + Flows: oaFlows{ + AuthorizationCode: &oaFlow{ + AuthorizationURL: authorizeURL, + TokenURL: tokenURL, + Scopes: scopes, + }, + }, + } +} + +// AutoConfig sets up CLI autoconfiguration via `x-cli-config` for use by CLI +// clients, e.g. using a tool like Restish (https://rest.sh/). +func (r *Router) AutoConfig(autoConfig AutoConfig) { + r.autoConfig = &autoConfig +} + +// SecurityRequirement sets up a security requirement for the entire API by +// name and with the given scopes. Use together with the other auth options +// like GatewayAuthCode. +func (r *Router) SecurityRequirement(name string, scopes ...string) { + r.security[name] = scopes +} + // Resource creates a new resource attached to this router at the given path. // The path can include parameters, e.g. `/things/{thing-id}`. Each resource // path must be unique. @@ -256,12 +317,14 @@ func New(docs, version string) *Router { title, desc := splitDocs(docs) r := &Router{ - mux: chi.NewRouter(), - resources: []*Resource{}, - title: title, - description: desc, - version: version, - servers: []oaServer{}, + mux: chi.NewRouter(), + resources: []*Resource{}, + title: title, + description: desc, + version: version, + servers: []oaServer{}, + securitySchemes: map[string]oaSecurityScheme{}, + security: map[string][]string{}, } r.docsHandler = RapiDocHandler(r) diff --git a/router_test.go b/router_test.go index 1886f087..fb1320b7 100644 --- a/router_test.go +++ b/router_test.go @@ -2,6 +2,7 @@ package huma import ( "bytes" + "encoding/json" "io" "io/ioutil" "net/http" @@ -240,3 +241,99 @@ func TestInvalidPathParam(t *testing.T) { }) }) } + +func TestRouterSecurity(t *testing.T) { + app := newTestRouter() + + // Document that the API gateway handles auth via OAuth2 Authorization Code. + app.GatewayAuthCode("default", "https://example.com/authorize", "https://example.com/token", nil) + app.GatewayClientCredentials("m2m", "https://example.com/token", nil) + app.GatewayBasicAuth("basic") + + // Every call must be authenticated using the default auth mechanism + // registered above. + app.SecurityRequirement("default") + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var parsed map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.Nil(t, err) + + assert.Equal(t, parsed["security"], map[string]interface{}{ + "default": nil, + }) + + assert.Equal(t, parsed["components"].(map[string]interface{})["securitySchemes"], map[string]interface{}{ + "default": map[string]interface{}{ + "type": "oauth2", + "flows": map[string]interface{}{ + "authorizationCode": map[string]interface{}{ + "authorizationUrl": "https://example.com/authorize", + "tokenUrl": "https://example.com/token", + }, + }, + }, + "m2m": map[string]interface{}{ + "type": "oauth2", + "flows": map[string]interface{}{ + "clientCredentials": map[string]interface{}{ + "tokenUrl": "https://example.com/token", + }, + }, + }, + "basic": map[string]interface{}{ + "type": "http", + "scheme": "basic", + "flows": map[string]interface{}{}, + }, + }) +} + +// TODO: test app.AutoConfig +func TestRouterAutoConfig(t *testing.T) { + app := newTestRouter() + + app.GatewayAuthCode("authcode", "https://example.com/authorize", "https://example.com/token", nil) + app.SecurityRequirement("authcode") + + app.AutoConfig(AutoConfig{ + Security: "authcode", + Prompt: map[string]AutoConfigVar{ + "extra": { + Description: "Some extra value", + Example: "abc123", + }, + }, + Params: map[string]string{ + "another": "https://example.com/extras/{extra}", + }, + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/openapi.json", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var parsed map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &parsed) + assert.Nil(t, err) + + assert.Equal(t, parsed["x-cli-config"], map[string]interface{}{ + "security": "authcode", + "prompt": map[string]interface{}{ + "extra": map[string]interface{}{ + "description": "Some extra value", + "example": "abc123", + }, + }, + "params": map[string]interface{}{ + "another": "https://example.com/extras/{extra}", + }, + }) +} From e8c400a990a8103bdd9afa5914b72d6a053e4afd Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Wed, 23 Dec 2020 15:38:44 -0800 Subject: [PATCH 17/21] fix: security reference structure --- router.go | 15 +++++++++++---- router_test.go | 7 ++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/router.go b/router.go index d64e83f0..6550a982 100644 --- a/router.go +++ b/router.go @@ -39,7 +39,7 @@ type Router struct { contact oaContact servers []oaServer securitySchemes map[string]oaSecurityScheme - security map[string][]string + security []map[string][]string autoConfig *AutoConfig @@ -160,9 +160,16 @@ func (r *Router) AutoConfig(autoConfig AutoConfig) { // SecurityRequirement sets up a security requirement for the entire API by // name and with the given scopes. Use together with the other auth options -// like GatewayAuthCode. +// like GatewayAuthCode. Calling multiple times results in requiring one OR +// the other schemes but not both. func (r *Router) SecurityRequirement(name string, scopes ...string) { - r.security[name] = scopes + if scopes == nil { + scopes = []string{} + } + + r.security = append(r.security, map[string][]string{ + name: scopes, + }) } // Resource creates a new resource attached to this router at the given path. @@ -324,7 +331,7 @@ func New(docs, version string) *Router { version: version, servers: []oaServer{}, securitySchemes: map[string]oaSecurityScheme{}, - security: map[string][]string{}, + security: []map[string][]string{}, } r.docsHandler = RapiDocHandler(r) diff --git a/router_test.go b/router_test.go index fb1320b7..e5fcba06 100644 --- a/router_test.go +++ b/router_test.go @@ -264,9 +264,10 @@ func TestRouterSecurity(t *testing.T) { err := json.Unmarshal(w.Body.Bytes(), &parsed) assert.Nil(t, err) - assert.Equal(t, parsed["security"], map[string]interface{}{ - "default": nil, - }) + assert.Equal(t, parsed["security"], []interface{}{ + map[string]interface{}{ + "default": []interface{}{}, + }}) assert.Equal(t, parsed["components"].(map[string]interface{})["securitySchemes"], map[string]interface{}{ "default": map[string]interface{}{ From d91be40e4f87271bb7a74a3156aa277544769efc Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Mon, 4 Jan 2021 11:01:01 -0800 Subject: [PATCH 18/21] fix: include servers in rendered OpenAPI --- router.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/router.go b/router.go index 6550a982..0a713782 100644 --- a/router.go +++ b/router.go @@ -73,6 +73,10 @@ func (r *Router) OpenAPI() *gabs.Container { doc.Set(r.description, "info", "description") } + if len(r.servers) > 0 { + doc.Set(r.servers, "servers") + } + components := &oaComponents{ Schemas: map[string]*schema.Schema{}, SecuritySchemes: r.securitySchemes, From eb2256a9be63a69f376a953cc15dcedfbf5d1e8f Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Mon, 4 Jan 2021 11:01:56 -0800 Subject: [PATCH 19/21] feat: schema ref generation improvements --- openapi.go | 20 +++++++++++++++++--- operation.go | 7 +++++-- schema/schema.go | 3 ++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/openapi.go b/openapi.go index 796ec4cf..88434ae7 100644 --- a/openapi.go +++ b/openapi.go @@ -73,9 +73,23 @@ func (c *oaComponents) AddSchema(t reflect.Type, mode schema.Mode, hint string) name = hint } - s, err := schema.GenerateWithMode(t, mode, nil) - if err != nil { - panic(err) + var s *schema.Schema + + if t.Kind() == reflect.Slice { + // We actually want to create two models: one for the container slice + // and one for the items within it. + ref := c.AddSchema(t.Elem(), mode, name+"Item") + s = &schema.Schema{ + Type: schema.TypeArray, + Items: &schema.Schema{ + Ref: ref, + }, + } + } else { + var err error + if s, err = schema.GenerateWithMode(t, mode, nil); err != nil { + panic(err) + } } orig := name diff --git a/operation.go b/operation.go index babeea63..baa5cfa5 100644 --- a/operation.go +++ b/operation.go @@ -22,6 +22,7 @@ type Operation struct { params map[string]oaParam requestContentType string requestSchema *schema.Schema + requestModel reflect.Type responses []Response maxBodyBytes int64 bodyReadTimeout time.Duration @@ -70,7 +71,8 @@ func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container { if ct == "" { ct = "application/json" } - doc.Set(o.requestSchema, "requestBody", "content", ct, "schema") + ref := components.AddSchema(o.requestModel, schema.ModeAll, o.id+"-request") + doc.Set(ref, "requestBody", "content", ct, "schema", "$ref") } // responses @@ -98,7 +100,7 @@ func (o *Operation) toOpenAPI(components *oaComponents) *gabs.Container { } if resp.model != nil { - ref := components.AddSchema(resp.model, schema.ModeRead, o.id) + ref := components.AddSchema(resp.model, schema.ModeAll, o.id+"-response") doc.Set(ref, "responses", status, "content", resp.contentType, "schema", "$ref") } } @@ -179,6 +181,7 @@ func (o *Operation) Run(handler interface{}) { // Get body if present. if body, ok := input.FieldByName("Body"); ok { + o.requestModel = body.Type o.requestSchema, err = schema.GenerateWithMode(body.Type, schema.ModeWrite, nil) if err != nil { panic(fmt.Errorf("unable to generate JSON schema: %w", err)) diff --git a/schema/schema.go b/schema/schema.go index 0e22e6e6..39961b54 100644 --- a/schema/schema.go +++ b/schema/schema.go @@ -144,13 +144,14 @@ type Schema struct { WriteOnly bool `json:"writeOnly,omitempty"` Deprecated bool `json:"deprecated,omitempty"` ContentEncoding string `json:"contentEncoding,omitempty"` + Ref string `json:"$ref,omitempty"` } // HasValidation returns true if at least one validator is set on the schema. // This excludes the schema's type but includes most other fields and can be // used to trigger additional slow validation steps when needed. func (s *Schema) HasValidation() bool { - if s.Items != nil || len(s.Properties) > 0 || s.AdditionalProperties != nil || len(s.PatternProperties) > 0 || len(s.Required) > 0 || len(s.Enum) > 0 || s.Minimum != nil || s.ExclusiveMinimum != nil || s.Maximum != nil || s.ExclusiveMaximum != nil || s.MultipleOf != 0 || s.MinLength != nil || s.MaxLength != nil || s.Pattern != "" || s.MinItems != nil || s.MaxItems != nil || s.UniqueItems || s.MinProperties != nil || s.MaxProperties != nil || len(s.AllOf) > 0 || len(s.AnyOf) > 0 || len(s.OneOf) > 0 || s.Not != nil { + if s.Items != nil || len(s.Properties) > 0 || s.AdditionalProperties != nil || len(s.PatternProperties) > 0 || len(s.Required) > 0 || len(s.Enum) > 0 || s.Minimum != nil || s.ExclusiveMinimum != nil || s.Maximum != nil || s.ExclusiveMaximum != nil || s.MultipleOf != 0 || s.MinLength != nil || s.MaxLength != nil || s.Pattern != "" || s.MinItems != nil || s.MaxItems != nil || s.UniqueItems || s.MinProperties != nil || s.MaxProperties != nil || len(s.AllOf) > 0 || len(s.AnyOf) > 0 || len(s.OneOf) > 0 || s.Not != nil || s.Ref != "" { return true } From c3d40591a86cf70e0724d27ce3a51a9933666d5e Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Mon, 4 Jan 2021 11:02:13 -0800 Subject: [PATCH 20/21] feat: support CLI auto-config exclude param --- autoconfig.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/autoconfig.go b/autoconfig.go index f0c7f750..f354bcb8 100644 --- a/autoconfig.go +++ b/autoconfig.go @@ -7,6 +7,10 @@ type AutoConfigVar struct { Example string `json:"example,omitempty"` Default interface{} `json:"default,omitempty"` Enum []interface{} `json:"enum,omitempty"` + + // Exclude the value from being sent to the server. This essentially makes + // it a value which is only used in param templates. + Exclude bool `json:"exclude,omitempty"` } // AutoConfig holds an API's automatic configuration settings for the CLI. These From eaa86f83ec9fb625156ff23300769e073d0af487 Mon Sep 17 00:00:00 2001 From: Andrew Orban Date: Mon, 8 Feb 2021 10:42:03 -0800 Subject: [PATCH 21/21] Added 406 (NotAcceptable) to huma responses --- responses/responses.go | 5 +++++ responses/responses_test.go | 2 ++ 2 files changed, 7 insertions(+) diff --git a/responses/responses.go b/responses/responses.go index e026dd6a..f625e8ad 100644 --- a/responses/responses.go +++ b/responses/responses.go @@ -83,6 +83,11 @@ func NotFound() huma.Response { return errorResponse(http.StatusNotFound) } +// NotAcceptable HTTP 406 response with a structured error body (e.g. JSON). +func NotAcceptable() huma.Response { + return errorResponse(http.StatusNotAcceptable) +} + // RequestTimeout HTTP 408 response with a structured error body (e.g. JSON). func RequestTimeout() huma.Response { return errorResponse(http.StatusRequestTimeout) diff --git a/responses/responses_test.go b/responses/responses_test.go index bcf59a94..181e724a 100644 --- a/responses/responses_test.go +++ b/responses/responses_test.go @@ -28,6 +28,7 @@ var funcs = struct { Unauthorized, Forbidden, NotFound, + NotAcceptable, RequestTimeout, Conflict, PreconditionFailed, @@ -62,6 +63,7 @@ func TestResponses(t *testing.T) { http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, + http.StatusNotAcceptable, http.StatusRequestTimeout, http.StatusConflict, http.StatusPreconditionFailed,