Skip to content
This repository has been archived by the owner on Nov 27, 2024. It is now read-only.

rest: add initial support for PATCH #315

Merged
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions e2e/features/restapi/patch.feature
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"

1 change: 1 addition & 0 deletions e2e/step/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions e2e/step/user/user_when.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion server/config/server/proxy-config/dynamic/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
106 changes: 106 additions & 0 deletions server/core/workspace/workspace_patch.go
Original file line number Diff line number Diff line change
@@ -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)
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe add a test so that other patch types are rejected for now?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea, let me add them

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done in bd8a3d3


// 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
}
91 changes: 91 additions & 0 deletions server/core/workspace/workspace_patch_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
})
2 changes: 1 addition & 1 deletion server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions server/rest/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand All @@ -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)
})
Expand All @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading