mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-06-17 15:23:01 +08:00
feat(pluginhost): introduce browser-navigable plugin resources in Management API
- Added `resources` field in `management.register` for defining browser-accessible resources. - Updated examples and documentation to reflect resource-based paths under `/v0/resource/plugins/<pluginID>/...`. - Replaced legacy `GET` menu routes with resource-based implementations for consistent plugin behavior. - Enhanced request handling for resource paths, including proper response headers and streamlined test coverage.
This commit is contained in:
@@ -37,6 +37,7 @@ type Host struct {
|
||||
commandLineFlags map[string]commandLineFlagRecord
|
||||
commandLineHits map[string]struct{}
|
||||
managementRoutes map[string]managementRouteRecord
|
||||
resourceRoutes map[string]resourceRouteRecord
|
||||
streams *streamBridge
|
||||
httpStreams *hostHTTPStreamBridge
|
||||
callbackContexts *callbackContextRegistry
|
||||
@@ -58,6 +59,7 @@ func New() *Host {
|
||||
commandLineFlags: make(map[string]commandLineFlagRecord),
|
||||
commandLineHits: make(map[string]struct{}),
|
||||
managementRoutes: make(map[string]managementRouteRecord),
|
||||
resourceRoutes: make(map[string]resourceRouteRecord),
|
||||
streams: newStreamBridge(),
|
||||
httpStreams: newHostHTTPStreamBridge(),
|
||||
callbackContexts: newCallbackContextRegistry(),
|
||||
@@ -93,6 +95,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
|
||||
h.runtimeConfig = cfg
|
||||
|
||||
if !rc.Enabled {
|
||||
h.managementRoutes = make(map[string]managementRouteRecord)
|
||||
h.resourceRoutes = make(map[string]resourceRouteRecord)
|
||||
h.snapshot.Store(emptySnapshot())
|
||||
h.mu.Unlock()
|
||||
h.refreshThinkingProviders(nil)
|
||||
@@ -102,6 +106,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
|
||||
files, errSelect := selectPluginFiles(rc.Dir)
|
||||
if errSelect != nil {
|
||||
log.Warnf("pluginhost: failed to select plugin files: %v", errSelect)
|
||||
h.managementRoutes = make(map[string]managementRouteRecord)
|
||||
h.resourceRoutes = make(map[string]resourceRouteRecord)
|
||||
h.snapshot.Store(emptySnapshot())
|
||||
h.mu.Unlock()
|
||||
h.refreshThinkingProviders(nil)
|
||||
@@ -187,6 +193,7 @@ func (h *Host) ShutdownAll() {
|
||||
h.commandLineFlags = make(map[string]commandLineFlagRecord)
|
||||
h.commandLineHits = make(map[string]struct{})
|
||||
h.managementRoutes = make(map[string]managementRouteRecord)
|
||||
h.resourceRoutes = make(map[string]resourceRouteRecord)
|
||||
h.snapshot.Store(emptySnapshot())
|
||||
h.mu.Unlock()
|
||||
|
||||
|
||||
@@ -12,20 +12,30 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const managementBasePath = "/v0/management"
|
||||
const (
|
||||
managementBasePath = "/v0/management"
|
||||
resourcePluginBasePath = "/v0/resource/plugins"
|
||||
legacyPluginRoutePrefix = "/plugins"
|
||||
)
|
||||
|
||||
type managementRouteRecord struct {
|
||||
pluginID string
|
||||
route pluginapi.ManagementRoute
|
||||
}
|
||||
|
||||
// RegisterManagementRoutes rebuilds the plugin-owned Management API route table.
|
||||
type resourceRouteRecord struct {
|
||||
pluginID string
|
||||
route pluginapi.ResourceRoute
|
||||
}
|
||||
|
||||
// RegisterManagementRoutes rebuilds the plugin-owned Management API and resource route tables.
|
||||
func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string]struct{}) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
nextRoutes := make(map[string]managementRouteRecord)
|
||||
nextResources := make(map[string]resourceRouteRecord)
|
||||
for _, record := range h.Snapshot().records {
|
||||
plugin := record.plugin.Capabilities.ManagementAPI
|
||||
if plugin == nil || h.isPluginFused(record.id) {
|
||||
@@ -36,12 +46,19 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string
|
||||
log.Warnf("pluginhost: management registrar %s failed: %v", record.id, errRegister)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, item := range resp.Routes {
|
||||
method, path, okRoute := normalizeManagementRoute(item)
|
||||
if !okRoute {
|
||||
log.Warnf("pluginhost: plugin %s declared invalid management route %s %s", record.id, item.Method, item.Path)
|
||||
continue
|
||||
}
|
||||
if routeDeclaresLegacyMenuResource(method, item) {
|
||||
if !registerResourceRoute(nextResources, record.id, resourceRouteFromManagementRoute(item)) {
|
||||
log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := managementRouteKey(method, path)
|
||||
if _, exists := reserved[key]; exists {
|
||||
log.Warnf("pluginhost: plugin %s management route %s conflicts with an existing route and was skipped", record.id, key)
|
||||
@@ -58,10 +75,17 @@ func (h *Host) RegisterManagementRoutes(ctx context.Context, reserved map[string
|
||||
route: item,
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range resp.Resources {
|
||||
if !registerResourceRoute(nextResources, record.id, item) {
|
||||
log.Warnf("pluginhost: plugin %s declared invalid resource route %s", record.id, item.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.managementRoutes = nextRoutes
|
||||
h.resourceRoutes = nextResources
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -77,8 +101,9 @@ func (h *Host) callManagementRegistrar(ctx context.Context, record capabilityRec
|
||||
}
|
||||
}()
|
||||
return plugin.RegisterManagement(ctx, pluginapi.ManagementRegistrationRequest{
|
||||
Plugin: record.meta,
|
||||
BasePath: managementBasePath,
|
||||
Plugin: record.meta,
|
||||
BasePath: managementBasePath,
|
||||
ResourceBasePath: resourcePluginBasePath + "/" + record.id,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,6 +143,75 @@ func normalizeManagementRoute(item pluginapi.ManagementRoute) (string, string, b
|
||||
return method, fullPath, true
|
||||
}
|
||||
|
||||
func routeDeclaresLegacyMenuResource(method string, item pluginapi.ManagementRoute) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(method), http.MethodGet) && strings.TrimSpace(item.Menu) != ""
|
||||
}
|
||||
|
||||
func resourceRouteFromManagementRoute(item pluginapi.ManagementRoute) pluginapi.ResourceRoute {
|
||||
return pluginapi.ResourceRoute{
|
||||
Path: item.Path,
|
||||
Menu: item.Menu,
|
||||
Description: item.Description,
|
||||
Handler: item.Handler,
|
||||
}
|
||||
}
|
||||
|
||||
func registerResourceRoute(routes map[string]resourceRouteRecord, pluginID string, item pluginapi.ResourceRoute) bool {
|
||||
path, okRoute := normalizeResourceRoute(pluginID, item)
|
||||
if !okRoute {
|
||||
return false
|
||||
}
|
||||
key := managementRouteKey(http.MethodGet, path)
|
||||
if _, exists := routes[key]; exists {
|
||||
log.Warnf("pluginhost: plugin %s resource route %s conflicts with a higher-priority plugin and was skipped", pluginID, key)
|
||||
return true
|
||||
}
|
||||
item.Path = path
|
||||
routes[key] = resourceRouteRecord{
|
||||
pluginID: pluginID,
|
||||
route: item,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeResourceRoute(pluginID string, item pluginapi.ResourceRoute) (string, bool) {
|
||||
if item.Handler == nil {
|
||||
return "", false
|
||||
}
|
||||
pluginID = strings.TrimSpace(pluginID)
|
||||
if pluginID == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
path := strings.TrimSpace(item.Path)
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
pluginBasePath := resourcePluginBasePath + "/" + pluginID
|
||||
if strings.HasPrefix(path, pluginBasePath+"/") {
|
||||
path = strings.TrimPrefix(path, pluginBasePath)
|
||||
} else if strings.HasPrefix(path, legacyPluginRoutePrefix+"/"+pluginID+"/") {
|
||||
path = strings.TrimPrefix(path, legacyPluginRoutePrefix+"/"+pluginID)
|
||||
}
|
||||
path = strings.TrimRight(path, "/")
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
fullPath := pluginBasePath + path
|
||||
if !strings.HasPrefix(fullPath, pluginBasePath+"/") {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsAny(fullPath, " \t\r\n") || strings.Contains(fullPath, ":") || strings.Contains(fullPath, "*") || strings.Contains(fullPath, "..") {
|
||||
return "", false
|
||||
}
|
||||
return fullPath, true
|
||||
}
|
||||
|
||||
func managementRouteKey(method, path string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
|
||||
}
|
||||
@@ -178,6 +272,50 @@ func (h *Host) ServeManagementHTTP(w http.ResponseWriter, r *http.Request) bool
|
||||
return true
|
||||
}
|
||||
|
||||
// ServeResourceHTTP dispatches an unauthenticated browser-navigable resource request to a plugin route.
|
||||
func (h *Host) ServeResourceHTTP(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h == nil || w == nil || r == nil || r.URL == nil {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(r.Method, http.MethodGet) {
|
||||
return false
|
||||
}
|
||||
key := managementRouteKey(http.MethodGet, r.URL.Path)
|
||||
h.mu.Lock()
|
||||
record, okRoute := h.resourceRoutes[key]
|
||||
h.mu.Unlock()
|
||||
if !okRoute || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, errHandle := h.callResourceHandler(r.Context(), record, pluginapi.ManagementRequest{
|
||||
Method: http.MethodGet,
|
||||
Path: r.URL.Path,
|
||||
Headers: cloneHeader(r.Header),
|
||||
Query: cloneValues(r.URL.Query()),
|
||||
})
|
||||
if errHandle != nil {
|
||||
log.Warnf("pluginhost: resource handler %s failed: %v", record.pluginID, errHandle)
|
||||
http.Error(w, "plugin resource handler failed", http.StatusBadGateway)
|
||||
return true
|
||||
}
|
||||
|
||||
for keyHeader, values := range resp.Headers {
|
||||
for _, value := range values {
|
||||
w.Header().Add(keyHeader, value)
|
||||
}
|
||||
}
|
||||
statusCode := resp.StatusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
w.WriteHeader(statusCode)
|
||||
if _, errWrite := w.Write(resp.Body); errWrite != nil {
|
||||
log.Warnf("pluginhost: failed to write plugin resource response: %v", errWrite)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Host) callManagementHandler(ctx context.Context, record managementRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
|
||||
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
|
||||
return pluginapi.ManagementResponse{}, nil
|
||||
@@ -191,3 +329,17 @@ func (h *Host) callManagementHandler(ctx context.Context, record managementRoute
|
||||
}()
|
||||
return record.route.Handler.HandleManagement(ctx, req)
|
||||
}
|
||||
|
||||
func (h *Host) callResourceHandler(ctx context.Context, record resourceRouteRecord, req pluginapi.ManagementRequest) (resp pluginapi.ManagementResponse, err error) {
|
||||
if h == nil || record.route.Handler == nil || h.isPluginFused(record.pluginID) {
|
||||
return pluginapi.ManagementResponse{}, nil
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
h.fusePlugin(record.pluginID, "ResourceHandler.HandleManagement", recovered)
|
||||
resp = pluginapi.ManagementResponse{}
|
||||
err = fmt.Errorf("resource handler panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return record.route.Handler.HandleManagement(ctx, req)
|
||||
}
|
||||
|
||||
@@ -91,18 +91,77 @@ func TestManagementHandlerPanicFusesPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
|
||||
plugin := &managementPluginDouble{
|
||||
routes: []pluginapi.ManagementRoute{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/plugins/menu/status",
|
||||
func TestServeResourceHTTPDispatchesPluginResource(t *testing.T) {
|
||||
host := newHostWithRecords(capabilityRecord{
|
||||
id: "resource",
|
||||
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
|
||||
ManagementAPI: &managementPluginDouble{resources: []pluginapi.ResourceRoute{{
|
||||
Path: "/status",
|
||||
Menu: "Status",
|
||||
Description: "Shows plugin status.",
|
||||
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
|
||||
return pluginapi.ManagementResponse{}, nil
|
||||
Handler: managementHandlerFunc(func(_ context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
|
||||
if req.Path != "/v0/resource/plugins/resource/status" {
|
||||
t.Fatalf("resource request path = %q, want normalized resource path", req.Path)
|
||||
}
|
||||
return pluginapi.ManagementResponse{
|
||||
Headers: http.Header{"Content-Type": []string{"text/html; charset=utf-8"}},
|
||||
Body: []byte("<!doctype html><title>resource</title>"),
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
}}},
|
||||
}},
|
||||
})
|
||||
host.RegisterManagementRoutes(context.Background(), nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/resource/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
if !host.ServeResourceHTTP(rec, req) {
|
||||
t.Fatal("ServeResourceHTTP() = false, want true")
|
||||
}
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "<!doctype html><title>resource</title>" {
|
||||
t.Fatalf("response = %d %q, want 200 html", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
|
||||
t.Fatalf("Content-Type = %q, want text/html; charset=utf-8", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyGETManagementMenuRegistersAsResource(t *testing.T) {
|
||||
host := newHostWithRecords(capabilityRecord{
|
||||
id: "legacy",
|
||||
plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
|
||||
ManagementAPI: &managementPluginDouble{routes: []pluginapi.ManagementRoute{{
|
||||
Method: http.MethodGet,
|
||||
Path: "/plugins/legacy/status",
|
||||
Menu: "Legacy Status",
|
||||
Description: "Shows legacy plugin status.",
|
||||
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
|
||||
return pluginapi.ManagementResponse{Body: []byte("legacy")}, nil
|
||||
}),
|
||||
}}},
|
||||
}},
|
||||
})
|
||||
host.RegisterManagementRoutes(context.Background(), nil)
|
||||
|
||||
managementReq := httptest.NewRequest(http.MethodGet, "/v0/management/plugins/legacy/status", nil)
|
||||
managementRec := httptest.NewRecorder()
|
||||
if host.ServeManagementHTTP(managementRec, managementReq) {
|
||||
t.Fatal("legacy menu route was served as Management API route")
|
||||
}
|
||||
|
||||
resourceReq := httptest.NewRequest(http.MethodGet, "/v0/resource/plugins/legacy/status", nil)
|
||||
resourceRec := httptest.NewRecorder()
|
||||
if !host.ServeResourceHTTP(resourceRec, resourceReq) {
|
||||
t.Fatal("legacy menu route was not served as resource route")
|
||||
}
|
||||
if resourceRec.Body.String() != "legacy" {
|
||||
t.Fatalf("resource body = %q, want legacy", resourceRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisteredPluginsIncludesResourceMenus(t *testing.T) {
|
||||
plugin := &managementPluginDouble{
|
||||
routes: []pluginapi.ManagementRoute{
|
||||
{
|
||||
Method: http.MethodGet,
|
||||
Path: "/plugins/menu/hidden",
|
||||
@@ -110,11 +169,12 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
|
||||
return pluginapi.ManagementResponse{}, nil
|
||||
}),
|
||||
},
|
||||
},
|
||||
resources: []pluginapi.ResourceRoute{
|
||||
{
|
||||
Method: http.MethodPost,
|
||||
Path: "/plugins/menu/run",
|
||||
Menu: "Run",
|
||||
Description: "Runs a plugin action.",
|
||||
Path: "/status",
|
||||
Menu: "Status",
|
||||
Description: "Shows plugin status.",
|
||||
Handler: managementHandlerFunc(func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
|
||||
return pluginapi.ManagementResponse{}, nil
|
||||
}),
|
||||
@@ -136,17 +196,18 @@ func TestRegisteredPluginsIncludesGETManagementMenus(t *testing.T) {
|
||||
t.Fatalf("RegisteredPlugins()[0].Menus = %#v, want one visible GET menu", plugins[0].Menus)
|
||||
}
|
||||
menu := plugins[0].Menus[0]
|
||||
if menu.Path != "/v0/management/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
|
||||
if menu.Path != "/v0/resource/plugins/menu/status" || menu.Menu != "Status" || menu.Description != "Shows plugin status." {
|
||||
t.Fatalf("menu = %#v, want normalized status menu", menu)
|
||||
}
|
||||
}
|
||||
|
||||
type managementPluginDouble struct {
|
||||
routes []pluginapi.ManagementRoute
|
||||
routes []pluginapi.ManagementRoute
|
||||
resources []pluginapi.ResourceRoute
|
||||
}
|
||||
|
||||
func (p *managementPluginDouble) RegisterManagement(context.Context, pluginapi.ManagementRegistrationRequest) (pluginapi.ManagementRegistrationResponse, error) {
|
||||
return pluginapi.ManagementRegistrationResponse{Routes: p.routes}, nil
|
||||
return pluginapi.ManagementRegistrationResponse{Routes: p.routes, Resources: p.resources}, nil
|
||||
}
|
||||
|
||||
type managementHandlerFunc func(context.Context, pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error)
|
||||
|
||||
@@ -498,7 +498,12 @@ func (a *rpcPluginAdapter) RegisterManagement(ctx context.Context, req pluginapi
|
||||
route.Handler = a
|
||||
routes = append(routes, route)
|
||||
}
|
||||
return pluginapi.ManagementRegistrationResponse{Routes: routes}, nil
|
||||
resources := make([]pluginapi.ResourceRoute, 0, len(resp.Resources))
|
||||
for _, route := range resp.Resources {
|
||||
route.Handler = a
|
||||
resources = append(resources, route)
|
||||
}
|
||||
return pluginapi.ManagementRegistrationResponse{Routes: routes, Resources: resources}, nil
|
||||
}
|
||||
|
||||
func (a *rpcPluginAdapter) HandleManagement(ctx context.Context, req pluginapi.ManagementRequest) (pluginapi.ManagementResponse, error) {
|
||||
|
||||
@@ -103,7 +103,8 @@ type rpcThinkingApplyRequest struct {
|
||||
}
|
||||
|
||||
type rpcManagementRegistrationResponse struct {
|
||||
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
|
||||
Routes []pluginapi.ManagementRoute `json:"routes,omitempty"`
|
||||
Resources []pluginapi.ResourceRoute `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
type rpcEmptyResponse struct{}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package pluginhost
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -29,7 +28,7 @@ type RegisteredPluginInfo struct {
|
||||
Menus []RegisteredPluginMenu
|
||||
}
|
||||
|
||||
// RegisteredPluginMenu describes a plugin-owned GET Management API menu entry.
|
||||
// RegisteredPluginMenu describes a plugin-owned resource menu entry.
|
||||
type RegisteredPluginMenu struct {
|
||||
Path string
|
||||
Menu string
|
||||
@@ -67,10 +66,7 @@ func (h *Host) registeredPluginMenus() map[string][]RegisteredPluginMenu {
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, record := range h.managementRoutes {
|
||||
if !strings.EqualFold(strings.TrimSpace(record.route.Method), http.MethodGet) {
|
||||
continue
|
||||
}
|
||||
for _, record := range h.resourceRoutes {
|
||||
menu := strings.TrimSpace(record.route.Menu)
|
||||
if menu == "" {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user