├── LICENSE ├── Readme.md ├── ci.yml ├── redirects.go ├── redirects_example_test.go └── redirects_test.go /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 TJ Holowaychuk tj@tjholowaychuk.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Redirect 2 | 3 | Redirect format parser to match Netlify's [format](https://www.netlify.com/docs/redirects/). 4 | 5 | ## Example 6 | 7 | ```sh 8 | # Implicit 301 redirects 9 | /home / 10 | /blog/my-post.php /blog/my-post 11 | /news /blog 12 | /google https://www.google.com 13 | 14 | # Redirect with a 301 15 | /home / 301 16 | 17 | # Redirect with a 302 18 | /my-redirect / 302 19 | 20 | # Rewrite a path 21 | /pass-through /index.html 200 22 | 23 | # Show a custom 404 for this path 24 | /ecommerce /store-closed 404 25 | 26 | # Single page app rewrite 27 | /* /index.html 200 28 | 29 | # Proxying 30 | /api/* https://api.example.com/:splat 200 31 | 32 | # Forcing 33 | /app/* /app/index.html 200! 34 | 35 | # Params 36 | / /something 302 foo=bar 37 | / /something 302 foo=bar bar=baz 38 | ``` 39 | 40 | yields 41 | 42 | ```json 43 | [ 44 | { 45 | "From": "/home", 46 | "To": "/", 47 | "Status": 301, 48 | "Force": false, 49 | "Params": null 50 | }, 51 | { 52 | "From": "/blog/my-post.php", 53 | "To": "/blog/my-post", 54 | "Status": 301, 55 | "Force": false, 56 | "Params": null 57 | }, 58 | { 59 | "From": "/news", 60 | "To": "/blog", 61 | "Status": 301, 62 | "Force": false, 63 | "Params": null 64 | }, 65 | { 66 | "From": "/google", 67 | "To": "https://www.google.com", 68 | "Status": 301, 69 | "Force": false, 70 | "Params": null 71 | }, 72 | { 73 | "From": "/home", 74 | "To": "/", 75 | "Status": 301, 76 | "Force": false, 77 | "Params": null 78 | }, 79 | { 80 | "From": "/my-redirect", 81 | "To": "/", 82 | "Status": 302, 83 | "Force": false, 84 | "Params": null 85 | }, 86 | { 87 | "From": "/pass-through", 88 | "To": "/index.html", 89 | "Status": 200, 90 | "Force": false, 91 | "Params": null 92 | }, 93 | { 94 | "From": "/ecommerce", 95 | "To": "/store-closed", 96 | "Status": 404, 97 | "Force": false, 98 | "Params": null 99 | }, 100 | { 101 | "From": "/*", 102 | "To": "/index.html", 103 | "Status": 200, 104 | "Force": false, 105 | "Params": null 106 | }, 107 | { 108 | "From": "/api/*", 109 | "To": "https://api.example.com/:splat", 110 | "Status": 200, 111 | "Force": false, 112 | "Params": null 113 | }, 114 | { 115 | "From": "/app/*", 116 | "To": "/app/index.html", 117 | "Status": 200, 118 | "Force": true, 119 | "Params": null 120 | }, 121 | { 122 | "From": "/", 123 | "To": "/something", 124 | "Status": 302, 125 | "Force": false, 126 | "Params": { 127 | "foo": "bar" 128 | } 129 | }, 130 | { 131 | "From": "/", 132 | "To": "/something", 133 | "Status": 302, 134 | "Force": false, 135 | "Params": { 136 | "bar": "baz", 137 | "foo": "bar" 138 | } 139 | } 140 | ] 141 | ``` 142 | 143 | --- 144 | 145 | [![GoDoc](https://godoc.org/github.com/tj/go-redirects?status.svg)](https://godoc.org/github.com/tj/go-redirects) 146 | ![](https://img.shields.io/badge/license-MIT-blue.svg) 147 | ![](https://img.shields.io/badge/status-stable-green.svg) 148 | 149 | 150 | -------------------------------------------------------------------------------- /ci.yml: -------------------------------------------------------------------------------- 1 | version: 0.2 2 | 3 | phases: 4 | install: 5 | commands: 6 | - go get -t ./... 7 | build: 8 | commands: 9 | - go test -cover -v ./... 10 | -------------------------------------------------------------------------------- /redirects.go: -------------------------------------------------------------------------------- 1 | // Package redirects provides Netlify style _redirects file format parsing. 2 | package redirects 3 | 4 | import ( 5 | "bufio" 6 | "io" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Params is a map of key/value pairs. 15 | type Params map[string]interface{} 16 | 17 | // Has returns true if the param is present. 18 | func (p *Params) Has(key string) bool { 19 | if p == nil { 20 | return false 21 | } 22 | 23 | _, ok := (*p)[key] 24 | return ok 25 | } 26 | 27 | // Get returns the key value. 28 | func (p *Params) Get(key string) interface{} { 29 | if p == nil { 30 | return nil 31 | } 32 | 33 | return (*p)[key] 34 | } 35 | 36 | // A Rule represents a single redirection or rewrite rule. 37 | type Rule struct { 38 | // From is the path which is matched to perform the rule. 39 | From string 40 | 41 | // To is the destination which may be relative, or absolute 42 | // in order to proxy the request to another URL. 43 | To string 44 | 45 | // Status is one of the following: 46 | // 47 | // - 3xx a redirect 48 | // - 200 a rewrite 49 | // - defaults to 301 redirect 50 | // 51 | // When proxying this field is ignored. 52 | // 53 | Status int 54 | 55 | // Force is used to force a rewrite or redirect even 56 | // when a response (or static file) is present. 57 | Force bool 58 | 59 | // Params is an optional arbitrary map of key/value pairs. 60 | Params Params 61 | } 62 | 63 | // IsRewrite returns true if the rule represents a rewrite (status 200). 64 | func (r *Rule) IsRewrite() bool { 65 | return r.Status == 200 66 | } 67 | 68 | // IsProxy returns true if it's a proxy rule (aka contains a hostname). 69 | func (r *Rule) IsProxy() bool { 70 | u, err := url.Parse(r.To) 71 | if err != nil { 72 | return false 73 | } 74 | 75 | return u.Host != "" 76 | } 77 | 78 | // Must parse utility. 79 | func Must(v []Rule, err error) []Rule { 80 | if err != nil { 81 | panic(err) 82 | } 83 | 84 | return v 85 | } 86 | 87 | // Parse the given reader. 88 | func Parse(r io.Reader) (rules []Rule, err error) { 89 | s := bufio.NewScanner(r) 90 | 91 | for s.Scan() { 92 | line := strings.TrimSpace(s.Text()) 93 | 94 | // empty 95 | if line == "" { 96 | continue 97 | } 98 | 99 | // comment 100 | if strings.HasPrefix(line, "#") { 101 | continue 102 | } 103 | 104 | // fields 105 | fields := strings.Fields(line) 106 | 107 | // missing dst 108 | if len(fields) <= 1 { 109 | return nil, errors.Wrapf(err, "missing destination path: %q", line) 110 | } 111 | 112 | // src and dst 113 | rule := Rule{ 114 | From: fields[0], 115 | To: fields[1], 116 | Status: 301, 117 | } 118 | 119 | // status 120 | if len(fields) > 2 { 121 | code, force, err := parseStatus(fields[2]) 122 | if err != nil { 123 | return nil, errors.Wrapf(err, "parsing status %q", fields[2]) 124 | } 125 | 126 | rule.Status = code 127 | rule.Force = force 128 | } 129 | 130 | // params 131 | if len(fields) > 3 { 132 | rule.Params = parseParams(fields[3:]) 133 | } 134 | 135 | rules = append(rules, rule) 136 | } 137 | 138 | err = s.Err() 139 | return 140 | } 141 | 142 | // ParseString parses the given string. 143 | func ParseString(s string) ([]Rule, error) { 144 | return Parse(strings.NewReader(s)) 145 | } 146 | 147 | // parseParams returns parsed param key/value pairs. 148 | func parseParams(pairs []string) Params { 149 | m := make(Params) 150 | 151 | for _, p := range pairs { 152 | parts := strings.Split(p, "=") 153 | if len(parts) > 1 { 154 | m[parts[0]] = parts[1] 155 | } else { 156 | m[parts[0]] = true 157 | } 158 | } 159 | 160 | return m 161 | } 162 | 163 | // parseStatus returns the status code and force when "!" suffix is present. 164 | func parseStatus(s string) (code int, force bool, err error) { 165 | if strings.HasSuffix(s, "!") { 166 | force = true 167 | s = strings.Replace(s, "!", "", -1) 168 | } 169 | 170 | code, err = strconv.Atoi(s) 171 | return 172 | } 173 | -------------------------------------------------------------------------------- /redirects_example_test.go: -------------------------------------------------------------------------------- 1 | package redirects_test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/tj/go-redirects" 8 | ) 9 | 10 | func Example() { 11 | h := redirects.Must(redirects.ParseString(` 12 | # Implicit 301 redirects 13 | /home / 14 | /blog/my-post.php /blog/my-post 15 | /news /blog 16 | /google https://www.google.com 17 | 18 | # Redirect with a 301 19 | /home / 301 20 | 21 | # Redirect with a 302 22 | /my-redirect / 302 23 | 24 | # Rewrite a path 25 | /pass-through /index.html 200 26 | 27 | # Show a custom 404 for this path 28 | /ecommerce /store-closed 404 29 | 30 | # Single page app rewrite 31 | /* /index.html 200 32 | 33 | # Proxying 34 | /api/* https://api.example.com/:splat 200 35 | 36 | # Forcing 37 | /app/* /app/index.html 200! 38 | 39 | # Params 40 | / /something 302 foo=bar 41 | / /something 302 foo=bar bar=baz 42 | `)) 43 | 44 | enc := json.NewEncoder(os.Stdout) 45 | enc.SetIndent("", " ") 46 | enc.Encode(h) 47 | // Output: 48 | // [ 49 | // { 50 | // "From": "/home", 51 | // "To": "/", 52 | // "Status": 301, 53 | // "Force": false, 54 | // "Params": null 55 | // }, 56 | // { 57 | // "From": "/blog/my-post.php", 58 | // "To": "/blog/my-post", 59 | // "Status": 301, 60 | // "Force": false, 61 | // "Params": null 62 | // }, 63 | // { 64 | // "From": "/news", 65 | // "To": "/blog", 66 | // "Status": 301, 67 | // "Force": false, 68 | // "Params": null 69 | // }, 70 | // { 71 | // "From": "/google", 72 | // "To": "https://www.google.com", 73 | // "Status": 301, 74 | // "Force": false, 75 | // "Params": null 76 | // }, 77 | // { 78 | // "From": "/home", 79 | // "To": "/", 80 | // "Status": 301, 81 | // "Force": false, 82 | // "Params": null 83 | // }, 84 | // { 85 | // "From": "/my-redirect", 86 | // "To": "/", 87 | // "Status": 302, 88 | // "Force": false, 89 | // "Params": null 90 | // }, 91 | // { 92 | // "From": "/pass-through", 93 | // "To": "/index.html", 94 | // "Status": 200, 95 | // "Force": false, 96 | // "Params": null 97 | // }, 98 | // { 99 | // "From": "/ecommerce", 100 | // "To": "/store-closed", 101 | // "Status": 404, 102 | // "Force": false, 103 | // "Params": null 104 | // }, 105 | // { 106 | // "From": "/*", 107 | // "To": "/index.html", 108 | // "Status": 200, 109 | // "Force": false, 110 | // "Params": null 111 | // }, 112 | // { 113 | // "From": "/api/*", 114 | // "To": "https://api.example.com/:splat", 115 | // "Status": 200, 116 | // "Force": false, 117 | // "Params": null 118 | // }, 119 | // { 120 | // "From": "/app/*", 121 | // "To": "/app/index.html", 122 | // "Status": 200, 123 | // "Force": true, 124 | // "Params": null 125 | // }, 126 | // { 127 | // "From": "/", 128 | // "To": "/something", 129 | // "Status": 302, 130 | // "Force": false, 131 | // "Params": { 132 | // "foo": "bar" 133 | // } 134 | // }, 135 | // { 136 | // "From": "/", 137 | // "To": "/something", 138 | // "Status": 302, 139 | // "Force": false, 140 | // "Params": { 141 | // "bar": "baz", 142 | // "foo": "bar" 143 | // } 144 | // } 145 | // ] 146 | } 147 | -------------------------------------------------------------------------------- /redirects_test.go: -------------------------------------------------------------------------------- 1 | package redirects_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tj/assert" 7 | "github.com/tj/go-redirects" 8 | ) 9 | 10 | func TestParams_Has(t *testing.T) { 11 | p := redirects.Params{ 12 | "foo": true, 13 | "bar": "baz", 14 | } 15 | 16 | assert.True(t, p.Has("foo")) 17 | assert.True(t, p.Has("bar")) 18 | assert.False(t, p.Has("baz")) 19 | } 20 | 21 | func TestParams_Get(t *testing.T) { 22 | p := redirects.Params{ 23 | "foo": true, 24 | "bar": "baz", 25 | } 26 | 27 | assert.Equal(t, true, p.Get("foo")) 28 | assert.Equal(t, "baz", p.Get("bar")) 29 | assert.Equal(t, nil, p.Get("baz")) 30 | } 31 | 32 | func TestRule_IsProxy(t *testing.T) { 33 | t.Run("without host", func(t *testing.T) { 34 | r := redirects.Rule{ 35 | From: "/blog", 36 | To: "/blog/engineering", 37 | } 38 | 39 | assert.False(t, r.IsProxy()) 40 | }) 41 | 42 | t.Run("with host", func(t *testing.T) { 43 | r := redirects.Rule{ 44 | From: "/blog", 45 | To: "https://blog.apex.sh", 46 | } 47 | 48 | assert.True(t, r.IsProxy()) 49 | }) 50 | } 51 | 52 | func TestRule_IsRewrite(t *testing.T) { 53 | t.Run("with 3xx", func(t *testing.T) { 54 | r := redirects.Rule{ 55 | From: "/blog", 56 | To: "/blog/engineering", 57 | Status: 302, 58 | } 59 | 60 | assert.False(t, r.IsRewrite()) 61 | }) 62 | 63 | t.Run("with 200", func(t *testing.T) { 64 | r := redirects.Rule{ 65 | From: "/blog", 66 | To: "/blog/engineering", 67 | Status: 200, 68 | } 69 | 70 | assert.True(t, r.IsRewrite()) 71 | }) 72 | 73 | t.Run("with 0", func(t *testing.T) { 74 | r := redirects.Rule{ 75 | From: "/blog", 76 | To: "/blog/engineering", 77 | } 78 | 79 | assert.False(t, r.IsRewrite()) 80 | }) 81 | } 82 | --------------------------------------------------------------------------------