diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index f435fe8..8d24825 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,28 +21,9 @@ jobs: steps: - uses: actions/setup-go@v5 with: - go-version: stable + go-version: oldstable - uses: actions/checkout@v4 - name: golangci-lint - uses: golangci/golangci-lint-action@v8.0.0 + uses: golangci/golangci-lint-action@v9.2.0 with: - # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v2.4.0 - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - # args: --issues-exit-code=0 - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true then the action will use pre-installed Go. - # skip-go-installation: true - - # Optional: if set to true then the action don't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. - # skip-build-cache: true \ No newline at end of file + version: v2.11.3 diff --git a/.golangci.yml b/.golangci.yml index 5fdca2d..ea3cc52 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,6 +5,7 @@ run: linters: default: all disable: + - modernize - embeddedstructfieldcheck - nilnil - noinlineerr diff --git a/Makefile b/Makefile index ae33951..ef3f0a9 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -#GOLANGCI_LINT_VERSION := "v2.5.0" # Optional configuration to pinpoint golangci-lint version. +#GOLANGCI_LINT_VERSION := "v2.11.3" # Optional configuration to pinpoint golangci-lint version. # The head of Makefile determines location of dev-go to include standard targets. GO ?= go diff --git a/README.md b/README.md index 321f338..749a2f5 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,11 @@ func main() { ## Additional Integrations +Router adapters below are for a different, migration-friendly use case: adding OpenAPI docs to an +*existing* router with plain `http.Handler`/`http.HandlerFunc` routes, without rewriting them as +typed usecase interactors. + +* [`github.com/go-chi/chi`](https://github.com/go-chi/chi), see [example](./chirouter/example_openapi_collector_test.go). * [`github.com/gorilla/mux`](https://github.com/gorilla/mux), see [example](./gorillamux/example_openapi_collector_test.go). ## Performance Optimization diff --git a/_examples/advanced-generic-openapi31/_testdata/openapi.json b/_examples/advanced-generic-openapi31/_testdata/openapi.json index 31d6331..d8ad399 100644 --- a/_examples/advanced-generic-openapi31/_testdata/openapi.json +++ b/_examples/advanced-generic-openapi31/_testdata/openapi.json @@ -629,7 +629,7 @@ "name":"in_query","in":"query","description":"Object value in query.", "schema":{ "additionalProperties":{"format":"double","type":"number"},"description":"Object value in query.", - "type":["object","null"] + "type":"object" }, "style":"deepObject","explode":true }, diff --git a/_examples/advanced-generic-openapi31/gzip_pass_through_test.go b/_examples/advanced-generic-openapi31/gzip_pass_through_test.go index 7bb822f..ae1dff1 100644 --- a/_examples/advanced-generic-openapi31/gzip_pass_through_test.go +++ b/_examples/advanced-generic-openapi31/gzip_pass_through_test.go @@ -26,7 +26,7 @@ func Test_directGzip(t *testing.T) { r.ServeHTTP(rw, req) assert.Equal(t, http.StatusOK, rw.Code) - assert.Equal(t, "330epditz19z", rw.Header().Get("Etag")) + assert.Equal(t, "1mzg0y3xex17j", rw.Header().Get("Etag")) assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding")) assert.Equal(t, "abc", rw.Header().Get("X-Header")) assert.Less(t, len(rw.Body.Bytes()), 500) @@ -49,7 +49,7 @@ func Test_directGzip_HEAD(t *testing.T) { assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "330epditz19z", resp.Header.Get("Etag")) + assert.Equal(t, "1mzg0y3xex17j", resp.Header.Get("Etag")) assert.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) assert.Equal(t, "abc", resp.Header.Get("X-Header")) assert.Empty(t, body) @@ -80,7 +80,7 @@ func Test_directGzip_perf(t *testing.T) { assert.Less(t, res.Extra["B:rcvd/op"], 700.0) assert.Less(t, res.Extra["B:sent/op"], 104.0) assert.Less(t, res.AllocsPerOp(), int64(65)) - assert.Less(t, res.AllocedBytesPerOp(), int64(8800)) + assert.Less(t, res.AllocedBytesPerOp(), int64(8900)) } else { assert.Less(t, res.Extra["B:rcvd/op"], 700.0) assert.Less(t, res.Extra["B:sent/op"], 104.0) diff --git a/_examples/advanced/gzip_pass_through_test.go b/_examples/advanced/gzip_pass_through_test.go index f33ad81..4ec9155 100644 --- a/_examples/advanced/gzip_pass_through_test.go +++ b/_examples/advanced/gzip_pass_through_test.go @@ -24,7 +24,7 @@ func Test_directGzip(t *testing.T) { r.ServeHTTP(rw, req) assert.Equal(t, http.StatusOK, rw.Code) - assert.Equal(t, "330epditz19z", rw.Header().Get("Etag")) + assert.Equal(t, "1mzg0y3xex17j", rw.Header().Get("Etag")) assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding")) assert.Equal(t, "abc", rw.Header().Get("X-Header")) assert.Less(t, len(rw.Body.Bytes()), 500) @@ -47,7 +47,7 @@ func Test_directGzip_HEAD(t *testing.T) { assert.NoError(t, resp.Body.Close()) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "330epditz19z", resp.Header.Get("Etag")) + assert.Equal(t, "1mzg0y3xex17j", resp.Header.Get("Etag")) assert.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) assert.Equal(t, "abc", resp.Header.Get("X-Header")) assert.Empty(t, body) @@ -75,9 +75,9 @@ func Test_directGzip_perf(t *testing.T) { res := testing.Benchmark(Benchmark_directGzip) if httptestbench.RaceDetectorEnabled { - assert.Less(t, res.Extra["B:rcvd/op"], 640.0) + assert.Less(t, res.Extra["B:rcvd/op"], 670.0) assert.Less(t, res.Extra["B:sent/op"], 104.0) - assert.Less(t, res.AllocsPerOp(), int64(60)) + assert.Less(t, res.AllocsPerOp(), int64(62)) assert.Less(t, res.AllocedBytesPerOp(), int64(9000)) } else { assert.Less(t, res.Extra["B:rcvd/op"], 640.0) diff --git a/_examples/go.mod b/_examples/go.mod index 66b2681..9de4c41 100644 --- a/_examples/go.mod +++ b/_examples/go.mod @@ -6,7 +6,7 @@ replace github.com/swaggest/rest => ../ require ( github.com/bool64/ctxd v1.2.1 - github.com/bool64/dev v0.2.43 + github.com/bool64/dev v0.2.45 github.com/bool64/httpmock v0.1.15 github.com/bool64/httptestbench v0.1.4 github.com/gin-gonic/gin v1.10.0 @@ -17,8 +17,8 @@ require ( github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.9.0 github.com/swaggest/assertjson v1.9.0 - github.com/swaggest/jsonschema-go v0.3.78 - github.com/swaggest/openapi-go v0.2.60 + github.com/swaggest/jsonschema-go v0.3.79 + github.com/swaggest/openapi-go v0.2.61 github.com/swaggest/rest v0.0.0-00010101000000-000000000000 github.com/swaggest/swgui v1.8.2 github.com/swaggest/usecase v1.3.1 @@ -27,7 +27,7 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect - github.com/bool64/shared v0.1.5 // indirect + github.com/bool64/shared v0.1.7 // indirect github.com/bytedance/sonic v1.12.3 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/_examples/go.sum b/_examples/go.sum index 2468f26..1e47692 100644 --- a/_examples/go.sum +++ b/_examples/go.sum @@ -4,14 +4,14 @@ github.com/bool64/ctxd v1.2.1 h1:hARFteq0zdn4bwfmxLhak3fXFuvtJVKDH2X29VV/2ls= github.com/bool64/ctxd v1.2.1/go.mod h1:ZG6QkeGVLTiUl2mxPpyHmFhDzFZCyocr9hluBV3LYuc= github.com/bool64/dev v0.2.5/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.45 h1:3nLKhAS/6Oklk3Mt2lHYSN/Cb4tdAD77KLwzeP+6eYE= +github.com/bool64/dev v0.2.45/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/httpmock v0.1.15 h1:PWvuqpew/FEigT7cvv03/t9G+UeE3wD2QP8PVyBBUwc= github.com/bool64/httpmock v0.1.15/go.mod h1:Ht82vaR7YJu8YHcJoK5mDi5OG6NJfv/MmYfdfod1huQ= github.com/bool64/httptestbench v0.1.4 h1:35f9RwWqcnQRXM+sA+GUhWVGSa6XEFlKpNSH9oYzOjI= github.com/bool64/httptestbench v0.1.4/go.mod h1:b9ItXGtscs9AcR5oUDnWCTLosQ/DCjiG4euDbHN8T40= -github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= -github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/bool64/shared v0.1.7 h1:F3ZvBBmPDfMkTOTkSGd7cSoOAKI+SH5vDP9Bo4d0k1U= +github.com/bool64/shared v0.1.7/go.mod h1:M5io4aRK3lj9Rwy/OxS4mvm/LJPlSsL2cfu9z96LAOM= github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU= github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -129,10 +129,10 @@ github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7 github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= -github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw= -github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= -github.com/swaggest/openapi-go v0.2.60 h1:kglHH/WIfqAglfuWL4tu0LPakqNYySzklUWx06SjSKo= -github.com/swaggest/openapi-go v0.2.60/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk= +github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= +github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/swaggest/swgui v1.8.2 h1:JGpRCLGLZ7EqTwHsBEOo//kx8CM7Rv3RchgvfNpB+6E= diff --git a/chirouter/collector.go b/chirouter/collector.go new file mode 100644 index 0000000..c63924f --- /dev/null +++ b/chirouter/collector.go @@ -0,0 +1,53 @@ +package chirouter + +import ( + "net/http" + + oapi "github.com/swaggest/openapi-go" + "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/openapi" +) + +// OpenAPICollector is a wrapper for openapi.Collector tailored to walk chi router. +type OpenAPICollector struct { + // Collector is an actual OpenAPI collector. + Collector *openapi.Collector + + // OperationExtractor allows flexible extraction of OpenAPI information. + OperationExtractor func(h http.Handler) func(oc oapi.OperationContext) error +} + +// NewOpenAPICollector creates route walker for chi, that collects OpenAPI operations. +func NewOpenAPICollector(r oapi.Reflector) *OpenAPICollector { + return &OpenAPICollector{ + Collector: openapi.NewCollector(r), + } +} + +// OpenAPIPreparer defines http.Handler with OpenAPI information. +type OpenAPIPreparer interface { + SetupOpenAPIOperation(oc oapi.OperationContext) error +} + +// Describe attaches OpenAPI documentation to a http.HandlerFunc. Unlike Collector.AnnotateOperation, +// which is keyed by a separately maintained method+pattern string that can drift from the actual +// route, Describe keeps the route registration itself as the single source of truth. +// +// The result implements http.Handler, so register it with a method that accepts one, e.g. +// router.Method(http.MethodGet, pattern, c.Describe(h, setup)). +func (dc *OpenAPICollector) Describe(h http.HandlerFunc, setup func(oc oapi.OperationContext) error) http.Handler { + return nethttp.Describe(h, setup) +} + +// Walker walks chi route tree and collects OpenAPI information. +// +// Use it as chi.WalkFunc with chi.Walk. +func (dc *OpenAPICollector) Walker(method, route string, handler http.Handler, _ ...func(http.Handler) http.Handler) error { + if handler == nil { + return nil + } + + preparer := nethttp.Preparer(handler, dc.OperationExtractor) + + return nethttp.CollectRouteOperation(dc.Collector, method, route, preparer) +} diff --git a/chirouter/example_openapi_collector_test.go b/chirouter/example_openapi_collector_test.go new file mode 100644 index 0000000..4d6eae1 --- /dev/null +++ b/chirouter/example_openapi_collector_test.go @@ -0,0 +1,261 @@ +package chirouter_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/rest" + "github.com/swaggest/rest/chirouter" + "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/request" +) + +// Define request structure for your HTTP handler. +type myRequest struct { + Query1 int `query:"query1"` + Path1 string `path:"path1"` + Path2 int `path:"path2"` + Header1 float64 `header:"X-Header-1"` + FormData1 bool `formData:"formData1"` + FormData2 string `formData:"formData2"` +} + +type myResp struct { + Sum float64 `json:"sum"` + Concat string `json:"concat"` +} + +func newMyHandler() *myHandler { + decoderFactory := request.NewDecoderFactory() + decoderFactory.ApplyDefaults = true + decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues) + + return &myHandler{ + dec: decoderFactory.MakeDecoder(http.MethodGet, myRequest{}, nil), + } +} + +type myHandler struct { + // Automated request decoding is not required to collect OpenAPI schema, + // but it is good to have to establish a single source of truth and to simplify request reading. + dec nethttp.RequestDecoder +} + +func (m *myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var in myRequest + + if err := m.dec.Decode(r, &in, nil); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + // Serve request. + out := myResp{ + Sum: in.Header1 + float64(in.Path2) + float64(in.Query1), + Concat: in.Path1 + in.FormData2 + strconv.FormatBool(in.FormData1), + } + + j, err := json.Marshal(out) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + _, _ = w.Write(j) +} + +// SetupOpenAPIOperation declares OpenAPI schema for the handler. +func (m *myHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.SetTags("My Tag") + oc.SetSummary("My Summary") + oc.SetDescription("This endpoint aggregates request in structured way.") + + oc.AddReqStructure(myRequest{}) + oc.AddRespStructure(myResp{}) + oc.AddRespStructure(nil, openapi.WithContentType("text/html"), openapi.WithHTTPStatus(http.StatusBadRequest)) + oc.AddRespStructure(nil, openapi.WithContentType("text/html"), openapi.WithHTTPStatus(http.StatusInternalServerError)) + + return nil +} + +func ExampleNewOpenAPICollector() { + // Your router does not need special instrumentation. + router := chi.NewRouter() + + // If handler implements chirouter.OpenAPIPreparer, it will contribute detailed information to OpenAPI document. + router.Method(http.MethodGet, "/foo/{path1}/bar/{path2}", newMyHandler()) + + // If handler does not implement chirouter.OpenAPIPreparer, it will be exposed as incomplete. + router.Post("/uninstrumented-handler/{path-item}", func(w http.ResponseWriter, r *http.Request) {}) + + // A plain http.HandlerFunc can't implement chirouter.OpenAPIPreparer (funcs can't have methods), + // but it can still get full documentation via Collector.AnnotateOperation, keyed by method and pattern. + router.Get("/func-handler/{path-item}", func(w http.ResponseWriter, r *http.Request) {}) + + // Setup OpenAPI schema. + refl := openapi3.NewReflector() + refl.SpecSchema().SetTitle("Sample API") + refl.SpecSchema().SetVersion("v1.2.3") + refl.SpecSchema().SetDescription("This is an example.") + + // Walk the router with OpenAPI collector. + c := chirouter.NewOpenAPICollector(refl) + + // AnnotateOperation is the quickest way to document a func handler, but the method+pattern + // key is a second copy of the route: if the route changes and this string is not updated to + // match, the annotation silently stops applying (falls back to "Incomplete"). + c.Collector.AnnotateOperation(http.MethodGet, "/func-handler/{path-item}", func(oc openapi.OperationContext) error { + oc.SetSummary("Func Handler") + oc.AddReqStructure(struct { + PathItem string `path:"path-item"` + }{}) + oc.AddRespStructure(myResp{}) + + return nil + }) + + // Collector.Describe avoids that duplication: the route registration itself stays the single + // source of truth. Use it for func handlers you register yourself, especially routes built + // programmatically/in bulk, where keeping a second, string-keyed AnnotateOperation call in + // sync would be error-prone. Describe returns http.Handler, so register it with a method + // that accepts one, such as Method, rather than Get/Post/etc which require http.HandlerFunc. + router.Method(http.MethodGet, "/identified-func/{path-item}", c.Describe( + func(w http.ResponseWriter, r *http.Request) {}, + func(oc openapi.OperationContext) error { + oc.SetSummary("Identified Func Handler") + oc.AddReqStructure(struct { + PathItem string `path:"path-item"` + }{}) + oc.AddRespStructure(myResp{}) + + return nil + }, + )) + + _ = chi.Walk(router, c.Walker) + + // Get the resulting schema. + yml, _ := refl.Spec.MarshalYAML() + fmt.Println(string(yml)) + + // Output: + // openapi: 3.0.3 + // info: + // description: This is an example. + // title: Sample API + // version: v1.2.3 + // paths: + // /foo/{path1}/bar/{path2}: + // get: + // description: This endpoint aggregates request in structured way. + // parameters: + // - in: query + // name: query1 + // schema: + // type: integer + // - in: path + // name: path1 + // required: true + // schema: + // type: string + // - in: path + // name: path2 + // required: true + // schema: + // type: integer + // - in: header + // name: X-Header-1 + // schema: + // format: double + // type: number + // responses: + // "200": + // content: + // application/json: + // schema: + // $ref: '#/components/schemas/ChirouterTestMyResp' + // description: OK + // "400": + // content: + // text/html: + // schema: + // type: string + // description: Bad Request + // "500": + // content: + // text/html: + // schema: + // type: string + // description: Internal Server Error + // summary: My Summary + // tags: + // - My Tag + // /func-handler/{path-item}: + // get: + // parameters: + // - in: path + // name: path-item + // required: true + // schema: + // type: string + // responses: + // "200": + // content: + // application/json: + // schema: + // $ref: '#/components/schemas/ChirouterTestMyResp' + // description: OK + // summary: Func Handler + // /identified-func/{path-item}: + // get: + // parameters: + // - in: path + // name: path-item + // required: true + // schema: + // type: string + // responses: + // "200": + // content: + // application/json: + // schema: + // $ref: '#/components/schemas/ChirouterTestMyResp' + // description: OK + // summary: Identified Func Handler + // /uninstrumented-handler/{path-item}: + // post: + // description: Information about this operation was obtained using only HTTP method + // and path pattern. It may be incomplete and/or inaccurate. + // parameters: + // - in: path + // name: path-item + // required: true + // schema: + // type: string + // responses: + // "200": + // content: + // text/html: + // schema: + // type: string + // description: OK + // tags: + // - Incomplete + // components: + // schemas: + // ChirouterTestMyResp: + // properties: + // concat: + // type: string + // sum: + // format: double + // type: number + // type: object +} diff --git a/go.mod b/go.mod index e10b417..8ff015a 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/swaggest/rest go 1.18 require ( - github.com/bool64/dev v0.2.43 + github.com/bool64/dev v0.2.45 github.com/bool64/httpmock v0.1.15 - github.com/bool64/shared v0.1.5 + github.com/bool64/shared v0.1.7 github.com/cespare/xxhash/v2 v2.3.0 github.com/go-chi/chi/v5 v5.2.3 github.com/gorilla/mux v1.8.1 @@ -13,8 +13,8 @@ require ( github.com/stretchr/testify v1.8.2 github.com/swaggest/assertjson v1.9.0 github.com/swaggest/form/v5 v5.1.1 - github.com/swaggest/jsonschema-go v0.3.78 - github.com/swaggest/openapi-go v0.2.60 + github.com/swaggest/jsonschema-go v0.3.79 + github.com/swaggest/openapi-go v0.2.61 github.com/swaggest/refl v1.4.0 github.com/swaggest/usecase v1.3.1 ) diff --git a/go.sum b/go.sum index 0d7dce4..ea023fe 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,12 @@ github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/dev v0.2.43 h1:yQ7qiZVef6WtCl2vDYU0Y+qSq+0aBrQzY8KXkklk9cQ= -github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.45 h1:3nLKhAS/6Oklk3Mt2lHYSN/Cb4tdAD77KLwzeP+6eYE= +github.com/bool64/dev v0.2.45/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/httpmock v0.1.15 h1:PWvuqpew/FEigT7cvv03/t9G+UeE3wD2QP8PVyBBUwc= github.com/bool64/httpmock v0.1.15/go.mod h1:Ht82vaR7YJu8YHcJoK5mDi5OG6NJfv/MmYfdfod1huQ= github.com/bool64/shared v0.1.5 h1:fp3eUhBsrSjNCQPcSdQqZxxh9bBwrYiZ+zOKFkM0/2E= github.com/bool64/shared v0.1.5/go.mod h1:081yz68YC9jeFB3+Bbmno2RFWvGKv1lPKkMP6MHJlPs= +github.com/bool64/shared v0.1.7 h1:F3ZvBBmPDfMkTOTkSGd7cSoOAKI+SH5vDP9Bo4d0k1U= +github.com/bool64/shared v0.1.7/go.mod h1:M5io4aRK3lj9Rwy/OxS4mvm/LJPlSsL2cfu9z96LAOM= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -47,8 +49,12 @@ github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6c github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= github.com/swaggest/jsonschema-go v0.3.78 h1:5+YFQrLxOR8z6CHvgtZc42WRy/Q9zRQQ4HoAxlinlHw= github.com/swaggest/jsonschema-go v0.3.78/go.mod h1:4nniXBuE+FIGkOGuidjOINMH7OEqZK3HCSbfDuLRI0g= +github.com/swaggest/jsonschema-go v0.3.79 h1:0TOShCbAJ9Xjt1e2W83l+QtMQSG2pbun2EkiYTyafCs= +github.com/swaggest/jsonschema-go v0.3.79/go.mod h1:GqVmJ+XNLeUHhFIhHNKc+C68euxfrl3a3aoZH4vTRl0= github.com/swaggest/openapi-go v0.2.60 h1:kglHH/WIfqAglfuWL4tu0LPakqNYySzklUWx06SjSKo= github.com/swaggest/openapi-go v0.2.60/go.mod h1:jmFOuYdsWGtHU0BOuILlHZQJxLqHiAE6en+baE+QQUk= +github.com/swaggest/openapi-go v0.2.61 h1:psc+LE7pWhEjmJpmkti9tUmBPkkobdUNflBf5Ps6JSc= +github.com/swaggest/openapi-go v0.2.61/go.mod h1:786CwSwleh1IorB0nfwYGESWf83JgQh6fBc1PeJe4Iw= github.com/swaggest/refl v1.4.0 h1:CftOSdTqRqs100xpFOT/Rifss5xBV/CT0S/FN60Xe9k= github.com/swaggest/refl v1.4.0/go.mod h1:4uUVFVfPJ0NSX9FPwMPspeHos9wPFlCMGoPRllUbpvA= github.com/swaggest/usecase v1.3.1 h1:JdKV30MTSsDxAXxkldLNcEn8O2uf565khyo6gr5sS+w= diff --git a/gorillamux/collector.go b/gorillamux/collector.go index d72509c..3281989 100644 --- a/gorillamux/collector.go +++ b/gorillamux/collector.go @@ -2,10 +2,8 @@ package gorillamux import ( "net/http" - "reflect" "github.com/gorilla/mux" - "github.com/swaggest/jsonschema-go" oapi "github.com/swaggest/openapi-go" "github.com/swaggest/rest/nethttp" "github.com/swaggest/rest/openapi" @@ -46,7 +44,14 @@ type OpenAPIPreparer interface { SetupOpenAPIOperation(oc oapi.OperationContext) error } -type preparerFunc func(oc oapi.OperationContext) error +// Describe attaches OpenAPI documentation to a http.HandlerFunc. Unlike Collector.AnnotateOperation, +// which is keyed by a separately maintained method+pattern string that can drift from the actual +// route, Describe keeps the route registration itself as the single source of truth. +// +// The result implements http.Handler, e.g. router.Handle(pattern, c.Describe(h, setup)).Methods(http.MethodGet). +func (dc *OpenAPICollector) Describe(h http.HandlerFunc, setup func(oc oapi.OperationContext) error) http.Handler { + return nethttp.Describe(h, setup) +} // Walker walks route tree and collects OpenAPI information. func (dc *OpenAPICollector) Walker(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { @@ -73,63 +78,13 @@ func (dc *OpenAPICollector) Walker(route *mux.Route, _ *mux.Router, _ []*mux.Rou methods = dc.DefaultMethods } - var ( - openAPIPreparer OpenAPIPreparer - preparer preparerFunc - ) - - if nethttp.HandlerAs(handler, &openAPIPreparer) { - preparer = openAPIPreparer.SetupOpenAPIOperation - } else if dc.OperationExtractor != nil { - preparer = dc.OperationExtractor(handler) - } + preparer := nethttp.Preparer(handler, dc.OperationExtractor) for _, method := range methods { - if err := dc.Collector.CollectOperation(method, path, dc.collect(method, path, preparer)); err != nil { + if err := nethttp.CollectRouteOperation(dc.Collector, method, path, preparer); err != nil { return err } } return nil } - -func (dc *OpenAPICollector) collect(method, path string, preparer preparerFunc) preparerFunc { - return func(oc oapi.OperationContext) error { - // Do not apply default parameters to not conflict with custom preparer. - if preparer != nil { - return preparer(oc) - } - - // Do not apply default parameters to not conflict with custom annotation. - if dc.Collector.HasAnnotation(method, path) { - return nil - } - - _, _, pathItems, err := oapi.SanitizeMethodPath(method, path) - if err != nil { - return err - } - - if len(pathItems) > 0 { - req := jsonschema.Struct{} - for _, p := range pathItems { - req.Fields = append(req.Fields, jsonschema.Field{ - Name: "F" + p, - Tag: reflect.StructTag(`path:"` + p + `"`), - Value: "", - }) - } - - oc.AddReqStructure(req) - } - - oc.SetDescription("Information about this operation was obtained using only HTTP method and path pattern. " + - "It may be incomplete and/or inaccurate.") - oc.SetTags("Incomplete") - oc.AddRespStructure(nil, func(cu *oapi.ContentUnit) { - cu.ContentType = "text/html" - }) - - return nil - } -} diff --git a/gorillamux/example_openapi_collector_test.go b/gorillamux/example_openapi_collector_test.go index 1078466..deb9624 100644 --- a/gorillamux/example_openapi_collector_test.go +++ b/gorillamux/example_openapi_collector_test.go @@ -96,6 +96,11 @@ func ExampleNewOpenAPICollector() { router.Handle("/uninstrumented-handler/{path-item}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).Methods(http.MethodPost) + // A plain http.HandlerFunc can't implement gorillamux.OpenAPIPreparer (funcs can't have methods), + // but it can still get full documentation via Collector.AnnotateOperation, keyed by method and pattern. + router.Handle("/func-handler/{path-item}", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).Methods(http.MethodGet) + // Setup OpenAPI schema. refl := openapi3.NewReflector() refl.SpecSchema().SetTitle("Sample API") @@ -105,6 +110,36 @@ func ExampleNewOpenAPICollector() { // Walk the router with OpenAPI collector. c := gorillamux.NewOpenAPICollector(refl) + // AnnotateOperation is the quickest way to document a func handler, but the method+pattern + // key is a second copy of the route: if the route changes and this string is not updated to + // match, the annotation silently stops applying (falls back to "Incomplete"). + c.Collector.AnnotateOperation(http.MethodGet, "/func-handler/{path-item}", func(oc openapi.OperationContext) error { + oc.SetSummary("Func Handler") + oc.AddReqStructure(struct { + PathItem string `path:"path-item"` + }{}) + oc.AddRespStructure(myResp{}) + + return nil + }) + + // Collector.Describe avoids that duplication: the route registration itself stays the single + // source of truth. Use it for func handlers you register yourself, especially routes built + // programmatically/in bulk, where keeping a second, string-keyed AnnotateOperation call in + // sync would be error-prone. + router.Handle("/identified-func/{path-item}", c.Describe( + func(w http.ResponseWriter, r *http.Request) {}, + func(oc openapi.OperationContext) error { + oc.SetSummary("Identified Func Handler") + oc.AddReqStructure(struct { + PathItem string `path:"path-item"` + }{}) + oc.AddRespStructure(myResp{}) + + return nil + }, + )).Methods(http.MethodGet) + _ = router.Walk(c.Walker) // Get the resulting schema. @@ -163,6 +198,38 @@ func ExampleNewOpenAPICollector() { // summary: My Summary // tags: // - My Tag + // /func-handler/{path-item}: + // get: + // parameters: + // - in: path + // name: path-item + // required: true + // schema: + // type: string + // responses: + // "200": + // content: + // application/json: + // schema: + // $ref: '#/components/schemas/GorillamuxTestMyResp' + // description: OK + // summary: Func Handler + // /identified-func/{path-item}: + // get: + // parameters: + // - in: path + // name: path-item + // required: true + // schema: + // type: string + // responses: + // "200": + // content: + // application/json: + // schema: + // $ref: '#/components/schemas/GorillamuxTestMyResp' + // description: OK + // summary: Identified Func Handler // /uninstrumented-handler/{path-item}: // post: // description: Information about this operation was obtained using only HTTP method diff --git a/gzip/container.go b/gzip/container.go index 9c05a50..c95d57d 100644 --- a/gzip/container.go +++ b/gzip/container.go @@ -6,6 +6,7 @@ import ( "encoding/json" "io" "io/ioutil" + "net/http" "strconv" "github.com/cespare/xxhash/v2" @@ -18,8 +19,9 @@ type Writer interface { // JSONContainer contains compressed JSON. type JSONContainer struct { - gz []byte - hash string + gz []byte + hash string + gzipHash string } // WriteCompressedBytes writes compressed bytes to response. @@ -51,13 +53,14 @@ func (jc JSONContainer) UnpackJSON(v interface{}) error { // PackJSON puts Go value in JSON container. func (jc *JSONContainer) PackJSON(v interface{}) error { - res, err := MarshalJSON(v) + res, hash, gzipHash, err := marshalJSON(v) if err != nil { return err } jc.gz = res - jc.hash = strconv.FormatUint(xxhash.Sum64(res), 36) + jc.hash = hash + jc.gzipHash = gzipHash return nil } @@ -86,35 +89,65 @@ func (jc JSONContainer) MarshalJSON() (j []byte, err error) { return ioutil.ReadAll(r) } -// ETag returns hash of compressed bytes. +// ETag returns hash of uncompressed JSON content, identifying the identity representation. +// +// Unlike hashing compressed bytes, this is stable across changes to the gzip/flate +// implementation (e.g. across Go versions), which do not guarantee stable output bytes +// for the same input. func (jc JSONContainer) ETag() string { return jc.hash } +// GzipETag returns a hash of uncompressed JSON content salted for the gzip-compressed +// representation, so it differs from ETag. +// +// The gzip and identity representations are byte-distinct, so gzip-compression middleware +// (see response/gzip) uses this instead of ETag when serving the gzip body, to avoid one +// strong validator being shared between two different representations. +func (jc JSONContainer) GzipETag() string { + return jc.gzipHash +} + // MarshalJSON encodes Go value as JSON and compresses result with gzip. func MarshalJSON(v interface{}) ([]byte, error) { + res, _, _, err := marshalJSON(v) + + return res, err +} + +// marshalJSON encodes Go value as JSON, compressing it with gzip and hashing its uncompressed +// content in a single pass. It computes two digests of that same content, one plain (hash) and +// one with a "gzip" marker mixed in (gzipHash), so the gzip and identity representations get +// distinct ETags without a second pass over the (potentially much larger, pre-compression) content. +func marshalJSON(v interface{}) (compressed []byte, hash, gzipHash string, err error) { b := bytes.Buffer{} w := gzip.NewWriter(&b) + h := xxhash.New() + gh := xxhash.New() - enc := json.NewEncoder(w) + enc := json.NewEncoder(io.MultiWriter(w, h, gh)) - err := enc.Encode(v) - if err != nil { - return nil, err + if err := enc.Encode(v); err != nil { + return nil, "", "", err } - err = w.Close() - if err != nil { - return nil, err + if err := w.Close(); err != nil { + return nil, "", "", err } + _, _ = gh.Write(gzipETagSalt) //nolint:errcheck // xxhash.Digest.Write never returns an error. + // Copying result slice to reduce dynamic capacity. res := make([]byte, len(b.Bytes())) copy(res, b.Bytes()) - return res, nil + return res, strconv.FormatUint(h.Sum64(), 36), strconv.FormatUint(gh.Sum64(), 36), nil } +// gzipETagSalt is mixed into the gzip-representation digest so it differs from the plain +// content digest. +var gzipETagSalt = []byte("gzip") + // UnmarshalJSON decodes compressed JSON bytes into a Go value. func UnmarshalJSON(data []byte, v interface{}) error { b := bytes.NewReader(data) @@ -136,5 +169,16 @@ func UnmarshalJSON(data []byte, v interface{}) error { // JSONWriteTo writes JSON payload to writer. func (jc JSONContainer) JSONWriteTo(w io.Writer) (int, error) { + // The gzip and identity bodies are byte-distinct representations of the same content, so a + // validator shared between them would let a cache serve the wrong variant. If w can take the + // gzip-compressed bytes directly (see WriteCompressedBytes), swap in the ETag already + // computed for that representation, in place of whatever ETag a response encoder set before + // the compression decision was made. + if rw, ok := w.(http.ResponseWriter); ok { + if _, ok := w.(Writer); ok && rw.Header().Get("Etag") != "" { + rw.Header().Set("Etag", jc.gzipHash) + } + } + return WriteCompressedBytes(jc.gz, w) } diff --git a/gzip/container_test.go b/gzip/container_test.go index 2ad11ab..f67aeb0 100644 --- a/gzip/container_test.go +++ b/gzip/container_test.go @@ -28,6 +28,19 @@ func TestWriteJSON(t *testing.T) { cont := gzip.JSONContainer{} require.NoError(t, cont.PackJSON(v)) + // ETag hashes uncompressed content, so unlike a hash of gzip-compressed bytes, + // it is stable across Go versions (compress/flate does not guarantee stable output). + // GzipETag is salted so it differs from ETag: the gzip and identity representations are + // byte-distinct, and sharing one strong validator between them would let a cache serve the + // wrong variant. + const ( + etag = "2cxnp31dco6p1" + gzipETag = "386rh02sa3f4u" + ) + + assert.Equal(t, etag, cont.ETag()) + assert.Equal(t, gzipETag, cont.GzipETag()) + var vv []string require.NoError(t, cont.UnpackJSON(&vv)) @@ -68,7 +81,7 @@ func TestWriteJSON(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "gzip", w.Header().Get("Content-Encoding")) - assert.Equal(t, "1ofolk6sr5j4r", w.Header().Get("Etag")) + assert.Equal(t, gzipETag, w.Header().Get("Etag")) assert.Equal(t, cont.GzipCompressedJSON(), w.Body.Bytes()) w = httptest.NewRecorder() @@ -78,7 +91,7 @@ func TestWriteJSON(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "", w.Header().Get("Content-Encoding")) - assert.Equal(t, "1ofolk6sr5j4r", w.Header().Get("Etag")) + assert.Equal(t, etag, w.Header().Get("Etag")) assert.Equal(t, append(vj, '\n'), w.Body.Bytes()) w = httptest.NewRecorder() diff --git a/nethttp/openapi_router.go b/nethttp/openapi_router.go new file mode 100644 index 0000000..74fd888 --- /dev/null +++ b/nethttp/openapi_router.go @@ -0,0 +1,118 @@ +package nethttp + +import ( + "net/http" + "reflect" + + "github.com/swaggest/jsonschema-go" + oapi "github.com/swaggest/openapi-go" + "github.com/swaggest/rest/openapi" +) + +// RoutePreparer defines http.Handler with OpenAPI information. +// +// It is used by router adapters (such as chirouter and gorillamux) that augment an existing +// http.Handler-based router instead of wrapping typed usecase interactors. Third-party router +// adapters can implement the same collection flow with Describe, Preparer and CollectRouteOperation. +type RoutePreparer interface { + SetupOpenAPIOperation(oc oapi.OperationContext) error +} + +// RoutePreparerFunc sets up OpenAPI operation. +type RoutePreparerFunc func(oc oapi.OperationContext) error + +// describedHandler pairs a http.HandlerFunc with its own OpenAPI setup, so Preparer can +// recognize it the same way as a handler type that implements RoutePreparer directly. +// +// A map keyed by reflect.ValueOf(h).Pointer() would not work here: that pointer identifies the +// handler's compiled code, not a particular closure instance, so handlers built from the same +// func literal (e.g. in a loop, for programmatically registered routes) would collide and +// silently share one another's documentation. +type describedHandler struct { + http.HandlerFunc + + setup RoutePreparerFunc +} + +// SetupOpenAPIOperation implements RoutePreparer. +func (d describedHandler) SetupOpenAPIOperation(oc oapi.OperationContext) error { + return d.setup(oc) +} + +// Describe attaches OpenAPI documentation to a http.HandlerFunc. Unlike Collector.AnnotateOperation, +// which is keyed by a separately maintained method+pattern string that can drift from the actual +// route, Describe keeps the route registration itself as the single source of truth: it wraps the +// handler so Preparer recognizes it the same way as a handler that implements RoutePreparer +// directly. +// +// The result implements http.Handler, so register it with a method that accepts one +// (e.g. chi's Method/Handle, or gorilla/mux's Handle), not one that requires http.HandlerFunc. +func Describe(h http.HandlerFunc, setup RoutePreparerFunc) http.Handler { + return describedHandler{HandlerFunc: h, setup: setup} +} + +// Preparer resolves the preparer func for handler: interface implementation first (this is also +// how a handler wrapped with Describe is recognized), then the extractor as a last resort. +func Preparer( + handler http.Handler, + extractor func(h http.Handler) func(oc oapi.OperationContext) error, +) RoutePreparerFunc { + var ( + routePreparer RoutePreparer + preparer RoutePreparerFunc + ) + + if HandlerAs(handler, &routePreparer) { + preparer = routePreparer.SetupOpenAPIOperation + } + + if preparer == nil && extractor != nil { + preparer = extractor(handler) + } + + return preparer +} + +// CollectRouteOperation prepares and adds an OpenAPI operation, falling back to a best-effort +// "Incomplete" description (derived from method/path alone) when there is no preparer and no +// prior annotation. +func CollectRouteOperation(coll *openapi.Collector, method, path string, preparer RoutePreparerFunc) error { + return coll.CollectOperation(method, path, func(oc oapi.OperationContext) error { + // Do not apply default parameters to not conflict with custom preparer. + if preparer != nil { + return preparer(oc) + } + + // Do not apply default parameters to not conflict with custom annotation. + if coll.HasAnnotation(method, path) { + return nil + } + + _, _, pathItems, err := oapi.SanitizeMethodPath(method, path) + if err != nil { + return err + } + + if len(pathItems) > 0 { + req := jsonschema.Struct{} + for _, p := range pathItems { + req.Fields = append(req.Fields, jsonschema.Field{ + Name: "F" + p, + Tag: reflect.StructTag(`path:"` + p + `"`), + Value: "", + }) + } + + oc.AddReqStructure(req) + } + + oc.SetDescription("Information about this operation was obtained using only HTTP method and path pattern. " + + "It may be incomplete and/or inaccurate.") + oc.SetTags("Incomplete") + oc.AddRespStructure(nil, func(cu *oapi.ContentUnit) { + cu.ContentType = "text/html" + }) + + return nil + }) +} diff --git a/response/gzip/middleware.go b/response/gzip/middleware.go index ac942fb..6009a9c 100644 --- a/response/gzip/middleware.go +++ b/response/gzip/middleware.go @@ -21,6 +21,7 @@ const ( contentLengthHeader = "Content-Length" contentEncodingHeader = "Content-Encoding" acceptEncodingHeader = "Accept-Encoding" + varyHeader = "Vary" defaultBufferSize = 8 * 1024 ) @@ -28,6 +29,11 @@ const ( // Middleware enables gzip compression of handler response for requests that accept gzip encoding. func Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Whether the response ends up compressed always depends on Accept-Encoding, so without + // this a cache could store one representation (e.g. gzip-compressed bytes) and serve it + // to a request with a different Accept-Encoding (e.g. one that can't decode gzip). + w.Header().Add(varyHeader, acceptEncodingHeader) + w = maybeGzipResponseWriter(w, r) if closer, ok := w.(io.Closer); ok { defer func() { diff --git a/response/gzip/middleware_test.go b/response/gzip/middleware_test.go index 69c28ab..ef35ac8 100644 --- a/response/gzip/middleware_test.go +++ b/response/gzip/middleware_test.go @@ -61,6 +61,36 @@ func TestMiddleware(t *testing.T) { assert.Equal(t, resp, rw.Body.Bytes()) } +// TestMiddleware_vary checks that Vary: Accept-Encoding is set regardless of whether this +// particular response ends up compressed, so a cache does not store one representation +// (e.g. gzip-compressed bytes) and serve it to a request with a different Accept-Encoding +// that would not be able to decode it. +func TestMiddleware_vary(t *testing.T) { + resp := []byte(strings.Repeat("A", 10000) + "!!!") + h := gzip.Middleware(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) { + _, err := rw.Write(resp) + assert.NoError(t, err) + })) + + rw := httptest.NewRecorder() + r, err := http.NewRequest(http.MethodGet, "/", nil) + require.NoError(t, err) + + r.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(rw, r) + + assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding")) + assert.Equal(t, []string{"Accept-Encoding"}, rw.Header().Values("Vary")) + + rw = httptest.NewRecorder() + + r.Header.Del("Accept-Encoding") + h.ServeHTTP(rw, r) + + assert.Equal(t, "", rw.Header().Get("Content-Encoding")) + assert.Equal(t, []string{"Accept-Encoding"}, rw.Header().Values("Vary")) +} + // BenchmarkMiddleware measures performance of handler with compression. // // Sample result: