diff --git a/internal/broker/registry.go b/internal/broker/registry.go index 0a8e6f8..805f6bd 100644 --- a/internal/broker/registry.go +++ b/internal/broker/registry.go @@ -27,17 +27,53 @@ type AppEntry struct { BreakerThreshold int `json:"breaker_threshold"` // consecutive failures before opening (0 = disabled) BreakerCooldownMs int `json:"breaker_cooldown_ms"` // how long the breaker stays open - master string // resolved from KeyEnv at load - injector AuthInjector // built from AuthHeader/Scheme - allowSet map[string]bool - breaker *Breaker + master string // resolved from KeyEnv at load + injector AuthInjector // built from AuthHeader/Scheme + allowSet map[string]bool + allowPatterns [][]string // templated allow entries split on "/" ("{x}" matches any one segment) + breaker *Breaker } +// allowed reports whether a request path is permitted. Exact entries match +// literally (fast map hit); templated entries (containing a {name} segment, e.g. +// "/v1/calls/{call_id}") match any single non-empty segment in that position, so +// REST path params don't each need enumerating. An empty allow-list permits +// nothing (safe default; prod must declare). func (a *AppEntry) allowed(path string) bool { - if len(a.allowSet) == 0 { - return false // empty allow-list = nothing allowed (safe default; prod must declare) + if len(a.allowSet) == 0 && len(a.allowPatterns) == 0 { + return false } - return a.allowSet[path] + if a.allowSet[path] { + return true + } + segs := strings.Split(path, "/") + for _, pat := range a.allowPatterns { + if segmentsMatch(pat, segs) { + return true + } + } + return false +} + +// segmentsMatch reports whether request segments satisfy a templated pattern. A +// "{name}" pattern segment matches any single non-empty segment; every other +// segment must match literally. Lengths must be equal (no implicit wildcards). +func segmentsMatch(pat, segs []string) bool { + if len(pat) != len(segs) { + return false + } + for i, p := range pat { + if len(p) >= 2 && p[0] == '{' && p[len(p)-1] == '}' { + if segs[i] == "" { + return false + } + continue + } + if p != segs[i] { + return false + } + } + return true } // Registry holds the managed apps by id. @@ -87,8 +123,13 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) { } a.injector = injectorFor(a.AuthStyle, a.AuthHeader, a.AuthScheme, a.AuthParam, a.AuthUser) a.allowSet = map[string]bool{} + a.allowPatterns = nil for _, p := range a.Allow { - a.allowSet[p] = true + if strings.Contains(p, "{") { + a.allowPatterns = append(a.allowPatterns, strings.Split(p, "/")) + } else { + a.allowSet[p] = true + } } if a.CostField == "" { a.CostField = "cost_cents" diff --git a/internal/broker/zz_allow_pattern_test.go b/internal/broker/zz_allow_pattern_test.go new file mode 100644 index 0000000..6d075ee --- /dev/null +++ b/internal/broker/zz_allow_pattern_test.go @@ -0,0 +1,50 @@ +package broker + +import "testing" + +// Templated allow entries ("{name}" segments) let REST path params through +// without enumerating every id, while still refusing un-allowed paths. +func TestAllowPatternMatching(t *testing.T) { + reg, err := ParseRegistry([]byte(`[{ + "id":"io.pilot.x","upstream":"https://api.example.com","key_env":"X_KEY", + "auth_header":"Authorization","auth_scheme":"Bearer", + "allow":["/v1/usage","/v1/calls/{call_id}","/v1/numbers/{number_id}/messages"] + }]`), func(string) string { return "secret" }) + if err != nil { + t.Fatalf("ParseRegistry: %v", err) + } + app := reg.Get("io.pilot.x") + if app == nil { + t.Fatal("app not loaded") + } + cases := []struct { + path string + want bool + }{ + {"/v1/usage", true}, // exact + {"/v1/calls/call_abc123", true}, // one path param + {"/v1/calls/", false}, // empty segment must not match {call_id} + {"/v1/calls/abc/extra", false}, // too many segments + {"/v1/numbers/num_1/messages", true}, // param in the middle + {"/v1/numbers/num_1/calls", false}, // literal tail mismatch + {"/v1/agents", false}, // not allowed at all + {"/v1/usage/daily", false}, // longer than the exact entry + } + for _, c := range cases { + if got := app.allowed(c.path); got != c.want { + t.Errorf("allowed(%q) = %v, want %v", c.path, got, c.want) + } + } +} + +// An empty allow-list permits nothing (safe default), even with no patterns. +func TestAllowEmptyDeniesAll(t *testing.T) { + reg, err := ParseRegistry([]byte(`[{"id":"io.pilot.y","upstream":"https://api.example.com","key_env":"Y_KEY","allow":[]}]`), + func(string) string { return "secret" }) + if err != nil { + t.Fatalf("ParseRegistry: %v", err) + } + if reg.Get("io.pilot.y").allowed("/anything") { + t.Error("empty allow-list must deny all") + } +} diff --git a/internal/publish/broker_register.go b/internal/publish/broker_register.go index 36f2f2b..8ff387c 100644 --- a/internal/publish/broker_register.go +++ b/internal/publish/broker_register.go @@ -31,9 +31,16 @@ func (s Submission) MasterKeyEnv() string { // which header to inject it as (AuthHeader), and which method paths are allowed. func (s Submission) BrokerEntry() broker.AppEntry { authHeader := "Authorization" // safe default if the submitter didn't name one + authScheme := "" // e.g. "Bearer" for Authorization: Bearer for _, h := range s.Backend.Headers { if strings.TrimSpace(h.Name) != "" { authHeader = h.Name + // A header value like "Bearer managed" carries a scheme prefix; the + // trailing "managed" sentinel just marks that the broker injects the + // key. A bare "managed" means no scheme (e.g. x-api-key: ). + if f := strings.Fields(h.Value); len(f) >= 2 { + authScheme = f[0] + } break } } @@ -48,6 +55,7 @@ func (s Submission) BrokerEntry() broker.AppEntry { Upstream: strings.TrimRight(s.Backend.BaseURL, "/"), KeyEnv: s.MasterKeyEnv(), AuthHeader: authHeader, + AuthScheme: authScheme, Allow: allow, Quota: s.Backend.Quota, // per-caller cap set at publish time (0 = unlimited) } diff --git a/internal/publish/broker_register_test.go b/internal/publish/broker_register_test.go index 0197c11..eb624f7 100644 --- a/internal/publish/broker_register_test.go +++ b/internal/publish/broker_register_test.go @@ -92,3 +92,32 @@ func TestFileRegistrarUpsertsIdempotently(t *testing.T) { t.Fatalf("expected 1 entry after idempotent upsert, got %d", len(raw)) } } + +// A Bearer API: the header value "Bearer managed" makes BrokerEntry derive +// auth_scheme "Bearer" (so the broker injects "Authorization: Bearer "), +// and templated method paths flow through to the allow-list verbatim. +func TestBrokerEntryBearerScheme(t *testing.T) { + sub := Submission{ + ID: "io.pilot.agentphone", + Version: "0.1.0", + Backend: SubBackend{ + BaseURL: "https://api.agentphone.ai", + Auth: "managed", + Headers: []SubHeader{{Name: "Authorization", Value: "Bearer managed"}}, + }, + Methods: []SubMethod{ + {Name: "agentphone.place_call", HTTP: SubRoute{Verb: "POST", Path: "/v1/calls"}}, + {Name: "agentphone.get_call", HTTP: SubRoute{Verb: "GET", Path: "/v1/calls/{call_id}"}}, + }, + } + e := sub.BrokerEntry() + if e.KeyEnv != "AGENTPHONE_MASTER_KEY" { + t.Errorf("key_env = %q, want AGENTPHONE_MASTER_KEY", e.KeyEnv) + } + if e.AuthHeader != "Authorization" || e.AuthScheme != "Bearer" { + t.Errorf("auth = %q/%q, want Authorization/Bearer", e.AuthHeader, e.AuthScheme) + } + if len(e.Allow) != 2 || e.Allow[1] != "/v1/calls/{call_id}" { + t.Errorf("allow = %v, want the templated path preserved", e.Allow) + } +} diff --git a/internal/scaffold/config.go b/internal/scaffold/config.go index 1e50d60..250aae7 100644 --- a/internal/scaffold/config.go +++ b/internal/scaffold/config.go @@ -285,11 +285,38 @@ type Method struct { Roundtrip string `yaml:"roundtrip"` // measured warm roundtrip, for help } -// HTTPRoute maps a method to one backend HTTP endpoint. GET forwards the flat -// JSON payload as a query string; POST forwards it as a JSON body. +// HTTPRoute maps a method to one backend HTTP endpoint. The path may contain +// {name} placeholders, filled at call time from the payload (REST-style path +// params, e.g. /v1/calls/{call_id}). Of the remaining payload fields, a +// body verb (POST/PATCH/PUT) forwards them as a JSON body; a non-body verb +// (GET/DELETE) forwards them as a query string. type HTTPRoute struct { - Verb string `yaml:"verb"` // GET (default) | POST - Path string `yaml:"path"` // e.g. /current + Verb string `yaml:"verb"` // GET (default) | POST | PATCH | PUT | DELETE + Path string `yaml:"path"` // e.g. /current or /v1/calls/{call_id} + + // PathParams is derived in Resolve from {name} placeholders in Path; the + // generated adapter substitutes each from the payload (URL-escaped) and + // drops it from the body/query so it isn't sent twice. + PathParams []string `yaml:"-"` +} + +// BodyVerb reports whether this route sends remaining payload fields as a JSON +// body (POST/PATCH/PUT) rather than a query string (GET/DELETE). +func (h *HTTPRoute) BodyVerb() bool { + return h.Verb == "POST" || h.Verb == "PATCH" || h.Verb == "PUT" +} + +// pathParamPattern matches {name} placeholders in an http path. +var pathParamPattern = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) + +// pathParamNames returns the {name} placeholders in a path, in order. +func pathParamNames(path string) []string { + ms := pathParamPattern.FindAllStringSubmatch(path, -1) + out := make([]string, 0, len(ms)) + for _, m := range ms { + out = append(out, m[1]) + } + return out } // CLIRoute maps a method to a local subprocess invocation. Args may reference @@ -408,6 +435,7 @@ func (c *Config) Resolve() { } if m.HTTP != nil { m.HTTP.Verb = strings.ToUpper(m.HTTP.Verb) + m.HTTP.PathParams = pathParamNames(m.HTTP.Path) } } } @@ -494,8 +522,17 @@ func (c *Config) Validate() []error { if m.HTTP.Path == "" || !strings.HasPrefix(m.HTTP.Path, "/") { errs = append(errs, fmt.Errorf("methods[%d].http.path must start with /", i)) } - if m.HTTP.Verb != "GET" && m.HTTP.Verb != "POST" { - errs = append(errs, fmt.Errorf("methods[%d].http.verb %q must be GET or POST", i, m.HTTP.Verb)) + switch m.HTTP.Verb { + case "GET", "POST", "PATCH", "PUT", "DELETE": + default: + errs = append(errs, fmt.Errorf("methods[%d].http.verb %q must be GET|POST|PATCH|PUT|DELETE", i, m.HTTP.Verb)) + } + // Every {name} placeholder in the path must be a declared param, + // so the adapter can fill it and .help documents it. + for _, p := range pathParamNames(m.HTTP.Path) { + if _, ok := m.Params[p]; !ok { + errs = append(errs, fmt.Errorf("methods[%d] (%s): path placeholder {%s} has no matching entry under params", i, m.Name, p)) + } } } case "cli": diff --git a/internal/scaffold/templates/client_http.go.tmpl b/internal/scaffold/templates/client_http.go.tmpl index bd87974..da0defe 100644 --- a/internal/scaffold/templates/client_http.go.tmpl +++ b/internal/scaffold/templates/client_http.go.tmpl @@ -132,6 +132,42 @@ func (c *Client) PostRaw(ctx context.Context, path string, payload json.RawMessa {{- end}} return c.do(req) } + +// Do issues an arbitrary method on with an optional query string and JSON +// body, and returns the raw JSON body. Path placeholders are already filled by +// the caller; query is used for GET/DELETE, body for POST/PATCH/PUT. Empty-valued +// query keys are dropped; an empty body sends no body (no Content-Type). +func (c *Client) Do(ctx context.Context, method, path string, query map[string]string, body json.RawMessage) (json.RawMessage, error) { + u := c.baseURL + path + if len(query) > 0 { + q := url.Values{} + for k, v := range query { + if v != "" { + q.Set(k, v) + } + } + if enc := q.Encode(); enc != "" { + u += "?" + enc + } + } + var rdr io.Reader + var bodyBytes []byte + if len(bytes.TrimSpace(body)) > 0 { + bodyBytes = []byte(body) + rdr = bytes.NewReader(bodyBytes) + } + req, err := http.NewRequestWithContext(ctx, method, u, rdr) + if err != nil { + return nil, err + } + if bodyBytes != nil { + req.Header.Set("Content-Type", "application/json") + } +{{- if .Managed}} + c.applySig(req, bodyBytes) +{{- end}} + return c.do(req) +} {{- if .Managed}} // applySig stamps the caller-identity headers the broker verifies. The signed diff --git a/internal/scaffold/templates/example.pilot.app.yaml b/internal/scaffold/templates/example.pilot.app.yaml index 00a124b..8029e98 100644 --- a/internal/scaffold/templates/example.pilot.app.yaml +++ b/internal/scaffold/templates/example.pilot.app.yaml @@ -56,6 +56,20 @@ methods: verb: POST # POST -> payload forwarded as the JSON body path: /report + - name: weather.station + summary: "Get one station by id (REST path parameter)." + kind: utility + duration: fast + http: + verb: GET # GET | POST | PATCH | PUT | DELETE + # {placeholder} segments are filled from params at call time (URL-escaped) + # and dropped from the body/query. QUOTE any path with braces — an + # unquoted {x} is a YAML flow-map. Body verbs (POST/PATCH/PUT) send the + # remaining fields as JSON; GET/DELETE send them as the query string. + path: "/stations/{station_id}" + params: + station_id: "string (required) — path param" + - name: weather.health summary: "Liveness probe." kind: status diff --git a/internal/scaffold/templates/main.go.tmpl b/internal/scaffold/templates/main.go.tmpl index ec8306c..6e7348b 100644 --- a/internal/scaffold/templates/main.go.tmpl +++ b/internal/scaffold/templates/main.go.tmpl @@ -19,10 +19,13 @@ import ( "fmt" "log" "net" +{{- if eq .Backend.Type "http"}} + "net/url" +{{- end}} "os" "os/signal" "path/filepath" -{{- if .Backend.Headers}} +{{- if or .Backend.Headers (eq .Backend.Type "http")}} "strings" {{- end}} "syscall" @@ -147,31 +150,90 @@ func serve(ctx context.Context, socketPath string, d *ipc.Dispatcher) error { {{- if eq .Backend.Type "http"}} func registerHandlers(d *ipc.Dispatcher, c *backend.Client, version, backendURL string) { {{- range .Methods}} - d.Register("{{.Name}}", forward{{if eq .HTTP.Verb "POST"}}Post{{else}}Get{{end}}(c, "{{.HTTP.Path}}", dur("{{.TimeoutFor}}"))) // {{.Duration}} + d.Register("{{.Name}}", forward(c, "{{.HTTP.Verb}}", "{{.HTTP.Path}}", []string{ {{- range $p := .HTTP.PathParams}}{{printf "%q" $p}}, {{end -}} }, {{.HTTP.BodyVerb}}, dur("{{.TimeoutFor}}"))) // {{.Duration}} {{- end}} d.Register("{{.Namespace}}.help", helpHandler(version, backendURL)) } -// forwardGet copies the flat JSON payload into the query string of a GET. -func forwardGet(c *backend.Client, path string, timeout time.Duration) ipc.Handler { +// forward builds one backend request for a method. It fills {name} placeholders +// in pathTmpl from the payload (URL-escaped, then dropped from the payload), and +// sends the REMAINING fields as a JSON body when body is true (POST/PATCH/PUT) +// or as a query string otherwise (GET/DELETE). One generic forwarder covers +// every verb + path shape, so adding a method is pure pilot.app.yaml. +func forward(c *backend.Client, method, pathTmpl string, pathParams []string, body bool, timeout time.Duration) ipc.Handler { return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { - params, err := stringParams(req.Payload) + fields, err := objectFields(req.Payload) + if err != nil { + return nil, err + } + path, err := fillPath(pathTmpl, pathParams, fields) if err != nil { return nil, err } ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - return c.Get(ctx, path, params) + if body { + b, err := json.Marshal(fields) + if err != nil { + return nil, err + } + return c.Do(ctx, method, path, nil, b) + } + return c.Do(ctx, method, path, flatten(fields), nil) } } -// forwardPost forwards the JSON payload verbatim as the POST body. -func forwardPost(c *backend.Client, path string, timeout time.Duration) ipc.Handler { - return func(ctx context.Context, req *ipc.Envelope) (json.RawMessage, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - return c.PostRaw(ctx, path, req.Payload) +// objectFields decodes the payload into a field map preserving JSON types, so a +// POST/PATCH body keeps nested objects/arrays intact (a flat string map would +// stringify them). Path-param values are read out of this map by fillPath. +func objectFields(raw json.RawMessage) (map[string]json.RawMessage, error) { + out := map[string]json.RawMessage{} + if len(raw) == 0 { + return out, nil + } + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("payload must be a JSON object: %w", err) + } + return out, nil +} + +// fillPath substitutes each {name} in tmpl with its payload value (URL-escaped) +// and removes that key from fields so it isn't also sent in the body/query. A +// missing or empty path param is an error — the URL would otherwise be malformed. +func fillPath(tmpl string, pathParams []string, fields map[string]json.RawMessage) (string, error) { + out := tmpl + for _, k := range pathParams { + v, ok := fields[k] + if !ok { + return "", fmt.Errorf("missing required path parameter %q", k) + } + s := jsonScalar(v) + if s == "" { + return "", fmt.Errorf("path parameter %q must not be empty", k) + } + out = strings.ReplaceAll(out, "{"+k+"}", url.PathEscape(s)) + delete(fields, k) + } + return out, nil +} + +// flatten coerces remaining fields to query-string values: a JSON string becomes +// its unquoted text, anything else its literal JSON text. +func flatten(fields map[string]json.RawMessage) map[string]string { + out := make(map[string]string, len(fields)) + for k, v := range fields { + out[k] = jsonScalar(v) + } + return out +} + +// jsonScalar returns a JSON string's unquoted value, else the raw JSON text. +func jsonScalar(v json.RawMessage) string { + var s string + if json.Unmarshal(v, &s) == nil { + return s } + return string(v) } {{- else}} func registerHandlers(d *ipc.Dispatcher, r *backend.Runner, version string) { diff --git a/internal/scaffold/zz_http_compile_test.go b/internal/scaffold/zz_http_compile_test.go new file mode 100644 index 0000000..c189152 --- /dev/null +++ b/internal/scaffold/zz_http_compile_test.go @@ -0,0 +1,93 @@ +package scaffold + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// restPathParamSpec is a managed HTTP app exercising every generated HTTP shape: +// GET (list + path-param), POST body, PATCH body+path-param, DELETE path-param, +// and a multi-placeholder path. It compiles only if the new generic forward, +// the client's Do, and the conditional net/url + strings imports are all correct. +const restPathParamSpec = ` +id: io.pilot.restx +app_version: 0.1.0 +description: "REST app exercising path params and all verbs." +backend: + base_url: https://api.example.com + auth: managed +methods: + - name: restx.list + summary: "list things" + http: { verb: GET, path: /v1/things } + - name: restx.get + summary: "get one" + http: { verb: GET, path: "/v1/things/{id}" } + params: { id: the thing id } + - name: restx.create + summary: "create" + http: { verb: POST, path: /v1/things } + - name: restx.update + summary: "update" + http: { verb: PATCH, path: "/v1/things/{id}" } + params: { id: the thing id } + - name: restx.remove + summary: "delete" + http: { verb: DELETE, path: "/v1/things/{id}" } + params: { id: the thing id } + - name: restx.nested + summary: "nested path params" + http: { verb: GET, path: "/v1/things/{id}/items/{item_id}" } + params: { id: thing id, item_id: item id } +` + +// TestGeneratedHTTPPathParamProjectCompiles type-checks a generated managed-HTTP +// adapter that uses path params and all five verbs. This is the load-bearing +// guard for the HTTP-adapter improvements: a template typo (bad import gating, +// wrong forward signature) parses fine but fails `go build`. +func TestGeneratedHTTPPathParamProjectCompiles(t *testing.T) { + if testing.Short() { + t.Skip("skipping compile test in -short mode") + } + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("go toolchain not available") + } + + cfg := parseSpec(t, restPathParamSpec) + dir := t.TempDir() + if _, err := Generate(cfg, dir); err != nil { + t.Fatalf("generate: %v", err) + } + if sum, err := os.ReadFile(filepath.Join("..", "..", "go.sum")); err == nil { + if err := os.WriteFile(filepath.Join(dir, "go.sum"), sum, 0o644); err != nil { + t.Fatalf("seed go.sum: %v", err) + } + } + + // The generated dispatcher must wire path params through the generic forward. + mainSrc, err := os.ReadFile(filepath.Join(dir, "cmd", cfg.BinaryName, "main.go")) + if err != nil { + t.Fatalf("read main.go: %v", err) + } + for _, want := range []string{ + `forward(c, "GET", "/v1/things/{id}", []string{`, + `forward(c, "PATCH", "/v1/things/{id}", []string{`, + `forward(c, "DELETE", "/v1/things/{id}", []string{`, + `forward(c, "GET", "/v1/things/{id}/items/{item_id}", []string{`, + } { + if !strings.Contains(string(mainSrc), want) { + t.Errorf("generated main.go missing dispatcher wiring: %s", want) + } + } + + cmd := exec.Command(goBin, "build", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated http project failed to compile: %v\n%s", err, out) + } +} diff --git a/internal/scaffold/zz_http_pathparam_test.go b/internal/scaffold/zz_http_pathparam_test.go new file mode 100644 index 0000000..22f12a5 --- /dev/null +++ b/internal/scaffold/zz_http_pathparam_test.go @@ -0,0 +1,91 @@ +package scaffold + +import ( + "strings" + "testing" +) + +func parseResolved(t *testing.T, yaml string) *Config { + t.Helper() + c, err := Parse([]byte(yaml)) + if err != nil { + t.Fatalf("parse: %v", err) + } + c.Resolve() + return c +} + +func hasErrContaining(errs []error, sub string) bool { + for _, e := range errs { + if strings.Contains(e.Error(), sub) { + return true + } + } + return false +} + +const baseYAML = `id: io.pilot.x +app_version: 0.1.0 +description: test app +backend: + base_url: https://api.example.com +methods: +` + +func methodYAML(verb, path, params string) string { + // The path is quoted: a {placeholder} is a YAML flow-map indicator unquoted. + m := " - name: x.m\n http: { verb: " + verb + ", path: \"" + path + "\" }\n" + if params != "" { + m += " params: { " + params + " }\n" + } + return baseYAML + m +} + +// All five REST verbs validate (and lower-case is normalised). +func TestHTTPVerbsAccepted(t *testing.T) { + for _, v := range []string{"GET", "POST", "PATCH", "PUT", "DELETE", "get", "delete"} { + c := parseResolved(t, methodYAML(v, "/v1/things", "")) + if errs := c.Validate(); len(errs) != 0 { + t.Errorf("verb %q: unexpected errors: %v", v, errs) + } + } +} + +func TestHTTPVerbRejected(t *testing.T) { + c := parseResolved(t, methodYAML("CONNECT", "/v1/things", "")) + if !hasErrContaining(c.Validate(), "must be GET|POST|PATCH|PUT|DELETE") { + t.Errorf("expected verb rejection, got %v", c.Validate()) + } +} + +// A {placeholder} with no matching params: entry is a spec error. +func TestPathParamMustBeDeclared(t *testing.T) { + c := parseResolved(t, methodYAML("GET", "/v1/calls/{call_id}", "")) + if !hasErrContaining(c.Validate(), "path placeholder {call_id}") { + t.Errorf("expected undeclared path-param error, got %v", c.Validate()) + } + ok := parseResolved(t, methodYAML("GET", "/v1/calls/{call_id}", "call_id: the call id")) + if errs := ok.Validate(); len(errs) != 0 { + t.Errorf("declared path param: unexpected errors: %v", errs) + } +} + +// Resolve derives PathParams from the path, in order; BodyVerb classifies verbs. +func TestPathParamsDerivedAndBodyVerb(t *testing.T) { + c := parseResolved(t, methodYAML("PATCH", "/v1/numbers/{number_id}/agents/{agent_id}", "number_id: a, agent_id: b")) + h := c.Methods[0].HTTP + if len(h.PathParams) != 2 || h.PathParams[0] != "number_id" || h.PathParams[1] != "agent_id" { + t.Errorf("PathParams = %v, want [number_id agent_id]", h.PathParams) + } + if !h.BodyVerb() { + t.Error("PATCH should be a body verb") + } + g := parseResolved(t, methodYAML("GET", "/v1/x", "")).Methods[0].HTTP + if g.BodyVerb() { + t.Error("GET should not be a body verb") + } + d := parseResolved(t, methodYAML("DELETE", "/v1/x/{id}", "id: i")).Methods[0].HTTP + if d.BodyVerb() { + t.Error("DELETE should not be a body verb") + } +}