diff --git a/workspaces/backend/api/app.go b/workspaces/backend/api/app.go index ef3fe7db..2f76c252 100644 --- a/workspaces/backend/api/app.go +++ b/workspaces/backend/api/app.go @@ -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" @@ -35,6 +37,9 @@ const ( Version = "1.0.0" PathPrefix = "/api/v1" + MediaTypeJson = "application/json" + MediaTypeYaml = "application/yaml" + NamespacePathParam = "namespace" ResourceNamePathParam = "name" @@ -59,12 +64,13 @@ 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 @@ -72,13 +78,21 @@ func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme // 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 } @@ -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) diff --git a/workspaces/backend/api/helpers.go b/workspaces/backend/api/helpers.go index 0076a060..7583ea70 100644 --- a/workspaces/backend/api/helpers.go +++ b/workspaces/backend/api/helpers.go @@ -18,6 +18,7 @@ package api import ( "encoding/json" + "errors" "fmt" "mime" "net/http" @@ -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 { @@ -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. @@ -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 +} diff --git a/workspaces/backend/api/response_errors.go b/workspaces/backend/api/response_errors.go index cf7e1dd9..b9aadba3 100644 --- a/workspaces/backend/api/response_errors.go +++ b/workspaces/backend/api/response_errors.go @@ -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{ diff --git a/workspaces/backend/api/suite_test.go b/workspaces/backend/api/suite_test.go index ad3e518a..ce03bdad 100644 --- a/workspaces/backend/api/suite_test.go +++ b/workspaces/backend/api/suite_test.go @@ -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() diff --git a/workspaces/backend/api/workspacekinds_handler.go b/workspaces/backend/api/workspacekinds_handler.go index c8e0b6af..85bd5a62 100644 --- a/workspaces/backend/api/workspacekinds_handler.go +++ b/workspaces/backend/api/workspacekinds_handler.go @@ -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" @@ -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] @@ -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) +} diff --git a/workspaces/backend/api/workspacekinds_handler_test.go b/workspaces/backend/api/workspacekinds_handler_test.go index f37c58c9..19853676 100644 --- a/workspaces/backend/api/workspacekinds_handler_test.go +++ b/workspaces/backend/api/workspacekinds_handler_test.go @@ -17,6 +17,7 @@ limitations under the License. package api import ( + "bytes" "encoding/json" "fmt" "io" @@ -31,6 +32,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds" ) @@ -254,4 +256,255 @@ var _ = Describe("WorkspaceKinds Handler", func() { Expect(rs.StatusCode).To(Equal(http.StatusNotFound), descUnexpectedHTTPStatus, rr.Body.String()) }) }) + + // NOTE: these tests create and delete resources on the cluster, so cannot be run in parallel. + // therefore, we run them using the `Serial` Ginkgo decorator. + Context("when creating a WorkspaceKind", Serial, func() { + + var newWorkspaceKindName = "wsk-create-test" + var validYAML []byte + + BeforeEach(func() { + validYAML = []byte(fmt.Sprintf(` +apiVersion: kubeflow.org/v1beta1 +kind: WorkspaceKind +metadata: + name: %s +spec: + spawner: + displayName: "JupyterLab Notebook" + description: "A Workspace which runs JupyterLab in a Pod" + icon: + url: "https://jupyter.org/assets/favicons/apple-touch-icon-152x152.png" + logo: + url: "https://upload.wikimedia.org/wikipedia/commons/3/38/Jupyter_logo.svg" + podTemplate: + serviceAccount: + name: "default-editor" + volumeMounts: + home: "/home/jovyan" + options: + imageConfig: + spawner: + default: "jupyterlab_scipy_190" + values: + - id: "jupyterlab_scipy_190" + spawner: + displayName: "jupyter-scipy:v1.9.0" + description: "JupyterLab, with SciPy Packages" + spec: + image: "ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0" + imagePullPolicy: "IfNotPresent" + ports: + - id: "jupyterlab" + displayName: "JupyterLab" + port: 8888 + protocol: "HTTP" + podConfig: + spawner: + default: "tiny_cpu" + values: + - id: "tiny_cpu" + spawner: + displayName: "Tiny CPU" + description: "Pod with 0.1 CPU, 128 Mb RAM" + spec: + resources: + requests: + cpu: 100m + memory: 128Mi +`, newWorkspaceKindName)) + }) + + AfterEach(func() { + By("cleaning up the created WorkspaceKind") + wsk := &kubefloworgv1beta1.WorkspaceKind{ + ObjectMeta: metav1.ObjectMeta{ + Name: newWorkspaceKindName, + }, + } + _ = k8sClient.Delete(ctx, wsk) + }) + + It("should succeed when creating a WorkspaceKind with valid YAML", func() { + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr.Body.String()) + + By("verifying the resource was created in the cluster") + createdWsk := &kubefloworgv1beta1.WorkspaceKind{} + err = k8sClient.Get(ctx, types.NamespacedName{Name: newWorkspaceKindName}, createdWsk) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should fail to create a WorkspaceKind with no name in the YAML", func() { + missingNameYAML := []byte(` +apiVersion: kubeflow.org/v1beta1 +kind: WorkspaceKind +metadata: {} +spec: + spawner: + displayName: "This will fail"`) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(missingNameYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String()) + + By("decoding the error response") + var response ErrorEnvelope + err = json.Unmarshal(rr.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the error message indicates a validation failure") + Expect(response.Error.Cause.ValidationErrors).To(BeComparableTo( + []ValidationError{ + { + Type: field.ErrorTypeRequired, + Field: "metadata.name", + Message: field.ErrorTypeRequired.String(), + }, + }, + )) + }) + + It("should fail to create a WorkspaceKind that already exists", func() { + By("creating the HTTP request") + req1, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + req1.Header.Set("Content-Type", MediaTypeYaml) + req1.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler for the first time") + rr1 := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{}) + rs1 := rr1.Result() + defer rs1.Body.Close() + + By("verifying the HTTP response status code for the first request") + Expect(rs1.StatusCode).To(Equal(http.StatusCreated), descUnexpectedHTTPStatus, rr1.Body.String()) + + By("creating a second HTTP request") + req2, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML)) + Expect(err).NotTo(HaveOccurred()) + req2.Header.Set("Content-Type", MediaTypeYaml) + req2.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler for the second time") + rr2 := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{}) + rs2 := rr2.Result() + defer rs2.Body.Close() + + By("verifying the HTTP response status code for the second request") + Expect(rs2.StatusCode).To(Equal(http.StatusConflict), descUnexpectedHTTPStatus, rr1.Body.String()) + }) + + It("should fail when the YAML has the wrong kind", func() { + wrongKindYAML := []byte(` +apiVersion: v1 +kind: Pod +metadata: + name: i-am-the-wrong-kind`) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String()) + Expect(rr.Body.String()).To(ContainSubstring("unable to decode /v1, Kind=Pod into *v1beta1.WorkspaceKind")) + }) + + It("should fail when the body is not valid YAML", func() { + notYAML := []byte(`this is not yaml {`) + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(notYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing CreateWorkspaceKindHandler") + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusBadRequest), descUnexpectedHTTPStatus, rr.Body.String()) + + By("decoding the error response") + var response ErrorEnvelope + err = json.Unmarshal(rr.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the error message indicates a decoding failure") + Expect(response.Error.Message).To(ContainSubstring("error decoding request body: couldn't get version/kind; json parse error")) + }) + + It("should fail for an empty YAML object", func() { + invalidYAML := []byte("{}") + + By("creating the HTTP request") + req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML)) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Content-Type", MediaTypeYaml) + req.Header.Set(userIdHeader, adminUser) + + By("executing the CreateWorkspaceKindHandler") + rr := httptest.NewRecorder() + a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{}) + rs := rr.Result() + defer rs.Body.Close() + + By("verifying the HTTP response status code") + Expect(rs.StatusCode).To(Equal(http.StatusUnprocessableEntity), descUnexpectedHTTPStatus, rr.Body.String()) + + By("decoding the error response") + var response ErrorEnvelope + err = json.Unmarshal(rr.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the error message indicates a validation failure") + Expect(response.Error.Cause.ValidationErrors).To(BeComparableTo( + []ValidationError{ + { + Type: field.ErrorTypeRequired, + Field: "metadata.name", + Message: field.ErrorTypeRequired.String(), + }, + }, + )) + }) + }) }) diff --git a/workspaces/backend/api/workspaces_handler.go b/workspaces/backend/api/workspaces_handler.go index 2cf909fd..97354b12 100644 --- a/workspaces/backend/api/workspaces_handler.go +++ b/workspaces/backend/api/workspaces_handler.go @@ -176,6 +176,8 @@ func (a *App) GetWorkspacesHandler(w http.ResponseWriter, r *http.Request, ps ht // @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required." // @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace." // @Failure 409 {object} ErrorEnvelope "Conflict. Workspace 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 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server." // @Router /workspaces/{namespace} [post] func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { @@ -190,7 +192,7 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps } // validate the Content-Type header - if success := a.ValidateContentType(w, r, "application/json"); !success { + if success := a.ValidateContentType(w, r, MediaTypeJson); !success { return } @@ -198,6 +200,10 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps bodyEnvelope := &WorkspaceCreateEnvelope{} err := a.DecodeJSON(r, bodyEnvelope) if err != nil { + if a.IsMaxBytesError(err) { + a.requestEntityTooLargeResponse(w, r, err) + return + } a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err)) return } diff --git a/workspaces/backend/api/workspaces_handler_test.go b/workspaces/backend/api/workspaces_handler_test.go index 179b91b8..10f1b445 100644 --- a/workspaces/backend/api/workspaces_handler_test.go +++ b/workspaces/backend/api/workspaces_handler_test.go @@ -729,7 +729,7 @@ var _ = Describe("Workspaces Handler", func() { path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1) req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON))) Expect(err).NotTo(HaveOccurred()) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", MediaTypeJson) By("setting the auth headers") req.Header.Set(userIdHeader, adminUser) @@ -845,7 +845,7 @@ var _ = Describe("Workspaces Handler", func() { path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1) req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON))) Expect(err).NotTo(HaveOccurred()) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", MediaTypeJson) req.Header.Set(userIdHeader, adminUser) rr := httptest.NewRecorder() diff --git a/workspaces/backend/internal/repositories/workspacekinds/repo.go b/workspaces/backend/internal/repositories/workspacekinds/repo.go index 3796e297..02cd208b 100644 --- a/workspaces/backend/internal/repositories/workspacekinds/repo.go +++ b/workspaces/backend/internal/repositories/workspacekinds/repo.go @@ -28,6 +28,7 @@ import ( ) var ErrWorkspaceKindNotFound = errors.New("workspace kind not found") +var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists") type WorkspaceKindRepository struct { client client.Client @@ -68,3 +69,26 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode return workspaceKindsModels, nil } + +func (r *WorkspaceKindRepository) Create(ctx context.Context, workspaceKind *kubefloworgv1beta1.WorkspaceKind) (*models.WorkspaceKind, error) { + // create workspace kind + if err := r.client.Create(ctx, workspaceKind); err != nil { + if apierrors.IsAlreadyExists(err) { + return nil, ErrWorkspaceKindAlreadyExists + } + if apierrors.IsInvalid(err) { + // NOTE: we don't wrap this error so we can unpack it in the caller + // and extract the validation errors returned by the Kubernetes API server + return nil, err + } + return nil, err + } + + // convert the created workspace to a WorkspaceKindUpdate model + // + // TODO: this function should return the WorkspaceKindUpdate model, once the update WSK api is implemented + // + createdWorkspaceKindModel := models.NewWorkspaceKindModelFromWorkspaceKind(workspaceKind) + + return &createdWorkspaceKindModel, nil +} diff --git a/workspaces/backend/openapi/docs.go b/workspaces/backend/openapi/docs.go index e0fc73e4..ba09bcc6 100644 --- a/workspaces/backend/openapi/docs.go +++ b/workspaces/backend/openapi/docs.go @@ -122,6 +122,86 @@ const docTemplate = `{ } } } + }, + "post": { + "description": "Creates a new workspace kind.", + "consumes": [ + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Create workspace kind", + "parameters": [ + { + "description": "Kubernetes YAML manifest of a WorkspaceKind", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "WorkspaceKind created successfully", + "schema": { + "$ref": "#/definitions/api.WorkspaceKindEnvelope" + } + }, + "400": { + "description": "Bad Request.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to create WorkspaceKind.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict. WorkspaceKind with the same name already exists.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error. An unexpected error occurred on the server.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } } }, "/workspacekinds/{name}": { @@ -352,6 +432,18 @@ const docTemplate = `{ "$ref": "#/definitions/api.ErrorEnvelope" } }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, "500": { "description": "Internal server error. An unexpected error occurred on the server.", "schema": { diff --git a/workspaces/backend/openapi/swagger.json b/workspaces/backend/openapi/swagger.json index 4f9fdeff..31c2b554 100644 --- a/workspaces/backend/openapi/swagger.json +++ b/workspaces/backend/openapi/swagger.json @@ -120,6 +120,86 @@ } } } + }, + "post": { + "description": "Creates a new workspace kind.", + "consumes": [ + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "workspacekinds" + ], + "summary": "Create workspace kind", + "parameters": [ + { + "description": "Kubernetes YAML manifest of a WorkspaceKind", + "name": "body", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "WorkspaceKind created successfully", + "schema": { + "$ref": "#/definitions/api.WorkspaceKindEnvelope" + } + }, + "400": { + "description": "Bad Request.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized. Authentication is required.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "403": { + "description": "Forbidden. User does not have permission to create WorkspaceKind.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "409": { + "description": "Conflict. WorkspaceKind with the same name already exists.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "422": { + "description": "Unprocessable Entity. Validation error.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "500": { + "description": "Internal server error. An unexpected error occurred on the server.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + } + } } }, "/workspacekinds/{name}": { @@ -350,6 +430,18 @@ "$ref": "#/definitions/api.ErrorEnvelope" } }, + "413": { + "description": "Request Entity Too Large. The request body is too large.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, + "415": { + "description": "Unsupported Media Type. Content-Type header is not correct.", + "schema": { + "$ref": "#/definitions/api.ErrorEnvelope" + } + }, "500": { "description": "Internal server error. An unexpected error occurred on the server.", "schema": {