Skip to content

Commit efc53cb

Browse files
committed
feat!: Matches required query parameters
Code now parses and `MatchAndExpandPlaceholders` for query parameters as well as paths. Placeholders are shared between the two. This commit codifies some of the previously implicit edgecases, particularly around duplicate placeholders. Closes #20
1 parent 00c708c commit efc53cb

File tree

4 files changed

+493
-28
lines changed

4 files changed

+493
-28
lines changed

README.md

+6-2
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ This is a parser for the IPFS Web Gateway's `_redirects` file format.
77
Follow specification work at https://github.com/ipfs/specs/pull/290
88

99
## Format
10-
Currently only supports `from`, `to` and `status`.
10+
Currently only supports `from`, `fromQuery`, `to` and `status`.
1111

1212
```
13-
from to [status]
13+
from [fromQuery [fromQuery]] to [status]
1414
```
1515

1616
## Example
@@ -39,6 +39,10 @@ from to [status]
3939

4040
# Single page app rewrite (SPA, PWA)
4141
/* /index.html 200
42+
43+
# Query parameter rewrite
44+
/thing type=:type /things/:type.html 200
45+
/thing /things.html 200
4246
```
4347

4448
## Notes for contributors

redirects.go

+130-19
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/url"
9+
"regexp"
910
"strconv"
1011
"strings"
1112

@@ -21,6 +22,12 @@ type Rule struct {
2122
// From is the path which is matched to perform the rule.
2223
From string
2324

25+
// FromQuery is the set of required query parameters which
26+
// must be present to perform the rule.
27+
// A string without a preceding colon requires that query parameter is this exact value.
28+
// A string with a preceding colon will match any value, and provide it as a placeholder.
29+
FromQuery map[string]string
30+
2431
// To is the destination which may be relative, or absolute
2532
// in order to proxy the request to another URL.
2633
To string
@@ -51,37 +58,70 @@ func (r *Rule) IsProxy() bool {
5158

5259
// MatchAndExpandPlaceholders expands placeholders in `r.To` and returns true if the provided path matches.
5360
// Otherwise it returns false.
54-
func (r *Rule) MatchAndExpandPlaceholders(urlPath string) bool {
61+
func (r *Rule) MatchAndExpandPlaceholders(urlPath string, urlParams url.Values) bool {
5562
// get rule.From, trim trailing slash, ...
5663
fromPath := urlpath.New(strings.TrimSuffix(r.From, "/"))
5764
match, ok := fromPath.Match(urlPath)
58-
5965
if !ok {
6066
return false
6167
}
6268

63-
// We have a match! Perform substitution and return the updated rule
69+
placeholders := match.Params
70+
placeholders["splat"] = match.Trailing
71+
if !matchParams(r.FromQuery, urlParams, placeholders) {
72+
return false
73+
}
74+
75+
// We have a match! Perform substitution and return the updated rule
6476
toPath := r.To
65-
toPath = replacePlaceholders(toPath, match)
66-
toPath = replaceSplat(toPath, match)
77+
toPath = replacePlaceholders(toPath, placeholders)
78+
79+
// There's a placeholder unsupplied somewhere
80+
if strings.Contains(toPath, ":") {
81+
return false
82+
}
6783

6884
r.To = toPath
6985

7086
return true
7187
}
7288

73-
func replacePlaceholders(to string, match urlpath.Match) string {
74-
if len(match.Params) > 0 {
75-
for key, value := range match.Params {
76-
to = strings.ReplaceAll(to, ":"+key, value)
77-
}
89+
func replacePlaceholders(to string, placeholders map[string]string) string {
90+
if len(placeholders) == 0 {
91+
return to
92+
}
93+
94+
for key, value := range placeholders {
95+
to = strings.ReplaceAll(to, ":"+key, value)
7896
}
7997

8098
return to
8199
}
82100

83-
func replaceSplat(to string, match urlpath.Match) string {
84-
return strings.ReplaceAll(to, ":splat", match.Trailing)
101+
func replaceSplat(to string, splat string) string {
102+
return strings.ReplaceAll(to, ":splat", splat)
103+
}
104+
105+
func matchParams(fromQuery map[string]string, urlParams url.Values, placeholders map[string]string) bool {
106+
for neededK, neededV := range fromQuery {
107+
haveVs, ok := urlParams[neededK]
108+
if !ok {
109+
return false
110+
}
111+
112+
if isPlaceholder(neededV) {
113+
if _, ok := placeholders[neededV[1:]]; !ok {
114+
placeholders[neededV[1:]] = haveVs[0]
115+
}
116+
continue
117+
}
118+
119+
if !contains(haveVs, neededV) {
120+
return false
121+
}
122+
}
123+
124+
return true
85125
}
86126

87127
// Must parse utility.
@@ -124,10 +164,6 @@ func Parse(r io.Reader) (rules []Rule, err error) {
124164
return nil, fmt.Errorf("missing 'to' path")
125165
}
126166

127-
if len(fields) > 3 {
128-
return nil, fmt.Errorf("must match format 'from to [status]'")
129-
}
130-
131167
// implicit status
132168
rule := Rule{Status: 301}
133169

@@ -138,23 +174,42 @@ func Parse(r io.Reader) (rules []Rule, err error) {
138174
}
139175
rule.From = from
140176

177+
hasStatus := isLikelyStatusCode(fields[len(fields)-1])
178+
toIndex := len(fields) - 1
179+
if hasStatus {
180+
toIndex = len(fields) - 2
181+
}
182+
141183
// to (must parse as an absolute path or an URL)
142-
to, err := parseTo(fields[1])
184+
to, err := parseTo(fields[toIndex])
143185
if err != nil {
144186
return nil, errors.Wrapf(err, "parsing 'to'")
145187
}
146188
rule.To = to
147189

148190
// status
149-
if len(fields) > 2 {
150-
code, err := parseStatus(fields[2])
191+
if hasStatus {
192+
code, err := parseStatus(fields[len(fields)-1])
151193
if err != nil {
152194
return nil, errors.Wrapf(err, "parsing status %q", fields[2])
153195
}
154196

155197
rule.Status = code
156198
}
157199

200+
// from query
201+
if toIndex > 1 {
202+
rule.FromQuery = make(map[string]string)
203+
204+
for i := 1; i < toIndex; i++ {
205+
key, value, err := parseFromQuery(fields[i])
206+
if err != nil {
207+
return nil, errors.Wrapf(err, "parsing 'fromQuery'")
208+
}
209+
rule.FromQuery[key] = value
210+
}
211+
}
212+
158213
rules = append(rules, rule)
159214
}
160215

@@ -194,6 +249,46 @@ func parseFrom(s string) (string, error) {
194249
return s, nil
195250
}
196251

252+
func parseFromQuery(s string) (string, string, error) {
253+
params, err := url.ParseQuery(s)
254+
if err != nil {
255+
return "", "", err
256+
}
257+
if len(params) != 1 {
258+
return "", "", fmt.Errorf("separate different fromQuery arguments with a space")
259+
}
260+
261+
var key string
262+
var val []string
263+
// We know there's only 1, but we don't know the key to access it
264+
for k, v := range params {
265+
key = k
266+
val = v
267+
}
268+
269+
if url.QueryEscape(key) != key {
270+
return "", "", fmt.Errorf("fromQuery key must be URL encoded")
271+
}
272+
273+
if len(val) > 1 {
274+
return "", "", fmt.Errorf("separate different fromQuery arguments with a space")
275+
}
276+
277+
ignorePlaceholders := val[0]
278+
if isPlaceholder(val[0]) {
279+
ignorePlaceholders = ignorePlaceholders[1:]
280+
}
281+
282+
if url.QueryEscape(ignorePlaceholders) != ignorePlaceholders {
283+
return "", "", fmt.Errorf("fromQuery val must be URL encoded")
284+
}
285+
return key, val[0], nil
286+
}
287+
288+
func isPlaceholder(s string) bool {
289+
return strings.HasPrefix(s, ":")
290+
}
291+
197292
func parseTo(s string) (string, error) {
198293
// confirm value is within URL path spec
199294
u, err := url.Parse(s)
@@ -211,6 +306,13 @@ func parseTo(s string) (string, error) {
211306
return s, nil
212307
}
213308

309+
var likeStatusCode = regexp.MustCompile(`^\d{1,3}!?$`)
310+
311+
// isLikelyStatusCode returns true if the given string is likely to be a status code.
312+
func isLikelyStatusCode(s string) bool {
313+
return likeStatusCode.MatchString(s)
314+
}
315+
214316
// parseStatus returns the status code.
215317
func parseStatus(s string) (code int, err error) {
216318
if strings.HasSuffix(s, "!") {
@@ -237,3 +339,12 @@ func isValidStatusCode(status int) bool {
237339
}
238340
return false
239341
}
342+
343+
func contains(arr []string, s string) bool {
344+
for _, a := range arr {
345+
if a == s {
346+
return true
347+
}
348+
}
349+
return false
350+
}

redirects_example_test.go

+71-1
Original file line numberDiff line numberDiff line change
@@ -30,62 +30,132 @@ func Example() {
3030
3131
# Proxying
3232
/api/* https://api.example.com/:splat 200
33+
34+
# Query parameters
35+
/things type=photos /photos.html 200
36+
/things type= /empty.html 200
37+
/things type=:thing /things/:thing.html 200
38+
/things /things.html 200
39+
40+
# Multiple query parameters
41+
/stuff type=lost name=:name other=:ignore /other-stuff/:name.html 200
42+
43+
# Query parameters with implicit 301
44+
/items id=:id /items/:id.html
3345
`))
3446

3547
enc := json.NewEncoder(os.Stdout)
3648
enc.SetIndent("", " ")
3749
enc.Encode(h)
3850
// Output:
39-
// [
51+
// [
4052
// {
4153
// "From": "/home",
54+
// "FromQuery": null,
4255
// "To": "/",
4356
// "Status": 301
4457
// },
4558
// {
4659
// "From": "/blog/my-post.php",
60+
// "FromQuery": null,
4761
// "To": "/blog/my-post",
4862
// "Status": 301
4963
// },
5064
// {
5165
// "From": "/news",
66+
// "FromQuery": null,
5267
// "To": "/blog",
5368
// "Status": 301
5469
// },
5570
// {
5671
// "From": "/google",
72+
// "FromQuery": null,
5773
// "To": "https://www.google.com",
5874
// "Status": 301
5975
// },
6076
// {
6177
// "From": "/home",
78+
// "FromQuery": null,
6279
// "To": "/",
6380
// "Status": 301
6481
// },
6582
// {
6683
// "From": "/my-redirect",
84+
// "FromQuery": null,
6785
// "To": "/",
6886
// "Status": 302
6987
// },
7088
// {
7189
// "From": "/pass-through",
90+
// "FromQuery": null,
7291
// "To": "/index.html",
7392
// "Status": 200
7493
// },
7594
// {
7695
// "From": "/ecommerce",
96+
// "FromQuery": null,
7797
// "To": "/store-closed",
7898
// "Status": 404
7999
// },
80100
// {
81101
// "From": "/*",
102+
// "FromQuery": null,
82103
// "To": "/index.html",
83104
// "Status": 200
84105
// },
85106
// {
86107
// "From": "/api/*",
108+
// "FromQuery": null,
87109
// "To": "https://api.example.com/:splat",
88110
// "Status": 200
111+
// },
112+
// {
113+
// "From": "/things",
114+
// "FromQuery": {
115+
// "type": "photos"
116+
// },
117+
// "To": "/photos.html",
118+
// "Status": 200
119+
// },
120+
// {
121+
// "From": "/things",
122+
// "FromQuery": {
123+
// "type": ""
124+
// },
125+
// "To": "/empty.html",
126+
// "Status": 200
127+
// },
128+
// {
129+
// "From": "/things",
130+
// "FromQuery": {
131+
// "type": ":thing"
132+
// },
133+
// "To": "/things/:thing.html",
134+
// "Status": 200
135+
// },
136+
// {
137+
// "From": "/things",
138+
// "FromQuery": null,
139+
// "To": "/things.html",
140+
// "Status": 200
141+
// },
142+
// {
143+
// "From": "/stuff",
144+
// "FromQuery": {
145+
// "name": ":name",
146+
// "other": ":ignore",
147+
// "type": "lost"
148+
// },
149+
// "To": "/other-stuff/:name.html",
150+
// "Status": 200
151+
// },
152+
// {
153+
// "From": "/items",
154+
// "FromQuery": {
155+
// "id": ":id"
156+
// },
157+
// "To": "/items/:id.html",
158+
// "Status": 301
89159
// }
90160
// ]
91161
}

0 commit comments

Comments
 (0)