Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
39 changes: 27 additions & 12 deletions workspaces/backend/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ limitations under the License.
package api

import (
"fmt"
"log/slog"
"net/http"

"github.com/julienschmidt/httprouter"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authorization/authorizer"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand All @@ -35,6 +37,9 @@ const (
Version = "1.0.0"
PathPrefix = "/api/v1"

MediaTypeJson = "application/json"
MediaTypeYaml = "application/yaml"

NamespacePathParam = "namespace"
ResourceNamePathParam = "name"

Expand All @@ -59,26 +64,35 @@ const (
)

type App struct {
Config *config.EnvConfig
logger *slog.Logger
repositories *repositories.Repositories
Scheme *runtime.Scheme
RequestAuthN authenticator.Request
RequestAuthZ authorizer.Authorizer
Config *config.EnvConfig
logger *slog.Logger
repositories *repositories.Repositories
Scheme *runtime.Scheme
StrictYamlSerializer runtime.Serializer
RequestAuthN authenticator.Request
RequestAuthZ authorizer.Authorizer
}

// NewApp creates a new instance of the app
func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme *runtime.Scheme, reqAuthN authenticator.Request, reqAuthZ authorizer.Authorizer) (*App, error) {

// TODO: log the configuration on startup

// get a serializer for Kubernetes YAML
codecFactory := serializer.NewCodecFactory(scheme)
yamlSerializerInfo, found := runtime.SerializerInfoForMediaType(codecFactory.SupportedMediaTypes(), runtime.ContentTypeYAML)
if !found {
return nil, fmt.Errorf("unable to find Kubernetes serializer for media type: %s", runtime.ContentTypeYAML)
}

app := &App{
Config: cfg,
logger: logger,
repositories: repositories.NewRepositories(cl),
Scheme: scheme,
RequestAuthN: reqAuthN,
RequestAuthZ: reqAuthZ,
Config: cfg,
logger: logger,
repositories: repositories.NewRepositories(cl),
Scheme: scheme,
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
RequestAuthN: reqAuthN,
RequestAuthZ: reqAuthZ,
}
return app, nil
}
Expand Down Expand Up @@ -106,6 +120,7 @@ func (a *App) Routes() http.Handler {
// workspacekinds
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)

// swagger
router.GET(SwaggerPath, a.GetSwaggerHandler)
Expand Down
19 changes: 18 additions & 1 deletion workspaces/backend/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package api

import (
"encoding/json"
"errors"
"fmt"
"mime"
"net/http"
Expand Down Expand Up @@ -46,7 +47,7 @@ func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers htt
w.Header()[key] = value
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", MediaTypeJson)
w.WriteHeader(status)
_, err = w.Write(js)
if err != nil {
Expand All @@ -61,11 +62,21 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(v); err != nil {
// NOTE: we don't wrap this error so we can unpack it in the caller
if a.IsMaxBytesError(err) {
return err
}
return fmt.Errorf("error decoding JSON: %w", err)
}
return nil
}

// IsMaxBytesError checks if the error is an instance of http.MaxBytesError.
func (a *App) IsMaxBytesError(err error) bool {
var maxBytesError *http.MaxBytesError
return errors.As(err, &maxBytesError)
}

// ValidateContentType validates the Content-Type header of the request.
// If this method returns false, the request has been handled and the caller should return immediately.
// If this method returns true, the request has the correct Content-Type.
Expand Down Expand Up @@ -94,3 +105,9 @@ func (a *App) LocationGetWorkspace(namespace, name string) string {
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
return path
}

// LocationGetWorkspaceKind returns the GET location (HTTP path) for a workspace kind resource.
func (a *App) LocationGetWorkspaceKind(name string) string {
path := strings.Replace(WorkspaceKindsByNamePath, ":"+ResourceNamePathParam, name, 1)
return path
}
12 changes: 12 additions & 0 deletions workspaces/backend/api/response_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,18 @@ func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error
a.errorResponse(w, r, httpError)
}

// HTTP:413
func (a *App) requestEntityTooLargeResponse(w http.ResponseWriter, r *http.Request, err error) {
httpError := &HTTPError{
StatusCode: http.StatusRequestEntityTooLarge,
ErrorResponse: ErrorResponse{
Code: strconv.Itoa(http.StatusRequestEntityTooLarge),
Message: err.Error(),
},
}
a.errorResponse(w, r, httpError)
}

// HTTP:415
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
httpError := &HTTPError{
Expand Down
1 change: 1 addition & 0 deletions workspaces/backend/api/suite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ var _ = BeforeSuite(func() {
By("creating the application")
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
Expect(err).NotTo(HaveOccurred())

go func() {
defer GinkgoRecover()
Expand Down
99 changes: 99 additions & 0 deletions workspaces/backend/api/workspacekinds_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ package api

import (
"errors"
"fmt"
"io"
"net/http"

"github.com/julienschmidt/httprouter"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"

"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
Expand All @@ -31,6 +35,9 @@ import (
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
)

// TODO: this should wrap the models.WorkspaceKindUpdate once we implement the update handler
type WorkspaceKindCreateEnvelope Envelope[*models.WorkspaceKind]

type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]

type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
Expand Down Expand Up @@ -123,3 +130,95 @@ func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
a.dataResponse(w, r, responseEnvelope)
}

// CreateWorkspaceKindHandler creates a new workspace kind.
//
// @Summary Create workspace kind
// @Description Creates a new workspace kind.
// @Tags workspacekinds
// @Accept application/yaml
// @Produce json
// @Param body body string true "Kubernetes YAML manifest of a WorkspaceKind"
// @Success 201 {object} WorkspaceKindEnvelope "WorkspaceKind created successfully"
// @Failure 400 {object} ErrorEnvelope "Bad Request."
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create WorkspaceKind."
// @Failure 409 {object} ErrorEnvelope "Conflict. WorkspaceKind with the same name already exists."
// @Failure 413 {object} ErrorEnvelope "Request Entity Too Large. The request body is too large.""
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
// @Failure 422 {object} ErrorEnvelope "Unprocessable Entity. Validation error."
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
// @Router /workspacekinds [post]
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {

// validate the Content-Type header
if success := a.ValidateContentType(w, r, MediaTypeYaml); !success {
return
}

// decode the request body
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
if a.IsMaxBytesError(err) {
a.requestEntityTooLargeResponse(w, r, err)
return
}
a.badRequestResponse(w, r, err)
return
}
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
err = runtime.DecodeInto(a.StrictYamlSerializer, bodyBytes, workspaceKind)
if err != nil {
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
return
}

// validate the workspace kind
// NOTE: we only do basic validation so we know it's safe to send to the Kubernetes API server
// comprehensive validation will be done by Kubernetes
// NOTE: checking the name field is non-empty also verifies that the workspace kind is not nil/empty
var valErrs field.ErrorList
wskNamePath := field.NewPath("metadata", "name")
valErrs = append(valErrs, helper.ValidateFieldIsDNS1123Subdomain(wskNamePath, workspaceKind.Name)...)
if len(valErrs) > 0 {
a.failedValidationResponse(w, r, errMsgRequestBodyInvalid, valErrs, nil)
return
}

// =========================== AUTH ===========================
authPolicies := []*auth.ResourcePolicy{
auth.NewResourcePolicy(
auth.ResourceVerbCreate,
&kubefloworgv1beta1.WorkspaceKind{
ObjectMeta: metav1.ObjectMeta{
Name: workspaceKind.Name,
},
},
),
}
if success := a.requireAuth(w, r, authPolicies); !success {
return
}
// ============================================================

createdWorkspaceKind, err := a.repositories.WorkspaceKind.Create(r.Context(), workspaceKind)
if err != nil {
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
a.conflictResponse(w, r, err)
return
}
if apierrors.IsInvalid(err) {
causes := helper.StatusCausesFromAPIStatus(err)
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
return
}
a.serverErrorResponse(w, r, fmt.Errorf("error creating workspace kind: %w", err))
return
}

// calculate the GET location for the created workspace kind (for the Location header)
location := a.LocationGetWorkspaceKind(createdWorkspaceKind.Name)

responseEnvelope := &WorkspaceKindCreateEnvelope{Data: createdWorkspaceKind}
a.createdResponse(w, r, responseEnvelope, location)
}
Loading