Skip to content

Commit 5044ecf

Browse files
Merge pull request #10 from speakeasy-api/unmatched-requests
feat: support apiID and versionID and normalizing path hints
2 parents 8a80d77 + 7c8bd38 commit 5044ecf

25 files changed

+605
-125
lines changed

.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ issues:
4141
- noctx
4242
- nosnakecase
4343
- maintidx
44+
- gosec
4445
- path: _exports_test\.go
4546
linters:
4647
- testpackage

README.md

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ The Speakeasy Go SDK for evaluating API requests/responses. Compatible with any
88

99
## Requirements
1010

11-
Supported frameworks:
11+
Supported routers:
1212

1313
* gorilla/mux
1414
* go-chi/chi
1515
* http.DefaultServerMux
1616

17-
We also support custom Http frameworks:
17+
We also support custom HTTP frameworks:
1818

1919
* gin-gonic/gin
2020
* labstack/echo
@@ -27,20 +27,26 @@ We also support custom Http frameworks:
2727
go get github.com/speakeasy-api/speakeasy-go-sdk
2828
```
2929

30-
## Minimum configuration
30+
### Minimum configuration
3131

3232
[Sign up for free on our platform](https://www.speakeasyapi.dev/). After you've created a workspace and generated an API key enable Speakeasy in your API as follows:
3333

34-
Configure Speakeasy at the start of your `main()` function with just 2 lines of code:
34+
Configure Speakeasy at the start of your `main()` function:
3535

3636
```go
3737
import "github.com/speakeasy-api/speakeasy-go-sdk"
3838

3939
func main() {
40-
speakeasy.Configure(speakeasy.Configuration {
41-
APIKey: "YOUR API KEY HERE", // retrieve from Speakeasy API dashboard
40+
// Configure the Global SDK
41+
speakeasy.Configure(speakeasy.Config {
42+
APIKey: "YOUR API KEY HERE", // retrieve from Speakeasy API dashboard.
43+
ApiID: "YOUR API ID HERE", // custom Api ID to associate captured requests with.
44+
VersionID: "YOUR VERSION ID HERE", // custom Version ID to associate captured requests with.
4245
})
43-
// rest of your program.
46+
47+
// Associate the SDK's middleware with your router
48+
r := mux.NewRouter()
49+
r.Use(speakeasy.Middleware)
4450
}
4551
```
4652

@@ -49,7 +55,70 @@ and will be visible on the dashboard next time you log in. Visit our [docs site]
4955
learn more.
5056

5157

52-
## Optional Arguments
58+
### Advanced configuration
59+
60+
The Speakeasy SDK provides both a global and per Api configuration option. If you want to use the SDK to track multiple Apis or Versions from the same service you can configure individual instances of the SDK, like so:
61+
62+
```go
63+
import "github.com/speakeasy-api/speakeasy-go-sdk"
64+
65+
func main() {
66+
// Configure a new instance of the SDK
67+
sdkInstance := speakeasy.New(speakeasy.Config {
68+
APIKey: "YOUR API KEY HERE", // retrieve from Speakeasy API dashboard.
69+
ApiID: "YOUR API ID HERE", // custom Api ID to associate captured requests with.
70+
VersionID: "YOUR VERSION ID HERE", // custom Version ID to associate captured requests with.
71+
})
72+
73+
// Associate the SDK's middleware with your router
74+
r := mux.NewRouter()
75+
r.Use(sdkInstance.Middleware)
76+
}
77+
```
78+
79+
This allows multiple instances of the SDK to be associated with different routers or routes within your service.
80+
81+
## Request Matching
82+
83+
The Speakeasy SDK out of the box will do its best to match requests to your provided OpenAPI Schema. It does this by extracting the path template used by one of the supported routers or frameworks above for each request captured and attempting to match it to the paths defined in the OpenAPI Schema, for example:
84+
85+
```go
86+
r := mux.NewRouter()
87+
r.Use(sdkInstance.Middleware)
88+
r.HandleFunc("/v1/users/{id}", MyHandler) // The path template "/v1/users/{id}" is captured automatically by the SDK
89+
```
90+
91+
This isn't always successful or even possible, meaning requests received by Speakeasy will be marked as `unmatched`, and potentially not associated with your Api, Version or ApiEndpoints in the Speakeasy Dashboard.
92+
93+
To help the SDK in these situations you can provide path hints per request handler that match the paths in your OpenAPI Schema:
5394

54-
Coming soon !
95+
```go
96+
func MyHandler(w http.ResponseWriter, r *http.Request) {
97+
// Provide a path hint for the request using the OpenAPI Path Templating format: https://swagger.io/specification/#path-templating-matching
98+
ctrl := speakeasy.MiddlewareController(req)
99+
ctrl.PathHint("/v1/users/{id}")
100+
101+
// the rest of your handlers code
102+
}
55103
```
104+
105+
Notes:
106+
Wildcard path matching in Echo & Chi will end up with a OpenAPI path paramater called {wildcard} which will only match single level values represented by the wildcard. This is a restriction of the OpenAPI spec ([Detail Here](https://github.com/OAI/OpenAPI-Specification/issues/892#issuecomment-281449239)). For example:
107+
108+
`chi template: /user/{id}/path/* => openapi template: /user/{id}/path/{wildcard}`
109+
110+
And in the above example a path like `/user/1/path/some/sub/path` won't match but `/user/1/path/somesubpathstring` will, as `/` characters are not matched in path paramters by the OpenAPI spec.
111+
112+
113+
114+
## Capturing Customer IDs
115+
116+
To help associate requests with customers/users of your APIs you can provide a customer ID per request handler:
117+
118+
```go
119+
func MyHandler(w http.ResponseWriter, r *http.Request) {
120+
ctrl := speakeasy.MiddlewareController(req)
121+
ctrl.CustomerID("a-customers-id") // This customer ID will be used to associate this instance of a request with your customers/users
122+
123+
// the rest of your handlers code
124+
}

capture.go

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/chromedp/cdproto/har"
1515
"github.com/speakeasy-api/speakeasy-go-sdk/internal/log"
16+
"github.com/speakeasy-api/speakeasy-go-sdk/internal/pathhints"
1617
"github.com/speakeasy-api/speakeasy-schemas/grpc/go/registry/ingest"
1718
"go.uber.org/zap"
1819
"google.golang.org/grpc"
@@ -21,7 +22,7 @@ import (
2122
"google.golang.org/grpc/metadata"
2223
)
2324

24-
var maxCaptureSize = 9 * 1024 * 1024
25+
var maxCaptureSize = 1 * 1024 * 1024
2526

2627
var timeNow = func() time.Time {
2728
return time.Now()
@@ -33,7 +34,7 @@ var timeSince = func(t time.Time) time.Duration {
3334

3435
type handlerFunc func(http.ResponseWriter, *http.Request) error
3536

36-
func (s *speakeasy) handleRequestResponse(w http.ResponseWriter, r *http.Request, next http.HandlerFunc, capturePathHint func(r *http.Request) string) {
37+
func (s *Speakeasy) handleRequestResponse(w http.ResponseWriter, r *http.Request, next http.HandlerFunc, capturePathHint func(r *http.Request) string) {
3738
err := s.handleRequestResponseError(w, r, func(w http.ResponseWriter, r *http.Request) error {
3839
next.ServeHTTP(w, r)
3940
return nil
@@ -43,7 +44,7 @@ func (s *speakeasy) handleRequestResponse(w http.ResponseWriter, r *http.Request
4344
}
4445
}
4546

46-
func (s *speakeasy) handleRequestResponseError(w http.ResponseWriter, r *http.Request, next handlerFunc, capturePathHint func(r *http.Request) string) error {
47+
func (s *Speakeasy) handleRequestResponseError(w http.ResponseWriter, r *http.Request, next handlerFunc, capturePathHint func(r *http.Request) string) error {
4748
startTime := timeNow()
4849

4950
cw := NewCaptureWriter(w, maxCaptureSize)
@@ -62,32 +63,32 @@ func (s *speakeasy) handleRequestResponseError(w http.ResponseWriter, r *http.Re
6263
err := next(cw.GetResponseWriter(), r)
6364

6465
pathHint := capturePathHint(r)
66+
pathHint = pathhints.NormalizePathHint(pathHint)
6567

6668
// if developer has provided a path hint use it, otherwise use the pathHint from the request
6769
if c.pathHint != "" {
6870
pathHint = c.pathHint
6971
}
7072

7173
// Assuming response is done
72-
go s.captureRequestResponse(cw, r, startTime, pathHint)
74+
go s.captureRequestResponse(cw, r, startTime, pathHint, c.customerID)
7375

7476
return err
7577
}
7678

77-
func (s *speakeasy) captureRequestResponse(cw *captureWriter, r *http.Request, startTime time.Time, pathHint string) {
79+
func (s *Speakeasy) captureRequestResponse(cw *captureWriter, r *http.Request, startTime time.Time, pathHint, customerID string) {
7880
var ctx context.Context = valueOnlyContext{r.Context()}
7981

8082
if cw.IsReqValid() && cw.GetReqBuffer().Len() == 0 && r.Body != nil {
8183
// Read the body just in case it was not read in the handler
82-
if _, err := io.Copy(ioutil.Discard, r.Body); err != nil {
83-
log.From(ctx).Error("speakeasy-sdk: failed to read request body", zap.Error(err))
84-
}
84+
//nolint: errcheck
85+
io.Copy(ioutil.Discard, r.Body)
8586
}
8687

8788
opts := []grpc.DialOption{}
8889

8990
if s.secure {
90-
// nolint: gosec
91+
//nolint: gosec
9192
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: true})))
9293
} else {
9394
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
@@ -113,16 +114,19 @@ func (s *speakeasy) captureRequestResponse(cw *captureWriter, r *http.Request, s
113114
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("x-api-key", s.config.APIKey))
114115

115116
_, err = ingest.NewIngestServiceClient(conn).Ingest(ctx, &ingest.IngestRequest{
116-
Har: string(harData),
117-
PathHint: pathHint,
117+
Har: string(harData),
118+
PathHint: pathHint,
119+
ApiId: s.config.ApiID,
120+
VersionId: s.config.VersionID,
121+
CustomerId: customerID,
118122
})
119123
if err != nil {
120124
log.From(ctx).Error("speakeasy-sdk: failed to send ingest request", zap.Error(err))
121125
return
122126
}
123127
}
124128

125-
func (s *speakeasy) buildHarFile(ctx context.Context, cw *captureWriter, r *http.Request, startTime time.Time) *har.HAR {
129+
func (s *Speakeasy) buildHarFile(ctx context.Context, cw *captureWriter, r *http.Request, startTime time.Time) *har.HAR {
126130
return &har.HAR{
127131
Log: &har.Log{
128132
Version: "1.2",
@@ -145,8 +149,8 @@ func (s *speakeasy) buildHarFile(ctx context.Context, cw *captureWriter, r *http
145149
}
146150
}
147151

148-
// nolint:cyclop,funlen
149-
func (s *speakeasy) getHarRequest(ctx context.Context, cw *captureWriter, r *http.Request) *har.Request {
152+
//nolint:cyclop,funlen
153+
func (s *Speakeasy) getHarRequest(ctx context.Context, cw *captureWriter, r *http.Request) *har.Request {
150154
reqHeaders := []*har.NameValuePair{}
151155
for k, v := range r.Header {
152156
for _, vv := range v {
@@ -214,7 +218,7 @@ func (s *speakeasy) getHarRequest(ctx context.Context, cw *captureWriter, r *htt
214218
}
215219
}
216220

217-
func (s *speakeasy) getHarResponse(ctx context.Context, cw *captureWriter, r *http.Request, startTime time.Time) *har.Response {
221+
func (s *Speakeasy) getHarResponse(ctx context.Context, cw *captureWriter, r *http.Request, startTime time.Time) *har.Response {
218222
resHeaders := []*har.NameValuePair{}
219223

220224
cookieParser := http.Response{Header: http.Header{}}
@@ -286,9 +290,9 @@ func getHarCookies(cookies []*http.Cookie, startTime time.Time) []*har.Cookie {
286290
}
287291

288292
if cookie.MaxAge != 0 {
289-
harCookie.Expires = startTime.Add(time.Duration(cookie.MaxAge) * time.Second).Format(time.RFC3339Nano)
293+
harCookie.Expires = startTime.Add(time.Duration(cookie.MaxAge) * time.Second).Format(time.RFC3339)
290294
} else if (cookie.Expires != time.Time{}) {
291-
harCookie.Expires = cookie.Expires.Format(time.RFC3339Nano)
295+
harCookie.Expires = cookie.Expires.Format(time.RFC3339)
292296
}
293297

294298
harCookies = append(harCookies, harCookie)
@@ -298,10 +302,11 @@ func getHarCookies(cookies []*http.Cookie, startTime time.Time) []*har.Cookie {
298302
}
299303

300304
// This allows us to not be affected by context cancellation of the request that spawned our request capture while still retaining any context values.
301-
// nolint:containedctx
305+
//
306+
//nolint:containedctx
302307
type valueOnlyContext struct{ context.Context }
303308

304-
// nolint
309+
//nolint:nonamedreturns
305310
func (valueOnlyContext) Deadline() (deadline time.Time, ok bool) { return }
306311
func (valueOnlyContext) Done() <-chan struct{} { return nil }
307312
func (valueOnlyContext) Err() error { return nil }

controller.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ const (
1212
)
1313

1414
type controller struct {
15-
pathHint string
15+
pathHint string
16+
customerID string
1617
}
1718

1819
// MiddlewareController will return the speakeasy middleware controller from the current request,
@@ -27,6 +28,11 @@ func (c *controller) PathHint(pathHint string) {
2728
c.pathHint = pathHint
2829
}
2930

31+
// CustomerID will allow you to associate a customer ID with the current request.
32+
func (c *controller) CustomerID(customerID string) {
33+
c.customerID = customerID
34+
}
35+
3036
func contextWithController(ctx context.Context) (context.Context, *controller) {
3137
c := &controller{}
3238
return context.WithValue(ctx, controllerKey, c), c

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ require (
88
)
99

1010
require (
11+
github.com/AlekSi/pointer v1.2.0
1112
github.com/gin-gonic/gin v1.8.1
1213
github.com/go-chi/chi/v5 v5.0.7
1314
github.com/gorilla/mux v1.8.0
1415
github.com/labstack/echo/v4 v4.7.2
15-
github.com/speakeasy-api/speakeasy-schemas v0.0.1
16+
github.com/speakeasy-api/speakeasy-schemas v1.1.1
1617
go.uber.org/zap v1.21.0
1718
google.golang.org/grpc v1.48.0
1819
)

go.sum

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
22
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
3+
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
4+
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
35
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
46
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
57
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
@@ -113,8 +115,8 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L
113115
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
114116
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
115117
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
116-
github.com/speakeasy-api/speakeasy-schemas v0.0.1 h1:CH20+3TLmP9Y+8YxmYoLfA5cbyVzpw15w+SefvfKHqw=
117-
github.com/speakeasy-api/speakeasy-schemas v0.0.1/go.mod h1:g0yz/bfH6EDcBmACdbqM7BQtTzUrBfhJoIgDb6AsKRQ=
118+
github.com/speakeasy-api/speakeasy-schemas v1.1.1 h1:ZYHmVx/qmdohZCWyxBF/8fx1RhklHhz4CGH0kJQrvyQ=
119+
github.com/speakeasy-api/speakeasy-schemas v1.1.1/go.mod h1:g6NfrOjYLCJZp81ZLY2ksF7afC9BW9bL4Bg1V1oAFlw=
118120
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
119121
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
120122
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -239,8 +241,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
239241
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
240242
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
241243
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
242-
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
243244
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
245+
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
246+
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
244247
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
245248
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
246249
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

internal/pathhints/pathhint.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package pathhints
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
var varMatcher = regexp.MustCompile(`({(.*?:*.*?)}|:(.+?)\/|:(.*)|\*(.+)|\*)`)
10+
11+
// NormalizePathHint will take a path hint from the various support routers/frameworks and normalize it to the OpenAPI spec.
12+
func NormalizePathHint(pathHint string) string {
13+
matched := false
14+
out := replaceAllStringSubmatchFunc(varMatcher, pathHint, func(matches []string) string {
15+
matched = true
16+
17+
var varMatch string
18+
switch {
19+
case matches[0] == "*":
20+
varMatch = "wildcard"
21+
case matches[2] != "":
22+
varMatch = strings.Split(matches[2], ":")[0]
23+
case matches[3] != "":
24+
return fmt.Sprintf("{%s}/", matches[3])
25+
case matches[4] != "":
26+
varMatch = matches[4]
27+
default:
28+
varMatch = matches[5]
29+
}
30+
31+
return fmt.Sprintf("{%s}", varMatch)
32+
})
33+
if !matched {
34+
return pathHint
35+
}
36+
37+
return out
38+
}
39+
40+
func replaceAllStringSubmatchFunc(re *regexp.Regexp, str string, repl func([]string) string) string {
41+
result := ""
42+
lastIndex := 0
43+
44+
for _, v := range re.FindAllSubmatchIndex([]byte(str), -1) {
45+
groups := []string{}
46+
for i := 0; i < len(v); i += 2 {
47+
if v[i] == -1 || v[i+1] == -1 {
48+
groups = append(groups, "")
49+
} else {
50+
groups = append(groups, str[v[i]:v[i+1]])
51+
}
52+
}
53+
54+
result += str[lastIndex:v[0]] + repl(groups)
55+
lastIndex = v[1]
56+
}
57+
58+
return result + str[lastIndex:]
59+
}

0 commit comments

Comments
 (0)