From d5d6536568437c3fffff552bc02fca3d9ed243de Mon Sep 17 00:00:00 2001 From: Francesco Ilario Date: Fri, 11 Oct 2024 17:00:39 +0200 Subject: [PATCH] rest: add initial support for PATCH Signed-off-by: Francesco Ilario --- server/core/workspace/workspace_patch.go | 106 +++++++++++++ server/core/workspace/workspace_patch_test.go | 91 +++++++++++ server/rest/workspace/patch.go | 144 ++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 server/core/workspace/workspace_patch.go create mode 100644 server/core/workspace/workspace_patch_test.go create mode 100644 server/rest/workspace/patch.go diff --git a/server/core/workspace/workspace_patch.go b/server/core/workspace/workspace_patch.go new file mode 100644 index 00000000..39449302 --- /dev/null +++ b/server/core/workspace/workspace_patch.go @@ -0,0 +1,106 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + + "k8s.io/apimachinery/pkg/types" + "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) { + if command.PatchType != types.MergePatchType { + return nil, fmt.Errorf("unsupported patch type: %s", command.PatchType) + } + + // marshal workspace as json + wj, err := json.Marshal(w) + if err != nil { + return nil, err + } + + // apply jsonpatch + pwj, err := jsonpatch.MergePatch(wj, []byte(command.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 +} diff --git a/server/core/workspace/workspace_patch_test.go b/server/core/workspace/workspace_patch_test.go new file mode 100644 index 00000000..daf711b9 --- /dev/null +++ b/server/core/workspace/workspace_patch_test.go @@ -0,0 +1,91 @@ +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()) + }) + + It("should allow authenticated requests", 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)) + }) +}) diff --git a/server/rest/workspace/patch.go b/server/rest/workspace/patch.go new file mode 100644 index 00000000..e13eb1b1 --- /dev/null +++ b/server/rest/workspace/patch.go @@ -0,0 +1,144 @@ +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, marshal.UnmarshalerProvider) (*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 + UnmarshalerProvider marshal.UnmarshalerProvider +} + +// NewPatchWorkspaceHandler creates a PatchWorkspaceHandler +func NewDefaultPatchWorkspaceHandler( + handler PatchWorkspaceCommandHandlerFunc, +) *PatchWorkspaceHandler { + return NewPatchWorkspaceHandler( + MapPatchWorkspaceHttp, + handler, + marshal.DefaultMarshalerProvider, + marshal.DefaultUnmarshalerProvider, + ) +} + +// NewPatchWorkspaceHandler creates a PatchWorkspaceHandler +func NewPatchWorkspaceHandler( + mapperFunc PatchWorkspaceMapperFunc, + queryHandler PatchWorkspaceCommandHandlerFunc, + marshalerProvider marshal.MarshalerProvider, + unmarshalerProvider marshal.UnmarshalerProvider, +) *PatchWorkspaceHandler { + return &PatchWorkspaceHandler{ + MapperFunc: mapperFunc, + CommandHandler: queryHandler, + MarshalerProvider: marshalerProvider, + UnmarshalerProvider: unmarshalerProvider, + } +} + +func (h *PatchWorkspaceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + l := log.FromContext(r.Context()) + l.Debug("executing update") + + // 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 update command") + c, err := h.MapperFunc(r, h.UnmarshalerProvider) + if err != nil { + l.Debug("error mapping request to command", "error", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // execute + l.Debug("executing update 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 update command: resource not found") + w.WriteHeader(http.StatusNotFound) + default: + l.Error("error executing update 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, provider marshal.UnmarshalerProvider) (*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) + } + + ct, ok := r.Header["Content-Type"] + if !ok || len(ct) != 1 { + return nil, fmt.Errorf("Content-Type header is required") + } + + // retrieve namespace from path + n := r.PathValue("name") + ns := r.PathValue("namespace") + + // build command + return &workspace.PatchWorkspaceCommand{ + Workspace: n, + Owner: ns, + PatchType: types.PatchType(ct[0]), + Patch: d, + }, nil +}