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 <virgil@lethean.io>
This commit is contained in:
parent
753812ad57
commit
675079caf5
2 changed files with 301 additions and 8 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
193
pkg/provider/proxy_test.go
Normal file
193
pkg/provider/proxy_test.go
Normal file
|
|
@ -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",
|
||||
})
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue