From 4034766c939620c062a0dbad2207eaad6821c54b Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Wed, 12 Feb 2025 14:00:57 +0100 Subject: [PATCH] acc: Simplify writing handlers; support headers in responses (#2338) ## Changes Handlers now receive testserver.Request and return any which could be - string or []byte (returns it as is but sets content-type to json or plain text depending on content) - struct (encodes it as json and sets content-type to json) - testserver.Response (full control over status and headers) Note if testserver.Response is returned from the handler, it's Body attribute can still be an object. In that case, it'll be serialized and appropriate content-type header will be added. The config is now using the same testserver.Response struct, the same logic applies both configured responses and responses returned from handlers. As a result, one can set headers both in Golang handlers and in test.toml. This also fixes a bug with RecordRequest not seeing the body if it was already consumed by the handler. ## Tests - Existing rests. - acceptance/selftest/server is extended to set response header. --- acceptance/acceptance_test.go | 9 +- .../bundle/debug/out.stderr.parallel.txt | 6 +- acceptance/bundle/debug/out.stderr.txt | 3 +- acceptance/cmd_server_test.go | 8 +- acceptance/config_test.go | 6 +- acceptance/selftest/server/out.requests.txt | 4 + acceptance/selftest/server/output.txt | 8 +- acceptance/selftest/server/script | 2 + acceptance/selftest/server/test.toml | 2 + acceptance/server_test.go | 132 ++++----- libs/testserver/fake_workspace.go | 98 +++---- libs/testserver/server.go | 251 ++++++++++++++---- 12 files changed, 336 insertions(+), 193 deletions(-) diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index e61166c31..85c345032 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -7,7 +7,6 @@ import ( "flag" "fmt" "io" - "net/http" "os" "os/exec" "path/filepath" @@ -267,12 +266,8 @@ func runTest(t *testing.T, dir, coverDir string, repls testdiff.ReplacementsCont require.NotEmpty(t, stub.Pattern) items := strings.Split(stub.Pattern, " ") require.Len(t, items, 2) - server.Handle(items[0], items[1], func(fakeWorkspace *testserver.FakeWorkspace, req *http.Request) (any, int) { - statusCode := http.StatusOK - if stub.Response.StatusCode != 0 { - statusCode = stub.Response.StatusCode - } - return stub.Response.Body, statusCode + server.Handle(items[0], items[1], func(req testserver.Request) any { + return stub.Response }) } diff --git a/acceptance/bundle/debug/out.stderr.parallel.txt b/acceptance/bundle/debug/out.stderr.parallel.txt index 7dd770068..13c81c511 100644 --- a/acceptance/bundle/debug/out.stderr.parallel.txt +++ b/acceptance/bundle/debug/out.stderr.parallel.txt @@ -9,7 +9,7 @@ 10:07:59 Debug: ApplyReadOnly pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:folder_permissions 10:07:59 Debug: ApplyReadOnly pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:validate_sync_patterns 10:07:59 Debug: Path /Workspace/Users/[USERNAME]/.bundle/debug/default/files has type directory (ID: 0) pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync -10:07:59 Debug: non-retriable error: pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true -< {} pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true -< {} pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true +10:07:59 Debug: non-retriable error: Workspace path not found pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true +< HTTP/0.0 000 OK pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true +< } pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true < } pid=12345 mutator=validate mutator (read-only)=parallel mutator (read-only)=validate:files_to_sync sdk=true diff --git a/acceptance/bundle/debug/out.stderr.txt b/acceptance/bundle/debug/out.stderr.txt index 9cac8bb2b..e5867e008 100644 --- a/acceptance/bundle/debug/out.stderr.txt +++ b/acceptance/bundle/debug/out.stderr.txt @@ -79,11 +79,12 @@ 10:07:59 Debug: Apply pid=12345 mutator=validate 10:07:59 Debug: GET /api/2.0/workspace/get-status?path=/Workspace/Users/[USERNAME]/.bundle/debug/default/files < HTTP/1.1 404 Not Found +< { +< "message": "Workspace path not found" 10:07:59 Debug: POST /api/2.0/workspace/mkdirs > { > "path": "/Workspace/Users/[USERNAME]/.bundle/debug/default/files" > } -< HTTP/1.1 200 OK 10:07:59 Debug: GET /api/2.0/workspace/get-status?path=/Workspace/Users/[USERNAME]/.bundle/debug/default/files < HTTP/1.1 200 OK < { diff --git a/acceptance/cmd_server_test.go b/acceptance/cmd_server_test.go index d3db06003..dc48a85d7 100644 --- a/acceptance/cmd_server_test.go +++ b/acceptance/cmd_server_test.go @@ -1,8 +1,8 @@ package acceptance_test import ( + "context" "encoding/json" - "net/http" "os" "strings" "testing" @@ -14,7 +14,7 @@ import ( func StartCmdServer(t *testing.T) *testserver.Server { server := testserver.New(t) - server.Handle("GET", "/", func(_ *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("GET", "/", func(r testserver.Request) any { q := r.URL.Query() args := strings.Split(q.Get("args"), " ") @@ -27,7 +27,7 @@ func StartCmdServer(t *testing.T) *testserver.Server { defer Chdir(t, q.Get("cwd"))() - c := testcli.NewRunner(t, r.Context(), args...) + c := testcli.NewRunner(t, context.Background(), args...) c.Verbose = false stdout, stderr, err := c.Run() result := map[string]any{ @@ -39,7 +39,7 @@ func StartCmdServer(t *testing.T) *testserver.Server { exitcode = 1 } result["exitcode"] = exitcode - return result, http.StatusOK + return result }) return server } diff --git a/acceptance/config_test.go b/acceptance/config_test.go index 920e713a1..ec0d1baee 100644 --- a/acceptance/config_test.go +++ b/acceptance/config_test.go @@ -10,6 +10,7 @@ import ( "dario.cat/mergo" "github.com/BurntSushi/toml" "github.com/databricks/cli/libs/testdiff" + "github.com/databricks/cli/libs/testserver" "github.com/stretchr/testify/require" ) @@ -56,10 +57,7 @@ type ServerStub struct { Pattern string // The response body to return. - Response struct { - Body string - StatusCode int - } + Response testserver.Response } // FindConfigs finds all the config relevant for this test, diff --git a/acceptance/selftest/server/out.requests.txt b/acceptance/selftest/server/out.requests.txt index 2cb8708ac..34f4c4899 100644 --- a/acceptance/selftest/server/out.requests.txt +++ b/acceptance/selftest/server/out.requests.txt @@ -6,3 +6,7 @@ "method": "GET", "path": "/custom/endpoint" } +{ + "method": "GET", + "path": "/api/2.0/workspace/get-status" +} diff --git a/acceptance/selftest/server/output.txt b/acceptance/selftest/server/output.txt index f9e51caa9..7147f9c9b 100644 --- a/acceptance/selftest/server/output.txt +++ b/acceptance/selftest/server/output.txt @@ -6,10 +6,16 @@ } >>> curl -sD - [DATABRICKS_URL]/custom/endpoint?query=param HTTP/1.1 201 Created -Content-Type: application/json +X-Custom-Header: hello Date: (redacted) Content-Length: (redacted) +Content-Type: text/plain; charset=utf-8 custom --- response + +>>> errcode [CLI] workspace get-status /a/b/c +Error: Workspace path not found + +Exit code: 1 diff --git a/acceptance/selftest/server/script b/acceptance/selftest/server/script index 53e2c4b8a..810ea64b6 100644 --- a/acceptance/selftest/server/script +++ b/acceptance/selftest/server/script @@ -1,2 +1,4 @@ trace curl -s $DATABRICKS_HOST/api/2.0/preview/scim/v2/Me trace curl -sD - $DATABRICKS_HOST/custom/endpoint?query=param + +trace errcode $CLI workspace get-status /a/b/c diff --git a/acceptance/selftest/server/test.toml b/acceptance/selftest/server/test.toml index fca41bf02..43ad1e85b 100644 --- a/acceptance/selftest/server/test.toml +++ b/acceptance/selftest/server/test.toml @@ -12,6 +12,8 @@ Response.Body = '''custom response ''' Response.StatusCode = 201 +[Server.Response.Headers] +"X-Custom-Header" = ["hello"] [[Repls]] Old = 'Date: .*' diff --git a/acceptance/server_test.go b/acceptance/server_test.go index fd8006b8f..f73872e0b 100644 --- a/acceptance/server_test.go +++ b/acceptance/server_test.go @@ -1,14 +1,12 @@ package acceptance_test import ( - "bytes" "encoding/json" "fmt" "net/http" "github.com/databricks/databricks-sdk-go/service/catalog" "github.com/databricks/databricks-sdk-go/service/iam" - "github.com/gorilla/mux" "github.com/databricks/databricks-sdk-go/service/compute" "github.com/databricks/databricks-sdk-go/service/jobs" @@ -23,7 +21,7 @@ var testUser = iam.User{ } func AddHandlers(server *testserver.Server) { - server.Handle("GET", "/api/2.0/policies/clusters/list", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("GET", "/api/2.0/policies/clusters/list", func(req testserver.Request) any { return compute.ListPoliciesResponse{ Policies: []compute.Policy{ { @@ -35,10 +33,10 @@ func AddHandlers(server *testserver.Server) { Name: "some-test-cluster-policy", }, }, - }, http.StatusOK + } }) - server.Handle("GET", "/api/2.0/instance-pools/list", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("GET", "/api/2.0/instance-pools/list", func(req testserver.Request) any { return compute.ListInstancePools{ InstancePools: []compute.InstancePoolAndStats{ { @@ -46,10 +44,10 @@ func AddHandlers(server *testserver.Server) { InstancePoolId: "1234", }, }, - }, http.StatusOK + } }) - server.Handle("GET", "/api/2.1/clusters/list", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("GET", "/api/2.1/clusters/list", func(req testserver.Request) any { return compute.ListClustersResponse{ Clusters: []compute.ClusterDetails{ { @@ -61,74 +59,57 @@ func AddHandlers(server *testserver.Server) { ClusterId: "9876", }, }, - }, http.StatusOK + } }) - server.Handle("GET", "/api/2.0/preview/scim/v2/Me", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - return testUser, http.StatusOK + server.Handle("GET", "/api/2.0/preview/scim/v2/Me", func(req testserver.Request) any { + return testUser }) - server.Handle("GET", "/api/2.0/workspace/get-status", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - path := r.URL.Query().Get("path") - - return fakeWorkspace.WorkspaceGetStatus(path) + server.Handle("GET", "/api/2.0/workspace/get-status", func(req testserver.Request) any { + path := req.URL.Query().Get("path") + return req.Workspace.WorkspaceGetStatus(path) }) - server.Handle("POST", "/api/2.0/workspace/mkdirs", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - request := workspace.Mkdirs{} - decoder := json.NewDecoder(r.Body) - - err := decoder.Decode(&request) - if err != nil { - return internalError(err) + server.Handle("POST", "/api/2.0/workspace/mkdirs", func(req testserver.Request) any { + var request workspace.Mkdirs + if err := json.Unmarshal(req.Body, &request); err != nil { + return testserver.Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: http.StatusInternalServerError, + } } - return fakeWorkspace.WorkspaceMkdirs(request) + req.Workspace.WorkspaceMkdirs(request) + return "" }) - server.Handle("GET", "/api/2.0/workspace/export", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - path := r.URL.Query().Get("path") - - return fakeWorkspace.WorkspaceExport(path) + server.Handle("GET", "/api/2.0/workspace/export", func(req testserver.Request) any { + path := req.URL.Query().Get("path") + return req.Workspace.WorkspaceExport(path) }) - server.Handle("POST", "/api/2.0/workspace/delete", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - path := r.URL.Query().Get("path") - recursiveStr := r.URL.Query().Get("recursive") - var recursive bool - - if recursiveStr == "true" { - recursive = true - } else { - recursive = false - } - - return fakeWorkspace.WorkspaceDelete(path, recursive) + server.Handle("POST", "/api/2.0/workspace/delete", func(req testserver.Request) any { + path := req.URL.Query().Get("path") + recursive := req.URL.Query().Get("recursive") == "true" + req.Workspace.WorkspaceDelete(path, recursive) + return "" }) - server.Handle("POST", "/api/2.0/workspace-files/import-file/{path:.*}", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - vars := mux.Vars(r) - path := vars["path"] - - body := new(bytes.Buffer) - _, err := body.ReadFrom(r.Body) - if err != nil { - return internalError(err) - } - - return fakeWorkspace.WorkspaceFilesImportFile(path, body.Bytes()) + server.Handle("POST", "/api/2.0/workspace-files/import-file/{path:.*}", func(req testserver.Request) any { + path := req.Vars["path"] + req.Workspace.WorkspaceFilesImportFile(path, req.Body) + return "" }) - server.Handle("GET", "/api/2.1/unity-catalog/current-metastore-assignment", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("GET", "/api/2.1/unity-catalog/current-metastore-assignment", func(req testserver.Request) any { return catalog.MetastoreAssignment{ DefaultCatalogName: "main", - }, http.StatusOK + } }) - server.Handle("GET", "/api/2.0/permissions/directories/{objectId}", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - vars := mux.Vars(r) - objectId := vars["objectId"] - + server.Handle("GET", "/api/2.0/permissions/directories/{objectId}", func(req testserver.Request) any { + objectId := req.Vars["objectId"] return workspace.WorkspaceObjectPermissions{ ObjectId: objectId, ObjectType: "DIRECTORY", @@ -142,48 +123,43 @@ func AddHandlers(server *testserver.Server) { }, }, }, - }, http.StatusOK + } }) - server.Handle("POST", "/api/2.1/jobs/create", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - request := jobs.CreateJob{} - decoder := json.NewDecoder(r.Body) - - err := decoder.Decode(&request) - if err != nil { - return internalError(err) + server.Handle("POST", "/api/2.1/jobs/create", func(req testserver.Request) any { + var request jobs.CreateJob + if err := json.Unmarshal(req.Body, &request); err != nil { + return testserver.Response{ + Body: fmt.Sprintf("internal error: %s", err), + StatusCode: 500, + } } - return fakeWorkspace.JobsCreate(request) + return req.Workspace.JobsCreate(request) }) - server.Handle("GET", "/api/2.1/jobs/get", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - jobId := r.URL.Query().Get("job_id") - - return fakeWorkspace.JobsGet(jobId) + server.Handle("GET", "/api/2.1/jobs/get", func(req testserver.Request) any { + jobId := req.URL.Query().Get("job_id") + return req.Workspace.JobsGet(jobId) }) - server.Handle("GET", "/api/2.1/jobs/list", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { - return fakeWorkspace.JobsList() + server.Handle("GET", "/api/2.1/jobs/list", func(req testserver.Request) any { + return req.Workspace.JobsList() }) - server.Handle("GET", "/oidc/.well-known/oauth-authorization-server", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("GET", "/oidc/.well-known/oauth-authorization-server", func(_ testserver.Request) any { return map[string]string{ "authorization_endpoint": server.URL + "oidc/v1/authorize", "token_endpoint": server.URL + "/oidc/v1/token", - }, http.StatusOK + } }) - server.Handle("POST", "/oidc/v1/token", func(fakeWorkspace *testserver.FakeWorkspace, r *http.Request) (any, int) { + server.Handle("POST", "/oidc/v1/token", func(_ testserver.Request) any { return map[string]string{ "access_token": "oauth-token", "expires_in": "3600", "scope": "all-apis", "token_type": "Bearer", - }, http.StatusOK + } }) } - -func internalError(err error) (any, int) { - return fmt.Errorf("internal error: %w", err), http.StatusInternalServerError -} diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index c3e4f9a71..4e943f828 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "net/http" "sort" "strconv" "strings" @@ -33,40 +32,39 @@ func NewFakeWorkspace() *FakeWorkspace { } } -func (s *FakeWorkspace) WorkspaceGetStatus(path string) (workspace.ObjectInfo, int) { +func (s *FakeWorkspace) WorkspaceGetStatus(path string) Response { if s.directories[path] { - return workspace.ObjectInfo{ - ObjectType: "DIRECTORY", - Path: path, - }, http.StatusOK + return Response{ + Body: &workspace.ObjectInfo{ + ObjectType: "DIRECTORY", + Path: path, + }, + } } else if _, ok := s.files[path]; ok { - return workspace.ObjectInfo{ - ObjectType: "FILE", - Path: path, - Language: "SCALA", - }, http.StatusOK + return Response{ + Body: &workspace.ObjectInfo{ + ObjectType: "FILE", + Path: path, + Language: "SCALA", + }, + } } else { - return workspace.ObjectInfo{}, http.StatusNotFound + return Response{ + StatusCode: 404, + Body: map[string]string{"message": "Workspace path not found"}, + } } } -func (s *FakeWorkspace) WorkspaceMkdirs(request workspace.Mkdirs) (string, int) { +func (s *FakeWorkspace) WorkspaceMkdirs(request workspace.Mkdirs) { s.directories[request.Path] = true - - return "{}", http.StatusOK } -func (s *FakeWorkspace) WorkspaceExport(path string) ([]byte, int) { - file := s.files[path] - - if file == nil { - return nil, http.StatusNotFound - } - - return file, http.StatusOK +func (s *FakeWorkspace) WorkspaceExport(path string) []byte { + return s.files[path] } -func (s *FakeWorkspace) WorkspaceDelete(path string, recursive bool) (string, int) { +func (s *FakeWorkspace) WorkspaceDelete(path string, recursive bool) { if !recursive { s.files[path] = nil } else { @@ -76,28 +74,26 @@ func (s *FakeWorkspace) WorkspaceDelete(path string, recursive bool) (string, in } } } - - return "{}", http.StatusOK } -func (s *FakeWorkspace) WorkspaceFilesImportFile(path string, body []byte) (any, int) { +func (s *FakeWorkspace) WorkspaceFilesImportFile(path string, body []byte) { if !strings.HasPrefix(path, "/") { path = "/" + path } - s.files[path] = body - - return "{}", http.StatusOK } -func (s *FakeWorkspace) JobsCreate(request jobs.CreateJob) (any, int) { +func (s *FakeWorkspace) JobsCreate(request jobs.CreateJob) Response { jobId := s.nextJobId s.nextJobId++ jobSettings := jobs.JobSettings{} err := jsonConvert(request, &jobSettings) if err != nil { - return internalError(err) + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("Cannot convert request to jobSettings: %s", err), + } } s.jobs[jobId] = jobs.Job{ @@ -105,32 +101,44 @@ func (s *FakeWorkspace) JobsCreate(request jobs.CreateJob) (any, int) { Settings: &jobSettings, } - return jobs.CreateResponse{JobId: jobId}, http.StatusOK + return Response{ + Body: jobs.CreateResponse{JobId: jobId}, + } } -func (s *FakeWorkspace) JobsGet(jobId string) (any, int) { +func (s *FakeWorkspace) JobsGet(jobId string) Response { id := jobId jobIdInt, err := strconv.ParseInt(id, 10, 64) if err != nil { - return internalError(fmt.Errorf("failed to parse job id: %s", err)) + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("Failed to parse job id: %s: %v", err, id), + } } job, ok := s.jobs[jobIdInt] if !ok { - return jobs.Job{}, http.StatusNotFound + return Response{ + StatusCode: 404, + } } - return job, http.StatusOK + return Response{ + Body: job, + } } -func (s *FakeWorkspace) JobsList() (any, int) { +func (s *FakeWorkspace) JobsList() Response { list := make([]jobs.BaseJob, 0, len(s.jobs)) for _, job := range s.jobs { baseJob := jobs.BaseJob{} err := jsonConvert(job, &baseJob) if err != nil { - return internalError(fmt.Errorf("failed to convert job to base job: %w", err)) + return Response{ + StatusCode: 400, + Body: fmt.Sprintf("failed to convert job to base job: %s", err), + } } list = append(list, baseJob) @@ -141,9 +149,11 @@ func (s *FakeWorkspace) JobsList() (any, int) { return list[i].JobId < list[j].JobId }) - return jobs.ListJobsResponse{ - Jobs: list, - }, http.StatusOK + return Response{ + Body: jobs.ListJobsResponse{ + Jobs: list, + }, + } } // jsonConvert saves input to a value pointed by output @@ -163,7 +173,3 @@ func jsonConvert(input, output any) error { return nil } - -func internalError(err error) (string, int) { - return fmt.Sprintf("internal error: %s", err), http.StatusInternalServerError -} diff --git a/libs/testserver/server.go b/libs/testserver/server.go index cf4d5aca2..fa15973d7 100644 --- a/libs/testserver/server.go +++ b/libs/testserver/server.go @@ -5,14 +5,14 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" + "reflect" "slices" "strings" "sync" "github.com/gorilla/mux" - "github.com/stretchr/testify/assert" - "github.com/databricks/cli/internal/testutil" "github.com/databricks/databricks-sdk-go/apierr" ) @@ -29,10 +29,10 @@ type Server struct { RecordRequests bool IncludeRequestHeaders []string - Requests []Request + Requests []LoggedRequest } -type Request struct { +type LoggedRequest struct { Headers http.Header `json:"headers,omitempty"` Method string `json:"method"` Path string `json:"path"` @@ -40,6 +40,153 @@ type Request struct { RawBody string `json:"raw_body,omitempty"` } +type Request struct { + Method string + URL *url.URL + Headers http.Header + Body []byte + Vars map[string]string + Workspace *FakeWorkspace +} + +type Response struct { + StatusCode int + Headers http.Header + Body any +} + +type encodedResponse struct { + StatusCode int + Headers http.Header + Body []byte +} + +func NewRequest(t testutil.TestingT, r *http.Request, fakeWorkspace *FakeWorkspace) Request { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("Failed to read request body: %s", err) + } + + return Request{ + Method: r.Method, + URL: r.URL, + Headers: r.Header, + Body: body, + Vars: mux.Vars(r), + Workspace: fakeWorkspace, + } +} + +func normalizeResponse(t testutil.TestingT, resp any) encodedResponse { + result := normalizeResponseBody(t, resp) + if result.StatusCode == 0 { + result.StatusCode = 200 + } + return result +} + +func normalizeResponseBody(t testutil.TestingT, resp any) encodedResponse { + if isNil(resp) { + t.Errorf("Handler must not return nil") + return encodedResponse{StatusCode: 500} + } + + respBytes, ok := resp.([]byte) + if ok { + return encodedResponse{ + Body: respBytes, + Headers: getHeaders(respBytes), + } + } + + respString, ok := resp.(string) + if ok { + return encodedResponse{ + Body: []byte(respString), + Headers: getHeaders([]byte(respString)), + } + } + + respStruct, ok := resp.(Response) + if ok { + if isNil(respStruct.Body) { + return encodedResponse{ + StatusCode: respStruct.StatusCode, + Headers: respStruct.Headers, + Body: []byte{}, + } + } + + bytesVal, isBytes := respStruct.Body.([]byte) + if isBytes { + return encodedResponse{ + StatusCode: respStruct.StatusCode, + Headers: respStruct.Headers, + Body: bytesVal, + } + } + + stringVal, isString := respStruct.Body.(string) + if isString { + return encodedResponse{ + StatusCode: respStruct.StatusCode, + Headers: respStruct.Headers, + Body: []byte(stringVal), + } + } + + respBytes, err := json.MarshalIndent(respStruct.Body, "", " ") + if err != nil { + t.Errorf("JSON encoding error: %s", err) + return encodedResponse{ + StatusCode: 500, + Body: []byte("internal error"), + } + } + + headers := respStruct.Headers + if headers == nil { + headers = getJsonHeaders() + } + + return encodedResponse{ + StatusCode: respStruct.StatusCode, + Headers: headers, + Body: respBytes, + } + } + + respBytes, err := json.MarshalIndent(resp, "", " ") + if err != nil { + t.Errorf("JSON encoding error: %s", err) + return encodedResponse{ + StatusCode: 500, + Body: []byte("internal error"), + } + } + + return encodedResponse{ + Body: respBytes, + Headers: getJsonHeaders(), + } +} + +func getJsonHeaders() http.Header { + return map[string][]string{ + "Content-Type": {"application/json"}, + } +} + +func getHeaders(value []byte) http.Header { + if json.Valid(value) { + return getJsonHeaders() + } else { + return map[string][]string{ + "Content-Type": {"text/plain"}, + } + } +} + func New(t testutil.TestingT) *Server { router := mux.NewRouter() server := httptest.NewServer(router) @@ -96,7 +243,7 @@ Response.StatusCode = return s } -type HandlerFunc func(fakeWorkspace *FakeWorkspace, req *http.Request) (resp any, statusCode int) +type HandlerFunc func(req Request) any func (s *Server) Handle(method, path string, handler HandlerFunc) { s.Router.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { @@ -117,56 +264,22 @@ func (s *Server) Handle(method, path string, handler HandlerFunc) { fakeWorkspace = s.fakeWorkspaces[token] } - resp, statusCode := handler(fakeWorkspace, r) - + request := NewRequest(s.t, r, fakeWorkspace) if s.RecordRequests { - body, err := io.ReadAll(r.Body) - assert.NoError(s.t, err) - - headers := make(http.Header) - for k, v := range r.Header { - if !slices.Contains(s.IncludeRequestHeaders, k) { - continue - } - for _, vv := range v { - headers.Add(k, vv) - } - } - - req := Request{ - Headers: headers, - Method: r.Method, - Path: r.URL.Path, - } - - if json.Valid(body) { - req.Body = json.RawMessage(body) - } else { - req.RawBody = string(body) - } - - s.Requests = append(s.Requests, req) + s.Requests = append(s.Requests, getLoggedRequest(request, s.IncludeRequestHeaders)) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) + respAny := handler(request) + resp := normalizeResponse(s.t, respAny) - var respBytes []byte - var err error - if respString, ok := resp.(string); ok { - respBytes = []byte(respString) - } else if respBytes0, ok := resp.([]byte); ok { - respBytes = respBytes0 - } else { - respBytes, err = json.MarshalIndent(resp, "", " ") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + for k, v := range resp.Headers { + w.Header()[k] = v } - if _, err := w.Write(respBytes); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + w.WriteHeader(resp.StatusCode) + + if _, err := w.Write(resp.Body); err != nil { + s.t.Errorf("Failed to write response: %s", err) return } }).Methods(method) @@ -182,3 +295,43 @@ func getToken(r *http.Request) string { return header[len(prefix):] } + +func getLoggedRequest(req Request, includedHeaders []string) LoggedRequest { + result := LoggedRequest{ + Method: req.Method, + Path: req.URL.Path, + Headers: filterHeaders(req.Headers, includedHeaders), + } + + if json.Valid(req.Body) { + result.Body = json.RawMessage(req.Body) + } else { + result.RawBody = string(req.Body) + } + + return result +} + +func filterHeaders(h http.Header, includedHeaders []string) http.Header { + headers := make(http.Header) + for k, v := range h { + if !slices.Contains(includedHeaders, k) { + continue + } + headers[k] = v + } + return headers +} + +func isNil(i any) bool { + if i == nil { + return true + } + v := reflect.ValueOf(i) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Map, reflect.Ptr, reflect.Interface, reflect.Slice: + return v.IsNil() + default: + return false + } +}