├── .circleci ├── config.yml └── testcover.sh ├── .gitignore ├── .gometalinter.json ├── CHANGELOG.md ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── _examples ├── app │ ├── app.go │ └── openapi.yaml ├── operation-router │ └── gorilla │ │ ├── README.md │ │ └── main.go └── resolving │ └── gorilla │ ├── README.md │ └── main.go ├── adapter ├── chi │ ├── adapter.go │ ├── doc.go │ ├── init │ │ └── init.go │ ├── path_params.go │ ├── resolver.go │ ├── router.go │ ├── router_test.go │ └── testdata │ │ └── petstore.yml └── gorilla │ ├── adapter.go │ ├── doc.go │ ├── init │ └── init.go │ ├── path_params.go │ ├── resolver.go │ ├── resolver_test.go │ ├── router.go │ ├── router_test.go │ └── testdata │ └── petstore.yml ├── adapters.go ├── basis_resolving.go ├── cmd └── oas-expand │ ├── README.md │ └── expand.go ├── convert ├── array.go ├── array_test.go ├── convert.go └── convert_test.go ├── decode.go ├── decode_integration_test.go ├── decode_test.go ├── doc.go ├── e2e ├── README.md ├── middleware_order │ ├── doc.go │ └── main_test.go ├── query_validator │ ├── doc.go │ └── main_test.go ├── servespec │ ├── doc.go │ └── main_test.go └── testdata │ ├── adder.go │ ├── adder.yaml │ ├── greeter.go │ └── greeter.yaml ├── error.go ├── formats ├── partial_time.go └── partial_time_test.go ├── go_openapi_wrappers.go ├── load.go ├── load_test.go ├── media.go ├── middleware.go ├── middleware_media_type.go ├── middleware_media_type_test.go ├── middleware_path_params.go ├── middleware_path_params_test.go ├── middleware_query.go ├── middleware_query_test.go ├── middleware_request_body.go ├── middleware_request_body_test.go ├── middleware_response_body.go ├── middleware_response_body_test.go ├── operation.go ├── problem.go ├── router.go ├── spec_handlers.go ├── spec_handlers_test.go ├── testdata └── petstore_1.yml ├── testing_test.go ├── util_response.go ├── util_response_test.go └── validate ├── formats.go ├── formats_test.go ├── validate.go └── validate_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | version: 2 5 | 6 | jobs: 7 | "prepare-code": 8 | docker: 9 | - image: circleci/golang:1.9 10 | working_directory: /go/src/github.com/hypnoglow/oas2 11 | steps: 12 | - checkout 13 | - run: curl -sSL https://github.com/golang/dep/releases/download/v0.5.0/dep-linux-amd64 -o /go/bin/dep 14 | - run: chmod +x /go/bin/dep 15 | - run: /go/bin/dep ensure -v 16 | - persist_to_workspace: 17 | root: /go/src/github.com/hypnoglow 18 | paths: 19 | - oas2 20 | "test:1.9": 21 | docker: 22 | - image: circleci/golang:1.9 23 | working_directory: /go/src/github.com/hypnoglow/oas2 24 | steps: 25 | - attach_workspace: 26 | at: /go/src/github.com/hypnoglow 27 | - run: go build ./... 28 | - run: go test -v ./... 29 | "test:1.10": 30 | docker: 31 | - image: circleci/golang:1.10 32 | working_directory: /go/src/github.com/hypnoglow/oas2 33 | steps: 34 | - attach_workspace: 35 | at: /go/src/github.com/hypnoglow 36 | - run: go build ./... 37 | - run: go test -v ./... 38 | "lint": 39 | docker: 40 | - image: circleci/golang:1.9 41 | working_directory: /go/src/github.com/hypnoglow/oas2 42 | steps: 43 | - attach_workspace: 44 | at: /go/src/github.com/hypnoglow 45 | # - run: apk add --no-cache git gcc musl-dev # required for metalinter, see github.com/alecthomas/gometalinter/issues/149 46 | - run: go get -v -u github.com/alecthomas/gometalinter 47 | - run: gometalinter --install --update 48 | - run: gometalinter ./... 49 | "codecov": 50 | docker: 51 | - image: circleci/golang:1.9 52 | working_directory: /go/src/github.com/hypnoglow/oas2 53 | steps: 54 | - attach_workspace: 55 | at: /go/src/github.com/hypnoglow 56 | - run: ./.circleci/testcover.sh 57 | - run: bash <(curl -s https://codecov.io/bash) 58 | "e2e": 59 | docker: 60 | - image: circleci/golang:1.9 61 | working_directory: /go/src/github.com/hypnoglow/oas2 62 | steps: 63 | - attach_workspace: 64 | at: /go/src/github.com/hypnoglow 65 | - run: go test -tags e2e ./e2e/... 66 | workflows: 67 | version: 2 68 | prepare-and-test: 69 | jobs: 70 | - prepare-code 71 | - "test:1.9": 72 | requires: 73 | - prepare-code 74 | - "test:1.10": 75 | requires: 76 | - prepare-code 77 | - "lint": 78 | requires: 79 | - prepare-code 80 | - "codecov": 81 | requires: 82 | - prepare-code 83 | - "e2e": 84 | requires: 85 | - prepare-code 86 | -------------------------------------------------------------------------------- /.circleci/testcover.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "" > coverage.txt 5 | 6 | for d in $(go list ./... | grep -v vendor | grep -v examples); do 7 | go test -coverpkg=./... -coverprofile=profile.out -covermode=atomic $d 8 | if [ -f profile.out ]; then 9 | cat profile.out >> coverage.txt 10 | rm profile.out 11 | fi 12 | done -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_tmp/ 2 | /vendor/ 3 | 4 | coverage.txt 5 | profile.out 6 | 7 | -------------------------------------------------------------------------------- /.gometalinter.json: -------------------------------------------------------------------------------- 1 | { 2 | "disable": [ 3 | "gocyclo" 4 | ], 5 | "exclude": [ 6 | "error return value not checked.*defer\\s.*\\.Close()", 7 | "don't use an underscore in package name", 8 | "http.CloseNotifier is deprecated" 9 | ], 10 | "concurrency": 2, 11 | "deadline": "60s", 12 | "vendor": true 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | TODO: Describe changes. 11 | 12 | ## [0.7.2] - 2018-08-08 13 | 14 | ### Added 15 | 16 | - It is now possible to load multiple specs into a single router via `Router.AddSpec` method. ([#46](https://github.com/hypnoglow/oas2/pull/46)) 17 | 18 | ### Fixed 19 | 20 | - Fixed a panic on empty basePath in spec ([#45](https://github.com/hypnoglow/oas2/pull/45)) 21 | 22 | ## [0.7.1] - 2018-07-09 23 | 24 | ### Fixed 25 | 26 | - Array type params are now properly parsed honoring [collectionFormat](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject). 27 | 28 | ## [0.7.0] - 2018-06-08 29 | 30 | That's **a lot** of changes in this upcoming release :) 31 | 32 | ### Added 33 | 34 | - New `oas.Wrap()` option for `oas.NewRouter()` applies a middleware that wraps 35 | the router. That means it executes before the actual routing, so you can modify 36 | its behaviour, e.g. introducing new routes (handling 404 errors) or methods (handling 37 | OPTIONS method for CORS). See `oas.Wrap()` [documentation](https://github.com/hypnoglow/oas2/blob/b0d734259c9ebab2bb7196b49a48e3e3c0ada79a/router.go#L141) 38 | for more information. Note that those middleware also applies to a spec served 39 | by router, when enabled via `oas.ServeSpec()` option. 40 | - `DefaultBaseRouter()` got exposed for informational purposes. 41 | - `DefaultExtractorFunc()` was added, so users don't need to import [chi](https://github.com/go-chi/chi) 42 | package if they use default router. Thus, you can just `oas.Use(oas.PathParameterExtractor(oas.DefaultExtractorFunc))` 43 | 44 | ### Changed 45 | 46 | - Middleware order got adjusted. Now, when you pass middleware to `oas.NewRouter()` using 47 | `oas.Use()` option, they get applied exactly in the same order. See `oas.Use()` 48 | option [documentation](https://github.com/hypnoglow/oas2/blob/b0d734259c9ebab2bb7196b49a48e3e3c0ada79a/router.go#L167) 49 | and [this](https://github.com/hypnoglow/oas2/blob/b0d734259c9ebab2bb7196b49a48e3e3c0ada79a/e2e/middleware_order/main_test.go#L32) 50 | test for an example. 51 | - `oas` package now introduces wrapper types `oas.Document` and `oas.Operation`. All functions 52 | that previously exposed parameters from other libraries from [go-openapi](https://github.com/go-openapi) 53 | now use these types. 54 | 55 | For example, `oas.LoadFile()` now returns `*oas.Document` instead of `*loads.Document`. 56 | Thus, `oas.NewRouter()` accepts `*oas.Document` instead of `*loads.Document`. 57 | 58 | The main purpose for this change is that most users only need those types to 59 | use within this library, and they had to import [go-openapi](https://github.com/go-openapi) 60 | libraries just to pass variables around. Also, you still can access underlying types from [go-openapi](https://github.com/go-openapi) 61 | if you need. 62 | 63 | Functions that had their signatures changed: 64 | 65 | - `oas.LoadFile()` 66 | - `oas.NewRouter()` 67 | - `oas.WithOperation()` 68 | - `oas.GetOperation()` 69 | - `oas.MustOperation()` 70 | 71 | - Adjusted string representation of variables of type `oas.ValidationError`. 72 | - All package middlewares that require `oas.Operation` to work now **panic** 73 | if operation is not found in the request context. Previously, if middleware 74 | cannot find operation in request context, it would just silently skip the validation. 75 | That behavior is undesirable because validation is important. So, the change 76 | is done to actually notify package user that he is doing something wrong. 77 | 78 | Affected middlewares: 79 | 80 | - `oas.PathParameterExtractor()` 81 | - `oas.QueryValidator()` 82 | - `oas.BodyValidator()` 83 | - `oas.ResponseBodyValidator()` 84 | 85 | ## [0.6.1] - 2018-05-28 86 | 87 | ### Fixed 88 | 89 | - Fixed a bug when `oas.LoadFile` was not returning expanded spec. 90 | 91 | ## [0.6.0] - 2018-05-22 92 | 93 | ### Added 94 | 95 | - New feature: It is now possible to pass additional options to middlewares. 96 | In particular, the first available option is to provide Content-Type selectors 97 | to validators. 98 | 99 | ### Changed 100 | 101 | - **Breaking Change** Router now accepts a document instead of a spec. This 102 | allows, among other things, to access original spec, which is used when serving 103 | router spec. 104 | - **Breaking Change** `LoadSpec` function is replaced with `LoadFile`, which 105 | works almost the same way, but provides more options, for example to set 106 | spec host when loading. 107 | - **Breaking Change** `DecodeQuery` now accepts request and extracts operation 108 | spec from it, so developer no longer need to extract operation params from request 109 | only to use them in `DecodeQuery`. Old function moved to `DecodeQueryParams`. 110 | 111 | ### Fixed 112 | 113 | - Fixed a bug about type conversion for `default` values of query parameters. 114 | 115 | ## [0.5.1] - 2018-03-14 116 | 117 | ### Fixed 118 | 119 | - Fixed a bug that led to serving spec with duplicate parameters. 120 | 121 | ## [0.5.0] - 2018-03-13 122 | 123 | ### Added 124 | 125 | - New feature: add option [`ServeSpec`](https://github.com/hypnoglow/oas2/blob/4b7ce7cc55bdd7cbb66e94e8af94f3dd08e8fc01/router.go#L127) for router to serve its OpenAPI spec under the base path. 126 | - New tool: [oas-expand](https://github.com/hypnoglow/oas2/tree/7678e995b788570a0483e667e030f8c7166a6681/cmd/oas-expand) expands all `$ref`s in spec to improve startup time of 127 | the application. 128 | - OpenAPI parameter with `type: string` and `format: uuid` is now supported. 129 | 130 | ### Changed 131 | 132 | - **Breaking Change** `JsonError` renamed to`JSONError`. 133 | - Replaced internally-used self-written response recorder with [chi's](https://github.com/go-chi/chi/blob/master/middleware/wrap_writer18.go#L12) 134 | - The project now has more advanced CI checks. 135 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:d1665c44bd5db19aaee18d1b6233c99b0b9a986e8bccb24ef54747547a48027f" 6 | name = "github.com/PuerkitoBio/purell" 7 | packages = ["."] 8 | pruneopts = "UT" 9 | revision = "0bcb03f4b4d0a9428594752bd2a3b9aa0a9d4bd4" 10 | version = "v1.1.0" 11 | 12 | [[projects]] 13 | branch = "master" 14 | digest = "1:c739832d67eb1e9cc478a19cc1a1ccd78df0397bf8a32978b759152e205f644b" 15 | name = "github.com/PuerkitoBio/urlesc" 16 | packages = ["."] 17 | pruneopts = "UT" 18 | revision = "de5bf2ad457846296e2031421a34e2568e304e35" 19 | 20 | [[projects]] 21 | digest = "1:320e7ead93de9fd2b0e59b50fd92a4d50c1f8ab455d96bc2eb083267453a9709" 22 | name = "github.com/asaskevich/govalidator" 23 | packages = ["."] 24 | pruneopts = "UT" 25 | revision = "ccb8e960c48f04d6935e72476ae4a51028f9e22f" 26 | version = "v9" 27 | 28 | [[projects]] 29 | digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec" 30 | name = "github.com/davecgh/go-spew" 31 | packages = ["spew"] 32 | pruneopts = "UT" 33 | revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73" 34 | version = "v1.1.1" 35 | 36 | [[projects]] 37 | digest = "1:2cd7915ab26ede7d95b8749e6b1f933f1c6d5398030684e6505940a10f31cfda" 38 | name = "github.com/ghodss/yaml" 39 | packages = ["."] 40 | pruneopts = "UT" 41 | revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" 42 | version = "v1.0.0" 43 | 44 | [[projects]] 45 | digest = "1:b9914f85d95a0968bafd1be1908ba29e2eafafd88d6fd13696be42bf5368c380" 46 | name = "github.com/go-chi/chi" 47 | packages = ["."] 48 | pruneopts = "UT" 49 | revision = "b5294d10673813fac8558e7f47242bc9e61b4c25" 50 | version = "v3.3.3" 51 | 52 | [[projects]] 53 | digest = "1:50d6bc4aa3e70803230bb98a4b0e0f1331fefc2eb324e087adf7e986b8da082e" 54 | name = "github.com/go-openapi/analysis" 55 | packages = [ 56 | ".", 57 | "internal", 58 | ] 59 | pruneopts = "UT" 60 | revision = "7c1bef8f6d9fa6148ce0d8a0ebf5339a084a6639" 61 | version = "0.16.0" 62 | 63 | [[projects]] 64 | digest = "1:7a90552bf0ad97b41a4ac036d42aad77956c19b28398188ff7a5cc70b0c8bbf4" 65 | name = "github.com/go-openapi/errors" 66 | packages = ["."] 67 | pruneopts = "UT" 68 | revision = "b2b2befaf267d082d779bcef52d682a47c779517" 69 | version = "0.16.0" 70 | 71 | [[projects]] 72 | digest = "1:2997679181d901ac8aaf4330d11138ecf3974c6d3334995ff36f20cbd597daf8" 73 | name = "github.com/go-openapi/jsonpointer" 74 | packages = ["."] 75 | pruneopts = "UT" 76 | revision = "3a0015ad55fa9873f41605d3e8f28cd279c32ab2" 77 | version = "0.16.0" 78 | 79 | [[projects]] 80 | digest = "1:1ae3f233d75a731b164ca9feafd8ed646cbedf1784095876ed6988ce8aa88b1f" 81 | name = "github.com/go-openapi/jsonreference" 82 | packages = ["."] 83 | pruneopts = "UT" 84 | revision = "3fb327e6747da3043567ee86abd02bb6376b6be2" 85 | version = "0.16.0" 86 | 87 | [[projects]] 88 | digest = "1:ff04019588fc028ac28c3c565ce5316461a4641df197555041ee66cf45d213e3" 89 | name = "github.com/go-openapi/loads" 90 | packages = ["."] 91 | pruneopts = "UT" 92 | revision = "2a2b323bab96e6b1fdee110e57d959322446e9c9" 93 | version = "0.16.0" 94 | 95 | [[projects]] 96 | digest = "1:75386c3534ec29ece70aa0999dbac335a5998e821fff26d60843f2d421481c3d" 97 | name = "github.com/go-openapi/runtime" 98 | packages = ["."] 99 | pruneopts = "UT" 100 | revision = "9a3091f566c0811ef4d54b535179bc0fc484a11f" 101 | version = "0.16.0" 102 | 103 | [[projects]] 104 | digest = "1:3e5bdbd2a071c72c778c28fd7b5dfde95cdfbcef412f364377e031877205e418" 105 | name = "github.com/go-openapi/spec" 106 | packages = ["."] 107 | pruneopts = "UT" 108 | revision = "384415f06ee238aae1df5caad877de6ceac3a5c4" 109 | version = "0.16.0" 110 | 111 | [[projects]] 112 | digest = "1:c19e41854708ff52816cff12f30d454135f350a1e4fe6ab62a8be8df75458eb0" 113 | name = "github.com/go-openapi/strfmt" 114 | packages = ["."] 115 | pruneopts = "UT" 116 | revision = "913ee058e387ac83a67e2d9f13acecdcd5769fc6" 117 | version = "0.16.0" 118 | 119 | [[projects]] 120 | digest = "1:c80984d4a9bb79539743aff5af91b595d84f513700150b0ed73c1697d1200d54" 121 | name = "github.com/go-openapi/swag" 122 | packages = ["."] 123 | pruneopts = "UT" 124 | revision = "becd2f08beafcca035645a8a101e0e3e18140458" 125 | version = "0.16.0" 126 | 127 | [[projects]] 128 | digest = "1:b0fa3721f1aeaf3aca9678376b393dffdfdd7415d65db9207ca7bcaf3f893f7f" 129 | name = "github.com/go-openapi/validate" 130 | packages = ["."] 131 | pruneopts = "UT" 132 | revision = "7c1911976134d3a24d0c03127505163c9f16aa3b" 133 | version = "0.16.0" 134 | 135 | [[projects]] 136 | digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1" 137 | name = "github.com/gorilla/context" 138 | packages = ["."] 139 | pruneopts = "UT" 140 | revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42" 141 | version = "v1.1.1" 142 | 143 | [[projects]] 144 | digest = "1:e73f5b0152105f18bc131fba127d9949305c8693f8a762588a82a48f61756f5f" 145 | name = "github.com/gorilla/mux" 146 | packages = ["."] 147 | pruneopts = "UT" 148 | revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf" 149 | version = "v1.6.2" 150 | 151 | [[projects]] 152 | branch = "master" 153 | digest = "1:84a5a2b67486d5d67060ac393aa255d05d24ed5ee41daecd5635ec22657b6492" 154 | name = "github.com/mailru/easyjson" 155 | packages = [ 156 | "buffer", 157 | "jlexer", 158 | "jwriter", 159 | ] 160 | pruneopts = "UT" 161 | revision = "03f2033d19d5860aef995fe360ac7d395cd8ce65" 162 | 163 | [[projects]] 164 | branch = "master" 165 | digest = "1:5ab79470a1d0fb19b041a624415612f8236b3c06070161a910562f2b2d064355" 166 | name = "github.com/mitchellh/mapstructure" 167 | packages = ["."] 168 | pruneopts = "UT" 169 | revision = "f15292f7a699fcc1a38a80977f80a046874ba8ac" 170 | 171 | [[projects]] 172 | digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" 173 | name = "github.com/pkg/errors" 174 | packages = ["."] 175 | pruneopts = "UT" 176 | revision = "645ef00459ed84a119197bfb8d8205042c6df63d" 177 | version = "v0.8.0" 178 | 179 | [[projects]] 180 | digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe" 181 | name = "github.com/pmezard/go-difflib" 182 | packages = ["difflib"] 183 | pruneopts = "UT" 184 | revision = "792786c7400a136282c1664665ae0a8db921c6c2" 185 | version = "v1.0.0" 186 | 187 | [[projects]] 188 | digest = "1:18752d0b95816a1b777505a97f71c7467a8445b8ffb55631a7bf779f6ba4fa83" 189 | name = "github.com/stretchr/testify" 190 | packages = ["assert"] 191 | pruneopts = "UT" 192 | revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" 193 | version = "v1.2.2" 194 | 195 | [[projects]] 196 | branch = "master" 197 | digest = "1:465a33526e2f67923b633d1ff67999c13a41d5226ee8ae78ddc22bc41c5ec4b3" 198 | name = "golang.org/x/net" 199 | packages = [ 200 | "context", 201 | "idna", 202 | ] 203 | pruneopts = "UT" 204 | revision = "aaf60122140d3fcf75376d319f0554393160eb50" 205 | 206 | [[projects]] 207 | digest = "1:0c56024909189aee3364b7f21a95a27459f718aa7c199a5c111c36cfffd9eaef" 208 | name = "golang.org/x/text" 209 | packages = [ 210 | "collate", 211 | "collate/build", 212 | "internal/colltab", 213 | "internal/gen", 214 | "internal/tag", 215 | "internal/triegen", 216 | "internal/ucd", 217 | "language", 218 | "secure/bidirule", 219 | "transform", 220 | "unicode/bidi", 221 | "unicode/cldr", 222 | "unicode/norm", 223 | "unicode/rangetable", 224 | "width", 225 | ] 226 | pruneopts = "UT" 227 | revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" 228 | version = "v0.3.0" 229 | 230 | [[projects]] 231 | branch = "v2" 232 | digest = "1:2642fd0b6900c77247d61d80cf2eb59a374ef4ffc2d25a1b95b87dc355b2894e" 233 | name = "gopkg.in/mgo.v2" 234 | packages = [ 235 | "bson", 236 | "internal/json", 237 | ] 238 | pruneopts = "UT" 239 | revision = "9856a29383ce1c59f308dd1cf0363a79b5bef6b5" 240 | 241 | [[projects]] 242 | digest = "1:342378ac4dcb378a5448dd723f0784ae519383532f5e70ade24132c4c8693202" 243 | name = "gopkg.in/yaml.v2" 244 | packages = ["."] 245 | pruneopts = "UT" 246 | revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" 247 | version = "v2.2.1" 248 | 249 | [solve-meta] 250 | analyzer-name = "dep" 251 | analyzer-version = 1 252 | input-imports = [ 253 | "github.com/ghodss/yaml", 254 | "github.com/go-chi/chi", 255 | "github.com/go-openapi/analysis", 256 | "github.com/go-openapi/errors", 257 | "github.com/go-openapi/loads", 258 | "github.com/go-openapi/spec", 259 | "github.com/go-openapi/strfmt", 260 | "github.com/go-openapi/swag", 261 | "github.com/go-openapi/validate", 262 | "github.com/gorilla/mux", 263 | "github.com/pkg/errors", 264 | "github.com/stretchr/testify/assert", 265 | ] 266 | solver-name = "gps-cdcl" 267 | solver-version = 1 268 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | [prune] 2 | go-tests = true 3 | unused-packages = true 4 | 5 | [[constraint]] 6 | name = "github.com/ghodss/yaml" 7 | version = "1.0.0" 8 | 9 | [[constraint]] 10 | name = "github.com/go-openapi/analysis" 11 | version = "0.16.0" 12 | 13 | [[constraint]] 14 | name = "github.com/go-openapi/errors" 15 | version = "0.16.0" 16 | 17 | [[constraint]] 18 | name = "github.com/go-openapi/loads" 19 | version = "0.16.0" 20 | 21 | [[constraint]] 22 | name = "github.com/go-openapi/spec" 23 | version = "0.16.0" 24 | 25 | [[constraint]] 26 | name = "github.com/go-openapi/strfmt" 27 | version = "0.16.0" 28 | 29 | [[constraint]] 30 | name = "github.com/go-openapi/swag" 31 | version = "0.16.0" 32 | 33 | [[constraint]] 34 | name = "github.com/go-openapi/validate" 35 | version = "0.16.0" 36 | 37 | [[constraint]] 38 | name = "github.com/pkg/errors" 39 | version = "0.8.0" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Igor Zibarev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install 2 | install: 3 | @go install 4 | 5 | .PHONY: test 6 | test: 7 | go test -cover ./... 8 | 9 | .PHONY: test-race 10 | test-race: 11 | go test -race -cover ./... 12 | 13 | .PHONY: e2e 14 | e2e: 15 | go test -tags e2e ./e2e/... 16 | 17 | .PHONY: lint 18 | lint: install 19 | @gometalinter ./... 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oas2 2 | 3 | [![GoDoc](https://godoc.org/github.com/hypnoglow/oas2?status.svg)](https://godoc.org/github.com/hypnoglow/oas2) 4 | [![CircleCI](https://circleci.com/gh/hypnoglow/oas2.svg?style=shield)](https://circleci.com/gh/hypnoglow/oas2) 5 | [![codecov](https://codecov.io/gh/hypnoglow/oas2/branch/master/graph/badge.svg)](https://codecov.io/gh/hypnoglow/oas2) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/hypnoglow/oas2)](https://goreportcard.com/report/github.com/hypnoglow/oas2) 7 | [![GitHub release](https://img.shields.io/github/tag/hypnoglow/oas2.svg)](https://github.com/hypnoglow/oas2/releases) 8 | [![License MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE) 9 | 10 | **Note that this is not stable yet. In accordance with semantic versioning, the API can change between any minor versions. Use a vendoring tool of your 11 | preference to lock an exact [release](https://github.com/hypnoglow/oas2/releases) version.** 12 | 13 | Package oas2 provides utilities for building APIs using the OpenAPI 2.0 14 | specification (aka Swagger) in Go idiomatic way on top of `net/http`. 15 | 16 | You don't need to learn any special framework or write `net/http`-incompatible 17 | code - just delegate request validation, request parameters decoding 18 | and other routines to this library - and focus on your application logic. 19 | 20 | This package is built on top of [OpenAPI Initiative golang toolkit](https://github.com/go-openapi). 21 | 22 | ### Should I have an OpenAPI specification for my API? 23 | 24 | If you don't have a spec for your API yet - it's definitely worth it to create 25 | one. The specification itself provides many useful things, such as documentation, 26 | usage examples, and others. [Learn more](https://www.openapis.org/) about OpenAPI 27 | and its purposes. The great thing is that it is compatible with many tools for 28 | developers and consumers; [Swagger Toolkit](https://swagger.io/) is the most popular 29 | set of utilities for OpenAPI. 30 | 31 | This package offers an integration of the spec with your code. And tightly 32 | coupling your code with the spec is a good thing - you create a strong contract 33 | for API consumers, and any changes to your API will be clearly reflected in the 34 | spec. You will see many benefits, such as distinctly recognize the situation when 35 | you need to increase the major version of your API because of incompatible changes. 36 | 37 | ## Features 38 | 39 | ### Router from a spec 40 | 41 | This package provides an easy way to automatically create a router supporting 42 | all resources from your OpenAPI specification file. The underlying router is only 43 | your choice - you can use [gorilla/mux](https://github.com/gorilla/mux), [chi](https://github.com/go-chi/chi) 44 | or any other. 45 | 46 | Let's dive into a simple example. 47 | 48 | Given a spec: [petstore.yaml](_examples/petstore.yaml) 49 | 50 | First of all, load your spec in your app (note that though package import path ends in `oas2`, the package namespace is actually `oas`): 51 | 52 | ```go 53 | import "github.com/hypnoglow/oas2" 54 | 55 | // ... 56 | 57 | // specPath is a path to your spec file. 58 | doc, _ := oas.LoadFile(specPath) 59 | ``` 60 | 61 | Next, create an [operation](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#operationObject) handler. 62 | Let's define a handler for `findPetsByStatus` operation: 63 | 64 | ```go 65 | type FindPetsByStatusHandler struct { 66 | storage PetStorage 67 | } 68 | 69 | func (h FindPetsByStatusHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 70 | statuses := req.URL.Query()["status"] 71 | 72 | pets := h.storage.FindByStatus(statuses) 73 | 74 | _ = json.NewEncoder(w).Encode(pets) 75 | } 76 | ``` 77 | 78 | ```go 79 | handlers := oas.OperationHandlers{ 80 | "findPetsByStatus": findPetsByStatus{}, 81 | } 82 | ``` 83 | 84 | Define what options (logger, middleware) you will use: 85 | 86 | ```go 87 | logger := logrus.New() 88 | logger.SetLevel(logrus.DebugLevel) 89 | queryValidator := oas.QueryValidator(errHandler) 90 | ``` 91 | 92 | Create a router: 93 | 94 | ```go 95 | router, _ := oas.NewRouter( 96 | doc, 97 | handlers, 98 | oas.DebugLog(logger.Debugf), 99 | oas.Use(queryValidator) 100 | ) 101 | ``` 102 | 103 | Then you can use your `router` as an argument for `http.ListenAndServe` 104 | or even as a subrouter for the other router. 105 | 106 | ```go 107 | http.ListenAndServe(":8080", router) 108 | ``` 109 | 110 | Now the server handles requests based on the paths defined in the given spec. 111 | It validates request query parameters against the spec and runs `errHandler` 112 | func if any error occured during validation. The router also sets the operation 113 | identifier to each request's context, so it can be used in a handler or any custom 114 | middleware. 115 | 116 | See the full [example](_examples/router/main.go) for the complete code. 117 | 118 | ### Decode query parameters to a struct 119 | 120 | Given request query parameters: `?name=John&age=27` 121 | 122 | Given OpenAPI v2 (swagger) spec: 123 | 124 | ```yaml 125 | ... 126 | parameters: 127 | - name: name 128 | type: string 129 | - name: age 130 | type: integer 131 | format: int32 132 | - name: loves_apples 133 | type: bool 134 | default: true 135 | ... 136 | ``` 137 | 138 | In your Go code you create a struct: 139 | 140 | ```go 141 | type Member struct { 142 | Name string `oas:"name"` 143 | Age int32 `oas:"age"` 144 | LovesApples bool `oas:"loves_apples"` 145 | } 146 | ``` 147 | 148 | And populate it: 149 | 150 | ```go 151 | var m Member 152 | oas.DecodeQuery(req, &m) 153 | 154 | fmt.Printf("%#v", m) // Member{Name:"John", Age:27, LovesApples:true} 155 | ``` 156 | 157 | Note that it works only with oas router, because it needs to extract operation 158 | spec from the request. To use custom parameters spec, use `oas.DecodeQueryParams()`. 159 | See [`godoc example`](https://godoc.org/github.com/hypnoglow/oas2#example-DecodeQueryParams) for details. 160 | 161 | ### Pluggable formats & validators 162 | 163 | The specification [allows](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types) to have custom formats and to validate against them. 164 | 165 | This package provides the following custom formats and validators: 166 | - [`partial-time`](formats/partial_time.go) 167 | 168 | You can also implement your custom format and validator for it, and then register it: 169 | ```go 170 | validate.RegisterFormat("myformat", &MyCustomFormat{}, ValidateMyCustomFormat) 171 | ``` 172 | 173 | ## License 174 | 175 | [MIT](https://github.com/hypnoglow/oas2/blob/master/LICENSE). 176 | -------------------------------------------------------------------------------- /_examples/app/app.go: -------------------------------------------------------------------------------- 1 | // Package app provides common application logic for all examples. 2 | package app 3 | 4 | import ( 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "sync" 11 | 12 | "github.com/hypnoglow/oas2" 13 | ) 14 | 15 | func NewServer() *Server { 16 | return &Server{} 17 | } 18 | 19 | type Server struct { 20 | sumAccumulator int64 21 | sumCount int64 22 | mx sync.Mutex 23 | } 24 | 25 | func (srv *Server) PostSum(w http.ResponseWriter, req *http.Request) { 26 | var params postSumRequest 27 | if err := json.NewDecoder(req.Body).Decode(¶ms); err != nil { 28 | http.Error(w, err.Error(), http.StatusBadRequest) 29 | return 30 | } 31 | 32 | srv.mx.Lock() 33 | 34 | srv.sumAccumulator += params.Number 35 | srv.sumCount += 1 36 | 37 | resp := postSumResponse{Sum: srv.sumAccumulator} 38 | 39 | srv.mx.Unlock() 40 | 41 | srv.respondJSON(w, resp) 42 | } 43 | 44 | type postSumRequest struct { 45 | Number int64 `json:"number"` 46 | } 47 | 48 | type postSumResponse struct { 49 | Sum int64 `json:"sum"` 50 | } 51 | 52 | func (srv *Server) GetSum(w http.ResponseWriter, req *http.Request) { 53 | var params getSumRequest 54 | if err := oas.DecodeQuery(req, ¶ms); err != nil { 55 | log.Printf("[WARN] %s", err) 56 | http.Error(w, err.Error(), http.StatusInternalServerError) 57 | return 58 | } 59 | 60 | srv.mx.Lock() 61 | 62 | resp := getSumResponse{Sum: srv.sumAccumulator} 63 | if params.Count { 64 | cnt := srv.sumCount 65 | resp.Count = &cnt 66 | } 67 | 68 | srv.mx.Unlock() 69 | 70 | srv.respondJSON(w, resp) 71 | } 72 | 73 | type getSumRequest struct { 74 | Count bool `oas:"count"` 75 | } 76 | 77 | type getSumResponse struct { 78 | Sum int64 `json:"sum"` 79 | Count *int64 `json:"count,omitempty"` 80 | } 81 | 82 | func (srv *Server) respondJSON(w http.ResponseWriter, v interface{}) { 83 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 84 | if err := json.NewEncoder(w).Encode(v); err != nil { 85 | log.Printf("[ERROR] %s", err) 86 | http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) 87 | return 88 | } 89 | } 90 | 91 | func (srv *Server) HandleRequestProblem(p oas.Problem) { 92 | resp := errorResponse{} 93 | 94 | switch te := p.Cause().(type) { 95 | case oas.MultiError: 96 | for _, e := range te.Errors() { 97 | resp.Errors = append(resp.Errors, 98 | fmt.Sprintf("%s: %v", te.Message(), e), 99 | ) 100 | } 101 | default: 102 | resp.Errors = append(resp.Errors, te.Error()) 103 | } 104 | 105 | p.ResponseWriter().Header().Set("Content-Type", "application/json; charset=utf-8") 106 | p.ResponseWriter().WriteHeader(http.StatusBadRequest) 107 | srv.respondJSON(p.ResponseWriter(), resp) 108 | } 109 | 110 | func (srv *Server) HandleResponseProblem(p oas.Problem) { 111 | log.Printf("[WARN] oas problem on request %s %s: %v", p.Request().Method, p.Request().URL.String(), p.Cause()) 112 | } 113 | 114 | type errorResponse struct { 115 | Errors []string `json:"errors"` 116 | } 117 | 118 | func (srv *Server) Health(w http.ResponseWriter, req *http.Request) { 119 | io.WriteString(w, "I am alive!") 120 | } 121 | -------------------------------------------------------------------------------- /_examples/app/openapi.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: "1.0.0" 4 | title: "Adder server" 5 | basePath: "/api/v1" 6 | paths: 7 | /sum: 8 | get: 9 | summary: "Returns the sum accumulated on the server" 10 | operationId: "getSum" 11 | produces: 12 | - "application/json; charset=utf-8" 13 | parameters: 14 | - in: query 15 | name: count 16 | description: Add the count of sum operations to the response. 17 | type: boolean 18 | required: false 19 | default: false 20 | responses: 21 | 200: 22 | description: "The accumulated sum" 23 | schema: 24 | type: object 25 | properties: 26 | sum: 27 | type: integer 28 | count: 29 | type: integer 30 | required: 31 | - sum 32 | 400: 33 | description: "Bad Request" 34 | 500: 35 | description: "Server Error" 36 | post: 37 | summary: "Add a number to the sum accumulated on the server" 38 | operationId: "postSum" 39 | consumes: 40 | - "application/json; charset=utf-8" 41 | produces: 42 | - "application/json; charset=utf-8" 43 | parameters: 44 | - in: body 45 | name: body 46 | schema: 47 | type: object 48 | properties: 49 | number: 50 | type: integer 51 | required: 52 | - number 53 | required: true 54 | responses: 55 | 200: 56 | description: "The sum after adding the number to the accumulated value" 57 | schema: 58 | type: object 59 | properties: 60 | sum: 61 | type: integer 62 | required: 63 | - sum 64 | 400: 65 | description: "Bad Request" 66 | 500: 67 | description: "Server Error" 68 | -------------------------------------------------------------------------------- /_examples/operation-router/gorilla/README.md: -------------------------------------------------------------------------------- 1 | # Example: Operation Router 2 | 3 | This example demonstrates the use of an operation router 4 | with gorilla/mux as an underlying router implementation. 5 | 6 | ## Run 7 | 8 | Run the server: 9 | 10 | ``` 11 | go run main.go -spec ../../app/openapi.yaml 12 | ``` 13 | 14 | Send a correct request: 15 | 16 | ``` 17 | $ curl -XPOST -i --url 'http://localhost:8080/api/v1/sum' \ 18 | -H "Content-Type: application/json; charset=utf-8" \ 19 | -d '{"number":2}' 20 | HTTP/1.1 200 OK 21 | Content-Type: application/json; charset=utf-8 22 | Date: Tue, 25 Sep 2018 18:54:36 GMT 23 | Content-Length: 10 24 | 25 | {"sum":2} 26 | ``` 27 | 28 | Now try a request that does not meet spec parameters requirements: 29 | 30 | ``` 31 | $ curl -XPOST -i --url 'http://localhost:8080/api/v1/sum' \ 32 | -H "Content-Type: application/json; charset=utf-8" \ 33 | -d '{"number":"foo"}' 34 | HTTP/1.1 400 Bad Request 35 | Content-Type: application/json; charset=utf-8 36 | Date: Tue, 25 Sep 2018 18:55:00 GMT 37 | Content-Length: 106 38 | 39 | {"errors":["request body does not match the schema: number in body must be of type integer: \"string\""]} 40 | ``` 41 | -------------------------------------------------------------------------------- /_examples/operation-router/gorilla/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | 10 | "github.com/hypnoglow/oas2" 11 | "github.com/hypnoglow/oas2/_examples/app" 12 | _ "github.com/hypnoglow/oas2/adapter/gorilla/init" 13 | ) 14 | 15 | func main() { 16 | var specPath string 17 | flag.StringVar(&specPath, "spec", "", "Path to an OpenAPI spec file") 18 | flag.Parse() 19 | 20 | doc, err := oas.LoadFile(specPath) 21 | if err != nil { 22 | log.Fatalln(err) 23 | } 24 | 25 | err = http.ListenAndServe(":8080", api(doc)) 26 | log.Fatal(err) 27 | } 28 | 29 | func api(doc *oas.Document) http.Handler { 30 | // Create basis that provides middlewares. 31 | basis := oas.NewResolvingBasis("gorilla", doc) 32 | 33 | srv := app.NewServer() 34 | 35 | // Prepare problem handler options for validation issues. 36 | reqProblemHandler := oas.WithProblemHandlerFunc(srv.HandleRequestProblem) 37 | respProblemHandler := oas.WithProblemHandlerFunc(srv.HandleResponseProblem) 38 | 39 | // Create the root router. 40 | router := mux.NewRouter() 41 | 42 | // Build routing for the API using operation router. 43 | err := basis.OperationRouter(router). 44 | WithOperationHandlers(map[string]http.Handler{ 45 | "getSum": http.HandlerFunc(srv.GetSum), 46 | "postSum": http.HandlerFunc(srv.PostSum), 47 | }). 48 | WithMiddleware( 49 | // Add content-type validators. 50 | basis.RequestContentTypeValidator(reqProblemHandler), 51 | basis.ResponseContentTypeValidator(respProblemHandler), 52 | // Add query & body validators. 53 | basis.QueryValidator(reqProblemHandler), 54 | basis.RequestBodyValidator(reqProblemHandler), 55 | basis.ResponseBodyValidator(respProblemHandler), 56 | ). 57 | WithMissingOperationHandlerFunc(missingOperationHandler). 58 | Build() 59 | if err != nil { 60 | panic(err) 61 | } 62 | 63 | // Serve the spec itself so users can observe the API. 64 | router.Path("/openapi/v1"). 65 | Methods(http.MethodGet). 66 | Handler(oas.NewStaticSpecHandler(doc)) 67 | 68 | // Add healthcheck. 69 | router.Path("/healthz"). 70 | Methods(http.MethodGet). 71 | HandlerFunc(srv.Health) 72 | 73 | return router 74 | } 75 | 76 | func missingOperationHandler(operationID string) { 77 | log.Printf("[WARN] missing handler for operation %s", operationID) 78 | } 79 | -------------------------------------------------------------------------------- /_examples/resolving/gorilla/README.md: -------------------------------------------------------------------------------- 1 | # Example: Resolving basis 2 | 3 | This example demonstrates the use of a resolving basis 4 | with gorilla/mux as an underlying router implementation. 5 | 6 | ## Run 7 | 8 | Run the server: 9 | 10 | ``` 11 | go run main.go -spec ../../app/openapi.yaml 12 | ``` 13 | 14 | Send a correct request: 15 | 16 | ``` 17 | $ curl -XPOST -i --url 'http://localhost:8080/api/v1/sum' \ 18 | -H "Content-Type: application/json; charset=utf-8" \ 19 | -d '{"number":2}' 20 | HTTP/1.1 200 OK 21 | Content-Type: application/json; charset=utf-8 22 | Date: Tue, 25 Sep 2018 18:54:36 GMT 23 | Content-Length: 10 24 | 25 | {"sum":2} 26 | ``` 27 | 28 | Now try a request that does not meet spec parameters requirements: 29 | 30 | ``` 31 | $ curl -XPOST -i --url 'http://localhost:8080/api/v1/sum' \ 32 | -H "Content-Type: application/json; charset=utf-8" \ 33 | -d '{"number":"foo"}' 34 | HTTP/1.1 400 Bad Request 35 | Content-Type: application/json; charset=utf-8 36 | Date: Tue, 25 Sep 2018 18:55:00 GMT 37 | Content-Length: 106 38 | 39 | {"errors":["request body does not match the schema: number in body must be of type integer: \"string\""]} 40 | ``` 41 | -------------------------------------------------------------------------------- /_examples/resolving/gorilla/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | 10 | "github.com/hypnoglow/oas2" 11 | "github.com/hypnoglow/oas2/_examples/app" 12 | _ "github.com/hypnoglow/oas2/adapter/gorilla/init" 13 | ) 14 | 15 | func main() { 16 | var specPath string 17 | flag.StringVar(&specPath, "spec", "", "Path to an OpenAPI spec file") 18 | flag.Parse() 19 | 20 | doc, err := oas.LoadFile(specPath) 21 | if err != nil { 22 | log.Fatalln(err) 23 | } 24 | 25 | err = http.ListenAndServe(":8080", api(doc)) 26 | log.Fatal(err) 27 | } 28 | 29 | func api(doc *oas.Document) http.Handler { 30 | // Create basis that provides middlewares. 31 | basis := oas.NewResolvingBasis("gorilla", doc) 32 | 33 | srv := app.NewServer() 34 | 35 | // Prepare problem handler options for validation issues. 36 | reqProblemHandler := oas.WithProblemHandlerFunc(srv.HandleRequestProblem) 37 | respProblemHandler := oas.WithProblemHandlerFunc(srv.HandleResponseProblem) 38 | 39 | // Create the root router. 40 | router := mux.NewRouter() 41 | 42 | // Build routing for the API using subrouter. 43 | apiRouter := router.PathPrefix("/api/v1").Subrouter() 44 | apiRouter.Use( 45 | // First of all, use operation context middleware so other oas middlewares 46 | // can function properly. 47 | mux.MiddlewareFunc(basis.OperationContext()), 48 | // Add content-type validators. 49 | mux.MiddlewareFunc(basis.RequestContentTypeValidator(reqProblemHandler)), 50 | mux.MiddlewareFunc(basis.ResponseContentTypeValidator(respProblemHandler)), 51 | // Add query & body validators. 52 | mux.MiddlewareFunc(basis.QueryValidator(reqProblemHandler)), 53 | mux.MiddlewareFunc(basis.RequestBodyValidator(reqProblemHandler)), 54 | mux.MiddlewareFunc(basis.ResponseBodyValidator(respProblemHandler)), 55 | ) 56 | // Handle routes. 57 | apiRouter.Path("/sum").Methods(http.MethodGet).HandlerFunc(srv.GetSum) 58 | apiRouter.Path("/sum").Methods(http.MethodPost).HandlerFunc(srv.PostSum) 59 | 60 | // Serve the spec itself so users can observe the API. 61 | router.Path("/openapi/v1").Methods(http.MethodGet).Handler(oas.NewStaticSpecHandler(doc)) 62 | 63 | // Add healthcheck. 64 | router.Path("/healthz").Methods(http.MethodGet).HandlerFunc(srv.Health) 65 | 66 | return router 67 | } 68 | -------------------------------------------------------------------------------- /adapter/chi/adapter.go: -------------------------------------------------------------------------------- 1 | package oas_chi 2 | 3 | import ( 4 | "github.com/go-chi/chi" 5 | 6 | "github.com/hypnoglow/oas2" 7 | ) 8 | 9 | // adapter implements oas.Adapter using chi router. 10 | type adapter struct{} 11 | 12 | // Resolver returns a resolver based on chi router context. 13 | func (a adapter) Resolver(meta interface{}) oas.Resolver { 14 | doc, ok := meta.(*oas.Document) 15 | if !ok { 16 | panic("oas_chi: Resolver meta is not *oas.Document") 17 | } 18 | 19 | return NewResolver(doc) 20 | } 21 | 22 | // OperationRouter returns an operation router based on chi router. 23 | func (a adapter) OperationRouter(meta interface{}) oas.OperationRouter { 24 | r, ok := meta.(chi.Router) 25 | if !ok { 26 | panic("oas_chi: OperationRouter meta is not chi.Router") 27 | } 28 | 29 | return NewOperationRouter(r) 30 | } 31 | 32 | // PathParamExtractor returns a new path param extractor based on chi router 33 | // context. 34 | func (a adapter) PathParamExtractor() oas.PathParamExtractor { 35 | return NewPathParamExtractor() 36 | } 37 | 38 | // NewAdapter returns a new adapter based on chi router. 39 | func NewAdapter() oas.Adapter { 40 | return adapter{} 41 | } 42 | -------------------------------------------------------------------------------- /adapter/chi/doc.go: -------------------------------------------------------------------------------- 1 | // Package oas_chi provides specific implementations of oas components using 2 | // chi router. 3 | package oas_chi 4 | -------------------------------------------------------------------------------- /adapter/chi/init/init.go: -------------------------------------------------------------------------------- 1 | package init 2 | 3 | import ( 4 | "github.com/hypnoglow/oas2" 5 | "github.com/hypnoglow/oas2/adapter/chi" 6 | ) 7 | 8 | func init() { 9 | oas.RegisterAdapter("chi", oas_chi.NewAdapter()) 10 | } 11 | -------------------------------------------------------------------------------- /adapter/chi/path_params.go: -------------------------------------------------------------------------------- 1 | package oas_chi 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | 8 | "github.com/hypnoglow/oas2" 9 | ) 10 | 11 | // NewPathParamExtractor returns a new path param extractor that extracts 12 | // path parameter from the request using chi route context. 13 | func NewPathParamExtractor() oas.PathParamExtractor { 14 | return &pathParamsExtractor{} 15 | } 16 | 17 | type pathParamsExtractor struct{} 18 | 19 | // PathParam returns path parameter by key from the request context. 20 | func (e pathParamsExtractor) PathParam(req *http.Request, key string) string { 21 | return chi.URLParam(req, key) 22 | } 23 | -------------------------------------------------------------------------------- /adapter/chi/resolver.go: -------------------------------------------------------------------------------- 1 | package oas_chi 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/go-chi/chi" 8 | 9 | "github.com/hypnoglow/oas2" 10 | ) 11 | 12 | // NewResolver returns a resolver that resolves OpenAPI operation ID using 13 | // chi request route context. It should be used in conjunction with 14 | // chi router, and only with it. 15 | func NewResolver(doc *oas.Document) oas.Resolver { 16 | return &resolver{ 17 | doc: doc, 18 | } 19 | } 20 | 21 | // resolver implements Resolver using chi's mux RouteContext. 22 | type resolver struct { 23 | doc *oas.Document 24 | } 25 | 26 | // Resolve resolves operation id from the request using chi route 27 | // context. 28 | func (r *resolver) Resolve(req *http.Request) (string, bool) { 29 | ctx := chi.RouteContext(req.Context()) 30 | pt := ctx.RoutePattern() 31 | if pt == "" { 32 | return "", false 33 | } 34 | 35 | p := strings.TrimPrefix(pt, r.doc.BasePath()) 36 | op, ok := r.doc.Analyzer.OperationFor(req.Method, p) 37 | if !ok { 38 | return "", false 39 | } 40 | 41 | return op.ID, true 42 | } 43 | -------------------------------------------------------------------------------- /adapter/chi/router.go: -------------------------------------------------------------------------------- 1 | package oas_chi 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | 9 | "github.com/hypnoglow/oas2" 10 | ) 11 | 12 | // NewOperationRouter returns a new operation router based on chi router. 13 | func NewOperationRouter(r chi.Router) oas.OperationRouter { 14 | return &OperationRouter{ 15 | router: r, 16 | } 17 | } 18 | 19 | // OperationRouter is an operation router based on chi router. 20 | type OperationRouter struct { 21 | router chi.Router 22 | 23 | doc *oas.Document 24 | mws []oas.Middleware 25 | handlers map[string]http.Handler 26 | 27 | // onMissingOperationHandler is invoked with operation name 28 | // when operation handler is missing. 29 | onMissingOperationHandler func(op string) 30 | } 31 | 32 | // WithDocument sets the OpenAPI specification to build routes on. 33 | // It returns the router for convenient chaining. 34 | func (r *OperationRouter) WithDocument(doc *oas.Document) oas.OperationRouter { 35 | r.doc = doc 36 | return r 37 | } 38 | 39 | // WithMiddleware sets the middleware to build routing with. 40 | // It returns the router for convenient chaining. 41 | func (r *OperationRouter) WithMiddleware(mws ...oas.Middleware) oas.OperationRouter { 42 | r.mws = append(r.mws, mws...) 43 | return r 44 | } 45 | 46 | // WithOperationHandlers sets operation handlers to build routing with. 47 | // It returns the router for convenient chaining. 48 | func (r *OperationRouter) WithOperationHandlers(hh map[string]http.Handler) oas.OperationRouter { 49 | r.handlers = hh 50 | return r 51 | } 52 | 53 | // WithMissingOperationHandlerFunc sets the function that will be called 54 | // for each operation that is present in the spec but missing from operation 55 | // handlers. This is completely optional. You can use this method for example 56 | // to simply log a warning or to throw a panic and stop route building. 57 | // This method returns the router for convenient chaining. 58 | func (r *OperationRouter) WithMissingOperationHandlerFunc(fn func(string)) oas.OperationRouter { 59 | r.onMissingOperationHandler = fn 60 | return r 61 | } 62 | 63 | // Build builds routing based on the previously provided specification, 64 | // operation handlers, and other options. 65 | func (r *OperationRouter) Build() error { 66 | if r.doc == nil { 67 | return fmt.Errorf("no doc is given") 68 | } 69 | if r.handlers == nil { 70 | return fmt.Errorf("no operation handlers given") 71 | } 72 | 73 | var router chi.Router = chi.NewRouter() 74 | 75 | mws := make([]func(http.Handler) http.Handler, len(r.mws)) 76 | for i, mw := range r.mws { 77 | mws[i] = mw 78 | } 79 | 80 | router = router.With(mws...) 81 | 82 | for method, pathOps := range r.doc.Analyzer.Operations() { 83 | for path, operation := range pathOps { 84 | h, ok := r.handlers[operation.ID] 85 | if !ok { 86 | if r.onMissingOperationHandler != nil { 87 | r.onMissingOperationHandler(operation.ID) 88 | } 89 | continue 90 | } 91 | 92 | router.Method(method, path, h) 93 | } 94 | } 95 | 96 | if len(router.Routes()) == 0 { 97 | return nil 98 | } 99 | 100 | r.router.Mount(r.doc.BasePath(), router) 101 | 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /adapter/chi/router_test.go: -------------------------------------------------------------------------------- 1 | package oas_chi_test 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/go-chi/chi" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/hypnoglow/oas2" 13 | "github.com/hypnoglow/oas2/adapter/chi" 14 | _ "github.com/hypnoglow/oas2/adapter/chi/init" 15 | ) 16 | 17 | func TestOperationRouter_implementation(t *testing.T) { 18 | var _ oas.OperationRouter = &oas_chi.OperationRouter{} 19 | } 20 | 21 | func TestOperationRouter(t *testing.T) { 22 | doc, err := oas.LoadFile("testdata/petstore.yml") 23 | assert.NoError(t, err) 24 | 25 | r := chi.NewRouter() 26 | basis := oas.NewResolvingBasis("chi", doc) 27 | 28 | var notHandledOps []string 29 | 30 | err = basis.OperationRouter(r). 31 | WithOperationHandlers(map[string]http.Handler{ 32 | "getPetById": getPetHandler{}, 33 | }). 34 | WithMiddleware(basis.PathParamsContext()). 35 | WithMissingOperationHandlerFunc(func(s string) { 36 | notHandledOps = append(notHandledOps, s) 37 | }). 38 | Build() 39 | assert.NoError(t, err) 40 | 41 | w := httptest.NewRecorder() 42 | req := httptest.NewRequest(http.MethodGet, "/v2/pet/12", nil) 43 | r.ServeHTTP(w, req) 44 | 45 | assert.Equal(t, http.StatusOK, w.Code) 46 | assert.JSONEq(t, `{"name": "Hooch", "age": 3, "debug": true}`, w.Body.String()) 47 | assert.ElementsMatch(t, []string{"addPet", "loginUser"}, notHandledOps) 48 | } 49 | 50 | type getPetHandler struct{} 51 | 52 | func (h getPetHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 53 | id, ok := oas.GetPathParam(req, "petId").(int64) 54 | if !ok { 55 | w.WriteHeader(http.StatusNotFound) 56 | return 57 | } 58 | 59 | if id != 12 { 60 | w.WriteHeader(http.StatusNotFound) 61 | return 62 | } 63 | 64 | resp := map[string]interface{}{ 65 | "name": "Hooch", 66 | "age": 3, 67 | "debug": true, 68 | } 69 | 70 | if err := json.NewEncoder(w).Encode(resp); err != nil { 71 | panic(err) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /adapter/chi/testdata/petstore.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: "This is a sample server Petstore server." 4 | version: "1.0.0" 5 | title: "Swagger Petstore" 6 | termsOfService: "http://swagger.io/terms/" 7 | contact: 8 | email: "apiteam@swagger.io" 9 | license: 10 | name: "Apache 2.0" 11 | url: "http://www.apache.org/licenses/LICENSE-2.0.html" 12 | host: "petstore.swagger.io" 13 | basePath: "/v2" 14 | tags: 15 | - name: "pet" 16 | description: "Everything about your Pets" 17 | externalDocs: 18 | description: "Find out more" 19 | url: "http://swagger.io" 20 | schemes: 21 | - "http" 22 | paths: 23 | /pet: 24 | post: 25 | tags: 26 | - "pet" 27 | summary: "Add a new pet to the store" 28 | operationId: "addPet" 29 | consumes: 30 | - "application/json" 31 | produces: 32 | - "application/json" 33 | parameters: 34 | - in: "body" 35 | name: "body" 36 | description: "Pet object that needs to be added to the store" 37 | required: true 38 | schema: 39 | $ref: "#/definitions/Pet" 40 | - in: query 41 | name: debug 42 | type: boolean 43 | responses: 44 | 405: 45 | description: "Invalid input" 46 | security: 47 | - petstore_auth: 48 | - "write:pets" 49 | - "read:pets" 50 | /pet/{petId}: 51 | get: 52 | tags: 53 | - "pet" 54 | summary: "Find pet by ID" 55 | description: "Returns a single pet" 56 | operationId: "getPetById" 57 | produces: 58 | - "application/json" 59 | parameters: 60 | - in: query 61 | name: debug 62 | type: boolean 63 | responses: 64 | 200: 65 | description: "successful operation" 66 | schema: 67 | $ref: "#/definitions/Pet" 68 | 400: 69 | description: "Invalid ID supplied" 70 | 404: 71 | description: "Pet not found" 72 | security: 73 | - api_key: [] 74 | parameters: 75 | - name: "petId" 76 | in: "path" 77 | description: "ID of pet to return" 78 | required: true 79 | type: "integer" 80 | format: "int64" 81 | /user/login: 82 | get: 83 | tags: 84 | - "user" 85 | summary: "Logs user into the system" 86 | description: "" 87 | operationId: "loginUser" 88 | produces: 89 | - "application/json" 90 | parameters: 91 | - name: "username" 92 | in: "query" 93 | description: "The user name for login" 94 | required: true 95 | type: "string" 96 | - name: "password" 97 | in: "query" 98 | description: "The password for login in clear text" 99 | required: true 100 | type: "string" 101 | responses: 102 | 200: 103 | description: "successful operation" 104 | schema: 105 | type: "string" 106 | headers: 107 | X-Rate-Limit: 108 | type: "integer" 109 | format: "int32" 110 | description: "calls per hour allowed by the user" 111 | X-Expires-After: 112 | type: "string" 113 | format: "date-time" 114 | description: "date in UTC when token expires" 115 | 400: 116 | description: "Invalid username/password supplied" 117 | securityDefinitions: 118 | petstore_auth: 119 | type: "oauth2" 120 | authorizationUrl: "http://petstore.swagger.io/oauth/dialog" 121 | flow: "implicit" 122 | scopes: 123 | write:pets: "modify pets in your account" 124 | read:pets: "read your pets" 125 | api_key: 126 | type: "apiKey" 127 | name: "api_key" 128 | in: "header" 129 | definitions: 130 | Pet: 131 | type: "object" 132 | required: 133 | - "name" 134 | - "age" 135 | properties: 136 | id: 137 | type: "integer" 138 | format: "int64" 139 | name: 140 | type: "string" 141 | example: "doggie" 142 | age: 143 | type: "integer" 144 | format: "int32" 145 | example: 7 146 | status: 147 | type: "string" 148 | description: "pet status in the store" 149 | enum: 150 | - "available" 151 | - "pending" 152 | - "sold" 153 | ApiResponse: 154 | type: "object" 155 | properties: 156 | code: 157 | type: "integer" 158 | format: "int32" 159 | type: 160 | type: "string" 161 | message: 162 | type: "string" 163 | externalDocs: 164 | description: "Find out more about Swagger" 165 | url: "http://swagger.io" 166 | -------------------------------------------------------------------------------- /adapter/gorilla/adapter.go: -------------------------------------------------------------------------------- 1 | package oas_gorilla 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | 6 | "github.com/hypnoglow/oas2" 7 | ) 8 | 9 | // adapter implements oas.Adapter using gorilla mux router. 10 | type adapter struct{} 11 | 12 | // Resolver returns a resolver based on gorilla mux router context. 13 | func (a adapter) Resolver(meta interface{}) oas.Resolver { 14 | doc, ok := meta.(*oas.Document) 15 | if !ok { 16 | panic("oas_chi: Resolver meta is not *oas.Document") 17 | } 18 | 19 | return NewResolver(doc) 20 | } 21 | 22 | // OperationRouter returns an operation router based on gorilla mux router. 23 | func (a adapter) OperationRouter(meta interface{}) oas.OperationRouter { 24 | r, ok := meta.(*mux.Router) 25 | if !ok { 26 | panic("oas_chi: OperationRouter meta is not *mux.Router") 27 | } 28 | 29 | return NewOperationRouter(r) 30 | } 31 | 32 | // PathParamExtractor returns a new path param extractor based on gorilla mux 33 | // router context. 34 | func (a adapter) PathParamExtractor() oas.PathParamExtractor { 35 | return NewPathParamExtractor() 36 | } 37 | 38 | // NewAdapter returns a new adapter based on gorilla mux router. 39 | func NewAdapter() oas.Adapter { 40 | return adapter{} 41 | } 42 | -------------------------------------------------------------------------------- /adapter/gorilla/doc.go: -------------------------------------------------------------------------------- 1 | // Package oas_gorilla provides specific implementations of oas components using 2 | // gorilla/mux router. 3 | package oas_gorilla 4 | -------------------------------------------------------------------------------- /adapter/gorilla/init/init.go: -------------------------------------------------------------------------------- 1 | package init 2 | 3 | import ( 4 | "github.com/hypnoglow/oas2" 5 | "github.com/hypnoglow/oas2/adapter/gorilla" 6 | ) 7 | 8 | func init() { 9 | oas.RegisterAdapter("gorilla", oas_gorilla.NewAdapter()) 10 | } 11 | -------------------------------------------------------------------------------- /adapter/gorilla/path_params.go: -------------------------------------------------------------------------------- 1 | package oas_gorilla 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | 8 | "github.com/hypnoglow/oas2" 9 | ) 10 | 11 | // NewPathParamExtractor returns a new path param extractor that extracts 12 | // path parameter from the request using gorilla/mux route context. 13 | func NewPathParamExtractor() oas.PathParamExtractor { 14 | return &pathParamsExtractor{} 15 | } 16 | 17 | type pathParamsExtractor struct{} 18 | 19 | // PathParam returns path parameter by key from the request context. 20 | func (e pathParamsExtractor) PathParam(req *http.Request, key string) string { 21 | return mux.Vars(req)[key] 22 | } 23 | -------------------------------------------------------------------------------- /adapter/gorilla/resolver.go: -------------------------------------------------------------------------------- 1 | package oas_gorilla 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/hypnoglow/oas2" 10 | ) 11 | 12 | // NewResolver returns a resolver that resolves OpenAPI operation ID using 13 | // gorilla/mux request route context. It should be used in conjunction with 14 | // gorilla/mux router, and only with it. 15 | func NewResolver(doc *oas.Document) oas.Resolver { 16 | return &resolver{ 17 | doc: doc, 18 | } 19 | } 20 | 21 | // resolver implements Resolver using gorilla's mux CurrentRoute method 22 | // that extracts path template from the request. 23 | type resolver struct { 24 | doc *oas.Document 25 | } 26 | 27 | // Resolve resolves operation id from the request using gorilla/mux route 28 | // context. 29 | func (r *resolver) Resolve(req *http.Request) (string, bool) { 30 | cr := mux.CurrentRoute(req) 31 | if cr == nil { 32 | // WARNING: this can happen because of improper package vendoring. 33 | return "", false 34 | } 35 | 36 | pt, err := cr.GetPathTemplate() 37 | if err != nil { 38 | return "", false 39 | } 40 | 41 | p := strings.TrimPrefix(pt, r.doc.BasePath()) 42 | op, ok := r.doc.Analyzer.OperationFor(req.Method, p) 43 | if !ok { 44 | return "", false 45 | } 46 | 47 | return op.ID, true 48 | } 49 | -------------------------------------------------------------------------------- /adapter/gorilla/resolver_test.go: -------------------------------------------------------------------------------- 1 | package oas_gorilla 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/hypnoglow/oas2" 12 | ) 13 | 14 | func TestResolver(t *testing.T) { 15 | doc, err := oas.LoadFile("testdata/petstore.yml") 16 | assert.NoError(t, err) 17 | 18 | resolver := NewResolver(doc) 19 | 20 | h := addPetHandler{ 21 | resolver: resolver, 22 | t: t, 23 | } 24 | 25 | r := mux.NewRouter() 26 | r.Path("/v2/pet"). 27 | Methods(http.MethodPost). 28 | Handler(h) 29 | 30 | w := httptest.NewRecorder() 31 | req := httptest.NewRequest(http.MethodPost, "/v2/pet", nil) 32 | r.ServeHTTP(w, req) 33 | } 34 | 35 | type addPetHandler struct { 36 | resolver oas.Resolver 37 | t *testing.T 38 | } 39 | 40 | func (h addPetHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 41 | op, ok := h.resolver.Resolve(req) 42 | assert.True(h.t, ok) 43 | assert.Equal(h.t, "addPet", op) 44 | } 45 | -------------------------------------------------------------------------------- /adapter/gorilla/router.go: -------------------------------------------------------------------------------- 1 | package oas_gorilla 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/gorilla/mux" 8 | 9 | "github.com/hypnoglow/oas2" 10 | ) 11 | 12 | // NewOperationRouter returns a new operation router based on gorilla/mux 13 | // router. 14 | func NewOperationRouter(r *mux.Router) oas.OperationRouter { 15 | return &OperationRouter{ 16 | router: r, 17 | } 18 | } 19 | 20 | // OperationRouter is an operation router based on gorilla/mux router. 21 | type OperationRouter struct { 22 | router *mux.Router 23 | 24 | doc *oas.Document 25 | mws []oas.Middleware 26 | handlers map[string]http.Handler 27 | 28 | // onMissingOperationHandler is invoked with operation name 29 | // when operation handler is missing. 30 | onMissingOperationHandler func(op string) 31 | } 32 | 33 | // WithDocument sets the OpenAPI specification to build routes on. 34 | // It returns the router for convenient chaining. 35 | func (r *OperationRouter) WithDocument(doc *oas.Document) oas.OperationRouter { 36 | r.doc = doc 37 | return r 38 | } 39 | 40 | // WithMiddleware sets the middleware to build routing with. 41 | // It returns the router for convenient chaining. 42 | func (r *OperationRouter) WithMiddleware(mws ...oas.Middleware) oas.OperationRouter { 43 | r.mws = append(r.mws, mws...) 44 | return r 45 | } 46 | 47 | // WithOperationHandlers sets operation handlers to build routing with. 48 | // It returns the router for convenient chaining. 49 | func (r *OperationRouter) WithOperationHandlers(hh map[string]http.Handler) oas.OperationRouter { 50 | r.handlers = hh 51 | return r 52 | } 53 | 54 | // WithMissingOperationHandlerFunc sets the function that will be called 55 | // for each operation that is present in the spec but missing from operation 56 | // handlers. This is completely optional. You can use this method for example 57 | // to simply log a warning or to throw a panic and stop route building. 58 | // This method returns the router for convenient chaining. 59 | func (r *OperationRouter) WithMissingOperationHandlerFunc(fn func(string)) oas.OperationRouter { 60 | r.onMissingOperationHandler = fn 61 | return r 62 | } 63 | 64 | // Build builds routing based on the previously provided specification, 65 | // operation handlers, and other options. 66 | func (r *OperationRouter) Build() error { 67 | if r.doc == nil { 68 | return fmt.Errorf("no doc is given") 69 | } 70 | if r.handlers == nil { 71 | return fmt.Errorf("no operation handlers given") 72 | } 73 | 74 | router := r.router. 75 | PathPrefix(r.doc.BasePath()). 76 | Subrouter() 77 | 78 | mws := make([]mux.MiddlewareFunc, len(r.mws)) 79 | for i, mw := range r.mws { 80 | mws[i] = mux.MiddlewareFunc(mw) 81 | } 82 | router.Use(mws...) 83 | 84 | for method, pathOps := range r.doc.Analyzer.Operations() { 85 | for path, operation := range pathOps { 86 | h, ok := r.handlers[operation.ID] 87 | if !ok { 88 | if r.onMissingOperationHandler != nil { 89 | r.onMissingOperationHandler(operation.ID) 90 | } 91 | continue 92 | } 93 | 94 | router.Path(path).Methods(method).Handler(h) 95 | } 96 | } 97 | 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /adapter/gorilla/router_test.go: -------------------------------------------------------------------------------- 1 | package oas_gorilla_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gorilla/mux" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/hypnoglow/oas2" 12 | "github.com/hypnoglow/oas2/adapter/gorilla" 13 | _ "github.com/hypnoglow/oas2/adapter/gorilla/init" 14 | ) 15 | 16 | func TestOperationRouter_implementation(t *testing.T) { 17 | var _ oas.OperationRouter = &oas_gorilla.OperationRouter{} 18 | } 19 | 20 | func TestOperationRouter(t *testing.T) { 21 | doc, err := oas.LoadFile("testdata/petstore.yml") 22 | assert.NoError(t, err) 23 | 24 | r := mux.NewRouter() 25 | basis := oas.NewResolvingBasis("gorilla", doc) 26 | 27 | var notHandledOps []string 28 | 29 | err = basis.OperationRouter(r). 30 | WithOperationHandlers(map[string]http.Handler{ 31 | "addPet": addPetHandler2{}, 32 | }). 33 | WithMissingOperationHandlerFunc(func(s string) { 34 | notHandledOps = append(notHandledOps, s) 35 | }). 36 | Build() 37 | assert.NoError(t, err) 38 | 39 | w := httptest.NewRecorder() 40 | req := httptest.NewRequest(http.MethodPost, "/v2/pet", nil) 41 | r.ServeHTTP(w, req) 42 | 43 | assert.Equal(t, http.StatusOK, w.Code) 44 | assert.Equal(t, `{"foo":"bar"}`, w.Body.String()) 45 | assert.ElementsMatch(t, []string{"getPetById", "loginUser"}, notHandledOps) 46 | } 47 | 48 | type addPetHandler2 struct{} 49 | 50 | func (h addPetHandler2) ServeHTTP(w http.ResponseWriter, req *http.Request) { 51 | w.Write([]byte(`{"foo":"bar"}`)) 52 | } 53 | -------------------------------------------------------------------------------- /adapter/gorilla/testdata/petstore.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: "This is a sample server Petstore server." 4 | version: "1.0.0" 5 | title: "Swagger Petstore" 6 | termsOfService: "http://swagger.io/terms/" 7 | contact: 8 | email: "apiteam@swagger.io" 9 | license: 10 | name: "Apache 2.0" 11 | url: "http://www.apache.org/licenses/LICENSE-2.0.html" 12 | host: "petstore.swagger.io" 13 | basePath: "/v2" 14 | tags: 15 | - name: "pet" 16 | description: "Everything about your Pets" 17 | externalDocs: 18 | description: "Find out more" 19 | url: "http://swagger.io" 20 | schemes: 21 | - "http" 22 | paths: 23 | /pet: 24 | post: 25 | tags: 26 | - "pet" 27 | summary: "Add a new pet to the store" 28 | operationId: "addPet" 29 | consumes: 30 | - "application/json" 31 | produces: 32 | - "application/json" 33 | parameters: 34 | - in: "body" 35 | name: "body" 36 | description: "Pet object that needs to be added to the store" 37 | required: true 38 | schema: 39 | $ref: "#/definitions/Pet" 40 | - in: query 41 | name: debug 42 | type: boolean 43 | responses: 44 | 405: 45 | description: "Invalid input" 46 | security: 47 | - petstore_auth: 48 | - "write:pets" 49 | - "read:pets" 50 | /pet/{petId}: 51 | get: 52 | tags: 53 | - "pet" 54 | summary: "Find pet by ID" 55 | description: "Returns a single pet" 56 | operationId: "getPetById" 57 | produces: 58 | - "application/json" 59 | parameters: 60 | - in: query 61 | name: debug 62 | type: boolean 63 | responses: 64 | 200: 65 | description: "successful operation" 66 | schema: 67 | $ref: "#/definitions/Pet" 68 | 400: 69 | description: "Invalid ID supplied" 70 | 404: 71 | description: "Pet not found" 72 | security: 73 | - api_key: [] 74 | parameters: 75 | - name: "petId" 76 | in: "path" 77 | description: "ID of pet to return" 78 | required: true 79 | type: "integer" 80 | format: "int64" 81 | /user/login: 82 | get: 83 | tags: 84 | - "user" 85 | summary: "Logs user into the system" 86 | description: "" 87 | operationId: "loginUser" 88 | produces: 89 | - "application/json" 90 | parameters: 91 | - name: "username" 92 | in: "query" 93 | description: "The user name for login" 94 | required: true 95 | type: "string" 96 | - name: "password" 97 | in: "query" 98 | description: "The password for login in clear text" 99 | required: true 100 | type: "string" 101 | responses: 102 | 200: 103 | description: "successful operation" 104 | schema: 105 | type: "string" 106 | headers: 107 | X-Rate-Limit: 108 | type: "integer" 109 | format: "int32" 110 | description: "calls per hour allowed by the user" 111 | X-Expires-After: 112 | type: "string" 113 | format: "date-time" 114 | description: "date in UTC when token expires" 115 | 400: 116 | description: "Invalid username/password supplied" 117 | securityDefinitions: 118 | petstore_auth: 119 | type: "oauth2" 120 | authorizationUrl: "http://petstore.swagger.io/oauth/dialog" 121 | flow: "implicit" 122 | scopes: 123 | write:pets: "modify pets in your account" 124 | read:pets: "read your pets" 125 | api_key: 126 | type: "apiKey" 127 | name: "api_key" 128 | in: "header" 129 | definitions: 130 | Pet: 131 | type: "object" 132 | required: 133 | - "name" 134 | - "age" 135 | properties: 136 | id: 137 | type: "integer" 138 | format: "int64" 139 | name: 140 | type: "string" 141 | example: "doggie" 142 | age: 143 | type: "integer" 144 | format: "int32" 145 | example: 7 146 | status: 147 | type: "string" 148 | description: "pet status in the store" 149 | enum: 150 | - "available" 151 | - "pending" 152 | - "sold" 153 | ApiResponse: 154 | type: "object" 155 | properties: 156 | code: 157 | type: "integer" 158 | format: "int32" 159 | type: 160 | type: "string" 161 | message: 162 | type: "string" 163 | externalDocs: 164 | description: "Find out more about Swagger" 165 | url: "http://swagger.io" 166 | -------------------------------------------------------------------------------- /adapters.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Adapter interface defines a contract between oas and a router implementation. 8 | type Adapter interface { 9 | Resolver(meta interface{}) Resolver 10 | OperationRouter(meta interface{}) OperationRouter 11 | PathParamExtractor() PathParamExtractor 12 | } 13 | 14 | var ( 15 | adaptersMx sync.Mutex 16 | adapters = make(map[string]Adapter) 17 | ) 18 | 19 | // RegisterAdapter makes an adapter available by the provided name. If this 20 | // function is called twice with the same name or if adapter is nil, it panics. 21 | func RegisterAdapter(name string, adapter Adapter) { 22 | adaptersMx.Lock() 23 | defer adaptersMx.Unlock() 24 | 25 | if adapter == nil { 26 | panic("oas: RegisterAdapter adapter is nil") 27 | } 28 | if _, dup := adapters[name]; dup { 29 | panic("oas: RegisterAdapter called twice for adapter " + name) 30 | } 31 | 32 | adapters[name] = adapter 33 | } 34 | 35 | // mustGetAdapter returns previously registered adapter by the provided name. 36 | // If no adapter is registered by the name, it panics. 37 | func mustGetAdapter(name string) Adapter { 38 | adaptersMx.Lock() 39 | defer adaptersMx.Unlock() 40 | 41 | a, ok := adapters[name] 42 | if !ok { 43 | panic("oas: no adapter registered for name " + name) 44 | } 45 | return a 46 | } 47 | -------------------------------------------------------------------------------- /basis_resolving.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // Resolver resolves operation id from the request. 9 | type Resolver interface { 10 | Resolve(req *http.Request) (string, bool) 11 | } 12 | 13 | // NewResolvingBasis returns a new resolving basis. 14 | func NewResolvingBasis(name string, doc *Document) *ResolvingBasis { 15 | b := &ResolvingBasis{ 16 | adapter: mustGetAdapter(name), 17 | doc: doc, 18 | strict: true, 19 | } 20 | 21 | b.initCache() 22 | return b 23 | } 24 | 25 | // A ResolvingBasis provides fundamental oas middleware, which resolve operation 26 | // context from the request using the Resolver. 27 | type ResolvingBasis struct { 28 | adapter Adapter 29 | doc *Document 30 | 31 | // internals 32 | 33 | cache map[string]operationInfo 34 | 35 | // common options for derived middlewares 36 | 37 | strict bool 38 | } 39 | 40 | func (b *ResolvingBasis) initCache() { 41 | b.cache = make(map[string]operationInfo) 42 | // _ is method 43 | for _, pathOps := range b.doc.Analyzer.Operations() { 44 | // _ is path 45 | for _, operation := range pathOps { 46 | key := operation.ID 47 | value := operationInfo{ 48 | operation: operation, 49 | params: b.doc.Analyzer.ParametersFor(operation.ID), 50 | consumes: b.doc.Analyzer.ConsumesFor(operation), 51 | produces: b.doc.Analyzer.ProducesFor(operation), 52 | } 53 | b.cache[key] = value 54 | } 55 | } 56 | } 57 | 58 | // OperationRouter returns a new OperationRouter based on the underlying 59 | // adapter. This router is already configured to use basis oas document and 60 | // OperationContext middleware. 61 | func (b *ResolvingBasis) OperationRouter(meta interface{}) OperationRouter { 62 | return b.adapter. 63 | OperationRouter(meta). 64 | WithDocument(b.doc). 65 | WithMiddleware(b.OperationContext()) 66 | } 67 | 68 | // OperationContext returns a middleware that adds OpenAPI operation context to 69 | // the request. 70 | func (b *ResolvingBasis) OperationContext() Middleware { 71 | return func(next http.Handler) http.Handler { 72 | return &resolvingOperationContext{ 73 | oc: &operationContext{ 74 | next: next, 75 | }, 76 | resolver: b.adapter.Resolver(b.doc), 77 | cache: b.cache, 78 | strict: b.strict, 79 | } 80 | } 81 | } 82 | 83 | // resolvingOperationContext is a middleware that resolves operation context 84 | // from the request and adds operation info to the request context. 85 | type resolvingOperationContext struct { 86 | oc *operationContext 87 | resolver Resolver 88 | cache map[string]operationInfo 89 | strict bool 90 | } 91 | 92 | func (mw *resolvingOperationContext) ServeHTTP(w http.ResponseWriter, req *http.Request) { 93 | id, ok := mw.resolver.Resolve(req) 94 | if !ok { 95 | if mw.strict { 96 | panic("operation context middleware: cannot resolve operation id from the request") 97 | } 98 | mw.oc.ServeHTTP(w, req, operationInfo{}, false) 99 | return 100 | } 101 | 102 | oi, ok := mw.cache[id] 103 | if !ok { 104 | if mw.strict { 105 | panic(fmt.Sprintf("operation context middleware: cannot find operation info by the operation id %q", id)) 106 | } 107 | mw.oc.ServeHTTP(w, req, operationInfo{}, false) 108 | return 109 | } 110 | 111 | mw.oc.ServeHTTP(w, req, oi, true) 112 | } 113 | 114 | // PathParamsContext returns a middleware that provides path parameters 115 | // as request context values. With this middleware, handlers can call 116 | // GetPathParam(req, "foo") to get typed value of path parameter "foo". 117 | func (b *ResolvingBasis) PathParamsContext() Middleware { 118 | ex := b.adapter.PathParamExtractor() 119 | 120 | return func(next http.Handler) http.Handler { 121 | return &resolvingPathParamExtractor{ 122 | next: &pathParamExtractor{ 123 | next: next, 124 | extractor: ex, 125 | }, 126 | strict: b.strict, 127 | } 128 | } 129 | } 130 | 131 | // resolvingPathParamExtractor is a middleware that resolves operation context 132 | // from the request and enables path parameter extractor middleware. 133 | type resolvingPathParamExtractor struct { 134 | next *pathParamExtractor 135 | 136 | // strict enforces validation. If false, then validation is not 137 | // applied to requests without operation context. 138 | strict bool 139 | } 140 | 141 | func (mw *resolvingPathParamExtractor) ServeHTTP(w http.ResponseWriter, req *http.Request) { 142 | oi, ok := getOperationInfo(req) 143 | if !ok { 144 | if mw.strict { 145 | panic("path params context middleware: cannot find operation info in the request context") 146 | } 147 | mw.next.ServeHTTP(w, req, nil, false) 148 | return 149 | } 150 | 151 | mw.next.ServeHTTP(w, req, oi.params, true) 152 | } 153 | 154 | // QueryValidator returns a middleware that validates request query parameters. 155 | func (b *ResolvingBasis) QueryValidator(opts ...MiddlewareOption) Middleware { 156 | options := parseMiddlewareOptions(opts...) 157 | if options.problemHandler == nil { 158 | options.problemHandler = newProblemHandlerErrorResponder() 159 | } 160 | 161 | return func(next http.Handler) http.Handler { 162 | return &resolvingQueryValidator{ 163 | qv: &queryValidator{ 164 | next: next, 165 | problemHandler: options.problemHandler, 166 | continueOnProblem: options.continueOnProblem, 167 | }, 168 | strict: b.strict, 169 | } 170 | } 171 | } 172 | 173 | // resolvingQueryValidator is a middleware that resolves operation context 174 | // from the request and validates request query. 175 | type resolvingQueryValidator struct { 176 | qv *queryValidator 177 | 178 | // strict enforces validation. If false, then validation is not 179 | // applied to requests without operation context. 180 | strict bool 181 | } 182 | 183 | func (mw *resolvingQueryValidator) ServeHTTP(w http.ResponseWriter, req *http.Request) { 184 | oi, ok := getOperationInfo(req) 185 | if !ok { 186 | if mw.strict { 187 | panic("query validator middleware: cannot find operation info in the request context") 188 | } 189 | mw.qv.ServeHTTP(w, req, nil, false) 190 | return 191 | } 192 | 193 | mw.qv.ServeHTTP(w, req, oi.params, true) 194 | } 195 | 196 | // RequestContentTypeValidator returns a middleware that validates 197 | // Content-Type header of the request. 198 | // 199 | // In case of validation error, this middleware will respond with 200 | // either 406 or 415. 201 | func (b *ResolvingBasis) RequestContentTypeValidator(opts ...MiddlewareOption) Middleware { 202 | return func(next http.Handler) http.Handler { 203 | return &resolvingRequestContentTypeValidator{ 204 | rctv: &requestContentTypeValidator{ 205 | next: next, 206 | }, 207 | strict: b.strict, 208 | } 209 | } 210 | } 211 | 212 | type resolvingRequestContentTypeValidator struct { 213 | rctv *requestContentTypeValidator 214 | 215 | // strict enforces validation. If false, then validation is not 216 | // applied to requests without operation context. 217 | strict bool 218 | } 219 | 220 | func (mw *resolvingRequestContentTypeValidator) ServeHTTP(w http.ResponseWriter, req *http.Request) { 221 | oi, ok := getOperationInfo(req) 222 | if !ok { 223 | if mw.strict { 224 | panic("request content type validator middleware: cannot find operation info in the request context") 225 | } 226 | mw.rctv.ServeHTTP(w, req, nil, nil, false) 227 | return 228 | } 229 | 230 | mw.rctv.ServeHTTP(w, req, oi.consumes, oi.produces, true) 231 | } 232 | 233 | // RequestBodyValidator returns a middleware that validates request body. 234 | func (b *ResolvingBasis) RequestBodyValidator(opts ...MiddlewareOption) Middleware { 235 | options := parseMiddlewareOptions(opts...) 236 | if options.problemHandler == nil { 237 | options.problemHandler = newProblemHandlerErrorResponder() 238 | } 239 | 240 | return func(next http.Handler) http.Handler { 241 | return &resolvingRequestBodyValidator{ 242 | rbv: &requestBodyValidator{ 243 | next: next, 244 | jsonSelectors: options.jsonSelectors, 245 | problemHandler: options.problemHandler, 246 | continueOnProblem: options.continueOnProblem, 247 | }, 248 | strict: b.strict, 249 | } 250 | } 251 | } 252 | 253 | type resolvingRequestBodyValidator struct { 254 | rbv *requestBodyValidator 255 | 256 | // strict enforces validation. If false, then validation is not 257 | // applied to requests without operation context. 258 | strict bool 259 | } 260 | 261 | func (mw *resolvingRequestBodyValidator) ServeHTTP(w http.ResponseWriter, req *http.Request) { 262 | oi, ok := getOperationInfo(req) 263 | if !ok { 264 | if mw.strict { 265 | panic("request body validator middleware: cannot find operation info in the request context") 266 | } 267 | mw.rbv.ServeHTTP(w, req, nil, false) 268 | return 269 | } 270 | 271 | mw.rbv.ServeHTTP(w, req, oi.params, true) 272 | } 273 | 274 | // ResponseContentTypeValidator returns a middleware that validates 275 | // Content-Type header of the response. 276 | func (b *ResolvingBasis) ResponseContentTypeValidator(opts ...MiddlewareOption) Middleware { 277 | options := parseMiddlewareOptions(opts...) 278 | if options.problemHandler == nil { 279 | options.problemHandler = newProblemHandlerWarnLogger("response") 280 | } 281 | 282 | return func(next http.Handler) http.Handler { 283 | return &resolvingResponseContentTypeValidator{ 284 | rctv: &responseContentTypeValidator{ 285 | next: next, 286 | problemHandler: options.problemHandler, 287 | }, 288 | strict: b.strict, 289 | } 290 | } 291 | } 292 | 293 | type resolvingResponseContentTypeValidator struct { 294 | rctv *responseContentTypeValidator 295 | 296 | // strict enforces validation. If false, then validation is not 297 | // applied to requests without operation context. 298 | strict bool 299 | } 300 | 301 | func (mw *resolvingResponseContentTypeValidator) ServeHTTP(w http.ResponseWriter, req *http.Request) { 302 | oi, ok := getOperationInfo(req) 303 | if !ok { 304 | if mw.strict { 305 | panic("response content type validator middleware: cannot find operation info in the request context") 306 | } 307 | mw.rctv.ServeHTTP(w, req, nil, false) 308 | return 309 | } 310 | 311 | mw.rctv.ServeHTTP(w, req, oi.produces, true) 312 | } 313 | 314 | // ResponseBodyValidator returns a middleware that validates response body. 315 | func (b *ResolvingBasis) ResponseBodyValidator(opts ...MiddlewareOption) Middleware { 316 | options := parseMiddlewareOptions(opts...) 317 | if options.problemHandler == nil { 318 | options.problemHandler = newProblemHandlerWarnLogger("response") 319 | } 320 | 321 | return func(next http.Handler) http.Handler { 322 | return &resolvingResponseBodyValidator{ 323 | rbv: &responseBodyValidator{ 324 | next: next, 325 | jsonSelectors: options.jsonSelectors, 326 | problemHandler: options.problemHandler, 327 | }, 328 | strict: b.strict, 329 | } 330 | } 331 | } 332 | 333 | type resolvingResponseBodyValidator struct { 334 | rbv *responseBodyValidator 335 | 336 | // strict enforces validation. If false, then validation is not 337 | // applied to requests without operation context. 338 | strict bool 339 | } 340 | 341 | func (mw *resolvingResponseBodyValidator) ServeHTTP(w http.ResponseWriter, req *http.Request) { 342 | oi, ok := getOperationInfo(req) 343 | if !ok { 344 | if mw.strict { 345 | panic("response body validator middleware: cannot find operation info in the request context") 346 | } 347 | mw.rbv.ServeHTTP(w, req, nil, false) 348 | return 349 | } 350 | 351 | mw.rbv.ServeHTTP(w, req, oi.operation.Responses, true) 352 | } 353 | 354 | // ContextualMiddleware represents a middleware that works based on request 355 | // operation context. 356 | type ContextualMiddleware interface { 357 | ServeHTTP(w http.ResponseWriter, req *http.Request, op *Operation, ok bool) 358 | } 359 | 360 | // ContextualMiddleware returns a middleware that can work based on request 361 | // operation context which will be resolved by the basis. 362 | func (b *ResolvingBasis) ContextualMiddleware(m ContextualMiddleware) Middleware { 363 | return func(next http.Handler) http.Handler { 364 | return &resolvingContextualMiddleware{ 365 | next: m, 366 | strict: b.strict, 367 | } 368 | } 369 | } 370 | 371 | // resolvingContextualMiddleware is a contextual middleware that resolves 372 | // operation context from the request. 373 | type resolvingContextualMiddleware struct { 374 | next ContextualMiddleware 375 | 376 | // strict enforces validation. If false, then validation is not 377 | // applied to requests without operation context. 378 | strict bool 379 | } 380 | 381 | func (mw *resolvingContextualMiddleware) ServeHTTP(w http.ResponseWriter, req *http.Request) { 382 | oi, ok := getOperationInfo(req) 383 | if !ok { 384 | if mw.strict { 385 | panic("contextual middleware: cannot find operation info in the request context") 386 | } 387 | mw.next.ServeHTTP(w, req, nil, false) 388 | return 389 | } 390 | 391 | mw.next.ServeHTTP(w, req, wrapOperation(oi.operation), true) 392 | } 393 | -------------------------------------------------------------------------------- /cmd/oas-expand/README.md: -------------------------------------------------------------------------------- 1 | # Expand 2 | 3 | Expand is a CLI tool for expanding OAS files. It makes new specification file 4 | with all references expanded. Loading this new file is up to 100 times faster 5 | than loading regular (non-expanded) spec. So this may be used as a one-time 6 | action (for example on building docker image) to reduce time of all futher 7 | application starts. 8 | 9 | Install 10 | 11 | ```sh 12 | go get -u github.com/hypnoglow/oas2/cmd/oas-expand 13 | ``` 14 | 15 | Run to make specification file expanded 16 | 17 | ```sh 18 | oas-expand -target-dir=./cache spec.yaml 19 | ``` 20 | 21 | Cache directory can be passed to oas loader using `CacheDir` parameter 22 | 23 | ```go 24 | doc, err := oas.LoadSpec(specPath, CacheDir("./cache")) 25 | ``` 26 | 27 | You can easily see the difference 28 | 29 | ```go 30 | now := time.Now() 31 | oas.LoadSpec("./petstore.yaml") 32 | log.Printf("Spec parsed in %s\n", time.Since(now)) 33 | 34 | now = time.Now() 35 | oas.LoadSpec("./petstore.yaml", oas.CacheDir("./cache")) 36 | log.Printf("Expanded spec parsed in %s\n", time.Since(now)) 37 | ``` 38 | 39 | ``` 40 | 2018/02/13 18:47:08 Spec parsed in 1.224684788s 41 | 2018/02/13 18:47:08 Expanded spec parsed in 39.403587ms 42 | ``` -------------------------------------------------------------------------------- /cmd/oas-expand/expand.go: -------------------------------------------------------------------------------- 1 | // CLI utility that expands OAS file to reduce init time. 2 | package main 3 | 4 | import ( 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/hypnoglow/oas2" 11 | ) 12 | 13 | const help = `Expand OpenAPI specification 14 | 15 | Usage: 16 | oas-expand [FLAGS] 17 | 18 | Flags: 19 | -h, -help Print help message 20 | -t, -target-dir Save expanded spec to diretory 21 | ` 22 | 23 | func main() { 24 | flagHelp := flag.Bool("help", false, "Print help message") 25 | flagHelpShort := flag.Bool("h", false, "Print help message") 26 | flagTargetDir := flag.String("target-dir", "", "Output directory for expanded spec files") 27 | flagTargetDirShort := flag.String("t", "", "Output directory for expanded spec files") 28 | flag.Parse() 29 | 30 | if *flagHelp || *flagHelpShort { 31 | fmt.Println(help) 32 | os.Exit(0) 33 | } 34 | 35 | args := flag.Args() 36 | if len(args) != 1 { 37 | fmt.Println(help) 38 | os.Exit(1) 39 | } 40 | specFile := args[0] 41 | 42 | targetDir := *flagTargetDir 43 | if *flagTargetDirShort != "" { 44 | targetDir = *flagTargetDirShort 45 | } 46 | 47 | // Save expanded spec to file in dir 48 | if targetDir != "" { 49 | if _, err := oas.LoadFile(specFile, oas.LoadCacheDir(targetDir)); err != nil { 50 | fmt.Printf("Error: %s\n", err) 51 | os.Exit(1) 52 | } 53 | fmt.Println("Spec expanded successfully") 54 | os.Exit(0) 55 | } 56 | 57 | // Print expanded spec to stdout 58 | document, err := oas.LoadFile(specFile) 59 | if err != nil { 60 | fmt.Printf("Error: %s\n\n", err) 61 | os.Exit(1) 62 | } 63 | if err := json.NewEncoder(os.Stdout).Encode(document.Spec()); err != nil { 64 | fmt.Printf("Error: %s\n\n", err) 65 | os.Exit(1) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /convert/array.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import "fmt" 4 | 5 | const ( 6 | typeString = "string" 7 | typeInteger = "integer" 8 | typeNumber = "number" 9 | 10 | formatInt32 = "int32" 11 | formatInt64 = "int64" 12 | formatFloat = "float" 13 | formatDouble = "double" 14 | ) 15 | 16 | // Array converts array of strings according to type and format of array items type 17 | // described in OAS 2.0. 18 | // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject 19 | func Array(vals []string, itemsType, itemsFormat string) (value interface{}, err error) { 20 | switch itemsType { 21 | case typeString: 22 | switch itemsFormat { 23 | case "": 24 | return stringArray(vals) 25 | default: 26 | // For formats that are currently unsupported. 27 | return stringArray(vals) 28 | } 29 | case typeInteger: 30 | switch itemsFormat { 31 | case formatInt32: 32 | return int32Array(vals) 33 | case formatInt64: 34 | return int64Array(vals) 35 | default: 36 | // For formats that are currently unsupported. 37 | return int64Array(vals) 38 | } 39 | case typeNumber: 40 | switch itemsFormat { 41 | case formatFloat: 42 | return floatArray(vals) 43 | case formatDouble: 44 | return doubleArray(vals) 45 | default: 46 | // For formats that are currently unsupported. 47 | return doubleArray(vals) 48 | } 49 | default: 50 | return nil, fmt.Errorf("unsupported (not implemented yet?) items type %s for type array", itemsType) 51 | } 52 | } 53 | 54 | func stringArray(vals []string) (value interface{}, err error) { 55 | ps := make([]string, len(vals)) 56 | for i, v := range vals { 57 | p, err := Primitive(v, typeString, "") 58 | if err != nil { 59 | // This should actually never happen. 60 | return nil, err 61 | } 62 | ps[i] = p.(string) 63 | } 64 | return ps, nil 65 | } 66 | 67 | func int32Array(vals []string) (value interface{}, err error) { 68 | ps := make([]int32, len(vals)) 69 | for i, v := range vals { 70 | p, err := Primitive(v, typeInteger, formatInt32) 71 | if err != nil { 72 | return nil, err 73 | } 74 | ps[i] = p.(int32) 75 | } 76 | return ps, nil 77 | } 78 | 79 | func int64Array(vals []string) (value interface{}, err error) { 80 | ps := make([]int64, len(vals)) 81 | for i, v := range vals { 82 | p, err := Primitive(v, typeInteger, formatInt64) 83 | if err != nil { 84 | return nil, err 85 | } 86 | ps[i] = p.(int64) 87 | } 88 | return ps, nil 89 | } 90 | 91 | func floatArray(vals []string) (value interface{}, err error) { 92 | ps := make([]float32, len(vals)) 93 | for i, v := range vals { 94 | p, err := Primitive(v, typeNumber, formatFloat) 95 | if err != nil { 96 | return nil, err 97 | } 98 | ps[i] = p.(float32) 99 | } 100 | return ps, nil 101 | } 102 | 103 | func doubleArray(vals []string) (value interface{}, err error) { 104 | ps := make([]float64, len(vals)) 105 | for i, v := range vals { 106 | p, err := Primitive(v, typeNumber, formatDouble) 107 | if err != nil { 108 | return nil, err 109 | } 110 | ps[i] = p.(float64) 111 | } 112 | return ps, nil 113 | } 114 | -------------------------------------------------------------------------------- /convert/array_test.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func ExampleArray() { 9 | values := []string{"123", "456"} 10 | v, _ := Array(values, "integer", "int64") 11 | 12 | fmt.Printf("%#v", v) 13 | 14 | // Output: 15 | // []int64{123, 456} 16 | } 17 | 18 | func TestArray(t *testing.T) { 19 | t.Run("ok for string array", func(t *testing.T) { 20 | values := []string{"Nicolas", "Jonathan"} 21 | v, err := Array(values, "string", "") 22 | 23 | assertConversionResult(t, []string{"Nicolas", "Jonathan"}, v) 24 | assertConversionError(t, false, err) 25 | }) 26 | 27 | t.Run("ok for string array with any format", func(t *testing.T) { 28 | values := []string{"Nicolas", "Jonathan"} 29 | v, err := Array(values, "string", "phone") 30 | 31 | assertConversionResult(t, []string{"Nicolas", "Jonathan"}, v) 32 | assertConversionError(t, false, err) 33 | }) 34 | 35 | t.Run("ok for int64 array", func(t *testing.T) { 36 | values := []string{"123", "456"} 37 | 38 | v, err := Array(values, "integer", "int64") 39 | assertConversionResult(t, []int64{123, 456}, v) 40 | assertConversionError(t, false, err) 41 | }) 42 | 43 | t.Run("ok for int32 array", func(t *testing.T) { 44 | values := []string{"123", "456"} 45 | 46 | v, err := Array(values, "integer", "int32") 47 | assertConversionResult(t, []int32{123, 456}, v) 48 | assertConversionError(t, false, err) 49 | }) 50 | 51 | t.Run("ok for int array with any other format", func(t *testing.T) { 52 | values := []string{"123", "456"} 53 | 54 | v, err := Array(values, "integer", "years") 55 | assertConversionResult(t, []int64{123, 456}, v) 56 | assertConversionError(t, false, err) 57 | }) 58 | 59 | t.Run("ok for float array", func(t *testing.T) { 60 | values := []string{"123.456", "456.123", "100"} 61 | 62 | v, err := Array(values, "number", "float") 63 | assertConversionResult(t, []float32{123.456, 456.123, 100.0}, v) 64 | assertConversionError(t, false, err) 65 | }) 66 | 67 | t.Run("ok for double array", func(t *testing.T) { 68 | values := []string{"123.456", "456.123", "100"} 69 | 70 | v, err := Array(values, "number", "double") 71 | assertConversionResult(t, []float64{123.456, 456.123, 100.0}, v) 72 | assertConversionError(t, false, err) 73 | }) 74 | 75 | t.Run("ok for float array of any other format", func(t *testing.T) { 76 | values := []string{"123.456", "456.123", "100"} 77 | 78 | v, err := Array(values, "number", "unknown-format") 79 | assertConversionResult(t, []float64{123.456, 456.123, 100.0}, v) 80 | assertConversionError(t, false, err) 81 | }) 82 | 83 | t.Run("fail on mixed types in int64 array", func(t *testing.T) { 84 | values := []string{"123", "Max"} 85 | 86 | v, err := Array(values, "integer", "int64") 87 | assertConversionResult(t, nil, v) 88 | assertConversionError(t, true, err) 89 | }) 90 | 91 | t.Run("fail on mixed types in int32 array", func(t *testing.T) { 92 | values := []string{"123", "Max"} 93 | 94 | v, err := Array(values, "integer", "int32") 95 | assertConversionResult(t, nil, v) 96 | assertConversionError(t, true, err) 97 | }) 98 | 99 | t.Run("fail on mixed types in float array", func(t *testing.T) { 100 | values := []string{"123.456", "Max"} 101 | 102 | v, err := Array(values, "number", "float") 103 | assertConversionResult(t, nil, v) 104 | assertConversionError(t, true, err) 105 | }) 106 | 107 | t.Run("fail on mixed types in double array", func(t *testing.T) { 108 | values := []string{"123.456", "Max"} 109 | 110 | v, err := Array(values, "number", "double") 111 | assertConversionResult(t, nil, v) 112 | assertConversionError(t, true, err) 113 | }) 114 | 115 | t.Run("fail on unsupported type", func(t *testing.T) { 116 | values := []string{"true", "false"} 117 | 118 | v, err := Array(values, "boolean", "") 119 | assertConversionResult(t, nil, v) 120 | assertConversionError(t, true, err) 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /convert/convert.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/go-openapi/spec" 9 | ) 10 | 11 | // Parameter converts parameter's value(s) according to parameter's type 12 | // and format. Type and format MUST match OAS 2.0. 13 | // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject 14 | func Parameter(vals []string, param *spec.Parameter) (value interface{}, err error) { 15 | if param.Type == "array" { 16 | if param.Items == nil { 17 | return nil, fmt.Errorf("type array has no `items` field") 18 | } 19 | 20 | // Array can be represented in different ways based on 21 | // collectionFormat property 22 | if len(vals) != 0 { 23 | switch param.SimpleSchema.CollectionFormat { 24 | case "ssv": 25 | // Space-separated values 26 | vals = strings.Split(vals[0], " ") 27 | case "tsv": 28 | // Tab-separated values 29 | vals = strings.Split(vals[0], "\t") 30 | case "pipes": 31 | // Pipe-separated values 32 | vals = strings.Split(vals[0], "|") 33 | case "multi": 34 | // Multiple parameter instances rather than multiple values 35 | // Do nothing, values are already represented as an array in vals 36 | default: // "csv" 37 | // Comma-separated values 38 | vals = strings.Split(vals[0], ",") 39 | } 40 | } 41 | 42 | return Array(vals, param.Items.Type, param.Items.Format) 43 | } 44 | 45 | if param.Type == "file" { 46 | // TODO 47 | return nil, fmt.Errorf("type %s: NOT IMPLEMENTED", param.Type) 48 | } 49 | 50 | if len(vals) != 1 { 51 | return nil, fmt.Errorf( 52 | "values count is %d, want 1", 53 | len(vals), 54 | ) 55 | } 56 | 57 | return Primitive(vals[0], param.Type, param.Format) 58 | } 59 | 60 | // Primitive converts string values according to type and format described 61 | // in OAS 2.0. 62 | // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#parameterObject 63 | func Primitive(val string, typ, format string) (value interface{}, err error) { 64 | switch typ { 65 | case "string": 66 | return convertString(val, format) 67 | case "number": 68 | return convertNumber(val, format) 69 | case "integer": 70 | return convertInteger(val, format) 71 | case "boolean": 72 | return convertBoolean(val) 73 | default: 74 | return nil, fmt.Errorf( 75 | "unknown type: %s", 76 | typ, 77 | ) 78 | } 79 | } 80 | 81 | var evaluatesAsTrue = map[string]struct{}{ 82 | "true": struct{}{}, 83 | "1": struct{}{}, 84 | "yes": struct{}{}, 85 | "ok": struct{}{}, 86 | "y": struct{}{}, 87 | "on": struct{}{}, 88 | "selected": struct{}{}, 89 | "checked": struct{}{}, 90 | "t": struct{}{}, 91 | "enabled": struct{}{}, 92 | } 93 | 94 | var evaluatesAsFalse = map[string]struct{}{ 95 | "false": struct{}{}, 96 | "0": struct{}{}, 97 | "no": struct{}{}, 98 | "n": struct{}{}, 99 | "off": struct{}{}, 100 | "f": struct{}{}, 101 | "disabled": struct{}{}, 102 | } 103 | 104 | func convertString(val, format string) (interface{}, error) { 105 | switch format { 106 | case "": 107 | return val, nil 108 | case "partial-time", "uuid": 109 | // For now, return as-is. 110 | return val, nil 111 | default: 112 | // TODO: parse formats byte, binary, date, date-time 113 | return nil, fmt.Errorf( 114 | "unknown format %s for type string", 115 | format, 116 | ) 117 | } 118 | } 119 | 120 | func convertInteger(val, format string) (interface{}, error) { 121 | switch format { 122 | case "int32": 123 | i, err := strconv.ParseInt(val, 10, 32) 124 | if err != nil { 125 | return nil, fmt.Errorf("cannot convert %v to int32", val) 126 | } 127 | return int32(i), nil 128 | case "int64": 129 | fallthrough 130 | case "": 131 | i, err := strconv.ParseInt(val, 10, 64) 132 | if err != nil { 133 | return nil, fmt.Errorf("cannot convert %v to int64", val) 134 | } 135 | return i, nil 136 | default: 137 | return nil, fmt.Errorf( 138 | "unknown format %s for type integer", 139 | format, 140 | ) 141 | } 142 | } 143 | 144 | func convertNumber(val, format string) (interface{}, error) { 145 | switch format { 146 | case "float": 147 | f, err := strconv.ParseFloat(val, 32) 148 | if err != nil { 149 | return nil, fmt.Errorf("cannot convert %v to float", val) 150 | } 151 | return float32(f), nil 152 | case "double": 153 | fallthrough 154 | case "": 155 | f, err := strconv.ParseFloat(val, 64) 156 | if err != nil { 157 | return nil, fmt.Errorf("cannot convert %v to double", val) 158 | } 159 | return f, nil 160 | default: 161 | return nil, fmt.Errorf( 162 | "unknown format %s for type integer", 163 | format, 164 | ) 165 | } 166 | } 167 | 168 | func convertBoolean(val string) (interface{}, error) { 169 | v := strings.ToLower(val) 170 | if _, ok := evaluatesAsTrue[v]; ok { 171 | return true, nil 172 | } 173 | if _, ok := evaluatesAsFalse[v]; ok { 174 | return false, nil 175 | } 176 | return false, fmt.Errorf("unknown format %s for type boolean", val) 177 | } 178 | -------------------------------------------------------------------------------- /convert/convert_test.go: -------------------------------------------------------------------------------- 1 | package convert 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/go-openapi/spec" 8 | ) 9 | 10 | func TestParameter(t *testing.T) { 11 | t.Run("ok for primitive type", func(t *testing.T) { 12 | values := []string{"John"} 13 | param := spec.QueryParam("name").Typed("string", "") 14 | 15 | v, err := Parameter(values, param) 16 | assertConversionResult(t, "John", v) 17 | assertConversionError(t, false, err) 18 | }) 19 | 20 | t.Run("ok for string array", func(t *testing.T) { 21 | values := []string{"Nicolas", "Jonathan"} 22 | param := spec.QueryParam("names").Typed("array", "") 23 | param.Items = spec.NewItems().Typed("string", "") 24 | param.SimpleSchema.CollectionFormat = "multi" 25 | 26 | v, err := Parameter(values, param) 27 | assertConversionResult(t, []string{"Nicolas", "Jonathan"}, v) 28 | assertConversionError(t, false, err) 29 | }) 30 | 31 | t.Run("ok for space-separated array in string", func(t *testing.T) { 32 | values := []string{"Peter Lois"} 33 | param := spec.QueryParam("names").Typed("array", "") 34 | param.Items = spec.NewItems().Typed("string", "") 35 | param.SimpleSchema.CollectionFormat = "ssv" 36 | 37 | v, err := Parameter(values, param) 38 | assertConversionResult(t, []string{"Peter", "Lois"}, v) 39 | assertConversionError(t, false, err) 40 | }) 41 | 42 | t.Run("ok for tab-separated array in string", func(t *testing.T) { 43 | values := []string{"Brian\tStewie"} 44 | param := spec.QueryParam("names").Typed("array", "") 45 | param.Items = spec.NewItems().Typed("string", "") 46 | param.SimpleSchema.CollectionFormat = "tsv" 47 | 48 | v, err := Parameter(values, param) 49 | assertConversionResult(t, []string{"Brian", "Stewie"}, v) 50 | assertConversionError(t, false, err) 51 | }) 52 | 53 | t.Run("ok for pipe-separated array in string", func(t *testing.T) { 54 | values := []string{"Meg|Chris"} 55 | param := spec.QueryParam("names").Typed("array", "") 56 | param.Items = spec.NewItems().Typed("string", "") 57 | param.SimpleSchema.CollectionFormat = "pipes" 58 | 59 | v, err := Parameter(values, param) 60 | assertConversionResult(t, []string{"Meg", "Chris"}, v) 61 | assertConversionError(t, false, err) 62 | }) 63 | 64 | t.Run("ok for comma-separated array in string", func(t *testing.T) { 65 | values := []string{"Stan,Francine"} 66 | param := spec.QueryParam("names").Typed("array", "") 67 | param.Items = spec.NewItems().Typed("string", "") 68 | param.SimpleSchema.CollectionFormat = "csv" 69 | 70 | v, err := Parameter(values, param) 71 | assertConversionResult(t, []string{"Stan", "Francine"}, v) 72 | assertConversionError(t, false, err) 73 | }) 74 | 75 | t.Run("ok for comma-separated array in string as a default behavior", func(t *testing.T) { 76 | values := []string{"Steve,Hayley"} 77 | param := spec.QueryParam("names").Typed("array", "") 78 | param.Items = spec.NewItems().Typed("string", "") 79 | 80 | v, err := Parameter(values, param) 81 | assertConversionResult(t, []string{"Steve", "Hayley"}, v) 82 | assertConversionError(t, false, err) 83 | }) 84 | 85 | t.Run("fail for array that has no items type", func(t *testing.T) { 86 | values := []string{"does not matter"} 87 | param := spec.QueryParam("names").Typed("array", "") 88 | 89 | v, err := Parameter(values, param) 90 | assertConversionResult(t, nil, v) 91 | assertConversionError(t, true, err) 92 | }) 93 | 94 | t.Run("fail for file (not implemented yet)", func(t *testing.T) { 95 | values := []string{"does not matter"} 96 | param := spec.FormDataParam("photo").Typed("file", "") 97 | 98 | v, err := Parameter(values, param) 99 | assertConversionResult(t, nil, v) 100 | assertConversionError(t, true, err) 101 | }) 102 | 103 | t.Run("fail for multiple values on primitive type", func(t *testing.T) { 104 | values := []string{"John", "Edvard"} 105 | param := spec.QueryParam("name").Typed("string", "") 106 | 107 | v, err := Parameter(values, param) 108 | assertConversionResult(t, nil, v) 109 | assertConversionError(t, true, err) 110 | }) 111 | } 112 | 113 | func assertConversionResult(t *testing.T, expectedValue interface{}, v interface{}) { 114 | t.Helper() 115 | 116 | if !reflect.DeepEqual(expectedValue, v) { 117 | t.Errorf( 118 | "Expected value to be %v (%T) but got %v (%T)", 119 | expectedValue, 120 | expectedValue, 121 | v, 122 | v, 123 | ) 124 | } 125 | } 126 | 127 | func assertConversionError(t *testing.T, expectError bool, err error) { 128 | t.Helper() 129 | 130 | if err != nil && !expectError { 131 | t.Errorf("Unexpected error: %v", err) 132 | } 133 | if err == nil && expectError { 134 | t.Error("Expected error, but got nil") 135 | } 136 | } 137 | 138 | func TestPrimitive(t *testing.T) { 139 | cases := []struct { 140 | value string 141 | typ string 142 | format string 143 | expectedValue interface{} 144 | expectError bool 145 | }{ 146 | { 147 | value: "Igor", 148 | typ: "string", 149 | format: "", 150 | expectedValue: "Igor", 151 | }, 152 | { 153 | value: "123", 154 | typ: "integer", 155 | format: "int32", 156 | expectedValue: int32(123), 157 | }, 158 | { 159 | value: "456", 160 | typ: "integer", 161 | format: "int64", 162 | expectedValue: int64(456), 163 | }, 164 | { 165 | value: "44.44", 166 | typ: "number", 167 | format: "float", 168 | expectedValue: float32(44.44), 169 | }, 170 | { 171 | value: "55.55", 172 | typ: "number", 173 | format: "double", 174 | expectedValue: float64(55.55), 175 | }, 176 | { 177 | value: "true", 178 | typ: "boolean", 179 | expectedValue: true, 180 | }, 181 | { 182 | value: "1", 183 | typ: "boolean", 184 | expectedValue: true, 185 | }, 186 | { 187 | value: "yes", 188 | typ: "boolean", 189 | expectedValue: true, 190 | }, 191 | { 192 | value: "false", 193 | typ: "boolean", 194 | expectedValue: false, 195 | }, 196 | { 197 | // unknown string format 198 | value: "some", 199 | typ: "string", 200 | format: "xml", 201 | expectError: true, 202 | }, 203 | { 204 | // unknown number format 205 | value: "$15.50", 206 | typ: "number", 207 | format: "currency", 208 | expectError: true, 209 | }, 210 | { 211 | // unknown integer format 212 | value: "i15", 213 | typ: "integer", 214 | format: "imaginary", 215 | expectError: true, 216 | }, 217 | { 218 | // wrong value for number float 219 | value: "44.44abc", 220 | typ: "number", 221 | format: "float", 222 | expectError: true, 223 | }, 224 | { 225 | // wrong value for number double 226 | value: "55.55abc", 227 | typ: "number", 228 | format: "double", 229 | expectError: true, 230 | }, 231 | { 232 | // wrong value for integer int32 233 | value: "123abc", 234 | typ: "integer", 235 | format: "int32", 236 | expectError: true, 237 | }, 238 | { 239 | // wrong value for integer int64 240 | value: "456abc", 241 | typ: "integer", 242 | format: "int64", 243 | expectError: true, 244 | }, 245 | { 246 | // unknown type 247 | value: "a", 248 | typ: "char", 249 | expectError: true, 250 | }, 251 | } 252 | 253 | for _, c := range cases { 254 | v, err := Primitive(c.value, c.typ, c.format) 255 | if err != nil && !c.expectError { 256 | t.Errorf("Unexpected error: %v", err) 257 | } 258 | if err == nil && c.expectError { 259 | t.Error("Expected error, but got nil") 260 | } 261 | 262 | if !reflect.DeepEqual(c.expectedValue, v) { 263 | t.Errorf( 264 | "Expected value to be %v (%T) but got %v (%T)", 265 | c.expectedValue, 266 | c.expectedValue, 267 | v, 268 | v, 269 | ) 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /decode.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "reflect" 9 | 10 | "github.com/go-openapi/spec" 11 | 12 | "github.com/hypnoglow/oas2/convert" 13 | ) 14 | 15 | const ( 16 | tag = "oas" 17 | ) 18 | 19 | // DecodeQuery decodes all query params by request operation spec to the dst. 20 | func DecodeQuery(req *http.Request, dst interface{}) error { 21 | oi, ok := getOperationInfo(req) 22 | if ok { 23 | return DecodeQueryParams(oi.params, req.URL.Query(), dst) 24 | } 25 | 26 | return errors.New("decode query: cannot find OpenAPI operation info in the request context") 27 | } 28 | 29 | // DecodeQueryParams decodes query parameters by their spec to the dst. 30 | func DecodeQueryParams(ps []spec.Parameter, q url.Values, dst interface{}) error { 31 | dv := reflect.ValueOf(dst) 32 | if dv.Kind() != reflect.Ptr { 33 | return fmt.Errorf("dst is not a pointer to struct (cannot modify)") 34 | } 35 | 36 | dv = dv.Elem() 37 | if dv.Kind() != reflect.Struct { 38 | return fmt.Errorf("dst is not a pointer to struct (cannot modify)") 39 | } 40 | 41 | fields := fieldMap(dv) 42 | 43 | for _, p := range ps { 44 | // No such tag in struct - no need to populate. 45 | f, ok := fields[p.Name] 46 | if !ok { 47 | continue 48 | } 49 | 50 | vals, ok := q[p.Name] 51 | if !ok { 52 | // No such value in query. 53 | // If there is default - use it, otherwise skip this value. 54 | if p.Default == nil { 55 | continue 56 | } 57 | 58 | // Default value can be in a weird format internally, e.g. 59 | // when spec gets parsed default value for integer can be of 60 | // type float64. So we cannot assign it directly. We need to 61 | // proceed with conversion procedure. 62 | vals = []string{fmt.Sprintf("%v", p.Default)} 63 | } 64 | 65 | // Convert value by type+format in parameter. 66 | v, err := convert.Parameter(vals, &p) 67 | if err != nil { 68 | if p.Format != "" { 69 | return fmt.Errorf( 70 | "cannot use values %v as parameter %s with type %s and format %s", 71 | vals, 72 | p.Name, 73 | p.Type, 74 | p.Format, 75 | ) 76 | } 77 | return fmt.Errorf( 78 | "cannot use values %v as parameter %s with type %s", 79 | vals, 80 | p.Name, 81 | p.Type, 82 | ) 83 | } 84 | 85 | if err := set(v, f, dv); err != nil { 86 | return err 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func set(v interface{}, f reflect.StructField, dst reflect.Value) error { 94 | // Check if tag in struct can accept value of type v. 95 | if !isAssignable(f, v) { 96 | return fmt.Errorf("value of type %s is not assignable to field %s of type %s", reflect.TypeOf(v).String(), f.Name, f.Type.String()) 97 | } 98 | 99 | fieldVal := dst.FieldByName(f.Name) 100 | if !fieldVal.CanSet() { 101 | return fmt.Errorf("field %s of type %s is not settable", f.Name, dst.Type().Name()) 102 | } 103 | 104 | if f.Type.Kind() == reflect.Ptr { 105 | fieldVal.Set(reflect.New(f.Type.Elem())) 106 | fieldVal.Elem().Set(reflect.ValueOf(v)) 107 | } else { 108 | fieldVal.Set(reflect.ValueOf(v)) 109 | } 110 | return nil 111 | } 112 | 113 | func isAssignable(field reflect.StructField, value interface{}) bool { 114 | if field.Type.Kind() == reflect.Ptr { 115 | return reflect.TypeOf(value).AssignableTo(field.Type.Elem()) 116 | } 117 | 118 | return reflect.TypeOf(value).AssignableTo(field.Type) 119 | } 120 | 121 | // fieldMap returns v fields mapped by their tags. 122 | func fieldMap(rv reflect.Value) map[string]reflect.StructField { 123 | rt := rv.Type() 124 | 125 | m := make(map[string]reflect.StructField) 126 | n := rt.NumField() 127 | for i := 0; i < n; i++ { 128 | f := rt.Field(i) 129 | tag, ok := f.Tag.Lookup(tag) 130 | if !ok { 131 | continue 132 | } 133 | 134 | m[tag] = f 135 | } 136 | 137 | return m 138 | } 139 | -------------------------------------------------------------------------------- /decode_integration_test.go: -------------------------------------------------------------------------------- 1 | package oas_test 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/hypnoglow/oas2" 13 | ) 14 | 15 | func TestDecodeQueryParams(t *testing.T) { 16 | doc, err := oas.LoadFile(getSpecPath(t)) 17 | assert.NoError(t, err) 18 | 19 | oas.RegisterAdapter("fake", fakeAdapter{}) 20 | 21 | basis := oas.NewResolvingBasis("fake", doc) 22 | h := basis.OperationContext()(getPetsHandler{}) 23 | 24 | w := httptest.NewRecorder() 25 | req := httptest.NewRequest(http.MethodGet, "/api/pets", nil) 26 | h.ServeHTTP(w, req) 27 | 28 | var result struct { 29 | Limit int64 `json:"limit"` 30 | } 31 | 32 | assert.Equal(t, http.StatusOK, w.Code) 33 | 34 | if err = json.NewDecoder(w.Body).Decode(&result); err != nil { 35 | t.Fatalf("Unexpected error: %v", err) 36 | } 37 | 38 | assert.EqualValues(t, 10, result.Limit) 39 | } 40 | 41 | type fakeAdapter struct{} 42 | 43 | func (fakeAdapter) Resolver(meta interface{}) oas.Resolver { 44 | return fakeResolver{} 45 | } 46 | 47 | func (fakeAdapter) OperationRouter(meta interface{}) oas.OperationRouter { 48 | panic("implement me") 49 | } 50 | 51 | func (fakeAdapter) PathParamExtractor() oas.PathParamExtractor { 52 | panic("implement me") 53 | } 54 | 55 | type fakeResolver struct{} 56 | 57 | func (fakeResolver) Resolve(req *http.Request) (string, bool) { 58 | return "getPets", true 59 | } 60 | 61 | type getPetsHandler struct{} 62 | 63 | func (getPetsHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 64 | var query struct { 65 | Limit int64 `json:"limit" oas:"limit"` 66 | } 67 | 68 | if err := oas.DecodeQuery(req, &query); err != nil { 69 | http.Error(w, err.Error(), http.StatusInternalServerError) 70 | return 71 | } 72 | 73 | if err := json.NewEncoder(w).Encode(query); err != nil { 74 | http.Error(w, err.Error(), http.StatusInternalServerError) 75 | } 76 | } 77 | 78 | func getSpecPath(t *testing.T) string { 79 | t.Helper() 80 | 81 | spec := ` 82 | swagger: "2.0" 83 | info: 84 | title: Test API 85 | version: 0.1.0 86 | basePath: "/api" 87 | paths: 88 | /pets: 89 | get: 90 | summary: Find pets 91 | operationId: getPets 92 | parameters: 93 | - name: limit 94 | in: query 95 | required: false 96 | type: integer 97 | format: int64 98 | default: 10 99 | responses: 100 | 200: 101 | description: Pets found 102 | schema: 103 | type: object 104 | properties: 105 | limit: 106 | title: Requested limit 107 | type: integer 108 | format: int64 109 | 500: 110 | description: "Internal Server Error" 111 | ` 112 | p := "/tmp/spec.yaml" 113 | 114 | // write to file 115 | if err := ioutil.WriteFile(p, []byte(spec), 0755); err != nil { 116 | t.Fatalf("Unexpected error: %v", err) 117 | } 118 | 119 | return p 120 | } 121 | -------------------------------------------------------------------------------- /decode_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "reflect" 7 | "testing" 8 | 9 | "github.com/go-openapi/spec" 10 | ) 11 | 12 | func ExampleDecodeQueryParams() { 13 | // In real app parameters will be taken from spec document (yaml or json). 14 | params := []spec.Parameter{ 15 | *spec.QueryParam("name").Typed("string", ""), 16 | *spec.QueryParam("age").Typed("integer", "int32"), 17 | *spec.QueryParam("loves_apples").Typed("boolean", ""). 18 | AsRequired(). 19 | WithDefault(true), 20 | } 21 | 22 | // In real app query will be taken from *http.Request. 23 | query := url.Values{"name": []string{"John"}, "age": []string{"27"}} 24 | 25 | type member struct { 26 | Name string `oas:"name"` 27 | Age int32 `oas:"age"` 28 | LovesApples bool `oas:"loves_apples"` 29 | } 30 | 31 | var m member 32 | err := DecodeQueryParams(params, query, &m) 33 | assertNoError(err) 34 | 35 | fmt.Printf("%#v", m) 36 | 37 | // Output: 38 | // oas.member{Name:"John", Age:27, LovesApples:true} 39 | } 40 | 41 | func TestDecodeQueryParams(t *testing.T) { 42 | type ( 43 | // nolint: megacheck 44 | user struct { 45 | Name string `oas:"name"` 46 | Sex string `oas:"sex"` 47 | FieldWithNoTag string 48 | notSettable string `oas:"not_settable"` 49 | NotMandatory *string `oas:"not_mandatory"` 50 | } 51 | 52 | member struct { 53 | Nickname string `oas:"nickname"` 54 | Age int32 `oas:"age"` 55 | LovesApples bool `oas:"loves_apples"` 56 | Height float32 `oas:"height"` 57 | } 58 | ) 59 | 60 | String := func(s string) *string { return &s } 61 | 62 | number := 1 63 | 64 | cases := []struct { 65 | ps []spec.Parameter 66 | q url.Values 67 | dst interface{} 68 | expectedData interface{} 69 | expectedError error 70 | }{ 71 | { 72 | // Simple value 73 | ps: []spec.Parameter{ 74 | { 75 | ParamProps: spec.ParamProps{ 76 | Name: "name", 77 | In: "query", 78 | }, 79 | SimpleSchema: spec.SimpleSchema{ 80 | Type: "string", 81 | }, 82 | }, 83 | }, 84 | q: url.Values{ 85 | "name": []string{"John"}, 86 | }, 87 | dst: &user{}, 88 | expectedData: &user{ 89 | Name: "John", 90 | }, 91 | }, 92 | { 93 | // query parameter that is not defined in struct 94 | ps: []spec.Parameter{ 95 | { 96 | ParamProps: spec.ParamProps{ 97 | Name: "name", 98 | In: "query", 99 | }, 100 | SimpleSchema: spec.SimpleSchema{ 101 | Type: "string", 102 | }, 103 | }, 104 | { 105 | ParamProps: spec.ParamProps{ 106 | Name: "birthdate", 107 | In: "query", 108 | }, 109 | SimpleSchema: spec.SimpleSchema{ 110 | Type: "string", 111 | }, 112 | }, 113 | }, 114 | q: url.Values{ 115 | "name": []string{"John"}, 116 | "birthdate": []string{"1970-01-01"}, 117 | }, 118 | dst: &user{}, 119 | expectedData: &user{ 120 | Name: "John", 121 | }, 122 | }, 123 | { 124 | // With default value 125 | ps: []spec.Parameter{ 126 | { 127 | ParamProps: spec.ParamProps{ 128 | Name: "name", 129 | In: "query", 130 | }, 131 | SimpleSchema: spec.SimpleSchema{ 132 | Type: "string", 133 | }, 134 | }, 135 | { 136 | ParamProps: spec.ParamProps{ 137 | Name: "sex", 138 | In: "query", 139 | }, 140 | SimpleSchema: spec.SimpleSchema{ 141 | Type: "string", 142 | Default: "Male", 143 | }, 144 | }, 145 | }, 146 | q: url.Values{ 147 | "name": []string{"John"}, 148 | }, 149 | dst: &user{}, 150 | expectedData: &user{ 151 | Name: "John", 152 | Sex: "Male", 153 | }, 154 | }, 155 | { 156 | // With default value of wrong type 157 | ps: []spec.Parameter{ 158 | { 159 | ParamProps: spec.ParamProps{ 160 | Name: "nickname", 161 | In: "query", 162 | }, 163 | SimpleSchema: spec.SimpleSchema{ 164 | Type: "string", 165 | }, 166 | }, 167 | { 168 | ParamProps: spec.ParamProps{ 169 | Name: "loves_apples", 170 | In: "query", 171 | }, 172 | SimpleSchema: spec.SimpleSchema{ 173 | Type: "boolean", 174 | Default: 123, 175 | }, 176 | }, 177 | }, 178 | q: url.Values{ 179 | "nickname": []string{"John"}, 180 | }, 181 | dst: &member{}, 182 | expectedData: &member{ 183 | Nickname: "John", 184 | }, 185 | expectedError: fmt.Errorf("cannot use values [123] as parameter loves_apples with type boolean"), 186 | }, 187 | { 188 | // Different types of query parameters 189 | ps: []spec.Parameter{ 190 | { 191 | ParamProps: spec.ParamProps{ 192 | Name: "nickname", 193 | In: "query", 194 | }, 195 | SimpleSchema: spec.SimpleSchema{ 196 | Type: "string", 197 | }, 198 | }, 199 | { 200 | ParamProps: spec.ParamProps{ 201 | Name: "age", 202 | In: "query", 203 | }, 204 | SimpleSchema: spec.SimpleSchema{ 205 | Type: "integer", 206 | Format: "int32", 207 | }, 208 | }, 209 | { 210 | ParamProps: spec.ParamProps{ 211 | Name: "loves_apples", 212 | In: "query", 213 | }, 214 | SimpleSchema: spec.SimpleSchema{ 215 | Type: "boolean", 216 | }, 217 | }, 218 | { 219 | ParamProps: spec.ParamProps{ 220 | Name: "height", 221 | In: "query", 222 | }, 223 | SimpleSchema: spec.SimpleSchema{ 224 | Type: "number", 225 | Format: "float", 226 | }, 227 | }, 228 | }, 229 | q: url.Values{ 230 | "nickname": []string{"Princess"}, 231 | "age": []string{"40"}, 232 | "loves_apples": []string{"yes"}, 233 | "height": []string{"185.5"}, 234 | }, 235 | dst: &member{}, 236 | expectedData: &member{ 237 | Nickname: "Princess", 238 | Age: 40, 239 | LovesApples: true, 240 | Height: 185.5, 241 | }, 242 | }, 243 | { 244 | // dst passed by value 245 | dst: member{}, 246 | expectedData: member{}, 247 | expectedError: fmt.Errorf("dst is not a pointer to struct (cannot modify)"), 248 | }, 249 | { 250 | // dst is not a pointer to struct 251 | dst: &number, 252 | expectedData: &number, 253 | expectedError: fmt.Errorf("dst is not a pointer to struct (cannot modify)"), 254 | }, 255 | { 256 | // value is not convertible 257 | ps: []spec.Parameter{ 258 | { 259 | ParamProps: spec.ParamProps{ 260 | Name: "age", 261 | In: "query", 262 | }, 263 | SimpleSchema: spec.SimpleSchema{ 264 | Type: "integer", 265 | Format: "int32", 266 | }, 267 | }, 268 | }, 269 | q: url.Values{ 270 | "age": []string{"Twenty Two"}, 271 | }, 272 | dst: &member{}, 273 | expectedData: &member{}, 274 | expectedError: fmt.Errorf( 275 | "cannot use values %v as parameter %s with type %s and format %s", 276 | []string{"Twenty Two"}, 277 | "age", 278 | "integer", 279 | "int32", 280 | ), 281 | }, 282 | // not settable field 283 | { 284 | ps: []spec.Parameter{ 285 | { 286 | ParamProps: spec.ParamProps{ 287 | Name: "not_settable", 288 | In: "query", 289 | }, 290 | SimpleSchema: spec.SimpleSchema{ 291 | Type: "string", 292 | }, 293 | }, 294 | }, 295 | q: url.Values{ 296 | "not_settable": []string{"Twenty Two"}, 297 | }, 298 | dst: &user{}, 299 | expectedData: &user{}, 300 | expectedError: fmt.Errorf( 301 | "field notSettable of type user is not settable", 302 | ), 303 | }, 304 | { 305 | // Pointer field 306 | ps: []spec.Parameter{ 307 | { 308 | ParamProps: spec.ParamProps{ 309 | Name: "not_mandatory", 310 | In: "query", 311 | }, 312 | SimpleSchema: spec.SimpleSchema{ 313 | Type: "string", 314 | }, 315 | }, 316 | }, 317 | q: url.Values{ 318 | "not_mandatory": []string{"I can be nil"}, 319 | }, 320 | dst: &user{}, 321 | expectedData: &user{ 322 | NotMandatory: String("I can be nil"), 323 | }, 324 | }, 325 | } 326 | 327 | for _, c := range cases { 328 | err := DecodeQueryParams(c.ps, c.q, c.dst) 329 | if !reflect.DeepEqual(c.expectedError, err) { 330 | t.Errorf("Expected error to be %v but got %v", c.expectedError, err) 331 | } 332 | 333 | if !reflect.DeepEqual(c.expectedData, c.dst) { 334 | t.Errorf("Expected dst to be %v but got %v", c.expectedData, c.dst) 335 | } 336 | } 337 | } 338 | 339 | func TestDecodeQueryIntegerParameterWithDefault(t *testing.T) { 340 | params := []spec.Parameter{ 341 | { 342 | ParamProps: spec.ParamProps{ 343 | Name: "limit", 344 | In: "query", 345 | }, 346 | SimpleSchema: spec.SimpleSchema{ 347 | Type: "integer", 348 | Format: "int64", 349 | Default: float64(10), 350 | }, 351 | }, 352 | } 353 | 354 | q := url.Values{} 355 | 356 | var input struct { 357 | Limit int64 `oas:"limit"` 358 | } 359 | 360 | if err := DecodeQueryParams(params, q, &input); err != nil { 361 | t.Fatalf("Unexpected error: %v", err) 362 | } 363 | 364 | if input.Limit != 10 { 365 | t.Fatalf("Expected limit to be 10 but got %v", input.Limit) 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package oas provides utilities to work with OpenAPI 2.0 specification 2 | // (aka Swagger). 3 | // 4 | // The purpose of this package is to provide utilities for building APIs 5 | // leveraging the OpenAPI specification in Go idiomatic way on top of `net/http`. 6 | // The package can handle request validation, request parameters decoding 7 | // and other routines. 8 | package oas 9 | -------------------------------------------------------------------------------- /e2e/README.md: -------------------------------------------------------------------------------- 1 | # e2e 2 | 3 | This directory contains end-to-end tests for oas2. 4 | -------------------------------------------------------------------------------- /e2e/middleware_order/doc.go: -------------------------------------------------------------------------------- 1 | package middleware_order 2 | -------------------------------------------------------------------------------- /e2e/middleware_order/main_test.go: -------------------------------------------------------------------------------- 1 | // +build e2e 2 | 3 | package middleware_order 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "encoding/json" 9 | "io/ioutil" 10 | "log" 11 | "net/http" 12 | "net/http/httptest" 13 | "testing" 14 | 15 | "github.com/gorilla/mux" 16 | "github.com/stretchr/testify/assert" 17 | 18 | "github.com/hypnoglow/oas2" 19 | _ "github.com/hypnoglow/oas2/adapter/gorilla/init" 20 | "github.com/hypnoglow/oas2/e2e/testdata" 21 | ) 22 | 23 | // TestMiddlewareExecutionOrder tests that middleware passed to the router 24 | // is executed in correct order. 25 | func TestMiddlewareExecutionOrder(t *testing.T) { 26 | doc := testdata.GreeterSpec(t) 27 | basis := oas.NewResolvingBasis("gorilla", doc) 28 | 29 | t.Run("middleware passed inline with RouterMiddleware()", func(t *testing.T) { 30 | buffer := &bytes.Buffer{} 31 | 32 | r := mux.NewRouter() 33 | err := basis.OperationRouter(r). 34 | WithOperationHandlers(map[string]http.Handler{ 35 | "greet": testdata.GreetHandler{}, 36 | }). 37 | WithMiddleware( 38 | // We are testing that RequestIDLogger will have access to the request id 39 | // in the request created by RequestID middleware. 40 | RequestID, 41 | RequestIDLogger(log.New(buffer, "", 0)), 42 | ).Build() 43 | assert.NoError(t, err) 44 | 45 | testRouterMiddleware(t, r, buffer) 46 | }) 47 | 48 | t.Run("middleware passed as two options with RouterMiddleware()", func(t *testing.T) { 49 | buffer := &bytes.Buffer{} 50 | 51 | r := mux.NewRouter() 52 | err := basis.OperationRouter(r). 53 | WithOperationHandlers(map[string]http.Handler{ 54 | "greet": testdata.GreetHandler{}, 55 | }). 56 | // We are testing that RequestIDLogger will have access to the request id 57 | // in the request created by RequestID middleware. 58 | WithMiddleware(RequestID). 59 | WithMiddleware(RequestIDLogger(log.New(buffer, "", 0))). 60 | Build() 61 | assert.NoError(t, err) 62 | 63 | testRouterMiddleware(t, r, buffer) 64 | }) 65 | } 66 | 67 | func testRouterMiddleware(t *testing.T, router *mux.Router, buf *bytes.Buffer) { 68 | t.Helper() 69 | 70 | srv := httptest.NewServer(router) 71 | defer srv.Close() 72 | 73 | resp, err := srv.Client().Get(srv.URL + "/api/greeting?name=Andrew") 74 | if err != nil { 75 | t.Fatalf("Unexpected error: %v", err) 76 | } 77 | defer resp.Body.Close() 78 | 79 | assert.Equal(t, http.StatusOK, resp.StatusCode) 80 | 81 | b, err := ioutil.ReadAll(resp.Body) 82 | assert.NoError(t, err) 83 | 84 | var reply struct { 85 | Greeting string `json:"greeting"` 86 | } 87 | 88 | if err := json.Unmarshal(b, &reply); err != nil { 89 | t.Fatalf("Unexpected error: %v", err) 90 | } 91 | 92 | expectedGreeting := "Hello, Andrew!" 93 | if reply.Greeting != expectedGreeting { 94 | t.Fatalf("Expected greeting to be %q but got %q", expectedGreeting, reply.Greeting) 95 | } 96 | 97 | expectedLogEntry := "request with id 1234567890\n" 98 | if buf.String() != expectedLogEntry { 99 | t.Fatalf("Expected log entry to be %q but got %q", expectedLogEntry, buf.String()) 100 | } 101 | } 102 | 103 | type ctxKeyRequestID struct{} 104 | 105 | func RequestID(next http.Handler) http.Handler { 106 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 107 | ctx := context.WithValue( 108 | req.Context(), 109 | ctxKeyRequestID{}, 110 | "1234567890", 111 | ) 112 | next.ServeHTTP(w, req.WithContext(ctx)) 113 | }) 114 | } 115 | 116 | func GetRequestID(req *http.Request) string { 117 | id, ok := req.Context().Value(ctxKeyRequestID{}).(string) 118 | if !ok { 119 | return "" 120 | } 121 | return id 122 | } 123 | 124 | func RequestIDLogger(log *log.Logger) func(next http.Handler) http.Handler { 125 | return func(next http.Handler) http.Handler { 126 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 127 | if id := GetRequestID(req); id != "" { 128 | log.Printf("request with id %s", id) 129 | } else { 130 | log.Printf("request with no id") 131 | } 132 | next.ServeHTTP(w, req) 133 | }) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /e2e/query_validator/doc.go: -------------------------------------------------------------------------------- 1 | package query_validator 2 | -------------------------------------------------------------------------------- /e2e/query_validator/main_test.go: -------------------------------------------------------------------------------- 1 | // +build e2e 2 | 3 | package query_validator 4 | 5 | import ( 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/gorilla/mux" 14 | "github.com/stretchr/testify/assert" 15 | 16 | "github.com/hypnoglow/oas2" 17 | _ "github.com/hypnoglow/oas2/adapter/gorilla/init" 18 | "github.com/hypnoglow/oas2/e2e/testdata" 19 | ) 20 | 21 | // TestQueryValidatorMiddleware tests that router created with query validator 22 | // middleware will validate query. 23 | func TestQueryValidatorMiddleware(t *testing.T) { 24 | doc := testdata.GreeterSpec(t) 25 | basis := oas.NewResolvingBasis("gorilla", doc) 26 | 27 | r := mux.NewRouter() 28 | err := basis.OperationRouter(r). 29 | WithOperationHandlers(map[string]http.Handler{ 30 | "greet": testdata.GreetHandler{}, 31 | }). 32 | WithMiddleware( 33 | basis.QueryValidator( 34 | oas.WithProblemHandler(oas.ProblemHandlerFunc(handleValidationError)), 35 | ), 36 | ). 37 | Build() 38 | assert.NoError(t, err) 39 | 40 | srv := httptest.NewServer(r) 41 | defer srv.Close() 42 | 43 | // We expect to get 400 error on empty name. 44 | resp, err := srv.Client().Get(srv.URL + "/api/greeting?name=") 45 | if err != nil { 46 | t.Fatalf("Unexpected error: %v", err) 47 | } 48 | defer resp.Body.Close() 49 | 50 | if resp.StatusCode != http.StatusBadRequest { 51 | t.Fatalf("Unexpected response status: %s", resp.Status) 52 | } 53 | 54 | b, err := ioutil.ReadAll(resp.Body) 55 | if err != nil { 56 | t.Fatalf("Unexpected error: %v", err) 57 | } 58 | if !strings.Contains(string(b), "name in query is required") { 59 | t.Fatalf("Unexpected response body") 60 | } 61 | } 62 | 63 | func handleValidationError(p oas.Problem) { 64 | p.ResponseWriter().WriteHeader(http.StatusBadRequest) 65 | io.WriteString(p.ResponseWriter(), p.Cause().Error()) 66 | } 67 | -------------------------------------------------------------------------------- /e2e/servespec/doc.go: -------------------------------------------------------------------------------- 1 | package servespec 2 | -------------------------------------------------------------------------------- /e2e/servespec/main_test.go: -------------------------------------------------------------------------------- 1 | // +build e2e 2 | 3 | package servespec 4 | 5 | import ( 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/gorilla/mux" 13 | 14 | "github.com/hypnoglow/oas2" 15 | "github.com/hypnoglow/oas2/e2e/testdata" 16 | ) 17 | 18 | // TestMiddlewareIsAppliedToServedSpec tests that middleware passed to the router 19 | // with Wrap option is applied to the router's served spec. 20 | func TestMiddlewareIsAppliedToServedSpec(t *testing.T) { 21 | doc := testdata.GreeterSpec(t) 22 | 23 | r := mux.NewRouter() 24 | r.Path("/api"). 25 | Methods(http.MethodGet). 26 | Handler(oas.NewDynamicSpecHandler(doc)) 27 | 28 | srv := httptest.NewServer(r) 29 | defer srv.Close() 30 | 31 | resp, err := srv.Client().Get(srv.URL + "/api") 32 | if err != nil { 33 | t.Fatalf("Unexpected error: %v", err) 34 | } 35 | defer resp.Body.Close() 36 | 37 | if resp.StatusCode != http.StatusOK { 38 | t.Fatalf("Unexpected response status: %s", resp.Status) 39 | } 40 | 41 | b, err := ioutil.ReadAll(resp.Body) 42 | if err != nil { 43 | t.Fatalf("Unexpected error: %v", err) 44 | } 45 | 46 | // a very simple check that spec is actually served 47 | if !strings.Contains(string(b), "swagger") { 48 | t.Fatalf("Expected reply to contain \"swagger\"") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /e2e/testdata/adder.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "path" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/hypnoglow/oas2" 13 | ) 14 | 15 | // AddHandler is a simple handler that sums two numbers. 16 | type AddHandler struct{} 17 | 18 | // ServeHTTP implements http.Handler. 19 | func (AddHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 20 | var query struct { 21 | A int64 `oas:"a"` 22 | B int64 `oas:"b"` 23 | } 24 | if err := oas.DecodeQuery(req, &query); err != nil { 25 | http.Error(w, err.Error(), http.StatusInternalServerError) 26 | return 27 | } 28 | 29 | if _, err := fmt.Fprintf(w, `{"sum":%d}`, query.A+query.B); err != nil { 30 | http.Error(w, err.Error(), http.StatusInternalServerError) 31 | return 32 | } 33 | } 34 | 35 | // TestAdder tests adder server. 36 | func TestAdder(t *testing.T, srv *httptest.Server) { 37 | t.Helper() 38 | 39 | resp, err := srv.Client().Get(srv.URL + "/api/adder/sum?a=1&b=2") 40 | if err != nil { 41 | t.Fatalf("Unexpected error: %v", err) 42 | } 43 | defer resp.Body.Close() 44 | 45 | b, err := ioutil.ReadAll(resp.Body) 46 | if err != nil { 47 | t.Fatalf("Unexpected error: %v", err) 48 | } 49 | 50 | if resp.StatusCode != http.StatusOK { 51 | t.Fatalf("Unexpected response, status=%s, body=%q", resp.Status, string(b)) 52 | } 53 | 54 | expected := `{"sum":3}` 55 | if string(b) != expected { 56 | t.Fatalf("Expected %q but got %q", expected, string(b)) 57 | } 58 | } 59 | 60 | // AdderSpec returns an OpenAPI spec for adder server. 61 | func AdderSpec(t *testing.T) *oas.Document { 62 | t.Helper() 63 | 64 | _, filename, _, _ := runtime.Caller(0) 65 | doc, err := oas.LoadFile(path.Join(path.Dir(filename), "adder.yaml")) 66 | if err != nil { 67 | t.Fatalf("Unexpected error: %v", err) 68 | } 69 | return doc 70 | } 71 | -------------------------------------------------------------------------------- /e2e/testdata/adder.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: "1.0.0" 4 | title: "Adder server" 5 | basePath: "/api/adder" 6 | paths: 7 | /sum: 8 | get: 9 | summary: "Adds two numbers" 10 | operationId: "add" 11 | produces: 12 | - "application/json" 13 | parameters: 14 | - in: query 15 | name: a 16 | description: The first number 17 | type: integer 18 | required: true 19 | - in: query 20 | name: b 21 | description: The second number 22 | type: integer 23 | required: true 24 | responses: 25 | 200: 26 | description: "Sum of two numbers" 27 | schema: 28 | type: object 29 | properties: 30 | sum: 31 | type: integer 32 | required: 33 | - sum 34 | 400: 35 | description: "Invalid input" 36 | 500: 37 | description: "Server error" 38 | -------------------------------------------------------------------------------- /e2e/testdata/greeter.go: -------------------------------------------------------------------------------- 1 | package testdata 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "net/http/httptest" 8 | "path" 9 | "runtime" 10 | "testing" 11 | 12 | "github.com/hypnoglow/oas2" 13 | ) 14 | 15 | // GreetHandler is a simple handler that greets using a name. 16 | type GreetHandler struct{} 17 | 18 | // ServeHTTP implements http.Handler. 19 | func (GreetHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 20 | var query struct { 21 | Name string `oas:"name"` 22 | } 23 | if err := oas.DecodeQuery(req, &query); err != nil { 24 | http.Error(w, err.Error(), http.StatusInternalServerError) 25 | return 26 | } 27 | 28 | if _, err := fmt.Fprintf(w, `{"greeting":"Hello, %s!"}`, query.Name); err != nil { 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | return 31 | } 32 | } 33 | 34 | // TestGreeter tests greeter server. 35 | func TestGreeter(t *testing.T, srv *httptest.Server) { 36 | t.Helper() 37 | 38 | resp, err := srv.Client().Get(srv.URL + "/api/greeting?name=Foo") 39 | if err != nil { 40 | t.Fatalf("Unexpected error: %v", err) 41 | } 42 | defer resp.Body.Close() 43 | 44 | b, err := ioutil.ReadAll(resp.Body) 45 | if err != nil { 46 | t.Fatalf("Unexpected error: %v", err) 47 | } 48 | 49 | if resp.StatusCode != http.StatusOK { 50 | t.Fatalf("Unexpected response, status=%s, body=%q", resp.Status, string(b)) 51 | } 52 | 53 | expected := `{"greeting":"Hello, Foo!"}` 54 | if string(b) != expected { 55 | t.Fatalf("Expected %q but got %q", expected, string(b)) 56 | } 57 | } 58 | 59 | // GreeterSpec returns an OpenAPI spec for greeter server. 60 | func GreeterSpec(t *testing.T) *oas.Document { 61 | t.Helper() 62 | 63 | _, filename, _, _ := runtime.Caller(0) 64 | doc, err := oas.LoadFile(path.Join(path.Dir(filename), "greeter.yaml")) 65 | if err != nil { 66 | t.Fatalf("Unexpected error: %v", err) 67 | } 68 | return doc 69 | } 70 | -------------------------------------------------------------------------------- /e2e/testdata/greeter.yaml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | version: "1.0.0" 4 | title: "Greeter server" 5 | basePath: "/api" 6 | paths: 7 | /greeting: 8 | get: 9 | summary: "Returns a greeting for specific name" 10 | operationId: "greet" 11 | produces: 12 | - "application/json" 13 | parameters: 14 | - in: query 15 | name: name 16 | description: | 17 | Name to greet. 18 | Example: "John Doe" 19 | type: string 20 | minLength: 1 21 | required: true 22 | responses: 23 | 200: 24 | description: "Greeting" 25 | schema: 26 | type: object 27 | properties: 28 | greeting: 29 | type: string 30 | required: 31 | - greeting 32 | 400: 33 | description: "Invalid input" 34 | 500: 35 | description: "Server error" 36 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // MultiError describes an error that wraps multiple errors that share 8 | // the common message. 9 | type MultiError interface { 10 | Message() string 11 | Errors() []error 12 | } 13 | 14 | func newMultiError(msg string, errs ...error) multiError { 15 | return multiError{ 16 | msg: msg, 17 | errs: errs, 18 | } 19 | } 20 | 21 | type multiError struct { 22 | msg string 23 | errs []error 24 | } 25 | 26 | // Error implements error. 27 | func (me multiError) Error() string { 28 | var ss []string 29 | for _, err := range me.errs { 30 | ss = append(ss, err.Error()) 31 | } 32 | s := strings.Join(ss, ", ") 33 | if me.msg != "" { 34 | s = me.msg + ": " + s 35 | } 36 | return s 37 | } 38 | 39 | func (me multiError) Message() string { 40 | return me.msg 41 | } 42 | 43 | // Errors implements MultiError. 44 | func (me multiError) Errors() []error { 45 | return me.errs 46 | } 47 | -------------------------------------------------------------------------------- /formats/partial_time.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | import "time" 4 | 5 | const ( 6 | // RFC3339PartialTime is partial-time format as described in RFC3339 7 | // https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14 8 | RFC3339PartialTime = "15:04:05" 9 | ) 10 | 11 | // PartialTime represents a partial-time defined in RFC3339. 12 | // 13 | // swagger:strfmt partial-time 14 | type PartialTime time.Time 15 | 16 | // String implements fmt.Stringer 17 | func (pt PartialTime) String() string { 18 | return time.Time(pt).Format(RFC3339PartialTime) 19 | } 20 | 21 | // MarshalText implements encoding.TextMarshaler 22 | func (pt PartialTime) MarshalText() (text []byte, err error) { 23 | return []byte(pt.String()), nil 24 | } 25 | 26 | // UnmarshalText implements encoding.TextUnmarshaler 27 | func (pt *PartialTime) UnmarshalText(text []byte) error { 28 | if len(text) == 0 { 29 | return nil 30 | } 31 | 32 | partialTime, err := time.Parse(RFC3339PartialTime, string(text)) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | *pt = PartialTime(partialTime) 38 | return nil 39 | } 40 | 41 | // IsPartialTime returns true when the string is a valid partial time. 42 | func IsPartialTime(value string) bool { 43 | _, err := time.Parse(RFC3339PartialTime, value) 44 | return err == nil 45 | } 46 | -------------------------------------------------------------------------------- /formats/partial_time_test.go: -------------------------------------------------------------------------------- 1 | package formats 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestPartialTime_String(t *testing.T) { 10 | date, err := time.Parse(time.RFC3339, "2017-01-01T14:25:00Z") 11 | if err != nil { 12 | t.Fatalf("Unexpeted error: %v", err) 13 | } 14 | 15 | pt := PartialTime(date) 16 | expectedValue := "14:25:00" 17 | if pt.String() != expectedValue { 18 | t.Fatalf("Expected %q but got %q", expectedValue, pt.String()) 19 | } 20 | } 21 | 22 | func TestPartialTime_MarshalText(t *testing.T) { 23 | date, err := time.Parse(time.RFC3339, "2017-01-01T14:25:00Z") 24 | if err != nil { 25 | t.Fatalf("Unexpeted error: %v", err) 26 | } 27 | 28 | pt := PartialTime(date) 29 | expectedValue := []byte("14:25:00") 30 | txt, err := pt.MarshalText() 31 | if err != nil { 32 | t.Errorf("Unexpected error: %v", err) 33 | } 34 | if !bytes.Equal(txt, expectedValue) { 35 | t.Fatalf("Expected %v but got %v", expectedValue, txt) 36 | } 37 | } 38 | 39 | func TestPartialTime_UnmarshalText(t *testing.T) { 40 | t.Run("should unmarshal text for correct value", func(t *testing.T) { 41 | pt := PartialTime{} 42 | 43 | err := pt.UnmarshalText([]byte("14:25:00")) 44 | if err != nil { 45 | t.Errorf("Unexpected error: %v", err) 46 | } 47 | 48 | expectedValue := "14:25:00" 49 | if pt.String() != expectedValue { 50 | t.Fatalf("Expected %v but got %v", expectedValue, pt.String()) 51 | } 52 | }) 53 | 54 | t.Run("should be ok for zero length text", func(t *testing.T) { 55 | pt := PartialTime{} 56 | 57 | err := pt.UnmarshalText([]byte{}) 58 | if err != nil { 59 | t.Errorf("Unexpected error: %v", err) 60 | } 61 | 62 | expectedValue := "00:00:00" 63 | if pt.String() != expectedValue { 64 | t.Fatalf("Expected %v but got %v", expectedValue, pt.String()) 65 | } 66 | }) 67 | 68 | t.Run("should fail on incorrect value", func(t *testing.T) { 69 | pt := PartialTime{} 70 | 71 | err := pt.UnmarshalText([]byte("123456789")) 72 | if err == nil { 73 | t.Errorf("Expected error but got ") 74 | } 75 | }) 76 | } 77 | 78 | func TestIsPartialTime(t *testing.T) { 79 | t.Run("should be partial time", func(t *testing.T) { 80 | isPartial := IsPartialTime("14:15:30") 81 | if !isPartial { 82 | t.Errorf("Expected true but got %v", isPartial) 83 | } 84 | }) 85 | 86 | t.Run("should not be partial time", func(t *testing.T) { 87 | isPartial := IsPartialTime("1234567890") 88 | if isPartial { 89 | t.Errorf("Expected false but got %v", isPartial) 90 | } 91 | }) 92 | } 93 | -------------------------------------------------------------------------------- /go_openapi_wrappers.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "github.com/go-openapi/loads" 5 | "github.com/go-openapi/spec" 6 | ) 7 | 8 | // This file contains wrappers around go-openapi/* packages exported types to 9 | // not to force oas package users explicitly import go-openapi packages. 10 | 11 | // Document represents a swagger spec document. 12 | type Document struct { 13 | *loads.Document 14 | } 15 | 16 | func wrapDocument(doc *loads.Document) *Document { 17 | return &Document{Document: doc} 18 | } 19 | 20 | // Operation describes a single API operation on a path. 21 | type Operation struct { 22 | *spec.Operation 23 | } 24 | 25 | func wrapOperation(op *spec.Operation) *Operation { 26 | return &Operation{Operation: op} 27 | } 28 | -------------------------------------------------------------------------------- /load.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "crypto/sha512" 5 | "encoding/hex" 6 | "encoding/json" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/go-openapi/analysis" 12 | "github.com/go-openapi/loads" 13 | "github.com/go-openapi/spec" 14 | "github.com/go-openapi/strfmt" 15 | "github.com/go-openapi/validate" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | // LoadOptions represent options that are used on specification load. 20 | type LoadOptions struct { 21 | host string 22 | schemes []string 23 | appVersion string 24 | 25 | cacheDir string 26 | } 27 | 28 | // LoadOption is option to use when loading specification. 29 | type LoadOption func(*LoadOptions) 30 | 31 | // LoadSetHost returns option that sets specification host. 32 | func LoadSetHost(host string) LoadOption { 33 | return func(o *LoadOptions) { 34 | o.host = host 35 | } 36 | } 37 | 38 | // LoadSetSchemes returns option that sets specification schemes. 39 | func LoadSetSchemes(schemes []string) LoadOption { 40 | return func(o *LoadOptions) { 41 | o.schemes = schemes 42 | } 43 | } 44 | 45 | // LoadSetAPIVersion returns option that sets application API version. 46 | func LoadSetAPIVersion(version string) LoadOption { 47 | return func(o *LoadOptions) { 48 | o.appVersion = version 49 | } 50 | } 51 | 52 | // LoadCacheDir returns option that allows to load expanded spec from cache. 53 | func LoadCacheDir(dir string) LoadOption { 54 | return func(o *LoadOptions) { 55 | o.cacheDir = dir 56 | } 57 | } 58 | 59 | // LoadFile loads OpenAPI specification from file. 60 | func LoadFile(fpath string, opts ...LoadOption) (*Document, error) { 61 | options := LoadOptions{} 62 | for _, opt := range opts { 63 | opt(&options) 64 | } 65 | 66 | document, err := loadDocument(fpath, options.cacheDir) 67 | if err != nil { 68 | return nil, err 69 | } 70 | 71 | if options.host != "" { 72 | document.Spec().Host = options.host 73 | document.OrigSpec().Host = options.host 74 | } 75 | 76 | if options.schemes != nil { 77 | document.Spec().Schemes = options.schemes 78 | document.OrigSpec().Schemes = options.schemes 79 | } 80 | 81 | if options.appVersion != "" { 82 | document.Spec().Info.Version = options.appVersion 83 | document.OrigSpec().Info.Version = options.appVersion 84 | } 85 | 86 | return wrapDocument(document), nil 87 | } 88 | 89 | func loadDocument(fpath, cacheDir string) (*loads.Document, error) { 90 | document, err := loads.Spec(fpath) 91 | if err != nil { 92 | return nil, errors.Wrap(err, "load spec from file") 93 | } 94 | 95 | hashSum, err := hashFile(fpath) 96 | if err != nil { 97 | return nil, errors.Wrap(err, "calculate file hash") 98 | } 99 | 100 | if exp, err := loadExpandedFromCache(cacheDir, hashSum); err == nil { 101 | // When document loaded from cache, it is safe to use exp.Raw() 102 | return embeddedAnalyzed(document.Raw(), exp.Raw()) 103 | } 104 | 105 | // If cannot load from cache for some reason - expand original spec. 106 | 107 | // We assume that everything cached is valid, but when cache is empty - 108 | // we need to validate the original document. 109 | if err = validate.Spec(document, strfmt.Default); err != nil { 110 | return nil, errors.Wrap(err, "validate spec") 111 | } 112 | 113 | exp, err := document.Expanded(&spec.ExpandOptions{RelativeBase: fpath}) 114 | if err != nil { 115 | return nil, errors.Wrap(err, "expand spec") 116 | } 117 | 118 | if err = saveExpandedToCache(exp, cacheDir, hashSum); err != nil { 119 | return nil, errors.Wrap(err, "save expanded spec to cache") 120 | } 121 | 122 | // To use expanded document right away, we need to get raw from it. 123 | // WARNING: When document is expanded in memory like above, exp.Raw() still 124 | // returns not expanded spec, so do not try to use it here. 125 | expBytes, err := exp.Spec().MarshalJSON() 126 | if err != nil { 127 | return nil, errors.Wrap(err, "convert expanded spec to raw") 128 | } 129 | 130 | return embeddedAnalyzed(document.Raw(), json.RawMessage(expBytes)) 131 | } 132 | 133 | func embeddedAnalyzed(orig, flat json.RawMessage) (*loads.Document, error) { 134 | doc, err := loads.Embedded(orig, flat) 135 | if err != nil { 136 | return nil, errors.Wrap(err, "create embedded document") 137 | } 138 | doc.Analyzer = analysis.New(doc.Spec()) 139 | return doc, nil 140 | } 141 | 142 | // loadExpandedFromCache loads OpenAPI document from cache if cacheDir is not empty. 143 | func loadExpandedFromCache(cacheDir, fpath string) (*loads.Document, error) { 144 | if cacheDir == "" { 145 | return nil, errors.New("cache dir is empty") 146 | } 147 | 148 | cacheFilename := filepath.Join(cacheDir, fpath) + ".json" 149 | 150 | return loads.JSONSpec(cacheFilename) 151 | } 152 | 153 | // saveExpandedToCache saves OpenAPI document to cache if cacheDir is not empty. 154 | func saveExpandedToCache(expandedDoc *loads.Document, cacheDir, fpath string) error { 155 | if cacheDir == "" { 156 | return nil 157 | } 158 | 159 | cacheFilename := filepath.Join(cacheDir, fpath) + ".json" 160 | 161 | if err := os.MkdirAll(filepath.Dir(cacheFilename), 0700); err != nil { 162 | return errors.Wrap(err, "create cache dir") 163 | } 164 | 165 | f, err := os.Create(cacheFilename) 166 | if err != nil { 167 | return errors.Wrap(err, "create cache file") 168 | } 169 | defer f.Close() 170 | 171 | if err = json.NewEncoder(f).Encode(expandedDoc.Spec()); err != nil { 172 | return errors.Wrap(err, "write cache file") 173 | } 174 | return nil 175 | } 176 | 177 | func hashFile(path string) (string, error) { 178 | f, err := os.Open(path) // nolint: gosec 179 | if err != nil { 180 | return "", errors.Wrap(err, "open spec file") 181 | } 182 | defer f.Close() 183 | 184 | h := sha512.New512_256() 185 | if _, err := io.Copy(h, f); err != nil { 186 | return "", errors.Wrap(err, "copy file to hash") 187 | } 188 | 189 | return hex.EncodeToString(h.Sum(nil)), nil 190 | } 191 | -------------------------------------------------------------------------------- /load_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "testing" 7 | ) 8 | 9 | func TestLoadFile(t *testing.T) { 10 | 11 | t.Run("positive", func(t *testing.T) { 12 | fpath := "/tmp/spec.json" 13 | if err := ioutil.WriteFile(fpath, loadDocBytes(petstore).Raw(), 0755); err != nil { 14 | t.Fatalf("Unexpected error: %s", err) 15 | } 16 | 17 | _, err := LoadFile(fpath) 18 | if err != nil { 19 | t.Fatalf("Unexpected error: %s", err) 20 | } 21 | 22 | _ = os.Remove(fpath) 23 | }) 24 | 25 | t.Run("file not found", func(t *testing.T) { 26 | _, err := LoadFile("/tmp/non/existent/file.yaml") 27 | if err == nil { 28 | t.Fatal("Expected error, but got nil") 29 | } 30 | }) 31 | 32 | t.Run("should fail on spec expansion", func(t *testing.T) { 33 | fpath := "/tmp/spec-that-fails-expansion.json" 34 | if err := ioutil.WriteFile(fpath, []byte(specThatFailsToExpand), 0755); err != nil { 35 | t.Fatalf("Unexpected error: %s", err) 36 | } 37 | 38 | _, err := LoadFile(fpath) 39 | if err == nil { 40 | t.Fatal("Expected error, but got nil") 41 | } 42 | 43 | _ = os.Remove(fpath) 44 | }) 45 | 46 | t.Run("should fail on spec validation", func(t *testing.T) { 47 | fpath := "/tmp/spec-that-fails-validation.json" 48 | if err := ioutil.WriteFile(fpath, []byte(specThatFailsValidation), 0755); err != nil { 49 | t.Fatalf("Unexpected error: %s", err) 50 | } 51 | 52 | _, err := LoadFile(fpath) 53 | if err == nil { 54 | t.Fatal("Expected error, but got nil") 55 | } 56 | 57 | _ = os.Remove(fpath) 58 | }) 59 | 60 | } 61 | 62 | const ( 63 | specThatFailsToExpand = ` 64 | swagger: "2.0" 65 | info: 66 | title: "Part of Swagger Petstore" 67 | version: "1.0.0" 68 | host: "petstore.swagger.io" 69 | basePath: "/v2" 70 | tags: 71 | - name: "pet" 72 | paths: 73 | /pet/{petId}: 74 | get: 75 | tags: 76 | - "pet" 77 | summary: "Find pet by OperationID" 78 | description: "Returns a single pet" 79 | operationId: "getPetById" 80 | produces: 81 | - "application/json" 82 | parameters: 83 | - name: "petId" 84 | in: "path" 85 | description: "OperationID of pet to return" 86 | required: true 87 | type: "integer" 88 | format: "int64" 89 | - in: query 90 | name: debug 91 | type: boolean 92 | responses: 93 | 200: 94 | description: "successful operation" 95 | schema: 96 | $ref: "#/definitions/Pet" 97 | 404: 98 | description: "Pet not found" 99 | definitions: 100 | Dog: 101 | type: "object" 102 | required: 103 | - "id" 104 | - "name" 105 | properties: 106 | id: 107 | type: "integer" 108 | format: "int64" 109 | name: 110 | type: "string" 111 | ` 112 | 113 | specThatFailsValidation = ` 114 | swagger: "2.0" 115 | info: 116 | title: "Part of Swagger Petstore" 117 | version: "1.0.0" 118 | host: "petstore.swagger.io" 119 | basePath: "/v2" 120 | tags: 121 | - name: "pet" 122 | paths: 123 | /pet/{petId}: 124 | get: 125 | tags: 126 | - "pet" 127 | summary: "Find pet by OperationID" 128 | description: "Returns a single pet" 129 | operationId: "getPetById" 130 | parameters: 131 | - name: "petId" 132 | in: "path" 133 | description: "OperationID of pet to return" 134 | required: true 135 | type: "integer" 136 | format: "int64" 137 | - in: query 138 | name: debug 139 | type: boolean 140 | responses: 141 | 200: 142 | description: "successful operation" 143 | schema: 144 | $ref: "#/definitions/Pet" 145 | 404: 146 | description: "Pet not found" 147 | /pet: 148 | post: 149 | tags: 150 | - "pet" 151 | summary: "Add a new pet to the store" 152 | operationId: "getPetById" 153 | parameters: 154 | - in: "body" 155 | name: "body" 156 | description: "Pet object that needs to be added to the store" 157 | required: true 158 | schema: 159 | $ref: "#/definitions/Pet" 160 | - in: query 161 | name: debug 162 | type: boolean 163 | responses: 164 | 405: 165 | description: "Invalid input" 166 | definitions: 167 | Pet: 168 | type: "object" 169 | required: 170 | - "id" 171 | - "name" 172 | properties: 173 | id: 174 | type: "integer" 175 | format: "int64" 176 | name: 177 | type: "string" 178 | ` 179 | ) 180 | -------------------------------------------------------------------------------- /media.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import "regexp" 4 | 5 | var ( 6 | contentTypeSelectorRegexJSON *regexp.Regexp 7 | contentTypeSelectorRegexJSONAPI *regexp.Regexp 8 | ) 9 | 10 | const ( 11 | mediaTypeWildcard = "*/*" 12 | ) 13 | 14 | func init() { 15 | contentTypeSelectorRegexJSON = regexp.MustCompile(`(?i)^application\/json`) 16 | contentTypeSelectorRegexJSONAPI = regexp.MustCompile(`(?i)^application\/vnd\.api\+json$`) 17 | } 18 | -------------------------------------------------------------------------------- /middleware.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | ) 7 | 8 | // Middleware describes a middleware that can be applied to a http.handler. 9 | type Middleware func(next http.Handler) http.Handler 10 | 11 | // MiddlewareOptions represent options for middleware. 12 | type MiddlewareOptions struct { 13 | jsonSelectors []*regexp.Regexp 14 | problemHandler ProblemHandler 15 | continueOnProblem bool 16 | } 17 | 18 | // MiddlewareOption represent option for middleware. 19 | type MiddlewareOption func(*MiddlewareOptions) 20 | 21 | // WithJSONSelectors returns a middleware option that sets JSON Content-Type selectors. 22 | func WithJSONSelectors(selectors ...*regexp.Regexp) MiddlewareOption { 23 | return func(opts *MiddlewareOptions) { 24 | opts.jsonSelectors = append(opts.jsonSelectors, selectors...) 25 | } 26 | } 27 | 28 | // WithProblemHandler returns a middleware option that sets problem handler. 29 | func WithProblemHandler(h ProblemHandler) MiddlewareOption { 30 | return func(opts *MiddlewareOptions) { 31 | opts.problemHandler = h 32 | } 33 | } 34 | 35 | // WithProblemHandlerFunc returns a middleware option that sets problem handler. 36 | func WithProblemHandlerFunc(f ProblemHandlerFunc) MiddlewareOption { 37 | return func(opts *MiddlewareOptions) { 38 | opts.problemHandler = f 39 | } 40 | } 41 | 42 | // WithContinueOnProblem returns a middleware option that defines if middleware 43 | // should continue when error occurs. 44 | func WithContinueOnProblem(contin bool) MiddlewareOption { 45 | return func(opts *MiddlewareOptions) { 46 | opts.continueOnProblem = contin 47 | } 48 | } 49 | 50 | func parseMiddlewareOptions(opts ...MiddlewareOption) MiddlewareOptions { 51 | options := MiddlewareOptions{ 52 | jsonSelectors: nil, 53 | continueOnProblem: false, 54 | } 55 | for _, opt := range opts { 56 | opt(&options) 57 | } 58 | 59 | if options.jsonSelectors == nil { 60 | defaultJSONSelectors()(&options) 61 | } 62 | 63 | return options 64 | } 65 | 66 | func defaultJSONSelectors() MiddlewareOption { 67 | return func(opts *MiddlewareOptions) { 68 | opts.jsonSelectors = []*regexp.Regexp{ 69 | contentTypeSelectorRegexJSON, 70 | contentTypeSelectorRegexJSONAPI, 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /middleware_media_type.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | // matchMediaType checks if media type matches any allowed media type. 10 | func matchMediaType(mediaType string, allowed []string) bool { 11 | if len(allowed) == 0 { 12 | // If no media types are explicitly defined, allow all. 13 | return true 14 | } 15 | 16 | if mediaType == "" { 17 | // If no media type given, consider it is ok. 18 | // This is useful for HTTP 204 responses, as well as for 19 | // assuming "defaults". RFC 7231 does not strictly requires 20 | // Content-Type to be defined: https://tools.ietf.org/html/rfc7231#section-3.1.1.5 21 | return true 22 | } 23 | 24 | for _, a := range allowed { 25 | if a == mediaTypeWildcard { 26 | return true 27 | } 28 | if strings.EqualFold(mediaType, a) { 29 | return true 30 | } 31 | } 32 | 33 | return false 34 | } 35 | 36 | // matchMediaTypes checks if any of the media types matches any allowed 37 | // media type. 38 | func matchMediaTypes(mediaTypes []string, allowed []string) bool { 39 | if len(allowed) == 0 { 40 | // If no media types are explicitly defined, allow all. 41 | return true 42 | } 43 | 44 | if len(mediaTypes) == 0 { 45 | // If no media types are explicitly requested, allow all. 46 | return true 47 | } 48 | 49 | for _, mediaType := range mediaTypes { 50 | if mediaType == mediaTypeWildcard { 51 | return true 52 | } 53 | for _, a := range allowed { 54 | if a == mediaTypeWildcard { 55 | return true 56 | } 57 | if strings.EqualFold(a, mediaType) { 58 | return true 59 | } 60 | } 61 | } 62 | 63 | return false 64 | } 65 | 66 | // requestContentTypeValidator validates request against media types which 67 | // are defined by the corresponding operation. 68 | type requestContentTypeValidator struct { 69 | next http.Handler 70 | } 71 | 72 | func (mw *requestContentTypeValidator) ServeHTTP(w http.ResponseWriter, req *http.Request, consumes []string, produces []string, ok bool) { 73 | if !ok { 74 | mw.next.ServeHTTP(w, req) 75 | return 76 | } 77 | 78 | if req.ContentLength > 0 { 79 | ct := req.Header.Get("Content-Type") 80 | if !matchMediaType(ct, consumes) { 81 | w.WriteHeader(http.StatusUnsupportedMediaType) 82 | return 83 | } 84 | } 85 | 86 | if !matchMediaTypes(req.Header["Accept"], produces) { 87 | w.WriteHeader(http.StatusNotAcceptable) 88 | return 89 | } 90 | 91 | mw.next.ServeHTTP(w, req) 92 | } 93 | 94 | // requestContentTypeValidator validates response against media types which 95 | // are defined by the corresponding operation. 96 | type responseContentTypeValidator struct { 97 | next http.Handler 98 | 99 | problemHandler ProblemHandler 100 | } 101 | 102 | func (mw *responseContentTypeValidator) ServeHTTP(w http.ResponseWriter, req *http.Request, produces []string, ok bool) { 103 | mw.next.ServeHTTP(w, req) 104 | if !ok { 105 | return 106 | } 107 | 108 | ct := w.Header().Get("Content-Type") 109 | 110 | if !matchMediaType(ct, req.Header["Accept"]) { 111 | err := fmt.Errorf("Content-Type header of the response does not match Accept header of the request") 112 | mw.problemHandler.HandleProblem(NewProblem(w, req, err)) 113 | } 114 | 115 | if !matchMediaType(ct, produces) { 116 | err := fmt.Errorf("Content-Type header of the response does not match any of the media types the operation can produce") 117 | mw.problemHandler.HandleProblem(NewProblem(w, req, err)) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /middleware_media_type_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestRequestContentTypeValidator(t *testing.T) { 13 | testCases := map[string]struct { 14 | consumes []string 15 | expectHandlerCalled bool 16 | expectedStatus int 17 | }{ 18 | "consumes application/json": { 19 | consumes: []string{ 20 | "application/json", 21 | }, 22 | expectHandlerCalled: true, 23 | expectedStatus: http.StatusOK, 24 | }, 25 | "consumes application/xml": { 26 | consumes: []string{ 27 | "application/xml", 28 | }, 29 | expectHandlerCalled: false, 30 | expectedStatus: http.StatusUnsupportedMediaType, 31 | }, 32 | } 33 | 34 | // TODO: test accept 35 | var produces []string 36 | 37 | for name, tc := range testCases { 38 | t.Run(name, func(t *testing.T) { 39 | h := &fakeHandler{} 40 | v := &requestContentTypeValidator{next: h} 41 | 42 | w := httptest.NewRecorder() 43 | v.ServeHTTP(w, newRequest(nil), tc.consumes, produces, true) 44 | 45 | assert.Equal(t, tc.expectHandlerCalled, h.called) 46 | assert.Equal(t, tc.expectedStatus, w.Code) 47 | }) 48 | } 49 | } 50 | 51 | func TestResponseContentTypeValidator(t *testing.T) { 52 | testCases := map[string]struct { 53 | accept []string 54 | produces []string 55 | expectedErrors int 56 | }{ 57 | "accept and produces application/json": { 58 | accept: []string{"application/json"}, 59 | produces: []string{"application/json"}, 60 | expectedErrors: 0, 61 | }, 62 | "accept application/xml": { 63 | accept: []string{"application/xml"}, 64 | expectedErrors: 1, 65 | }, 66 | "produces application/xml": { 67 | produces: []string{"application/xml"}, 68 | expectedErrors: 1, 69 | }, 70 | } 71 | 72 | for name, tc := range testCases { 73 | t.Run(name, func(t *testing.T) { 74 | var errs []error 75 | errHandler := func(problem Problem) { 76 | errs = append(errs, problem.Cause()) 77 | } 78 | 79 | h := &fakeHandler{} 80 | v := &responseContentTypeValidator{ 81 | next: h, 82 | problemHandler: ProblemHandlerFunc(errHandler), 83 | } 84 | 85 | w := httptest.NewRecorder() 86 | req := newRequest(tc.accept) 87 | v.ServeHTTP(w, req, tc.produces, true) 88 | 89 | assert.Len(t, errs, tc.expectedErrors) 90 | }) 91 | } 92 | } 93 | 94 | func newRequest(accept []string) *http.Request { 95 | req := httptest.NewRequest(http.MethodPost, "/foo", bytes.NewBufferString(` 96 | { 97 | "foo": "bar" 98 | } 99 | `)) 100 | req.Header.Set("Content-Type", "application/json") 101 | if len(accept) > 0 { 102 | req.Header["Accept"] = accept 103 | } 104 | return req 105 | } 106 | 107 | type fakeHandler struct { 108 | called bool 109 | } 110 | 111 | func (h *fakeHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 112 | h.called = true 113 | w.Header().Set("Content-Type", "application/json") 114 | w.Write([]byte(`{"foo":"bar"}`)) 115 | } 116 | -------------------------------------------------------------------------------- /middleware_path_params.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/go-openapi/spec" 8 | 9 | "github.com/hypnoglow/oas2/convert" 10 | ) 11 | 12 | // PathParamExtractorFunc is a function that extracts path parameters by key 13 | // from the request. 14 | type PathParamExtractorFunc func(req *http.Request, key string) string 15 | 16 | // PathParam implements PathParamExtractor. 17 | func (f PathParamExtractorFunc) PathParam(req *http.Request, key string) string { 18 | return f(req, key) 19 | } 20 | 21 | // PathParamExtractor can extract path parameters by key from the request. 22 | type PathParamExtractor interface { 23 | PathParam(req *http.Request, key string) string 24 | } 25 | 26 | // GetPathParam returns a path parameter by name from a request. 27 | // For example, a handler defined on a path "/pet/{id}" gets a request with 28 | // path "/pet/12" - in this case GetPathParam(req, "id") returns 12. 29 | func GetPathParam(req *http.Request, name string) interface{} { 30 | return req.Context().Value(contextKeyPathParam(name)) 31 | } 32 | 33 | // WithPathParam returns request with context value defining path parameter name 34 | // set to value. 35 | func WithPathParam(req *http.Request, name string, value interface{}) *http.Request { 36 | return req.WithContext(context.WithValue(req.Context(), contextKeyPathParam(name), value)) 37 | } 38 | 39 | type contextKeyPathParam string 40 | 41 | // pathParamExtractor is a middleware that extracts parameters 42 | // defined in OpenAPI 2.0 spec as path parameters from path and adds 43 | // them to the request context. 44 | type pathParamExtractor struct { 45 | next http.Handler 46 | 47 | extractor PathParamExtractor 48 | } 49 | 50 | func (mw *pathParamExtractor) ServeHTTP(w http.ResponseWriter, req *http.Request, params []spec.Parameter, ok bool) { 51 | if !ok { 52 | mw.next.ServeHTTP(w, req) 53 | return 54 | } 55 | 56 | for _, p := range params { 57 | if p.In != "path" { 58 | continue 59 | } 60 | 61 | value, err := convert.Primitive(mw.extractor.PathParam(req, p.Name), p.Type, p.Format) 62 | if err == nil { 63 | req = WithPathParam(req, p.Name, value) 64 | } 65 | } 66 | 67 | mw.next.ServeHTTP(w, req) 68 | } 69 | -------------------------------------------------------------------------------- /middleware_path_params_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestPathParamsExtractor(t *testing.T) { 13 | testCases := map[string]struct { 14 | url string 15 | extractor func(req *http.Request, key string) string 16 | expectedStatus int 17 | expectedBody string 18 | }{ 19 | "extracts parameters": { 20 | url: "/v2/pet/12", 21 | extractor: func(req *http.Request, key string) string { 22 | return "12" 23 | }, 24 | expectedStatus: http.StatusOK, 25 | expectedBody: "pet by id: 12", 26 | }, 27 | } 28 | 29 | doc := loadDocFile(t, "testdata/petstore_1.yml") 30 | params := doc.Analyzer.ParametersFor("getPetById") 31 | 32 | for name, tc := range testCases { 33 | t.Run(name, func(t *testing.T) { 34 | h := &pathParamExtractor{ 35 | next: http.HandlerFunc(handleGetPetByID), 36 | extractor: PathParamExtractorFunc(tc.extractor), 37 | } 38 | 39 | req := httptest.NewRequest(http.MethodGet, tc.url, nil) 40 | w := httptest.NewRecorder() 41 | h.ServeHTTP(w, req, params, true) 42 | 43 | assert.Equal(t, tc.expectedStatus, w.Code) 44 | assert.Equal(t, tc.expectedBody, w.Body.String()) 45 | }) 46 | } 47 | } 48 | 49 | func handleGetPetByID(w http.ResponseWriter, req *http.Request) { 50 | id, ok := GetPathParam(req, "petId").(int64) 51 | if !ok { 52 | w.WriteHeader(http.StatusNotFound) 53 | return 54 | } 55 | 56 | fmt.Fprintf(w, "pet by id: %d", id) 57 | } 58 | -------------------------------------------------------------------------------- /middleware_query.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-openapi/spec" 7 | 8 | "github.com/hypnoglow/oas2/validate" 9 | ) 10 | 11 | // queryValidator is a middleware that validates request query by OpenAPI operation 12 | // definition. 13 | type queryValidator struct { 14 | next http.Handler 15 | 16 | problemHandler ProblemHandler 17 | continueOnProblem bool 18 | } 19 | 20 | func (mw *queryValidator) ServeHTTP(w http.ResponseWriter, req *http.Request, params []spec.Parameter, ok bool) { 21 | if !ok { 22 | mw.next.ServeHTTP(w, req) 23 | return 24 | } 25 | 26 | if errs := validate.Query(params, req.URL.Query()); len(errs) > 0 { 27 | me := newMultiError("query params do not match the schema", errs...) 28 | mw.problemHandler.HandleProblem(NewProblem(w, req, me)) 29 | if !mw.continueOnProblem { 30 | return 31 | } 32 | } 33 | 34 | mw.next.ServeHTTP(w, req) 35 | } 36 | -------------------------------------------------------------------------------- /middleware_query_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestQueryValidator(t *testing.T) { 13 | testCases := map[string]struct { 14 | query string 15 | expectedStatus int 16 | expectedBody string 17 | }{ 18 | "valid query": { 19 | query: "username=johndoe&password=123", 20 | expectedStatus: http.StatusOK, 21 | expectedBody: "username: johndoe, password: 123", 22 | }, 23 | "missing required query parameter": { 24 | query: "username=johndoe", 25 | expectedStatus: http.StatusBadRequest, 26 | expectedBody: `{"errors":[{"message":"param password is required","field":"password"}]}`, 27 | }, 28 | } 29 | 30 | doc := loadDocFile(t, "testdata/petstore_1.yml") 31 | params := doc.Analyzer.ParametersFor("loginUser") 32 | 33 | v := &queryValidator{ 34 | next: http.HandlerFunc(handleUserLogin), 35 | problemHandler: problemHandlerResponseWriter(), 36 | continueOnProblem: false, 37 | } 38 | 39 | for name, tc := range testCases { 40 | t.Run(name, func(t *testing.T) { 41 | req := httptest.NewRequest(http.MethodGet, "/v2/user/login?"+tc.query, nil) 42 | w := httptest.NewRecorder() 43 | v.ServeHTTP(w, req, params, true) 44 | 45 | assert.Equal(t, tc.expectedStatus, w.Code) 46 | assert.Equal(t, tc.expectedBody, w.Body.String()) 47 | }) 48 | } 49 | } 50 | 51 | func handleUserLogin(w http.ResponseWriter, req *http.Request) { 52 | username := req.URL.Query().Get("username") 53 | password := req.URL.Query().Get("password") 54 | 55 | // Never do this! This is just for testing purposes. 56 | fmt.Fprintf(w, "username: %s, password: %s", username, password) 57 | } 58 | -------------------------------------------------------------------------------- /middleware_request_body.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "regexp" 11 | 12 | "github.com/go-openapi/spec" 13 | 14 | "github.com/hypnoglow/oas2/validate" 15 | ) 16 | 17 | // requestBodyValidator is a middleware that validates request body by OpenAPI 18 | // operation definition. 19 | type requestBodyValidator struct { 20 | next http.Handler 21 | 22 | // jsonSelectors represent content-type selectors. If any selector 23 | // matches content-type of the request, then request body will be validated. 24 | // Otherwise no validation is performed. 25 | jsonSelectors []*regexp.Regexp 26 | 27 | problemHandler ProblemHandler 28 | continueOnProblem bool 29 | } 30 | 31 | func (mw *requestBodyValidator) ServeHTTP(w http.ResponseWriter, req *http.Request, params []spec.Parameter, ok bool) { 32 | if !ok { 33 | mw.next.ServeHTTP(w, req) 34 | return 35 | } 36 | 37 | if req.Body == http.NoBody { 38 | for _, param := range params { 39 | if param.In == "body" && param.Required { 40 | // No request body found, but operation actually requires body. 41 | e := fmt.Errorf("request body is empty, but the operation requires non-empty body") 42 | mw.problemHandler.HandleProblem(NewProblem(w, req, e)) 43 | if !mw.continueOnProblem { 44 | return 45 | } 46 | } 47 | } 48 | mw.next.ServeHTTP(w, req) 49 | return 50 | } 51 | 52 | if !mw.matchContentType(req) { 53 | mw.next.ServeHTTP(w, req) 54 | return 55 | } 56 | 57 | // Read req.Body using io.TeeReader, so it can be read again 58 | // in the actual request handler. 59 | body, err := bodyPayload(req) 60 | if err != nil { 61 | e := fmt.Errorf("request body contains invalid json: %s", err) 62 | mw.problemHandler.HandleProblem(NewProblem(w, req, e)) 63 | if !mw.continueOnProblem { 64 | return 65 | } 66 | } 67 | 68 | if errs := validate.Body(params, body); len(errs) > 0 { 69 | me := newMultiError("request body does not match the schema", errs...) 70 | mw.problemHandler.HandleProblem(NewProblem(w, req, me)) 71 | if !mw.continueOnProblem { 72 | return 73 | } 74 | } 75 | 76 | mw.next.ServeHTTP(w, req) 77 | } 78 | 79 | // matchContentType checks if content type of the request matches any selector. 80 | func (mw *requestBodyValidator) matchContentType(req *http.Request) bool { 81 | contentType := req.Header.Get("Content-Type") 82 | for _, selector := range mw.jsonSelectors { 83 | if selector.MatchString(contentType) { 84 | return true 85 | } 86 | } 87 | 88 | return false 89 | } 90 | 91 | // bodyPayload reads req.Body and returns it. Request body can be 92 | // read again later. 93 | func bodyPayload(req *http.Request) (interface{}, error) { 94 | buf := &bytes.Buffer{} 95 | tr := io.TeeReader(req.Body, buf) 96 | defer req.Body.Close() 97 | 98 | var payload interface{} 99 | if err := json.NewDecoder(tr).Decode(&payload); err != nil { 100 | return nil, err 101 | } 102 | 103 | req.Body = ioutil.NopCloser(buf) 104 | return payload, nil 105 | } 106 | -------------------------------------------------------------------------------- /middleware_request_body_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/http/httptest" 10 | "regexp" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestRequestBodyValidator(t *testing.T) { 17 | testCases := map[string]struct { 18 | contentType string 19 | body string 20 | expectedStatus int 21 | expectedBody string 22 | }{ 23 | "valid json body": { 24 | contentType: "application/json", 25 | body: `{"name":"johndoe","age":7}`, 26 | expectedStatus: http.StatusOK, 27 | expectedBody: "pet name: johndoe", 28 | }, 29 | "required field \"name\" is missing": { 30 | contentType: "application/json", 31 | body: `{"age":7}`, 32 | expectedStatus: http.StatusBadRequest, 33 | expectedBody: `{"errors":[{"message":"name in body is required","field":"name"}]}`, 34 | }, 35 | "value for field \"age\" is incorrect": { 36 | contentType: "application/json", 37 | body: `{"name":"johndoe","age":"abc"}`, 38 | expectedStatus: http.StatusBadRequest, 39 | expectedBody: `{"errors":[{"message":"age in body must be of type integer: \"string\"","field":"age"}]}`, 40 | }, 41 | "no body": { 42 | contentType: "application/json", 43 | body: "", 44 | expectedStatus: http.StatusBadRequest, 45 | expectedBody: `{"errors":[{"message":"request body is empty, but the operation requires non-empty body"}]}`, 46 | }, 47 | "invalid json body": { 48 | contentType: "application/json", 49 | body: `{"name":"johndoe`, 50 | expectedStatus: http.StatusBadRequest, 51 | expectedBody: `{"errors":[{"message":"request body contains invalid json: unexpected EOF"}]}`, 52 | }, 53 | "skip body validation for not application/json content type": { 54 | contentType: "text/plain", 55 | body: "some", 56 | expectedStatus: http.StatusUnsupportedMediaType, // returned from the actual handler 57 | expectedBody: "", 58 | }, 59 | } 60 | 61 | doc := loadDocFile(t, "testdata/petstore_1.yml") 62 | params := doc.Analyzer.ParametersFor("addPet") 63 | 64 | v := &requestBodyValidator{ 65 | next: http.HandlerFunc(handleAddPet), 66 | jsonSelectors: []*regexp.Regexp{contentTypeSelectorRegexJSON}, 67 | problemHandler: problemHandlerResponseWriter(), 68 | continueOnProblem: false, 69 | } 70 | 71 | for name, tc := range testCases { 72 | t.Run(name, func(t *testing.T) { 73 | var body io.Reader 74 | if tc.body != "" { 75 | body = bytes.NewBufferString(tc.body) 76 | } 77 | req := httptest.NewRequest(http.MethodPost, "/v2/pet", body) 78 | req.Header.Set("Content-Type", tc.contentType) 79 | w := httptest.NewRecorder() 80 | v.ServeHTTP(w, req, params, true) 81 | 82 | assert.Equal(t, tc.expectedStatus, w.Code) 83 | assert.Equal(t, tc.expectedBody, w.Body.String()) 84 | }) 85 | } 86 | } 87 | 88 | func handleAddPet(w http.ResponseWriter, req *http.Request) { 89 | type pet struct { 90 | Name string `json:"name"` 91 | PhotoURLs []string `json:"photoUrls"` 92 | } 93 | 94 | var p pet 95 | if err := json.NewDecoder(req.Body).Decode(&p); err != nil { 96 | w.WriteHeader(http.StatusUnsupportedMediaType) 97 | return 98 | } 99 | 100 | fmt.Fprintf(w, "pet name: %s", p.Name) 101 | } 102 | -------------------------------------------------------------------------------- /middleware_response_body.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "regexp" 9 | 10 | "github.com/go-openapi/spec" 11 | 12 | "github.com/hypnoglow/oas2/validate" 13 | ) 14 | 15 | // responseBodyValidator is a middleware that validates response body by OpenAPI 16 | // operation definition. 17 | type responseBodyValidator struct { 18 | next http.Handler 19 | 20 | // jsonSelectors represent content-type selectors. If any selector 21 | // matches content-type of the response, then response body will be validated. 22 | // Otherwise no validation is performed. 23 | jsonSelectors []*regexp.Regexp 24 | 25 | problemHandler ProblemHandler 26 | } 27 | 28 | func (mw *responseBodyValidator) ServeHTTP(w http.ResponseWriter, req *http.Request, responses *spec.Responses, ok bool) { 29 | if !ok { 30 | mw.next.ServeHTTP(w, req) 31 | return 32 | } 33 | 34 | respBuf := &bytes.Buffer{} 35 | rr := newWrapResponseWriter(w, 1) 36 | rr.Tee(respBuf) 37 | 38 | mw.next.ServeHTTP(rr, req) 39 | 40 | // First of all, check if response is defined for the status code. 41 | responseSpec, ok := responses.StatusCodeResponses[rr.Status()] 42 | if !ok { 43 | // If no response is explicitly defined for the status code, consider it 44 | // is ok. 45 | // 46 | // Quote from OpenAPI 2.0 spec: 47 | // > It is not expected from the documentation to necessarily cover all 48 | // > possible HTTP response codes, since they may not be known in advance. 49 | return 50 | } 51 | 52 | if responseSpec.Schema == nil { 53 | // This may be ok for example for HTTP 204 responses, but any response 54 | // with a body should explicitly define a schema. 55 | // 56 | // Quote from OpenAPI 2.0 spec: 57 | // > If this field does not exist, it means no content is returned as 58 | // > part of the response. 59 | if respBuf.Len() > 0 { 60 | e := fmt.Errorf("response has non-emtpy body, but the operation does not define response schema for code %d", rr.Status()) 61 | mw.problemHandler.HandleProblem(NewProblem(w, req, e)) 62 | } 63 | return 64 | } 65 | 66 | // Check the content type of the response. If it does not match any selector, 67 | // don't validate the response. 68 | if !mw.matchContentType(rr.Header()) { 69 | return 70 | } 71 | 72 | var body interface{} 73 | if err := json.NewDecoder(respBuf).Decode(&body); err != nil { 74 | e := fmt.Errorf("response body contains invalid json: %s", err) 75 | mw.problemHandler.HandleProblem(NewProblem(w, req, e)) 76 | return 77 | } 78 | 79 | if errs := validate.BySchema(responseSpec.Schema, body); len(errs) > 0 { 80 | me := newMultiError("response body does not match the schema", errs...) 81 | mw.problemHandler.HandleProblem(NewProblem(w, req, me)) 82 | return 83 | } 84 | } 85 | 86 | // matchContentType checks if content type of the request matches any selector. 87 | func (mw *responseBodyValidator) matchContentType(hdr http.Header) bool { 88 | contentType := hdr.Get("Content-Type") 89 | for _, selector := range mw.jsonSelectors { 90 | if selector.MatchString(contentType) { 91 | return true 92 | } 93 | } 94 | 95 | return false 96 | } 97 | -------------------------------------------------------------------------------- /middleware_response_body_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "regexp" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestResponseBodyValidator(t *testing.T) { 16 | testCases := map[string]struct { 17 | url string 18 | expectedStatus int 19 | expectedBody string 20 | expectedLogBuffer string 21 | }{ 22 | "logs validation error": { 23 | url: "/v2/pet/12", 24 | expectedStatus: http.StatusOK, 25 | expectedBody: `{"id":123,"name":"Kitty"}`, 26 | expectedLogBuffer: "problem handler: response body does not match the schema: field=age value= message=age in body is required", 27 | }, 28 | "no logs when no response spec defined": { 29 | url: "/v2/pet/500", 30 | expectedStatus: http.StatusInternalServerError, 31 | expectedBody: `{"error":"foo"}`, 32 | expectedLogBuffer: "", 33 | }, 34 | "logs validation warning when no schema defined": { 35 | url: "/v2/pet/404", 36 | expectedStatus: http.StatusNotFound, 37 | expectedBody: `{"error":"not found"}`, 38 | expectedLogBuffer: "problem handler: response has non-emtpy body, but the operation does not define response schema for code 404", 39 | }, 40 | "logs validation error when response body is bad json": { 41 | url: "/v2/pet/badjson", 42 | expectedStatus: http.StatusOK, 43 | expectedBody: `{"name":`, 44 | expectedLogBuffer: "problem handler: response body contains invalid json: unexpected EOF", 45 | }, 46 | } 47 | 48 | doc := loadDocFile(t, "testdata/petstore_1.yml") 49 | _, _, op, ok := doc.Analyzer.OperationForName("getPetById") 50 | assert.True(t, ok) 51 | 52 | logBuffer := &bytes.Buffer{} 53 | 54 | v := &responseBodyValidator{ 55 | next: http.HandlerFunc(handleGetPetByIDFaked), 56 | jsonSelectors: []*regexp.Regexp{contentTypeSelectorRegexJSON}, 57 | problemHandler: problemHandlerBufferLogger(logBuffer), 58 | } 59 | 60 | for name, tc := range testCases { 61 | t.Run(name, func(t *testing.T) { 62 | logBuffer.Reset() 63 | defer logBuffer.Reset() 64 | 65 | req := httptest.NewRequest(http.MethodGet, tc.url, nil) 66 | w := httptest.NewRecorder() 67 | v.ServeHTTP(w, req, op.Responses, true) 68 | 69 | assert.Equal(t, tc.expectedStatus, w.Code) 70 | assert.Equal(t, tc.expectedBody, strings.TrimSpace(w.Body.String())) 71 | assert.Equal(t, tc.expectedLogBuffer, strings.TrimSpace(logBuffer.String())) 72 | }) 73 | } 74 | } 75 | 76 | func handleGetPetByIDFaked(w http.ResponseWriter, req *http.Request) { 77 | // set Content-Type for all responses to ensure validator does not filter 78 | // them out prior to other checks like response spec schema presence. 79 | w.Header().Set("Content-Type", "application/json") 80 | 81 | // fake not found 82 | if req.URL.Path == "/v2/pet/404" { 83 | w.WriteHeader(http.StatusNotFound) 84 | w.Write([]byte(`{"error":"not found"}`)) 85 | return 86 | } 87 | 88 | // fake for server error { 89 | if req.URL.Path == "/v2/pet/500" { 90 | w.WriteHeader(http.StatusInternalServerError) 91 | w.Write([]byte(`{"error":"foo"}`)) 92 | return 93 | } 94 | 95 | // fake for bad json 96 | if req.URL.Path == "/v2/pet/badjson" { 97 | w.Write([]byte(`{"name":`)) 98 | return 99 | } 100 | 101 | // normal 102 | 103 | type pet struct { 104 | ID int64 `json:"id"` 105 | Name string `json:"name"` 106 | } 107 | 108 | p := pet{123, "Kitty"} 109 | 110 | err := json.NewEncoder(w).Encode(p) 111 | assertNoError(err) 112 | } 113 | -------------------------------------------------------------------------------- /operation.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/go-openapi/spec" 8 | ) 9 | 10 | type operationInfo struct { 11 | operation *spec.Operation 12 | 13 | // params include all applicable operation params, even those defined 14 | // on the path operation belongs to. 15 | params []spec.Parameter 16 | 17 | // consumes is either operation-defined "consumes" property or spec-wide 18 | // "consumes" property. 19 | consumes []string 20 | 21 | // produces is either operation-defined "produces" property or spec-wide 22 | // "produces" property. 23 | produces []string 24 | } 25 | 26 | // operationContext is a middleware that adds operation info to the request 27 | // context and calls next. 28 | type operationContext struct { 29 | next http.Handler 30 | } 31 | 32 | func (mw *operationContext) ServeHTTP(w http.ResponseWriter, req *http.Request, oi operationInfo, ok bool) { 33 | if ok { 34 | req = withOperationInfo(req, oi) 35 | } 36 | 37 | mw.next.ServeHTTP(w, req) 38 | } 39 | 40 | type contextKeyOperationInfo struct{} 41 | 42 | // withOperationInfo returns request with context value defining *spec.Operation. 43 | func withOperationInfo(req *http.Request, info operationInfo) *http.Request { 44 | return req.WithContext( 45 | context.WithValue(req.Context(), contextKeyOperationInfo{}, info), 46 | ) 47 | } 48 | 49 | // getOperationInfo returns *spec.Operation from the request's context. 50 | // In case of operation not found GetOperation returns nil. 51 | func getOperationInfo(req *http.Request) (operationInfo, bool) { 52 | op, ok := req.Context().Value(contextKeyOperationInfo{}).(operationInfo) 53 | return op, ok 54 | } 55 | 56 | // mustOperationInfo returns *spec.Operation from the request's context. 57 | // In case of operation not found MustOperation panics. 58 | // 59 | // nolint 60 | func mustOperationInfo(req *http.Request) operationInfo { 61 | op, ok := getOperationInfo(req) 62 | if ok { 63 | return op 64 | } 65 | 66 | panic("request has no OpenAPI operation spec in its context") 67 | } 68 | -------------------------------------------------------------------------------- /problem.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | // NewProblem returns a new problem occurred while processing the request. 9 | func NewProblem(w http.ResponseWriter, req *http.Request, err error) Problem { 10 | return Problem{ 11 | w: w, 12 | req: req, 13 | err: err, 14 | } 15 | } 16 | 17 | // Problem describes a problem occurred while processing the request (or the response). 18 | // In most cases, the problem represents a validation error. 19 | type Problem struct { 20 | w http.ResponseWriter 21 | req *http.Request 22 | err error 23 | } 24 | 25 | // Cause returns the underlying error that represents the problem. 26 | func (p Problem) Cause() error { 27 | return p.err 28 | } 29 | 30 | // ResponseWriter retruns the ResponseWriter relative to the request. 31 | func (p Problem) ResponseWriter() http.ResponseWriter { 32 | return p.w 33 | } 34 | 35 | // Request returns the request on which the problem occured. 36 | func (p Problem) Request() *http.Request { 37 | return p.req 38 | } 39 | 40 | // ProblemHandlerFunc is a function that handles problems occurred in a middleware 41 | // while processing a request or a response. 42 | // 43 | // This function implements ProblemHandler. 44 | type ProblemHandlerFunc func(Problem) 45 | 46 | // HandleProblem handles the problem. 47 | func (f ProblemHandlerFunc) HandleProblem(problem Problem) { 48 | f(problem) 49 | } 50 | 51 | // ProblemHandler can handle problems occurred in a middleware while processing 52 | // a request or a response. 53 | // 54 | // Validation error depends on the middleware type, e.g. for query validator 55 | // middleware the error will describe query validation failure. Usually, the 56 | // handler should not wrap the error with a message like "query validation failure", 57 | // because the message will be already present in such error. 58 | type ProblemHandler interface { 59 | HandleProblem(problem Problem) 60 | } 61 | 62 | // newProblemHandlerErrorResponder is a very simple ProblemHandler that 63 | // writes problem error message to the response. 64 | func newProblemHandlerErrorResponder() ProblemHandlerFunc { 65 | return func(p Problem) { 66 | p.ResponseWriter().Header().Set("Content-Type", "text/plain; charset=utf-8") 67 | p.ResponseWriter().WriteHeader(http.StatusBadRequest) 68 | p.ResponseWriter().Write([]byte(p.err.Error())) // nolint 69 | } 70 | } 71 | 72 | // newProblemHandlerWarnLogger is a very simple ProblemHandler that writes 73 | // problem error to the standard logger with a warning prefix. 74 | func newProblemHandlerWarnLogger(kind string) ProblemHandlerFunc { 75 | return func(p Problem) { 76 | log.Printf("[WARN] oas %s problem on \"%s %s\": %v", kind, p.Request().Method, p.Request().URL.String(), p.Cause()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // OperationRouter describes an OpenAPI operation router, which can build 8 | // routing based on the specification, given operation handlers, and other 9 | // options. 10 | // 11 | // See "adapter/*" packages for implementations. 12 | type OperationRouter interface { 13 | // WithDocument sets the OpenAPI specification to build routes on. 14 | // It returns the router for convenient chaining. 15 | WithDocument(doc *Document) OperationRouter 16 | 17 | // WithMiddleware sets the middleware to build routing with. 18 | // It returns the router for convenient chaining. 19 | WithMiddleware(mws ...Middleware) OperationRouter 20 | 21 | // WithOperationHandlers sets operation handlers to build routing with. 22 | // It returns the router for convenient chaining. 23 | WithOperationHandlers(map[string]http.Handler) OperationRouter 24 | 25 | // WithMissingOperationHandlerFunc sets the function that will be called 26 | // for each operation that is present in the spec but missing from operation 27 | // handlers. This is completely optional. You can use this method for example 28 | // to simply log a warning or to throw a panic and stop route building. 29 | // This method returns the router for convenient chaining. 30 | WithMissingOperationHandlerFunc(fn func(string)) OperationRouter 31 | 32 | // Build builds routing based on the previously provided specification, 33 | // operation handlers, and other options. 34 | Build() error 35 | } 36 | -------------------------------------------------------------------------------- /spec_handlers.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/ghodss/yaml" 8 | "github.com/go-openapi/spec" 9 | ) 10 | 11 | // SpecHandlerType represents spec handler type. 12 | type SpecHandlerType int 13 | 14 | const ( 15 | // SpecHandlerTypeDynamic represents dynamic spec handler. 16 | SpecHandlerTypeDynamic SpecHandlerType = iota + 1 17 | 18 | // SpecHandlerTypeStatic represents static spec handler. 19 | SpecHandlerTypeStatic 20 | ) 21 | 22 | // NewDynamicSpecHandler returns HTTP handler for OpenAPI spec that 23 | // changes its host and schemes dynamically based on incoming request. 24 | func NewDynamicSpecHandler(doc *Document) http.Handler { 25 | return &dynamicSpecHandler{s: doc.Spec()} 26 | } 27 | 28 | type dynamicSpecHandler struct { 29 | s *spec.Swagger 30 | } 31 | 32 | func (h *dynamicSpecHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 33 | host := req.Header.Get("X-Forwarded-Host") 34 | if host == "" { 35 | host = req.Host 36 | } 37 | 38 | scheme := req.Header.Get("X-Forwarded-Proto") 39 | if scheme == "" { 40 | scheme = req.Header.Get("X-Scheme") 41 | if scheme == "" { 42 | scheme = "http" 43 | } 44 | } 45 | 46 | specShallowCopy := &spec.Swagger{ 47 | VendorExtensible: h.s.VendorExtensible, 48 | SwaggerProps: h.s.SwaggerProps, 49 | } 50 | specShallowCopy.Host = host 51 | specShallowCopy.Schemes = []string{scheme} 52 | 53 | writeSpec(w, specShallowCopy) 54 | } 55 | 56 | // NewStaticSpecHandler returns HTTP handler for static OpenAPI spec. 57 | func NewStaticSpecHandler(doc *Document) http.Handler { 58 | return &staticSpecHandler{s: doc.Spec()} 59 | } 60 | 61 | type staticSpecHandler struct { 62 | s *spec.Swagger 63 | } 64 | 65 | func (h *staticSpecHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { 66 | writeSpec(w, h.s) 67 | } 68 | 69 | func writeSpec(w http.ResponseWriter, s *spec.Swagger) { 70 | b, err := yaml.Marshal(s) 71 | if err != nil { 72 | w.WriteHeader(http.StatusInternalServerError) 73 | } 74 | 75 | w.Header().Set("Content-Type", "application/x-yaml") 76 | w.Header().Set("Content-Length", fmt.Sprintf("%d", len(b))) 77 | w.Write(b) // nolint 78 | } 79 | -------------------------------------------------------------------------------- /spec_handlers_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func TestDynamicSpecHandler(t *testing.T) { 11 | doc := loadDocFile(t, "testdata/petstore_1.yml") 12 | 13 | h := NewDynamicSpecHandler(doc) 14 | 15 | rr := httptest.NewRecorder() 16 | req := httptest.NewRequest(http.MethodGet, "/foo", nil) 17 | req.Header.Set("X-Forwarded-Host", "foo.bar.com") 18 | req.Header.Set("X-Forwarded-Proto", "https") 19 | 20 | h.ServeHTTP(rr, req) 21 | 22 | writtenDoc := loadDocBytes(rr.Body.Bytes()) 23 | 24 | if writtenDoc.Spec().Host != "foo.bar.com" { 25 | t.Errorf("Expected host to be foo.bar.com but got %q", writtenDoc.Spec().Host) 26 | } 27 | 28 | if !reflect.DeepEqual(writtenDoc.Spec().Schemes, []string{"https"}) { 29 | t.Errorf("Expected schemes to be [https] but got %v", writtenDoc.Spec().Schemes) 30 | } 31 | 32 | // check that original spec fields remain same 33 | 34 | if doc.Spec().Host != "petstore.swagger.io" { 35 | t.Errorf("Expected original spec host hasn't changed but got %v", doc.Spec().Host) 36 | } 37 | 38 | if !reflect.DeepEqual(doc.Spec().Schemes, []string{"http"}) { 39 | t.Errorf("Expected original spec schemes hasn't changed but got %v", doc.Spec().Schemes) 40 | } 41 | } 42 | 43 | func TestStaticSpecHandler(t *testing.T) { 44 | doc := loadDocFile(t, "testdata/petstore_1.yml") 45 | 46 | doc.Spec().Info.Version = "1.2.3" 47 | doc.Spec().Host = "foo.bar.com" 48 | doc.Spec().Schemes = []string{"https"} 49 | 50 | h := NewStaticSpecHandler(doc) 51 | 52 | rr := httptest.NewRecorder() 53 | req := httptest.NewRequest(http.MethodGet, "/foo", nil) 54 | 55 | h.ServeHTTP(rr, req) 56 | 57 | writtenDoc := loadDocBytes(rr.Body.Bytes()) 58 | 59 | if writtenDoc.Spec().Info.Version != "1.2.3" { 60 | t.Errorf("Expected version to be 1.2.3 but got %q", writtenDoc.Spec().Info.Version) 61 | } 62 | 63 | if writtenDoc.Spec().Host != "foo.bar.com" { 64 | t.Errorf("Expected host to be foo.bar.com but got %q", writtenDoc.Spec().Host) 65 | } 66 | 67 | if !reflect.DeepEqual(writtenDoc.Spec().Schemes, []string{"https"}) { 68 | t.Errorf("Expected schemes to be [https] but got %v", writtenDoc.Spec().Schemes) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /testdata/petstore_1.yml: -------------------------------------------------------------------------------- 1 | swagger: "2.0" 2 | info: 3 | description: "This is a sample server Petstore server." 4 | version: "1.0.0" 5 | title: "Swagger Petstore" 6 | termsOfService: "http://swagger.io/terms/" 7 | contact: 8 | email: "apiteam@swagger.io" 9 | license: 10 | name: "Apache 2.0" 11 | url: "http://www.apache.org/licenses/LICENSE-2.0.html" 12 | host: "petstore.swagger.io" 13 | basePath: "/v2" 14 | tags: 15 | - name: "pet" 16 | description: "Everything about your Pets" 17 | externalDocs: 18 | description: "Find out more" 19 | url: "http://swagger.io" 20 | schemes: 21 | - "http" 22 | paths: 23 | /pet: 24 | post: 25 | tags: 26 | - "pet" 27 | summary: "Add a new pet to the store" 28 | operationId: "addPet" 29 | consumes: 30 | - "application/json" 31 | produces: 32 | - "application/json" 33 | parameters: 34 | - in: "body" 35 | name: "body" 36 | description: "Pet object that needs to be added to the store" 37 | required: true 38 | schema: 39 | $ref: "#/definitions/Pet" 40 | - in: query 41 | name: debug 42 | type: boolean 43 | responses: 44 | 405: 45 | description: "Invalid input" 46 | security: 47 | - petstore_auth: 48 | - "write:pets" 49 | - "read:pets" 50 | /pet/{petId}: 51 | get: 52 | tags: 53 | - "pet" 54 | summary: "Find pet by ID" 55 | description: "Returns a single pet" 56 | operationId: "getPetById" 57 | produces: 58 | - "application/json" 59 | parameters: 60 | - in: query 61 | name: debug 62 | type: boolean 63 | responses: 64 | 200: 65 | description: "successful operation" 66 | schema: 67 | $ref: "#/definitions/Pet" 68 | 400: 69 | description: "Invalid ID supplied" 70 | 404: 71 | description: "Pet not found" 72 | security: 73 | - api_key: [] 74 | parameters: 75 | - name: "petId" 76 | in: "path" 77 | description: "ID of pet to return" 78 | required: true 79 | type: "integer" 80 | format: "int64" 81 | /user/login: 82 | get: 83 | tags: 84 | - "user" 85 | summary: "Logs user into the system" 86 | description: "" 87 | operationId: "loginUser" 88 | produces: 89 | - "application/json" 90 | parameters: 91 | - name: "username" 92 | in: "query" 93 | description: "The user name for login" 94 | required: true 95 | type: "string" 96 | - name: "password" 97 | in: "query" 98 | description: "The password for login in clear text" 99 | required: true 100 | type: "string" 101 | responses: 102 | 200: 103 | description: "successful operation" 104 | schema: 105 | type: "string" 106 | headers: 107 | X-Rate-Limit: 108 | type: "integer" 109 | format: "int32" 110 | description: "calls per hour allowed by the user" 111 | X-Expires-After: 112 | type: "string" 113 | format: "date-time" 114 | description: "date in UTC when token expires" 115 | 400: 116 | description: "Invalid username/password supplied" 117 | securityDefinitions: 118 | petstore_auth: 119 | type: "oauth2" 120 | authorizationUrl: "http://petstore.swagger.io/oauth/dialog" 121 | flow: "implicit" 122 | scopes: 123 | write:pets: "modify pets in your account" 124 | read:pets: "read your pets" 125 | api_key: 126 | type: "apiKey" 127 | name: "api_key" 128 | in: "header" 129 | definitions: 130 | Pet: 131 | type: "object" 132 | required: 133 | - "name" 134 | - "age" 135 | properties: 136 | id: 137 | type: "integer" 138 | format: "int64" 139 | name: 140 | type: "string" 141 | example: "doggie" 142 | age: 143 | type: "integer" 144 | format: "int32" 145 | example: 7 146 | status: 147 | type: "string" 148 | description: "pet status in the store" 149 | enum: 150 | - "available" 151 | - "pending" 152 | - "sold" 153 | ApiResponse: 154 | type: "object" 155 | properties: 156 | code: 157 | type: "integer" 158 | format: "int32" 159 | type: 160 | type: "string" 161 | message: 162 | type: "string" 163 | externalDocs: 164 | description: "Find out more about Swagger" 165 | url: "http://swagger.io" 166 | -------------------------------------------------------------------------------- /testing_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "testing" 10 | 11 | "github.com/go-openapi/loads" 12 | "github.com/go-openapi/strfmt" 13 | "github.com/go-openapi/swag" 14 | "github.com/go-openapi/validate" 15 | ) 16 | 17 | func assertNoError(err error) { 18 | if err != nil { 19 | panic(err) 20 | } 21 | } 22 | 23 | func mustWriteBadRequest(w http.ResponseWriter, contentType string, b []byte) { 24 | w.Header().Set("Content-Type", contentType) 25 | w.WriteHeader(http.StatusBadRequest) 26 | _, err := w.Write(b) 27 | assertNoError(err) 28 | } 29 | 30 | // problemHandlerResponseWriter returns a ProblemHandlerFunc that writes to the 31 | // response. 32 | func problemHandlerResponseWriter() ProblemHandlerFunc { 33 | return func(problem Problem) { 34 | var b []byte 35 | var err error 36 | 37 | switch te := problem.Cause().(type) { 38 | case MultiError: 39 | b, err = json.Marshal(convertErrs(te.Errors()...)) 40 | default: 41 | b, err = json.Marshal(convertErrs(te)) 42 | } 43 | 44 | assertNoError(err) 45 | 46 | mustWriteBadRequest( 47 | problem.ResponseWriter(), 48 | "application/json", 49 | b, 50 | ) 51 | } 52 | } 53 | 54 | // problemHandlerResponseWriter returns a ProblemHandlerFunc that logs to the 55 | // buffer. 56 | func problemHandlerBufferLogger(buf *bytes.Buffer) ProblemHandlerFunc { 57 | l := log.New(buf, "", 0) 58 | 59 | return func(problem Problem) { 60 | switch te := problem.Cause().(type) { 61 | case MultiError: 62 | for _, e := range convertErrs(te.Errors()...).Errors { 63 | l.Printf("problem handler: %s: %s", te.Message(), buildErrMessage(e)) 64 | } 65 | default: 66 | l.Printf("problem handler: %s", te.Error()) 67 | } 68 | } 69 | } 70 | 71 | // --- 72 | 73 | type ( 74 | errorItem struct { 75 | Message string `json:"message"` 76 | HasField bool `json:"-"` 77 | Field string `json:"field,omitempty"` 78 | HasValue bool `json:"-"` 79 | Value interface{} `json:"value,omitempty"` 80 | } 81 | payload struct { 82 | Errors []errorItem `json:"errors"` 83 | } 84 | ) 85 | 86 | func convertErrs(errs ...error) payload { 87 | // This is an example of composing an error for response from validation 88 | // errors. 89 | 90 | type fielder interface { 91 | Field() string 92 | } 93 | 94 | type valuer interface { 95 | Value() interface{} 96 | } 97 | 98 | p := payload{Errors: make([]errorItem, 0)} 99 | for _, e := range errs { 100 | item := errorItem{Message: e.Error()} 101 | if fe, ok := e.(fielder); ok { 102 | item.Field = fe.Field() 103 | item.HasField = true 104 | } 105 | if ve, ok := e.(valuer); ok { 106 | item.Value = ve.Value() 107 | item.HasValue = true 108 | } 109 | p.Errors = append(p.Errors, item) 110 | } 111 | 112 | return p 113 | } 114 | 115 | func buildErrMessage(err errorItem) string { 116 | msg := "" 117 | if err.HasField { 118 | msg += "field=" + err.Field + " " 119 | } 120 | if err.HasValue { 121 | msg += fmt.Sprintf("value=%v", err.Value) + " " 122 | } 123 | msg += "message=" + err.Message 124 | return msg 125 | } 126 | 127 | func loadDocFile(t *testing.T, fpath string) *Document { 128 | doc, err := LoadFile(fpath) 129 | if err != nil { 130 | t.Fatalf("Unexpected error: %v", err) 131 | } 132 | return doc 133 | } 134 | 135 | func loadDocBytes(b []byte) *Document { 136 | yml, err := swag.BytesToYAMLDoc(b) 137 | if err != nil { 138 | log.Fatalf("failed to convert spec to yaml: %v", err) 139 | } 140 | jsn, err := swag.YAMLToJSON(yml) 141 | if err != nil { 142 | log.Fatalf("failed to convert yaml to json: %v", err) 143 | } 144 | 145 | doc, err := loads.Analyzed(jsn, "2.0") 146 | if err != nil { 147 | log.Fatalf("failed to analyze spec: %v", err) 148 | } 149 | 150 | doc, err = doc.Expanded() 151 | if err != nil { 152 | log.Fatalf("failed to expand spec: %v", err) 153 | } 154 | 155 | if err := validate.Spec(doc, strfmt.Default); err != nil { 156 | log.Fatalf("failed to validate spec: %v", err) 157 | } 158 | 159 | return wrapDocument(doc) 160 | } 161 | 162 | var petstore = []byte(` 163 | swagger: "2.0" 164 | info: 165 | description: "This is a sample server Petstore server." 166 | version: "1.0.0" 167 | title: "Swagger Petstore" 168 | termsOfService: "http://swagger.io/terms/" 169 | contact: 170 | email: "apiteam@swagger.io" 171 | license: 172 | name: "Apache 2.0" 173 | url: "http://www.apache.org/licenses/LICENSE-2.0.html" 174 | host: "petstore.swagger.io" 175 | basePath: "/v2" 176 | tags: 177 | - name: "pet" 178 | description: "Everything about your Pets" 179 | externalDocs: 180 | description: "Find out more" 181 | url: "http://swagger.io" 182 | schemes: 183 | - "http" 184 | paths: 185 | /pet: 186 | post: 187 | tags: 188 | - "pet" 189 | summary: "Add a new pet to the store" 190 | operationId: "addPet" 191 | consumes: 192 | - "application/json" 193 | produces: 194 | - "application/json" 195 | parameters: 196 | - in: "body" 197 | name: "body" 198 | description: "Pet object that needs to be added to the store" 199 | required: true 200 | schema: 201 | $ref: "#/definitions/Pet" 202 | - in: query 203 | name: debug 204 | type: boolean 205 | responses: 206 | 405: 207 | description: "Invalid input" 208 | security: 209 | - petstore_auth: 210 | - "write:pets" 211 | - "read:pets" 212 | /pet/{petId}: 213 | get: 214 | tags: 215 | - "pet" 216 | summary: "Find pet by ID" 217 | description: "Returns a single pet" 218 | operationId: "getPetById" 219 | produces: 220 | - "application/json" 221 | parameters: 222 | - in: query 223 | name: debug 224 | type: boolean 225 | responses: 226 | 200: 227 | description: "successful operation" 228 | schema: 229 | $ref: "#/definitions/Pet" 230 | 400: 231 | description: "Invalid ID supplied" 232 | 404: 233 | description: "Pet not found" 234 | security: 235 | - api_key: [] 236 | parameters: 237 | - name: "petId" 238 | in: "path" 239 | description: "ID of pet to return" 240 | required: true 241 | type: "integer" 242 | format: "int64" 243 | /user/login: 244 | get: 245 | tags: 246 | - "user" 247 | summary: "Logs user into the system" 248 | description: "" 249 | operationId: "loginUser" 250 | produces: 251 | - "application/json" 252 | parameters: 253 | - name: "username" 254 | in: "query" 255 | description: "The user name for login" 256 | required: true 257 | type: "string" 258 | - name: "password" 259 | in: "query" 260 | description: "The password for login in clear text" 261 | required: true 262 | type: "string" 263 | responses: 264 | 200: 265 | description: "successful operation" 266 | schema: 267 | type: "string" 268 | headers: 269 | X-Rate-Limit: 270 | type: "integer" 271 | format: "int32" 272 | description: "calls per hour allowed by the user" 273 | X-Expires-After: 274 | type: "string" 275 | format: "date-time" 276 | description: "date in UTC when token expires" 277 | 400: 278 | description: "Invalid username/password supplied" 279 | securityDefinitions: 280 | petstore_auth: 281 | type: "oauth2" 282 | authorizationUrl: "http://petstore.swagger.io/oauth/dialog" 283 | flow: "implicit" 284 | scopes: 285 | write:pets: "modify pets in your account" 286 | read:pets: "read your pets" 287 | api_key: 288 | type: "apiKey" 289 | name: "api_key" 290 | in: "header" 291 | definitions: 292 | Pet: 293 | type: "object" 294 | required: 295 | - "name" 296 | - "age" 297 | properties: 298 | id: 299 | type: "integer" 300 | format: "int64" 301 | name: 302 | type: "string" 303 | example: "doggie" 304 | age: 305 | type: "integer" 306 | format: "int32" 307 | example: 7 308 | status: 309 | type: "string" 310 | description: "pet status in the store" 311 | enum: 312 | - "available" 313 | - "pending" 314 | - "sold" 315 | ApiResponse: 316 | type: "object" 317 | properties: 318 | code: 319 | type: "integer" 320 | format: "int32" 321 | type: 322 | type: "string" 323 | message: 324 | type: "string" 325 | externalDocs: 326 | description: "Find out more about Swagger" 327 | url: "http://swagger.io" 328 | `) 329 | -------------------------------------------------------------------------------- /util_response.go: -------------------------------------------------------------------------------- 1 | // The code in this file is taken from github.com/go-chi/chi/middleware package. 2 | 3 | package oas 4 | 5 | // The original work was derived from Goji's middleware, source: 6 | // https://github.com/zenazn/goji/tree/master/web/middleware 7 | 8 | import ( 9 | "bufio" 10 | "io" 11 | "net" 12 | "net/http" 13 | ) 14 | 15 | // newWrapResponseWriter wraps an http.ResponseWriter, returning a proxy that allows you to 16 | // hook into various parts of the response process. 17 | func newWrapResponseWriter(w http.ResponseWriter, protoMajor int) wrapResponseWriter { 18 | _, cn := w.(http.CloseNotifier) 19 | _, fl := w.(http.Flusher) 20 | 21 | bw := basicWriter{ResponseWriter: w} 22 | 23 | if protoMajor == 2 { 24 | _, ps := w.(http.Pusher) 25 | if cn && fl && ps { 26 | return &http2FancyWriter{bw} 27 | } 28 | } else { 29 | _, hj := w.(http.Hijacker) 30 | _, rf := w.(io.ReaderFrom) 31 | if cn && fl && hj && rf { 32 | return &httpFancyWriter{bw} 33 | } 34 | } 35 | if fl { 36 | return &flushWriter{bw} 37 | } 38 | 39 | return &bw 40 | } 41 | 42 | func (f *http2FancyWriter) Push(target string, opts *http.PushOptions) error { 43 | return f.basicWriter.ResponseWriter.(http.Pusher).Push(target, opts) 44 | } 45 | 46 | var _ http.Pusher = &http2FancyWriter{} 47 | 48 | // wrapResponseWriter is a proxy around an http.ResponseWriter that allows you to hook 49 | // into various parts of the response process. 50 | type wrapResponseWriter interface { 51 | http.ResponseWriter 52 | // Status returns the HTTP status of the request, or 0 if one has not 53 | // yet been sent. 54 | Status() int 55 | // BytesWritten returns the total number of bytes sent to the client. 56 | BytesWritten() int 57 | // Tee causes the response body to be written to the given io.Writer in 58 | // addition to proxying the writes through. Only one io.Writer can be 59 | // tee'd to at once: setting a second one will overwrite the first. 60 | // Writes will be sent to the proxy before being written to this 61 | // io.Writer. It is illegal for the tee'd writer to be modified 62 | // concurrently with writes. 63 | Tee(io.Writer) 64 | // Unwrap returns the original proxied target. 65 | Unwrap() http.ResponseWriter 66 | } 67 | 68 | // basicWriter wraps a http.ResponseWriter that implements the minimal 69 | // http.ResponseWriter interface. 70 | type basicWriter struct { 71 | http.ResponseWriter 72 | wroteHeader bool 73 | code int 74 | bytes int 75 | tee io.Writer 76 | } 77 | 78 | func (b *basicWriter) WriteHeader(code int) { 79 | if !b.wroteHeader { 80 | b.code = code 81 | b.wroteHeader = true 82 | b.ResponseWriter.WriteHeader(code) 83 | } 84 | } 85 | func (b *basicWriter) Write(buf []byte) (int, error) { 86 | b.WriteHeader(http.StatusOK) 87 | n, err := b.ResponseWriter.Write(buf) 88 | if b.tee != nil { 89 | _, err2 := b.tee.Write(buf[:n]) 90 | // Prefer errors generated by the proxied writer. 91 | if err == nil { 92 | err = err2 93 | } 94 | } 95 | b.bytes += n 96 | return n, err 97 | } 98 | func (b *basicWriter) maybeWriteHeader() { 99 | if !b.wroteHeader { 100 | b.WriteHeader(http.StatusOK) 101 | } 102 | } 103 | func (b *basicWriter) Status() int { 104 | return b.code 105 | } 106 | func (b *basicWriter) BytesWritten() int { 107 | return b.bytes 108 | } 109 | func (b *basicWriter) Tee(w io.Writer) { 110 | b.tee = w 111 | } 112 | func (b *basicWriter) Unwrap() http.ResponseWriter { 113 | return b.ResponseWriter 114 | } 115 | 116 | type flushWriter struct { 117 | basicWriter 118 | } 119 | 120 | func (f *flushWriter) Flush() { 121 | f.wroteHeader = true 122 | 123 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 124 | fl.Flush() 125 | } 126 | 127 | var _ http.Flusher = &flushWriter{} 128 | 129 | // httpFancyWriter is a HTTP writer that additionally satisfies http.CloseNotifier, 130 | // http.Flusher, http.Hijacker, and io.ReaderFrom. It exists for the common case 131 | // of wrapping the http.ResponseWriter that package http gives you, in order to 132 | // make the proxied object support the full method set of the proxied object. 133 | type httpFancyWriter struct { 134 | basicWriter 135 | } 136 | 137 | func (f *httpFancyWriter) CloseNotify() <-chan bool { 138 | cn := f.basicWriter.ResponseWriter.(http.CloseNotifier) 139 | return cn.CloseNotify() 140 | } 141 | func (f *httpFancyWriter) Flush() { 142 | f.wroteHeader = true 143 | 144 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 145 | fl.Flush() 146 | } 147 | func (f *httpFancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { 148 | hj := f.basicWriter.ResponseWriter.(http.Hijacker) 149 | return hj.Hijack() 150 | } 151 | func (f *httpFancyWriter) ReadFrom(r io.Reader) (int64, error) { 152 | if f.basicWriter.tee != nil { 153 | n, err := io.Copy(&f.basicWriter, r) 154 | f.basicWriter.bytes += int(n) 155 | return n, err 156 | } 157 | rf := f.basicWriter.ResponseWriter.(io.ReaderFrom) 158 | f.basicWriter.maybeWriteHeader() 159 | n, err := rf.ReadFrom(r) 160 | f.basicWriter.bytes += int(n) 161 | return n, err 162 | } 163 | 164 | var _ http.CloseNotifier = &httpFancyWriter{} 165 | var _ http.Flusher = &httpFancyWriter{} 166 | var _ http.Hijacker = &httpFancyWriter{} 167 | var _ io.ReaderFrom = &httpFancyWriter{} 168 | 169 | // http2FancyWriter is a HTTP2 writer that additionally satisfies http.CloseNotifier, 170 | // http.Flusher, and io.ReaderFrom. It exists for the common case 171 | // of wrapping the http.ResponseWriter that package http gives you, in order to 172 | // make the proxied object support the full method set of the proxied object. 173 | type http2FancyWriter struct { 174 | basicWriter 175 | } 176 | 177 | func (f *http2FancyWriter) CloseNotify() <-chan bool { 178 | cn := f.basicWriter.ResponseWriter.(http.CloseNotifier) 179 | return cn.CloseNotify() 180 | } 181 | func (f *http2FancyWriter) Flush() { 182 | f.wroteHeader = true 183 | 184 | fl := f.basicWriter.ResponseWriter.(http.Flusher) 185 | fl.Flush() 186 | } 187 | 188 | var _ http.CloseNotifier = &http2FancyWriter{} 189 | var _ http.Flusher = &http2FancyWriter{} 190 | -------------------------------------------------------------------------------- /util_response_test.go: -------------------------------------------------------------------------------- 1 | package oas 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | ) 7 | 8 | func TestFlushWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 9 | f := &flushWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 10 | f.Flush() 11 | 12 | if !f.wroteHeader { 13 | t.Fatal("want Flush to have set wroteHeader=true") 14 | } 15 | } 16 | 17 | func TestHttpFancyWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 18 | f := &httpFancyWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 19 | f.Flush() 20 | 21 | if !f.wroteHeader { 22 | t.Fatal("want Flush to have set wroteHeader=true") 23 | } 24 | } 25 | 26 | func TestHttp2FancyWriterRemembersWroteHeaderWhenFlushed(t *testing.T) { 27 | f := &http2FancyWriter{basicWriter{ResponseWriter: httptest.NewRecorder()}} 28 | f.Flush() 29 | 30 | if !f.wroteHeader { 31 | t.Fatal("want Flush to have set wroteHeader=true") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /validate/formats.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "github.com/go-openapi/strfmt" 5 | 6 | "github.com/hypnoglow/oas2/formats" 7 | ) 8 | 9 | var ( 10 | formatRegistry strfmt.Registry 11 | ) 12 | 13 | func init() { 14 | formatRegistry = strfmt.Default 15 | 16 | RegisterFormat("partialtime", &formats.PartialTime{}, formats.IsPartialTime) 17 | } 18 | 19 | // RegisterFormat registers custom format and validator for it. 20 | // See default OAS formatRegistry here: 21 | // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#data-types 22 | func RegisterFormat(name string, format strfmt.Format, validator strfmt.Validator) { 23 | formatRegistry.Add(name, format, validator) 24 | } 25 | -------------------------------------------------------------------------------- /validate/formats_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/go-openapi/spec" 8 | ) 9 | 10 | func TestPartialTimeValidation(t *testing.T) { 11 | param := spec.QueryParam("starts_at").Typed("string", "partial-time") 12 | 13 | t.Run("positive", func(t *testing.T) { 14 | values := url.Values{"starts_at": []string{"10:30:05"}} 15 | errs := Query([]spec.Parameter{*param}, values) 16 | if len(errs) > 0 { 17 | t.Errorf("Unexpected errors %v", errs) 18 | } 19 | }) 20 | 21 | t.Run("negative", func(t *testing.T) { 22 | values := url.Values{"starts_at": []string{"10abc"}} 23 | errs := Query([]spec.Parameter{*param}, values) 24 | if len(errs) == 0 { 25 | t.Errorf("Expected errors but got no errors") 26 | } 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /validate/validate.go: -------------------------------------------------------------------------------- 1 | // Package validate provides utilities that allow to validate request and 2 | // response data against OpenAPI Specification parameter and schema definitions. 3 | // 4 | // Note that errors returned from validation functions are generally of type 5 | // Error, so they can be asserted to corresponding interface(s) to retrieve 6 | // error's field and value. 7 | // errs := validate.Query(ps, q) 8 | // for _, err := range errs { 9 | // if e, ok := err.(validate.Error) { 10 | // field, value := e.Field(), e.Value() 11 | // // ... 12 | // } 13 | // } 14 | package validate 15 | 16 | import ( 17 | "fmt" 18 | "net/url" 19 | "strings" 20 | 21 | "github.com/go-openapi/errors" 22 | "github.com/go-openapi/spec" 23 | "github.com/go-openapi/validate" 24 | 25 | "github.com/hypnoglow/oas2/convert" 26 | ) 27 | 28 | // Query validates request query parameters by spec and returns errors 29 | // if any. 30 | func Query(ps []spec.Parameter, q url.Values) []error { 31 | errs := make(ValidationErrors, 0) 32 | 33 | // Iterate over spec parameters and validate each against the spec. 34 | for _, p := range ps { 35 | if p.In != "query" { 36 | // Validating only "query" parameters. 37 | continue 38 | } 39 | 40 | errs = append(errs, validateQueryParam(p, q)...) 41 | 42 | delete(q, p.Name) // to check not described parameters passed 43 | } 44 | 45 | // Check that no additional parameters passed. 46 | for name := range q { 47 | errs = append(errs, ValidationErrorf(name, q.Get(name), "parameter %s is unknown", name)) 48 | } 49 | 50 | return errs.Errors() 51 | } 52 | 53 | // Body validates request body by spec and returns errors if any. 54 | func Body(ps []spec.Parameter, data interface{}) []error { 55 | errs := make(ValidationErrors, 0) 56 | 57 | for _, p := range ps { 58 | if p.In != "body" { 59 | // Validating only "body" parameters. 60 | continue 61 | } 62 | 63 | errs = append(errs, validateBodyParam(p, data)...) 64 | } 65 | 66 | return errs.Errors() 67 | } 68 | 69 | // BySchema validates data by spec and returns errors if any. 70 | func BySchema(sch *spec.Schema, data interface{}) []error { 71 | return validatebySchema(sch, data).Errors() 72 | } 73 | 74 | // ValidationError describes validation error. 75 | type ValidationError interface { 76 | error 77 | 78 | // Field returns field name where error occurred. 79 | Field() string 80 | 81 | // Value returns original value passed by client on field where error 82 | // occurred. 83 | Value() interface{} 84 | } 85 | 86 | // ValidationErrorf returns a new formatted ValidationError. 87 | func ValidationErrorf(field string, value interface{}, format string, args ...interface{}) ValidationError { 88 | return valErr{ 89 | message: fmt.Sprintf(format, args...), 90 | field: field, 91 | value: value, 92 | } 93 | } 94 | 95 | // ValidationErrors is a set of validation errors. 96 | type ValidationErrors []ValidationError 97 | 98 | // Errors returns ValidationErrors in form of Go builtin errors. 99 | func (es ValidationErrors) Errors() []error { 100 | if len(es) == 0 { 101 | return nil 102 | } 103 | 104 | errs := make([]error, len(es)) 105 | for i, e := range es { 106 | errs[i] = e 107 | } 108 | return errs 109 | } 110 | 111 | func validateQueryParam(p spec.Parameter, q url.Values) (errs ValidationErrors) { 112 | _, ok := q[p.Name] 113 | if !ok { 114 | if p.Required { 115 | errs = append(errs, ValidationErrorf(p.Name, nil, "param %s is required", p.Name)) 116 | } 117 | return errs 118 | } 119 | 120 | value, err := convert.Parameter(q[p.Name], &p) 121 | if err != nil { 122 | // TODO: q.Get(p.Name) relies on type that is not array/file. 123 | return append(errs, ValidationErrorf(p.Name, q.Get(p.Name), "param %s: %s", p.Name, err)) 124 | } 125 | 126 | if result := validate.NewParamValidator(&p, formatRegistry).Validate(value); result != nil { 127 | for _, e := range result.Errors { 128 | errs = append(errs, ValidationErrorf(p.Name, value, e.Error())) 129 | } 130 | } 131 | 132 | return errs 133 | } 134 | 135 | func validateBodyParam(p spec.Parameter, data interface{}) (errs ValidationErrors) { 136 | return validatebySchema(p.Schema, data) 137 | } 138 | 139 | func validatebySchema(sch *spec.Schema, data interface{}) (errs ValidationErrors) { 140 | err := validate.AgainstSchema(sch, data, formatRegistry) 141 | ves, ok := err.(*errors.CompositeError) 142 | if ok && len(ves.Errors) > 0 { 143 | for _, e := range ves.Errors { 144 | ve := e.(*errors.Validation) 145 | errs = append(errs, ValidationErrorf(strings.TrimPrefix(ve.Name, "."), nil, strings.TrimPrefix(ve.Error(), "."))) 146 | } 147 | } 148 | 149 | return errs 150 | } 151 | 152 | // valErr implements ValidationError. 153 | type valErr struct { 154 | message string 155 | field string 156 | value interface{} 157 | } 158 | 159 | func (v valErr) Error() string { 160 | return v.message 161 | } 162 | 163 | func (v valErr) Field() string { 164 | return v.field 165 | } 166 | 167 | func (v valErr) Value() interface{} { 168 | return v.value 169 | } 170 | -------------------------------------------------------------------------------- /validate/validate_test.go: -------------------------------------------------------------------------------- 1 | package validate 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "reflect" 8 | "testing" 9 | 10 | "github.com/go-openapi/spec" 11 | ) 12 | 13 | func TestQuery(t *testing.T) { 14 | var maxAge float64 = 18 15 | 16 | cases := []struct { 17 | ps []spec.Parameter 18 | q url.Values 19 | expectedErrors []error 20 | }{ 21 | // not an "in: query" parameter is skipped 22 | { 23 | ps: []spec.Parameter{ 24 | { 25 | ParamProps: spec.ParamProps{ 26 | Name: "name", 27 | In: "path", 28 | Required: true, 29 | }, 30 | SimpleSchema: spec.SimpleSchema{ 31 | Type: "string", 32 | }, 33 | }, 34 | }, 35 | }, 36 | // error on additional parameter 37 | { 38 | ps: []spec.Parameter{ 39 | { 40 | ParamProps: spec.ParamProps{ 41 | Name: "name", 42 | In: "query", 43 | Required: true, 44 | }, 45 | SimpleSchema: spec.SimpleSchema{ 46 | Type: "string", 47 | }, 48 | }, 49 | }, 50 | q: url.Values{"name": {"johnhoe"}, "age": {"27"}}, 51 | expectedErrors: []error{ 52 | ValidationErrorf("age", "27", "parameter age is unknown"), 53 | }, 54 | }, 55 | // error on parameter conversion 56 | { 57 | ps: []spec.Parameter{ 58 | { 59 | ParamProps: spec.ParamProps{ 60 | Name: "age", 61 | In: "query", 62 | Required: true, 63 | }, 64 | SimpleSchema: spec.SimpleSchema{ 65 | Type: "integer", 66 | Format: "int32", 67 | }, 68 | }, 69 | }, 70 | q: url.Values{"age": {"johndoe"}}, 71 | expectedErrors: []error{ 72 | ValidationErrorf("age", "johndoe", "param age: cannot convert johndoe to int32"), 73 | }, 74 | }, 75 | // error on parameter validation 76 | { 77 | ps: []spec.Parameter{ 78 | { 79 | ParamProps: spec.ParamProps{ 80 | Name: "age", 81 | In: "query", 82 | Required: true, 83 | }, 84 | SimpleSchema: spec.SimpleSchema{ 85 | Type: "integer", 86 | Format: "int32", 87 | }, 88 | CommonValidations: spec.CommonValidations{ 89 | Minimum: &maxAge, 90 | }, 91 | }, 92 | }, 93 | q: url.Values{"age": {"17"}}, 94 | expectedErrors: []error{ 95 | ValidationErrorf("age", int32(17), "age in query should be greater than or equal to 18"), 96 | }, 97 | }, 98 | // required parameter is missing 99 | { 100 | ps: []spec.Parameter{ 101 | { 102 | ParamProps: spec.ParamProps{ 103 | Name: "age", 104 | In: "query", 105 | Required: true, 106 | }, 107 | SimpleSchema: spec.SimpleSchema{ 108 | Type: "integer", 109 | Format: "int32", 110 | }, 111 | }, 112 | }, 113 | q: url.Values{}, 114 | expectedErrors: []error{ 115 | ValidationErrorf("age", nil, "param age is required"), 116 | }, 117 | }, 118 | } 119 | 120 | for _, c := range cases { 121 | errs := Query(c.ps, c.q) 122 | if !reflect.DeepEqual(c.expectedErrors, errs) { 123 | t.Errorf("Expected errors to be\n%#v\n but got\n%#v", c.expectedErrors, errs) 124 | } 125 | } 126 | } 127 | 128 | func TestBody(t *testing.T) { 129 | cases := []struct { 130 | ps []spec.Parameter 131 | data interface{} 132 | expectedErrors []error 133 | }{ 134 | // not an "in: body" parameter is skipped 135 | { 136 | ps: []spec.Parameter{ 137 | { 138 | ParamProps: spec.ParamProps{ 139 | Name: "name", 140 | In: "query", 141 | Required: true, 142 | }, 143 | SimpleSchema: spec.SimpleSchema{ 144 | Type: "string", 145 | }, 146 | }, 147 | }, 148 | }, 149 | // not an "in: query" parameter is skipped 150 | { 151 | ps: []spec.Parameter{ 152 | { 153 | ParamProps: spec.ParamProps{ 154 | Name: "user", 155 | In: "body", 156 | Required: true, 157 | Schema: &spec.Schema{ 158 | SchemaProps: spec.SchemaProps{ 159 | Type: spec.StringOrArray{"object"}, 160 | Properties: map[string]spec.Schema{ 161 | "name": { 162 | SchemaProps: spec.SchemaProps{ 163 | Type: spec.StringOrArray{"string"}, 164 | }, 165 | }, 166 | }, 167 | Required: []string{"name"}, 168 | }, 169 | }, 170 | }, 171 | }, 172 | }, 173 | data: testhelperMakeUserData("John Doe"), 174 | expectedErrors: nil, 175 | }, 176 | } 177 | 178 | for _, c := range cases { 179 | errs := Body(c.ps, c.data) 180 | if !reflect.DeepEqual(c.expectedErrors, errs) { 181 | t.Errorf("Expected errors to be %v but got %v", c.expectedErrors, errs) 182 | } 183 | } 184 | } 185 | 186 | func TestBySchema(t *testing.T) { 187 | cases := []struct { 188 | sch *spec.Schema 189 | data interface{} 190 | expectedErrors []error 191 | }{ 192 | // ok 193 | { 194 | sch: &spec.Schema{ 195 | SchemaProps: spec.SchemaProps{ 196 | Type: spec.StringOrArray{"object"}, 197 | Properties: map[string]spec.Schema{ 198 | "name": { 199 | SchemaProps: spec.SchemaProps{ 200 | Type: spec.StringOrArray{"string"}, 201 | }, 202 | }, 203 | }, 204 | Required: []string{"name"}, 205 | }, 206 | }, 207 | data: testhelperMakeUserData("John Doe"), 208 | }, 209 | // string length not satisfied 210 | { 211 | sch: &spec.Schema{ 212 | SchemaProps: spec.SchemaProps{ 213 | Type: spec.StringOrArray{"object"}, 214 | Properties: map[string]spec.Schema{ 215 | "name": { 216 | SchemaProps: spec.SchemaProps{ 217 | Type: spec.StringOrArray{"string"}, 218 | MinLength: int64Ptr(4), 219 | }, 220 | }, 221 | }, 222 | Required: []string{"name"}, 223 | }, 224 | }, 225 | data: testhelperMakeUserData("Max"), 226 | expectedErrors: []error{ValidationErrorf("name", nil, "name in body should be at least 4 chars long")}, 227 | }, 228 | } 229 | 230 | for _, c := range cases { 231 | errs := BySchema(c.sch, c.data) 232 | if !reflect.DeepEqual(c.expectedErrors, errs) { 233 | t.Errorf("Expected errors to be %#v but got %#v", c.expectedErrors, errs) 234 | } 235 | } 236 | } 237 | 238 | func TestValidationError(t *testing.T) { 239 | ve := ValidationErrorf("name", nil, "name cannot be empty") 240 | 241 | if ve.Error() != "name cannot be empty" { 242 | t.Errorf("Unexpected error message") 243 | } 244 | 245 | if ve.Field() != "name" { 246 | t.Errorf("Unexpected error field") 247 | } 248 | 249 | if ve.Value() != nil { 250 | t.Errorf("Unexpected error value") 251 | } 252 | } 253 | 254 | func testhelperMakeUserData(name string) interface{} { 255 | var v interface{} 256 | js := fmt.Sprintf(`{"name": "%s"}`, name) 257 | if err := json.Unmarshal([]byte(js), &v); err != nil { 258 | panic(err) 259 | } 260 | return v 261 | } 262 | 263 | func int64Ptr(f int64) *int64 { 264 | return &f 265 | } 266 | --------------------------------------------------------------------------------