feat(ws): backend api to create wsk with YAML (#434)
* feat(ws): Notebooks 2.0 // Backend // API that allows frontend to upload a YAML file containing a full new WorkspaceKind definition Signed-off-by: Asaad Balum <asaad.balum@gmail.com> * mathew: 1 Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> --------- Signed-off-by: Asaad Balum <asaad.balum@gmail.com> Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com> Co-authored-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
This commit is contained in:
parent
f90ee781ac
commit
d38b24c76c
|
|
@ -17,11 +17,13 @@ limitations under the License.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||||
"k8s.io/apiserver/pkg/authentication/authenticator"
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
||||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
@ -35,6 +37,9 @@ const (
|
||||||
Version = "1.0.0"
|
Version = "1.0.0"
|
||||||
PathPrefix = "/api/v1"
|
PathPrefix = "/api/v1"
|
||||||
|
|
||||||
|
MediaTypeJson = "application/json"
|
||||||
|
MediaTypeYaml = "application/yaml"
|
||||||
|
|
||||||
NamespacePathParam = "namespace"
|
NamespacePathParam = "namespace"
|
||||||
ResourceNamePathParam = "name"
|
ResourceNamePathParam = "name"
|
||||||
|
|
||||||
|
|
@ -63,6 +68,7 @@ type App struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
repositories *repositories.Repositories
|
repositories *repositories.Repositories
|
||||||
Scheme *runtime.Scheme
|
Scheme *runtime.Scheme
|
||||||
|
StrictYamlSerializer runtime.Serializer
|
||||||
RequestAuthN authenticator.Request
|
RequestAuthN authenticator.Request
|
||||||
RequestAuthZ authorizer.Authorizer
|
RequestAuthZ authorizer.Authorizer
|
||||||
}
|
}
|
||||||
|
|
@ -72,11 +78,19 @@ func NewApp(cfg *config.EnvConfig, logger *slog.Logger, cl client.Client, scheme
|
||||||
|
|
||||||
// TODO: log the configuration on startup
|
// 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{
|
app := &App{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
repositories: repositories.NewRepositories(cl),
|
repositories: repositories.NewRepositories(cl),
|
||||||
Scheme: scheme,
|
Scheme: scheme,
|
||||||
|
StrictYamlSerializer: yamlSerializerInfo.StrictSerializer,
|
||||||
RequestAuthN: reqAuthN,
|
RequestAuthN: reqAuthN,
|
||||||
RequestAuthZ: reqAuthZ,
|
RequestAuthZ: reqAuthZ,
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +120,7 @@ func (a *App) Routes() http.Handler {
|
||||||
// workspacekinds
|
// workspacekinds
|
||||||
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
|
router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
|
||||||
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
|
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)
|
||||||
|
router.POST(AllWorkspaceKindsPath, a.CreateWorkspaceKindHandler)
|
||||||
|
|
||||||
// swagger
|
// swagger
|
||||||
router.GET(SwaggerPath, a.GetSwaggerHandler)
|
router.GET(SwaggerPath, a.GetSwaggerHandler)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"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()[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", MediaTypeJson)
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_, err = w.Write(js)
|
_, err = w.Write(js)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -61,11 +62,21 @@ func (a *App) DecodeJSON(r *http.Request, v any) error {
|
||||||
decoder := json.NewDecoder(r.Body)
|
decoder := json.NewDecoder(r.Body)
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
if err := decoder.Decode(v); err != nil {
|
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 fmt.Errorf("error decoding JSON: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// 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 false, the request has been handled and the caller should return immediately.
|
||||||
// If this method returns true, the request has the correct Content-Type.
|
// 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)
|
path = strings.Replace(path, ":"+ResourceNamePathParam, name, 1)
|
||||||
return path
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,18 @@ func (a *App) conflictResponse(w http.ResponseWriter, r *http.Request, err error
|
||||||
a.errorResponse(w, r, httpError)
|
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
|
// HTTP:415
|
||||||
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
|
func (a *App) unsupportedMediaTypeResponse(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
httpError := &HTTPError{
|
httpError := &HTTPError{
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ var _ = BeforeSuite(func() {
|
||||||
By("creating the application")
|
By("creating the application")
|
||||||
// NOTE: we use the `k8sClient` rather than `k8sManager.GetClient()` to avoid race conditions with the cached client
|
// 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)
|
a, err = NewApp(&config.EnvConfig{}, appLogger, k8sClient, k8sManager.GetScheme(), reqAuthN, reqAuthZ)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer GinkgoRecover()
|
defer GinkgoRecover()
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,15 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
|
||||||
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
||||||
|
|
@ -31,6 +35,9 @@ import (
|
||||||
repository "github.com/kubeflow/notebooks/workspaces/backend/internal/repositories/workspacekinds"
|
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 WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
|
||||||
|
|
||||||
type WorkspaceKindEnvelope 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}
|
responseEnvelope := &WorkspaceKindListEnvelope{Data: workspaceKinds}
|
||||||
a.dataResponse(w, r, responseEnvelope)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -31,6 +32,7 @@ import (
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||||
|
|
||||||
models "github.com/kubeflow/notebooks/workspaces/backend/internal/models/workspacekinds"
|
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())
|
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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
|
||||||
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create workspace."
|
// @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 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."
|
// @Failure 500 {object} ErrorEnvelope "Internal server error. An unexpected error occurred on the server."
|
||||||
// @Router /workspaces/{namespace} [post]
|
// @Router /workspaces/{namespace} [post]
|
||||||
func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
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
|
// validate the Content-Type header
|
||||||
if success := a.ValidateContentType(w, r, "application/json"); !success {
|
if success := a.ValidateContentType(w, r, MediaTypeJson); !success {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,6 +200,10 @@ func (a *App) CreateWorkspaceHandler(w http.ResponseWriter, r *http.Request, ps
|
||||||
bodyEnvelope := &WorkspaceCreateEnvelope{}
|
bodyEnvelope := &WorkspaceCreateEnvelope{}
|
||||||
err := a.DecodeJSON(r, bodyEnvelope)
|
err := a.DecodeJSON(r, bodyEnvelope)
|
||||||
if err != nil {
|
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))
|
a.badRequestResponse(w, r, fmt.Errorf("error decoding request body: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -729,7 +729,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
||||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", MediaTypeJson)
|
||||||
|
|
||||||
By("setting the auth headers")
|
By("setting the auth headers")
|
||||||
req.Header.Set(userIdHeader, adminUser)
|
req.Header.Set(userIdHeader, adminUser)
|
||||||
|
|
@ -845,7 +845,7 @@ var _ = Describe("Workspaces Handler", func() {
|
||||||
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
path := strings.Replace(WorkspacesByNamespacePath, ":"+NamespacePathParam, namespaceNameCrud, 1)
|
||||||
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(string(bodyEnvelopeJSON)))
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", MediaTypeJson)
|
||||||
req.Header.Set(userIdHeader, adminUser)
|
req.Header.Set(userIdHeader, adminUser)
|
||||||
|
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
|
var ErrWorkspaceKindNotFound = errors.New("workspace kind not found")
|
||||||
|
var ErrWorkspaceKindAlreadyExists = errors.New("workspacekind already exists")
|
||||||
|
|
||||||
type WorkspaceKindRepository struct {
|
type WorkspaceKindRepository struct {
|
||||||
client client.Client
|
client client.Client
|
||||||
|
|
@ -68,3 +69,26 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
|
||||||
|
|
||||||
return workspaceKindsModels, nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}": {
|
"/workspacekinds/{name}": {
|
||||||
|
|
@ -352,6 +432,18 @@ const docTemplate = `{
|
||||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
"$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": {
|
"500": {
|
||||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
|
||||||
|
|
@ -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}": {
|
"/workspacekinds/{name}": {
|
||||||
|
|
@ -350,6 +430,18 @@
|
||||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
"$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": {
|
"500": {
|
||||||
"description": "Internal server error. An unexpected error occurred on the server.",
|
"description": "Internal server error. An unexpected error occurred on the server.",
|
||||||
"schema": {
|
"schema": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue