Skip to content

Commit 8533b2c

Browse files
author
Jamie Curnow
committed
Support Preferred Example Header
- Added support for example=something in Prefer request header - Will fallback to a random example if not found - Correctly support the Prefer header syntax specification, status doesn't have to be the first item anymore
1 parent 8104239 commit 8533b2c

File tree

2 files changed

+162
-15
lines changed

2 files changed

+162
-15
lines changed

apisprout.go

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"net/url"
1313
"os"
1414
"path/filepath"
15+
"regexp"
1516
"strconv"
1617
"strings"
1718
"time"
@@ -137,21 +138,32 @@ func addParameter(flags *pflag.FlagSet, name, short string, def interface{}, des
137138

138139
// getTypedExample will return an example from a given media type, if such an
139140
// example exists. If multiple examples are given, then one is selected at
140-
// random.
141-
func getTypedExample(mt *openapi3.MediaType) (interface{}, error) {
141+
// random unless an "example" item exists in the Prefer header
142+
func getTypedExample(mt *openapi3.MediaType, prefer map[string]string) (interface{}, error) {
142143
if mt.Example != nil {
143144
return mt.Example, nil
144145
}
145146

146147
if len(mt.Examples) > 0 {
148+
// If preferred example requested and it it exists, return it
149+
preferredExample := ""
150+
if mapContainsKey(prefer, "example") {
151+
preferredExample = prefer["example"]
152+
if _, ok := mt.Examples[preferredExample]; ok {
153+
return mt.Examples[preferredExample].Value.Value, nil
154+
}
155+
}
156+
147157
// Choose a random example to return.
148158
keys := make([]string, 0, len(mt.Examples))
149159
for k := range mt.Examples {
150160
keys = append(keys, k)
151161
}
152162

153-
selected := keys[rand.Intn(len(keys))]
154-
return mt.Examples[selected].Value.Value, nil
163+
if len(keys) > 0 {
164+
selected := keys[rand.Intn(len(keys))]
165+
return mt.Examples[selected].Value.Value, nil
166+
}
155167
}
156168

157169
if mt.Schema != nil {
@@ -163,11 +175,12 @@ func getTypedExample(mt *openapi3.MediaType) (interface{}, error) {
163175
}
164176

165177
// getExample tries to return an example for a given operation.
166-
func getExample(negotiator *ContentNegotiator, prefer string, op *openapi3.Operation) (int, string, map[string]*openapi3.HeaderRef, interface{}, error) {
178+
// Using the Prefer http header, the consumer can specify the type of response they want.
179+
func getExample(negotiator *ContentNegotiator, prefer map[string]string, op *openapi3.Operation) (int, string, map[string]*openapi3.HeaderRef, interface{}, error) {
167180
var responses []string
168181
var blankHeaders = make(map[string]*openapi3.HeaderRef)
169182

170-
if prefer == "" {
183+
if !mapContainsKey(prefer, "status") {
171184
// First, make a list of responses ordered by successful (200-299 status code)
172185
// before other types.
173186
success := make([]string, 0)
@@ -181,10 +194,10 @@ func getExample(negotiator *ContentNegotiator, prefer string, op *openapi3.Opera
181194
}
182195
responses = append(success, other...)
183196
} else {
184-
if op.Responses[prefer] == nil {
197+
if op.Responses[prefer["status"]] == nil {
185198
return 0, "", blankHeaders, nil, ErrNoExample
186199
}
187-
responses = []string{prefer}
200+
responses = []string{prefer["status"]}
188201
}
189202

190203
// Now try to find the first example we can and return it!
@@ -207,7 +220,7 @@ func getExample(negotiator *ContentNegotiator, prefer string, op *openapi3.Opera
207220
continue
208221
}
209222

210-
example, err := getTypedExample(content)
223+
example, err := getTypedExample(content, prefer)
211224
if err == nil {
212225
return status, mt, response.Value.Headers, example, nil
213226
}
@@ -311,6 +324,66 @@ func load(uri string, data []byte) (swagger *openapi3.Swagger, router *openapi3f
311324
return
312325
}
313326

327+
// parsePreferHeader takes the value of a prefer header and splits it out into key value pairs
328+
//
329+
// HTTP Prefer header specification examples:
330+
// - Prefer: status=200; example="something"
331+
// - Prefer: example=something;status=200;
332+
// - Prefer: example="somet,;hing";status=200;
333+
//
334+
// As part of the Prefer specification, it is completely valid to specify
335+
// multiple Prefer headers in a single request, however we won't be
336+
// supporting that for the moment and only the first Prefer header
337+
// will be used.
338+
func parsePreferHeader(value string) map[string]string {
339+
prefer := map[string]string{}
340+
if value != "" {
341+
// In the event that something is quoted, we want to pull those items out of the string
342+
// and save them for later, so they don't conflict with other splitting logic.
343+
344+
quotedRegex := regexp.MustCompile(`"[^"]*"`)
345+
splitRegex := regexp.MustCompile(`(,|;| )`)
346+
wilcardRegex := regexp.MustCompile(`%%([0-9]+)%%`)
347+
348+
quotedStrings := quotedRegex.FindAllString(value, -1)
349+
if len(quotedStrings) > 0 {
350+
// replace each quoted string with a placehoder
351+
for idx, quotedString := range quotedStrings {
352+
value = strings.Replace(value, quotedString, fmt.Sprintf("%%%%%v%%%%", idx), 1)
353+
}
354+
}
355+
356+
pairs := splitRegex.Split(value, -1)
357+
for _, pair := range pairs {
358+
pair = strings.TrimSpace(pair)
359+
if pair != "" {
360+
// Put any wildcards back
361+
wildcardStrings := wilcardRegex.FindAllStringSubmatch(pair, -1)
362+
for _, wildcard := range wildcardStrings {
363+
quotedIdx, _ := strconv.Atoi(wildcard[1])
364+
pair = strings.Replace(pair, wildcard[0], quotedStrings[quotedIdx], 1)
365+
}
366+
367+
// Determine the key and valid for this argument
368+
if strings.Contains(pair, "=") {
369+
eqIdx := strings.Index(pair, "=")
370+
prefer[pair[:eqIdx]] = strings.Trim(pair[eqIdx+1:], `"`)
371+
} else {
372+
prefer[pair] = ""
373+
}
374+
}
375+
}
376+
}
377+
return prefer
378+
}
379+
380+
func mapContainsKey(dict map[string]string, key string) bool {
381+
if _, ok := dict[key]; ok {
382+
return true
383+
}
384+
return false
385+
}
386+
314387
// server loads an OpenAPI file and runs a mock server using the paths and
315388
// examples defined in the file.
316389
func server(cmd *cobra.Command, args []string) {
@@ -535,12 +608,7 @@ func server(cmd *cobra.Command, args []string) {
535608
}
536609
}
537610

538-
prefer := req.Header.Get("Prefer")
539-
if strings.HasPrefix(prefer, "status=") {
540-
prefer = prefer[7:10]
541-
} else {
542-
prefer = ""
543-
}
611+
prefer := parsePreferHeader(req.Header.Get("Prefer"))
544612

545613
status, mediatype, headers, example, err := getExample(negotiator, prefer, route.Operation)
546614
if err != nil {

apisprout_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"reflect"
45
"testing"
56

67
"github.com/getkin/kin-openapi/openapi3"
@@ -81,3 +82,81 @@ func TestAddLocalServers(t *testing.T) {
8182
})
8283
}
8384
}
85+
86+
func TestParsePreferHeader(t *testing.T) {
87+
tests := []struct {
88+
name string
89+
header string
90+
want map[string]string
91+
}{
92+
{
93+
name: "Single",
94+
header: "status=200",
95+
want: map[string]string{
96+
"status": "200",
97+
},
98+
},
99+
{
100+
name: "Single Quotes",
101+
header: "status=\"200\"",
102+
want: map[string]string{
103+
"status": "200",
104+
},
105+
},
106+
{
107+
name: "Single Quotes Space",
108+
header: "example=\"in progress\"",
109+
want: map[string]string{
110+
"example": "in progress",
111+
},
112+
},
113+
{
114+
name: "Multiple Semicolon",
115+
header: "status=200;example=complete",
116+
want: map[string]string{
117+
"status": "200",
118+
"example": "complete",
119+
},
120+
},
121+
{
122+
name: "Multiple Semi Space",
123+
header: "status=200; example=complete",
124+
want: map[string]string{
125+
"status": "200",
126+
"example": "complete",
127+
},
128+
},
129+
{
130+
name: "Multiple Comma",
131+
header: "status=200,example=complete",
132+
want: map[string]string{
133+
"status": "200",
134+
"example": "complete",
135+
},
136+
},
137+
{
138+
name: "Multiple Comma Space",
139+
header: "status=200, example=complete",
140+
want: map[string]string{
141+
"status": "200",
142+
"example": "complete",
143+
},
144+
},
145+
{
146+
name: "Mixed Pairs",
147+
header: "example=complete; foo, status=\"200\",",
148+
want: map[string]string{
149+
"example": "complete",
150+
"foo": "",
151+
"status": "200",
152+
},
153+
},
154+
}
155+
for _, tt := range tests {
156+
t.Run(tt.name, func(t *testing.T) {
157+
if got := parsePreferHeader(tt.header); !reflect.DeepEqual(got, tt.want) {
158+
t.Errorf("parsePreferHeader() = %v, want %v", got, tt.want)
159+
}
160+
})
161+
}
162+
}

0 commit comments

Comments
 (0)