From 675079caf5b742acecc0bfbc0eeca5341ece3fc4 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 14 Mar 2026 12:22:27 +0000 Subject: [PATCH] feat(provider): implement ProxyProvider reverse proxy Replace the Phase 3 stub with a working ProxyProvider that reverse-proxies requests to upstream provider binaries via httputil.ReverseProxy. Implements Provider + Renderable interfaces. Includes 9 tests covering proxy routing, health passthrough, element spec, and invalid upstream handling. Co-Authored-By: Virgil --- pkg/provider/proxy.go | 116 ++++++++++++++++++++-- pkg/provider/proxy_test.go | 193 +++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 pkg/provider/proxy_test.go diff --git a/pkg/provider/proxy.go b/pkg/provider/proxy.go index f7d3831..9eb2b4a 100644 --- a/pkg/provider/proxy.go +++ b/pkg/provider/proxy.go @@ -2,11 +2,111 @@ package provider -// ProxyProvider will wrap polyglot (PHP/TS) providers that publish an OpenAPI -// spec and run their own HTTP handler. The Go API layer reverse-proxies to -// their endpoint. -// -// This is a Phase 3 feature. The type is declared here as a forward reference -// so the package structure is established. -// -// See the design spec SS Polyglot Providers for the full ProxyProvider contract. +import ( + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +// ProxyConfig configures a ProxyProvider that reverse-proxies to an upstream +// process (typically a runtime provider binary listening on 127.0.0.1). +type ProxyConfig struct { + // Name is the provider identity, e.g. "cool-widget". + Name string + + // BasePath is the API route prefix, e.g. "/api/v1/cool-widget". + BasePath string + + // Upstream is the full URL of the upstream process, + // e.g. "http://127.0.0.1:9901". + Upstream string + + // Element describes the custom element for GUI rendering. + // Leave zero-value if the provider has no UI. + Element ElementSpec + + // SpecFile is the filesystem path to the provider's OpenAPI spec. + // Used by the Swagger aggregator. Leave empty if none. + SpecFile string +} + +// ProxyProvider reverse-proxies requests to an upstream HTTP process. +// It implements Provider and Renderable so it integrates with the +// service provider framework and GUI discovery. +type ProxyProvider struct { + config ProxyConfig + proxy *httputil.ReverseProxy +} + +// NewProxy creates a ProxyProvider from the given configuration. +// The upstream URL must be valid or NewProxy will panic. +func NewProxy(cfg ProxyConfig) *ProxyProvider { + target, err := url.Parse(cfg.Upstream) + if err != nil { + panic("provider.NewProxy: invalid upstream URL: " + err.Error()) + } + + proxy := httputil.NewSingleHostReverseProxy(target) + + // Preserve the original Director but strip the base path so the + // upstream receives clean paths (e.g. /items instead of /api/v1/cool-widget/items). + defaultDirector := proxy.Director + basePath := strings.TrimSuffix(cfg.BasePath, "/") + + proxy.Director = func(req *http.Request) { + defaultDirector(req) + // Strip the base path prefix from the request path. + req.URL.Path = strings.TrimPrefix(req.URL.Path, basePath) + if req.URL.Path == "" { + req.URL.Path = "/" + } + req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, basePath) + } + + return &ProxyProvider{ + config: cfg, + proxy: proxy, + } +} + +// Name returns the provider identity. +func (p *ProxyProvider) Name() string { + return p.config.Name +} + +// BasePath returns the API route prefix. +func (p *ProxyProvider) BasePath() string { + return p.config.BasePath +} + +// RegisterRoutes mounts a catch-all reverse proxy handler on the router group. +func (p *ProxyProvider) RegisterRoutes(rg *gin.RouterGroup) { + rg.Any("/*path", func(c *gin.Context) { + // Use the underlying http.ResponseWriter directly. Gin's + // responseWriter wrapper does not implement http.CloseNotifier, + // which httputil.ReverseProxy requires for cancellation signalling. + var w http.ResponseWriter = c.Writer + if uw, ok := w.(interface{ Unwrap() http.ResponseWriter }); ok { + w = uw.Unwrap() + } + p.proxy.ServeHTTP(w, c.Request) + }) +} + +// Element returns the custom element specification for GUI rendering. +func (p *ProxyProvider) Element() ElementSpec { + return p.config.Element +} + +// SpecFile returns the path to the provider's OpenAPI spec file. +func (p *ProxyProvider) SpecFile() string { + return p.config.SpecFile +} + +// Upstream returns the upstream URL string. +func (p *ProxyProvider) Upstream() string { + return p.config.Upstream +} diff --git a/pkg/provider/proxy_test.go b/pkg/provider/proxy_test.go new file mode 100644 index 0000000..f98ad72 --- /dev/null +++ b/pkg/provider/proxy_test.go @@ -0,0 +1,193 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package provider_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "forge.lthn.ai/core/api" + "forge.lthn.ai/core/api/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// -- ProxyProvider tests ------------------------------------------------------ + +func TestProxyProvider_Name_Good(t *testing.T) { + p := provider.NewProxy(provider.ProxyConfig{ + Name: "cool-widget", + BasePath: "/api/v1/cool-widget", + Upstream: "http://127.0.0.1:9999", + }) + assert.Equal(t, "cool-widget", p.Name()) +} + +func TestProxyProvider_BasePath_Good(t *testing.T) { + p := provider.NewProxy(provider.ProxyConfig{ + Name: "cool-widget", + BasePath: "/api/v1/cool-widget", + Upstream: "http://127.0.0.1:9999", + }) + assert.Equal(t, "/api/v1/cool-widget", p.BasePath()) +} + +func TestProxyProvider_Element_Good(t *testing.T) { + elem := provider.ElementSpec{ + Tag: "core-cool-widget", + Source: "/assets/cool-widget.js", + } + p := provider.NewProxy(provider.ProxyConfig{ + Name: "cool-widget", + BasePath: "/api/v1/cool-widget", + Upstream: "http://127.0.0.1:9999", + Element: elem, + }) + assert.Equal(t, "core-cool-widget", p.Element().Tag) + assert.Equal(t, "/assets/cool-widget.js", p.Element().Source) +} + +func TestProxyProvider_SpecFile_Good(t *testing.T) { + p := provider.NewProxy(provider.ProxyConfig{ + Name: "cool-widget", + BasePath: "/api/v1/cool-widget", + Upstream: "http://127.0.0.1:9999", + SpecFile: "/tmp/openapi.json", + }) + assert.Equal(t, "/tmp/openapi.json", p.SpecFile()) +} + +func TestProxyProvider_Proxy_Good(t *testing.T) { + // Start a test upstream server. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{ + "path": r.URL.Path, + "method": r.Method, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer upstream.Close() + + // Create proxy provider pointing to the test server. + p := provider.NewProxy(provider.ProxyConfig{ + Name: "test-proxy", + BasePath: "/api/v1/test-proxy", + Upstream: upstream.URL, + }) + + // Mount on an api.Engine. + engine, err := api.New() + require.NoError(t, err) + engine.Register(p) + + handler := engine.Handler() + + // Send a request through the proxy. + req := httptest.NewRequest("GET", "/api/v1/test-proxy/items", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]string + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + + // The upstream should see the path with base path stripped. + assert.Equal(t, "/items", body["path"]) + assert.Equal(t, "GET", body["method"]) +} + +func TestProxyProvider_ProxyRoot_Good(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{"path": r.URL.Path} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer upstream.Close() + + p := provider.NewProxy(provider.ProxyConfig{ + Name: "test-proxy", + BasePath: "/api/v1/test-proxy", + Upstream: upstream.URL, + }) + + engine, err := api.New() + require.NoError(t, err) + engine.Register(p) + + handler := engine.Handler() + + // Request to the base path itself (root of the provider). + req := httptest.NewRequest("GET", "/api/v1/test-proxy/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]string + err = json.Unmarshal(w.Body.Bytes(), &body) + require.NoError(t, err) + assert.Equal(t, "/", body["path"]) +} + +func TestProxyProvider_HealthPassthrough_Good(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer upstream.Close() + + p := provider.NewProxy(provider.ProxyConfig{ + Name: "health-test", + BasePath: "/api/v1/health-test", + Upstream: upstream.URL, + }) + + engine, err := api.New() + require.NoError(t, err) + engine.Register(p) + + handler := engine.Handler() + + req := httptest.NewRequest("GET", "/api/v1/health-test/health", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"status":"ok"`) +} + +func TestProxyProvider_Renderable_Good(t *testing.T) { + // Verify ProxyProvider satisfies Renderable via the Registry. + p := provider.NewProxy(provider.ProxyConfig{ + Name: "renderable-proxy", + BasePath: "/api/v1/renderable", + Upstream: "http://127.0.0.1:9999", + Element: provider.ElementSpec{Tag: "core-test-panel", Source: "/assets/test.js"}, + }) + + reg := provider.NewRegistry() + reg.Add(p) + + renderables := reg.Renderable() + require.Len(t, renderables, 1) + assert.Equal(t, "core-test-panel", renderables[0].Element().Tag) +} + +func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) { + assert.Panics(t, func() { + provider.NewProxy(provider.ProxyConfig{ + Name: "bad", + BasePath: "/api/v1/bad", + Upstream: "://not-a-url", + }) + }) +}