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

Commit

Permalink
rest: add initial support for PATCH
Browse files Browse the repository at this point in the history
Signed-off-by: Francesco Ilario <filario@redhat.com>
  • Loading branch information
filariow committed Oct 11, 2024
1 parent 3484bfe commit d5d6536
Show file tree
Hide file tree
Showing 3 changed files with 341 additions and 0 deletions.
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)
}

// 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))
})
})
144 changes: 144 additions & 0 deletions server/rest/workspace/patch.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit d5d6536

Please sign in to comment.