|
| 1 | +# BFF Handler Extensions |
| 2 | + |
| 3 | +Some downstream builds need to add behavior that does not belong upstream yet. Instead of maintaining long-lived forks, the BFF exposes a simple extension registry so downstream code can override individual handlers while reusing the rest of the stack. |
| 4 | + |
| 5 | +## Table of Contents |
| 6 | + |
| 7 | +- [Core Concepts](#core-concepts) |
| 8 | +- [Ownership Boundaries](#ownership-boundaries) |
| 9 | +- [Available App Methods](#available-app-methods) |
| 10 | +- [Step-by-Step: Overriding an Existing Handler](#step-by-step-overriding-an-existing-handler) |
| 11 | +- [Step-by-Step: Adding a Downstream-Only Route](#step-by-step-adding-a-downstream-only-route) |
| 12 | +- [Accessing the Kubernetes Client](#accessing-the-kubernetes-client) |
| 13 | +- [Importing Upstream Types](#importing-upstream-types) |
| 14 | +- [Testing Downstream Overrides](#testing-downstream-overrides) |
| 15 | +- [Current Handler ID Coverage](#current-handler-id-coverage) |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## Core Concepts |
| 20 | + |
| 21 | +- **Handler IDs** – Each overridable endpoint has a stable `HandlerID` string constant defined upstream in `internal/api/app.go`. Downstream code references these IDs to register overrides. |
| 22 | +- **Handler Factories** – Downstream packages call `api.RegisterHandlerOverride(id, factory)` inside `init()`. Factories receive the `*api.App` plus a `buildDefault` function, so you can either replace the handler entirely or fall back to upstream logic conditionally. |
| 23 | +- **Downstream-Only Routes** – Some endpoints exist upstream only as stubs returning 501 Not Implemented. These are designed to be fully implemented downstream. The route is wired upstream, but the real logic lives in the override. |
| 24 | +- **Dependencies** – The `App` exposes read-only accessors for configuration, repositories, and the Kubernetes client factory. It also exposes helper methods such as `BadRequest`, `ServerError`, and `WriteJSON` so overrides can follow the same conventions as upstream handlers. |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Ownership Boundaries |
| 29 | + |
| 30 | +- **Upstream-only artifacts** live under `internal/api`, `internal/repositories`, and the default handler tree. These packages must remain vendor-neutral and keep their existing contracts intact so downstream imports keep compiling. |
| 31 | +- **Downstream-only artifacts** live under `clients/ui/bff/internal/<vendor>/` (e.g., `internal/redhat/`). Any logic that assumes vendor-specific credentials, namespaces, or controllers must stay here so other distributions do not pick it up accidentally. |
| 32 | +- **Shared interfaces** (for example, repository interfaces or the handler override registry itself) stay upstream. Only implementations specific to a vendor move downstream. |
| 33 | + |
| 34 | +**Rule of thumb:** If a change requires vendor-only RBAC, Kubernetes resources, or APIs invisible to open-source users, keep it downstream. Everything else should be proposed upstream. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## Available App Methods |
| 39 | + |
| 40 | +The `*api.App` instance provides these exported methods for use in downstream handlers: |
| 41 | + |
| 42 | +| Method | Description | |
| 43 | +|--------|-------------| |
| 44 | +| `app.Config()` | Returns `config.EnvConfig` with deployment settings | |
| 45 | +| `app.Logger()` | Returns `*slog.Logger` for structured logging | |
| 46 | +| `app.KubernetesClientFactory()` | Returns factory to build Kubernetes clients | |
| 47 | +| `app.Repositories()` | Returns `*repositories.Repositories` for data access | |
| 48 | +| `app.WriteJSON(w, status, data, headers)` | Writes JSON response with proper content-type | |
| 49 | +| `app.ReadJSON(w, r, dst)` | Parses JSON request body into destination struct | |
| 50 | +| `app.BadRequest(w, r, err)` | Returns HTTP 400 with error message | |
| 51 | +| `app.ServerError(w, r, err)` | Returns HTTP 500 with error message | |
| 52 | +| `app.NotImplemented(w, r, feature)` | Returns HTTP 501 for unimplemented features | |
| 53 | +| `app.AttachNamespace(handler)` | Middleware that extracts `namespace` query param into context | |
| 54 | + |
| 55 | +--- |
| 56 | + |
| 57 | +## Step-by-Step: Overriding an Existing Handler |
| 58 | + |
| 59 | +This workflow overrides an upstream handler with downstream-specific logic. |
| 60 | + |
| 61 | +### 1. Add the blank import in `main.go` |
| 62 | + |
| 63 | +In `clients/ui/bff/cmd/main.go`, add a blank import for your handlers package: |
| 64 | + |
| 65 | +```go |
| 66 | +import ( |
| 67 | + // ... other imports ... |
| 68 | + |
| 69 | + // Import downstream handlers to register overrides via init() |
| 70 | + _ "github.com/kubeflow/model-registry/ui/bff/internal/redhat/handlers" |
| 71 | +) |
| 72 | +``` |
| 73 | + |
| 74 | +### 2. Locate the Handler ID upstream |
| 75 | + |
| 76 | +Handler IDs are defined in `internal/api/app.go`. For example, Model Registry Settings endpoints: |
| 77 | + |
| 78 | +```go |
| 79 | +const ( |
| 80 | + handlerModelRegistrySettingsListID HandlerID = "modelRegistrySettings:list" |
| 81 | + handlerModelRegistrySettingsCreateID HandlerID = "modelRegistrySettings:create" |
| 82 | + handlerModelRegistrySettingsGetID HandlerID = "modelRegistrySettings:get" |
| 83 | + handlerModelRegistrySettingsUpdateID HandlerID = "modelRegistrySettings:update" |
| 84 | + handlerModelRegistrySettingsDeleteID HandlerID = "modelRegistrySettings:delete" |
| 85 | +) |
| 86 | +``` |
| 87 | + |
| 88 | +### 3. Create the downstream handler file |
| 89 | + |
| 90 | +Create a file in `internal/<vendor>/handlers/` (e.g., `internal/redhat/handlers/my_handler.go`): |
| 91 | + |
| 92 | +```go |
| 93 | +package handlers |
| 94 | + |
| 95 | +import ( |
| 96 | + "fmt" |
| 97 | + "net/http" |
| 98 | + |
| 99 | + "github.com/julienschmidt/httprouter" |
| 100 | + |
| 101 | + "github.com/kubeflow/model-registry/ui/bff/internal/api" |
| 102 | + "github.com/kubeflow/model-registry/ui/bff/internal/constants" |
| 103 | +) |
| 104 | + |
| 105 | +// Mirror the upstream handler ID string |
| 106 | +const myHandlerID = api.HandlerID("modelRegistrySettings:list") |
| 107 | + |
| 108 | +func init() { |
| 109 | + api.RegisterHandlerOverride(myHandlerID, myOverrideFactory) |
| 110 | +} |
| 111 | + |
| 112 | +func myOverrideFactory(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle { |
| 113 | + // Optionally fall back to upstream when mocking |
| 114 | + if app.Config().MockK8Client { |
| 115 | + return buildDefault() |
| 116 | + } |
| 117 | + |
| 118 | + return app.AttachNamespace(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { |
| 119 | + namespace, ok := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) |
| 120 | + if !ok || namespace == "" { |
| 121 | + app.BadRequest(w, r, fmt.Errorf("missing namespace")) |
| 122 | + return |
| 123 | + } |
| 124 | + |
| 125 | + // Your downstream implementation here |
| 126 | + resp := api.ModelRegistrySettingsListEnvelope{Data: nil} |
| 127 | + if err := app.WriteJSON(w, http.StatusOK, resp, nil); err != nil { |
| 128 | + app.ServerError(w, r, err) |
| 129 | + } |
| 130 | + }) |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +--- |
| 135 | + |
| 136 | +## Step-by-Step: Adding a Downstream-Only Route |
| 137 | + |
| 138 | +For routes that have **no real upstream implementation** (upstream returns 501 Not Implemented), use this pattern. |
| 139 | + |
| 140 | +### 1. Upstream defines the route and stub handler |
| 141 | + |
| 142 | +In `internal/api/app.go`, the route is defined with a handler that returns 501: |
| 143 | + |
| 144 | +```go |
| 145 | +// Path constant |
| 146 | +const KubernetesServicesListPath = SettingsPath + "/services" |
| 147 | + |
| 148 | +// Handler ID |
| 149 | +const handlerKubernetesServicesListID HandlerID = "kubernetes:services:list" |
| 150 | + |
| 151 | +// Route registration in Routes() |
| 152 | +apiRouter.GET( |
| 153 | + KubernetesServicesListPath, |
| 154 | + app.handlerWithOverride(handlerKubernetesServicesListID, func() httprouter.Handle { |
| 155 | + return app.AttachNamespace(app.kubernetesServicesNotImplementedHandler) |
| 156 | + }), |
| 157 | +) |
| 158 | +``` |
| 159 | + |
| 160 | +The stub handler in `internal/api/kubernetes_resources_handler.go`: |
| 161 | + |
| 162 | +```go |
| 163 | +// Generic handler for endpoints not implemented upstream |
| 164 | +func (app *App) endpointNotImplementedHandler(feature string) func(http.ResponseWriter, *http.Request, httprouter.Params) { |
| 165 | + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { |
| 166 | + app.NotImplemented(w, r, feature) |
| 167 | + } |
| 168 | +} |
| 169 | +``` |
| 170 | + |
| 171 | +### 2. Downstream provides the real implementation |
| 172 | + |
| 173 | +In `internal/redhat/handlers/kubernetes_services.go`: |
| 174 | + |
| 175 | +```go |
| 176 | +package handlers |
| 177 | + |
| 178 | +import ( |
| 179 | + "fmt" |
| 180 | + "net/http" |
| 181 | + |
| 182 | + "github.com/julienschmidt/httprouter" |
| 183 | + "github.com/kubeflow/model-registry/ui/bff/internal/api" |
| 184 | + "github.com/kubeflow/model-registry/ui/bff/internal/constants" |
| 185 | +) |
| 186 | + |
| 187 | +const kubernetesServicesListHandlerID = api.HandlerID("kubernetes:services:list") |
| 188 | + |
| 189 | +func init() { |
| 190 | + api.RegisterHandlerOverride(kubernetesServicesListHandlerID, overrideKubernetesServicesList) |
| 191 | +} |
| 192 | + |
| 193 | +func overrideKubernetesServicesList(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle { |
| 194 | + // Fall back to stub when K8s client is mocked |
| 195 | + if app.Config().MockK8Client { |
| 196 | + return buildDefault() |
| 197 | + } |
| 198 | + |
| 199 | + return app.AttachNamespace(func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { |
| 200 | + namespace, ok := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) |
| 201 | + if !ok || namespace == "" { |
| 202 | + app.BadRequest(w, r, fmt.Errorf("missing namespace")) |
| 203 | + return |
| 204 | + } |
| 205 | + |
| 206 | + client, err := app.KubernetesClientFactory().GetClient(r.Context()) |
| 207 | + if err != nil { |
| 208 | + app.ServerError(w, r, fmt.Errorf("failed to get client: %w", err)) |
| 209 | + return |
| 210 | + } |
| 211 | + |
| 212 | + // Real implementation using the Kubernetes client |
| 213 | + services, err := client.GetServiceDetails(r.Context(), namespace) |
| 214 | + if err != nil { |
| 215 | + app.ServerError(w, r, err) |
| 216 | + return |
| 217 | + } |
| 218 | + |
| 219 | + // Build and return response |
| 220 | + items := make([]api.KubernetesServiceItem, 0, len(services)) |
| 221 | + for _, svc := range services { |
| 222 | + items = append(items, api.KubernetesServiceItem{ |
| 223 | + Name: svc.Name, |
| 224 | + Namespace: namespace, |
| 225 | + }) |
| 226 | + } |
| 227 | + |
| 228 | + resp := api.KubernetesServicesListEnvelope{Data: items} |
| 229 | + if err := app.WriteJSON(w, http.StatusOK, resp, nil); err != nil { |
| 230 | + app.ServerError(w, r, err) |
| 231 | + } |
| 232 | + }) |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +--- |
| 237 | + |
| 238 | +## Accessing the Kubernetes Client |
| 239 | + |
| 240 | +To interact with Kubernetes resources in your handler: |
| 241 | + |
| 242 | +```go |
| 243 | +func myHandler(app *api.App) httprouter.Handle { |
| 244 | + return app.AttachNamespace(func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { |
| 245 | + // Get the Kubernetes client from the factory |
| 246 | + client, err := app.KubernetesClientFactory().GetClient(r.Context()) |
| 247 | + if err != nil { |
| 248 | + app.ServerError(w, r, fmt.Errorf("failed to get Kubernetes client: %w", err)) |
| 249 | + return |
| 250 | + } |
| 251 | + |
| 252 | + // Use the client to interact with Kubernetes |
| 253 | + // The client interface is defined in internal/integrations/kubernetes |
| 254 | + namespace := r.Context().Value(constants.NamespaceHeaderParameterKey).(string) |
| 255 | + |
| 256 | + // Example: Get service details |
| 257 | + services, err := client.GetServiceDetails(r.Context(), namespace) |
| 258 | + if err != nil { |
| 259 | + app.ServerError(w, r, err) |
| 260 | + return |
| 261 | + } |
| 262 | + |
| 263 | + // Process services... |
| 264 | + }) |
| 265 | +} |
| 266 | +``` |
| 267 | + |
| 268 | +The `KubernetesClientInterface` provides methods for common operations. Check `internal/integrations/kubernetes/client.go` for available methods. |
| 269 | + |
| 270 | +--- |
| 271 | + |
| 272 | +## Importing Upstream Types |
| 273 | + |
| 274 | +When building downstream handlers, you'll commonly import these packages: |
| 275 | + |
| 276 | +```go |
| 277 | +import ( |
| 278 | + // Core API types and App |
| 279 | + "github.com/kubeflow/model-registry/ui/bff/internal/api" |
| 280 | + |
| 281 | + // Configuration types |
| 282 | + "github.com/kubeflow/model-registry/ui/bff/internal/config" |
| 283 | + |
| 284 | + // Context keys for namespace, identity, etc. |
| 285 | + "github.com/kubeflow/model-registry/ui/bff/internal/constants" |
| 286 | + |
| 287 | + // Kubernetes client interface |
| 288 | + k8s "github.com/kubeflow/model-registry/ui/bff/internal/integrations/kubernetes" |
| 289 | + |
| 290 | + // Data models |
| 291 | + "github.com/kubeflow/model-registry/ui/bff/internal/models" |
| 292 | + |
| 293 | + // Upstream repositories (if needed) |
| 294 | + "github.com/kubeflow/model-registry/ui/bff/internal/repositories" |
| 295 | + |
| 296 | + // Router |
| 297 | + "github.com/julienschmidt/httprouter" |
| 298 | +) |
| 299 | +``` |
| 300 | + |
| 301 | +**Commonly used types from `api` package:** |
| 302 | + |
| 303 | +- `api.App` – Main application instance |
| 304 | +- `api.HandlerID` – Type for handler identifiers |
| 305 | +- `api.HandlerFactory` – Signature for override factories |
| 306 | +- `api.RegisterHandlerOverride()` – Register an override |
| 307 | +- Envelope types like `api.ModelRegistrySettingsListEnvelope`, `api.KubernetesServicesListEnvelope` |
| 308 | + |
| 309 | +--- |
| 310 | + |
| 311 | +## Testing Downstream Overrides |
| 312 | + |
| 313 | +Keep unit tests next to your override implementations in the downstream package. |
| 314 | + |
| 315 | +### Testing with mocked Kubernetes client |
| 316 | + |
| 317 | +When `MockK8Client=true`, your handlers should fall back to upstream stubs or return mock data: |
| 318 | + |
| 319 | +```go |
| 320 | +func TestMyHandler_MockMode(t *testing.T) { |
| 321 | + // Create app with mocked K8s client |
| 322 | + cfg := config.EnvConfig{ |
| 323 | + MockK8Client: true, |
| 324 | + } |
| 325 | + |
| 326 | + // Your handler factory should return buildDefault() or mock behavior |
| 327 | + // when app.Config().MockK8Client is true |
| 328 | +} |
| 329 | +``` |
| 330 | + |
| 331 | +### Pattern for conditional override activation |
| 332 | + |
| 333 | +```go |
| 334 | +func shouldUseDownstreamOverrides(app *api.App) bool { |
| 335 | + if app == nil { |
| 336 | + return false |
| 337 | + } |
| 338 | + // When K8s client is mocked, use upstream stub handlers |
| 339 | + return !app.Config().MockK8Client |
| 340 | +} |
| 341 | + |
| 342 | +func myOverrideFactory(app *api.App, buildDefault func() httprouter.Handle) httprouter.Handle { |
| 343 | + // Fall back to upstream stub when K8s is mocked |
| 344 | + if !shouldUseDownstreamOverrides(app) { |
| 345 | + return buildDefault() |
| 346 | + } |
| 347 | + |
| 348 | + // Real implementation |
| 349 | + return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { |
| 350 | + // ... |
| 351 | + } |
| 352 | +} |
| 353 | +``` |
| 354 | + |
| 355 | +### Test file structure |
| 356 | + |
| 357 | +``` |
| 358 | +internal/redhat/handlers/ |
| 359 | +├── model_registry_settings.go |
| 360 | +├── model_registry_settings_test.go |
| 361 | +├── kubernetes_services.go |
| 362 | +└── kubernetes_services_test.go |
| 363 | +``` |
| 364 | + |
| 365 | +--- |
| 366 | + |
| 367 | +## Current Handler ID Coverage |
| 368 | + |
| 369 | +These handler IDs are currently wired with `handlerWithOverride()` upstream: |
| 370 | + |
| 371 | +| Handler ID | HTTP Method | Path | Description | |
| 372 | +|------------|-------------|------|-------------| |
| 373 | +| `modelRegistrySettings:list` | GET | `/api/v1/settings/model_registry` | List all model registries | |
| 374 | +| `modelRegistrySettings:create` | POST | `/api/v1/settings/model_registry` | Create a model registry | |
| 375 | +| `modelRegistrySettings:get` | GET | `/api/v1/settings/model_registry/:model_registry_id` | Get a model registry | |
| 376 | +| `modelRegistrySettings:update` | PATCH | `/api/v1/settings/model_registry/:model_registry_id` | Update a model registry | |
| 377 | +| `modelRegistrySettings:delete` | DELETE | `/api/v1/settings/model_registry/:model_registry_id` | Delete a model registry | |
| 378 | +| `kubernetes:services:list` | GET | `/api/v1/settings/services` | List Kubernetes services (downstream-only) | |
| 379 | + |
| 380 | +--- |
| 381 | + |
| 382 | +## Change Workflow Summary |
| 383 | + |
| 384 | +### To override an existing handler: |
| 385 | + |
| 386 | +1. Find the handler ID in `internal/api/app.go` |
| 387 | +2. Create handler file in `internal/<vendor>/handlers/` |
| 388 | +3. Register override in `init()` with `api.RegisterHandlerOverride()` |
| 389 | +4. Add blank import in `cmd/main.go` (if not already present) |
| 390 | + |
| 391 | +### To add a downstream-only route: |
| 392 | + |
| 393 | +1. Define route path constant and handler ID in `internal/api/app.go` |
| 394 | +2. Create stub handler returning 501 in `internal/api/` |
| 395 | +3. Wire route with `handlerWithOverride()` in `Routes()` |
| 396 | +4. Implement real handler in `internal/<vendor>/handlers/` |
| 397 | +5. Register override in `init()` with `api.RegisterHandlerOverride()` |
0 commit comments