mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-09 12:00:47 +00:00
feat(task): replace built-in task adaptors with a sandboxed JS plugin system (#7076)
This commit is contained in:
@@ -0,0 +1,931 @@
|
||||
package jsplugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
type RouteType string
|
||||
|
||||
const (
|
||||
RouteTypeSubmit RouteType = "submit"
|
||||
RouteTypeQuery RouteType = "query"
|
||||
RouteTypeDynamic RouteType = "dynamic"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Type RouteType `json:"type"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Decode string `json:"decode,omitempty"`
|
||||
Render string `json:"render,omitempty"`
|
||||
TaskIDParam string `json:"taskIdParam,omitempty"`
|
||||
// Models restricts this route to the listed models. The host matches the
|
||||
// canonical top-level "model" body field before any JS hook runs; empty
|
||||
// means unrestricted. Must be a subset of meta.models.
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
// ProtocolClaim is one entry of meta.protocols. Models narrows the protocol's
|
||||
// endpoint bindings to a subset of meta.models; empty binds every model.
|
||||
// Supports names the request forms a mode-bearing protocol accepts; decode
|
||||
// and normalize rewrite it into host-table order.
|
||||
type ProtocolClaim struct {
|
||||
Name string `json:"name"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
Supports []string `json:"supports,omitempty"`
|
||||
objectForm bool
|
||||
}
|
||||
|
||||
// ProtocolMode is one client request form a host protocol operation accepts
|
||||
// and the plugin hook that implements it.
|
||||
type ProtocolMode struct {
|
||||
Name string
|
||||
Hook string
|
||||
}
|
||||
|
||||
type BodyKind string
|
||||
|
||||
const (
|
||||
BodyNone BodyKind = "none"
|
||||
BodyJSON BodyKind = "json"
|
||||
BodyForm BodyKind = "form"
|
||||
BodyMultipart BodyKind = "multipart"
|
||||
)
|
||||
|
||||
type HostProtocolOperation struct {
|
||||
Name string
|
||||
Methods []string
|
||||
Path string
|
||||
BodyKinds []BodyKind
|
||||
ModelField string
|
||||
RequiredProtocolMembers []string
|
||||
Modes []ProtocolMode
|
||||
RequiredDriverHooks []string
|
||||
}
|
||||
|
||||
type HostProtocolDefinition struct {
|
||||
Name string
|
||||
Operations []HostProtocolOperation
|
||||
}
|
||||
|
||||
var hostProtocols = []HostProtocolDefinition{
|
||||
{Name: "openai_responses", Operations: []HostProtocolOperation{
|
||||
{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/responses", BodyKinds: []BodyKind{BodyJSON}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}, Modes: []ProtocolMode{{Name: "stream", Hook: "renderEvents"}, {Name: "sync", Hook: "renderFinal"}, {Name: "background", Hook: "renderFinal"}}},
|
||||
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/responses/:response_id", BodyKinds: []BodyKind{BodyNone}},
|
||||
}},
|
||||
{Name: "openai_video", Operations: []HostProtocolOperation{
|
||||
{Name: "create", Methods: []string{http.MethodPost}, Path: "/v1/videos", BodyKinds: []BodyKind{BodyJSON, BodyMultipart}, ModelField: "model", RequiredProtocolMembers: []string{"decodeRequest"}},
|
||||
{Name: "retrieve", Methods: []string{http.MethodGet}, Path: "/v1/videos/:task_id", BodyKinds: []BodyKind{BodyNone}, RequiredProtocolMembers: []string{"render"}},
|
||||
{Name: "content", Methods: []string{http.MethodGet, http.MethodHead}, Path: "/v1/videos/:task_id/content", BodyKinds: []BodyKind{BodyNone}, RequiredDriverHooks: []string{"listArtifacts", "buildContentRequest"}},
|
||||
}},
|
||||
}
|
||||
|
||||
func HostProtocol(name string) (HostProtocolDefinition, bool) {
|
||||
for _, definition := range hostProtocols {
|
||||
if definition.Name == name {
|
||||
return definition, true
|
||||
}
|
||||
}
|
||||
return HostProtocolDefinition{}, false
|
||||
}
|
||||
|
||||
func HostProtocols() []HostProtocolDefinition {
|
||||
definitions := make([]HostProtocolDefinition, len(hostProtocols))
|
||||
for index, definition := range hostProtocols {
|
||||
definitions[index] = definition
|
||||
definitions[index].Operations = append([]HostProtocolOperation(nil), definition.Operations...)
|
||||
for operationIndex := range definitions[index].Operations {
|
||||
operation := &definitions[index].Operations[operationIndex]
|
||||
operation.Methods = append([]string(nil), operation.Methods...)
|
||||
operation.BodyKinds = append([]BodyKind(nil), operation.BodyKinds...)
|
||||
operation.RequiredProtocolMembers = append([]string(nil), operation.RequiredProtocolMembers...)
|
||||
operation.Modes = append([]ProtocolMode(nil), operation.Modes...)
|
||||
operation.RequiredDriverHooks = append([]string(nil), operation.RequiredDriverHooks...)
|
||||
}
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
// DefinedModes returns each distinct mode on the protocol in host-table order.
|
||||
func (d HostProtocolDefinition) DefinedModes() []ProtocolMode {
|
||||
seen := make(map[string]struct{})
|
||||
modes := make([]ProtocolMode, 0)
|
||||
for _, operation := range d.Operations {
|
||||
for _, mode := range operation.Modes {
|
||||
if _, exists := seen[mode.Name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[mode.Name] = struct{}{}
|
||||
modes = append(modes, mode)
|
||||
}
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
func orderProtocolSupports(protocol string, supports []string) []string {
|
||||
if len(supports) == 0 {
|
||||
return supports
|
||||
}
|
||||
definition, ok := HostProtocol(protocol)
|
||||
if !ok {
|
||||
return supports
|
||||
}
|
||||
rank := make(map[string]int)
|
||||
for index, mode := range definition.DefinedModes() {
|
||||
rank[mode.Name] = index
|
||||
}
|
||||
ordered := append([]string(nil), supports...)
|
||||
slices.SortStableFunc(ordered, func(left, right string) int {
|
||||
leftRank, leftKnown := rank[left]
|
||||
rightRank, rightKnown := rank[right]
|
||||
switch {
|
||||
case leftKnown && rightKnown:
|
||||
if leftRank < rightRank {
|
||||
return -1
|
||||
}
|
||||
if leftRank > rightRank {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case leftKnown:
|
||||
return -1
|
||||
case rightKnown:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
func LookupHostProtocolOperation(method, path string) (string, HostProtocolOperation, bool) {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
for _, definition := range hostProtocols {
|
||||
for _, operation := range definition.Operations {
|
||||
if operation.Path != path || operation.ModelField == "" {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(operation.Methods, method) {
|
||||
return definition.Name, operation, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", HostProtocolOperation{}, false
|
||||
}
|
||||
|
||||
type RouteBinding struct {
|
||||
Plugin *LoadedPlugin
|
||||
Route Route
|
||||
}
|
||||
|
||||
type ProtocolBinding struct {
|
||||
Plugin *LoadedPlugin
|
||||
Protocol string
|
||||
Operation HostProtocolOperation
|
||||
Model string
|
||||
}
|
||||
|
||||
const (
|
||||
ContextKeyPinnedPlugin = "task_plugin_pinned_plugin"
|
||||
ContextKeyPinnedRoute = "task_plugin_pinned_route"
|
||||
ContextKeyPinnedEndpoint = "task_plugin_pinned_endpoint"
|
||||
ContextKeyRouteRequest = "task_plugin_route_request"
|
||||
ContextKeyProtocolRequest = "task_plugin_protocol_request"
|
||||
)
|
||||
|
||||
type PinnedPlugin struct {
|
||||
Generation *RoutingGeneration
|
||||
Plugin *LoadedPlugin
|
||||
}
|
||||
|
||||
type PinnedRoute struct {
|
||||
Generation *RoutingGeneration
|
||||
Plugin *LoadedPlugin
|
||||
Route Route
|
||||
}
|
||||
|
||||
// PinnedEndpoint carries the exact generation and endpoint candidates selected
|
||||
// before distribution. Plugin initially names the deterministic request parser;
|
||||
// distribution may rebind it to another candidate from the same generation
|
||||
// when multiple legacy providers expose the same model.
|
||||
type PinnedEndpoint struct {
|
||||
Generation *RoutingGeneration
|
||||
Plugin *LoadedPlugin
|
||||
Protocol string
|
||||
Operation HostProtocolOperation
|
||||
Model string
|
||||
Candidates []ProtocolBinding
|
||||
}
|
||||
|
||||
// RouteRequestContext is the canonical request view exposed to declarative
|
||||
// routing hooks. RequestBody contains decoded JSON or multipart text fields;
|
||||
// raw binary and multipart file bytes remain host-owned.
|
||||
type RouteRequestContext struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Params map[string]string `json:"params"`
|
||||
Query map[string][]string `json:"query"`
|
||||
Body any `json:"body"`
|
||||
Files []map[string]any `json:"-"`
|
||||
RequestBody any `json:"-"`
|
||||
}
|
||||
|
||||
func (r RouteRequestContext) JSValue() map[string]any {
|
||||
params := make(map[string]string, len(r.Params))
|
||||
for key, value := range r.Params {
|
||||
params[key] = value
|
||||
}
|
||||
query := make(map[string][]string, len(r.Query))
|
||||
for key, values := range r.Query {
|
||||
query[key] = append([]string(nil), values...)
|
||||
}
|
||||
return map[string]any{
|
||||
"path": r.Path,
|
||||
"method": r.Method,
|
||||
"params": params,
|
||||
"query": query,
|
||||
"body": clonePluginRequestValue(r.Body),
|
||||
}
|
||||
}
|
||||
|
||||
func clonePluginRequestValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
cloned := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
cloned[key] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []any:
|
||||
cloned := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item)
|
||||
}
|
||||
return cloned
|
||||
case []string:
|
||||
return append([]string(nil), typed...)
|
||||
case map[string][]string:
|
||||
cloned := make(map[string][]string, len(typed))
|
||||
for key, values := range typed {
|
||||
cloned[key] = append([]string(nil), values...)
|
||||
}
|
||||
return cloned
|
||||
case []map[string]any:
|
||||
cloned := make([]map[string]any, len(typed))
|
||||
for index, item := range typed {
|
||||
cloned[index] = clonePluginRequestValue(item).(map[string]any)
|
||||
}
|
||||
return cloned
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
type ProtocolRequestContext struct {
|
||||
RouteRequestContext
|
||||
Protocol string `json:"protocol"`
|
||||
Operation string `json:"operation"`
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
func (p ProtocolRequestContext) JSValue() map[string]any {
|
||||
value := p.RouteRequestContext.JSValue()
|
||||
value["protocol"] = p.Protocol
|
||||
value["operation"] = p.Operation
|
||||
value["model"] = p.Model
|
||||
value["stream"] = p.Stream
|
||||
return value
|
||||
}
|
||||
|
||||
// SupportsHostProtocol reports whether the current host release has a
|
||||
// concrete wire/state machine for an otherwise valid manifest endpoint.
|
||||
func SupportsHostProtocol(protocol string) bool { _, ok := HostProtocol(protocol); return ok }
|
||||
|
||||
// RoutingGeneration is an immutable, request-pinnable view of all effective
|
||||
// plugins and their deterministic routing indexes.
|
||||
type RoutingGeneration struct {
|
||||
Number uint64
|
||||
PublishedAt time.Time
|
||||
|
||||
byKey map[string]*LoadedPlugin
|
||||
byModel map[string]*LoadedPlugin
|
||||
byChannelType map[int]*LoadedPlugin
|
||||
routeIndex map[string]RouteBinding
|
||||
protocolIndex map[string][]ProtocolBinding
|
||||
plugins []*LoadedPlugin
|
||||
routes []RouteBinding
|
||||
runtime http.Handler
|
||||
retainCurrent map[string]struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
routeMethodPattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE)$`)
|
||||
pathNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
staticSegment = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
||||
memberNamePattern = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
|
||||
)
|
||||
|
||||
var reservedRouteNamespaces = []string{
|
||||
"/api",
|
||||
"/assets",
|
||||
"/setup",
|
||||
"/v1/tasks",
|
||||
"/console",
|
||||
"/login",
|
||||
"/forbidden",
|
||||
"/sign-in",
|
||||
"/sign-up",
|
||||
"/forgot-password",
|
||||
"/oauth",
|
||||
"/otp",
|
||||
"/register",
|
||||
"/reset",
|
||||
"/privacy-policy",
|
||||
"/user-agreement",
|
||||
"/about",
|
||||
"/pricing",
|
||||
"/rankings",
|
||||
"/user",
|
||||
"/401",
|
||||
"/403",
|
||||
"/404",
|
||||
"/500",
|
||||
"/503",
|
||||
"/chat2link",
|
||||
"/system-settings",
|
||||
"/channels",
|
||||
"/chat",
|
||||
"/dashboard",
|
||||
"/errors",
|
||||
"/keys",
|
||||
"/models",
|
||||
"/playground",
|
||||
"/profile",
|
||||
"/redemption-codes",
|
||||
"/subscriptions",
|
||||
"/system-info",
|
||||
"/task-plugins",
|
||||
"/usage-logs",
|
||||
"/users",
|
||||
"/wallet",
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) Get(key string) (*LoadedPlugin, bool) {
|
||||
if g == nil {
|
||||
return nil, false
|
||||
}
|
||||
plugin, ok := g.byKey[key]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) GetByChannelType(channelType int) (*LoadedPlugin, bool) {
|
||||
if g == nil || channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
|
||||
return nil, false
|
||||
}
|
||||
plugin, ok := g.byChannelType[channelType]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// GetByModel returns the deterministic effective plugin metadata used for a
|
||||
// model-level host concern such as billing. When multiple providers expose the
|
||||
// same model name, the first plugin in generation order owns that shared
|
||||
// metadata view.
|
||||
func (g *RoutingGeneration) GetByModel(model string) (*LoadedPlugin, bool) {
|
||||
if g == nil || model == "" {
|
||||
return nil, false
|
||||
}
|
||||
plugin, ok := g.byModel[model]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// LookupDeclaredRoute resolves a manifest path declaration. It does not match
|
||||
// an incoming concrete URL; runtime matching is delegated to Gin.
|
||||
func (g *RoutingGeneration) LookupDeclaredRoute(method, path string) (RouteBinding, bool) {
|
||||
if g == nil {
|
||||
return RouteBinding{}, false
|
||||
}
|
||||
normalizedMethod, err := normalizeRouteMethod(method)
|
||||
if err != nil {
|
||||
return RouteBinding{}, false
|
||||
}
|
||||
shape, err := routePathShape(path)
|
||||
if err != nil {
|
||||
return RouteBinding{}, false
|
||||
}
|
||||
binding, ok := g.routeIndex[normalizedMethod+" "+shape]
|
||||
return binding, ok
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) LookupEndpoint(method, path, model string) (ProtocolBinding, bool) {
|
||||
if g == nil {
|
||||
return ProtocolBinding{}, false
|
||||
}
|
||||
normalizedMethod, err := normalizeRouteMethod(method)
|
||||
if err != nil {
|
||||
return ProtocolBinding{}, false
|
||||
}
|
||||
bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
|
||||
if len(bindings) == 0 {
|
||||
return ProtocolBinding{}, false
|
||||
}
|
||||
return bindings[0], true
|
||||
}
|
||||
|
||||
// LookupEndpointCandidates returns every legacy provider implementation that
|
||||
// can serve one shared model endpoint. Candidate order is deterministic and
|
||||
// the first binding is the parser used before channel distribution.
|
||||
func (g *RoutingGeneration) LookupEndpointCandidates(method, path, model string) []ProtocolBinding {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
normalizedMethod, err := normalizeRouteMethod(method)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
bindings := g.protocolIndex[endpointIndexKey(normalizedMethod, path, model)]
|
||||
return append([]ProtocolBinding(nil), bindings...)
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) Plugins() []*LoadedPlugin {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]*LoadedPlugin(nil), g.plugins...)
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) Routes() []RouteBinding {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]RouteBinding(nil), g.routes...)
|
||||
}
|
||||
|
||||
// RuntimeHandler is the inner router built for this exact generation. It is
|
||||
// published in the same atomic pointer as the routing indexes.
|
||||
func (g *RoutingGeneration) RuntimeHandler() http.Handler {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
return g.runtime
|
||||
}
|
||||
|
||||
func (g *RoutingGeneration) RetainsIncumbent(key string) bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
_, retained := g.retainCurrent[key]
|
||||
return retained
|
||||
}
|
||||
|
||||
// RebuildWithPlugins creates a generation from the supplied exact plugin
|
||||
// pointers. It is used when a rejected hot update must retain the incumbent
|
||||
// runtime object for that key.
|
||||
func (g *RoutingGeneration) RebuildWithPlugins(plugins []*LoadedPlugin) (*RoutingGeneration, error) {
|
||||
if g == nil {
|
||||
return nil, fmt.Errorf("cannot rebuild a nil routing generation")
|
||||
}
|
||||
byKey := make(map[string]*LoadedPlugin, len(plugins))
|
||||
for _, plugin := range plugins {
|
||||
if plugin == nil {
|
||||
return nil, fmt.Errorf("cannot rebuild routing generation with a nil plugin")
|
||||
}
|
||||
if _, exists := g.byKey[plugin.Meta.Key]; !exists {
|
||||
return nil, fmt.Errorf("plugin %q is not present in routing generation %d", plugin.Meta.Key, g.Number)
|
||||
}
|
||||
if _, duplicate := byKey[plugin.Meta.Key]; duplicate {
|
||||
return nil, fmt.Errorf("plugin %q appears more than once in routing generation rebuild", plugin.Meta.Key)
|
||||
}
|
||||
byKey[plugin.Meta.Key] = plugin
|
||||
}
|
||||
rebuilt, err := buildRoutingGeneration(byKey, nil, false, g.Number)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rebuilt.PublishedAt = g.PublishedAt
|
||||
rebuilt.retainCurrent = cloneStringSet(g.retainCurrent)
|
||||
return rebuilt, nil
|
||||
}
|
||||
|
||||
// WithRuntime returns a shallow immutable copy carrying the prepared inner
|
||||
// handler. Callers use it before publication; published generations must not
|
||||
// be mutated.
|
||||
func (g *RoutingGeneration) WithRuntime(handler http.Handler) *RoutingGeneration {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
prepared := *g
|
||||
prepared.runtime = handler
|
||||
return &prepared
|
||||
}
|
||||
|
||||
func cloneStringSet(source map[string]struct{}) map[string]struct{} {
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
clone := make(map[string]struct{}, len(source))
|
||||
for key := range source {
|
||||
clone[key] = struct{}{}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func normalizeRouteMethod(method string) (string, error) {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
if !routeMethodPattern.MatchString(method) {
|
||||
return "", fmt.Errorf("plugin route method %q is not supported", method)
|
||||
}
|
||||
return method, nil
|
||||
}
|
||||
|
||||
// NormalizeRoutePath validates the canonical path syntax used by plugin route
|
||||
// declarations. A trailing slash is allowed and remains significant.
|
||||
func NormalizeRoutePath(routePath string) (string, error) {
|
||||
if routePath == "" || routePath[0] != '/' {
|
||||
return "", fmt.Errorf("plugin route path must start with /")
|
||||
}
|
||||
if routePath == "/" {
|
||||
return "", fmt.Errorf("plugin route path / is reserved")
|
||||
}
|
||||
if strings.ContainsAny(routePath, "?#%") {
|
||||
return "", fmt.Errorf("plugin route path %q must not contain a query, fragment, or percent-encoding", routePath)
|
||||
}
|
||||
if strings.Contains(routePath, "//") {
|
||||
return "", fmt.Errorf("plugin route path %q must not contain empty segments", routePath)
|
||||
}
|
||||
|
||||
segments := strings.Split(strings.TrimPrefix(routePath, "/"), "/")
|
||||
seenNames := make(map[string]struct{})
|
||||
for index, segment := range segments {
|
||||
if segment == "" && index == len(segments)-1 {
|
||||
continue
|
||||
}
|
||||
if segment == "." || segment == ".." {
|
||||
return "", fmt.Errorf("plugin route path %q must not contain dot segments", routePath)
|
||||
}
|
||||
if after, ok := strings.CutPrefix(segment, ":"); ok {
|
||||
name := after
|
||||
if !pathNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("plugin route path %q has invalid parameter %q", routePath, segment)
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if after, ok := strings.CutPrefix(segment, "*"); ok {
|
||||
name := after
|
||||
if index != len(segments)-1 || !pathNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("plugin route path %q has an invalid catch-all segment", routePath)
|
||||
}
|
||||
if _, exists := seenNames[name]; exists {
|
||||
return "", fmt.Errorf("plugin route path %q repeats parameter %q", routePath, name)
|
||||
}
|
||||
seenNames[name] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if !staticSegment.MatchString(segment) {
|
||||
return "", fmt.Errorf("plugin route path %q has invalid segment %q", routePath, segment)
|
||||
}
|
||||
}
|
||||
return routePath, nil
|
||||
}
|
||||
|
||||
func routePathShape(routePath string) (string, error) {
|
||||
normalized, err := NormalizeRoutePath(routePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
segments := strings.Split(strings.TrimPrefix(normalized, "/"), "/")
|
||||
for index, segment := range segments {
|
||||
if strings.HasPrefix(segment, ":") {
|
||||
segments[index] = ":"
|
||||
} else if strings.HasPrefix(segment, "*") {
|
||||
segments[index] = "*"
|
||||
}
|
||||
}
|
||||
return "/" + strings.Join(segments, "/"), nil
|
||||
}
|
||||
|
||||
func endpointIndexKey(method, path, model string) string {
|
||||
return method + "\x00" + path + "\x00" + model
|
||||
}
|
||||
|
||||
func intersectingReservedNamespace(routePath string) (string, bool) {
|
||||
for _, namespace := range reservedRouteNamespaces {
|
||||
if routePatternIntersectsNamespace(routePath, namespace) {
|
||||
return namespace, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func routePatternIntersectsNamespace(routePath, namespace string) bool {
|
||||
routeSegments := strings.Split(strings.Trim(strings.TrimPrefix(routePath, "/"), "/"), "/")
|
||||
namespaceSegments := strings.Split(strings.TrimPrefix(namespace, "/"), "/")
|
||||
for index, namespaceSegment := range namespaceSegments {
|
||||
if index >= len(routeSegments) {
|
||||
return false
|
||||
}
|
||||
routeSegment := routeSegments[index]
|
||||
if strings.HasPrefix(routeSegment, "*") {
|
||||
return true
|
||||
}
|
||||
if !strings.HasPrefix(routeSegment, ":") && routeSegment != namespaceSegment {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateRoute(route *Route) error {
|
||||
if route.Method != strings.ToUpper(strings.TrimSpace(route.Method)) {
|
||||
return fmt.Errorf("plugin route method %q must use canonical uppercase spelling", route.Method)
|
||||
}
|
||||
method, err := normalizeRouteMethod(route.Method)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
route.Method = method
|
||||
route.Path, err = NormalizeRoutePath(route.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if namespace, reserved := intersectingReservedNamespace(route.Path); reserved {
|
||||
return fmt.Errorf("plugin route path %q intersects reserved namespace %s", route.Path, namespace)
|
||||
}
|
||||
switch route.Type {
|
||||
case RouteTypeSubmit:
|
||||
if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
|
||||
return fmt.Errorf("submit route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
|
||||
}
|
||||
case RouteTypeQuery:
|
||||
if route.Decode != "" || strings.TrimSpace(route.Render) == "" {
|
||||
return fmt.Errorf("query route %s %s must declare render and must not declare decode", route.Method, route.Path)
|
||||
}
|
||||
if route.Action != "" {
|
||||
return fmt.Errorf("query route %s %s must not declare action", route.Method, route.Path)
|
||||
}
|
||||
if route.TaskIDParam == "" {
|
||||
route.TaskIDParam = "task_id"
|
||||
}
|
||||
if !pathNamePattern.MatchString(route.TaskIDParam) {
|
||||
return fmt.Errorf("query route %s %s has invalid taskIdParam %q", route.Method, route.Path, route.TaskIDParam)
|
||||
}
|
||||
if !pathHasParameter(route.Path, route.TaskIDParam) {
|
||||
return fmt.Errorf("query route %s %s must contain :%s", route.Method, route.Path, route.TaskIDParam)
|
||||
}
|
||||
case RouteTypeDynamic:
|
||||
if route.Decode == "" || route.Render == "" || route.TaskIDParam != "" {
|
||||
return fmt.Errorf("dynamic route %s %s must declare decode and render and must not declare taskIdParam", route.Method, route.Path)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("plugin route %s %s has unsupported type %q", route.Method, route.Path, route.Type)
|
||||
}
|
||||
if route.Decode != "" && !memberNamePattern.MatchString(route.Decode) {
|
||||
return fmt.Errorf("plugin route %s %s has invalid decode %q", route.Method, route.Path, route.Decode)
|
||||
}
|
||||
if route.Render != "" && !memberNamePattern.MatchString(route.Render) {
|
||||
return fmt.Errorf("plugin route %s %s has invalid render %q", route.Method, route.Path, route.Render)
|
||||
}
|
||||
if strings.TrimSpace(route.Action) != route.Action {
|
||||
return fmt.Errorf("plugin route %s %s action must not have surrounding whitespace", route.Method, route.Path)
|
||||
}
|
||||
if len(route.Models) > 0 {
|
||||
if route.Type == RouteTypeQuery {
|
||||
return fmt.Errorf("query route %s %s must not declare models", route.Method, route.Path)
|
||||
}
|
||||
if err := validateModelScope(route.Models, fmt.Sprintf("route %s %s", route.Method, route.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateModelScope enforces the shared rules for route.models and
|
||||
// per-protocol models entries: non-empty canonical names, no duplicates.
|
||||
func validateModelScope(models []string, subject string) error {
|
||||
seen := make(map[string]struct{}, len(models))
|
||||
for _, model := range models {
|
||||
if strings.TrimSpace(model) == "" || strings.TrimSpace(model) != model {
|
||||
return fmt.Errorf("plugin %s models must contain non-empty canonical names", subject)
|
||||
}
|
||||
if _, duplicate := seen[model]; duplicate {
|
||||
return fmt.Errorf("plugin %s models must be unique", subject)
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathHasParameter(routePath, name string) bool {
|
||||
return slices.Contains(strings.Split(routePath, "/"), ":"+name)
|
||||
}
|
||||
|
||||
func ResolveRouteAction(route Route, resolvedAction string) string {
|
||||
if strings.TrimSpace(resolvedAction) != "" {
|
||||
return resolvedAction
|
||||
}
|
||||
return route.Action
|
||||
}
|
||||
|
||||
func buildRoutingGeneration(factory, override map[string]*LoadedPlugin, overrideEnabled bool, number uint64) (*RoutingGeneration, error) {
|
||||
effective := effectivePlugins(factory, override, overrideEnabled)
|
||||
return buildRoutingGenerationFromPlugins(effective, number)
|
||||
}
|
||||
|
||||
func buildRoutingGenerationAdmitting(
|
||||
factory, override map[string]*LoadedPlugin,
|
||||
overrideEnabled bool,
|
||||
number uint64,
|
||||
current *RoutingGeneration,
|
||||
retainCurrent map[string]struct{},
|
||||
) (*RoutingGeneration, map[string]string, error) {
|
||||
candidates := effectivePlugins(factory, override, overrideEnabled)
|
||||
accepted := make(map[string]*LoadedPlugin, len(candidates))
|
||||
currentByKey := make(map[string]*LoadedPlugin)
|
||||
if current != nil {
|
||||
for _, plugin := range current.plugins {
|
||||
currentByKey[plugin.Meta.Key] = plugin
|
||||
}
|
||||
}
|
||||
generation, err := buildRoutingGenerationFromPlugins(accepted, number)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("initialize routing generation: %w", err)
|
||||
}
|
||||
|
||||
unchangedKeys := make([]string, 0)
|
||||
changedKeys := make([]string, 0)
|
||||
newKeys := make([]string, 0)
|
||||
for key, candidate := range candidates {
|
||||
incumbent, exists := currentByKey[key]
|
||||
switch {
|
||||
case exists && candidate == incumbent:
|
||||
unchangedKeys = append(unchangedKeys, key)
|
||||
case exists:
|
||||
changedKeys = append(changedKeys, key)
|
||||
default:
|
||||
newKeys = append(newKeys, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(unchangedKeys)
|
||||
sort.Strings(changedKeys)
|
||||
sort.Strings(newKeys)
|
||||
|
||||
routingErrors := make(map[string]string)
|
||||
orderedKeys := append(unchangedKeys, changedKeys...)
|
||||
orderedKeys = append(orderedKeys, newKeys...)
|
||||
rejectedKeys := make([]string, 0)
|
||||
for _, key := range orderedKeys {
|
||||
candidate := candidates[key]
|
||||
accepted[key] = candidate
|
||||
trial, trialErr := buildRoutingGenerationFromPlugins(accepted, number)
|
||||
if trialErr == nil {
|
||||
generation = trial
|
||||
continue
|
||||
}
|
||||
delete(accepted, key)
|
||||
routingErrors[key] = fmt.Sprintf("plugin %s rejected from routing generation: %v", key, trialErr)
|
||||
rejectedKeys = append(rejectedKeys, key)
|
||||
}
|
||||
|
||||
for _, key := range rejectedKeys {
|
||||
candidate := candidates[key]
|
||||
incumbent, hasIncumbent := currentByKey[key]
|
||||
_, mayRetain := retainCurrent[key]
|
||||
if !hasIncumbent || !mayRetain || incumbent == candidate {
|
||||
continue
|
||||
}
|
||||
accepted[key] = incumbent
|
||||
fallback, fallbackErr := buildRoutingGenerationFromPlugins(accepted, number)
|
||||
if fallbackErr == nil {
|
||||
generation = fallback
|
||||
continue
|
||||
}
|
||||
delete(accepted, key)
|
||||
}
|
||||
generation.retainCurrent = cloneStringSet(retainCurrent)
|
||||
return generation, routingErrors, nil
|
||||
}
|
||||
|
||||
func effectivePlugins(factory, override map[string]*LoadedPlugin, overrideEnabled bool) map[string]*LoadedPlugin {
|
||||
effective := make(map[string]*LoadedPlugin, len(factory)+len(override))
|
||||
maps.Copy(effective, factory)
|
||||
if overrideEnabled {
|
||||
maps.Copy(effective, override)
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
func buildRoutingGenerationFromPlugins(effective map[string]*LoadedPlugin, number uint64) (*RoutingGeneration, error) {
|
||||
keys := make([]string, 0, len(effective))
|
||||
for key := range effective {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
generation := &RoutingGeneration{
|
||||
Number: number,
|
||||
PublishedAt: time.Now(),
|
||||
byKey: make(map[string]*LoadedPlugin, len(effective)),
|
||||
byModel: make(map[string]*LoadedPlugin),
|
||||
byChannelType: make(map[int]*LoadedPlugin),
|
||||
routeIndex: make(map[string]RouteBinding),
|
||||
protocolIndex: make(map[string][]ProtocolBinding),
|
||||
plugins: make([]*LoadedPlugin, 0, len(effective)),
|
||||
}
|
||||
for _, key := range keys {
|
||||
plugin := effective[key]
|
||||
generation.byKey[key] = plugin
|
||||
generation.plugins = append(generation.plugins, plugin)
|
||||
for _, model := range plugin.Meta.Models {
|
||||
if _, exists := generation.byModel[model]; !exists {
|
||||
generation.byModel[model] = plugin
|
||||
}
|
||||
}
|
||||
|
||||
for _, channelType := range plugin.Meta.ChannelTypes {
|
||||
if channelType == 0 || channelType == constant.ChannelTypeTaskPlugin {
|
||||
continue
|
||||
}
|
||||
if other, exists := generation.byChannelType[channelType]; exists {
|
||||
return nil, fmt.Errorf("plugin %s channelType %d conflicts with plugin %s", plugin.Meta.Key, channelType, other.Meta.Key)
|
||||
}
|
||||
generation.byChannelType[channelType] = plugin
|
||||
}
|
||||
|
||||
for _, route := range plugin.Meta.Routes {
|
||||
shape, err := routePathShape(route.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
indexKey := route.Method + " " + shape
|
||||
if other, exists := generation.routeIndex[indexKey]; exists {
|
||||
return nil, fmt.Errorf("plugin %s route %s %s conflicts with plugin %s route %s", plugin.Meta.Key, route.Method, route.Path, other.Plugin.Meta.Key, other.Route.Path)
|
||||
}
|
||||
binding := RouteBinding{Plugin: plugin, Route: route}
|
||||
generation.routeIndex[indexKey] = binding
|
||||
generation.routes = append(generation.routes, binding)
|
||||
}
|
||||
|
||||
for _, claim := range plugin.Meta.Protocols {
|
||||
definition, _ := HostProtocol(claim.Name)
|
||||
boundModels := plugin.Meta.Models
|
||||
if len(claim.Models) > 0 {
|
||||
boundModels = claim.Models
|
||||
}
|
||||
for _, operation := range definition.Operations {
|
||||
if operation.ModelField == "" {
|
||||
continue
|
||||
}
|
||||
for _, method := range operation.Methods {
|
||||
for _, model := range boundModels {
|
||||
indexKey := endpointIndexKey(method, operation.Path, model)
|
||||
bindings := generation.protocolIndex[indexKey]
|
||||
if len(bindings) > 0 {
|
||||
other := bindings[0]
|
||||
legacyProviders := len(plugin.Meta.ChannelTypes) > 0 && len(other.Plugin.Meta.ChannelTypes) > 0
|
||||
if !legacyProviders || claim.Name != other.Protocol {
|
||||
return nil, fmt.Errorf("plugin %s protocol %s %s model %q conflicts with plugin %s", plugin.Meta.Key, method, operation.Path, model, other.Plugin.Meta.Key)
|
||||
}
|
||||
}
|
||||
generation.protocolIndex[indexKey] = append(bindings, ProtocolBinding{Plugin: plugin, Protocol: claim.Name, Operation: operation, Model: model})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return generation, nil
|
||||
}
|
||||
|
||||
// PreflightRoutingConflict reports whether admitting candidate into the
|
||||
// current generation would collide on a channel type, native route, or
|
||||
// protocol-model binding. A same-key entry is replaced first so re-uploading
|
||||
// a plugin (or overriding a factory built-in) does not self-conflict.
|
||||
func PreflightRoutingConflict(current *RoutingGeneration, candidate *LoadedPlugin) error {
|
||||
if candidate == nil {
|
||||
return fmt.Errorf("cannot preflight a nil plugin")
|
||||
}
|
||||
effective := make(map[string]*LoadedPlugin)
|
||||
number := uint64(0)
|
||||
if current != nil {
|
||||
for _, plugin := range current.Plugins() {
|
||||
effective[plugin.Meta.Key] = plugin
|
||||
}
|
||||
number = current.Number
|
||||
}
|
||||
effective[candidate.Meta.Key] = candidate
|
||||
_, err := buildRoutingGenerationFromPlugins(effective, number)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user