Files
pulse/internal/agentcapabilities/projection.go
T
rcourtman 313648b2f1 feat(mcp): add additive fleet-context filtering
get_fleet_context returned the whole registry with no way to narrow it,
so a large fleet produced a payload that exceeded the agent harness's
50KB cap and forced agents to receive or page through healthy resources
that are pure noise from a triage standpoint.

Add optional additive query-param filters — hasFindings, severity,
technology, resourceType — that compose by intersection. All optional
(omitting every filter returns the full fleet, backward compatible);
unknown/unmatched values return 200 with resources: [] (a valid triage
answer, not an error). The hasFindings=true filter is the headline
triage case: show me only what needs attention.

The MCP adapter layer had no GET query-param transport — ProjectCapabilityCall
short-circuited GET after path substitution and dropped all other args.
Close that gap generically: ProjectedCall gains a Query url.Values field
populated from leftover non-path public args for GET/DELETE, and
BuildCapabilityHTTPRequest encodes it onto the request URL. Benefits any
future GET capability with filter args, not just fleet-context. The
resources/list adapter calls fleet-context with empty args and continues
to receive the unfiltered fleet.

The manifest now declares the filters via inputSchema so they are
discoverable through the capability surface the same way add_node's
params are.

- ProjectedCall.Query + GET query-param forwarding (projection.go, http.go)
- fleetContextFilter parsing/matching in HandleFleetContext
- fleetContextInputSchema + InputSchema on the capability
- Regenerated cmd/pulse-mcp/README.md via generate-pulse-intelligence-docs
- Contract updates: api-contracts, ai-runtime, agent-lifecycle, storage-recovery
- Tests: adapter query-forwarding unit tests, 7 filter handler tests
  (hasFindings/severity/technology/resourceType/no-filter/unknown/compose),
  contract pinning inputSchema + end-to-end query forwarding
2026-07-03 18:01:05 +01:00

654 lines
25 KiB
Go

package agentcapabilities
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
)
const (
// FleetContextCapabilityName is the manifest-owned fleet triage read.
FleetContextCapabilityName = "get_fleet_context"
// ResourceContextCapabilityName is the manifest-owned per-resource depth read.
ResourceContextCapabilityName = "get_resource_context"
// ListResourceCapabilitiesCapabilityName is the manifest-owned per-resource
// structured capability surface (names + parameter schemas). Companion to
// get_resource_context: that tool renders capabilities as count-limited prose
// facts (omitting parameter schemas); this tool returns the structured list
// an agent needs to populate plan_action.capabilityName and params.
ListResourceCapabilitiesCapabilityName = "list_resource_capabilities"
// PatrolControlStatusCapabilityName is the manifest-owned Patrol control
// status read. It is count-only and content-safe so external agents can
// orient on the same Patrol -> Assistant -> governed action ->
// verification loop without fetching finding/action identifiers or
// command output first.
PatrolControlStatusCapabilityName = "get_patrol_control_status"
// OperationsLoopStatusCapabilityName is retained as a source compatibility
// alias while the manifest projects Patrol control as the canonical
// external-agent capability.
OperationsLoopStatusCapabilityName = PatrolControlStatusCapabilityName
// ResourceIDArgumentName is the shared path/body argument for canonical
// resource identity in Pulse Intelligence context and action tools.
ResourceIDArgumentName = "resourceId"
// FleetContextCapabilityPath is the manifest-owned fleet triage route.
FleetContextCapabilityPath = "/api/agent/fleet-context"
// ResourceContextCapabilityPath is the manifest-owned per-resource context route.
ResourceContextCapabilityPath = "/api/agent/resource-context/{" + ResourceIDArgumentName + "}"
// ListResourceCapabilitiesCapabilityPath is the manifest-owned per-resource
// capability surface route.
ListResourceCapabilitiesCapabilityPath = "/api/agent/resource-capabilities/{" + ResourceIDArgumentName + "}"
// PatrolControlStatusCapabilityPath is the manifest-owned Patrol control
// status route.
PatrolControlStatusCapabilityPath = "/api/agent/patrol-control/status"
// OperationsLoopStatusCapabilityPath is retained as a source compatibility
// alias while the manifest projects Patrol control as the canonical route.
OperationsLoopStatusCapabilityPath = PatrolControlStatusCapabilityPath
// OperationsLoopStatusCompatibilityPath is the legacy route kept for older
// external-agent clients.
OperationsLoopStatusCompatibilityPath = "/api/agent/operations-loop/status"
// GetOperatorStateCapabilityName is the manifest-owned operator-state read.
GetOperatorStateCapabilityName = "get_operator_state"
// SetOperatorStateCapabilityName is the manifest-owned operator-state write.
SetOperatorStateCapabilityName = "set_operator_state"
// ClearOperatorStateCapabilityName is the manifest-owned operator-state clear.
ClearOperatorStateCapabilityName = "clear_operator_state"
// OperatorStateCapabilityPath is the shared operator-state route template.
OperatorStateCapabilityPath = "/api/resources/{" + ResourceIDArgumentName + "}/operator-state"
// ListNodesCapabilityName is the manifest-owned configured-source list read.
ListNodesCapabilityName = "list_nodes"
// AddNodeCapabilityName is the manifest-owned configured-source create action.
AddNodeCapabilityName = "add_node"
// UpdateNodeCapabilityName is the manifest-owned configured-source update action.
UpdateNodeCapabilityName = "update_node"
// RemoveNodeCapabilityName is the manifest-owned configured-source delete action.
RemoveNodeCapabilityName = "remove_node"
// TestNodeCredentialsCapabilityName is the manifest-owned proposed-source credential validation action.
TestNodeCredentialsCapabilityName = "test_node_credentials"
// TestNodeConnectionCapabilityName is the manifest-owned saved-source connection validation action.
TestNodeConnectionCapabilityName = "test_node_connection"
// RefreshNodeClusterMembershipCapabilityName is the manifest-owned Proxmox cluster endpoint refresh action.
RefreshNodeClusterMembershipCapabilityName = "refresh_node_cluster_membership"
// DiscoverLANCapabilityName is the manifest-owned LAN discovery scan action.
DiscoverLANCapabilityName = "discover_lan"
// ListFindingsCapabilityName is the manifest-owned Patrol finding list read.
ListFindingsCapabilityName = "list_findings"
// AcknowledgeFindingCapabilityName is the manifest-owned Patrol acknowledge write.
AcknowledgeFindingCapabilityName = "acknowledge_finding"
// SnoozeFindingCapabilityName is the manifest-owned Patrol snooze write.
SnoozeFindingCapabilityName = "snooze_finding"
// DismissFindingCapabilityName is the manifest-owned Patrol dismiss write.
DismissFindingCapabilityName = "dismiss_finding"
// ResolveFindingCapabilityName is the manifest-owned Patrol resolve write.
ResolveFindingCapabilityName = "resolve_finding"
// FindingIDArgumentName is the shared argument for Patrol finding identity.
FindingIDArgumentName = "finding_id"
// ResolutionNoteArgumentName is the shared operator/agent explanation for
// why a Patrol finding is being marked resolved.
ResolutionNoteArgumentName = "resolution_note"
// NoteArgumentName is the shared optional operator note argument used by
// Patrol finding lifecycle actions that accept freeform context.
NoteArgumentName = "note"
// PlanActionCapabilityName is the manifest-owned governed action planner.
PlanActionCapabilityName = "plan_action"
// DecideActionCapabilityName is the manifest-owned governed action decision writer.
DecideActionCapabilityName = "decide_action"
// ExecuteActionCapabilityName is the manifest-owned governed action executor.
ExecuteActionCapabilityName = "execute_action"
// RequestIDArgumentName is the shared idempotency/correlation argument.
RequestIDArgumentName = "requestId"
// ActionIDArgumentName is the shared durable governed action id argument.
ActionIDArgumentName = "actionId"
// CapabilityNameArgumentName is the shared target resource capability argument.
CapabilityNameArgumentName = "capabilityName"
// ReasonArgumentName is the shared operator-readable reason argument.
ReasonArgumentName = "reason"
// RequestedByArgumentName is the shared stable requester identity argument.
RequestedByArgumentName = "requestedBy"
// OutcomeArgumentName is the shared approval decision argument.
OutcomeArgumentName = "outcome"
// PlanActionCapabilityPath is the manifest-owned governed action planning route.
PlanActionCapabilityPath = "/api/actions/plan"
// ActionDecisionCapabilityPath is the manifest-owned governed action decision route template.
ActionDecisionCapabilityPath = "/api/actions/{" + ActionIDArgumentName + "}/decision"
// ActionExecutionCapabilityPath is the manifest-owned governed action execution route template.
ActionExecutionCapabilityPath = "/api/actions/{" + ActionIDArgumentName + "}/execute"
// EventSubscriptionCapabilityName is the manifest-owned streaming
// capability. It is intentionally not projected as a request/response tool.
EventSubscriptionCapabilityName = "subscribe_events"
// ToolMetaPulseCapabilityKey is the namespaced _meta key containing
// structured Pulse capability metadata for external-agent tools projected
// from the canonical manifest.
ToolMetaPulseCapabilityKey = "pulse.capability"
)
// ProjectedTool is the manifest-backed request/response tool shape consumed by
// MCP and other external-agent adapters.
type ProjectedTool struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"inputSchema"`
OutputSchema json.RawMessage `json:"outputSchema,omitempty"`
Annotations *ToolBehaviorHints `json:"annotations,omitempty"`
Meta map[string]any `json:"_meta,omitempty"`
}
// ToolBehaviorHints are the external-agent behavior hints projected from the
// manifest action-mode and approval-policy posture. They are hints for clients,
// not an authorization boundary; Pulse API scopes and governed action approval
// remain the enforcement layer.
type ToolBehaviorHints struct {
Title string `json:"title,omitempty"`
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
DestructiveHint *bool `json:"destructiveHint,omitempty"`
IdempotentHint *bool `json:"idempotentHint,omitempty"`
OpenWorldHint *bool `json:"openWorldHint,omitempty"`
}
// pathPlaceholderRE matches `{paramName}` in capability paths.
var pathPlaceholderRE = regexp.MustCompile(`\{([a-zA-Z][a-zA-Z0-9]*)\}`)
// OperationsLoopCapabilityNames returns the canonical capability set required
// for an external agent to run the Pulse Intelligence operations loop without
// falling back to native-only UI state.
func OperationsLoopCapabilityNames() []string {
return []string{
OperationsLoopStatusCapabilityName,
FleetContextCapabilityName,
ResourceContextCapabilityName,
ListFindingsCapabilityName,
PlanActionCapabilityName,
DecideActionCapabilityName,
ExecuteActionCapabilityName,
ResolveFindingCapabilityName,
}
}
// IsRequestResponseCapability reports whether a capability can be projected as
// a request/response external-agent tool.
func IsRequestResponseCapability(cap Capability) bool {
return cap.Name != EventSubscriptionCapabilityName
}
// FindCapability returns the capability with the given manifest name.
func FindCapability(capabilities []Capability, name string) (Capability, bool) {
for _, cap := range capabilities {
if cap.Name == name {
return CloneCapability(cap), true
}
}
return Capability{}, false
}
// CapabilityLookupError is returned when a named manifest capability cannot be
// resolved. Adapters can translate the wording for their own surface while
// preserving the shared lookup semantics.
type CapabilityLookupError struct {
Name string
}
func (e CapabilityLookupError) Error() string {
return fmt.Sprintf("unknown capability: %s", e.Name)
}
// ResolveCapability returns the capability with the given manifest name or a
// typed lookup error when it is absent.
func ResolveCapability(capabilities []Capability, name string) (Capability, error) {
cap, ok := FindCapability(capabilities, name)
if !ok {
return Capability{}, CapabilityLookupError{Name: name}
}
return cap, nil
}
// ResolveRequestResponseCapability returns a manifest capability only when it
// is eligible for request/response tool projection. Streaming capabilities
// intentionally look absent to tools/call-style adapters because they belong
// on the shared SSE subscription path instead.
func ResolveRequestResponseCapability(capabilities []Capability, name string) (Capability, error) {
cap, err := ResolveCapability(capabilities, name)
if err != nil {
return Capability{}, err
}
if !IsRequestResponseCapability(cap) {
return Capability{}, CapabilityLookupError{Name: name}
}
return cap, nil
}
// ProjectTool projects one request/response manifest capability into the common
// external-agent tool shape.
func ProjectTool(cap Capability) (ProjectedTool, bool) {
if !IsRequestResponseCapability(cap) {
return ProjectedTool{}, false
}
return ProjectedTool{
Name: cap.Name,
Title: CapabilityTitle(cap),
Description: ToolDescription(cap),
InputSchema: ToolInputSchema(cap),
OutputSchema: ToolOutputSchema(cap),
Annotations: ToolAnnotations(cap),
Meta: ToolMeta(cap),
}, true
}
// CapabilityTitle returns the manifest-owned human-readable title for a
// capability, falling back to a stable sentence-cased projection of the
// programmatic name for older manifests.
func CapabilityTitle(cap Capability) string {
if title := strings.TrimSpace(cap.Title); title != "" {
return title
}
name := strings.TrimSpace(cap.Name)
if name == "" {
return ""
}
title := strings.Join(strings.Fields(strings.ReplaceAll(name, "_", " ")), " ")
return strings.ToUpper(title[:1]) + title[1:]
}
// ProjectTools projects request/response manifest capabilities into the common
// external-agent tool list shape. Streaming capabilities such as
// subscribe_events are intentionally filtered out.
func ProjectTools(capabilities []Capability) []ProjectedTool {
tools := make([]ProjectedTool, 0, len(capabilities))
for _, cap := range capabilities {
tool, ok := ProjectTool(cap)
if !ok {
continue
}
tools = append(tools, tool)
}
return tools
}
// FindManifestSurfaceToolContract resolves a manifest-published surface tool
// contract by stable operator-surface id.
func FindManifestSurfaceToolContract(manifest Manifest, surfaceID string) (SurfaceToolContract, bool) {
surfaceID = strings.TrimSpace(surfaceID)
if surfaceID == "" {
return SurfaceToolContract{}, false
}
for _, contract := range manifest.SurfaceToolContracts {
if strings.EqualFold(strings.TrimSpace(contract.SurfaceID), surfaceID) {
return contract.NormalizeCollections(), true
}
}
return SurfaceToolContract{}, false
}
// ManifestSurfaceToolCapabilities returns the request/response capabilities
// that a manifest-published external-agent surface is allowed to expose as
// tools. The normalized surface tool contract is authoritative; missing
// surface tool contracts expose no tools rather than inferring from raw
// manifest capabilities.
func ManifestSurfaceToolCapabilities(manifest Manifest, surfaceID string) []Capability {
contract, declared := ResolveManifestSurfaceToolContract(manifest, surfaceID)
if !declared {
return []Capability{}
}
affordances, _ := ManifestSurfaceAffordances(manifest, contract.SurfaceID)
if !affordances.Tools {
return []Capability{}
}
capabilities := make([]Capability, 0, len(contract.ToolNames))
seen := map[string]struct{}{}
for _, rawName := range contract.ToolNames {
name := strings.TrimSpace(rawName)
if name == "" {
continue
}
if _, exists := seen[name]; exists {
continue
}
seen[name] = struct{}{}
capability, err := ResolveRequestResponseCapability(manifest.Capabilities, name)
if err != nil {
continue
}
capabilities = append(capabilities, capability)
}
return capabilities
}
// ProjectManifestSurfaceTools projects the manifest-owned tool allowlist for an
// external-agent surface into the shared tools/list shape.
func ProjectManifestSurfaceTools(manifest Manifest, surfaceID string) []ProjectedTool {
return ProjectTools(ManifestSurfaceToolCapabilities(manifest, surfaceID))
}
// ToolDescription projects manifest-owned capability metadata into the
// description shown by external-agent tool surfaces.
func ToolDescription(cap Capability) string {
governance := NormalizeCapabilityGovernance(cap)
var b strings.Builder
if desc := strings.TrimSpace(cap.Description); desc != "" {
b.WriteString(desc)
b.WriteString("\n\n")
}
b.WriteString("Pulse capability metadata:")
if category := strings.TrimSpace(cap.Category); category != "" {
b.WriteString("\n- category: ")
b.WriteString(category)
}
route := strings.TrimSpace(strings.Join([]string{cap.Method, cap.Path}, " "))
if route != "" {
b.WriteString("\n- route: ")
b.WriteString(route)
}
if scope := strings.TrimSpace(cap.Scope); scope != "" {
b.WriteString("\n- required scope: ")
b.WriteString(scope)
}
b.WriteString("\n- action mode: ")
b.WriteString(string(governance.ActionMode))
b.WriteString("\n- approval policy: ")
b.WriteString(string(governance.ApprovalPolicy))
if requestShape := strings.TrimSpace(cap.RequestBodyShape); requestShape != "" {
b.WriteString("\n- request body: ")
b.WriteString(requestShape)
}
if responseShape := strings.TrimSpace(cap.ResponseShape); responseShape != "" {
b.WriteString("\n- response: ")
b.WriteString(responseShape)
}
if len(cap.ErrorCodes) > 0 {
b.WriteString("\n- stable error codes: ")
b.WriteString(strings.Join(cap.ErrorCodes, ", "))
}
return b.String()
}
// ToolAnnotations projects manifest governance into external-agent tool
// behavior hints so clients can distinguish read-only, non-destructive
// scan/check, and write-capable operations without carrying a Pulse-specific
// policy table.
func ToolAnnotations(cap Capability) *ToolBehaviorHints {
return toolBehaviorHintsForActionMode(NormalizeCapabilityGovernance(cap).ActionMode)
}
// ToolGovernanceBehaviorHints projects registry-owned governance into the same
// neutral behavior-hint vocabulary used by manifest-backed external tools.
func ToolGovernanceBehaviorHints(governance ToolGovernanceDescriptor) *ToolBehaviorHints {
return toolBehaviorHintsForActionMode(NormalizeToolGovernanceDescriptor(governance).ActionMode)
}
func toolBehaviorHintsForActionMode(mode ActionMode) *ToolBehaviorHints {
switch mode {
case ActionModeRead, ActionModeMixed, ActionModeWrite:
default:
mode = ActionModeRead
}
readOnly := mode == ActionModeRead
destructive := mode == ActionModeWrite
idempotent := readOnly
openWorld := true
return &ToolBehaviorHints{
ReadOnlyHint: boolRef(readOnly),
DestructiveHint: boolRef(destructive),
IdempotentHint: boolRef(idempotent),
OpenWorldHint: boolRef(openWorld),
}
}
// CloneToolBehaviorHints returns a detached behavior-hint struct for shared
// Assistant and external-agent tool projections.
func CloneToolBehaviorHints(hints *ToolBehaviorHints) *ToolBehaviorHints {
if hints == nil {
return nil
}
cloned := &ToolBehaviorHints{Title: hints.Title}
cloned.ReadOnlyHint = cloneBoolRef(hints.ReadOnlyHint)
cloned.DestructiveHint = cloneBoolRef(hints.DestructiveHint)
cloned.IdempotentHint = cloneBoolRef(hints.IdempotentHint)
cloned.OpenWorldHint = cloneBoolRef(hints.OpenWorldHint)
return cloned
}
// ToolMeta projects Pulse-specific manifest metadata into the standard _meta
// extension object used by external-agent tools. The human description still
// carries this posture for model selection, while _meta gives clients a
// structured route/scope/governance contract without parsing prose.
func ToolMeta(cap Capability) map[string]any {
governance := NormalizeCapabilityGovernance(cap)
capability := map[string]any{}
setMetaString(capability, "name", cap.Name)
setMetaString(capability, "category", cap.Category)
setMetaString(capability, "scope", cap.Scope)
if requestShape := strings.TrimSpace(cap.RequestBodyShape); requestShape != "" {
capability["requestBodyShape"] = requestShape
}
if responseShape := strings.TrimSpace(cap.ResponseShape); responseShape != "" {
capability["responseShape"] = responseShape
}
if len(cap.ErrorCodes) > 0 {
capability["errorCodes"] = append([]string(nil), cap.ErrorCodes...)
}
route := map[string]any{}
setMetaString(route, "method", cap.Method)
setMetaString(route, "path", cap.Path)
if len(route) > 0 {
capability["route"] = route
}
governanceMeta := map[string]any{}
setMetaString(governanceMeta, "actionMode", string(governance.ActionMode))
setMetaString(governanceMeta, "approvalPolicy", string(governance.ApprovalPolicy))
setMetaString(governanceMeta, "approvalSummary", governance.ApprovalSummary)
if len(governanceMeta) > 0 {
capability["governance"] = governanceMeta
}
if len(capability) == 0 {
return nil
}
return CloneToolArguments(map[string]any{
ToolMetaPulseCapabilityKey: capability,
})
}
func setMetaString(meta map[string]any, key, value string) {
if trimmed := strings.TrimSpace(value); trimmed != "" {
meta[key] = trimmed
}
}
func boolRef(v bool) *bool {
return &v
}
func cloneBoolRef(v *bool) *bool {
if v == nil {
return nil
}
cloned := *v
return &cloned
}
// ToolInputSchema returns the agent-facing input schema for a capability. A
// manifest-owned schema is forwarded verbatim; otherwise a permissive adapter
// fallback is derived from path placeholders and request-body posture.
func ToolInputSchema(cap Capability) json.RawMessage {
if len(cap.InputSchema) > 0 {
return CloneRawMessage(cap.InputSchema)
}
properties := map[string]any{}
required := []string{}
for _, name := range PathParameterNames(cap.Path) {
properties[name] = map[string]any{
"type": "string",
"description": "Canonical " + name + " (e.g. \"vm:101\", \"container:web-1\")",
}
required = append(required, name)
}
if CapabilityHasRequestBody(cap) {
desc := "Request body fields"
if cap.RequestBodyShape != "" {
desc = "Request body fields. Shape hint: " + cap.RequestBodyShape
}
properties["body"] = map[string]any{
"type": "object",
"description": desc,
"additionalProperties": true,
}
}
return ObjectInputSchema(required, properties, true)
}
// ToolOutputSchema returns the manifest-owned structured output schema for a
// request/response capability. Unlike input schemas, there is no fallback: a
// responseShape string is a human-readable hint, not a validation contract for
// MCP structuredContent.
func ToolOutputSchema(cap Capability) json.RawMessage {
return CloneRawMessage(cap.OutputSchema)
}
// ProjectedCall is the manifest-owned route/body projection for one capability
// invocation. Adapters still own their transport, auth headers, and response
// wrapping.
type ProjectedCall struct {
Path string
Body json.RawMessage
HasBody bool
// Query carries filter arguments forwarded as URL query parameters for
// GET/DELETE capabilities (which have no request body). Populated from
// leftover public arguments after path-parameter substitution. Empty for
// capabilities that declare no filter arguments.
Query url.Values
}
// ProjectCapabilityCall turns agent-facing tool arguments into the Pulse HTTP
// path and optional JSON body declared by the capability manifest.
func ProjectCapabilityCall(cap Capability, args map[string]any) (ProjectedCall, error) {
path, err := SubstitutePathParameters(cap.Path, args)
if err != nil {
return ProjectedCall{}, err
}
projected := ProjectedCall{Path: path}
if !CapabilityHasRequestBody(cap) {
// GET/DELETE carry non-path arguments as URL query parameters rather
// than a JSON body. Collect leftover public arguments after path
// substitution so filter-style capabilities (e.g. get_fleet_context)
// can forward filters without the agent constructing a raw query
// string. Empty when no non-path arguments are supplied — preserving
// the prior unfiltered behavior.
pathParams := PathParameterSet(cap.Path)
query := url.Values{}
for k, v := range PublicToolArguments(args) {
if pathParams[k] {
continue
}
if s, ok := v.(string); ok {
query.Set(k, s)
}
}
if len(query) > 0 {
projected.Query = query
}
return projected, nil
}
bodyArg, ok := args["body"]
if !ok {
pathParams := PathParameterSet(cap.Path)
cleaned := map[string]any{}
for k, v := range PublicToolArguments(args) {
if !pathParams[k] {
cleaned[k] = v
}
}
bodyArg = cleaned
}
body, err := json.Marshal(bodyArg)
if err != nil {
return ProjectedCall{}, err
}
projected.Body = body
projected.HasBody = true
return projected, nil
}
// CapabilityHasRequestBody reports whether adapters should send a JSON request
// body for a capability call.
func CapabilityHasRequestBody(cap Capability) bool {
return !strings.EqualFold(cap.Method, http.MethodGet) && !strings.EqualFold(cap.Method, http.MethodDelete)
}
// PathParameterNames returns placeholder names declared by a capability path.
func PathParameterNames(path string) []string {
matches := pathPlaceholderRE.FindAllStringSubmatch(path, -1)
names := make([]string, 0, len(matches))
for _, match := range matches {
names = append(names, match[1])
}
return names
}
// PathParameterSet returns the set of placeholder names declared in a capability
// path. Used by adapters to keep path arguments out of JSON request bodies.
func PathParameterSet(path string) map[string]bool {
set := map[string]bool{}
for _, name := range PathParameterNames(path) {
set[name] = true
}
return set
}
// SubstitutePathParameters replaces `{name}` segments in a capability path with
// the corresponding argument value, encoded as one path segment.
func SubstitutePathParameters(path string, args map[string]any) (string, error) {
var missing []string
out := pathPlaceholderRE.ReplaceAllStringFunc(path, func(match string) string {
name := match[1 : len(match)-1] // strip { and }
v, ok := args[name]
if !ok {
missing = append(missing, name)
return match
}
s, ok := v.(string)
if !ok {
missing = append(missing, name+" (not a string)")
return match
}
return EscapePathSegmentParameter(s)
})
if len(missing) > 0 {
return "", fmt.Errorf("missing path argument(s): %s", strings.Join(missing, ", "))
}
return out, nil
}
// EscapePathSegmentParameter percent-encodes a path parameter as a single URL
// path segment. It intentionally escapes `/`, `:`, spaces, and other reserved
// bytes.
func EscapePathSegmentParameter(value string) string {
var b strings.Builder
for _, c := range []byte(value) {
if isUnreservedPathByte(c) {
b.WriteByte(c)
continue
}
_, _ = fmt.Fprintf(&b, "%%%02X", c)
}
return b.String()
}
func isUnreservedPathByte(c byte) bool {
return (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' ||
c == '.' ||
c == '_' ||
c == '~'
}