Skip to content

Commit

Permalink
feat(ws): add CRUD operations to backend (#66)
Browse files Browse the repository at this point in the history
* feat(ws): Notebooks 2.0 // Backend // List Workspaces API - II

In this PR:
- FUP for Notebooks 2.0 // Backend // List Workspaces API (#60) review
- Create /api/v1/workspaces to return all workspaces
- Review API endpoints as requested

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* Notebooks 2.0 // Backend // CRUD Workspaces API

In this PR:
- Created handlers and repositories for create, get and delete workspace
- Improved the type of our json response

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* feat(ws): Notebooks 2.0 // Backend // List WorkspaceKinds

This PR builds on top of: #61 and #65

In this PR:
- Created handlers and repositories for get workspacekinds

This PR closes #51

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>

* mathew: fix linting

Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>

---------

Signed-off-by: Eder Ignatowicz <ignatowicz@gmail.com>
Signed-off-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
Co-authored-by: Mathew Wicks <5735406+thesuperzapper@users.noreply.github.com>
  • Loading branch information
ederign and thesuperzapper authored Oct 10, 2024
1 parent f852c16 commit e46633b
Show file tree
Hide file tree
Showing 17 changed files with 1,255 additions and 113 deletions.
40 changes: 40 additions & 0 deletions workspaces/backend/.golangci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
run:
timeout: 5m
allow-parallel-runners: true

issues:
# don't skip warning about doc comments
# don't exclude the default set of lint
exclude-use-default: false
# restore some of the defaults
# (fill in the rest as needed)
exclude-rules:
- path: "api/*"
linters:
- lll
- path: "internal/*"
linters:
- dupl
- lll
linters:
disable-all: true
enable:
- dupl
- errcheck
- exportloopref
- goconst
- gocyclo
- gofmt
- goimports
- gosimple
- govet
- ineffassign
- lll
- misspell
- nakedret
- prealloc
- staticcheck
- typecheck
- unconvert
- unparam
- unused
82 changes: 62 additions & 20 deletions workspaces/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,70 @@ make run PORT=8000
```
### Endpoints

| URL Pattern | Handler | Action |
|------------------------------------------------------|----------------------|-------------------------------|
| GET /v1/healthcheck | HealthcheckHandler | Show application information. |
| GET /v1/spawner/{namespace}/workspaces | GetWorkspacesHandler | Get all Workspaces |
| POST /v1/spawner/{namespace}/workspaces | TBD | Create a Workspace |
| GET /v1/spawner/{namespace}/workspaces/{name} | TBD | Get a Workspace entity |
| PATCH /v1/spawner/{namespace}/workspaces/{name} | TBD | Patch a Workspace entity |
| PUT /v1/spawner/{namespace}/workspaces/{name} | TBD | Update a Workspace entity |
| DELETE /v1/spawner/{namespace}/workspaces/{name} | TBD | Delete a Workspace entity |
| GET /v1/spawner/{namespace}/workspacekinds | TDB | Get all WorkspaceKind |
| POST /v1/spawner/{namespace}/workspacekinds | TDB | Create a WorkspaceKind |
| GET /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Get a WorkspaceKind entity |
| PATCH /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
| PUT /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
| DELETE /v1/spawner/{namespace}/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |
| URL Pattern | Handler | Action |
|----------------------------------------------|------------------------|-----------------------------------------|
| GET /api/v1/healthcheck | healthcheck_handler | Show application information. |
| GET /api/v1/workspaces | workspaces_handler | Get all Workspaces |
| GET /api/v1/workspaces/{namespace} | workspaces_handler | Get all Workspaces from a namespace |
| POST /api/v1/workspaces/{namespace} | workspaces_handler | Create a Workspace in a given namespace |
| GET /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Get a Workspace entity |
| PATCH /api/v1/workspaces/{namespace}/{name} | TBD | Patch a Workspace entity |
| PUT /api/v1/workspaces/{namespace}/{name} | TBD | Update a Workspace entity |
| DELETE /api/v1/workspaces/{namespace}/{name} | workspaces_handler | Delete a Workspace entity |
| GET /api/v1/workspacekinds | workspacekinds_handler | Get all WorkspaceKind |
| POST /api/v1/workspacekinds | TBD | Create a WorkspaceKind |
| GET /api/v1/workspacekinds/{name} | workspacekinds_handler | Get a WorkspaceKind entity |
| PATCH /api/v1/workspacekinds/{name} | TBD | Patch a WorkspaceKind entity |
| PUT /api/v1/workspacekinds/{name} | TBD | Update a WorkspaceKind entity |
| DELETE /api/v1/workspacekinds/{name} | TBD | Delete a WorkspaceKind entity |

### Sample local calls
```
# GET /v1/healthcheck
curl -i localhost:4000/api/v1/healthcheck/
# GET /api/v1/healthcheck
curl -i localhost:4000/api/v1/healthcheck
```
``````
# GET /v1/spawner/{namespace}/workspace
curl -i localhost:4000/api/v1/spawner/{namespace}/workspaces
```
# GET /api/v1/workspaces/
curl -i localhost:4000/api/v1/workspaces
```
```
# GET /api/v1/workspaces/{namespace}
curl -i localhost:4000/api/v1/workspaces/default
```
```
# POST /api/v1/workspaces/{namespace}
curl -X POST http://localhost:4000/api/v1/workspaces/default \
-H "Content-Type: application/json" \
-d '{
"name": "dora",
"paused": false,
"defer_updates": false,
"kind": "jupyterlab",
"image_config": "jupyterlab_scipy_190",
"pod_config": "tiny_cpu",
"home_volume": "workspace-home-bella",
"data_volumes": [
{
"pvc_name": "workspace-data-bella",
"mount_path": "/data/my-data",
"read_only": false
}
]
}'
```
```
# GET /api/v1/workspaces/{namespace}/{name}
curl -i localhost:4000/api/v1/workspaces/default/dora
```
```
# DELETE /api/v1/workspaces/{namespace}/{name}
curl -X DELETE localhost:4000/api/v1/workspaces/workspace-test/dora
```
```
# GET /api/v1/workspacekinds
curl -i localhost:4000/api/v1/workspacekinds
```
```
# GET /api/v1/workspacekinds/{name}
curl -i localhost:4000/api/v1/workspacekinds/jupyterlab
```
35 changes: 28 additions & 7 deletions workspaces/backend/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ limitations under the License.
package api

import (
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
"log/slog"
"net/http"

Expand All @@ -26,14 +25,27 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)

const (
Version = "1.0.0"
HealthCheckPath = "/api/v1/healthcheck/"
NamespacePathParam = "namespace"
PathPrefix = "/api/v1/spawner/:namespace"
WorkspacesPath = PathPrefix + "/workspaces"
Version = "1.0.0"
PathPrefix = "/api/v1"

// healthcheck
HealthCheckPath = PathPrefix + "/healthcheck"

// workspaces
AllWorkspacesPath = PathPrefix + "/workspaces"
NamespacePathParam = "namespace"
WorkspaceNamePathParam = "name"
WorkspacesByNamespacePath = AllWorkspacesPath + "/:" + NamespacePathParam
WorkspacesByNamePath = AllWorkspacesPath + "/:" + NamespacePathParam + "/:" + WorkspaceNamePathParam

// workspacekinds
AllWorkspaceKindsPath = PathPrefix + "/workspacekinds"
WorkspaceKindNamePathParam = "name"
WorkspaceKindsByNamePath = AllWorkspaceKindsPath + "/:" + WorkspaceNamePathParam
)

type App struct {
Expand Down Expand Up @@ -63,7 +75,16 @@ func (a *App) Routes() http.Handler {
router.MethodNotAllowed = http.HandlerFunc(a.methodNotAllowedResponse)

router.GET(HealthCheckPath, a.HealthcheckHandler)
router.GET(WorkspacesPath, a.GetWorkspacesHandler)

router.GET(AllWorkspacesPath, a.GetWorkspacesHandler)
router.GET(WorkspacesByNamespacePath, a.GetWorkspacesHandler)

router.GET(WorkspacesByNamePath, a.GetWorkspaceHandler)
router.POST(WorkspacesByNamespacePath, a.CreateWorkspaceHandler)
router.DELETE(WorkspacesByNamePath, a.DeleteWorkspaceHandler)

router.GET(AllWorkspaceKindsPath, a.GetWorkspaceKindsHandler)
router.GET(WorkspaceKindsByNamePath, a.GetWorkspaceKindHandler)

return a.RecoverPanic(a.enableCORS(router))
}
12 changes: 6 additions & 6 deletions workspaces/backend/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type ErrorResponse struct {
Message string `json:"message"`
}

type ErrorEnvelope struct {
Error *HTTPError `json:"error"`
}

func (a *App) LogError(r *http.Request, err error) {
var (
method = r.Method
Expand All @@ -55,11 +59,9 @@ func (a *App) badRequestResponse(w http.ResponseWriter, r *http.Request, err err
}

func (a *App) errorResponse(w http.ResponseWriter, r *http.Request, error *HTTPError) {

env := Envelope{"error": error}
env := ErrorEnvelope{Error: error}

err := a.WriteJSON(w, error.StatusCode, env, nil)

if err != nil {
a.LogError(r, err)
w.WriteHeader(error.StatusCode)
Expand All @@ -80,7 +82,6 @@ func (a *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err er
}

func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) {

httpError := &HTTPError{
StatusCode: http.StatusNotFound,
ErrorResponse: ErrorResponse{
Expand All @@ -92,7 +93,6 @@ func (a *App) notFoundResponse(w http.ResponseWriter, r *http.Request) {
}

func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {

httpError := &HTTPError{
StatusCode: http.StatusMethodNotAllowed,
ErrorResponse: ErrorResponse{
Expand All @@ -105,11 +105,11 @@ func (a *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {

// nolint:unused
func (a *App) failedValidationResponse(w http.ResponseWriter, r *http.Request, errors map[string]string) {

message, err := json.Marshal(errors)
if err != nil {
message = []byte("{}")
}

httpError := &HTTPError{
StatusCode: http.StatusUnprocessableEntity,
ErrorResponse: ErrorResponse{
Expand Down
7 changes: 3 additions & 4 deletions workspaces/backend/api/healthcheck__handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ package api

import (
"encoding/json"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
"io"
"net/http"
"net/http/httptest"
Expand All @@ -28,6 +26,8 @@ import (
"github.com/stretchr/testify/assert"

"github.com/kubeflow/notebooks/workspaces/backend/internal/config"
"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)

func TestHealthCheckHandler(t *testing.T) {
Expand All @@ -46,8 +46,7 @@ func TestHealthCheckHandler(t *testing.T) {

app.HealthcheckHandler(rr, req, nil)
rs := rr.Result()

defer rs.Body.Close()
defer rs.Body.Close() // nolint: errcheck

body, err := io.ReadAll(rs.Body)
if err != nil {
Expand Down
1 change: 0 additions & 1 deletion workspaces/backend/api/healthcheck_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ func (a *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps http
}

err = a.WriteJSON(w, http.StatusOK, healthCheck, nil)

if err != nil {
a.serverErrorResponse(w, r, err)
}
Expand Down
6 changes: 3 additions & 3 deletions workspaces/backend/api/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ import (
"strings"
)

type Envelope map[string]interface{}
type Envelope[D any] struct {
Data D `json:"data"`
}

func (a *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error {

js, err := json.MarshalIndent(data, "", "\t")

if err != nil {
return err
}
Expand Down Expand Up @@ -60,7 +61,6 @@ func (a *App) ReadJSON(w http.ResponseWriter, r *http.Request, dst any) error {
dec.DisallowUnknownFields()

err := dec.Decode(dst)

if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
Expand Down
78 changes: 78 additions & 0 deletions workspaces/backend/api/workspacekinds_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
*
* Copyright 2024.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* /
*/

package api

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

"github.com/julienschmidt/httprouter"

"github.com/kubeflow/notebooks/workspaces/backend/internal/models"
"github.com/kubeflow/notebooks/workspaces/backend/internal/repositories"
)

type WorkspaceKindsEnvelope Envelope[[]models.WorkspaceKindModel]
type WorkspaceKindEnvelope Envelope[models.WorkspaceKindModel]

func (a *App) GetWorkspaceKindHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
name := ps.ByName("name")

if name == "" {
a.serverErrorResponse(w, r, fmt.Errorf("workspace kind name is missing"))
return
}

workspaceKind, err := a.repositories.WorkspaceKind.GetWorkspaceKind(r.Context(), name)
if err != nil {
if errors.Is(err, repositories.ErrWorkspaceKindNotFound) {
a.notFoundResponse(w, r)
return
}
a.serverErrorResponse(w, r, err)
return
}

workspaceKindEnvelope := WorkspaceKindEnvelope{
Data: workspaceKind,
}

err = a.WriteJSON(w, http.StatusOK, workspaceKindEnvelope, nil)
if err != nil {
a.serverErrorResponse(w, r, err)
}
}

func (a *App) GetWorkspaceKindsHandler(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
workspaceKinds, err := a.repositories.WorkspaceKind.GetWorkspaceKinds(r.Context())
if err != nil {
a.serverErrorResponse(w, r, err)
return
}

workspaceKindsEnvelope := WorkspaceKindsEnvelope{
Data: workspaceKinds,
}

err = a.WriteJSON(w, http.StatusOK, workspaceKindsEnvelope, nil)
if err != nil {
a.serverErrorResponse(w, r, err)
}
}
Loading

0 comments on commit e46633b

Please sign in to comment.