diff --git a/e2e/features/restapi/patch.feature b/e2e/features/restapi/patch.feature new file mode 100644 index 00000000..83cc3195 --- /dev/null +++ b/e2e/features/restapi/patch.feature @@ -0,0 +1,8 @@ +Feature: Patch workspaces via REST API + + Scenario: users can update their workspaces' visibility + Given An user is onboarded + And Default workspace is created for them + When The user patches workspace visibility to "community" + Then The workspace visibility is updated to "community" + diff --git a/e2e/step/user/user.go b/e2e/step/user/user.go index d499231e..4f1ddbff 100644 --- a/e2e/step/user/user.go +++ b/e2e/step/user/user.go @@ -14,6 +14,7 @@ func RegisterSteps(ctx *godog.ScenarioContext) { ctx.When(`^The user requests their default workspace$`, whenUserRequestsTheirDefaultWorkspace) ctx.When(`^The user changes workspace visibility to "([^"]*)"$`, whenTheUserChangesWorkspaceVisibilityTo) + ctx.When(`^The user patches workspace visibility to "([^"]*)"$`, whenTheUserPatchesWorkspaceVisibilityTo) // then ctx.Then(`^The user retrieves a list of workspaces containing just the default one$`, thenTheUserRetrievesAListOfWorkspacesContainingJustTheDefaultOne) diff --git a/e2e/step/user/user_when.go b/e2e/step/user/user_when.go index 3a1ae7ab..da03a549 100644 --- a/e2e/step/user/user_when.go +++ b/e2e/step/user/user_when.go @@ -56,6 +56,34 @@ func whenUserRequestsTheirDefaultWorkspace(ctx context.Context) (context.Context return tcontext.InjectUserWorkspace(ctx, w), nil } +func whenTheUserPatchesWorkspaceVisibilityTo(ctx context.Context, visibility string) (context.Context, error) { + cli, err := wrest.BuildWorkspacesClient(ctx) + if err != nil { + return ctx, err + } + + // retrieve user's Workspace from context + w, err := func() (*restworkspacesv1alpha1.Workspace, error) { + w, ok := tcontext.LookupUserWorkspace(ctx) + if !ok { + // fallback to InternalWorkspace + iw := tcontext.RetrieveInternalWorkspace(ctx) + return mapper.Default.InternalWorkspaceToWorkspace(&iw) + } + return &w, nil + }() + if err != nil { + return ctx, err + } + + pw := w.DeepCopy() + pw.Spec.Visibility = restworkspacesv1alpha1.WorkspaceVisibility(visibility) + if err := cli.Patch(ctx, pw, client.MergeFrom(w)); err != nil { + return ctx, err + } + return tcontext.InjectUserWorkspace(ctx, *pw), nil +} + func whenTheUserChangesWorkspaceVisibilityTo(ctx context.Context, visibility string) (context.Context, error) { cli, err := wrest.BuildWorkspacesClient(ctx) if err != nil { diff --git a/server/config/server/proxy-config/dynamic/config.yaml b/server/config/server/proxy-config/dynamic/config.yaml index 5a36c39c..6c710177 100644 --- a/server/config/server/proxy-config/dynamic/config.yaml +++ b/server/config/server/proxy-config/dynamic/config.yaml @@ -9,7 +9,7 @@ http: service: web entrypoints: - web - rule: PathPrefix(`/apis/workspaces.konflux-ci.dev`) && ( Method(`GET`) || Method(`PUT`) ) + rule: PathPrefix(`/apis/workspaces.konflux-ci.dev`) && ( Method(`GET`) || Method(`PUT`) || Method(`PATCH`) ) middlewares: - jwt-authorizer app-healthz: diff --git a/server/core/workspace/workspace_patch.go b/server/core/workspace/workspace_patch.go new file mode 100644 index 00000000..daaa59bc --- /dev/null +++ b/server/core/workspace/workspace_patch.go @@ -0,0 +1,136 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/strategicpatch" + "sigs.k8s.io/controller-runtime/pkg/client" + + jsonpatch "github.com/evanphx/json-patch/v5" + ccontext "github.com/konflux-workspaces/workspaces/server/core/context" + "github.com/konflux-workspaces/workspaces/server/log" + + restworkspacesv1alpha1 "github.com/konflux-workspaces/workspaces/server/api/v1alpha1" + workspacesv1alpha1 "github.com/konflux-workspaces/workspaces/server/api/v1alpha1" +) + +// PatchWorkspaceCommand contains the information needed to retrieve a Workspace the user has access to from the data source +type PatchWorkspaceCommand struct { + Owner string + Workspace string + Patch []byte + PatchType types.PatchType +} + +// PatchWorkspaceResponse contains the workspace the user requested +type PatchWorkspaceResponse struct { + Workspace *restworkspacesv1alpha1.Workspace +} + +// PatchWorkspaceHandler processes PatchWorkspaceCommand and returns PatchWorkspaceResponse fetching data from a WorkspacePatcher +type PatchWorkspaceHandler struct { + reader WorkspaceReader + updater WorkspaceUpdater +} + +// NewPatchWorkspaceHandler creates a new PatchWorkspaceHandler that uses a specified WorkspacePatcher +func NewPatchWorkspaceHandler(reader WorkspaceReader, updater WorkspaceUpdater) *PatchWorkspaceHandler { + return &PatchWorkspaceHandler{ + reader: reader, + updater: updater, + } +} + +// Handle handles a PatchWorkspaceCommand and returns a PatchWorkspaceResponse or an error +func (h *PatchWorkspaceHandler) Handle(ctx context.Context, command PatchWorkspaceCommand) (*PatchWorkspaceResponse, error) { + // authorization + // If required, implement here complex logic like multiple-domains filtering, etc + u, ok := ctx.Value(ccontext.UserSignupComplaintNameKey).(string) + if !ok { + return nil, fmt.Errorf("unauthenticated request") + } + + // validate query + // TODO: sanitize input, block reserved labels, etc + + // retrieve workspace + w := workspacesv1alpha1.Workspace{} + if err := h.reader.ReadUserWorkspace(ctx, u, command.Owner, command.Workspace, &w); err != nil { + return nil, err + } + + // apply patch + pw, err := h.applyPatch(&w, command) + if err != nil { + return nil, fmt.Errorf("error patching Workspace %s/%s: %w", command.Owner, command.Workspace, err) + } + + log.FromContext(ctx).Debug("updating workspace", "workspace", pw) + opts := &client.UpdateOptions{} + if err := h.updater.UpdateUserWorkspace(ctx, u, pw, opts); err != nil { + return nil, err + } + + // reply + return &PatchWorkspaceResponse{ + Workspace: pw, + }, nil +} + +func (h *PatchWorkspaceHandler) applyPatch(w *workspacesv1alpha1.Workspace, command PatchWorkspaceCommand) (*workspacesv1alpha1.Workspace, error) { + switch command.PatchType { + case types.MergePatchType: + return h.applyMergePatch(w, command.Patch) + case types.StrategicMergePatchType: + return h.applyStrategicMergePatch(w, command.Patch) + default: + return nil, fmt.Errorf("unsupported patch type: %s", command.PatchType) + } +} + +func (h *PatchWorkspaceHandler) applyMergePatch(w *workspacesv1alpha1.Workspace, patch []byte) (*workspacesv1alpha1.Workspace, error) { + // marshal workspace as json + wj, err := json.Marshal(w) + if err != nil { + return nil, err + } + + // apply jsonpatch + pwj, err := jsonpatch.MergePatch(wj, patch) + if err != nil { + return nil, err + } + + // unmarshal json to struct + pw := workspacesv1alpha1.Workspace{} + if err := json.Unmarshal(pwj, &pw); err != nil { + return nil, err + } + + return &pw, nil +} + +func (h *PatchWorkspaceHandler) applyStrategicMergePatch(w *workspacesv1alpha1.Workspace, patch []byte) (*workspacesv1alpha1.Workspace, error) { + // marshal workspace as json + wj, err := json.Marshal(w) + if err != nil { + return nil, err + } + + // apply jsonpatch + pwj, err := strategicpatch.StrategicMergePatch(wj, patch, *w) + if err != nil { + return nil, err + } + + // unmarshal json to struct + pw := workspacesv1alpha1.Workspace{} + if err := json.Unmarshal(pwj, &pw); err != nil { + return nil, err + } + + return &pw, nil +} diff --git a/server/core/workspace/workspace_patch_test.go b/server/core/workspace/workspace_patch_test.go new file mode 100644 index 00000000..fba091b3 --- /dev/null +++ b/server/core/workspace/workspace_patch_test.go @@ -0,0 +1,149 @@ +package workspace_test + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + "sigs.k8s.io/controller-runtime/pkg/client" + + ccontext "github.com/konflux-workspaces/workspaces/server/core/context" + "github.com/konflux-workspaces/workspaces/server/core/workspace" + + workspacesv1alpha1 "github.com/konflux-workspaces/workspaces/server/api/v1alpha1" +) + +var _ = Describe("", func() { + var ( + ctrl *gomock.Controller + ctx context.Context + reader *MockWorkspaceReader + updater *MockWorkspaceUpdater + request workspace.PatchWorkspaceCommand + handler workspace.PatchWorkspaceHandler + w workspacesv1alpha1.Workspace + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + ctx = context.Background() + w = workspacesv1alpha1.Workspace{ + ObjectMeta: v1.ObjectMeta{ + Name: "default", + Namespace: "user", + }, + Spec: workspacesv1alpha1.WorkspaceSpec{ + Visibility: workspacesv1alpha1.WorkspaceVisibilityPrivate, + }, + } + updater = NewMockWorkspaceUpdater(ctrl) + reader = NewMockWorkspaceReader(ctrl) + request = workspace.PatchWorkspaceCommand{ + Workspace: w.Name, + Owner: w.Namespace, + } + handler = *workspace.NewPatchWorkspaceHandler(reader, updater) + }) + + AfterEach(func() { ctrl.Finish() }) + + It("should not allow unauthenticated requests", func() { + // don't set the "user" value within ctx + + response, err := handler.Handle(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(fmt.Errorf("unauthenticated request"))) + Expect(response).To(BeNil()) + }) + + Context("authenticated requests", func() { + It("should allow merge patch", func() { + // given + request.PatchType = types.MergePatchType + request.Patch = []byte(`{"spec":{"visibility":"community"}}`) + username := "foo" + ctx := context.WithValue(ctx, ccontext.UserSignupComplaintNameKey, username) + opts := &client.UpdateOptions{} + reader.EXPECT(). + ReadUserWorkspace(ctx, username, w.Namespace, w.Name, gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, user, owner, workspace string, rw *workspacesv1alpha1.Workspace, opts ...client.GetOption) error { + w.DeepCopyInto(rw) + return nil + }) + updater.EXPECT(). + UpdateUserWorkspace(ctx, username, gomock.Any(), opts). + Return(nil) + + // when + response, err := handler.Handle(ctx, request) + + // then + Expect(err).NotTo(HaveOccurred()) + Expect(response).NotTo(BeNil()) + expectedWorkspace := w.DeepCopy() + expectedWorkspace.Spec.Visibility = workspacesv1alpha1.WorkspaceVisibilityCommunity + Expect(response.Workspace).To(BeEquivalentTo(expectedWorkspace)) + }) + + It("should allow strategic merge patch", func() { + // given + request.PatchType = types.StrategicMergePatchType + request.Patch = []byte(`{"spec":{"visibility":"community"}}`) + username := "foo" + ctx := context.WithValue(ctx, ccontext.UserSignupComplaintNameKey, username) + opts := &client.UpdateOptions{} + reader.EXPECT(). + ReadUserWorkspace(ctx, username, w.Namespace, w.Name, gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, user, owner, workspace string, rw *workspacesv1alpha1.Workspace, opts ...client.GetOption) error { + w.DeepCopyInto(rw) + return nil + }) + updater.EXPECT(). + UpdateUserWorkspace(ctx, username, gomock.Any(), opts). + Return(nil) + + // when + response, err := handler.Handle(ctx, request) + + // then + Expect(err).NotTo(HaveOccurred()) + Expect(response).NotTo(BeNil()) + expectedWorkspace := w.DeepCopy() + expectedWorkspace.Spec.Visibility = workspacesv1alpha1.WorkspaceVisibilityCommunity + Expect(response.Workspace).To(BeEquivalentTo(expectedWorkspace)) + }) + }) + + DescribeTable("Unsupported patch types are rejected", + func(patchType types.PatchType) { + // given + username := "foo" + ctx := context.WithValue(ctx, ccontext.UserSignupComplaintNameKey, username) + request.PatchType = patchType + reader.EXPECT(). + ReadUserWorkspace(ctx, username, w.Namespace, w.Name, gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, user, owner, workspace string, rw *workspacesv1alpha1.Workspace, opts ...client.GetOption) error { + w.DeepCopyInto(rw) + return nil + }) + updater.EXPECT(). + UpdateUserWorkspace(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Times(0) + + // when + response, err := handler.Handle(ctx, request) + + // then + Expect(response).To(BeNil()) + Expect(err).To(MatchError(fmt.Errorf("unsupported patch type: %s", patchType))) + }, + Entry("empty patchType", types.PatchType("")), + Entry("invalid patchType", types.PatchType("bar")), + Entry("apply patch", types.ApplyPatchType), + ) +}) diff --git a/server/go.mod b/server/go.mod index 3d38526a..b045dc8b 100644 --- a/server/go.mod +++ b/server/go.mod @@ -4,6 +4,7 @@ go 1.22.2 require ( github.com/codeready-toolchain/api v0.0.0-20240708122235-0af5a9a178bb + github.com/evanphx/json-patch/v5 v5.9.0 github.com/go-logr/logr v1.4.2 github.com/konflux-workspaces/workspaces/operator v0.0.0-00010101000000-000000000000 github.com/onsi/ginkgo/v2 v2.20.2 @@ -19,7 +20,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.11.2 // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.4 // indirect diff --git a/server/main.go b/server/main.go index bc12f78a..811c69ac 100644 --- a/server/main.go +++ b/server/main.go @@ -75,6 +75,7 @@ func run(l *slog.Logger) error { workspace.NewListWorkspaceHandler(c).Handle, workspace.NewCreateWorkspaceHandler(writer).Handle, workspace.NewUpdateWorkspaceHandler(writer).Handle, + workspace.NewPatchWorkspaceHandler(c, writer).Handle, ) // HTTP Server graceful shutdown diff --git a/server/rest/server.go b/server/rest/server.go index ee3f29ce..4384a1ea 100644 --- a/server/rest/server.go +++ b/server/rest/server.go @@ -27,10 +27,11 @@ func New( listHandle workspace.ListWorkspaceQueryHandlerFunc, createHandle workspace.CreateWorkspaceCommandHandlerFunc, updateHandle workspace.UpdateWorkspaceCommandHandlerFunc, + patchHandle workspace.PatchWorkspaceCommandHandlerFunc, ) *http.Server { return &http.Server{ Addr: addr, - Handler: buildServerHandler(logger, cache, readHandle, listHandle, createHandle, updateHandle), + Handler: buildServerHandler(logger, cache, readHandle, listHandle, createHandle, updateHandle, patchHandle), ReadHeaderTimeout: 3 * time.Second, } } @@ -42,10 +43,11 @@ func buildServerHandler( listHandle workspace.ListWorkspaceQueryHandlerFunc, createHandle workspace.CreateWorkspaceCommandHandlerFunc, updateHandle workspace.UpdateWorkspaceCommandHandlerFunc, + patchHandle workspace.PatchWorkspaceCommandHandlerFunc, ) http.Handler { mux := http.NewServeMux() addHealthz(mux) - addWorkspaces(mux, cache, readHandle, listHandle, createHandle, updateHandle) + addWorkspaces(mux, cache, readHandle, listHandle, createHandle, updateHandle, patchHandle) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }) @@ -66,6 +68,7 @@ func addWorkspaces( listHandle workspace.ListWorkspaceQueryHandlerFunc, _ workspace.CreateWorkspaceCommandHandlerFunc, updateHandle workspace.UpdateWorkspaceCommandHandlerFunc, + patchHandle workspace.PatchWorkspaceCommandHandlerFunc, ) { // Read mux.Handle(fmt.Sprintf("GET %s/{name}", NamespacedWorkspacesPrefix), @@ -100,6 +103,16 @@ func addWorkspaces( marshal.DefaultUnmarshalerProvider, )))) + // Patch + mux.Handle(fmt.Sprintf("PATCH %s/{name}", NamespacedWorkspacesPrefix), + withAuthHeaderInfo( + withUserSignupAuth(cache, + workspace.NewPatchWorkspaceHandler( + workspace.MapPatchWorkspaceHttp, + patchHandle, + marshal.DefaultMarshalerProvider, + )))) + // Create // mux.Handle(fmt.Sprintf("POST %s", NamespacedWorkspacesPrefix), // withAuthHeaderInfo( diff --git a/server/rest/workspace/patch.go b/server/rest/workspace/patch.go new file mode 100644 index 00000000..f7089305 --- /dev/null +++ b/server/rest/workspace/patch.go @@ -0,0 +1,160 @@ +package workspace + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + + "github.com/konflux-workspaces/workspaces/server/core" + "github.com/konflux-workspaces/workspaces/server/core/workspace" + "github.com/konflux-workspaces/workspaces/server/log" + "github.com/konflux-workspaces/workspaces/server/rest/header" + "github.com/konflux-workspaces/workspaces/server/rest/marshal" + "k8s.io/apimachinery/pkg/types" +) + +var ( + _ http.Handler = &PatchWorkspaceHandler{} + + _ PatchWorkspaceMapperFunc = MapPatchWorkspaceHttp +) + +// handler dependencies +type PatchWorkspaceMapperFunc func(*http.Request) (*workspace.PatchWorkspaceCommand, error) +type PatchWorkspaceCommandHandlerFunc func(context.Context, workspace.PatchWorkspaceCommand) (*workspace.PatchWorkspaceResponse, error) + +// PatchWorkspaceHandler the http.Request handler for Patch Workspaces endpoint +type PatchWorkspaceHandler struct { + MapperFunc PatchWorkspaceMapperFunc + CommandHandler PatchWorkspaceCommandHandlerFunc + + MarshalerProvider marshal.MarshalerProvider +} + +// NewPatchWorkspaceHandler creates a PatchWorkspaceHandler +func NewDefaultPatchWorkspaceHandler( + handler PatchWorkspaceCommandHandlerFunc, +) *PatchWorkspaceHandler { + return NewPatchWorkspaceHandler( + MapPatchWorkspaceHttp, + handler, + marshal.DefaultMarshalerProvider, + ) +} + +// NewPatchWorkspaceHandler creates a PatchWorkspaceHandler +func NewPatchWorkspaceHandler( + mapperFunc PatchWorkspaceMapperFunc, + commandHandler PatchWorkspaceCommandHandlerFunc, + marshalerProvider marshal.MarshalerProvider, +) *PatchWorkspaceHandler { + return &PatchWorkspaceHandler{ + MapperFunc: mapperFunc, + CommandHandler: commandHandler, + MarshalerProvider: marshalerProvider, + } +} + +func (h *PatchWorkspaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + l := log.FromContext(r.Context()) + l.Debug("executing patch") + + // build marshaler for the given request + l.Debug("building marshaler for request") + m, err := h.MarshalerProvider(r) + if err != nil { + l.Debug("error building marshaler for request", "error", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // map + l.Debug("mapping request to patch command") + c, err := h.MapperFunc(r) + if err != nil { + l.Debug("error mapping request to command", "error", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // execute + l.Debug("executing patch command", "command", c) + cr, err := h.CommandHandler(r.Context(), *c) + if err != nil { + l = l.With("error", err) + switch { + case errors.Is(err, core.ErrNotFound): + l.Debug("error executing patch command: resource not found") + w.WriteHeader(http.StatusNotFound) + default: + l.Error("error executing patch command") + w.WriteHeader(http.StatusInternalServerError) + } + return + } + + // marshal response + l.Debug("marshaling response", "response", &cr) + d, err := m.Marshal(cr.Workspace) + if err != nil { + l.Error("unexpected error marshaling response", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // reply + l.Debug("writing response", "response", d) + w.Header().Add(header.ContentType, m.ContentType()) + if _, err := w.Write(d); err != nil { + l.Error("unexpected error writing response", "error", err) + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func MapPatchWorkspaceHttp(r *http.Request) (*workspace.PatchWorkspaceCommand, error) { + // parse request body + d, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("error reading request body: %w", err) + } + + pt, err := parsePatchType(r) + if err != nil { + return nil, err + } + + // retrieve namespace from path + n := r.PathValue("name") + ns := r.PathValue("namespace") + + // build command + return &workspace.PatchWorkspaceCommand{ + Workspace: n, + Owner: ns, + PatchType: pt, + Patch: d, + }, nil +} + +func parsePatchType(r *http.Request) (types.PatchType, error) { + ct, ok := r.Header["Content-Type"] + if !ok || len(ct) != 1 { + return "", fmt.Errorf("Content-Type header is required") + } + + switch ct[0] { + case string(types.MergePatchType): + return types.MergePatchType, nil + case string(types.StrategicMergePatchType): + return types.StrategicMergePatchType, nil + case string(types.JSONPatchType): + return types.JSONPatchType, nil + case string(types.ApplyPatchType): + return types.ApplyPatchType, nil + default: + return "", fmt.Errorf("unsupported Content-Type: %s", ct) + } +} diff --git a/server/rest/workspace/patch_test.go b/server/rest/workspace/patch_test.go new file mode 100644 index 00000000..c5e4cbc4 --- /dev/null +++ b/server/rest/workspace/patch_test.go @@ -0,0 +1,115 @@ +package workspace_test + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" + "k8s.io/apimachinery/pkg/types" + + "github.com/konflux-workspaces/workspaces/server/rest/workspace/mocks" + + coreworkspace "github.com/konflux-workspaces/workspaces/server/core/workspace" + "github.com/konflux-workspaces/workspaces/server/rest/marshal" + "github.com/konflux-workspaces/workspaces/server/rest/workspace" + + restworkspacesv1alpha1 "github.com/konflux-workspaces/workspaces/server/api/v1alpha1" +) + +var _ = Describe("Patch tests", func() { + var ( + ctrl *gomock.Controller + w *restworkspacesv1alpha1.Workspace + request *http.Request + fake *mocks.MockFakeResponseWriter + ) + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + w = &restworkspacesv1alpha1.Workspace{} + w.Name = "foo" + w.Namespace = "bar" + + request = buildPatchRequest(w) + fake = mocks.NewMockFakeResponseWriter(ctrl) + }) + + AfterEach(func() { ctrl.Finish() }) + + DescribeTable("workspace PATCH handler", + func( + mapperFunc workspace.PatchWorkspaceMapperFunc, + patchHandler workspace.PatchWorkspaceCommandHandlerFunc, + marshaler marshal.MarshalerProvider, + prepare func() http.ResponseWriter, + ) { + response := prepare() + handler := workspace.NewPatchWorkspaceHandler(mapperFunc, patchHandler, marshaler) + handler.ServeHTTP(response, request) + }, + Entry("failure in marshal provider", workspace.MapPatchWorkspaceHttp, nopPatchHandler, errorMarshalProvider, func() http.ResponseWriter { + fake.EXPECT().WriteHeader(http.StatusBadRequest) + return fake + }), + Entry("no Content-Type in request", workspace.MapPatchWorkspaceHttp, nopPatchHandler, marshal.DefaultMarshalerProvider, func() http.ResponseWriter { + request.Body = io.NopCloser(bytes.NewReader([]byte{})) + request.Header.Del("Content-Type") + fake.EXPECT().WriteHeader(http.StatusBadRequest).Times(1) + return fake + }), + Entry("invalid Content-Type", workspace.MapPatchWorkspaceHttp, nopPatchHandler, marshal.DefaultMarshalerProvider, func() http.ResponseWriter { + request.Header.Set("Content-Type", "invalid") + fake.EXPECT().WriteHeader(http.StatusBadRequest).Times(1) + return fake + }), + Entry("failure in patch handler", workspace.MapPatchWorkspaceHttp, badPatchHandler, marshal.DefaultMarshalerProvider, func() http.ResponseWriter { + fake.EXPECT().WriteHeader(http.StatusInternalServerError) + return fake + }), + Entry("failure marshaling response", workspace.MapPatchWorkspaceHttp, nopPatchHandler, badMarshalProvider, func() http.ResponseWriter { + fake.EXPECT().WriteHeader(http.StatusInternalServerError) + return fake + }), + Entry("failure to write response", workspace.MapPatchWorkspaceHttp, nopPatchHandler, marshal.DefaultMarshalerProvider, func() http.ResponseWriter { + fake.EXPECT().Header().Return(http.Header{}) + fake.EXPECT().Write(gomock.Any()).Return(0, fmt.Errorf("failed to write response body")) + fake.EXPECT().WriteHeader(http.StatusInternalServerError) + return fake + }), + Entry("failure to write response", workspace.MapPatchWorkspaceHttp, nopPatchHandler, marshal.DefaultMarshalerProvider, func() http.ResponseWriter { + fake.EXPECT().Header().Return(http.Header{}) + fake.EXPECT().Write(gomock.Any()).DoAndReturn(func(a any) (int, error) { + slice, ok := a.([]byte) + Expect(ok).To(BeTrue()) + return len(slice), nil + }) + return fake + }), + ) +}) + +func badPatchHandler(ctx context.Context, cmd coreworkspace.PatchWorkspaceCommand) (*coreworkspace.PatchWorkspaceResponse, error) { + return nil, fmt.Errorf("bad patch handler") +} + +func nopPatchHandler(_ctx context.Context, cmd coreworkspace.PatchWorkspaceCommand) (*coreworkspace.PatchWorkspaceResponse, error) { + return &coreworkspace.PatchWorkspaceResponse{}, nil +} + +func buildPatchRequest(workspace *restworkspacesv1alpha1.Workspace) *http.Request { + byteSlice, err := marshal.DefaultMarshal.Marshal(workspace) + Expect(err).NotTo(HaveOccurred()) + + url := fmt.Sprintf("/apis/workspaces.io/v1alpha1/namespaces/%s/workspaces", workspace.GetNamespace()) + + request, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(byteSlice)) + Expect(err).NotTo(HaveOccurred()) + request.Header.Add("Content-Type", string(types.MergePatchType)) + request.Header.Add("Accept", marshal.DefaultMarshal.ContentType()) + return request +}