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>
This commit is contained in:
parent
981527855a
commit
175e5a5841
|
|
@ -56,6 +56,9 @@ const (
|
|||
// swagger
|
||||
SwaggerPath = PathPrefix + "/swagger/*any"
|
||||
SwaggerDocPath = PathPrefix + "/swagger/doc.json"
|
||||
|
||||
// YAML manifest content type
|
||||
ContentTypeYAMLManifest = "application/vnd.kubeflow-notebooks.manifest+yaml"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
|
|
@ -106,6 +109,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)
|
||||
|
|
|
|||
|
|
@ -94,3 +94,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,17 @@ 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/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/runtime/serializer"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
|
||||
"github.com/kubeflow/notebooks/workspaces/backend/internal/auth"
|
||||
|
|
@ -35,6 +41,74 @@ type WorkspaceKindListEnvelope Envelope[[]models.WorkspaceKind]
|
|||
|
||||
type WorkspaceKindEnvelope Envelope[models.WorkspaceKind]
|
||||
|
||||
// cachedRestrictedScheme holds the pre-built runtime scheme that only knows about WorkspaceKind.
|
||||
var cachedRestrictedScheme *runtime.Scheme
|
||||
|
||||
// cachedUniversalDeserializer holds the pre-built universal deserializer for the restricted scheme.
|
||||
var cachedUniversalDeserializer runtime.Decoder
|
||||
|
||||
// cachedExpectedGVK holds the expected GVK for WorkspaceKind, derived programmatically.
|
||||
var cachedExpectedGVK schema.GroupVersionKind
|
||||
|
||||
// init builds the restricted scheme and deserializer once at package initialization time.
|
||||
func init() {
|
||||
restrictedScheme := runtime.NewScheme()
|
||||
if err := kubefloworgv1beta1.AddToScheme(restrictedScheme); err != nil {
|
||||
panic(fmt.Sprintf("failed to add WorkspaceKind types to restricted scheme: %v", err))
|
||||
}
|
||||
cachedRestrictedScheme = restrictedScheme
|
||||
|
||||
codecs := serializer.NewCodecFactory(cachedRestrictedScheme)
|
||||
cachedUniversalDeserializer = codecs.UniversalDeserializer()
|
||||
|
||||
workspaceKind := &kubefloworgv1beta1.WorkspaceKind{}
|
||||
gvks, _, err := cachedRestrictedScheme.ObjectKinds(workspaceKind)
|
||||
if err != nil || len(gvks) == 0 {
|
||||
panic(fmt.Sprintf("failed to derive GVK from WorkspaceKind type: %v", err))
|
||||
}
|
||||
cachedExpectedGVK = gvks[0]
|
||||
}
|
||||
|
||||
// ParseWorkspaceKindManifestBody reads and decodes a YAML request body into a WorkspaceKind object
|
||||
// using Kubernetes runtime validation to ensure maximum security.
|
||||
func (a *App) ParseWorkspaceKindManifestBody(w http.ResponseWriter, r *http.Request) (*kubefloworgv1beta1.WorkspaceKind, bool) {
|
||||
// NOTE: A server-level middleware should enforce a max body size.
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
a.badRequestResponse(w, r, fmt.Errorf("failed to read request body: %w", err))
|
||||
return nil, false
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Body.Close(); err != nil {
|
||||
a.LogWarn(r, fmt.Sprintf("failed to close request body: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
if len(body) == 0 {
|
||||
a.badRequestResponse(w, r, errors.New("request body is empty"))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
obj, gvk, err := cachedUniversalDeserializer.Decode(body, nil, nil)
|
||||
if err != nil {
|
||||
a.badRequestResponse(w, r, fmt.Errorf("failed to decode YAML manifest: %w", err))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if gvk.Kind != cachedExpectedGVK.Kind || gvk.Version != cachedExpectedGVK.Version {
|
||||
a.badRequestResponse(w, r, fmt.Errorf("invalid GVK: expected %s, got %s", cachedExpectedGVK.Kind, gvk.Kind))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
workspaceKind, ok := obj.(*kubefloworgv1beta1.WorkspaceKind)
|
||||
if !ok {
|
||||
a.badRequestResponse(w, r, fmt.Errorf("unexpected type: got %T, want *WorkspaceKind", obj))
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return workspaceKind, true
|
||||
}
|
||||
|
||||
// GetWorkspaceKindHandler retrieves a specific workspace kind by name.
|
||||
//
|
||||
// @Summary Get workspace kind
|
||||
|
|
@ -123,3 +197,73 @@ 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 from a YAML manifest.
|
||||
|
||||
// @Summary Create workspace kind
|
||||
// @Description Creates a new workspace kind from a raw YAML manifest.
|
||||
// @Tags workspacekinds
|
||||
// @Accept application/vnd.kubeflow-notebooks.manifest+yaml
|
||||
// @Produce json
|
||||
// @Param body body string true "Raw YAML manifest of the WorkspaceKind"
|
||||
// @Success 201 {object} WorkspaceKindEnvelope "Successful creation. Returns the newly created workspace kind details."
|
||||
// @Failure 400 {object} ErrorEnvelope "Bad Request. The YAML is invalid or a required field is missing."
|
||||
// @Failure 401 {object} ErrorEnvelope "Unauthorized. Authentication is required."
|
||||
// @Failure 403 {object} ErrorEnvelope "Forbidden. User does not have permission to create the workspace kind."
|
||||
// @Failure 409 {object} ErrorEnvelope "Conflict. A WorkspaceKind with the same name already exists."
|
||||
// @Failure 415 {object} ErrorEnvelope "Unsupported Media Type. Content-Type header is not correct."
|
||||
// @Failure 500 {object} ErrorEnvelope "Internal server error."
|
||||
// @Router /workspacekinds [post]
|
||||
func (a *App) CreateWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
// === Content-Type check ===
|
||||
if ok := a.ValidateContentType(w, r, ContentTypeYAMLManifest); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// === Read body and parse with secure parser ===
|
||||
newWsk, ok := a.ParseWorkspaceKindManifestBody(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// === Validate name exists in YAML ===
|
||||
if newWsk.Name == "" {
|
||||
a.badRequestResponse(w, r, errors.New("'.metadata.name' is a required field in the YAML manifest"))
|
||||
return
|
||||
}
|
||||
|
||||
// === AUTH ===
|
||||
authPolicies := []*auth.ResourcePolicy{
|
||||
auth.NewResourcePolicy(
|
||||
auth.ResourceVerbCreate,
|
||||
&kubefloworgv1beta1.WorkspaceKind{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: newWsk.Name},
|
||||
},
|
||||
),
|
||||
}
|
||||
if success := a.requireAuth(w, r, authPolicies); !success {
|
||||
return
|
||||
}
|
||||
|
||||
// === Create ===
|
||||
createdModel, err := a.repositories.WorkspaceKind.Create(r.Context(), newWsk)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrWorkspaceKindAlreadyExists) {
|
||||
a.conflictResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
// This handles validation errors from the K8s API Server (webhook)
|
||||
if apierrors.IsInvalid(err) {
|
||||
causes := helper.StatusCausesFromAPIStatus(err)
|
||||
a.failedValidationResponse(w, r, errMsgKubernetesValidation, nil, causes)
|
||||
return
|
||||
}
|
||||
a.serverErrorResponse(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
// === Return created object in envelope ===
|
||||
location := a.LocationGetWorkspaceKind(createdModel.Name)
|
||||
responseEnvelope := &WorkspaceKindEnvelope{Data: createdModel}
|
||||
a.createdResponse(w, r, responseEnvelope, location)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -195,8 +196,6 @@ var _ = Describe("WorkspaceKinds Handler", func() {
|
|||
})
|
||||
})
|
||||
|
||||
// NOTE: these tests assume a specific state of the cluster, so cannot be run in parallel with other tests.
|
||||
// therefore, we run them using the `Serial` Ginkgo decorators.
|
||||
Context("with no existing WorkspaceKinds", Serial, func() {
|
||||
|
||||
It("should return an empty list of WorkspaceKinds", func() {
|
||||
|
|
@ -254,4 +253,143 @@ 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 runs before each "It" block, ensuring the validYAML is always available.
|
||||
BeforeEach(func() {
|
||||
validYAML = []byte(fmt.Sprintf(`
|
||||
apiVersion: kubeflow.org/v1beta1
|
||||
kind: WorkspaceKind
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
spawner:
|
||||
displayName: "Test Jupyter Environment"
|
||||
description: "A valid description for testing."
|
||||
icon:
|
||||
url: "https://example.com/icon.png"
|
||||
logo:
|
||||
url: "https://example.com/logo.svg"
|
||||
podTemplate:
|
||||
options:
|
||||
imageConfig:
|
||||
spawner:
|
||||
default: "default-image"
|
||||
values:
|
||||
- id: "default-image"
|
||||
name: "Jupyter Scipy"
|
||||
path: "kubeflownotebooks/jupyter-scipy:v1.9.0"
|
||||
spawner:
|
||||
displayName: "Jupyter with SciPy v1.9.0"
|
||||
spec:
|
||||
image: "kubeflownotebooks/jupyter-scipy:v1.9.0"
|
||||
ports:
|
||||
- id: "notebook-port"
|
||||
displayName: "Notebook Port"
|
||||
port: 8888
|
||||
protocol: "HTTP"
|
||||
podConfig:
|
||||
spawner:
|
||||
default: "default-pod-config"
|
||||
values:
|
||||
- id: "default-pod-config"
|
||||
name: "Default Resources"
|
||||
spawner:
|
||||
displayName: "Small CPU/RAM"
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: "2Gi"
|
||||
volumeMounts:
|
||||
home: "/home/jovyan"
|
||||
`, 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 new WorkspaceKind with valid YAML", func() {
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusCreated), "Body: %s", rr.Body.String())
|
||||
})
|
||||
|
||||
It("should return a 409 Conflict when creating a WorkspaceKind that already exists", func() {
|
||||
By("creating the resource once successfully")
|
||||
req1, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||
req1.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||
req1.Header.Set(userIdHeader, adminUser)
|
||||
rr1 := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr1, req1, httprouter.Params{})
|
||||
Expect(rr1.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
By("attempting to create the exact same resource a second time")
|
||||
req2, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(validYAML))
|
||||
req2.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||
req2.Header.Set(userIdHeader, adminUser)
|
||||
rr2 := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr2, req2, httprouter.Params{})
|
||||
|
||||
Expect(rr2.Code).To(Equal(http.StatusConflict))
|
||||
})
|
||||
|
||||
It("should fail with 400 Bad Request when the YAML has the wrong kind", func() {
|
||||
wrongKindYAML := []byte(`apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: i-am-the-wrong-kind`)
|
||||
req, _ := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(wrongKindYAML))
|
||||
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
|
||||
Expect(rr.Code).To(Equal(http.StatusBadRequest))
|
||||
// UPDATED: Check for the new, more specific error message
|
||||
Expect(rr.Body.String()).To(ContainSubstring("no kind \\\"Pod\\\" is registered"))
|
||||
})
|
||||
|
||||
It("should fail with 400 Bad Request for an empty YAML object", func() {
|
||||
invalidYAML := []byte("{}")
|
||||
req, err := http.NewRequest(http.MethodPost, AllWorkspaceKindsPath, bytes.NewReader(invalidYAML))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
req.Header.Set("Content-Type", ContentTypeYAMLManifest)
|
||||
req.Header.Set(userIdHeader, adminUser)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
a.CreateWorkspaceKindHandler(rr, req, httprouter.Params{})
|
||||
rs := rr.Result()
|
||||
defer rs.Body.Close()
|
||||
|
||||
Expect(rs.StatusCode).To(Equal(http.StatusBadRequest))
|
||||
body, _ := io.ReadAll(rs.Body)
|
||||
// UPDATED: Check for the new, more specific error message from the secure parser
|
||||
Expect(string(body)).To(ContainSubstring("failed to decode YAML manifest"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,21 @@ func (r *WorkspaceKindRepository) GetWorkspaceKinds(ctx context.Context) ([]mode
|
|||
|
||||
return workspaceKindsModels, nil
|
||||
}
|
||||
|
||||
func (r *WorkspaceKindRepository) Create(ctx context.Context, wk *kubefloworgv1beta1.WorkspaceKind) (models.WorkspaceKind, error) {
|
||||
if err := r.client.Create(ctx, wk); err != nil {
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
return models.WorkspaceKind{}, ErrWorkspaceKindAlreadyExists
|
||||
}
|
||||
if apierrors.IsInvalid(err) {
|
||||
// NOTE: we don't wrap this error so we can unpack it in the caller
|
||||
return models.WorkspaceKind{}, err
|
||||
}
|
||||
return models.WorkspaceKind{}, err
|
||||
}
|
||||
|
||||
// Convert the created k8s object to our backend model before returning
|
||||
createdModel := models.NewWorkspaceKindModelFromWorkspaceKind(wk)
|
||||
|
||||
return createdModel, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,74 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new workspace kind from a raw YAML manifest.",
|
||||
"consumes": [
|
||||
"application/vnd.kubeflow-notebooks.manifest+yaml"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"workspacekinds"
|
||||
],
|
||||
"summary": "Create workspace kind",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Raw YAML manifest of the WorkspaceKind",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Successful creation. Returns the newly created workspace kind details.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request. The YAML is invalid or a required field is missing.",
|
||||
"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 the workspace kind.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict. A WorkspaceKind with the same name already exists.",
|
||||
"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.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspacekinds/{name}": {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,74 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"description": "Creates a new workspace kind from a raw YAML manifest.",
|
||||
"consumes": [
|
||||
"application/vnd.kubeflow-notebooks.manifest+yaml"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"workspacekinds"
|
||||
],
|
||||
"summary": "Create workspace kind",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Raw YAML manifest of the WorkspaceKind",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Successful creation. Returns the newly created workspace kind details.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.WorkspaceKindEnvelope"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request. The YAML is invalid or a required field is missing.",
|
||||
"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 the workspace kind.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict. A WorkspaceKind with the same name already exists.",
|
||||
"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.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/api.ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspacekinds/{name}": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue