From cafc660da92fc3a9c467911d38f2eac348b7454d Mon Sep 17 00:00:00 2001 From: Eder Ignatowicz Date: Wed, 29 May 2024 10:46:20 -0400 Subject: [PATCH] feat: Initial commit for UI #108 In this commit: - basic Dockerfile - basic Makefile - Scaffold of App and first sample endpoint (http://localhost:4000/api/v1/healthcheck/) - REST API basic infrastructure and error handling Signed-off-by: Eder Ignatowicz --- clients/ui/bff/.gitignore | 1 + clients/ui/bff/Dockerfile | 36 ++++++ clients/ui/bff/Makefile | 33 ++++++ clients/ui/bff/README.md | 23 ++++ clients/ui/bff/api/app.go | 41 +++++++ clients/ui/bff/api/errors.go | 103 ++++++++++++++++++ .../ui/bff/api/healthcheck__handler_test.go | 50 +++++++++ clients/ui/bff/api/healthcheck_handler.go | 22 ++++ clients/ui/bff/api/helpers.go | 87 +++++++++++++++ clients/ui/bff/api/middleware.go | 29 +++++ clients/ui/bff/cmd/main.go | 47 ++++++++ clients/ui/bff/config/environment.go | 5 + clients/ui/bff/data/health_check_model.go | 22 ++++ clients/ui/bff/data/models.go | 12 ++ clients/ui/bff/go.mod | 14 +++ clients/ui/bff/go.sum | 11 ++ clients/ui/frontend/.gitkeep | 0 17 files changed, 536 insertions(+) create mode 100644 clients/ui/bff/.gitignore create mode 100644 clients/ui/bff/Dockerfile create mode 100644 clients/ui/bff/Makefile create mode 100644 clients/ui/bff/README.md create mode 100644 clients/ui/bff/api/app.go create mode 100644 clients/ui/bff/api/errors.go create mode 100644 clients/ui/bff/api/healthcheck__handler_test.go create mode 100644 clients/ui/bff/api/healthcheck_handler.go create mode 100644 clients/ui/bff/api/helpers.go create mode 100644 clients/ui/bff/api/middleware.go create mode 100644 clients/ui/bff/cmd/main.go create mode 100644 clients/ui/bff/config/environment.go create mode 100644 clients/ui/bff/data/health_check_model.go create mode 100644 clients/ui/bff/data/models.go create mode 100644 clients/ui/bff/go.mod create mode 100644 clients/ui/bff/go.sum create mode 100644 clients/ui/frontend/.gitkeep diff --git a/clients/ui/bff/.gitignore b/clients/ui/bff/.gitignore new file mode 100644 index 00000000..5e56e040 --- /dev/null +++ b/clients/ui/bff/.gitignore @@ -0,0 +1 @@ +/bin diff --git a/clients/ui/bff/Dockerfile b/clients/ui/bff/Dockerfile new file mode 100644 index 00000000..4a8ced09 --- /dev/null +++ b/clients/ui/bff/Dockerfile @@ -0,0 +1,36 @@ +# Use the golang image to build the application +FROM golang:1.22.2 AS builder +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /ui + +# Copy the Go Modules manifests +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy the go source files +COPY cmd/ cmd/ +COPY api/ api/ +COPY config/ config/ +COPY data/ data/ + +# Build the Go application +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o bff ./cmd/main.go + +# Use distroless as minimal base image to package the application binary +FROM gcr.io/distroless/static:nonroot +WORKDIR / +COPY --from=builder ui/bff ./ +USER 65532:65532 + +# Expose port 4000 +EXPOSE 4000 + +# Define environment variables +ENV PORT 4001 +ENV ENV development + +ENTRYPOINT ["/bff"] diff --git a/clients/ui/bff/Makefile b/clients/ui/bff/Makefile new file mode 100644 index 00000000..1f60e8ee --- /dev/null +++ b/clients/ui/bff/Makefile @@ -0,0 +1,33 @@ +CONTAINER_TOOL ?= docker +IMG ?= model-registry-bff:latest + +.PHONY: all +all: build + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +.PHONY: fmt +fmt: + go fmt ./... + +.PHONY: vet +vet: . + go vet ./... + +.PHONY: test +test: + go test ./... + +.PHONY: build +build: fmt vet test + go build -o bin/bff cmd/main.go + +.PHONY: run +run: fmt vet + PORT=4000 go run ./cmd/main.go + +.PHONY: docker-build +docker-build: + $(CONTAINER_TOOL) build -t ${IMG} . \ No newline at end of file diff --git a/clients/ui/bff/README.md b/clients/ui/bff/README.md new file mode 100644 index 00000000..e7f96d8a --- /dev/null +++ b/clients/ui/bff/README.md @@ -0,0 +1,23 @@ +# Kubeflow Model Registry UI BFF +The Kubeflow Model Registry UI BFF is the _backend for frontend_ (BFF) used by the Kubeflow Model Registry UI. + +# Building and Deploying +TBD + +# Development +TBD + +## Getting started + +### Endpoints + +| URL Pattern | Handler | Action | +|---------------------|--------------------|-------------------------------| +| GET /v1/healthcheck | HealthcheckHandler | Show application information. | + + +### Sample local calls +``` +# GET /v1/healthcheck +curl -i localhost:4000/api/v1/healthcheck/ +``` \ No newline at end of file diff --git a/clients/ui/bff/api/app.go b/clients/ui/bff/api/app.go new file mode 100644 index 00000000..00fd009a --- /dev/null +++ b/clients/ui/bff/api/app.go @@ -0,0 +1,41 @@ +package api + +import ( + "github.com/kubeflow/model-registry/ui/bff/config" + "github.com/kubeflow/model-registry/ui/bff/data" + "log/slog" + "net/http" + + "github.com/julienschmidt/httprouter" +) + +const ( + // TODO(ederign) discuss versioning with the team + Version = "1.0.0" + HealthCheckPath = "/api/v1/healthcheck/" +) + +type App struct { + config config.EnvConfig + logger *slog.Logger + models data.Models +} + +func NewApp(cfg config.EnvConfig, logger *slog.Logger) *App { + app := &App{ + config: cfg, + logger: logger, + } + return app +} + +func (app *App) Routes() http.Handler { + router := httprouter.New() + + router.NotFound = http.HandlerFunc(app.notFoundResponse) + router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse) + + router.GET(HealthCheckPath, app.HealthcheckHandler) + + return app.RecoverPanic(app.enableCORS(router)) +} diff --git a/clients/ui/bff/api/errors.go b/clients/ui/bff/api/errors.go new file mode 100644 index 00000000..1f002cc8 --- /dev/null +++ b/clients/ui/bff/api/errors.go @@ -0,0 +1,103 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" +) + +type HTTPError struct { + StatusCode int `json:"-"` + ErrorResponse +} + +type ErrorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (app *App) LogError(r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + ) + + app.logger.Error(err.Error(), "method", method, "uri", uri) +} + +func (app *App) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) { + httpError := &HTTPError{ + StatusCode: http.StatusBadRequest, + ErrorResponse: ErrorResponse{ + Code: strconv.Itoa(http.StatusBadRequest), + Message: err.Error(), + }, + } + app.errorResponse(w, r, httpError) +} + +func (app *App) errorResponse(w http.ResponseWriter, r *http.Request, error *HTTPError) { + + env := Envelope{"error": error} + + err := app.WriteJSON(w, error.StatusCode, env, nil) + + if err != nil { + app.LogError(r, err) + w.WriteHeader(error.StatusCode) + } +} + +func (app *App) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) { + app.LogError(r, err) + + httpError := &HTTPError{ + StatusCode: http.StatusInternalServerError, + ErrorResponse: ErrorResponse{ + Code: strconv.Itoa(http.StatusInternalServerError), + Message: "the server encountered a problem and could not process your request", + }, + } + app.errorResponse(w, r, httpError) +} + +func (app *App) notFoundResponse(w http.ResponseWriter, r *http.Request) { + + httpError := &HTTPError{ + StatusCode: http.StatusNotFound, + ErrorResponse: ErrorResponse{ + Code: strconv.Itoa(http.StatusNotFound), + Message: "the requested resource could not be found", + }, + } + app.errorResponse(w, r, httpError) +} + +func (app *App) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) { + + httpError := &HTTPError{ + StatusCode: http.StatusMethodNotAllowed, + ErrorResponse: ErrorResponse{ + Code: strconv.Itoa(http.StatusMethodNotAllowed), + Message: fmt.Sprintf("the %s method is not supported for this resource", r.Method), + }, + } + app.errorResponse(w, r, httpError) +} + +func (app *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{ + Code: strconv.Itoa(http.StatusUnprocessableEntity), + Message: string(message), + }, + } + app.errorResponse(w, r, httpError) +} diff --git a/clients/ui/bff/api/healthcheck__handler_test.go b/clients/ui/bff/api/healthcheck__handler_test.go new file mode 100644 index 00000000..87f5432d --- /dev/null +++ b/clients/ui/bff/api/healthcheck__handler_test.go @@ -0,0 +1,50 @@ +package api + +import ( + "encoding/json" + "github.com/kubeflow/model-registry/ui/bff/config" + "github.com/kubeflow/model-registry/ui/bff/data" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthCheckHandler(t *testing.T) { + + app := App{config: config.EnvConfig{ + Port: 4000, + }} + + rr := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, HealthCheckPath, nil) + if err != nil { + t.Fatal(err) + } + + app.HealthcheckHandler(rr, req, nil) + rs := rr.Result() + + defer rs.Body.Close() + + body, err := io.ReadAll(rs.Body) + if err != nil { + t.Fatal("Failed to read response body") + } + + var healthCheckRes data.HealthCheckModel + err = json.Unmarshal(body, &healthCheckRes) + if err != nil { + t.Fatalf("Error unmarshalling response JSON: %v", err) + } + + expected := data.HealthCheckModel{ + Status: "available", + SystemInfo: data.SystemInfo{ + Version: Version, + }, + } + + assert.Equal(t, expected, healthCheckRes) +} diff --git a/clients/ui/bff/api/healthcheck_handler.go b/clients/ui/bff/api/healthcheck_handler.go new file mode 100644 index 00000000..2f8223a7 --- /dev/null +++ b/clients/ui/bff/api/healthcheck_handler.go @@ -0,0 +1,22 @@ +package api + +import ( + "github.com/julienschmidt/httprouter" + "net/http" +) + +func (app *App) HealthcheckHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + + healthCheck, err := app.models.HealthCheck.HealthCheck(Version) + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + + err = app.WriteJSON(w, http.StatusOK, healthCheck, nil) + + if err != nil { + app.serverErrorResponse(w, r, err) + } + +} diff --git a/clients/ui/bff/api/helpers.go b/clients/ui/bff/api/helpers.go new file mode 100644 index 00000000..c1c0e87b --- /dev/null +++ b/clients/ui/bff/api/helpers.go @@ -0,0 +1,87 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +type Envelope map[string]any + +func (app *App) WriteJSON(w http.ResponseWriter, status int, data any, headers http.Header) error { + + js, err := json.MarshalIndent(data, "", "\t") + + if err != nil { + return err + } + + js = append(js, '\n') + + for key, value := range headers { + w.Header()[key] = value + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write(js) + + return nil +} + +func (app *App) ReadJSON(w http.ResponseWriter, r *http.Request, dst any) error { + + maxBytes := 1_048_576 + r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes)) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + err := dec.Decode(dst) + + if err != nil { + var syntaxError *json.SyntaxError + var unmarshalTypeError *json.UnmarshalTypeError + var invalidUnmarshalError *json.InvalidUnmarshalError + var maxBytesError *http.MaxBytesError + + switch { + case errors.As(err, &syntaxError): + return fmt.Errorf("body contains badly-formed JSON (at character %d)", syntaxError.Offset) + + case errors.Is(err, io.ErrUnexpectedEOF): + return errors.New("body contains badly-formed JSON") + + case errors.As(err, &unmarshalTypeError): + if unmarshalTypeError.Field != "" { + return fmt.Errorf("body contains incorrect JSON type for field %q", unmarshalTypeError.Field) + } + return fmt.Errorf("body contains incorrect JSON type (at character %d)", unmarshalTypeError.Offset) + + case errors.Is(err, io.EOF): + return errors.New("body must not be empty") + + case errors.As(err, &maxBytesError): + return fmt.Errorf("body must not be larger than %d bytes", maxBytesError.Limit) + + case strings.HasPrefix(err.Error(), "json: unknown field "): + fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ") + return fmt.Errorf("body contains unknown key %s", fieldName) + + case errors.As(err, &invalidUnmarshalError): + panic(err) + default: + return err + } + } + + err = dec.Decode(&struct{}{}) + if !errors.Is(err, io.EOF) { + return errors.New("body must only contain a single JSON value") + } + + return nil +} diff --git a/clients/ui/bff/api/middleware.go b/clients/ui/bff/api/middleware.go new file mode 100644 index 00000000..8d9d5d68 --- /dev/null +++ b/clients/ui/bff/api/middleware.go @@ -0,0 +1,29 @@ +package api + +import ( + "fmt" + "net/http" +) + +func (app *App) RecoverPanic(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + w.Header().Set("Connection", "close") + app.serverErrorResponse(w, r, fmt.Errorf("%s", err)) + } + }() + + next.ServeHTTP(w, r) + }) +} + +func (app *App) enableCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // TODO(ederign) restrict CORS to a much smaller set of trusted origins. + // TODO(ederign) deal with preflight requests + w.Header().Set("Access-Control-Allow-Origin", "*") + + next.ServeHTTP(w, r) + }) +} diff --git a/clients/ui/bff/cmd/main.go b/clients/ui/bff/cmd/main.go new file mode 100644 index 00000000..32b43543 --- /dev/null +++ b/clients/ui/bff/cmd/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "github.com/kubeflow/model-registry/ui/bff/api" + "github.com/kubeflow/model-registry/ui/bff/config" + + "log/slog" + "net/http" + "os" + "strconv" + "time" +) + +func main() { + var cfg config.EnvConfig + flag.IntVar(&cfg.Port, "port", getEnvAsInt("PORT", 4000), "API server port") + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + app := api.NewApp(cfg, logger) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: app.Routes(), + IdleTimeout: time.Minute, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + logger.Info("starting server", "addr", srv.Addr) + + err := srv.ListenAndServe() + logger.Error(err.Error()) + os.Exit(1) +} + +func getEnvAsInt(name string, defaultVal int) int { + if value, exists := os.LookupEnv(name); exists { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultVal +} diff --git a/clients/ui/bff/config/environment.go b/clients/ui/bff/config/environment.go new file mode 100644 index 00000000..5acd1ce6 --- /dev/null +++ b/clients/ui/bff/config/environment.go @@ -0,0 +1,5 @@ +package config + +type EnvConfig struct { + Port int +} diff --git a/clients/ui/bff/data/health_check_model.go b/clients/ui/bff/data/health_check_model.go new file mode 100644 index 00000000..b8b557e6 --- /dev/null +++ b/clients/ui/bff/data/health_check_model.go @@ -0,0 +1,22 @@ +package data + +type SystemInfo struct { + Version string `json:"version"` +} + +type HealthCheckModel struct { + Status string `json:"status"` + SystemInfo SystemInfo `json:"system_info"` +} + +func (m HealthCheckModel) HealthCheck(version string) (HealthCheckModel, error) { + + var res = HealthCheckModel{ + Status: "available", + SystemInfo: SystemInfo{ + Version: version, + }, + } + + return res, nil +} diff --git a/clients/ui/bff/data/models.go b/clients/ui/bff/data/models.go new file mode 100644 index 00000000..96325911 --- /dev/null +++ b/clients/ui/bff/data/models.go @@ -0,0 +1,12 @@ +package data + +// Models struct is a single convenient container to hold and represent all our data. +type Models struct { + HealthCheck HealthCheckModel +} + +func NewModels() Models { + return Models{ + HealthCheck: HealthCheckModel{}, + } +} diff --git a/clients/ui/bff/go.mod b/clients/ui/bff/go.mod new file mode 100644 index 00000000..fdf29493 --- /dev/null +++ b/clients/ui/bff/go.mod @@ -0,0 +1,14 @@ +module github.com/kubeflow/model-registry/ui/bff + +go 1.22.2 + +require ( + github.com/julienschmidt/httprouter v1.3.0 + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/clients/ui/bff/go.sum b/clients/ui/bff/go.sum new file mode 100644 index 00000000..3df8d16e --- /dev/null +++ b/clients/ui/bff/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/clients/ui/frontend/.gitkeep b/clients/ui/frontend/.gitkeep new file mode 100644 index 00000000..e69de29b