-
Notifications
You must be signed in to change notification settings - Fork 5
/
server.go
927 lines (762 loc) · 26.2 KB
/
server.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/lestrrat/go-jsval"
"github.com/team-telnyx/telnyx-mock/param"
"github.com/team-telnyx/telnyx-mock/param/coercer"
"github.com/team-telnyx/telnyx-mock/spec"
)
//
// Public types
//
// ExpansionLevel represents expansions on a single "level" of resource. It may
// have subexpansions that are meant to take effect on resources that are
// nested below it (on other levels).
type ExpansionLevel struct {
expansions map[string]*ExpansionLevel
// wildcard specifies that everything should be expanded.
wildcard bool
}
// PathParamsMap holds a collection of parameter that values that have been
// extracted from the path of a request. This is useful to hand off to the data
// generator so that it can use these IDs while generating results.
type PathParamsMap struct {
// PrimaryID contains a value for a primary ID extracted from a request
// path. A "primary" object is the one being enacted on and which will be
// directly returned with the API's response.
//
// Note that not all endpoints have a primary ID, and in those cases this
// value will be nil. Examples of endpoints without a primary ID are
// "create" and "list" methods.
PrimaryID *string
// SecondaryIDs contains a collection of "secondary IDs" (i.e., not the
// primary ID) extracted from the request path.
SecondaryIDs []*PathParamsSecondaryID
// replacedPrimaryID is the old value of an ID field that's had its value
// replaced by PrimaryID. This is used so that we can look for other
// instances of this replaced ID, and also replace them.
//
// For example, if we're handling a charge and replaced an old ID `ch_old`
// with the new value `ch_123` (from PrimaryID), this field would contain
// `ch_old`. If we found another instance of `ch_old` in another field's
// value (say if there was embedded refund with a field called `charge`
// that pointed back to its parent charge ID), we'd recognize it via this
// field and replace it with PrimaryID.
//
// nil if no ID has been replaced.
replacedPrimaryID *string
}
// PathParamsSecondaryID holds the name and value for a "secondary ID" (i.e.,
// one that is not the primary ID) found in a request path.
type PathParamsSecondaryID struct {
// ID is the value of the parameter extracted from the request path.
ID string
// Name is the name of the parameter according to the enclosing `{}` in the
// OpenAPI specification.
//
// For example, it might read `fee` if extracted from:
//
// /v1/application_fees/{fee}/refunds
//
Name string
// replacedIDs is a slice of old values for an ID field that's had its
// value replaced by this secondary parameter's new ID. This is used so
// that we can look for other instances of this
// replaced ID, and also replace them.
//
// This is a slice as opposed to a single value because it's possible that
// we could encounter multiple fields while generating a response that all
// represent the same entity. Say for example that a series of nested
// expansions have been requested, each that internalizes an entity of a
// parameter's type -- we load a fixture for each but there's no guarantee
// that the entity in each one references the same ID.
//
// For more information, see PathParamsMap.replacedPrimaryID.
replacedIDs []string
}
// appendReplacedID appends a replaced ID to the secondary ID's internal slice
// of replaced IDs.
//
// This function skips the case of an empty string value, so its use should be
// preferred over using the internal slice directly.
func (p *PathParamsSecondaryID) appendReplacedID(replacedID string) {
if replacedID != "" {
p.replacedIDs = append(p.replacedIDs, replacedID)
}
}
// ResponseError is a JSON-serializable structure representing an error
// returned from Telnyx's API.
type ResponseError struct {
ErrorInfo struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
// StubServer handles incoming HTTP requests and responds to them appropriately
// based off the set of OpenAPI routes that it's been configured with.
type StubServer struct {
fixtures *spec.Fixtures
routes map[spec.HTTPVerb][]stubServerRoute
spec *spec.Spec
}
// HandleRequest handes an HTTP request directed at the API stub.
func (s *StubServer) HandleRequest(w http.ResponseWriter, r *http.Request) {
start := time.Now()
fmt.Printf("Request: %v %v\n", r.Method, r.URL.Path)
fmt.Printf("Headers: %v\n", r.Header)
q, _ := url.ParseQuery(r.URL.RawQuery)
fmt.Printf("Query: %v\n", q)
fmt.Printf("Body: %v\n", r.Body)
auth := r.Header.Get("Authorization")
if !validateAuth(auth) {
message := fmt.Sprintf(invalidAuthorization, auth)
telnyxError := createTelnyxError(typeInvalidRequestError, message)
writeResponse(w, r, start, http.StatusUnauthorized, telnyxError)
return
}
// Every response needs a X-Request-Id header except the invalid authorization
w.Header().Set("X-Request-Id", "req_123")
// Reflect the Request-Id header
w.Header().Set("Request-Id", r.Header.Get("Request-Id"))
route, pathParams := s.routeRequest(r)
if route == nil {
message := fmt.Sprintf(invalidRoute, r.Method, r.URL.Path)
telnyxError := createTelnyxError(typeInvalidRequestError, message)
writeResponse(w, r, start, http.StatusNotFound, telnyxError)
return
}
var (
response spec.Response
ok bool
)
for _, code := range []spec.StatusCode{"200", "201", "202"} {
response, ok = route.operation.Responses[code]
if ok {
break
}
}
if !ok {
fmt.Printf("Couldn't find 200 response in spec\n")
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
var responseContent spec.MediaType
var schema *spec.Schema
var wrapWithList bool
responseObject, err := response.ResolveRef(s.spec.Components.Responses)
if err != nil {
fmt.Printf("error resolving response ref: %s\n", err)
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
if responseContent, ok = responseObject.Content["text/plain"]; ok {
generator := DataGenerator{}
valueWrapper := generator.prepareSchemaExample(responseContent.Schema)
value := fmt.Sprintf("%v", valueWrapper.value)
if verbose {
fmt.Printf("Response data: %s\n", value)
}
w.Header().Set("Content-type", "text/plain")
writeResponse(w, r, start, http.StatusOK, []byte(value))
return
}
responseContent, ok = responseObject.Content["application/json"]
if !ok {
fmt.Printf("Couldn't find application/json content type in response\n")
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
dataRoot := responseContent.Schema
// It's possible the Response object won't have a literal `data` object
// and will instead use a `$ref` to (eventually) point to the `data`
// object. We're gonna recurse a bit until we find it or give up.
for i := 0; i < 3; i++ {
if _, ok := dataRoot.Properties["data"]; ok {
break
}
if dataRoot.Ref != "" {
dataRoot, _ = dataRoot.ResolveRef(s.spec.Components.Schemas)
} else {
break
}
}
if _, ok := dataRoot.Properties["data"]; !ok {
fmt.Printf("Couldn't find data object in response\n")
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
dataObject, err := dataRoot.Properties["data"].ResolveRef(s.spec.Components.Schemas)
if err != nil {
fmt.Printf("error resolving data object ref: %s\n", err)
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
if dataObject.Items != nil {
schema, err = dataObject.Items.ResolveRef(s.spec.Components.Schemas)
if err != nil {
fmt.Printf("error resolving item object ref: %s\n", err)
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
wrapWithList = true
} else {
schema = dataObject
}
var metaObject *spec.Schema
if meta, ok := responseContent.Schema.Properties["meta"]; ok {
metaObject, err = meta.ResolveRef(s.spec.Components.Schemas)
if err != nil {
fmt.Printf("error resolving meta object ref: %s\n", err)
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
}
if verbose {
fmt.Printf("IDs extracted from route: %+v\n", pathParams)
fmt.Printf("Response schema: %s\n", responseContent.Schema)
}
requestData, err := param.ParseParams(r)
if err != nil {
message := fmt.Sprintf("Couldn't parse query/body: %v", err)
fmt.Printf(message + "\n")
telnyxError := createTelnyxError(typeInvalidRequestError, message)
writeResponse(w, r, start, http.StatusBadRequest, telnyxError)
return
}
if verbose {
if requestData != nil {
fmt.Printf("Request data: %+v\n", requestData)
} else {
fmt.Printf("Request data: (none)\n")
}
}
// Note that requestData is actually manipulated in place, but we show it
// returned here to make it clear that this function will be manipulating
// it.
requestData, telnyxError := validateAndCoerceRequest(r, route, requestData)
if telnyxError != nil {
writeResponse(w, r, start, http.StatusBadRequest, telnyxError)
return
}
expansions, rawExpansions := extractExpansions(requestData)
if verbose {
fmt.Printf("Expansions: %+v\n", rawExpansions)
}
generator := DataGenerator{s.spec.Components.Schemas, s.fixtures}
responseData, err := generator.Generate(schema, metaObject, &GenerateParams{
Expansions: expansions,
PathParams: pathParams,
RequestData: requestData,
RequestMethod: r.Method,
RequestPath: r.URL.Path,
WrapWithList: wrapWithList,
})
if err != nil {
fmt.Printf("Couldn't generate response: %v\n", err)
writeResponse(w, r, start, http.StatusInternalServerError,
createInternalServerError())
return
}
if verbose {
responseDataJSON, err := json.MarshalIndent(responseData, "", " ")
if err != nil {
panic(err)
}
fmt.Printf("Response data: %s\n", responseDataJSON)
}
writeResponse(w, r, start, http.StatusOK, responseData)
}
func (s *StubServer) initializeRouter() error {
var numEndpoints int
var numPaths int
var numValidators int
s.routes = make(map[spec.HTTPVerb][]stubServerRoute)
componentsForValidation := spec.GetComponentsForValidation(&s.spec.Components)
for path, verbs := range s.spec.Paths {
numPaths++
pathPattern, pathParamNames := compilePath(path)
if verbose {
fmt.Printf("Compiled path: %v\n", pathPattern.String())
}
for verb, operation := range verbs {
numEndpoints++
var requestMediaType *string
var requestSchema *spec.Schema
var requestValidator *jsval.JSVal
var hasNestedProperties bool
// For `GET` and `DELETE` requests we build a validator based off a
// pseudo-schema constructed from the endpoint's query parameters.
// For all other verbs we use the body schema.
if verb == "get" || verb == "delete" {
var err error
requestSchema, err = spec.BuildQuerySchema(operation, s.spec.Components.Parameters)
if err != nil {
return err
}
hasNestedProperties = schemaHasNestedProperties(requestSchema)
requestValidator, err = spec.GetValidatorForOpenAPI3Schema(
requestSchema, componentsForValidation)
if err != nil {
return err
}
} else {
requestMediaType, requestSchema = getRequestBodySchema(operation)
if requestSchema != nil {
var err error
requestValidator, err = spec.GetValidatorForOpenAPI3Schema(
requestSchema, componentsForValidation)
if err != nil {
return err
}
}
}
// Note that this may be nil if no suitable validator could be
// generated.
if requestValidator != nil {
numValidators++
}
// We use whether the route ends with a parameter as a heuristic as
// to whether we should expect an object's primary ID in the URL.
var hasPrimaryID bool
for _, suffix := range hasPrimaryIDSuffixes {
if strings.HasSuffix(string(path), suffix) {
hasPrimaryID = true
break
}
}
route := stubServerRoute{
hasPrimaryID: hasPrimaryID,
pattern: pathPattern,
operation: operation,
pathParamNames: pathParamNames,
requestMediaType: requestMediaType,
requestSchema: requestSchema,
requestValidator: requestValidator,
requestSchemaHasNestedProperties: hasNestedProperties,
}
// net/http will always give us verbs in uppercase, so build our
// routing table this way too
verb = spec.HTTPVerb(strings.ToUpper(string(verb)))
s.routes[verb] = append(s.routes[verb], route)
}
}
for _, verbRoutes := range s.routes {
// After sorting all routes, order them by their number of path
// parameters so that paths with static portions will tend to be
// preferred over those with dynamic parts.
//
// For example, `/v1/invoices/upcoming` should be preferred over
// `/v1/invoices/:invoice` even though both will match the string
// `/v1/invoices/upcoming`.
sort.Slice(verbRoutes, func(i, j int) bool {
return len(verbRoutes[i].pathParamNames) < len(verbRoutes[j].pathParamNames)
})
}
fmt.Printf("Routing to %v path(s) and %v endpoint(s) with %v validator(s)\n",
numPaths, numEndpoints, numValidators)
return nil
}
func schemaHasNestedProperties(oaiSchema *spec.Schema) bool {
for _, v := range oaiSchema.Properties {
if len(v.Properties) > 0 {
return true
}
}
return false
}
// routeRequest tries to find a matching route for the given request. If
// successful, it returns the matched route and where possible, an extracted ID
// which comes from the last capture group in the URL. An ID is only returned
// if it looks like it's supposed to be the primary identifier of the returned
// object (i.e., the route's pattern ended with a parameter). A nil is returned
// as the second return value when no primary ID is available.
func (s *StubServer) routeRequest(r *http.Request) (*stubServerRoute, *PathParamsMap) {
verbRoutes := s.routes[spec.HTTPVerb(r.Method)]
splitPath := strings.SplitAfterN(r.URL.Path, "/v2", 2)
for _, route := range verbRoutes {
if len(splitPath) < 2 {
continue
}
matches := route.pattern.FindAllStringSubmatch(splitPath[1], -1)
if len(matches) < 1 {
continue
}
// There are no path parameters. Return the route only.
if len(route.pathParamNames) < 1 {
return &route, nil
}
// There will only ever be a single match in the string (this match
// contains the entire match plus all capture groups).
firstMatch := matches[0]
// Secondary IDs are any IDs in the URL that are *not* the primary ID
// (which you'll see if say a resource is nested under another
// resource).
//
// Normally, we can calculate the number of secondary IDs based on the
// number of path parameters by subtracting one for the primary ID.
// There's a special case if the path doesn't have a primary ID in
// which the number of secondary IDs equals the number of path
// parameters.
var numSecondaryIDs int
if route.hasPrimaryID {
numSecondaryIDs = len(route.pathParamNames) - 1
} else {
numSecondaryIDs = len(route.pathParamNames)
}
var secondaryIDs []*PathParamsSecondaryID
if numSecondaryIDs > 0 {
secondaryIDs = make([]*PathParamsSecondaryID, numSecondaryIDs)
for i := 0; i < numSecondaryIDs; i++ {
secondaryIDs[i] = &PathParamsSecondaryID{
// Note that the first position of `firstMatch` is the
// entire matching string. Capture groups start at position
// 1, so we add one to `i`.
ID: firstMatch[i+1],
Name: route.pathParamNames[i],
}
}
}
// Not all routes have a primary ID even if they might have secondary
// IDs. Consider for example a list endpoint nested under another
// resource:
//
// GET "/v1/application_fees/fee_123/refunds
//
var primaryID *string
if route.hasPrimaryID {
primaryID = &firstMatch[len(firstMatch)-1]
}
// Return the route along with any IDs that matched in the path.
return &route, &PathParamsMap{
PrimaryID: primaryID,
SecondaryIDs: secondaryIDs,
}
}
return nil, nil
}
//
// Private values
//
const (
contentTypeEmpty = "Request's `Content-Type` header was empty. Expected: `%s`."
contentTypeMismatched = "Request's `Content-Type` didn't match the path's expected media type. Expected: `%s`. Was: `%s`."
invalidAuthorization = "Please authenticate by specifying an " +
"`Authorization` header with any valid looking testmode secret API " +
"key. For example, `Authorization: Bearer KEYSUPERSECRET`. " +
"Authorization was '%s'."
invalidRoute = "Unrecognized request URL (%s: %s)."
internalServerError = "An internal error occurred."
typeInvalidRequestError = "invalid_request_error"
)
// Suffixes for which we will try to exact an object's ID from the path.
var hasPrimaryIDSuffixes = [...]string{
// The general case: we're looking for the end of an OpenAPI URL parameter.
"}",
// These are resource "actions". They don't take the standard form, but we
// can expect an object's primary ID to live right before them in a path.
"/approve",
"/capture",
"/cancel",
"/close",
"/decline",
"/finalize",
"/mark_uncollectible",
"/pay",
"/refund",
"/reject",
"/send",
"/verify",
"/void",
}
var pathParameterPattern = regexp.MustCompile(`\{(\w+)\}`)
//
// Private types
//
// stubServerRoute is a single route in a StubServer's routing table. It has a
// pattern to match an incoming path and a description of the method that would
// be executed in the event of a match.
type stubServerRoute struct {
hasPrimaryID bool
operation *spec.Operation
pathParamNames []string
pattern *regexp.Regexp
requestMediaType *string
requestSchema *spec.Schema
requestValidator *jsval.JSVal
requestSchemaHasNestedProperties bool
}
//
// Private functions
//
// compilePath compiles a path extracted from OpenAPI into a regular expression
// that we can use for matching against incoming HTTP requests.
//
// The first return value is a regular expression. The second is a slice of
// names for the parameters included in the path in order of their appearance.
// This slice is `nil` if the path had no parameters.
func compilePath(path spec.Path) (*regexp.Regexp, []string) {
var pathParamNames []string
parts := strings.Split(string(path), "/")
pattern := `\A`
for _, part := range parts {
if part == "" {
continue
}
submatches := pathParameterPattern.FindAllStringSubmatch(part, -1)
if submatches == nil {
pattern += `/` + part
} else {
pattern += `/(?P<` + submatches[0][1] + `>[^\.\/\?]+)`
pathParamNames = append(pathParamNames, submatches[0][1])
}
}
return regexp.MustCompile(pattern + `\z`), pathParamNames
}
// Helper to create an internal server error for API issues.
func createInternalServerError() *ResponseError {
return createTelnyxError(typeInvalidRequestError, internalServerError)
}
// This creates a Telnyx error to return in case of API errors.
func createTelnyxError(errorType string, errorMessage string) *ResponseError {
return &ResponseError{
ErrorInfo: struct {
Message string `json:"message"`
Type string `json:"type"`
}{
Message: errorMessage,
Type: errorType,
},
}
}
func extractExpansions(data map[string]interface{}) (*ExpansionLevel, []string) {
expand, ok := data["expand"]
if !ok {
return nil, nil
}
var expansions []string
expandStr, ok := expand.(string)
if ok {
expansions = append(expansions, expandStr)
return parseExpansionLevel(expansions), expansions
}
expandArr, ok := expand.([]interface{})
if ok {
for _, expand := range expandArr {
expandStr := expand.(string)
expansions = append(expansions, expandStr)
}
return parseExpansionLevel(expansions), expansions
}
return nil, nil
}
// getRequestBodySchema gets the media type and expected request schema for the
// given operation. We don't expect any endpoint in the Telnyx API to have
// multiple supported media types, so the operation's first media type and
// request schema is always the one that's returned.
//
// The first value is a media type like "application/x-www-form-urlencoded", or
// nil if the operation has no request schemas.
func getRequestBodySchema(operation *spec.Operation) (*string, *spec.Schema) {
if operation.RequestBody == nil {
return nil, nil
}
for mediaType, spec := range operation.RequestBody.Content {
return &mediaType, spec.Schema
}
return nil, nil
}
func isCurl(userAgent string) bool {
return strings.HasPrefix(userAgent, "curl/")
}
// parseExpansionLevel parses a set of raw expansions from a request query
// string or form and produces a structure more useful for performing actual
// expansions.
func parseExpansionLevel(raw []string) *ExpansionLevel {
sort.Strings(raw)
level := &ExpansionLevel{expansions: make(map[string]*ExpansionLevel)}
groups := make(map[string][]string)
for _, expansion := range raw {
parts := strings.Split(expansion, ".")
if len(parts) == 1 {
if parts[0] == "*" {
level.wildcard = true
} else {
level.expansions[parts[0]] =
&ExpansionLevel{expansions: make(map[string]*ExpansionLevel)}
}
} else {
groups[parts[0]] = append(groups[parts[0]], strings.Join(parts[1:], "."))
}
}
for key, subexpansions := range groups {
level.expansions[key] = parseExpansionLevel(subexpansions)
}
return level
}
// validateAndCoerceRequest validates an incoming request against an OpenAPI
// schema and does parameter coercion.
//
// Firstly, `Content-Type` is checked against the schema's media type, then
// string-encoded parameters are coerced to expected types (where possible).
// Finally, we validate the incoming payload against the schema.
func validateAndCoerceRequest(
r *http.Request,
route *stubServerRoute,
requestData map[string]interface{}) (map[string]interface{}, *ResponseError) {
// We only check content type on non-`GET` non-`DELETE` requests.
//
// `GET` requests either send no parameters or send parameters only in the
// query.
//
// `DELETE` will often have no parameters. When it does, they're in the
// body, but we'll ignore content type validation in this one case for
// simplicity.
if r.Method != http.MethodDelete && r.Method != http.MethodGet && route.requestSchema != nil {
contentType := r.Header.Get("Content-Type")
if contentType == "" {
message := fmt.Sprintf(contentTypeEmpty, *route.requestMediaType)
fmt.Printf(message + "\n")
return nil, createTelnyxError(typeInvalidRequestError, message)
}
// Truncate content type parameters. For example, given:
//
// application/json; charset=utf-8
//
// We want to chop off the `; charset=utf-8` at the end.
contentType = strings.Split(contentType, ";")[0]
if contentType != *route.requestMediaType {
message := fmt.Sprintf(contentTypeMismatched, *route.requestMediaType, contentType)
fmt.Printf(message + "\n")
return nil, createTelnyxError(typeInvalidRequestError, message)
}
}
fmt.Printf("Request data: %v\n", requestData)
var paramsForValidation map[string]interface{}
if (r.Method == http.MethodGet || r.Method == http.MethodDelete) && !route.requestSchemaHasNestedProperties {
paramsForValidation = flattenParams(requestData)
} else {
paramsForValidation = requestData
}
if route.requestSchema != nil {
if err := coercer.CoerceParams(route.requestSchema, paramsForValidation); err != nil {
message := fmt.Sprintf("Request coercion error: %v", err)
fmt.Printf(message + "\n")
return nil, createTelnyxError(typeInvalidRequestError, message)
}
if err := route.requestValidator.Validate(paramsForValidation); err != nil {
message := fmt.Sprintf("Request validation error: %v", err)
fmt.Printf(message + "\n")
return nil, createTelnyxError(typeInvalidRequestError, message)
}
}
// All checks were successful.
return requestData, nil
}
func flattenParams(params map[string]interface{}) map[string]interface{} {
var flatten func(params map[string]interface{}, depth int) map[string]interface{}
flatten = func(params map[string]interface{}, depth int) map[string]interface{} {
var newKey string
r := make(map[string]interface{})
for k, v := range params {
switch child := v.(type) {
case map[string]interface{}:
depth = depth + 1
nm := flatten(child, depth)
for nk, nv := range nm {
if depth == 1 {
newKey = k + "[" + nk
} else {
newKey = k + "][" + nk
}
r[newKey] = nv
}
default:
if depth >= 1 {
newKey = k + "]"
} else {
newKey = k
}
r[newKey] = v
}
}
return r
}
return flatten(params, 0)
}
func validateAuth(auth string) bool {
if auth == "" {
return false
}
parts := strings.Split(auth, " ")
// Expect ["Bearer", "KEYSUPERSECRET"]
if len(parts) != 2 || parts[1] == "" {
return false
}
var key string
switch parts[0] {
case "Bearer":
key = parts[1]
default:
return false
}
keyParts := strings.Split(key, "KEY")
// Expect ["", "arbitrary-string"]
if len(keyParts) != 2 {
return false
}
// Expect something (anything but an empty string) in the first position
if len(keyParts[1]) == 0 {
return false
}
return true
}
func writeResponse(w http.ResponseWriter, r *http.Request, start time.Time, status int, data interface{}) {
if data == nil {
data = http.StatusText(status)
}
var encodedData []byte
var err error
if v := w.Header().Get("Content-Type"); v == "" {
w.Header().Set("Content-Type", "application/json")
if !isCurl(r.Header.Get("User-Agent")) {
encodedData, err = json.Marshal(&data)
} else {
encodedData, err = json.MarshalIndent(&data, "", " ")
encodedData = append(encodedData, '\n')
}
} else {
var ok bool
encodedData, ok = data.([]byte)
if !ok {
// Reset this as our API errors are json
w.Header().Set("Content-type", "application/json")
err = fmt.Errorf("Error decoding data to []byte: %v", data)
}
}
if err != nil {
fmt.Printf("Error serializing response: %v\n", err)
writeResponse(w, r, start, http.StatusInternalServerError, nil)
return
}
w.Header().Set("Telnyx-Mock-Version", version)
w.WriteHeader(status)
_, err = w.Write(encodedData)
if err != nil {
fmt.Printf("Error writing to client: %v\n", err)
}
fmt.Printf("Response: elapsed=%v status=%v\n", time.Now().Sub(start), status)
}