Files
homelab-frontend/internal/serviceadapter/router.go
T
Admin Bot bd4cbbd0a3
CI / Vet, test, build (push) Successful in 2m3s
CI / Build and push image (push) Successful in 45s
feat: proxy requests to service adapter upstreams instead of echoing
2026-08-27 08:55:41 -07:00

154 lines
4.3 KiB
Go

package serviceadapter
import (
"fmt"
"net"
"net/http"
"net/http/httputil"
"net/url"
"time"
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
)
// Dispatcher handles X-Service based routing to service adapters.
type Dispatcher struct {
registry *Registry
// authValidator would check capabilities if internal/auth exists
// For now, we stub it
}
// NewDispatcher creates a new service adapter dispatcher.
func NewDispatcher(registry *Registry) *Dispatcher {
return &Dispatcher{
registry: registry,
}
}
// Matches returns true if the request should be dispatched based on X-Service header.
func (d *Dispatcher) Matches(r *http.Request) bool {
return r.Header.Get("X-Service") != ""
}
// Dispatch routes a request to the appropriate adapter.
// Returns a problem document if the adapter or resource is not found.
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
serviceName := r.Header.Get("X-Service")
if serviceName == "" {
// No X-Service header — this shouldn't happen if Matches() was called
d.writeError(w, problem.BadRequest("X-Service header required"))
return
}
// Look up service adapter
adapter := d.registry.Get(serviceName)
if adapter == nil {
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName))
_ = p.Write(w)
return
}
// Get resource and method from request
resourceName := r.Header.Get("X-Resource")
if resourceName == "" {
d.writeError(w, problem.BadRequest("X-Resource header required"))
return
}
// Find resource
var resource *Resource
for i := range adapter.Spec.Resources {
if adapter.Spec.Resources[i].Name == resourceName {
resource = &adapter.Spec.Resources[i]
break
}
}
if resource == nil {
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName))
_ = p.Write(w)
return
}
// Find method matching HTTP verb
var method *Method
for i := range resource.Methods {
if resource.Methods[i].Verb == r.Method {
method = &resource.Methods[i]
break
}
}
if method == nil {
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName))
_ = p.Write(w)
return
}
// Check auth requirements (stub for now — internal/auth integration in 8.3)
// Determine required capability
requiredCapability := ""
auth := resource.Auth
if auth == nil {
auth = &adapter.Spec.Auth
}
if method.Auth != nil {
auth = method.Auth
}
if auth != nil && auth.Required && auth.Capability != "" {
requiredCapability = auth.Capability
// Would validate JWT and capability here (depends on internal/auth)
// For now, stub — just log that it would be checked
if !d.hasCapability(r, requiredCapability) {
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
"Forbidden", fmt.Sprintf("capability '%s' required", requiredCapability))
_ = p.Write(w)
return
}
}
// Build upstream URL
upstreamURL, err := url.Parse(adapter.Spec.Upstream.URL)
if err != nil {
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
"Internal Server Error", fmt.Sprintf("invalid upstream URL: %v", err)))
return
}
// Create reverse proxy with director that rewrites the path
proxy := httputil.NewSingleHostReverseProxy(upstreamURL)
proxy.Director = func(req *http.Request) {
req.URL.Scheme = upstreamURL.Scheme
req.URL.Host = upstreamURL.Host
req.URL.Path = method.UpstreamPath
req.RequestURI = ""
req.Host = upstreamURL.Host
}
// Set timeout
timeout := adapter.Spec.Upstream.TimeoutSeconds
if timeout <= 0 {
timeout = 30
}
proxy.Transport = &http.Transport{
DialContext: (&net.Dialer{Timeout: time.Duration(timeout) * time.Second}).DialContext,
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
}
// Forward the request
proxy.ServeHTTP(w, r)
}
// hasCapability checks if the request has the required capability.
// Stub implementation — depends on internal/auth JWT validation.
func (d *Dispatcher) hasCapability(r *http.Request, capability string) bool {
// TODO: Parse JWT from Authorization header and check capabilities
// For now, assume all authenticated requests have all capabilities
return r.Header.Get("Authorization") != ""
}
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
_ = p.Write(w)
}