Skip to content

Commit

Permalink
feat: Initial commit for UI #108
Browse files Browse the repository at this point in the history
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 <ignatowicz@gmail.com>
  • Loading branch information
ederign committed May 29, 2024
1 parent c009d32 commit cafc660
Show file tree
Hide file tree
Showing 17 changed files with 536 additions and 0 deletions.
1 change: 1 addition & 0 deletions clients/ui/bff/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/bin
36 changes: 36 additions & 0 deletions clients/ui/bff/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
33 changes: 33 additions & 0 deletions clients/ui/bff/Makefile
Original file line number Diff line number Diff line change
@@ -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<target>\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} .
23 changes: 23 additions & 0 deletions clients/ui/bff/README.md
Original file line number Diff line number Diff line change
@@ -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/
```
41 changes: 41 additions & 0 deletions clients/ui/bff/api/app.go
Original file line number Diff line number Diff line change
@@ -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))
}
103 changes: 103 additions & 0 deletions clients/ui/bff/api/errors.go
Original file line number Diff line number Diff line change
@@ -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)
}
50 changes: 50 additions & 0 deletions clients/ui/bff/api/healthcheck__handler_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 22 additions & 0 deletions clients/ui/bff/api/healthcheck_handler.go
Original file line number Diff line number Diff line change
@@ -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)
}

}
Loading

0 comments on commit cafc660

Please sign in to comment.