-
Notifications
You must be signed in to change notification settings - Fork 11
feat: support record and replay websocket requests #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
075fadc
a9d51b5
0bfb5b4
fa51811
67c33e7
00b0ac4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,10 +21,14 @@ import ( | |
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| "strconv" | ||
| "strings" | ||
| "unicode" | ||
|
|
||
| "github.com/google/test-server/internal/config" | ||
| "github.com/google/test-server/internal/redact" | ||
| "github.com/google/test-server/internal/store" | ||
| "github.com/gorilla/websocket" | ||
| ) | ||
|
|
||
| type ReplayHTTPServer struct { | ||
|
|
@@ -68,22 +72,34 @@ func (r *ReplayHTTPServer) handleRequest(w http.ResponseWriter, req *http.Reques | |
| return | ||
| } | ||
| fmt.Printf("Replaying request: %ss\n", redactedReq.Request) | ||
|
|
||
| reqHash, err := redactedReq.ComputeSum() | ||
| fileName, err := redactedReq.GetRecordingFileName() | ||
| if err != nil { | ||
| fmt.Printf("Error computing request sum: %v\n", err) | ||
| http.Error(w, fmt.Sprintf("Error computing request sum: %v", err), http.StatusInternalServerError) | ||
| fmt.Printf("Invalid recording file name: %v\n", err) | ||
| http.Error(w, fmt.Sprintf("Invalid recording file name: %v", err), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| if req.Header.Get("Upgrade") == "websocket" { | ||
| fmt.Printf("Upgrading connection to websocket...\n") | ||
|
|
||
| resp, err := r.loadResponse(reqHash) | ||
| chunks, err := r.loadWebsocketChunks(fileName) | ||
| if err != nil { | ||
| fmt.Printf("Error loading websocket response: %v\n", err) | ||
| http.Error(w, fmt.Sprintf("Error loading websocket response: %v", err), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| fmt.Printf("Replaying websocket: %s\n", fileName) | ||
| r.proxyWebsocket(w, req, chunks) | ||
| return | ||
| } | ||
| fmt.Printf("Replaying http request: %s\n", redactedReq.Request) | ||
| resp, err := r.loadResponse(fileName) | ||
| if err != nil { | ||
| fmt.Printf("Error loading response: %v\n", err) | ||
| http.Error(w, fmt.Sprintf("Error loading response: %v", err), http.StatusInternalServerError) | ||
| return | ||
| } | ||
|
|
||
| err = r.writedResponse(w, resp) | ||
| err = r.writeResponse(w, resp) | ||
| if err != nil { | ||
| fmt.Printf("Error writing response: %v\n", err) | ||
| panic(err) | ||
|
|
@@ -106,8 +122,8 @@ func (r *ReplayHTTPServer) createRedactedRequest(req *http.Request) (*store.Reco | |
| return recordedRequest, nil | ||
| } | ||
|
|
||
| func (r *ReplayHTTPServer) loadResponse(sha string) (*store.RecordedResponse, error) { | ||
| responseFile := filepath.Join(r.recordingDir, sha+".resp") | ||
| func (r *ReplayHTTPServer) loadResponse(fileName string) (*store.RecordedResponse, error) { | ||
| responseFile := filepath.Join(r.recordingDir, fileName+".resp") | ||
| fmt.Printf("loading response from : %s\n", responseFile) | ||
| responseData, err := os.ReadFile(responseFile) | ||
| if err != nil { | ||
|
|
@@ -116,7 +132,7 @@ func (r *ReplayHTTPServer) loadResponse(sha string) (*store.RecordedResponse, er | |
| return store.DeserializeResponse(responseData) | ||
| } | ||
|
|
||
| func (r *ReplayHTTPServer) writedResponse(w http.ResponseWriter, resp *store.RecordedResponse) error { | ||
| func (r *ReplayHTTPServer) writeResponse(w http.ResponseWriter, resp *store.RecordedResponse) error { | ||
| for key, values := range resp.Header { | ||
| for _, value := range values { | ||
| if key == "Content-Length" || key == "Content-Encoding" { | ||
|
|
@@ -131,3 +147,130 @@ func (r *ReplayHTTPServer) writedResponse(w http.ResponseWriter, resp *store.Rec | |
| _, err := w.Write(resp.Body) | ||
| return err | ||
| } | ||
|
|
||
| func extractNumber(i *int, content string) (int, error) { | ||
| numStart := *i | ||
| for *i < len(content) && unicode.IsDigit(rune(content[*i])) { | ||
| *i++ | ||
| } | ||
| numEnd := *i | ||
| if numStart == numEnd { | ||
| return 0, fmt.Errorf("missing chunk length after prefix at position %d", numStart-1) | ||
| } | ||
| numStr := content[numStart:numEnd] | ||
| num, err := strconv.Atoi(numStr) | ||
| if err != nil { | ||
| return 0, fmt.Errorf("invalid chunk length '%s': %w", numStr, err) | ||
| } | ||
| return num, nil | ||
| } | ||
|
|
||
| func (r *ReplayHTTPServer) proxyWebsocket(w http.ResponseWriter, req *http.Request, chunks []string) { | ||
| clientConn, err := r.upgradeConnectionToWebsocket(w, req) | ||
| if err != nil { | ||
| http.Error(w, fmt.Sprintf("Error proxying websocket: %v", err), http.StatusInternalServerError) | ||
| return | ||
| } | ||
| defer clientConn.Close() | ||
| replayWebsocket(clientConn, chunks) | ||
| } | ||
|
|
||
| func (r *ReplayHTTPServer) loadWebsocketChunks(sha string) ([]string, error) { | ||
| responseFile := filepath.Join(r.recordingDir, sha+".websocket") | ||
| fmt.Printf("loading websocket response from : %s\n", responseFile) | ||
| bytes, err := os.ReadFile(responseFile) | ||
| var chunks = make([]string, 0) | ||
| if err != nil { | ||
| fmt.Printf("Error loading websocket response: %v\n", err) | ||
| return chunks, err | ||
| } | ||
|
|
||
| i := 0 | ||
| response := string(bytes) | ||
| for i < len(response) { | ||
| // Extracts prefix | ||
| prefix := response[i] | ||
| if prefix != '>' && prefix != '<' { | ||
| return nil, fmt.Errorf("invalid message prefix at position %d: expected '>' or '<', got '%c'", i, prefix) | ||
| } | ||
| i++ // Move cursor past prefix. | ||
|
|
||
| // Extracts chunk length number | ||
| num, err := extractNumber(&i, response) | ||
| i++ // Move cursor to skip the whitespace between the number and the actual chunk. | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to extract number %v", err) | ||
| } | ||
|
|
||
| // Extracts chunk | ||
| chunkStart := i | ||
| chunkEnd := chunkStart + num | ||
| if chunkEnd > len(response) { | ||
| return nil, fmt.Errorf("chunk length %d at position %d exceeds response bounds", chunkEnd, chunkStart) | ||
| } | ||
| chunk := response[chunkStart : chunkEnd-1] // Remove the \n appended at the end of the chunk | ||
| chunks = append(chunks, string(prefix)+chunk) | ||
| i = chunkEnd | ||
| } | ||
| return chunks, nil | ||
| } | ||
|
|
||
| func replayWebsocket(conn *websocket.Conn, chunks []string) { | ||
| for _, chunk := range chunks { | ||
| if strings.HasPrefix(chunk, ">") { | ||
hkt74 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| _, buf, err := conn.ReadMessage() | ||
hkt74 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| reqChunk := string(buf) | ||
| if err != nil { | ||
| fmt.Printf("Error reading from websocket: %v\n", err) | ||
| return | ||
| } | ||
|
|
||
| runes := []rune(chunk) | ||
| recChunk := string(runes[1:]) | ||
| if reqChunk != recChunk { | ||
| fmt.Printf("input chunk mismatch\n Input chunk: %s\n Recorded chunk: %s\n", reqChunk, recChunk) | ||
| writeError(conn, "input chunk mismatch") | ||
| return | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to return an error in this case? how would we know to fail the test?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call, the current behavior test will fail after timeout (5 seconds), the test is awaiting message from the server, while the request mismatch would skip the subsequent message write To make it fail fast, we probably could write an error message in the session, but it require the test setup to listen to the error message and fail the test. what do you think I track this as a follow up action? need more time to explore the possible jasmine setup.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it possible to close the connection with an error? |
||
| } | ||
| } else if strings.HasPrefix(chunk, "<") { | ||
| runes := []rune(chunk) | ||
| recChunk := string(runes[1:]) | ||
| // Write binary message. (messageType=2) | ||
| err := conn.WriteMessage(2, []byte(recChunk)) | ||
| if err != nil { | ||
| fmt.Printf("Error writing to websocket: %v\n", err) | ||
| return | ||
| } | ||
| } else { | ||
| fmt.Printf("Unreconginized chunk: %s", chunk) | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func writeError(conn *websocket.Conn, errMsg string) { | ||
| closeMessage := websocket.FormatCloseMessage( | ||
| websocket.CloseInternalServerErr, | ||
| errMsg, | ||
| ) | ||
| err := conn.WriteMessage(websocket.CloseMessage, closeMessage) | ||
| if err != nil { | ||
| fmt.Printf("Failed to write error: %v\n", err) | ||
| } | ||
| } | ||
|
|
||
| func (r *ReplayHTTPServer) upgradeConnectionToWebsocket(w http.ResponseWriter, req *http.Request) (*websocket.Conn, error) { | ||
| upgrader := websocket.Upgrader{ | ||
| ReadBufferSize: 1024, | ||
| WriteBufferSize: 1024, | ||
| CheckOrigin: func(r *http.Request) bool { | ||
| return true // Allow all origins | ||
| }, | ||
| } | ||
|
|
||
| clientConn, err := upgrader.Upgrade(w, req, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| return clientConn, err | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.