├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── test-builds.yml ├── testdata ├── empty.json ├── empty-route-schema.json ├── router_with_prefix.json ├── tags.json ├── extension.json ├── users-deprecated-with-description.json ├── security.json ├── query.json ├── subrouter.json ├── headers.json ├── cookies.json ├── schema-no-content.json ├── params.json ├── multipart-requestbody.json ├── params-autofill.json ├── allof.json ├── anyof.json ├── oneOf.json ├── users_employees.yaml └── users_employees.json ├── apirouter ├── router.go ├── transformpath.go └── transformpath_test.go ├── .gitignore ├── Makefile ├── support ├── testdata │ ├── integration.json │ └── intergation-subrouter.json ├── fiber │ ├── fiber.go │ ├── fiber_test.go │ └── integration_test.go ├── echo │ ├── echo.go │ ├── echo_test.go │ └── integration_test.go └── gorilla │ ├── gorilla.go │ ├── gorilla_test.go │ ├── examples_test.go │ ├── integration_test.go │ └── testdata │ └── examples-users.json ├── LICENSE ├── operation.go ├── go.mod ├── operation_test.go ├── CHANGELOG.md ├── main.go ├── README.md ├── go.sum ├── route.go ├── main_test.go └── route_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @davidebianchi 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /testdata/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": {} 8 | } 9 | -------------------------------------------------------------------------------- /apirouter/router.go: -------------------------------------------------------------------------------- 1 | package apirouter 2 | 3 | type Router[HandlerFunc any, Route any] interface { 4 | AddRoute(method string, path string, handler HandlerFunc) Route 5 | SwaggerHandler(contentType string, blob []byte) HandlerFunc 6 | TransformPathToOasPath(path string) string 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /testdata/empty-route-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/": { 9 | "post": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | } 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /testdata/router_with_prefix.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/prefix/foo": { 9 | "get": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | } 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apirouter/transformpath.go: -------------------------------------------------------------------------------- 1 | package apirouter 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func TransformPathParamsWithColon(path string) string { 8 | pathParams := strings.Split(path, "/") 9 | for i, param := range pathParams { 10 | if strings.HasPrefix(param, ":") { 11 | pathParams[i] = strings.Replace(param, ":", "{", 1) + "}" 12 | } 13 | } 14 | return strings.Join(pathParams, "/") 15 | } 16 | -------------------------------------------------------------------------------- /testdata/tags.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/users": { 9 | "get": { 10 | "tags": ["Tag1", "Tag2"], 11 | "responses": { 12 | "default": { 13 | "description": "" 14 | } 15 | } 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /testdata/extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/users": { 9 | "get": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | }, 15 | "x-extension-field": { 16 | "foo": "bar" 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /testdata/users-deprecated-with-description.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/users": { 9 | "get": { 10 | "deprecated": true, 11 | "description": "this is the long route description", 12 | "responses": { 13 | "default": { 14 | "description": "" 15 | } 16 | }, 17 | "summary": "small description" 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /testdata/security.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/users": { 9 | "get": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | }, 15 | "security": [ 16 | { 17 | "api_key": [], 18 | "auth": [ 19 | "resource.write" 20 | ] 21 | } 22 | ] 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /testdata/query.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/projects": { 9 | "get": { 10 | "parameters": [ 11 | { 12 | "description": "projectId is the project id", 13 | "in": "query", 14 | "name": "projectId", 15 | "schema": { 16 | "type": "string" 17 | } 18 | } 19 | ], 20 | "responses": { 21 | "default": { 22 | "description": "" 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION ?= latest 2 | 3 | # Create a variable that contains the current date in UTC 4 | # Different flow if this script is running on Darwin or Linux machines. 5 | ifeq (Darwin,$(shell uname)) 6 | NOW_DATE = $(shell date -u +%d-%m-%Y) 7 | else 8 | NOW_DATE = $(shell date -u -I) 9 | endif 10 | 11 | all: test 12 | 13 | .PHONY: test 14 | test: 15 | go test ./... -coverprofile coverage.out 16 | 17 | .PHONY: version 18 | version: 19 | sed -i.bck "s|## Unreleased|## Unreleased\n\n## ${VERSION} - ${NOW_DATE}|g" "CHANGELOG.md" 20 | rm -fr "CHANGELOG.md.bck" 21 | git add "CHANGELOG.md" 22 | git commit -m "Upgrade version to v${VERSION}" 23 | git tag v${VERSION} 24 | -------------------------------------------------------------------------------- /testdata/subrouter.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/foo": { 9 | "get": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | } 15 | } 16 | }, 17 | "/prefix/taz": { 18 | "get": { 19 | "responses": { 20 | "default": { 21 | "description": "" 22 | } 23 | } 24 | } 25 | }, 26 | "/prefix": { 27 | "get": { 28 | "responses": { 29 | "default": { 30 | "description": "" 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /testdata/headers.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/projects": { 9 | "get": { 10 | "parameters": [ 11 | { 12 | "in": "header", 13 | "name": "bar", 14 | "schema": { 15 | "type": "string" 16 | } 17 | }, 18 | { 19 | "description": "foo description", 20 | "in": "header", 21 | "name": "foo", 22 | "schema": { 23 | "type": "string" 24 | } 25 | } 26 | ], 27 | "responses": { 28 | "default": { 29 | "description": "" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /support/testdata/integration.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/hello": { 9 | "get": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | } 15 | } 16 | }, 17 | "/hello/{value}": { 18 | "post": { 19 | "parameters": [ 20 | { 21 | "in": "path", 22 | "name": "value", 23 | "required": true, 24 | "schema": { 25 | "type": "string" 26 | } 27 | } 28 | ], 29 | "responses": { 30 | "default": { 31 | "description": "" 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /testdata/cookies.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/projects": { 9 | "get": { 10 | "parameters": [ 11 | { 12 | "in": "cookie", 13 | "name": "csrftoken", 14 | "schema": { 15 | "type": "string" 16 | } 17 | }, 18 | { 19 | "description": "boolean. Set 0 to disable and 1 to enable", 20 | "in": "cookie", 21 | "name": "debug", 22 | "schema": { 23 | "type": "integer" 24 | } 25 | } 26 | ], 27 | "responses": { 28 | "default": { 29 | "description": "" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /support/fiber/fiber.go: -------------------------------------------------------------------------------- 1 | package fiber 2 | 3 | import ( 4 | "github.com/davidebianchi/gswagger/apirouter" 5 | "github.com/gofiber/fiber/v2" 6 | ) 7 | 8 | type HandlerFunc = fiber.Handler 9 | type Route = fiber.Router 10 | 11 | type fiberRouter struct { 12 | router fiber.Router 13 | } 14 | 15 | func NewRouter(router fiber.Router) apirouter.Router[HandlerFunc, Route] { 16 | return fiberRouter{ 17 | router: router, 18 | } 19 | } 20 | 21 | func (r fiberRouter) AddRoute(method string, path string, handler HandlerFunc) Route { 22 | return r.router.Add(method, path, handler) 23 | } 24 | 25 | func (r fiberRouter) SwaggerHandler(contentType string, blob []byte) HandlerFunc { 26 | return func(c *fiber.Ctx) error { 27 | c.Set("Content-Type", contentType) 28 | return c.Send(blob) 29 | } 30 | } 31 | 32 | func (r fiberRouter) TransformPathToOasPath(path string) string { 33 | return apirouter.TransformPathParamsWithColon(path) 34 | } 35 | -------------------------------------------------------------------------------- /support/echo/echo.go: -------------------------------------------------------------------------------- 1 | package echo 2 | 3 | import ( 4 | "github.com/davidebianchi/gswagger/apirouter" 5 | 6 | "net/http" 7 | 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | type Route = *echo.Route 12 | 13 | type echoRouter struct { 14 | router *echo.Echo 15 | } 16 | 17 | func (r echoRouter) AddRoute(method string, path string, handler echo.HandlerFunc) Route { 18 | return r.router.Add(method, path, handler) 19 | } 20 | 21 | func (r echoRouter) SwaggerHandler(contentType string, blob []byte) echo.HandlerFunc { 22 | return func(c echo.Context) error { 23 | c.Response().Header().Add("Content-Type", contentType) 24 | return c.JSONBlob(http.StatusOK, blob) 25 | } 26 | } 27 | 28 | func (r echoRouter) TransformPathToOasPath(path string) string { 29 | return apirouter.TransformPathParamsWithColon(path) 30 | } 31 | 32 | func NewRouter(router *echo.Echo) apirouter.Router[echo.HandlerFunc, Route] { 33 | return echoRouter{ 34 | router: router, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /support/testdata/intergation-subrouter.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/hello": { 9 | "get": { 10 | "responses": { 11 | "default": { 12 | "description": "" 13 | } 14 | } 15 | } 16 | }, 17 | "/hello/{value}": { 18 | "post": { 19 | "parameters": [ 20 | { 21 | "in": "path", 22 | "name": "value", 23 | "required": true, 24 | "schema": { 25 | "type": "string" 26 | } 27 | } 28 | ], 29 | "responses": { 30 | "default": { 31 | "description": "" 32 | } 33 | } 34 | } 35 | }, 36 | "/prefix/foo": { 37 | "get": { 38 | "responses": { 39 | "default": { 40 | "description": "" 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /testdata/schema-no-content.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/{id}": { 9 | "post": { 10 | "parameters": [ 11 | { 12 | "in": "path", 13 | "name": "id", 14 | "required": true, 15 | "schema": {} 16 | }, 17 | { 18 | "in": "query", 19 | "name": "q", 20 | "schema": {} 21 | }, 22 | { 23 | "in": "header", 24 | "name": "key", 25 | "schema": {} 26 | }, 27 | { 28 | "in": "cookie", 29 | "name": "cookie1", 30 | "schema": {} 31 | } 32 | ], 33 | "requestBody": { 34 | "content": {}, 35 | "description": "request body without schema" 36 | }, 37 | "responses": { 38 | "204": { 39 | "description": "" 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /support/gorilla/gorilla.go: -------------------------------------------------------------------------------- 1 | package gorilla 2 | 3 | import ( 4 | "github.com/davidebianchi/gswagger/apirouter" 5 | 6 | "net/http" 7 | 8 | "github.com/gorilla/mux" 9 | ) 10 | 11 | // HandlerFunc is the http type handler used by gorilla/mux 12 | type HandlerFunc func(w http.ResponseWriter, req *http.Request) 13 | type Route = *mux.Route 14 | 15 | type gorillaRouter struct { 16 | router *mux.Router 17 | } 18 | 19 | func (r gorillaRouter) AddRoute(method string, path string, handler HandlerFunc) Route { 20 | return r.router.HandleFunc(path, handler).Methods(method) 21 | } 22 | 23 | func (r gorillaRouter) SwaggerHandler(contentType string, blob []byte) HandlerFunc { 24 | return func(w http.ResponseWriter, req *http.Request) { 25 | w.Header().Set("Content-Type", contentType) 26 | w.WriteHeader(http.StatusOK) 27 | w.Write(blob) 28 | } 29 | } 30 | 31 | func (r gorillaRouter) TransformPathToOasPath(path string) string { 32 | return path 33 | } 34 | 35 | func NewRouter(router *mux.Router) apirouter.Router[HandlerFunc, Route] { 36 | return gorillaRouter{ 37 | router: router, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Davide Bianchi 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 | -------------------------------------------------------------------------------- /testdata/params.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/cars/{carId}/drivers/{driverId}": { 9 | "get": { 10 | "parameters": [ 11 | { 12 | "in": "path", 13 | "name": "carId", 14 | "required": true, 15 | "schema": { 16 | "type": "string" 17 | } 18 | }, 19 | { 20 | "in": "path", 21 | "name": "driverId", 22 | "required": true, 23 | "schema": { 24 | "type": "string" 25 | } 26 | } 27 | ], 28 | "responses": { 29 | "default": { 30 | "description": "" 31 | } 32 | } 33 | } 34 | }, 35 | "/users/{userId}": { 36 | "get": { 37 | "parameters": [ 38 | { 39 | "description": "userId is a number above 0", 40 | "in": "path", 41 | "name": "userId", 42 | "required": true, 43 | "schema": { 44 | "type": "integer" 45 | } 46 | } 47 | ], 48 | "responses": { 49 | "default": { 50 | "description": "" 51 | } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /apirouter/transformpath_test.go: -------------------------------------------------------------------------------- 1 | package apirouter 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestTransformPathParamsWithColon(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | path string 13 | expectedPath string 14 | }{ 15 | { 16 | name: "only /", 17 | path: "/", 18 | expectedPath: "/", 19 | }, 20 | { 21 | name: "without params", 22 | path: "/foo", 23 | expectedPath: "/foo", 24 | }, 25 | { 26 | name: "without params ending with /", 27 | path: "/foo/", 28 | expectedPath: "/foo/", 29 | }, 30 | { 31 | name: "with params", 32 | path: "/foo/:par1", 33 | expectedPath: "/foo/{par1}", 34 | }, 35 | { 36 | name: "with params ending with /", 37 | path: "/foo/:par1/", 38 | expectedPath: "/foo/{par1}/", 39 | }, 40 | { 41 | name: "with multiple params", 42 | path: "/:par1/:par2/:par3", 43 | expectedPath: "/{par1}/{par2}/{par3}", 44 | }, 45 | { 46 | name: "with multiple params ending with /", 47 | path: "/:par1/:par2/:par3/", 48 | expectedPath: "/{par1}/{par2}/{par3}/", 49 | }, 50 | } 51 | 52 | for _, test := range testCases { 53 | t.Run(test.name, func(t *testing.T) { 54 | actual := TransformPathParamsWithColon(test.path) 55 | 56 | require.Equal(t, test.expectedPath, actual) 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/test-builds.yml: -------------------------------------------------------------------------------- 1 | name: Test and build 2 | on: 3 | pull_request: 4 | types: [opened] 5 | push: 6 | jobs: 7 | tests: 8 | name: Test on go ${{ matrix.go_version }} os ${{ matrix.os }} 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | go_version: ['1.24', '1.25'] 13 | os: [ubuntu-latest] 14 | include: 15 | - go_version: '1.25' 16 | os: macos-latest 17 | steps: 18 | - uses: actions/checkout@v1 19 | - name: Use golang ${{ matrix.go_version }} 20 | uses: actions/setup-go@v1 21 | with: 22 | go-version: ${{ matrix.go_version }} 23 | 24 | - name: Go version 25 | run: | 26 | go version 27 | - name: Go get dependencies 28 | run: | 29 | go get -v -t -d ./... 30 | - name: Run tests 31 | run: | 32 | go test ./... -count=1 -race -cover -coverprofile cover.out 33 | - name: Build 34 | run: | 35 | go build -v . 36 | - name: Send the coverage output 37 | uses: shogo82148/actions-goveralls@v1 38 | with: 39 | path-to-profile: cover.out 40 | flag-name: Go-${{ matrix.go_version }} 41 | parallel: true 42 | 43 | post-tests: 44 | runs-on: ubuntu-latest 45 | needs: tests 46 | steps: 47 | - name: Close coverage report 48 | uses: shogo82148/actions-goveralls@v1 49 | with: 50 | parallel-finished: true 51 | -------------------------------------------------------------------------------- /testdata/multipart-requestbody.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/files": { 9 | "post": { 10 | "requestBody": { 11 | "description": "upload file", 12 | "content": { 13 | "multipart/form-data": { 14 | "schema": { 15 | "type": "object", 16 | "properties": { 17 | "id": { 18 | "type": "string" 19 | }, 20 | "address": { 21 | "type": "object", 22 | "properties": { 23 | "street": { 24 | "type": "string" 25 | }, 26 | "city": { 27 | "type": "string" 28 | } 29 | } 30 | }, 31 | "profileImage": { 32 | "type": "string", 33 | "format": "binary" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | }, 40 | "responses": { 41 | "200": { 42 | "description": "", 43 | "content": { 44 | "application/json": { 45 | "schema": { 46 | "type": "string" 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /operation.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import ( 4 | "github.com/getkin/kin-openapi/openapi3" 5 | ) 6 | 7 | // Operation type 8 | type Operation struct { 9 | *openapi3.Operation 10 | } 11 | 12 | // NewOperation returns an OpenApi operation. 13 | func NewOperation() Operation { 14 | return Operation{ 15 | openapi3.NewOperation(), 16 | } 17 | } 18 | 19 | // AddRequestBody set request body into operation. 20 | func (o *Operation) AddRequestBody(requestBody *openapi3.RequestBody) { 21 | o.RequestBody = &openapi3.RequestBodyRef{ 22 | Value: requestBody, 23 | } 24 | } 25 | 26 | // AddResponse add response to operation. It check if the description is present 27 | // (otherwise default to empty string). This method does not add the default response, 28 | // but it is always possible to add it manually. 29 | func (o *Operation) AddResponse(status int, response *openapi3.Response) { 30 | if o.Responses == nil { 31 | o.Responses = &openapi3.Responses{} 32 | } 33 | if response.Description == nil { 34 | // a description is required by kin openapi, so we set an empty description 35 | // if it is not given. 36 | response.WithDescription("") 37 | } 38 | o.Operation.AddResponse(status, response) 39 | } 40 | 41 | func (o *Operation) addSecurityRequirements(securityRequirements SecurityRequirements) { 42 | if securityRequirements != nil && o.Security == nil { 43 | o.Security = openapi3.NewSecurityRequirements() 44 | } 45 | for _, securityRequirement := range securityRequirements { 46 | o.Security.With(openapi3.SecurityRequirement(securityRequirement)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /support/fiber/fiber_test.go: -------------------------------------------------------------------------------- 1 | package fiber 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/davidebianchi/gswagger/apirouter" 10 | 11 | "github.com/gofiber/fiber/v2" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestFiberRouterSupport(t *testing.T) { 16 | fiberRouter := fiber.New() 17 | ar := NewRouter(fiberRouter) 18 | 19 | t.Run("create a new api router", func(t *testing.T) { 20 | require.Implements(t, (*apirouter.Router[HandlerFunc, Route])(nil), ar) 21 | }) 22 | 23 | t.Run("add new route", func(t *testing.T) { 24 | route := ar.AddRoute(http.MethodGet, "/foo", func(c *fiber.Ctx) error { 25 | return c.SendStatus(http.StatusOK) 26 | }) 27 | require.IsType(t, route, fiber.New()) 28 | 29 | t.Run("router exposes correctly api", func(t *testing.T) { 30 | r := httptest.NewRequest(http.MethodGet, "/foo", nil) 31 | 32 | resp, err := fiberRouter.Test(r) 33 | require.NoError(t, err) 34 | require.Equal(t, http.StatusOK, resp.StatusCode) 35 | }) 36 | 37 | t.Run("router exposes api only to the specific method", func(t *testing.T) { 38 | r := httptest.NewRequest(http.MethodPost, "/foo", nil) 39 | 40 | resp, err := fiberRouter.Test(r) 41 | require.NoError(t, err) 42 | require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) 43 | }) 44 | }) 45 | 46 | t.Run("create openapi handler", func(t *testing.T) { 47 | handlerFunc := ar.SwaggerHandler("text/html", []byte("some data")) 48 | fiberRouter.Get("/oas", handlerFunc) 49 | 50 | t.Run("responds correctly to the API", func(t *testing.T) { 51 | r := httptest.NewRequest(http.MethodGet, "/oas", nil) 52 | 53 | resp, err := fiberRouter.Test(r) 54 | require.NoError(t, err) 55 | require.Equal(t, http.StatusOK, resp.StatusCode) 56 | require.Equal(t, "text/html", resp.Header.Get("Content-Type")) 57 | 58 | body, err := io.ReadAll(resp.Body) 59 | require.NoError(t, err) 60 | require.Equal(t, "some data", string(body)) 61 | }) 62 | }) 63 | } 64 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/davidebianchi/gswagger 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/getkin/kin-openapi v0.131.0 7 | github.com/ghodss/yaml v1.0.0 8 | github.com/gofiber/fiber/v2 v2.52.5 9 | github.com/gorilla/mux v1.8.1 10 | github.com/invopop/jsonschema v0.12.0 11 | github.com/labstack/echo/v4 v4.12.0 12 | github.com/stretchr/testify v1.9.0 13 | ) 14 | 15 | require ( 16 | github.com/andybalholm/brotli v1.0.5 // indirect 17 | github.com/bahlo/generic-list-go v0.2.0 // indirect 18 | github.com/buger/jsonparser v1.1.1 // indirect 19 | github.com/davecgh/go-spew v1.1.1 // indirect 20 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 21 | github.com/go-openapi/swag v0.23.0 // indirect 22 | github.com/google/uuid v1.5.0 // indirect 23 | github.com/josharian/intern v1.0.0 // indirect 24 | github.com/klauspost/compress v1.17.0 // indirect 25 | github.com/labstack/gommon v0.4.2 // indirect 26 | github.com/mailru/easyjson v0.7.7 // indirect 27 | github.com/mattn/go-colorable v0.1.13 // indirect 28 | github.com/mattn/go-isatty v0.0.20 // indirect 29 | github.com/mattn/go-runewidth v0.0.15 // indirect 30 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 31 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 32 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 33 | github.com/perimeterx/marshmallow v1.1.5 // indirect 34 | github.com/pmezard/go-difflib v1.0.0 // indirect 35 | github.com/rivo/uniseg v0.2.0 // indirect 36 | github.com/valyala/bytebufferpool v1.0.0 // indirect 37 | github.com/valyala/fasthttp v1.51.0 // indirect 38 | github.com/valyala/fasttemplate v1.2.2 // indirect 39 | github.com/valyala/tcplisten v1.0.0 // indirect 40 | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect 41 | golang.org/x/crypto v0.22.0 // indirect 42 | golang.org/x/net v0.24.0 // indirect 43 | golang.org/x/sys v0.19.0 // indirect 44 | golang.org/x/text v0.14.0 // indirect 45 | gopkg.in/yaml.v2 v2.4.0 // indirect 46 | gopkg.in/yaml.v3 v3.0.1 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /support/echo/echo_test.go: -------------------------------------------------------------------------------- 1 | package echo 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/davidebianchi/gswagger/apirouter" 10 | "github.com/labstack/echo/v4" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestGorillaMuxRouter(t *testing.T) { 15 | echoRouter := echo.New() 16 | ar := NewRouter(echoRouter) 17 | 18 | t.Run("create a new api router", func(t *testing.T) { 19 | require.Implements(t, (*apirouter.Router[echo.HandlerFunc, Route])(nil), ar) 20 | }) 21 | 22 | t.Run("add new route", func(t *testing.T) { 23 | route := ar.AddRoute(http.MethodGet, "/foo", func(c echo.Context) error { 24 | return c.String(http.StatusOK, "") 25 | }) 26 | require.IsType(t, route, &echo.Route{}) 27 | 28 | t.Run("router exposes correctly api", func(t *testing.T) { 29 | w := httptest.NewRecorder() 30 | r := httptest.NewRequest(http.MethodGet, "/foo", nil) 31 | 32 | echoRouter.ServeHTTP(w, r) 33 | 34 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 35 | }) 36 | 37 | t.Run("router exposes api only to the specific method", func(t *testing.T) { 38 | w := httptest.NewRecorder() 39 | r := httptest.NewRequest(http.MethodPost, "/foo", nil) 40 | 41 | echoRouter.ServeHTTP(w, r) 42 | 43 | require.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) 44 | }) 45 | }) 46 | 47 | t.Run("create openapi handler", func(t *testing.T) { 48 | handlerFunc := ar.SwaggerHandler("text/html", []byte("some data")) 49 | echoRouter.GET("/oas", handlerFunc) 50 | 51 | t.Run("responds correctly to the API", func(t *testing.T) { 52 | w := httptest.NewRecorder() 53 | r := httptest.NewRequest(http.MethodGet, "/oas", nil) 54 | 55 | echoRouter.ServeHTTP(w, r) 56 | 57 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 58 | require.Equal(t, "text/html", w.Result().Header.Get("Content-Type")) 59 | 60 | body, err := io.ReadAll(w.Result().Body) 61 | require.NoError(t, err) 62 | require.Equal(t, "some data", string(body)) 63 | }) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /support/gorilla/gorilla_test.go: -------------------------------------------------------------------------------- 1 | package gorilla 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/davidebianchi/gswagger/apirouter" 10 | "github.com/gorilla/mux" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestGorillaMuxRouter(t *testing.T) { 15 | muxRouter := mux.NewRouter() 16 | ar := NewRouter(muxRouter) 17 | 18 | t.Run("create a new api router", func(t *testing.T) { 19 | require.Implements(t, (*apirouter.Router[HandlerFunc, Route])(nil), ar) 20 | }) 21 | 22 | t.Run("add new route", func(t *testing.T) { 23 | route := ar.AddRoute(http.MethodGet, "/foo", func(w http.ResponseWriter, req *http.Request) { 24 | w.WriteHeader(200) 25 | w.Write(nil) 26 | }) 27 | require.IsType(t, route, &mux.Route{}) 28 | 29 | t.Run("router exposes correctly api", func(t *testing.T) { 30 | w := httptest.NewRecorder() 31 | r := httptest.NewRequest(http.MethodGet, "/foo", nil) 32 | 33 | muxRouter.ServeHTTP(w, r) 34 | 35 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 36 | }) 37 | 38 | t.Run("router exposes api only to the specific method", func(t *testing.T) { 39 | w := httptest.NewRecorder() 40 | r := httptest.NewRequest(http.MethodPost, "/foo", nil) 41 | 42 | muxRouter.ServeHTTP(w, r) 43 | 44 | require.Equal(t, http.StatusMethodNotAllowed, w.Result().StatusCode) 45 | }) 46 | }) 47 | 48 | t.Run("create openapi handler", func(t *testing.T) { 49 | handlerFunc := ar.SwaggerHandler("text/html", []byte("some data")) 50 | muxRouter.HandleFunc("/oas", handlerFunc).Methods(http.MethodGet) 51 | 52 | t.Run("responds correctly to the API", func(t *testing.T) { 53 | w := httptest.NewRecorder() 54 | r := httptest.NewRequest(http.MethodGet, "/oas", nil) 55 | 56 | muxRouter.ServeHTTP(w, r) 57 | 58 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 59 | require.Equal(t, "text/html", w.Result().Header.Get("Content-Type")) 60 | 61 | body, err := io.ReadAll(w.Result().Body) 62 | require.NoError(t, err) 63 | require.Equal(t, "some data", string(body)) 64 | }) 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /testdata/params-autofill.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/cars/{carId}/drivers/{driverId}": { 9 | "get": { 10 | "parameters": [ 11 | { 12 | "in": "path", 13 | "name": "carId", 14 | "required": true, 15 | "schema": { 16 | "type": "string" 17 | } 18 | }, 19 | { 20 | "in": "path", 21 | "name": "driverId", 22 | "required": true, 23 | "schema": { 24 | "type": "string" 25 | } 26 | } 27 | ], 28 | "responses": { 29 | "default": { 30 | "description": "" 31 | } 32 | } 33 | } 34 | }, 35 | "/users/{userId}": { 36 | "get": { 37 | "parameters": [ 38 | { 39 | "in": "path", 40 | "name": "userId", 41 | "required": true, 42 | "schema": { 43 | "type": "string" 44 | } 45 | }, 46 | { 47 | "in": "query", 48 | "name": "query", 49 | "schema": { 50 | "type": "string" 51 | } 52 | } 53 | ], 54 | "responses": { 55 | "default": { 56 | "description": "" 57 | } 58 | } 59 | } 60 | }, 61 | "/files/{name}.{extension}": { 62 | "get": { 63 | "parameters": [ 64 | { 65 | "in": "path", 66 | "name": "extension", 67 | "required": true, 68 | "schema": { 69 | "type": "string" 70 | } 71 | }, 72 | { 73 | "in": "path", 74 | "name": "name", 75 | "required": true, 76 | "schema": { 77 | "type": "string" 78 | } 79 | } 80 | ], 81 | "responses": { 82 | "default": { 83 | "description": "" 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /operation_test.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestNewOperation(t *testing.T) { 12 | schema := openapi3.NewObjectSchema().WithProperties(map[string]*openapi3.Schema{ 13 | "foo": openapi3.NewStringSchema(), 14 | "bar": openapi3.NewIntegerSchema().WithMax(15).WithMin(5), 15 | }) 16 | 17 | tests := []struct { 18 | name string 19 | getOperation func(t *testing.T, operation Operation) Operation 20 | expectedJSON string 21 | }{ 22 | { 23 | name: "add request body", 24 | getOperation: func(t *testing.T, operation Operation) Operation { 25 | requestBody := openapi3.NewRequestBody().WithJSONSchema(schema) 26 | operation.AddRequestBody(requestBody) 27 | operation.Responses = openapi3.NewResponses() 28 | return operation 29 | }, 30 | expectedJSON: `{"info":{"title":"test openapi title","version":"test openapi version"},"openapi":"3.0.0","paths":{"/":{"post":{"requestBody":{"content":{"application/json":{"schema":{"properties":{"bar":{"maximum":15,"minimum":5,"type":"integer"},"foo":{"type":"string"}},"type":"object"}}}},"responses":{"default":{"description":""}}}}}}`, 31 | }, 32 | { 33 | name: "add response", 34 | getOperation: func(t *testing.T, operation Operation) Operation { 35 | response := openapi3.NewResponse().WithJSONSchema(schema) 36 | operation.AddResponse(200, response) 37 | return operation 38 | }, 39 | expectedJSON: `{"info":{"title":"test openapi title","version":"test openapi version"},"openapi":"3.0.0","paths":{"/":{"post":{"responses":{"200":{"content":{"application/json":{"schema":{"properties":{"bar":{"maximum":15,"minimum":5,"type":"integer"},"foo":{"type":"string"}},"type":"object"}}},"description":""}}}}}}`, 40 | }, 41 | } 42 | 43 | for _, test := range tests { 44 | t.Run(test.name, func(t *testing.T) { 45 | openapi := getBaseSwagger(t) 46 | openapi.OpenAPI = "3.0.0" 47 | operation := test.getOperation(t, NewOperation()) 48 | 49 | openapi.AddOperation("/", http.MethodPost, operation.Operation) 50 | 51 | data, _ := openapi.MarshalJSON() 52 | jsonData := string(data) 53 | require.JSONEq(t, test.expectedJSON, jsonData, "actual json data: %s", jsonData) 54 | }) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /testdata/allof.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/all-of": { 9 | "post": { 10 | "requestBody": { 11 | "content": { 12 | "application/json": { 13 | "schema": { 14 | "allOf": [ 15 | { 16 | "maximum": 2, 17 | "minimum": 1, 18 | "type": "number" 19 | }, 20 | { 21 | "maximum": 3, 22 | "minimum": 2, 23 | "type": "number" 24 | } 25 | ] 26 | } 27 | } 28 | } 29 | }, 30 | "responses": { 31 | "200": { 32 | "content": { 33 | "application/json": { 34 | "schema": { 35 | "allOf": [ 36 | { 37 | "maximum": 2, 38 | "minimum": 1, 39 | "type": "number" 40 | }, 41 | { 42 | "maximum": 3, 43 | "minimum": 2, 44 | "type": "number" 45 | } 46 | ] 47 | } 48 | } 49 | }, 50 | "description": "" 51 | } 52 | } 53 | } 54 | }, 55 | "/nested-schema": { 56 | "get": { 57 | "responses": { 58 | "200": { 59 | "content": { 60 | "application/json": { 61 | "schema": { 62 | "properties": { 63 | "foo": { 64 | "type": "string" 65 | }, 66 | "nested": { 67 | "allOf": [ 68 | { 69 | "maximum": 2, 70 | "minimum": 1, 71 | "type": "number" 72 | }, 73 | { 74 | "maximum": 3, 75 | "minimum": 2, 76 | "type": "number" 77 | } 78 | ] 79 | } 80 | } 81 | } 82 | } 83 | }, 84 | "description": "" 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /testdata/anyof.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/any-of": { 9 | "post": { 10 | "requestBody": { 11 | "content": { 12 | "application/json": { 13 | "schema": { 14 | "anyOf": [ 15 | { 16 | "maximum": 2, 17 | "minimum": 1, 18 | "type": "number" 19 | }, 20 | { 21 | "maximum": 3, 22 | "minimum": 2, 23 | "type": "number" 24 | } 25 | ] 26 | } 27 | } 28 | } 29 | }, 30 | "responses": { 31 | "200": { 32 | "content": { 33 | "application/json": { 34 | "schema": { 35 | "anyOf": [ 36 | { 37 | "maximum": 2, 38 | "minimum": 1, 39 | "type": "number" 40 | }, 41 | { 42 | "maximum": 3, 43 | "minimum": 2, 44 | "type": "number" 45 | } 46 | ] 47 | } 48 | } 49 | }, 50 | "description": "" 51 | } 52 | } 53 | } 54 | }, 55 | "/nested-schema": { 56 | "get": { 57 | "responses": { 58 | "200": { 59 | "content": { 60 | "application/json": { 61 | "schema": { 62 | "properties": { 63 | "foo": { 64 | "type": "string" 65 | }, 66 | "nested": { 67 | "anyOf": [ 68 | { 69 | "maximum": 2, 70 | "minimum": 1, 71 | "type": "number" 72 | }, 73 | { 74 | "maximum": 3, 75 | "minimum": 2, 76 | "type": "number" 77 | } 78 | ] 79 | } 80 | } 81 | } 82 | } 83 | }, 84 | "description": "" 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /testdata/oneOf.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/one-of": { 9 | "post": { 10 | "requestBody": { 11 | "content": { 12 | "application/json": { 13 | "schema": { 14 | "oneOf": [ 15 | { 16 | "maximum": 2, 17 | "minimum": 1, 18 | "type": "number" 19 | }, 20 | { 21 | "maximum": 3, 22 | "minimum": 2, 23 | "type": "number" 24 | } 25 | ] 26 | } 27 | } 28 | } 29 | }, 30 | "responses": { 31 | "200": { 32 | "content": { 33 | "application/json": { 34 | "schema": { 35 | "oneOf": [ 36 | { 37 | "maximum": 2, 38 | "minimum": 1, 39 | "type": "number" 40 | }, 41 | { 42 | "maximum": 3, 43 | "minimum": 2, 44 | "type": "number" 45 | } 46 | ] 47 | } 48 | } 49 | }, 50 | "description": "" 51 | } 52 | } 53 | } 54 | }, 55 | "/user-profile": { 56 | "post": { 57 | "requestBody": { 58 | "content": { 59 | "application/json": { 60 | "schema": { 61 | "additionalProperties": false, 62 | "properties": { 63 | "firstName": { 64 | "title": "user first name", 65 | "type": "string" 66 | }, 67 | "lastName": { 68 | "title": "user last name", 69 | "type": "string" 70 | }, 71 | "metadata": { 72 | "oneOf": [ 73 | { 74 | "type": "string" 75 | }, 76 | { 77 | "type": "number" 78 | } 79 | ], 80 | "title": "custom properties" 81 | }, 82 | "userType": { 83 | "title": "type of user", 84 | "type": "string", 85 | "enum": ["simple", "advanced"] 86 | } 87 | }, 88 | "required": [ 89 | "firstName", 90 | "lastName" 91 | ], 92 | "type": "object" 93 | } 94 | } 95 | } 96 | }, 97 | "responses": { 98 | "200": { 99 | "content": { 100 | "text/plain": { 101 | "schema": { 102 | "type": "string" 103 | } 104 | } 105 | }, 106 | "description": "" 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /support/gorilla/examples_test.go: -------------------------------------------------------------------------------- 1 | package gorilla_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | swagger "github.com/davidebianchi/gswagger" 12 | "github.com/davidebianchi/gswagger/support/gorilla" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/getkin/kin-openapi/openapi3" 16 | "github.com/gorilla/mux" 17 | ) 18 | 19 | func TestExample(t *testing.T) { 20 | context := context.Background() 21 | muxRouter := mux.NewRouter() 22 | 23 | router, _ := swagger.NewRouter(gorilla.NewRouter(muxRouter), swagger.Options{ 24 | Context: context, 25 | Openapi: &openapi3.T{ 26 | Info: &openapi3.Info{ 27 | Title: "my title", 28 | Version: "1.0.0", 29 | }, 30 | }, 31 | }) 32 | 33 | okHandler := func(w http.ResponseWriter, req *http.Request) { 34 | w.WriteHeader(http.StatusOK) 35 | w.Write([]byte("OK")) 36 | } 37 | 38 | type User struct { 39 | Name string `json:"name" jsonschema:"title=The user name,required" jsonschema_extras:"example=Jane"` 40 | PhoneNumber int `json:"phone" jsonschema:"title=mobile number of user"` 41 | Groups []string `json:"groups,omitempty" jsonschema:"title=groups of the user,default=users"` 42 | Address string `json:"address" jsonschema:"title=user address"` 43 | } 44 | type errorResponse struct { 45 | Message string `json:"message"` 46 | } 47 | 48 | router.AddRoute(http.MethodPost, "/users", okHandler, swagger.Definitions{ 49 | RequestBody: &swagger.ContentValue{ 50 | Content: swagger.Content{ 51 | "application/json": {Value: User{}}, 52 | }, 53 | }, 54 | Responses: map[int]swagger.ContentValue{ 55 | 201: { 56 | Content: swagger.Content{ 57 | "text/html": {Value: ""}, 58 | }, 59 | }, 60 | 401: { 61 | Content: swagger.Content{ 62 | "application/json": {Value: &errorResponse{}}, 63 | }, 64 | Description: "invalid request", 65 | }, 66 | }, 67 | }) 68 | 69 | router.AddRoute(http.MethodGet, "/users", okHandler, swagger.Definitions{ 70 | Responses: map[int]swagger.ContentValue{ 71 | 200: { 72 | Content: swagger.Content{ 73 | "application/json": {Value: &[]User{}}, 74 | }, 75 | }, 76 | }, 77 | }) 78 | 79 | carSchema := openapi3.NewObjectSchema().WithProperties(map[string]*openapi3.Schema{ 80 | "foo": openapi3.NewStringSchema(), 81 | "bar": openapi3.NewIntegerSchema().WithMax(15).WithMin(5), 82 | }) 83 | requestBody := openapi3.NewRequestBody().WithJSONSchema(carSchema) 84 | operation := swagger.NewOperation() 85 | operation.AddRequestBody(requestBody) 86 | 87 | router.AddRawRoute(http.MethodPost, "/cars", okHandler, operation) 88 | 89 | _, err := router.AddRoute(http.MethodGet, "/users/{userId}", okHandler, swagger.Definitions{ 90 | Querystring: swagger.ParameterValue{ 91 | "query": { 92 | Schema: &swagger.Schema{Value: ""}, 93 | }, 94 | }, 95 | }) 96 | require.NoError(t, err) 97 | 98 | _, err = router.AddRoute(http.MethodGet, "/cars/{carId}/drivers/{driverId}", okHandler, swagger.Definitions{}) 99 | require.NoError(t, err) 100 | 101 | router.GenerateAndExposeOpenapi() 102 | 103 | t.Run("correctly exposes documentation", func(t *testing.T) { 104 | w := httptest.NewRecorder() 105 | req := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 106 | 107 | muxRouter.ServeHTTP(w, req) 108 | 109 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 110 | require.Equal(t, "application/json", w.Result().Header.Get("Content-Type")) 111 | 112 | body, err := io.ReadAll(w.Result().Body) 113 | require.NoError(t, err) 114 | expected, err := os.ReadFile("./testdata/examples-users.json") 115 | require.NoError(t, err) 116 | require.JSONEq(t, string(expected), string(body), "actual json data: %s", body) 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /testdata/users_employees.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | title: test openapi title 3 | version: test openapi version 4 | openapi: 3.0.0 5 | paths: 6 | /employees: 7 | get: 8 | responses: 9 | '200': 10 | content: 11 | application/json: 12 | schema: 13 | additionalProperties: false 14 | properties: 15 | organization_name: 16 | type: string 17 | users: 18 | items: 19 | additionalProperties: false 20 | properties: 21 | address: 22 | title: user address 23 | type: string 24 | groups: 25 | default: 26 | - users 27 | items: 28 | type: string 29 | title: groups of the user 30 | type: array 31 | name: 32 | example: Jane 33 | title: The user name 34 | type: string 35 | phone: 36 | title: mobile number of user 37 | type: integer 38 | required: 39 | - name 40 | - phone 41 | - address 42 | type: object 43 | type: array 44 | required: 45 | - organization_name 46 | - users 47 | type: object 48 | description: '' 49 | /users: 50 | get: 51 | responses: 52 | '200': 53 | content: 54 | application/json: 55 | schema: 56 | items: 57 | additionalProperties: false 58 | properties: 59 | address: 60 | title: user address 61 | type: string 62 | groups: 63 | default: 64 | - users 65 | items: 66 | type: string 67 | title: groups of the user 68 | type: array 69 | name: 70 | example: Jane 71 | title: The user name 72 | type: string 73 | phone: 74 | title: mobile number of user 75 | type: integer 76 | required: 77 | - name 78 | - phone 79 | - address 80 | type: object 81 | type: array 82 | description: '' 83 | post: 84 | requestBody: 85 | content: 86 | application/json: 87 | schema: 88 | additionalProperties: false 89 | properties: 90 | address: 91 | title: user address 92 | type: string 93 | groups: 94 | default: 95 | - users 96 | items: 97 | type: string 98 | title: groups of the user 99 | type: array 100 | name: 101 | example: Jane 102 | title: The user name 103 | type: string 104 | phone: 105 | title: mobile number of user 106 | type: integer 107 | required: 108 | - name 109 | - phone 110 | - address 111 | type: object 112 | responses: 113 | '201': 114 | content: 115 | text/html: 116 | schema: 117 | type: string 118 | description: '' 119 | '401': 120 | content: 121 | application/json: 122 | schema: 123 | additionalProperties: false 124 | properties: 125 | message: 126 | type: string 127 | required: 128 | - message 129 | type: object 130 | description: invalid request 131 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## Unreleased 9 | 10 | ## 0.10.1 - 06-10-2025 11 | 12 | ### Refactor 13 | 14 | - renamed swagger to openapi in error messages 15 | 16 | ## 0.10.0 - 28-02-2024 17 | 18 | ### BREAKING CHANGES 19 | 20 | - remove support to golang 1.19 21 | 22 | ### Updated 23 | 24 | - change jsonschema lib to use again `invopop/jsonschema` one, since now it support additionalProperties instead of patternProperties 25 | 26 | ## 0.9.0 - 17-04-2023 27 | 28 | ### Added 29 | 30 | - add new fields to Definition: 31 | - Security 32 | - Tags 33 | - Summary 34 | - Description 35 | - Deprecated 36 | - Extensions 37 | 38 | ### Updated 39 | 40 | - update kin-openapi to 0.114.0: this change removes components from exposed oas (if not manually set). In this update of kin-openapi, there is a breaking change with the T struct, which now accepts component as pointer. 41 | 42 | ## 0.8.0 - 23-12-2022 43 | 44 | ### BREAKING CHANGES 45 | 46 | - add `TransformPathToOasPath(path string) string` method to apirouter.Router interface to handle different types of paths parameters. If you use one of the supported routers, you should do nothing; 47 | 48 | ## 0.7.0 - 22-12-2022 49 | 50 | This is a big major release. The main achievement is to increase the usability of this library to all the routers. 51 | Below are listed the breaking changes you should care when update the version. 52 | 53 | ### BREAKING CHANGES 54 | 55 | - `apirouter.NewGorillaMuxRouter` is now `gorilla.NewRouter` (exposed by package `github.com/davidebianchi/gswagger/support/gorilla`). 56 | - removed `apirouter.HandlerFunc`. Now it is exposed by `gorilla.HandlerFunc` 57 | - changed `apirouter.Router` interface: 58 | - now it accept a generics `HandlerFunc` to define the handler function 59 | - add method `SwaggerHandler(contentType string, json []byte) HandlerFunc` 60 | - `NewRouter` function now accept `HandlerFunc` as generics 61 | - drop support to golang <= 1.17 62 | - `GenerateAndExposeSwagger` renamed to `GenerateAndExposeOpenapi` 63 | 64 | ### Feature 65 | 66 | - support to different types of routers 67 | - add [fiber](https://github.com/gofiber/fiber) support 68 | - add [echo](https://echo.labstack.com/) support 69 | 70 | ## 0.6.1 - 17-11-2022 71 | 72 | ### Changed 73 | 74 | - change jsonschema lib to `mia-platform/jsonschema v0.1.0`. This update removes the `patternProperties` with `additionalProperties` from all schemas 75 | - remove use of deprecated io/ioutil lib 76 | 77 | ## 0.6.0 - 04-11-2022 78 | 79 | ### Added 80 | 81 | - Tags support to `router.AddRoute` accepted definition 82 | 83 | ## 0.5.1 - 03-10-2022 84 | 85 | ### Fixed 86 | 87 | - upgrade deps 88 | 89 | ## v0.5.0 - 05-08-2022 90 | 91 | ### Added 92 | 93 | - path params are auto generated if not set 94 | 95 | ## v0.4.0 - 02-08-2022 96 | 97 | ### Changed 98 | 99 | - change jsonschema lib to `invopop/jsonschema v0.5.0`. This updates remove the `additionalProperties: true` from all the schemas, as it is the default value 100 | 101 | ### BREAKING CHANGES 102 | 103 | - modified Router interface by sorting addRoute arguments in a different manner: first method and then path 104 | To migrate, all the router implementation must be updated with the Router interface change. 105 | 106 | Before: 107 | 108 | ```go 109 | type Router interface { 110 | AddRoute(path, method string, handler HandlerFunc) Route 111 | } 112 | ``` 113 | 114 | After: 115 | 116 | ```go 117 | type Router interface { 118 | AddRoute(method, path string, handler HandlerFunc) Route 119 | } 120 | ``` 121 | 122 | ### Updates 123 | 124 | - kin-openapi@v0.98.0 125 | - go-openapi/swag@v0.21.1 126 | - labstack/echo/v4@v4.7.2 127 | 128 | ## v0.3.0 - 10-11-2021 129 | 130 | ### Added 131 | 132 | - handle router with path prefix 133 | - add SubRouter method to use a new sub router 134 | 135 | ## v0.2.0 - 16-10-2021 136 | 137 | ### BREAKING CHANGES 138 | 139 | Introduced the `apirouter.Router` interface, which abstract the used router. 140 | Changed function are: 141 | 142 | - Router struct now support `apirouter.Router` interface 143 | - NewRouter function accepted router is an `apirouter.Router` 144 | - AddRawRoute now accept `apirouter.Handler` iterface 145 | - AddRawRoute returns an interface instead of *mux.Router. This interface is the Route returned by specific Router 146 | - AddRoute now accept `apirouter.Handler` iterface 147 | - AddRoute returns an interface instead of *mux.Router. This interface is the Route returned by specific Router 148 | 149 | ## v0.1.0 150 | 151 | Initial release 152 | -------------------------------------------------------------------------------- /support/gorilla/integration_test.go: -------------------------------------------------------------------------------- 1 | package gorilla_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | swagger "github.com/davidebianchi/gswagger" 12 | "github.com/davidebianchi/gswagger/support/gorilla" 13 | "github.com/getkin/kin-openapi/openapi3" 14 | "github.com/gorilla/mux" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | const ( 19 | swaggerOpenapiTitle = "test openapi title" 20 | swaggerOpenapiVersion = "test openapi version" 21 | ) 22 | 23 | type SwaggerRouter = swagger.Router[gorilla.HandlerFunc, gorilla.Route] 24 | 25 | func TestGorillaIntegration(t *testing.T) { 26 | t.Run("router works correctly", func(t *testing.T) { 27 | muxRouter, oasRouter := setupSwagger(t) 28 | 29 | err := oasRouter.GenerateAndExposeOpenapi() 30 | require.NoError(t, err) 31 | 32 | w := httptest.NewRecorder() 33 | r := httptest.NewRequest(http.MethodGet, "/hello", nil) 34 | 35 | muxRouter.ServeHTTP(w, r) 36 | 37 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 38 | 39 | body := readBody(t, w.Result().Body) 40 | require.Equal(t, "OK", body) 41 | 42 | t.Run("and generate swagger", func(t *testing.T) { 43 | w := httptest.NewRecorder() 44 | r := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 45 | 46 | muxRouter.ServeHTTP(w, r) 47 | 48 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 49 | 50 | body := readBody(t, w.Result().Body) 51 | require.JSONEq(t, readFile(t, "../testdata/integration.json"), body) 52 | }) 53 | }) 54 | 55 | t.Run("works correctly with subrouter - handles path prefix - gorilla mux", func(t *testing.T) { 56 | muxRouter, oasRouter := setupSwagger(t) 57 | 58 | muxSubRouter := muxRouter.NewRoute().Subrouter() 59 | subRouter, err := oasRouter.SubRouter(gorilla.NewRouter(muxSubRouter), swagger.SubRouterOptions{ 60 | PathPrefix: "/prefix", 61 | }) 62 | require.NoError(t, err) 63 | 64 | _, err = subRouter.AddRoute(http.MethodGet, "/foo", okHandler, swagger.Definitions{}) 65 | require.NoError(t, err) 66 | 67 | err = oasRouter.GenerateAndExposeOpenapi() 68 | require.NoError(t, err) 69 | 70 | t.Run("correctly call router", func(t *testing.T) { 71 | w := httptest.NewRecorder() 72 | r := httptest.NewRequest(http.MethodGet, "/hello", nil) 73 | 74 | muxRouter.ServeHTTP(w, r) 75 | 76 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 77 | 78 | body := readBody(t, w.Result().Body) 79 | require.Equal(t, "OK", body) 80 | }) 81 | 82 | t.Run("correctly call sub router", func(t *testing.T) { 83 | w := httptest.NewRecorder() 84 | r := httptest.NewRequest(http.MethodGet, "/prefix/foo", nil) 85 | 86 | muxRouter.ServeHTTP(w, r) 87 | 88 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 89 | 90 | body := readBody(t, w.Result().Body) 91 | require.Equal(t, "OK", body) 92 | }) 93 | 94 | t.Run("and generate swagger", func(t *testing.T) { 95 | w := httptest.NewRecorder() 96 | r := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 97 | 98 | muxRouter.ServeHTTP(w, r) 99 | 100 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 101 | 102 | body := readBody(t, w.Result().Body) 103 | require.JSONEq(t, readFile(t, "../testdata/intergation-subrouter.json"), body, body) 104 | }) 105 | }) 106 | } 107 | 108 | func readBody(t *testing.T, requestBody io.ReadCloser) string { 109 | t.Helper() 110 | 111 | body, err := io.ReadAll(requestBody) 112 | require.NoError(t, err) 113 | 114 | return string(body) 115 | } 116 | 117 | func setupSwagger(t *testing.T) (*mux.Router, *SwaggerRouter) { 118 | t.Helper() 119 | 120 | context := context.Background() 121 | muxRouter := mux.NewRouter() 122 | 123 | router, err := swagger.NewRouter(gorilla.NewRouter(muxRouter), swagger.Options{ 124 | Context: context, 125 | Openapi: &openapi3.T{ 126 | Info: &openapi3.Info{ 127 | Title: swaggerOpenapiTitle, 128 | Version: swaggerOpenapiVersion, 129 | }, 130 | }, 131 | }) 132 | require.NoError(t, err) 133 | 134 | operation := swagger.Operation{} 135 | 136 | _, err = router.AddRawRoute(http.MethodGet, "/hello", okHandler, operation) 137 | require.NoError(t, err) 138 | 139 | _, err = router.AddRoute(http.MethodPost, "/hello/{value}", okHandler, swagger.Definitions{}) 140 | require.NoError(t, err) 141 | 142 | return muxRouter, router 143 | } 144 | 145 | func okHandler(w http.ResponseWriter, req *http.Request) { 146 | w.WriteHeader(http.StatusOK) 147 | w.Write([]byte(`OK`)) 148 | } 149 | 150 | func readFile(t *testing.T, path string) string { 151 | t.Helper() 152 | 153 | fileContent, err := os.ReadFile(path) 154 | require.NoError(t, err) 155 | 156 | return string(fileContent) 157 | } 158 | -------------------------------------------------------------------------------- /support/fiber/integration_test.go: -------------------------------------------------------------------------------- 1 | package fiber_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | swagger "github.com/davidebianchi/gswagger" 12 | oasFiber "github.com/davidebianchi/gswagger/support/fiber" 13 | 14 | "github.com/getkin/kin-openapi/openapi3" 15 | "github.com/gofiber/fiber/v2" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | type SwaggerRouter = swagger.Router[oasFiber.HandlerFunc, oasFiber.Route] 20 | 21 | const ( 22 | swaggerOpenapiTitle = "test openapi title" 23 | swaggerOpenapiVersion = "test openapi version" 24 | ) 25 | 26 | func TestFiberIntegration(t *testing.T) { 27 | t.Run("router works correctly", func(t *testing.T) { 28 | router, oasRouter := setupSwagger(t) 29 | 30 | err := oasRouter.GenerateAndExposeOpenapi() 31 | require.NoError(t, err) 32 | 33 | t.Run("/hello", func(t *testing.T) { 34 | r := httptest.NewRequest(http.MethodGet, "/hello", nil) 35 | 36 | resp, err := router.Test(r) 37 | require.NoError(t, err) 38 | require.Equal(t, http.StatusOK, resp.StatusCode) 39 | 40 | body := readBody(t, resp.Body) 41 | require.Equal(t, "OK", body) 42 | }) 43 | 44 | t.Run("/hello/:value", func(t *testing.T) { 45 | r := httptest.NewRequest(http.MethodPost, "/hello/something", nil) 46 | 47 | resp, err := router.Test(r) 48 | require.NoError(t, err) 49 | require.Equal(t, http.StatusOK, resp.StatusCode) 50 | 51 | body := readBody(t, resp.Body) 52 | require.Equal(t, "OK", body) 53 | }) 54 | 55 | t.Run("and generate swagger", func(t *testing.T) { 56 | r := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 57 | 58 | resp, err := router.Test(r) 59 | require.NoError(t, err) 60 | require.Equal(t, http.StatusOK, resp.StatusCode) 61 | 62 | body := readBody(t, resp.Body) 63 | require.JSONEq(t, readFile(t, "../testdata/integration.json"), body, body) 64 | }) 65 | }) 66 | 67 | t.Run("works correctly with subrouter - handles path prefix - gorilla mux", func(t *testing.T) { 68 | fiberRouter, oasRouter := setupSwagger(t) 69 | 70 | subRouter, err := oasRouter.SubRouter(oasFiber.NewRouter(fiberRouter), swagger.SubRouterOptions{ 71 | PathPrefix: "/prefix", 72 | }) 73 | require.NoError(t, err) 74 | 75 | _, err = subRouter.AddRoute(http.MethodGet, "/foo", okHandler, swagger.Definitions{}) 76 | require.NoError(t, err) 77 | 78 | err = oasRouter.GenerateAndExposeOpenapi() 79 | require.NoError(t, err) 80 | 81 | t.Run("correctly call /hello", func(t *testing.T) { 82 | r := httptest.NewRequest(http.MethodGet, "/hello", nil) 83 | 84 | resp, err := fiberRouter.Test(r) 85 | require.NoError(t, err) 86 | require.Equal(t, http.StatusOK, resp.StatusCode) 87 | 88 | body := readBody(t, resp.Body) 89 | require.Equal(t, "OK", body) 90 | }) 91 | 92 | t.Run("correctly call sub router", func(t *testing.T) { 93 | r := httptest.NewRequest(http.MethodGet, "/prefix/foo", nil) 94 | 95 | resp, err := fiberRouter.Test(r) 96 | require.NoError(t, err) 97 | require.Equal(t, http.StatusOK, resp.StatusCode) 98 | 99 | body := readBody(t, resp.Body) 100 | require.Equal(t, "OK", body) 101 | }) 102 | 103 | t.Run("and generate swagger", func(t *testing.T) { 104 | r := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 105 | 106 | resp, err := fiberRouter.Test(r) 107 | require.NoError(t, err) 108 | require.Equal(t, http.StatusOK, resp.StatusCode) 109 | 110 | body := readBody(t, resp.Body) 111 | require.JSONEq(t, readFile(t, "../testdata/intergation-subrouter.json"), body, body) 112 | }) 113 | }) 114 | } 115 | 116 | func setupSwagger(t *testing.T) (*fiber.App, *SwaggerRouter) { 117 | t.Helper() 118 | 119 | context := context.Background() 120 | fiberRouter := fiber.New() 121 | 122 | router, err := swagger.NewRouter(oasFiber.NewRouter(fiberRouter), swagger.Options{ 123 | Context: context, 124 | Openapi: &openapi3.T{ 125 | Info: &openapi3.Info{ 126 | Title: swaggerOpenapiTitle, 127 | Version: swaggerOpenapiVersion, 128 | }, 129 | }, 130 | }) 131 | require.NoError(t, err) 132 | 133 | operation := swagger.Operation{} 134 | 135 | _, err = router.AddRawRoute(http.MethodGet, "/hello", okHandler, operation) 136 | require.NoError(t, err) 137 | 138 | _, err = router.AddRoute(http.MethodPost, "/hello/:value", okHandler, swagger.Definitions{}) 139 | require.NoError(t, err) 140 | 141 | return fiberRouter, router 142 | } 143 | 144 | func okHandler(c *fiber.Ctx) error { 145 | c.Status(http.StatusOK) 146 | return c.SendString("OK") 147 | } 148 | 149 | func readBody(t *testing.T, requestBody io.ReadCloser) string { 150 | t.Helper() 151 | 152 | body, err := io.ReadAll(requestBody) 153 | require.NoError(t, err) 154 | 155 | return string(body) 156 | } 157 | 158 | func readFile(t *testing.T, path string) string { 159 | t.Helper() 160 | 161 | fileContent, err := os.ReadFile(path) 162 | require.NoError(t, err) 163 | 164 | return string(fileContent) 165 | } 166 | -------------------------------------------------------------------------------- /support/echo/integration_test.go: -------------------------------------------------------------------------------- 1 | package echo_test 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "testing" 10 | 11 | oasEcho "github.com/davidebianchi/gswagger/support/echo" 12 | 13 | swagger "github.com/davidebianchi/gswagger" 14 | "github.com/getkin/kin-openapi/openapi3" 15 | "github.com/labstack/echo/v4" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | const ( 20 | swaggerOpenapiTitle = "test openapi title" 21 | swaggerOpenapiVersion = "test openapi version" 22 | ) 23 | 24 | type echoSwaggerRouter = swagger.Router[echo.HandlerFunc, *echo.Route] 25 | 26 | func TestEchoIntegration(t *testing.T) { 27 | t.Run("router works correctly - echo", func(t *testing.T) { 28 | echoRouter, oasRouter := setupEchoSwagger(t) 29 | 30 | err := oasRouter.GenerateAndExposeOpenapi() 31 | require.NoError(t, err) 32 | 33 | t.Run("/hello", func(t *testing.T) { 34 | w := httptest.NewRecorder() 35 | r := httptest.NewRequest(http.MethodGet, "/hello", nil) 36 | 37 | echoRouter.ServeHTTP(w, r) 38 | 39 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 40 | 41 | body := readBody(t, w.Result().Body) 42 | require.Equal(t, "OK", body) 43 | }) 44 | 45 | t.Run("/hello/:value", func(t *testing.T) { 46 | w := httptest.NewRecorder() 47 | r := httptest.NewRequest(http.MethodPost, "/hello/something", nil) 48 | 49 | echoRouter.ServeHTTP(w, r) 50 | 51 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 52 | 53 | body := readBody(t, w.Result().Body) 54 | require.Equal(t, "OK", body) 55 | }) 56 | 57 | t.Run("and generate swagger", func(t *testing.T) { 58 | w := httptest.NewRecorder() 59 | r := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 60 | 61 | echoRouter.ServeHTTP(w, r) 62 | 63 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 64 | 65 | body := readBody(t, w.Result().Body) 66 | require.JSONEq(t, readFile(t, "../testdata/integration.json"), body) 67 | }) 68 | }) 69 | 70 | t.Run("works correctly with subrouter - handles path prefix - echo", func(t *testing.T) { 71 | eRouter, oasRouter := setupEchoSwagger(t) 72 | 73 | subRouter, err := oasRouter.SubRouter(oasEcho.NewRouter(eRouter), swagger.SubRouterOptions{ 74 | PathPrefix: "/prefix", 75 | }) 76 | require.NoError(t, err) 77 | 78 | _, err = subRouter.AddRoute(http.MethodGet, "/foo", okHandler, swagger.Definitions{}) 79 | require.NoError(t, err) 80 | 81 | err = oasRouter.GenerateAndExposeOpenapi() 82 | require.NoError(t, err) 83 | 84 | t.Run("correctly call /hello", func(t *testing.T) { 85 | w := httptest.NewRecorder() 86 | r := httptest.NewRequest(http.MethodGet, "/hello", nil) 87 | 88 | eRouter.ServeHTTP(w, r) 89 | 90 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 91 | 92 | body := readBody(t, w.Result().Body) 93 | require.Equal(t, "OK", body) 94 | }) 95 | 96 | t.Run("correctly call sub router", func(t *testing.T) { 97 | w := httptest.NewRecorder() 98 | r := httptest.NewRequest(http.MethodGet, "/prefix/foo", nil) 99 | 100 | eRouter.ServeHTTP(w, r) 101 | 102 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 103 | 104 | body := readBody(t, w.Result().Body) 105 | require.Equal(t, "OK", body) 106 | }) 107 | 108 | t.Run("and generate swagger", func(t *testing.T) { 109 | w := httptest.NewRecorder() 110 | r := httptest.NewRequest(http.MethodGet, swagger.DefaultJSONDocumentationPath, nil) 111 | 112 | eRouter.ServeHTTP(w, r) 113 | 114 | require.Equal(t, http.StatusOK, w.Result().StatusCode) 115 | 116 | body := readBody(t, w.Result().Body) 117 | require.JSONEq(t, readFile(t, "../testdata/intergation-subrouter.json"), body, body) 118 | }) 119 | }) 120 | } 121 | 122 | func readBody(t *testing.T, requestBody io.ReadCloser) string { 123 | t.Helper() 124 | 125 | body, err := io.ReadAll(requestBody) 126 | require.NoError(t, err) 127 | 128 | return string(body) 129 | } 130 | 131 | func setupEchoSwagger(t *testing.T) (*echo.Echo, *echoSwaggerRouter) { 132 | t.Helper() 133 | 134 | context := context.Background() 135 | e := echo.New() 136 | 137 | router, err := swagger.NewRouter(oasEcho.NewRouter(e), swagger.Options{ 138 | Context: context, 139 | Openapi: &openapi3.T{ 140 | Info: &openapi3.Info{ 141 | Title: swaggerOpenapiTitle, 142 | Version: swaggerOpenapiVersion, 143 | }, 144 | }, 145 | }) 146 | require.NoError(t, err) 147 | 148 | operation := swagger.Operation{} 149 | 150 | _, err = router.AddRawRoute(http.MethodGet, "/hello", okHandler, operation) 151 | require.NoError(t, err) 152 | 153 | _, err = router.AddRoute(http.MethodPost, "/hello/:value", okHandler, swagger.Definitions{}) 154 | require.NoError(t, err) 155 | 156 | return e, router 157 | } 158 | 159 | func okHandler(c echo.Context) error { 160 | return c.String(http.StatusOK, "OK") 161 | } 162 | 163 | func readFile(t *testing.T, path string) string { 164 | t.Helper() 165 | 166 | fileContent, err := os.ReadFile(path) 167 | require.NoError(t, err) 168 | 169 | return string(fileContent) 170 | } 171 | -------------------------------------------------------------------------------- /support/gorilla/testdata/examples-users.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "my title", 4 | "version": "1.0.0" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/cars/{carId}/drivers/{driverId}": { 9 | "get": { 10 | "parameters": [ 11 | { 12 | "in": "path", 13 | "name": "carId", 14 | "required": true, 15 | "schema": { 16 | "type": "string" 17 | } 18 | }, 19 | { 20 | "in": "path", 21 | "name": "driverId", 22 | "required": true, 23 | "schema": { 24 | "type": "string" 25 | } 26 | } 27 | ], 28 | "responses": { 29 | "default": { 30 | "description": "" 31 | } 32 | } 33 | } 34 | }, 35 | "/users": { 36 | "get": { 37 | "responses": { 38 | "200": { 39 | "content": { 40 | "application/json": { 41 | "schema": { 42 | "items": { 43 | "additionalProperties": false, 44 | "properties": { 45 | "address": { 46 | "title": "user address", 47 | "type": "string" 48 | }, 49 | "groups": { 50 | "default": [ 51 | "users" 52 | ], 53 | "items": { 54 | "type": "string" 55 | }, 56 | "title": "groups of the user", 57 | "type": "array" 58 | }, 59 | "name": { 60 | "example": "Jane", 61 | "title": "The user name", 62 | "type": "string" 63 | }, 64 | "phone": { 65 | "title": "mobile number of user", 66 | "type": "integer" 67 | } 68 | }, 69 | "required": [ 70 | "name", 71 | "phone", 72 | "address" 73 | ], 74 | "type": "object" 75 | }, 76 | "type": "array" 77 | } 78 | } 79 | }, 80 | "description": "" 81 | } 82 | } 83 | }, 84 | "post": { 85 | "requestBody": { 86 | "content": { 87 | "application/json": { 88 | "schema": { 89 | "additionalProperties": false, 90 | "properties": { 91 | "address": { 92 | "title": "user address", 93 | "type": "string" 94 | }, 95 | "groups": { 96 | "default": [ 97 | "users" 98 | ], 99 | "items": { 100 | "type": "string" 101 | }, 102 | "title": "groups of the user", 103 | "type": "array" 104 | }, 105 | "name": { 106 | "example": "Jane", 107 | "title": "The user name", 108 | "type": "string" 109 | }, 110 | "phone": { 111 | "title": "mobile number of user", 112 | "type": "integer" 113 | } 114 | }, 115 | "required": [ 116 | "name", 117 | "phone", 118 | "address" 119 | ], 120 | "type": "object" 121 | } 122 | } 123 | } 124 | }, 125 | "responses": { 126 | "201": { 127 | "content": { 128 | "text/html": { 129 | "schema": { 130 | "type": "string" 131 | } 132 | } 133 | }, 134 | "description": "" 135 | }, 136 | "401": { 137 | "content": { 138 | "application/json": { 139 | "schema": { 140 | "additionalProperties": false, 141 | "properties": { 142 | "message": { 143 | "type": "string" 144 | } 145 | }, 146 | "required": [ 147 | "message" 148 | ], 149 | "type": "object" 150 | } 151 | } 152 | }, 153 | "description": "invalid request" 154 | } 155 | } 156 | } 157 | }, 158 | "/users/{userId}": { 159 | "get": { 160 | "parameters": [ 161 | { 162 | "in": "path", 163 | "name": "userId", 164 | "required": true, 165 | "schema": { 166 | "type": "string" 167 | } 168 | }, 169 | { 170 | "in": "query", 171 | "name": "query", 172 | "schema": { 173 | "type": "string" 174 | } 175 | } 176 | ], 177 | "responses": { 178 | "default": { 179 | "description": "" 180 | } 181 | } 182 | } 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package swagger 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/davidebianchi/gswagger/apirouter" 11 | "github.com/getkin/kin-openapi/openapi3" 12 | "github.com/ghodss/yaml" 13 | ) 14 | 15 | var ( 16 | // ErrGenerateOAS throws when fails the marshalling of the swagger struct. 17 | ErrGenerateOAS = errors.New("fail to generate openapi") 18 | // ErrValidatingOAS throws when given openapi params are not correct. 19 | ErrValidatingOAS = errors.New("fails to validate openapi") 20 | 21 | // Deprecated: ErrGenerateSwagger has been deprecated, use ErrGenerateOAS instead. 22 | ErrGenerateSwagger = ErrGenerateOAS 23 | // Deprecated: ErrValidatingSwagger has been deprecated, use ErrValidatingOAS instead. 24 | ErrValidatingSwagger = ErrValidatingOAS 25 | ) 26 | 27 | const ( 28 | // DefaultJSONDocumentationPath is the path of the openapi documentation in json format. 29 | DefaultJSONDocumentationPath = "/documentation/json" 30 | // DefaultYAMLDocumentationPath is the path of the openapi documentation in yaml format. 31 | DefaultYAMLDocumentationPath = "/documentation/yaml" 32 | defaultOpenapiVersion = "3.0.0" 33 | ) 34 | 35 | // Router handle the api router and the openapi schema. 36 | // api router supported out of the box are: 37 | // - gorilla mux 38 | type Router[HandlerFunc, Route any] struct { 39 | router apirouter.Router[HandlerFunc, Route] 40 | swaggerSchema *openapi3.T 41 | context context.Context 42 | jsonDocumentationPath string 43 | yamlDocumentationPath string 44 | pathPrefix string 45 | } 46 | 47 | // Options to be passed to create the new router and swagger 48 | type Options struct { 49 | Context context.Context 50 | Openapi *openapi3.T 51 | // JSONDocumentationPath is the path exposed by json endpoint. Default to /documentation/json. 52 | JSONDocumentationPath string 53 | // YAMLDocumentationPath is the path exposed by yaml endpoint. Default to /documentation/yaml. 54 | YAMLDocumentationPath string 55 | // Add path prefix to add to every router path. 56 | PathPrefix string 57 | } 58 | 59 | // NewRouter generate new router with openapi. Default to OpenAPI 3.0.0 60 | func NewRouter[HandlerFunc, Route any](router apirouter.Router[HandlerFunc, Route], options Options) (*Router[HandlerFunc, Route], error) { 61 | openapi, err := generateNewValidOpenapi(options.Openapi) 62 | if err != nil { 63 | return nil, fmt.Errorf("%w: %s", ErrValidatingOAS, err) 64 | } 65 | 66 | var ctx = options.Context 67 | if options.Context == nil { 68 | ctx = context.Background() 69 | } 70 | 71 | yamlDocumentationPath := DefaultYAMLDocumentationPath 72 | if options.YAMLDocumentationPath != "" { 73 | if err := isValidDocumentationPath(options.YAMLDocumentationPath); err != nil { 74 | return nil, err 75 | } 76 | yamlDocumentationPath = options.YAMLDocumentationPath 77 | } 78 | 79 | jsonDocumentationPath := DefaultJSONDocumentationPath 80 | if options.JSONDocumentationPath != "" { 81 | if err := isValidDocumentationPath(options.JSONDocumentationPath); err != nil { 82 | return nil, err 83 | } 84 | jsonDocumentationPath = options.JSONDocumentationPath 85 | } 86 | 87 | return &Router[HandlerFunc, Route]{ 88 | router: router, 89 | swaggerSchema: openapi, 90 | context: ctx, 91 | yamlDocumentationPath: yamlDocumentationPath, 92 | jsonDocumentationPath: jsonDocumentationPath, 93 | pathPrefix: options.PathPrefix, 94 | }, nil 95 | } 96 | 97 | type SubRouterOptions struct { 98 | PathPrefix string 99 | } 100 | 101 | func (r Router[HandlerFunc, Route]) SubRouter(router apirouter.Router[HandlerFunc, Route], opts SubRouterOptions) (*Router[HandlerFunc, Route], error) { 102 | return &Router[HandlerFunc, Route]{ 103 | router: router, 104 | swaggerSchema: r.swaggerSchema, 105 | context: r.context, 106 | jsonDocumentationPath: r.jsonDocumentationPath, 107 | yamlDocumentationPath: r.yamlDocumentationPath, 108 | pathPrefix: opts.PathPrefix, 109 | }, nil 110 | } 111 | 112 | func generateNewValidOpenapi(openapi *openapi3.T) (*openapi3.T, error) { 113 | if openapi == nil { 114 | return nil, fmt.Errorf("openapi is required") 115 | } 116 | if openapi.OpenAPI == "" { 117 | openapi.OpenAPI = defaultOpenapiVersion 118 | } 119 | if openapi.Paths == nil { 120 | openapi.Paths = &openapi3.Paths{} 121 | } 122 | 123 | if openapi.Info == nil { 124 | return nil, fmt.Errorf("openapi info is required") 125 | } 126 | if openapi.Info.Title == "" { 127 | return nil, fmt.Errorf("openapi info title is required") 128 | } 129 | if openapi.Info.Version == "" { 130 | return nil, fmt.Errorf("openapi info version is required") 131 | } 132 | 133 | return openapi, nil 134 | } 135 | 136 | // GenerateAndExposeOpenapi creates a /documentation/json route on router and 137 | // expose the generated swagger 138 | func (r Router[_, _]) GenerateAndExposeOpenapi() error { 139 | if err := r.swaggerSchema.Validate(r.context); err != nil { 140 | return fmt.Errorf("%w: %s", ErrValidatingOAS, err) 141 | } 142 | 143 | jsonSwagger, err := r.swaggerSchema.MarshalJSON() 144 | if err != nil { 145 | return fmt.Errorf("%w json marshal: %s", ErrGenerateOAS, err) 146 | } 147 | r.router.AddRoute(http.MethodGet, r.jsonDocumentationPath, r.router.SwaggerHandler("application/json", jsonSwagger)) 148 | 149 | yamlSwagger, err := yaml.JSONToYAML(jsonSwagger) 150 | if err != nil { 151 | return fmt.Errorf("%w yaml marshal: %s", ErrGenerateOAS, err) 152 | } 153 | r.router.AddRoute(http.MethodGet, r.yamlDocumentationPath, r.router.SwaggerHandler("text/plain", yamlSwagger)) 154 | 155 | return nil 156 | } 157 | 158 | func isValidDocumentationPath(path string) error { 159 | if !strings.HasPrefix(path, "/") { 160 | return fmt.Errorf("invalid path %s. Path should start with '/'", path) 161 | } 162 | return nil 163 | } 164 | -------------------------------------------------------------------------------- /testdata/users_employees.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "title": "test openapi title", 4 | "version": "test openapi version" 5 | }, 6 | "openapi": "3.0.0", 7 | "paths": { 8 | "/employees": { 9 | "get": { 10 | "responses": { 11 | "200": { 12 | "content": { 13 | "application/json": { 14 | "schema": { 15 | "additionalProperties": false, 16 | "properties": { 17 | "organization_name": { 18 | "type": "string" 19 | }, 20 | "users": { 21 | "items": { 22 | "additionalProperties": false, 23 | "properties": { 24 | "address": { 25 | "title": "user address", 26 | "type": "string" 27 | }, 28 | "groups": { 29 | "default": [ 30 | "users" 31 | ], 32 | "items": { 33 | "type": "string" 34 | }, 35 | "title": "groups of the user", 36 | "type": "array" 37 | }, 38 | "name": { 39 | "example": "Jane", 40 | "title": "The user name", 41 | "type": "string" 42 | }, 43 | "phone": { 44 | "title": "mobile number of user", 45 | "type": "integer" 46 | } 47 | }, 48 | "required": [ 49 | "name", 50 | "phone", 51 | "address" 52 | ], 53 | "type": "object" 54 | }, 55 | "type": "array" 56 | } 57 | }, 58 | "required": [ 59 | "organization_name", 60 | "users" 61 | ], 62 | "type": "object" 63 | } 64 | } 65 | }, 66 | "description": "" 67 | } 68 | } 69 | } 70 | }, 71 | "/users": { 72 | "get": { 73 | "responses": { 74 | "200": { 75 | "content": { 76 | "application/json": { 77 | "schema": { 78 | "items": { 79 | "additionalProperties": false, 80 | "properties": { 81 | "address": { 82 | "title": "user address", 83 | "type": "string" 84 | }, 85 | "groups": { 86 | "default": [ 87 | "users" 88 | ], 89 | "items": { 90 | "type": "string" 91 | }, 92 | "title": "groups of the user", 93 | "type": "array" 94 | }, 95 | "name": { 96 | "example": "Jane", 97 | "title": "The user name", 98 | "type": "string" 99 | }, 100 | "phone": { 101 | "title": "mobile number of user", 102 | "type": "integer" 103 | } 104 | }, 105 | "required": [ 106 | "name", 107 | "phone", 108 | "address" 109 | ], 110 | "type": "object" 111 | }, 112 | "type": "array" 113 | } 114 | } 115 | }, 116 | "description": "" 117 | } 118 | } 119 | }, 120 | "post": { 121 | "requestBody": { 122 | "content": { 123 | "application/json": { 124 | "schema": { 125 | "additionalProperties": false, 126 | "properties": { 127 | "address": { 128 | "title": "user address", 129 | "type": "string" 130 | }, 131 | "groups": { 132 | "default": [ 133 | "users" 134 | ], 135 | "items": { 136 | "type": "string" 137 | }, 138 | "title": "groups of the user", 139 | "type": "array" 140 | }, 141 | "name": { 142 | "example": "Jane", 143 | "title": "The user name", 144 | "type": "string" 145 | }, 146 | "phone": { 147 | "title": "mobile number of user", 148 | "type": "integer" 149 | } 150 | }, 151 | "required": [ 152 | "name", 153 | "phone", 154 | "address" 155 | ], 156 | "type": "object" 157 | } 158 | } 159 | } 160 | }, 161 | "responses": { 162 | "201": { 163 | "content": { 164 | "text/html": { 165 | "schema": { 166 | "type": "string" 167 | } 168 | } 169 | }, 170 | "description": "" 171 | }, 172 | "401": { 173 | "content": { 174 | "application/json": { 175 | "schema": { 176 | "additionalProperties": false, 177 | "properties": { 178 | "message": { 179 | "type": "string" 180 | } 181 | }, 182 | "required": [ 183 | "message" 184 | ], 185 | "type": "object" 186 | } 187 | } 188 | }, 189 | "description": "invalid request" 190 | } 191 | } 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |