This repository has been archived by the owner on Nov 27, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
rest: add initial support for PATCH (#315)
* rest: add initial support for PATCH Signed-off-by: Francesco Ilario <filario@redhat.com> * enable patch endpoint Signed-off-by: Francesco Ilario <filario@redhat.com> * add patch endpoint e2e test Signed-off-by: Francesco Ilario <filario@redhat.com> * remove @wip Signed-off-by: Francesco Ilario <filario@redhat.com> * fix user patch step Signed-off-by: Francesco Ilario <filario@redhat.com> * Add tests for unsupported patch types Signed-off-by: Francesco Ilario <filario@redhat.com> * add strategic merge support Signed-off-by: Francesco Ilario <filario@redhat.com> --------- Signed-off-by: Francesco Ilario <filario@redhat.com>
- Loading branch information
Showing
11 changed files
with
615 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.