Skip to content

Commit d33fb77

Browse files
Merge pull request #5 from speakeasy-api/support-other-routers-and-path-hints
feat: added support for alternate middleware, the ability to provide path hints and request limits
2 parents 58f007f + f769fe9 commit d33fb77

32 files changed

+2084
-369
lines changed

.golangci.yaml

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,34 +12,53 @@ linters:
1212
- gochecknoglobals
1313
- paralleltest
1414
- dupl
15-
- containedctx
16-
# Deprecated
1715
- golint
1816
- maligned
17+
# deprecated/archived
1918
- interfacer
2019
- scopelint
2120
issues:
21+
include:
22+
- EXC0002
23+
- EXC0011
24+
- EXC0012
25+
- EXC0013
26+
- EXC0014
27+
- EXC0015
2228
exclude:
2329
- "returns unexported type"
2430
- "unlambda"
25-
- "should rewrite http.NewRequestWithContext"
2631
exclude-rules:
2732
# Exclude some linters from running on tests files.
2833
- path: _test\.go
2934
linters:
30-
- scopelint
3135
- goerr113
3236
- funlen
37+
- godot
38+
- dupl
3339
- gocognit
3440
- cyclop
41+
- noctx
3542
- nosnakecase
43+
- maintidx
3644
- path: _exports_test\.go
3745
linters:
3846
- testpackage
39-
include:
40-
- EXC0012
41-
- EXC0013
42-
- EXC0014
43-
- EXC0015
47+
- path: cmd/*
48+
linters:
49+
- funlen
50+
linters-settings:
51+
tagliatelle:
52+
case:
53+
use-field-name: true
54+
rules:
55+
json: snake
56+
yaml: camel
57+
revive:
58+
rules:
59+
- name: var-naming
60+
arguments: [["API"], []]
61+
stylecheck:
62+
checks: ["all", "-ST1000", "-ST1003"]
4463
run:
4564
go: "1.14"

capture.go

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
package speakeasy
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/tls"
7+
"encoding/json"
8+
"io"
9+
"io/ioutil"
10+
"net/http"
11+
"net/http/httptest"
12+
"time"
13+
14+
"github.com/chromedp/cdproto/har"
15+
"github.com/speakeasy-api/speakeasy-go-sdk/internal/log"
16+
"github.com/speakeasy-api/speakeasy-schemas/grpc/go/registry/ingest"
17+
"go.uber.org/zap"
18+
"google.golang.org/grpc"
19+
"google.golang.org/grpc/credentials"
20+
"google.golang.org/grpc/credentials/insecure"
21+
"google.golang.org/grpc/metadata"
22+
)
23+
24+
var maxCaptureSize = 9 * 1024 * 1024
25+
26+
var timeNow = func() time.Time {
27+
return time.Now()
28+
}
29+
30+
var timeSince = func(t time.Time) time.Duration {
31+
return time.Since(t)
32+
}
33+
34+
type handlerFunc func(http.ResponseWriter, *http.Request) error
35+
36+
func (s *speakeasy) handleRequestResponse(w http.ResponseWriter, r *http.Request, next http.HandlerFunc, capturePathHint func(r *http.Request) string) {
37+
err := s.handleRequestResponseError(w, r, func(w http.ResponseWriter, r *http.Request) error {
38+
next.ServeHTTP(w, r)
39+
return nil
40+
}, capturePathHint)
41+
if err != nil {
42+
log.Logger().Error("speakeasy-sdk: unexpected error from non-error handlerFunc", zap.Error(err))
43+
}
44+
}
45+
46+
func (s *speakeasy) handleRequestResponseError(w http.ResponseWriter, r *http.Request, next handlerFunc, capturePathHint func(r *http.Request) string) error {
47+
startTime := timeNow()
48+
49+
cw := NewCaptureWriter(w, maxCaptureSize)
50+
51+
if r.Body != nil {
52+
// We need to duplicate the request body, because it should be consumed by the next handler first before we can read it
53+
// (as io.Reader is a stream and can only be read once) but we are potentially storing a large request (such as a file upload)
54+
// in memory, so we may need to allow the middleware to be configured to not read the body or have a max size
55+
tee := io.TeeReader(r.Body, cw.GetRequestWriter())
56+
r.Body = ioutil.NopCloser(tee)
57+
}
58+
59+
ctx, c := contextWithController(r.Context())
60+
r = r.WithContext(ctx)
61+
62+
err := next(cw.GetResponseWriter(), r)
63+
64+
pathHint := capturePathHint(r)
65+
66+
// if developer has provided a path hint use it, otherwise use the pathHint from the request
67+
if c.pathHint != "" {
68+
pathHint = c.pathHint
69+
}
70+
71+
// Assuming response is done
72+
go s.captureRequestResponse(cw, r, startTime, pathHint)
73+
74+
return err
75+
}
76+
77+
func (s *speakeasy) captureRequestResponse(cw *captureWriter, r *http.Request, startTime time.Time, pathHint string) {
78+
var ctx context.Context = valueOnlyContext{r.Context()}
79+
80+
if cw.IsReqValid() && cw.GetReqBuffer().Len() == 0 && r.Body != nil {
81+
// 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+
}
85+
}
86+
87+
opts := []grpc.DialOption{}
88+
89+
if s.secure {
90+
// nolint: gosec
91+
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: true})))
92+
} else {
93+
opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
94+
}
95+
96+
if s.config.GRPCDialer != nil {
97+
opts = append(opts, grpc.WithContextDialer(s.config.GRPCDialer()))
98+
}
99+
100+
conn, err := grpc.DialContext(ctx, s.serverURL, opts...)
101+
if err != nil {
102+
log.From(ctx).Error("speakeasy-sdk: failed to create grpc connection", zap.Error(err))
103+
return
104+
}
105+
defer conn.Close()
106+
107+
harData, err := json.Marshal(s.buildHarFile(ctx, cw, r, startTime))
108+
if err != nil {
109+
log.From(ctx).Error("speakeasy-sdk: failed to ingest request body", zap.Error(err))
110+
return
111+
}
112+
113+
ctx = metadata.NewOutgoingContext(ctx, metadata.Pairs("x-api-key", s.config.APIKey))
114+
115+
_, err = ingest.NewIngestServiceClient(conn).Ingest(ctx, &ingest.IngestRequest{
116+
Har: string(harData),
117+
PathHint: pathHint,
118+
})
119+
if err != nil {
120+
log.From(ctx).Error("speakeasy-sdk: failed to send ingest request", zap.Error(err))
121+
return
122+
}
123+
}
124+
125+
func (s *speakeasy) buildHarFile(ctx context.Context, cw *captureWriter, r *http.Request, startTime time.Time) *har.HAR {
126+
return &har.HAR{
127+
Log: &har.Log{
128+
Version: "1.2",
129+
Creator: &har.Creator{
130+
Name: sdkName,
131+
Version: speakeasyVersion,
132+
},
133+
Comment: "request capture for " + r.URL.String(),
134+
Entries: []*har.Entry{
135+
{
136+
StartedDateTime: startTime.Format(time.RFC3339),
137+
Time: float64(timeSince(startTime).Milliseconds()),
138+
Request: s.getHarRequest(ctx, cw, r),
139+
Response: s.getHarResponse(ctx, cw, r, startTime),
140+
Connection: r.URL.Port(),
141+
ServerIPAddress: r.URL.Hostname(),
142+
},
143+
},
144+
},
145+
}
146+
}
147+
148+
// nolint:cyclop,funlen
149+
func (s *speakeasy) getHarRequest(ctx context.Context, cw *captureWriter, r *http.Request) *har.Request {
150+
reqHeaders := []*har.NameValuePair{}
151+
for k, v := range r.Header {
152+
for _, vv := range v {
153+
reqHeaders = append(reqHeaders, &har.NameValuePair{Name: k, Value: vv})
154+
}
155+
}
156+
157+
reqQueryParams := []*har.NameValuePair{}
158+
159+
for k, v := range r.URL.Query() {
160+
for _, vv := range v {
161+
reqQueryParams = append(reqQueryParams, &har.NameValuePair{Name: k, Value: vv})
162+
}
163+
}
164+
165+
reqCookies := getHarCookies(r.Cookies(), time.Time{})
166+
bodyText := "--dropped--"
167+
if cw.IsReqValid() {
168+
bodyText = cw.GetReqBuffer().String()
169+
}
170+
171+
hw := httptest.NewRecorder()
172+
173+
for k, vv := range r.Header {
174+
for _, v := range vv {
175+
hw.Header().Set(k, v)
176+
}
177+
}
178+
179+
b := bytes.NewBuffer([]byte{})
180+
headerSize := -1
181+
if err := hw.Header().Write(b); err != nil {
182+
log.From(ctx).Error("speakeasy-sdk: failed to read length of request headers", zap.Error(err))
183+
} else {
184+
headerSize = b.Len()
185+
}
186+
187+
var postData *har.PostData
188+
if len(bodyText) > 0 {
189+
reqContentType := r.Header.Get("Content-Type")
190+
if reqContentType == "" {
191+
reqContentType = http.DetectContentType(cw.GetReqBuffer().Bytes())
192+
if reqContentType == "" {
193+
reqContentType = "application/octet-stream" // default http content type
194+
}
195+
}
196+
197+
postData = &har.PostData{
198+
MimeType: reqContentType,
199+
Text: bodyText,
200+
Params: []*har.Param{}, // We don't parse the body here to populate this
201+
}
202+
}
203+
204+
return &har.Request{
205+
Method: r.Method,
206+
URL: r.URL.String(),
207+
Headers: reqHeaders,
208+
QueryString: reqQueryParams,
209+
BodySize: r.ContentLength,
210+
PostData: postData,
211+
HTTPVersion: r.Proto,
212+
Cookies: reqCookies,
213+
HeadersSize: int64(headerSize),
214+
}
215+
}
216+
217+
func (s *speakeasy) getHarResponse(ctx context.Context, cw *captureWriter, r *http.Request, startTime time.Time) *har.Response {
218+
resHeaders := []*har.NameValuePair{}
219+
220+
cookieParser := http.Response{Header: http.Header{}}
221+
222+
for k, v := range cw.origResW.Header() {
223+
for _, vv := range v {
224+
if k == "Set-Cookie" {
225+
cookieParser.Header.Add(k, vv)
226+
}
227+
228+
resHeaders = append(resHeaders, &har.NameValuePair{Name: k, Value: vv})
229+
}
230+
}
231+
232+
resCookies := getHarCookies(cookieParser.Cookies(), startTime)
233+
234+
resContentType := cw.origResW.Header().Get("Content-Type")
235+
if resContentType == "" {
236+
resContentType = "application/octet-stream" // default http content type
237+
}
238+
239+
bodyText := "--dropped--"
240+
contentBodySize := 0
241+
if cw.IsResValid() {
242+
bodyText = cw.GetResBuffer().String()
243+
contentBodySize = cw.GetResBuffer().Len()
244+
}
245+
246+
bodySize := cw.GetResponseSize()
247+
if cw.GetStatus() == http.StatusNotModified {
248+
bodySize = 0
249+
}
250+
251+
b := bytes.NewBuffer([]byte{})
252+
headerSize := -1
253+
if err := cw.origResW.Header().Write(b); err != nil {
254+
log.From(ctx).Error("speakeasy-sdk: failed to read length of response headers", zap.Error(err))
255+
} else {
256+
headerSize = b.Len()
257+
}
258+
259+
return &har.Response{
260+
Status: int64(cw.status),
261+
StatusText: http.StatusText(cw.status),
262+
HTTPVersion: r.Proto,
263+
Headers: resHeaders,
264+
Cookies: resCookies,
265+
Content: &har.Content{ // we are assuming we are getting the raw response here, so if we are put in the chain such that compression or encoding happens then the response text will be unreadable
266+
Size: int64(contentBodySize),
267+
MimeType: resContentType,
268+
Text: bodyText,
269+
},
270+
RedirectURL: cw.origResW.Header().Get("Location"),
271+
HeadersSize: int64(headerSize),
272+
BodySize: int64(bodySize),
273+
}
274+
}
275+
276+
func getHarCookies(cookies []*http.Cookie, startTime time.Time) []*har.Cookie {
277+
harCookies := []*har.Cookie{}
278+
for _, cookie := range cookies {
279+
harCookie := &har.Cookie{
280+
Name: cookie.Name,
281+
Value: cookie.Value,
282+
Path: cookie.Path,
283+
Domain: cookie.Domain,
284+
Secure: cookie.Secure,
285+
HTTPOnly: cookie.HttpOnly,
286+
}
287+
288+
if cookie.MaxAge != 0 {
289+
harCookie.Expires = startTime.Add(time.Duration(cookie.MaxAge) * time.Second).Format(time.RFC3339)
290+
} else if (cookie.Expires != time.Time{}) {
291+
harCookie.Expires = cookie.Expires.Format(time.RFC3339)
292+
}
293+
294+
harCookies = append(harCookies, harCookie)
295+
}
296+
297+
return harCookies
298+
}
299+
300+
// 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
302+
type valueOnlyContext struct{ context.Context }
303+
304+
// nolint
305+
func (valueOnlyContext) Deadline() (deadline time.Time, ok bool) { return }
306+
func (valueOnlyContext) Done() <-chan struct{} { return nil }
307+
func (valueOnlyContext) Err() error { return nil }

0 commit comments

Comments
 (0)