├── .github ├── CODEOWNERS ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── scripts │ └── setup_examples_test.bash └── workflows │ └── ci.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── _examples └── basic │ └── main.go ├── deprecation.go ├── deprecation_test.go ├── doc.go ├── go.mod ├── go.sum ├── group.go ├── version.go ├── version_test.go ├── versioning.go └── versioning_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in the repo. 2 | * @kataras 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # custom: https://paypal.me/kataras 2 | github: kataras 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Examples for the `versioning` project can be found at 2 | . 3 | 4 | Documentation for the `versioning` project can be found at 5 | . 6 | 7 | Love the `versioning` package? Please consider supporting the project: 8 | 👉 https://paypal.me/kataras 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: type:bug 6 | assignees: kataras 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. [...] 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. ubuntu, windows] 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Other 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Describe the issue you are facing or ask for help 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST]" 5 | labels: type:idea 6 | assignees: kataras 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # We'd love to see more contributions 2 | 3 | Read how you can [contribute to the project](https://github.com/kataras/versioning/blob/master/CONTRIBUTING.md). 4 | 5 | > Please attach an [issue](https://github.com/kataras/versioning/issues) link which your PR solves otherwise your work may be rejected. -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" -------------------------------------------------------------------------------- /.github/scripts/setup_examples_test.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | for f in ../../_examples/*; do 4 | if [ -d "$f" ]; then 5 | # Will not run if no directories are available 6 | go mod init 7 | go get -u github.com/kataras/versioning@master 8 | go mod download 9 | go run . 10 | fi 11 | done 12 | 13 | # git update-index --chmod=+x ./.github/scripts/setup_examples_test.bash -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go_version: [1.19.x] 16 | steps: 17 | - name: Set up Go 1.x 18 | uses: actions/setup-go@v2 19 | with: 20 | go-version: ${{ matrix.go_version }} 21 | - name: Check out code into the Go module directory 22 | uses: actions/checkout@v2 23 | - name: Test 24 | run: go test -v ./... 25 | - name: Setup examples for testing 26 | run: ./.github/scripts/setup_examples_test.bash 27 | - name: Test examples 28 | continue-on-error: true 29 | working-directory: _examples 30 | run: go test -v -mod=mod -cover -race ./... 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | # go.sum -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kataras2006@hotmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all read our [Code of Conduct](https://github.com/kataras/versioning/blob/master/CODE_OF_CONDUCT.md). 4 | 5 | ## PR 6 | 7 | 1. Open a new [issue](https://github.com/kataras/versioning/issues/new) 8 | * Write version of your local Go programming language. 9 | * Describe your problem, what did you expect to see and what you see instead. 10 | * If it's a feature request, describe your idea as better as you can 11 | 2. Fork the [repository](https://github.com/kataras/versioning). 12 | 3. Make your changes. 13 | 4. Compare & Push the PR from [here](https://github.com/kataras/versioning/compare). 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2022 Gerasimos Maropoulos 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Versioning (Go) 2 | 3 | [![build status](https://img.shields.io/github/actions/workflow/status/kataras/versioning/ci.yml?style=for-the-badge)](https://github.com/kataras/versioning/actions) [![report card](https://img.shields.io/badge/report%20card-a%2B-ff3333.svg?style=for-the-badge)](https://goreportcard.com/report/github.com/kataras/versioning) [![godocs](https://img.shields.io/badge/go-%20docs-488AC7.svg?style=for-the-badge)](https://godoc.org/github.com/kataras/versioning) [![donate on PayPal](https://img.shields.io/badge/support-PayPal-blue.svg?style=for-the-badge)](https://www.paypal.me/kataras) 4 | 5 | [Semver](https://semver.org/) versioning for your APIs. It implements all the suggestions written at [api-guidelines](https://github.com/byrondover/api-guidelines/blob/master/Guidelines.md#versioning) and more. 6 | 7 | The version comparison is done by the [go-version](https://github.com/hashicorp/go-version) package. It supports matching over patterns like `">= 1.0, < 3"` and e.t.c. 8 | 9 | ## Getting started 10 | 11 | The only requirement is the [Go Programming Language](https://golang.org/dl). 12 | 13 | ```sh 14 | $ go get github.com/kataras/versioning 15 | ``` 16 | 17 | ## Features 18 | 19 | - Per route version matching, an `http.Handler` with "switch" cases via [versioning.Map](https://github.com/kataras/versioning/blob/master/versioning.go#L33) for version => handler 20 | - Per group versioned routes and deprecation API 21 | - Version matching like ">= 1.0, < 2.0" or just "2.0.1" and e.t.c. 22 | - Version not found handler (can be customized by simply adding the `versioning.NotFound`: customNotMatchVersionHandler on the Map) 23 | - Version is retrieved from the "Accept" and "Accept-Version" headers (can be customized through request's context key) 24 | - Respond with "X-API-Version" header, if version found. 25 | - Deprecation options with customizable "X-API-Warn", "X-API-Deprecation-Date", "X-API-Deprecation-Info" headers via `Deprecated` wrapper. 26 | 27 | ## Compare Versions 28 | 29 | ```go 30 | // If reports whether the "version" is a valid match to the "is". 31 | // The "is" can be a version constraint like ">= 1, < 3". 32 | If(version string, is string) bool 33 | ``` 34 | 35 | ```go 36 | // Match reports whether the current version matches the "expectedVersion". 37 | Match(r *http.Request, expectedVersion string) bool 38 | ``` 39 | 40 | Example 41 | 42 | ```go 43 | router.HandleFunc("/api/user", func(w http.ResponseWriter, r *http.Request) { 44 | if versioning.Match(r, ">= 2.2.3") { 45 | // [logic for >= 2.2.3 version of your handler goes here] 46 | return 47 | } 48 | }) 49 | ``` 50 | 51 | ## Determining The Current Version 52 | 53 | Current request version is retrieved by `versioning.GetVersion(r *http.Request)`. 54 | 55 | By default the `GetVersion` will try to read from: 56 | - `Accept` header, i.e `Accept: "application/json; version=1.0"` 57 | - `Accept-Version` header, i.e `Accept-Version: "1.0"` 58 | 59 | ```go 60 | func handler(w http.ResponseWriter, r *http.Request){ 61 | currentVersion := versioning.GetVersion(r) 62 | } 63 | ``` 64 | 65 | You can also **set a custom version** to a handler trough a middleware by setting a request context's value. 66 | For example: 67 | ```go 68 | import ( 69 | "context" 70 | "net/http" 71 | 72 | "github.com/kataras/versioning" 73 | ) 74 | 75 | func urlParamVersion(next http.Handler) http.Handler { 76 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){ 77 | version := r.URL.Query().Get("v") // ?v=2.3.5 78 | if version == "" { 79 | // set a default version, e.g. 1.0 80 | version = "1.0" 81 | } 82 | r = r.WithContext(versioning.WithVersion(r.Context(), version)) 83 | next.ServeHTTP(w, r) 84 | }) 85 | } 86 | ``` 87 | 88 | ## Map Versions to Handlers 89 | 90 | The `versioning.NewMatcher(versioning.Map) http.Handler` creates a single handler which decides what handler need to be executed based on the requested version. 91 | 92 | ```go 93 | // middleware for all versions. 94 | func myMiddleware(next http.Handler) http.Handler { 95 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){ 96 | // [...] 97 | next.ServeHTTP(w, r) 98 | }) 99 | } 100 | 101 | func myCustomVersionNotFound(w http.ResponseWriter, r *http.Request) { 102 | w.WriteHeader(404) 103 | fmt.Fprintf(w, "%s version not found", versioning.GetVersion(r)) 104 | } 105 | 106 | router := http.NewServeMux() 107 | router.Handle("/", myMiddleware(versioning.NewMatcher(versioning.Map{ 108 | // v1Handler is a handler of yuors that will be executed only on version 1. 109 | "1.0": v1Handler, 110 | ">= 2, < 3": v2Handler, 111 | versioning.NotFound: http.HandlerFunc(myCustomNotVersionFound), 112 | }))) 113 | ``` 114 | 115 | ### Deprecation 116 | 117 | Using the `versioning.Deprecated(handler http.Handler, options versioning.DeprecationOptions) http.Handler` function you can mark a specific handler version as deprecated. 118 | 119 | ```go 120 | v1Handler = versioning.Deprecated(v1Handler, versioning.DeprecationOptions{ 121 | // if empty defaults to: "WARNING! You are using a deprecated version of this API." 122 | WarnMessage string 123 | DeprecationDate time.Time 124 | DeprecationInfo string 125 | }) 126 | 127 | router.Handle("/", versioning.NewMatcher(versioning.Map{ 128 | "1.0": v1Handler, 129 | // [...] 130 | })) 131 | ``` 132 | 133 | This will make the handler to send these headers to the client: 134 | 135 | - `"X-API-Warn": options.WarnMessage` 136 | - `"X-API-Deprecation-Date": options.DeprecationDate` 137 | - `"X-API-Deprecation-Info": options.DeprecationInfo` 138 | 139 | > versioning.DefaultDeprecationOptions can be passed instead if you don't care about Date and Info. 140 | 141 | ## Grouping Routes By Version 142 | 143 | Grouping routes by version is possible as well. 144 | 145 | Using the `versioning.NewGroup(version string) *versioning.Group` function you can create a group to register your versioned routes. 146 | The `versioning.RegisterGroups(r *http.ServeMux, versionNotFoundHandler http.Handler, groups ...*versioning.Group)` must be called in the end in order to register the routes to a specific `StdMux`. 147 | 148 | ```go 149 | router := http.NewServeMux() 150 | 151 | // version 1. 152 | usersAPIV1 := versioning.NewGroup(">= 1, < 2") 153 | usersAPIV1.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { 154 | if r.Method != http.MethodGet { 155 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 156 | return 157 | } 158 | 159 | w.Write([]byte("v1 resource: /api/users handler")) 160 | }) 161 | usersAPIV1.HandleFunc("/api/users/new", func(w http.ResponseWriter, r *http.Request) { 162 | if r.Method != http.MethodPost { 163 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 164 | return 165 | } 166 | 167 | w.Write([]byte("v1 resource: /api/users/new post handler")) 168 | }) 169 | 170 | // version 2. 171 | usersAPIV2 := versioning.NewGroup(">= 2, < 3") 172 | usersAPIV2.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { 173 | if r.Method != http.MethodPost { 174 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 175 | return 176 | } 177 | 178 | w.Write([]byte("v2 resource: /api/users handler")) 179 | }) 180 | usersAPIV2.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { 181 | if r.Method != http.MethodPost { 182 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 183 | return 184 | } 185 | 186 | w.Write([]byte("v2 resource: /api/users post handler")) 187 | }) 188 | 189 | versioning.RegisterGroups(router, versioning.NotFoundHandler, usersAPIV1, usersAPIV2) 190 | ``` 191 | 192 | > A middleware can be registered, using the methods we learnt above, i.e by using the `versioning.Match` in order to detect what code/handler you want to be executed when "x" or no version is requested. 193 | 194 | ### Deprecation for Group 195 | 196 | Just call the `Group#Deprecated(versioning.DeprecationOptions)` on the group you want to notify your API consumers that this specific version is deprecated. 197 | 198 | ```go 199 | userAPIV1 := versioning.NewGroup("1.0").Deprecated(versioning.DefaultDeprecationOptions) 200 | ``` 201 | 202 | For a more detailed technical documentation you can head over to our [godocs](https://godoc.org/github.com/kataras/versioning). And for executable code you can always visit the [_examples](_examples) repository's subdirectory. 203 | 204 | ## License 205 | 206 | kataras/versioning is free and open-source software licensed under the [MIT License](https://tldrlegal.com/license/mit-license). 207 | -------------------------------------------------------------------------------- /_examples/basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/kataras/versioning" 7 | ) 8 | 9 | func main() { 10 | router := http.NewServeMux() 11 | 12 | examplePerRoute(router) 13 | exampleRegisterGroups(router) 14 | 15 | println("Listening on: http://localhost:8080") 16 | http.ListenAndServe(":8080", router) 17 | } 18 | 19 | // How to test: 20 | // Open Postman 21 | // GET: localhost:8080/api/cats 22 | // Headers[1] = Accept-Version: "1" and repeat with 23 | // Headers[1] = Accept-Version: "2.5" 24 | // or even "Accept": "application/json; version=2.5" 25 | func examplePerRoute(router *http.ServeMux) { 26 | router.Handle("/api/cats", versioning.NewMatcher(versioning.Map{ 27 | "1": catsVersionExactly1Handler, 28 | ">= 2, < 3": catsV2Handler, 29 | versioning.NotFound: versioning.NotFoundHandler, 30 | })) 31 | } 32 | 33 | // How to test: 34 | // Open Postman 35 | // GET: localhost:8080/api/users 36 | // Headers[1] = Accept-Version: "1.9.9" and repeat with 37 | // Headers[1] = Accept-Version: "2.5" 38 | // 39 | // POST: localhost:8080/api/users/new 40 | // Headers[1] = Accept-Version: "1.8.3" 41 | // 42 | // POST: localhost:8080/api/users 43 | // Headers[1] = Accept-Version: "2" 44 | func exampleRegisterGroups(router *http.ServeMux) { 45 | // version 1. 46 | usersAPIV1 := versioning.NewGroup(">= 1, < 2") 47 | usersAPIV1.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { 48 | if r.Method != http.MethodGet { 49 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 50 | return 51 | } 52 | 53 | w.Write([]byte("v1 resource: /api/users handler")) 54 | }) 55 | usersAPIV1.HandleFunc("/api/users/new", func(w http.ResponseWriter, r *http.Request) { 56 | if r.Method != http.MethodPost { 57 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 58 | return 59 | } 60 | 61 | w.Write([]byte("v1 resource: /api/users/new post handler")) 62 | }) 63 | 64 | // version 2. 65 | usersAPIV2 := versioning.NewGroup(">= 2, < 3") 66 | usersAPIV2.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { 67 | if r.Method != http.MethodPost { 68 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 69 | return 70 | } 71 | 72 | w.Write([]byte("v2 resource: /api/users handler")) 73 | }) 74 | usersAPIV2.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) { 75 | if r.Method != http.MethodPost { 76 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) 77 | return 78 | } 79 | 80 | w.Write([]byte("v2 resource: /api/users post handler")) 81 | }) 82 | 83 | versioning.RegisterGroups(router, versioning.NotFoundHandler, usersAPIV1, usersAPIV2) 84 | } 85 | 86 | var catsVersionExactly1Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 | w.Write([]byte("v1 exactly resource: /api/cats handler")) 88 | }) 89 | 90 | var catsV2Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 91 | w.Write([]byte("v2 resource: /api/cats handler")) 92 | }) 93 | -------------------------------------------------------------------------------- /deprecation.go: -------------------------------------------------------------------------------- 1 | package versioning 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | // HeaderTimeFormat is the time format that will be used to send DeprecationOptions's DeprectationDate time. 9 | var HeaderTimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT" 10 | 11 | // DeprecationOptions describes the deprecation headers key-values. 12 | // - "X-API-Warn": options.WarnMessage 13 | // - "X-API-Deprecation-Date": time.Now().Format("Mon, 02 Jan 2006 15:04:05 GMT") 14 | // - "X-API-Deprecation-Info": options.DeprecationInfo 15 | type DeprecationOptions struct { 16 | WarnMessage string 17 | DeprecationDate time.Time 18 | DeprecationInfo string 19 | } 20 | 21 | // ShouldHandle reports whether the deprecation headers should be present or no. 22 | func (opts DeprecationOptions) ShouldHandle() bool { 23 | return opts.WarnMessage != "" || !opts.DeprecationDate.IsZero() || opts.DeprecationInfo != "" 24 | } 25 | 26 | // DefaultDeprecationOptions are the default deprecation options, 27 | // it defaults the "X-API-Warn" header to a generic message. 28 | var DefaultDeprecationOptions = DeprecationOptions{ 29 | WarnMessage: "WARNING! You are using a deprecated version of this API.", 30 | } 31 | 32 | // Deprecated marks a specific handler as a deprecated. 33 | // Deprecated can be used to tell the clients that 34 | // a newer version of that specific resource is available instead. 35 | func Deprecated(handler http.Handler, options DeprecationOptions) http.Handler { 36 | if options.WarnMessage == "" { 37 | options.WarnMessage = DefaultDeprecationOptions.WarnMessage 38 | } 39 | 40 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | w.Header().Set("X-API-Warn", options.WarnMessage) 42 | 43 | if !options.DeprecationDate.IsZero() { 44 | w.Header().Set("X-API-Deprecation-Date", options.DeprecationDate.Format(HeaderTimeFormat)) 45 | } 46 | 47 | if options.DeprecationInfo != "" { 48 | w.Header().Set("X-API-Deprecation-Info", options.DeprecationInfo) 49 | } 50 | 51 | handler.ServeHTTP(w, r) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /deprecation_test.go: -------------------------------------------------------------------------------- 1 | package versioning_test 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/kataras/versioning" 10 | ) 11 | 12 | func TestDeprecated(t *testing.T) { 13 | router := http.NewServeMux() 14 | 15 | writeVesion := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | w.Write([]byte(versioning.GetVersion(r))) 17 | }) 18 | 19 | opts := versioning.DeprecationOptions{ 20 | WarnMessage: "deprecated, see ", 21 | DeprecationDate: time.Now().UTC(), 22 | DeprecationInfo: "a bigger version is available, see for more information", 23 | } 24 | router.Handle("/", versioning.Deprecated(writeVesion, opts)) 25 | 26 | srv := httptest.NewServer(router) 27 | defer srv.Close() 28 | 29 | expectedDeprecationDate := opts.DeprecationDate.Format(versioning.HeaderTimeFormat) 30 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "1.0")). 31 | statusCode(http.StatusOK). 32 | headerEq("X-API-Warn", opts.WarnMessage). 33 | headerEq("X-API-Deprecation-Date", expectedDeprecationDate). 34 | bodyEq("1.0") 35 | } 36 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Source code and other details for the project are available at GitHub: 3 | 4 | https://github.com/kataras/versioning 5 | 6 | Current Version 7 | 8 | 0.0.1 9 | 10 | Installation 11 | 12 | The only requirement is the Go Programming Language 13 | 14 | $ go get github.com/kataras/versioning 15 | 16 | Examples 17 | 18 | https://github.com/kataras/versioning/tree/master/_examples 19 | */ 20 | 21 | package versioning 22 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kataras/versioning 2 | 3 | go 1.19 4 | 5 | require github.com/hashicorp/go-version v1.6.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 2 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 3 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | package versioning 2 | 3 | import "net/http" 4 | 5 | // Group is a group of version-based routes. 6 | // One version per one or more routes. 7 | type Group struct { 8 | version string 9 | routes map[string]Map // key = path, value = map[version] = handler 10 | 11 | deprecation DeprecationOptions 12 | } 13 | 14 | // NewGroup returns a ptr to Group based on the given "version". 15 | // 16 | // See `Handle` and `RegisterGroups` for more. 17 | func NewGroup(version string) *Group { 18 | return &Group{ 19 | version: version, 20 | routes: make(map[string]Map), 21 | } 22 | } 23 | 24 | // Deprecated marks this group and all its versioned routes 25 | // as deprecated versions of that endpoint. 26 | // It can be called in the end just before `RegisterGroups` 27 | // or first by `NewGroup(...).Deprecated(...)`. It returns itself. 28 | func (g *Group) Deprecated(options DeprecationOptions) *Group { 29 | // if `Deprecated` is called in the end. 30 | for _, versions := range g.routes { 31 | versions[g.version] = Deprecated(versions[g.version], options) 32 | } 33 | 34 | // store the options if called before registering any versioned routes. 35 | g.deprecation = options 36 | 37 | return g 38 | } 39 | 40 | func (g *Group) addVRoute(path string, handler http.Handler) { 41 | if _, exists := g.routes[path]; !exists { 42 | g.routes[path] = Map{g.version: handler} 43 | } 44 | } 45 | 46 | // Handle registers a versioned route to the group. 47 | // A call of `RegisterGroups` is necessary in order to register the actual routes 48 | // when the group is complete. 49 | // 50 | // See `RegisterGroups` for more. 51 | func (g *Group) Handle(path string, handler http.Handler) { 52 | if g.deprecation.ShouldHandle() { // if `Deprecated` called first. 53 | handler = Deprecated(handler, g.deprecation) 54 | } 55 | 56 | g.addVRoute(path, handler) 57 | } 58 | 59 | // HandleFunc registers a versioned route to the group. 60 | // A call of `RegisterGroups` is necessary in order to register the actual routes 61 | // when the group is complete. 62 | // 63 | // See `RegisterGroups` for more. 64 | func (g *Group) HandleFunc(path string, handlerFn func(w http.ResponseWriter, r *http.Request)) { 65 | var handler http.Handler = http.HandlerFunc(handlerFn) 66 | 67 | if g.deprecation.ShouldHandle() { // if `Deprecated` called first. 68 | handler = Deprecated(handler, g.deprecation) 69 | } 70 | 71 | g.addVRoute(path, handler) 72 | } 73 | 74 | // StdMux is an interface which types like `net/http#ServeMux` 75 | // implements in order to register handlers per path. 76 | // 77 | // See `RegisterGroups`. 78 | type StdMux interface{ Handle(string, http.Handler) } 79 | 80 | // RegisterGroups registers one or more groups to an `net/http#ServeMux` if not nil, and returns the routes. 81 | // Map's key is the request path from `Group#Handle` and value is the `http.Handler`. 82 | // See `NewGroup` and `NotFoundHandler` too. 83 | func RegisterGroups(mux StdMux, notFoundHandler http.Handler, groups ...*Group) map[string]http.Handler { 84 | total := make(map[string]Map) 85 | routes := make(map[string]http.Handler) 86 | 87 | for _, g := range groups { 88 | for path, versions := range g.routes { 89 | if _, exists := total[path]; exists { 90 | total[path][g.version] = versions[g.version] 91 | } else { 92 | total[path] = versions 93 | } 94 | } 95 | } 96 | 97 | for path, versions := range total { 98 | if notFoundHandler != nil { 99 | versions[NotFound] = notFoundHandler 100 | } 101 | 102 | matcher := NewMatcher(versions) 103 | if mux != nil { 104 | mux.Handle(path, matcher) 105 | } 106 | 107 | routes[path] = matcher 108 | } 109 | 110 | return routes 111 | } 112 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package versioning 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | // contextKey is the context key of the version. 11 | contextKey interface{} = "api.version" 12 | // NotFound is the key that can be used inside a `Map` or inside `context.WithValue(r.Context(), versioning.contextKey, versioning.NotFound)` 13 | // to tell that a version wasn't found, therefore the not found handler should handle the request instead. 14 | NotFound = contextKey.(string) + ".notfound" 15 | ) 16 | 17 | const ( 18 | // AcceptVersionHeaderKey is the header key of "Accept-Version". 19 | AcceptVersionHeaderKey = "Accept-Version" 20 | // AcceptHeaderKey is the header key of "Accept". 21 | AcceptHeaderKey = "Accept" 22 | // AcceptHeaderVersionValue is the Accept's header value search term the requested version. 23 | AcceptHeaderVersionValue = "version" 24 | ) 25 | 26 | var versionNotFoundText = []byte("version not found") 27 | 28 | // NotFoundHandler is the default version not found handler that 29 | // is executed from `NewMatcher` when no version is registered as available to dispatch a resource. 30 | var NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 31 | // 303 is an option too, 32 | // end-dev has the chance to change that behavior by using the NotFound in the map: 33 | // 34 | // https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html 35 | /* 36 | 10.5.2 501 Not Implemented 37 | 38 | The server does not support the functionality required to fulfill the request. 39 | This is the appropriate response when the server does not 40 | recognize the request method and is not capable of supporting it for any resource. 41 | */ 42 | 43 | w.WriteHeader(http.StatusNotImplemented) 44 | w.Write(versionNotFoundText) 45 | }) 46 | 47 | // GetVersion returns the current request version. 48 | // 49 | // By default the `GetVersion` will try to read from: 50 | // - "Accept" header, i.e Accept: "application/json; version=1.0" 51 | // - "Accept-Version" header, i.e Accept-Version: "1.0" 52 | // 53 | // However, the end developer can also set a custom version for a handler trough a middleware by using the request's context's value 54 | // for versions (see `WithVersion` for further details on that). 55 | func GetVersion(r *http.Request) string { 56 | // firstly by context store, if manually set-ed by a middleware. 57 | if v := r.Context().Value(contextKey); v != nil { 58 | if version, ok := v.(string); ok { 59 | return version 60 | } 61 | } 62 | 63 | // secondly by the "Accept-Version" header. 64 | if version := r.Header.Get(AcceptVersionHeaderKey); version != "" { 65 | return version 66 | } 67 | 68 | // thirdly by the "Accept" header which is like"...; version=1.0" 69 | acceptValue := r.Header.Get(AcceptHeaderKey) 70 | if acceptValue != "" { 71 | if idx := strings.Index(acceptValue, AcceptHeaderVersionValue); idx != -1 { 72 | rem := acceptValue[idx:] 73 | startVersion := strings.Index(rem, "=") 74 | if startVersion == -1 || len(rem) < startVersion+1 { 75 | return NotFound 76 | } 77 | 78 | rem = rem[startVersion+1:] 79 | 80 | end := strings.Index(rem, " ") 81 | if end == -1 { 82 | end = strings.Index(rem, ";") 83 | } 84 | if end == -1 { 85 | end = len(rem) 86 | } 87 | 88 | if version := rem[:end]; version != "" { 89 | return version 90 | } 91 | } 92 | } 93 | 94 | return NotFound 95 | } 96 | 97 | // WithVersion creates the new context that contains a passed version. 98 | // Example of how you can change the default behavior to extract a requested version (which is by headers) 99 | // from a "version" url parameter instead: 100 | // func(w http.ResponseWriter, r *http.Request) { // &version=1 101 | // r = r.WithContext(versioning.WithVersion(r.Context(), r.URL.Query().Get("version"))) 102 | // nextHandler.ServeHTTP(w,r) 103 | // } 104 | func WithVersion(ctx context.Context, version string) context.Context { 105 | return context.WithValue(ctx, contextKey, version) 106 | } -------------------------------------------------------------------------------- /version_test.go: -------------------------------------------------------------------------------- 1 | package versioning_test 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/kataras/versioning" 10 | ) 11 | 12 | func TestGetVersion(t *testing.T) { 13 | router := http.NewServeMux() 14 | 15 | writeVesion := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | w.Write([]byte(versioning.GetVersion(r))) 17 | }) 18 | 19 | router.Handle("/", writeVesion) 20 | router.HandleFunc("/manual", func(w http.ResponseWriter, r *http.Request) { 21 | r = r.WithContext(versioning.WithVersion(context.Background(), "11.0.5")) 22 | writeVesion.ServeHTTP(w, r) 23 | }) 24 | 25 | srv := httptest.NewServer(router) 26 | defer srv.Close() 27 | 28 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "1.0")). 29 | statusCode(http.StatusOK). 30 | bodyEq("1.0") 31 | 32 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=2.1")). 33 | statusCode(http.StatusOK). 34 | bodyEq("2.1") 35 | 36 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=2.1 ;other=dsa")). 37 | statusCode(http.StatusOK). 38 | bodyEq("2.1") 39 | 40 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "version=2.1")). 41 | statusCode(http.StatusOK). 42 | bodyEq("2.1") 43 | 44 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "version=1")). 45 | statusCode(http.StatusOK). 46 | bodyEq("1") 47 | 48 | // unknown versions. 49 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "")). 50 | statusCode(http.StatusOK). 51 | bodyEq(versioning.NotFound) 52 | 53 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version=")). 54 | statusCode(http.StatusOK). 55 | bodyEq(versioning.NotFound) 56 | 57 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version= ;other=dsa")). 58 | statusCode(http.StatusOK). 59 | bodyEq(versioning.NotFound) 60 | 61 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptHeaderKey, "version=")). 62 | statusCode(http.StatusOK). 63 | bodyEq(versioning.NotFound) 64 | 65 | expect(t, http.MethodGet, srv.URL+"/manual", withHeader(versioning.AcceptHeaderKey, "application/vnd.api+json; version= ;other=dsa")). 66 | statusCode(http.StatusOK). 67 | bodyEq("11.0.5") 68 | } 69 | -------------------------------------------------------------------------------- /versioning.go: -------------------------------------------------------------------------------- 1 | package versioning 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/hashicorp/go-version" 7 | ) 8 | 9 | // If reports whether the "version" is a valid match to the "is". 10 | // The "is" should be a version constraint like ">= 1, < 3". 11 | func If(v string, is string) bool { 12 | ver, err := version.NewVersion(v) 13 | if err != nil { 14 | return false 15 | } 16 | 17 | constraints, err := version.NewConstraint(is) 18 | if err != nil { 19 | return false 20 | } 21 | 22 | return constraints.Check(ver) 23 | } 24 | 25 | // Match reports whether the current version matches the "expectedVersion". 26 | func Match(r *http.Request, expectedVersion string) bool { 27 | return If(GetVersion(r), expectedVersion) 28 | } 29 | 30 | // Map is a map of version to handler. 31 | // A handler per version or constraint, the key can be something like ">1, <=2" or just "1". 32 | type Map map[string]http.Handler 33 | 34 | // NewMatcher creates a single handler which decides what handler 35 | // should be executed based on the requested version. 36 | // 37 | // Use the `NewGroup` if you want to add many routes under a specific version. 38 | // 39 | // See `Map` and `NewGroup` too. 40 | func NewMatcher(versions Map) http.Handler { 41 | constraintsHandlers, notFoundHandler := buildConstraints(versions) 42 | 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | versionString := GetVersion(r) 45 | if versionString == NotFound { 46 | notFoundHandler.ServeHTTP(w, r) 47 | return 48 | } 49 | 50 | ver, err := version.NewVersion(versionString) 51 | if err != nil { 52 | notFoundHandler.ServeHTTP(w, r) 53 | return 54 | } 55 | 56 | for _, ch := range constraintsHandlers { 57 | if ch.constraints.Check(ver) { 58 | w.Header().Set("X-API-Version", ver.String()) 59 | ch.handler.ServeHTTP(w, r) 60 | return 61 | } 62 | } 63 | 64 | // pass the not matched version so the not found handler can have knowedge about it. 65 | // ctx.Values().Set(Key, versionString) 66 | // or let a manual cal of GetVersion(ctx) do that instead. 67 | notFoundHandler.ServeHTTP(w, r) 68 | }) 69 | } 70 | 71 | type constraintsHandler struct { 72 | constraints version.Constraints 73 | handler http.Handler 74 | } 75 | 76 | func buildConstraints(versionsHandler Map) (constraintsHandlers []*constraintsHandler, notfoundHandler http.Handler) { 77 | for v, h := range versionsHandler { 78 | if v == NotFound { 79 | notfoundHandler = h 80 | continue 81 | } 82 | 83 | constraints, err := version.NewConstraint(v) 84 | if err != nil { 85 | panic(err) 86 | } 87 | 88 | constraintsHandlers = append(constraintsHandlers, &constraintsHandler{ 89 | constraints: constraints, 90 | handler: h, 91 | }) 92 | } 93 | 94 | if notfoundHandler == nil { 95 | notfoundHandler = NotFoundHandler 96 | } 97 | 98 | return 99 | } 100 | -------------------------------------------------------------------------------- /versioning_test.go: -------------------------------------------------------------------------------- 1 | package versioning_test 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/kataras/versioning" 14 | ) 15 | 16 | var notFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) 18 | }) 19 | 20 | const ( 21 | v10Response = "v1.0 handler" 22 | v2Response = "v2.x handler" 23 | ) 24 | 25 | func sendHandler(contents string) http.HandlerFunc { 26 | return func(w http.ResponseWriter, r *http.Request) { 27 | w.Write([]byte(contents)) 28 | } 29 | } 30 | 31 | func TestIf(t *testing.T) { 32 | if expected, got := true, versioning.If("1.0", ">=1"); expected != got { 33 | t.Fatalf("expected %s to be %s", "1.0", ">= 1") 34 | } 35 | if expected, got := true, versioning.If("1.2.3", "> 1.2"); expected != got { 36 | t.Fatalf("expected %s to be %s", "1.2.3", "> 1.2") 37 | } 38 | } 39 | 40 | func TestNewMatcher(t *testing.T) { 41 | router := http.NewServeMux() 42 | router.Handle("/api/user", versioning.NewMatcher(versioning.Map{ 43 | "1.0": sendHandler(v10Response), 44 | ">= 2, < 3": sendHandler(v2Response), 45 | versioning.NotFound: notFoundHandler, 46 | })) 47 | 48 | // middleware as usual. 49 | myMiddleware := func(next http.Handler) http.HandlerFunc { 50 | return func(w http.ResponseWriter, r *http.Request) { 51 | w.Header().Set("X-Custom", "something") 52 | next.ServeHTTP(w, r) 53 | } 54 | } 55 | myVersions := versioning.Map{ 56 | "1.0": sendHandler(v10Response), 57 | } 58 | 59 | router.Handle("/api/user/with_middleware", myMiddleware(versioning.NewMatcher(myVersions))) 60 | 61 | srv := httptest.NewServer(router) 62 | defer srv.Close() 63 | 64 | expect(t, http.MethodGet, srv.URL+"/api/user", withHeader(versioning.AcceptVersionHeaderKey, "1")). 65 | statusCode(http.StatusOK). 66 | bodyEq(v10Response) 67 | expect(t, http.MethodGet, srv.URL+"/api/user", withHeader(versioning.AcceptVersionHeaderKey, "2.0")). 68 | statusCode(http.StatusOK). 69 | bodyEq(v2Response) 70 | expect(t, http.MethodGet, srv.URL+"/api/user", withHeader(versioning.AcceptVersionHeaderKey, "2.1")). 71 | statusCode(http.StatusOK). 72 | bodyEq(v2Response) 73 | expect(t, http.MethodGet, srv.URL+"/api/user", withHeader(versioning.AcceptVersionHeaderKey, "2.9.9")). 74 | statusCode(http.StatusOK). 75 | bodyEq(v2Response) 76 | 77 | // middleware as usual. 78 | expect(t, http.MethodGet, srv.URL+"/api/user/with_middleware", withHeader(versioning.AcceptVersionHeaderKey, "1.0")). 79 | statusCode(http.StatusOK). 80 | bodyEq(v10Response).headerEq("X-Custom", "something") 81 | expect(t, http.MethodGet, srv.URL+"/api/user", withHeader(versioning.AcceptVersionHeaderKey, "3.0")). 82 | statusCode(http.StatusNotFound). 83 | bodyEq("Not Found\n") 84 | } 85 | 86 | func TestNewGroup(t *testing.T) { 87 | router := http.NewServeMux() 88 | 89 | userAPIV1 := versioning.NewGroup("1.0").Deprecated(versioning.DefaultDeprecationOptions) 90 | userAPIV1.Handle("/", sendHandler(v10Response)) 91 | 92 | userAPIV2 := versioning.NewGroup(">= 2, < 3") 93 | userAPIV2.Handle("/", sendHandler(v2Response)) 94 | userAPIV2.Handle("/other", sendHandler(v2Response)) 95 | 96 | versioning.RegisterGroups(router, versioning.NotFoundHandler, userAPIV1, userAPIV2) 97 | 98 | srv := httptest.NewServer(router) 99 | defer srv.Close() 100 | 101 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "1")). 102 | statusCode(http.StatusOK). 103 | bodyEq(v10Response). 104 | headerEq("X-API-Warn", versioning.DefaultDeprecationOptions.WarnMessage) 105 | 106 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "2.1")). 107 | statusCode(http.StatusOK). 108 | bodyEq(v2Response) 109 | 110 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "2.9.9")). 111 | statusCode(http.StatusOK). 112 | bodyEq(v2Response) 113 | 114 | expect(t, http.MethodGet, srv.URL+"/other", withHeader(versioning.AcceptVersionHeaderKey, "2.9")). 115 | statusCode(http.StatusOK). 116 | bodyEq(v2Response) 117 | 118 | expect(t, http.MethodGet, srv.URL, withHeader(versioning.AcceptVersionHeaderKey, "3.0")). 119 | statusCode(http.StatusNotImplemented). 120 | bodyEq("version not found") 121 | } 122 | 123 | // Small test suite for this package follows. 124 | 125 | func expect(t *testing.T, method, url string, testieOptions ...func(*http.Request)) *testie { 126 | req, err := http.NewRequest(method, url, nil) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | for _, opt := range testieOptions { 132 | opt(req) 133 | } 134 | 135 | return testReq(t, req) 136 | } 137 | 138 | func withHeader(key string, value string) func(*http.Request) { 139 | return func(r *http.Request) { 140 | r.Header.Add(key, value) 141 | } 142 | } 143 | 144 | func withQuery(key string, value string) func(*http.Request) { 145 | return func(r *http.Request) { 146 | q := r.URL.Query() 147 | q.Add(key, value) 148 | 149 | enc := strings.NewReader(q.Encode()) 150 | r.Body = ioutil.NopCloser(enc) 151 | r.GetBody = func() (io.ReadCloser, error) { return http.NoBody, nil } 152 | 153 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 154 | } 155 | } 156 | 157 | func withFormField(key string, value string) func(*http.Request) { 158 | return func(r *http.Request) { 159 | if r.Form == nil { 160 | r.Form = make(url.Values) 161 | } 162 | r.Form.Add(key, value) 163 | 164 | enc := strings.NewReader(r.Form.Encode()) 165 | r.Body = ioutil.NopCloser(enc) 166 | r.ContentLength = int64(enc.Len()) 167 | 168 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") 169 | } 170 | } 171 | 172 | func expectWithBody(t *testing.T, method, url string, body string, headers http.Header) *testie { 173 | req, err := http.NewRequest(method, url, bytes.NewBufferString(body)) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | 178 | if len(headers) > 0 { 179 | req.Header = http.Header{} 180 | for k, v := range headers { 181 | req.Header[k] = v 182 | } 183 | } 184 | 185 | return testReq(t, req) 186 | } 187 | 188 | func testReq(t *testing.T, req *http.Request) *testie { 189 | resp, err := http.DefaultClient.Do(req) 190 | if err != nil { 191 | t.Fatal(err) 192 | } 193 | 194 | resp.Request = req 195 | return &testie{t: t, resp: resp} 196 | } 197 | 198 | func testHandler(t *testing.T, handler http.Handler, method, url string) *testie { 199 | w := httptest.NewRecorder() 200 | req := httptest.NewRequest(method, url, nil) 201 | handler.ServeHTTP(w, req) 202 | resp := w.Result() 203 | resp.Request = req 204 | return &testie{t: t, resp: resp} 205 | } 206 | 207 | type testie struct { 208 | t *testing.T 209 | resp *http.Response 210 | } 211 | 212 | func (te *testie) statusCode(expected int) *testie { 213 | if got := te.resp.StatusCode; expected != got { 214 | te.t.Fatalf("%s: expected status code: %d but got %d", te.resp.Request.URL, expected, got) 215 | } 216 | 217 | return te 218 | } 219 | 220 | func (te *testie) bodyEq(expected string) *testie { 221 | te.t.Helper() 222 | 223 | b, err := ioutil.ReadAll(te.resp.Body) 224 | te.resp.Body.Close() 225 | if err != nil { 226 | te.t.Fatal(err) 227 | } 228 | 229 | if got := string(b); expected != got { 230 | te.t.Fatalf("%s: expected to receive '%s' but got '%s'", te.resp.Request.URL, expected, got) 231 | } 232 | 233 | return te 234 | } 235 | 236 | func (te *testie) headerEq(key, expected string) *testie { 237 | if got := te.resp.Header.Get(key); expected != got { 238 | te.t.Fatalf("%s: expected header value of %s to be: '%s' but got '%s'", te.resp.Request.URL, key, expected, got) 239 | } 240 | 241 | return te 242 | } 243 | --------------------------------------------------------------------------------