diff --git a/docs/advanced-guide/http-communication/page.md b/docs/advanced-guide/http-communication/page.md index d57e2ac4a5..bd75a8fcad 100644 --- a/docs/advanced-guide/http-communication/page.md +++ b/docs/advanced-guide/http-communication/page.md @@ -12,7 +12,7 @@ GoFr promotes microservice architecture and to facilitate the same, it provides at application level using `AddHTTPService()` method. Support for inter-service HTTP calls provide the following benefits: -1. Access to the methods from container - GET, PUT, POST, PATCH, DELETE. +1. Access to the methods from container - GET, PUT, POST, PATCH, DELETE, QUERY. 2. Logs and traces for the request. 3. {% new-tab-link newtab=false title="Circuit breaking" href="/docs/advanced-guide/circuit-breaker" /%} for enhanced resilience and fault tolerance. 4. {% new-tab-link newtab=false title="Custom Health Check" href="/docs/advanced-guide/monitoring-service-health" /%} Endpoints @@ -83,6 +83,9 @@ The HTTP service client provides methods for making requests to downstream servi - `Delete(ctx, path, body)` +- `Query(ctx, path, queryParams, body)` + + **For scenarios requiring custom header propagation (authentication, multi-tenancy, user identity propagation), use the `WithHeaders` variants:** - `GetWithHeaders(ctx, path, queryParams, headers)` @@ -95,6 +98,13 @@ The HTTP service client provides methods for making requests to downstream servi - `DeleteWithHeaders(ctx, path, body, headers)` +- `QueryWithHeaders(ctx, path, queryParams, body, headers)` + +> **QUERY** (RFC 10008) is a safe, idempotent method that carries the query in the +> request body — bridging the gap between GET (no body) and POST (not safe/idempotent). +> Like GET, QUERY calls are wrapped by the circuit-breaker and retry options when configured. + + ```go func Customer(ctx *gofr.Context) (any, error) { // Get the payment service client diff --git a/examples/http-server/main.go b/examples/http-server/main.go index 65868b83a6..8719620226 100644 --- a/examples/http-server/main.go +++ b/examples/http-server/main.go @@ -28,6 +28,10 @@ func main() { a.GET("/trace", TraceHandler) a.GET("/mysql", MysqlHandler) + // QUERY (RFC 10008): a safe, idempotent method that carries the query in the + // request body. Read the body via ctx.Bind, the same as a POST. + a.QUERY("/search", SearchHandler) + // Run the application a.Run() } @@ -46,6 +50,20 @@ func ErrorHandler(c *gofr.Context) (any, error) { return nil, errors.New("some error occurred") } +// SearchHandler demonstrates the HTTP QUERY method: the search criteria arrive in +// the request body and are echoed back as the query result. +func SearchHandler(c *gofr.Context) (any, error) { + criteria := struct { + Filter string `json:"filter"` + }{} + + if err := c.Bind(&criteria); err != nil { + return nil, err + } + + return map[string]string{"matched": criteria.Filter}, nil +} + func RedisHandler(c *gofr.Context) (any, error) { val, err := c.Redis.Get(c, "test").Result() if err != nil && err != redis.Nil { // If key is not found, we are not considering this an error and returning "". diff --git a/examples/http-server/main_test.go b/examples/http-server/main_test.go index 796fa38d8b..9fcef515b3 100644 --- a/examples/http-server/main_test.go +++ b/examples/http-server/main_test.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/go-redis/redismock/v9" "github.com/stretchr/testify/assert" @@ -95,6 +96,39 @@ func TestIntegration_SimpleAPIServer(t *testing.T) { } } +func TestIntegration_QueryHandler(t *testing.T) { + httpPort := testutil.GetFreePort(t) + port := testutil.GetFreePort(t) + + t.Setenv("HTTP_PORT", strconv.Itoa(httpPort)) + t.Setenv("METRICS_PORT", strconv.Itoa(port)) + + host := fmt.Sprintf("http://localhost:%d", httpPort) + + go main() + time.Sleep(100 * time.Millisecond) // Giving some time to start the server + + req, _ := http.NewRequest("QUERY", host+"/search", strings.NewReader(`{"filter":"golang"}`)) + req.Header.Set("content-type", "application/json") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + data := struct { + Data map[string]string `json:"data"` + }{} + require.NoError(t, json.Unmarshal(b, &data)) + assert.Equal(t, "golang", data.Data["matched"]) +} + + func TestIntegration_SimpleAPIServer_Errors(t *testing.T) { httpPort := testutil.GetFreePort(t) port := testutil.GetFreePort(t) @@ -347,6 +381,7 @@ func TestTraceHandler(t *testing.T) { ctx := createTestContext(http.MethodGet, "/trace", mockContainer) + // HTTP service mock - use mocks.HTTPServices["serviceName"] to access the specific service // Important: Use the map keyed by service name, not mocks.HTTPService (singular) mockResp := &http.Response{ diff --git a/pkg/gofr/gofr_test.go b/pkg/gofr/gofr_test.go index b53a6af240..dd6900ed74 100644 --- a/pkg/gofr/gofr_test.go +++ b/pkg/gofr/gofr_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "testing" "time" @@ -1935,3 +1936,55 @@ func Test_HTTPMethods(t *testing.T) { }) } } + +// Test_QUERY_Registration verifies that app.QUERY registers a route for the HTTP +// QUERY method (RFC 10008) and that the handler can read the request body via Bind. +func Test_QUERY_Registration(t *testing.T) { + port := testutil.GetFreePort(t) + + c := container.NewContainer(config.NewMockConfig(nil)) + + app := &App{ + httpServer: &httpServer{ + router: gofrHTTP.NewRouter(), + port: port, + }, + container: c, + Config: config.NewMockConfig(map[string]string{ + "REQUEST_TIMEOUT": "5", + "SHUTDOWN_GRACE_PERIOD": "1s", + }), + } + + app.QUERY("/search", func(ctx *Context) (any, error) { + body := struct { + Filter string `json:"filter"` + }{} + if err := ctx.Bind(&body); err != nil { + return nil, err + } + + return map[string]string{"filter": body.Filter}, nil + }) + + go app.Run() + + time.Sleep(100 * time.Millisecond) + + netClient := &http.Client{Timeout: 500 * time.Millisecond} + + req, _ := http.NewRequestWithContext(t.Context(), "QUERY", + fmt.Sprintf("http://localhost:%d/search", port), strings.NewReader(`{"filter":"title"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, err := netClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(respBody), `"filter":"title"`) +} diff --git a/pkg/gofr/mcp.go b/pkg/gofr/mcp.go index d96e0abf74..7519c371ab 100644 --- a/pkg/gofr/mcp.go +++ b/pkg/gofr/mcp.go @@ -29,10 +29,12 @@ func WithExcludedRoutes(paths ...string) MCPOption { } } -// EnableMCP exposes the app's read-only HTTP handlers (GET/HEAD/OPTIONS) as agent-callable tools over -// an MCP server on its own port (MCP_PORT, default 8200; MCP_PORT=0 disables the server). Write -// handlers are never exposed, so an agent cannot mutate state through this surface. The tools are also -// reachable in handlers via ctx.LLM().Tools() regardless of whether the server is enabled. +// EnableMCP exposes the app's safe HTTP handlers — read-only methods (GET/HEAD/OPTIONS) and QUERY +// (RFC 10008, safe and idempotent, whose query payload is passed as a "body" tool argument) — as +// agent-callable tools over an MCP server on its own port (MCP_PORT, default 8200; MCP_PORT=0 disables +// the server). Write handlers (POST/PUT/PATCH/DELETE) are never exposed, so an agent cannot mutate +// state through this surface. The tools are also reachable in handlers via ctx.LLM().Tools() regardless +// of whether the server is enabled. func (a *App) EnableMCP(opts ...MCPOption) { cfg := &mcpConfig{exclude: make(map[string]bool)} for _, o := range opts { diff --git a/pkg/gofr/mcp_test.go b/pkg/gofr/mcp_test.go index 0c774dd405..9f526d6218 100644 --- a/pkg/gofr/mcp_test.go +++ b/pkg/gofr/mcp_test.go @@ -328,3 +328,72 @@ func TestHelpers(t *testing.T) { assert.Equal(t, "42", scalar(json.RawMessage(`"42"`))) assert.Equal(t, "true", scalar(json.RawMessage(`true`))) } + +func TestRouterTools_List_ExposesQuery(t *testing.T) { + app, rt := testRouterTools(t) + app.QUERY("/search", func(*Context) (any, error) { return nil, nil }) + app.POST("/write", func(*Context) (any, error) { return nil, nil }) + + specs := rt.List() + names := toolNames(specs) + + assert.Contains(t, names, "query_search", "QUERY handlers are exposed (safe + idempotent)") + assert.NotContains(t, names, "post_write", "write handlers remain unexposed") + + // The QUERY tool must advertise a required "body" argument for the query payload. + var querySpec ai.ToolSpec + + for _, s := range specs { + if s.Name == "query_search" { + querySpec = s + } + } + + require.NotEmpty(t, querySpec.Name) + assert.Equal(t, ai.ReadOnly, querySpec.Access, "QUERY is safe, so it is ReadOnly") + + schema := map[string]any{} + require.NoError(t, json.Unmarshal(querySpec.InputSchema, &schema)) + + props, _ := schema["properties"].(map[string]any) + assert.Contains(t, props, "body", "QUERY tool schema must include a body property") + assert.Contains(t, schema["required"], "body", "body must be required") +} + +func TestRouterTools_Call_QueryForwardsBody(t *testing.T) { + app, rt := testRouterTools(t) + app.QUERY("/search", func(c *Context) (any, error) { + payload := map[string]any{} + if err := c.Bind(&payload); err != nil { + return nil, err + } + + return payload, nil + }) + + res, err := rt.Call(t.Context(), "query_search", json.RawMessage(`{"body":{"filter":"title"}}`)) + require.NoError(t, err) + + body, err := res.JSON() + require.NoError(t, err) + assert.Contains(t, string(body), `"filter":"title"`, "the QUERY body must reach the handler") +} + +func TestRouterTools_Call_QueryWithPathParamAndBody(t *testing.T) { + app, rt := testRouterTools(t) + app.QUERY("/index/{name}/search", func(c *Context) (any, error) { + payload := map[string]any{} + _ = c.Bind(&payload) + payload["index"] = c.PathParam("name") + + return payload, nil + }) + + res, err := rt.Call(t.Context(), "query_index_name_search", + json.RawMessage(`{"name":"books","body":{"q":"go"}}`)) + require.NoError(t, err) + + body, _ := res.JSON() + assert.Contains(t, string(body), `"index":"books"`) + assert.Contains(t, string(body), `"q":"go"`) +} diff --git a/pkg/gofr/rest.go b/pkg/gofr/rest.go index 330469fe45..6352593ebc 100644 --- a/pkg/gofr/rest.go +++ b/pkg/gofr/rest.go @@ -30,6 +30,16 @@ func (a *App) PATCH(pattern string, handler Handler) { a.add("PATCH", pattern, handler) } +// QUERY adds a Handler for the HTTP QUERY method (RFC 10008) for a route pattern. +// QUERY is a safe, idempotent method that carries a request body describing the +// query; read it in the handler via ctx.Bind, the same way as a POST body. +// Per RFC 10008 a server should reject a QUERY with a missing/invalid Content-Type +// with 400; gofr does not enforce this automatically, so validate it in the handler +// when required. +func (a *App) QUERY(pattern string, handler Handler) { + a.add(methodQuery, pattern, handler) +} + func (a *App) add(method, pattern string, h Handler) { if !a.httpRegistered && !isPortAvailable(a.httpServer.port) { a.container.Logger.Fatalf("http port %d is blocked or unreachable", a.httpServer.port) diff --git a/pkg/gofr/service/auth.go b/pkg/gofr/service/auth.go index b4bef6e6e8..e8e55e3079 100644 --- a/pkg/gofr/service/auth.go +++ b/pkg/gofr/service/auth.go @@ -82,3 +82,18 @@ func (a *authProvider) DeleteWithHeaders(ctx context.Context, path string, body return a.HTTP.DeleteWithHeaders(ctx, path, body, headers) } + +func (a *authProvider) Query(ctx context.Context, path string, queryParams map[string]any, + body []byte) (*http.Response, error) { + return a.QueryWithHeaders(ctx, path, queryParams, body, nil) +} + +func (a *authProvider) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, + body []byte, headers map[string]string) (*http.Response, error) { + headers, err := a.auth(ctx, headers) + if err != nil { + return nil, err + } + + return a.HTTP.QueryWithHeaders(ctx, path, queryParams, body, headers) +} diff --git a/pkg/gofr/service/auth_test.go b/pkg/gofr/service/auth_test.go index fd18f9f207..85e479d1a1 100644 --- a/pkg/gofr/service/auth_test.go +++ b/pkg/gofr/service/auth_test.go @@ -60,7 +60,7 @@ func TestAuthProvider(t *testing.T) { {authOption: validOAuthConfig, headers: map[string]string{AuthHeader: "auth-string"}, err: authHeaderExistsErr}, } - httpMethods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} + httpMethods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, methodQuery} for i, tc := range testCases { t.Run(fmt.Sprintf("Test Case #%d", i), func(t *testing.T) { @@ -143,6 +143,8 @@ func callHTTPServiceWithHeaders(ctx context.Context, service HTTP, method string return service.PatchWithHeaders(ctx, path, queryParams, body, headers) case http.MethodDelete: return service.DeleteWithHeaders(ctx, path, body, headers) + case methodQuery: + return service.QueryWithHeaders(ctx, path, queryParams, body, headers) default: return nil, AuthErr{Message: "unknown method"} } @@ -164,6 +166,8 @@ func callHTTPServiceWithoutHeaders(ctx context.Context, service HTTP, method str return service.Patch(ctx, path, queryParams, body) case http.MethodDelete: return service.Delete(ctx, path, body) + case methodQuery: + return service.Query(ctx, path, queryParams, body) default: return nil, AuthErr{Message: "unknown method"} } diff --git a/pkg/gofr/service/circuit_breaker.go b/pkg/gofr/service/circuit_breaker.go index a579fec19e..b0e91e4c12 100644 --- a/pkg/gofr/service/circuit_breaker.go +++ b/pkg/gofr/service/circuit_breaker.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "net/http" "sync" "time" @@ -18,6 +19,8 @@ var ( // ErrCircuitOpen indicates that the circuit breaker is open. ErrCircuitOpen = errors.New("unable to connect to server at host") ErrUnexpectedCircuitBreakerResultType = errors.New("unexpected result type from circuit breaker") + // ErrUnsupportedMethod indicates that the circuit breaker was asked to route an HTTP method it does not handle. + ErrUnsupportedMethod = errors.New("unsupported HTTP method for circuit breaker") ) // CircuitBreakerConfig holds the configuration for the circuitBreaker. @@ -255,6 +258,12 @@ func (cb *circuitBreaker) doRequest(ctx context.Context, method, path string, qu result, err = cb.executeWithCircuitBreaker(ctx, func(ctx context.Context) (*http.Response, error) { return cb.HTTP.DeleteWithHeaders(ctx, path, body, headers) }) + case methodQuery: + result, err = cb.executeWithCircuitBreaker(ctx, func(ctx context.Context) (*http.Response, error) { + return cb.HTTP.QueryWithHeaders(ctx, path, queryParams, body, headers) + }) + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedMethod, method) } resp, err := cb.handleCircuitBreakerResult(result, err) @@ -321,3 +330,15 @@ func (cb *circuitBreaker) Delete(ctx context.Context, path string, body []byte) *http.Response, error) { return cb.doRequest(ctx, http.MethodDelete, path, nil, body, nil) } + +// QueryWithHeaders is a wrapper for doRequest with the QUERY method and headers. +func (cb *circuitBreaker) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, + body []byte, headers map[string]string) (*http.Response, error) { + return cb.doRequest(ctx, methodQuery, path, queryParams, body, headers) +} + +// Query is a wrapper for doRequest with the QUERY method. +func (cb *circuitBreaker) Query(ctx context.Context, path string, queryParams map[string]any, + body []byte) (*http.Response, error) { + return cb.doRequest(ctx, methodQuery, path, queryParams, body, nil) +} diff --git a/pkg/gofr/service/circuit_breaker_test.go b/pkg/gofr/service/circuit_breaker_test.go index 9f5deaaf95..65800eb3f7 100644 --- a/pkg/gofr/service/circuit_breaker_test.go +++ b/pkg/gofr/service/circuit_breaker_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "sync" + "sync/atomic" "testing" "time" @@ -1206,3 +1207,188 @@ func TestCircuitBreaker_SlowHealthCheckDoesNotBlock(t *testing.T) { cb.mu.RUnlock() assert.Equal(t, ClosedState, state, "circuit should be closed after successful recovery") } + +func TestHttpService_QuerySuccessRequests(t *testing.T) { + server := testServer() + defer server.Close() + + ctrl := gomock.NewController(t) + mockMetric := NewMockMetrics(ctrl) + + mockMetric.EXPECT().RecordHistogram(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockMetric.EXPECT().NewCounter(gomock.Any(), gomock.Any()).AnyTimes() + mockMetric.EXPECT().NewGauge(gomock.Any(), gomock.Any()).AnyTimes() + mockMetric.EXPECT().SetGauge(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + service := NewHTTPService(server.URL, logging.NewMockLogger(logging.DEBUG), mockMetric, &CircuitBreakerConfig{ + Threshold: 1, + Interval: 1, + }) + + resp, err := service.Query(t.Context(), "test", nil, []byte(`{"q":"x"}`)) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = resp.Body.Close() +} + +func TestHttpService_QueryWithHeaderSuccessRequests(t *testing.T) { + server := testServer() + defer server.Close() + + ctrl := gomock.NewController(t) + mockMetric := NewMockMetrics(ctrl) + + mockMetric.EXPECT().RecordHistogram(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + mockMetric.EXPECT().NewCounter(gomock.Any(), gomock.Any()).AnyTimes() + mockMetric.EXPECT().NewGauge(gomock.Any(), gomock.Any()).AnyTimes() + mockMetric.EXPECT().SetGauge(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + service := NewHTTPService(server.URL, logging.NewMockLogger(logging.DEBUG), mockMetric, &CircuitBreakerConfig{ + Threshold: 1, + Interval: 1, + }) + + resp, err := service.QueryWithHeaders(t.Context(), "test", nil, []byte(`{"q":"x"}`), + map[string]string{"content-type": "application/json"}) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = resp.Body.Close() +} + +// TestCircuitBreaker_doRequest_UnsupportedMethod verifies the default case: +// an unknown method must return ErrUnsupportedMethod instead of a nil response. +func TestCircuitBreaker_doRequest_UnsupportedMethod(t *testing.T) { + cb := &circuitBreaker{state: ClosedState, interval: time.Second} + + //nolint:bodyclose // the unsupported-method path returns a nil response, nothing to close + resp, err := cb.doRequest(t.Context(), "TRACE", "test", nil, nil, nil) + + require.ErrorIs(t, err, ErrUnsupportedMethod) + assert.Nil(t, resp) +} + +// cbQueryServer is a controllable downstream for QUERY circuit-breaker integration +// tests. When down is true, QUERY /search returns 503 (>500, trips the breaker). When +// aliveMirrorsDown is true, the /.well-known/alive probe also fails while down, so the +// breaker stays open until the service actually heals. +func cbQueryServer(down *atomic.Bool, aliveMirrorsDown bool) *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + if r.Method != methodQuery { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if down.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + body, _ := io.ReadAll(r.Body) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }) + + mux.HandleFunc("/.well-known/alive", func(w http.ResponseWriter, _ *http.Request) { + if aliveMirrorsDown && down.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + w.WriteHeader(http.StatusOK) + }) + + return httptest.NewServer(mux) +} + +// cbQueryBreaker builds a circuitBreaker directly (not via NewCircuitBreaker) so it does NOT +// start the background health-check goroutine — keeping the test deterministic and leak-free. +// Its embedded HTTP is a plain httpService (no decorators) pointing at url. +func cbQueryBreaker(t *testing.T, url string, threshold int, interval time.Duration) *circuitBreaker { + t.Helper() + + ctrl := gomock.NewController(t) + m := NewMockMetrics(ctrl) + m.EXPECT().RecordHistogram(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().NewCounter(gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().NewGauge(gomock.Any(), gomock.Any()).AnyTimes() + m.EXPECT().SetGauge(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + + base := NewHTTPService(url, logging.NewMockLogger(logging.ERROR), m) + + return &circuitBreaker{ + state: ClosedState, + threshold: threshold, + interval: interval, + HTTP: base, + } +} + +// TestCircuitBreaker_Query_Trips verifies an outbound QUERY participates in circuit-breaker +// failure accounting: repeated downstream 503s open the circuit and further QUERY calls are +// short-circuited with ErrCircuitOpen without hitting the downstream. +func TestCircuitBreaker_Query_Trips(t *testing.T) { + var down atomic.Bool + + server := cbQueryServer(&down, false) + defer server.Close() + + cb := cbQueryBreaker(t, server.URL, 2, time.Minute) + + // Healthy: QUERY succeeds and body is echoed. + resp, err := cb.Query(t.Context(), "search", nil, []byte(`{"q":"ok"}`)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + + // Failing downstream: drive QUERY calls until the circuit opens. + down.Store(true) + + var opened bool + + for i := 0; i < 6; i++ { + resp, err = cb.Query(t.Context(), "search", nil, []byte(`{"q":"x"}`)) + if resp != nil { + _ = resp.Body.Close() + } + + if err != nil && err.Error() == ErrCircuitOpen.Error() { + opened = true + break + } + } + + assert.True(t, opened, "QUERY calls should open the circuit after repeated downstream failures") +} + +// TestCircuitBreaker_Query_Recovers verifies that when the circuit is open, an outbound QUERY +// triggers recovery: once the interval has elapsed and the downstream health probe reports UP, +// the breaker closes and the QUERY is served. Deterministic — no background goroutine, no sleeps. +func TestCircuitBreaker_Query_Recovers(t *testing.T) { + var down atomic.Bool + + server := cbQueryServer(&down, false) // /.well-known/alive stays UP => healthy + defer server.Close() + + cb := cbQueryBreaker(t, server.URL, 2, 50*time.Millisecond) + + // Force the breaker open with a stale lastChecked so the next QUERY attempts recovery. + cb.state = OpenState + cb.lastChecked = time.Now().Add(-time.Hour) + + resp, err := cb.Query(t.Context(), "search", nil, []byte(`{"q":"ok"}`)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() + + cb.mu.RLock() + state := cb.state + cb.mu.RUnlock() + assert.Equal(t, ClosedState, state, "circuit should be closed after a successful QUERY recovery") +} diff --git a/pkg/gofr/service/custom_header.go b/pkg/gofr/service/custom_header.go index 7e7694989f..600ce3669b 100644 --- a/pkg/gofr/service/custom_header.go +++ b/pkg/gofr/service/custom_header.go @@ -80,6 +80,18 @@ func (a *customHeader) DeleteWithHeaders(ctx context.Context, path string, body return a.HTTP.DeleteWithHeaders(ctx, path, body, headers) } +func (a *customHeader) Query(ctx context.Context, path string, queryParams map[string]any, body []byte) ( + *http.Response, error) { + return a.QueryWithHeaders(ctx, path, queryParams, body, nil) +} + +func (a *customHeader) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, body []byte, + headers map[string]string) (*http.Response, error) { + headers = setCustomHeader(headers, a.Headers) + + return a.HTTP.QueryWithHeaders(ctx, path, queryParams, body, headers) +} + func setCustomHeader(headers, customHeader map[string]string) map[string]string { if headers == nil { headers = make(map[string]string) diff --git a/pkg/gofr/service/custom_header_test.go b/pkg/gofr/service/custom_header_test.go index 4808ce2e16..7b4dfb091a 100644 --- a/pkg/gofr/service/custom_header_test.go +++ b/pkg/gofr/service/custom_header_test.go @@ -166,3 +166,32 @@ func TestCustomDomainProvider_Delete(t *testing.T) { assert.Equal(t, http.StatusNoContent, resp.StatusCode) require.NoError(t, err) } + +func TestCustomDomainProvider_Query(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + queryParams := map[string]any{"key": "value"} + body := []byte(`{"q":"x"}`) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "QUERY", r.Method) + assert.Equal(t, "test_value", r.Header.Get("Test_key")) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + customHeaderService := NewHTTPService(server.URL, logging.NewMockLogger(logging.INFO), nil, + &DefaultHeaders{ + Headers: map[string]string{ + "TEST_KEY": "test_value", + }}) + + resp, err := customHeaderService.Query(t.Context(), "/path", queryParams, body) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/pkg/gofr/service/file_token_auth.go b/pkg/gofr/service/file_token_auth.go index aaec377e35..3e4b875306 100644 --- a/pkg/gofr/service/file_token_auth.go +++ b/pkg/gofr/service/file_token_auth.go @@ -301,3 +301,18 @@ func (d *fileTokenDecorator) DeleteWithHeaders(ctx context.Context, path string, return d.HTTP.DeleteWithHeaders(ctx, path, body, headers) } + +func (d *fileTokenDecorator) Query(ctx context.Context, path string, queryParams map[string]any, + body []byte) (*http.Response, error) { + return d.QueryWithHeaders(ctx, path, queryParams, body, nil) +} + +func (d *fileTokenDecorator) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, + body []byte, headers map[string]string) (*http.Response, error) { + headers, err := d.inject(headers) + if err != nil { + return nil, err + } + + return d.HTTP.QueryWithHeaders(ctx, path, queryParams, body, headers) +} diff --git a/pkg/gofr/service/file_token_auth_test.go b/pkg/gofr/service/file_token_auth_test.go index bfaa664ad6..bbdaebd719 100644 --- a/pkg/gofr/service/file_token_auth_test.go +++ b/pkg/gofr/service/file_token_auth_test.go @@ -243,6 +243,7 @@ func TestFileTokenAuthConfig_InjectsBearerHeaderAllVerbs(t *testing.T) { {"PUT", func() (*http.Response, error) { return svc.Put(ctx, "", nil, nil) }}, {"PATCH", func() (*http.Response, error) { return svc.Patch(ctx, "", nil, nil) }}, {"DELETE", func() (*http.Response, error) { return svc.Delete(ctx, "", nil) }}, + {"QUERY", func() (*http.Response, error) { return svc.Query(ctx, "", nil, nil) }}, } for _, tc := range tests { diff --git a/pkg/gofr/service/mock_http_service.go b/pkg/gofr/service/mock_http_service.go index 90f2fd83ef..4d7fb9fb29 100644 --- a/pkg/gofr/service/mock_http_service.go +++ b/pkg/gofr/service/mock_http_service.go @@ -174,6 +174,37 @@ func (mr *MockHTTPMockRecorder) PostWithHeaders(ctx, path, queryParams, body, he return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostWithHeaders", reflect.TypeOf((*MockHTTP)(nil).PostWithHeaders), ctx, path, queryParams, body, headers) } +// Query mocks base method. +func (m *MockHTTP) Query(ctx context.Context, path string, queryParams map[string]any, body []byte) (*http.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Query", ctx, path, queryParams, body) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Query indicates an expected call of Query. +func (mr *MockHTTPMockRecorder) Query(ctx, path, queryParams, body any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Query", reflect.TypeOf((*MockHTTP)(nil).Query), ctx, path, queryParams, body) +} + +// QueryWithHeaders mocks base method. +func (m *MockHTTP) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, body []byte, headers map[string]string) (*http.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryWithHeaders", ctx, path, queryParams, body, headers) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryWithHeaders indicates an expected call of QueryWithHeaders. +func (mr *MockHTTPMockRecorder) QueryWithHeaders(ctx, path, queryParams, body, headers any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryWithHeaders", reflect.TypeOf((*MockHTTP)(nil).QueryWithHeaders), ctx, path, queryParams, body, headers) +} + + // Put mocks base method. func (m *MockHTTP) Put(ctx context.Context, api string, queryParams map[string]any, body []byte) (*http.Response, error) { m.ctrl.T.Helper() @@ -390,3 +421,33 @@ func (mr *MockhttpClientMockRecorder) PutWithHeaders(ctx, api, queryParams, body mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PutWithHeaders", reflect.TypeOf((*MockhttpClient)(nil).PutWithHeaders), ctx, api, queryParams, body, headers) } + +// Query mocks base method. +func (m *MockhttpClient) Query(ctx context.Context, path string, queryParams map[string]any, body []byte) (*http.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Query", ctx, path, queryParams, body) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Query indicates an expected call of Query. +func (mr *MockhttpClientMockRecorder) Query(ctx, path, queryParams, body any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Query", reflect.TypeOf((*MockhttpClient)(nil).Query), ctx, path, queryParams, body) +} + +// QueryWithHeaders mocks base method. +func (m *MockhttpClient) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, body []byte, headers map[string]string) (*http.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryWithHeaders", ctx, path, queryParams, body, headers) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryWithHeaders indicates an expected call of QueryWithHeaders. +func (mr *MockhttpClientMockRecorder) QueryWithHeaders(ctx, path, queryParams, body, headers any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryWithHeaders", reflect.TypeOf((*MockhttpClient)(nil).QueryWithHeaders), ctx, path, queryParams, body, headers) +} diff --git a/pkg/gofr/service/new.go b/pkg/gofr/service/new.go index 9192a77e3d..eee25ef6fb 100644 --- a/pkg/gofr/service/new.go +++ b/pkg/gofr/service/new.go @@ -24,6 +24,10 @@ const ( metricLabelService = "service" ) +// methodQuery is the HTTP QUERY method (RFC 10008). Go's net/http does not yet +// define an http.MethodQuery constant, so it is declared here. +const methodQuery = "QUERY" + type httpService struct { *http.Client trace.Tracer @@ -76,6 +80,13 @@ type httpClient interface { Delete(ctx context.Context, api string, body []byte) (*http.Response, error) // DeleteWithHeaders performs an HTTP DELETE request with custom headers. DeleteWithHeaders(ctx context.Context, api string, body []byte, headers map[string]string) (*http.Response, error) + + // Query performs an HTTP QUERY request (RFC 10008). QUERY is a safe, idempotent + // method that carries the query in the request body. + Query(ctx context.Context, path string, queryParams map[string]any, body []byte) (*http.Response, error) + // QueryWithHeaders performs an HTTP QUERY request with custom headers. + QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, body []byte, + headers map[string]string) (*http.Response, error) } // NewHTTPService function creates a new instance of the httpService struct, which implements the HTTP interface. @@ -156,6 +167,16 @@ func (h *httpService) DeleteWithHeaders(ctx context.Context, path string, body [ return h.createAndSendRequest(ctx, http.MethodDelete, path, nil, body, headers) } +func (h *httpService) Query(ctx context.Context, path string, queryParams map[string]any, + body []byte) (*http.Response, error) { + return h.QueryWithHeaders(ctx, path, queryParams, body, nil) +} + +func (h *httpService) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, + body []byte, headers map[string]string) (*http.Response, error) { + return h.createAndSendRequest(ctx, methodQuery, path, queryParams, body, headers) +} + func (h *httpService) createAndSendRequest(ctx context.Context, method string, path string, queryParams map[string]any, body []byte, headers map[string]string) (*http.Response, error) { uri := h.url + "/" + path diff --git a/pkg/gofr/service/new_test.go b/pkg/gofr/service/new_test.go index a739482af6..8a7e32ded2 100644 --- a/pkg/gofr/service/new_test.go +++ b/pkg/gofr/service/new_test.go @@ -442,3 +442,65 @@ func newService(t *testing.T, server *httptest.Server) *httpService { Logger: logging.NewMockLogger(logging.INFO), } } + +func TestHTTPService_Query(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + + assert.Equal(t, "QUERY", r.Method) + assert.Equal(t, "/search", r.URL.Path) + assert.Equal(t, "index=books", r.URL.RawQuery) + assert.JSONEq(t, `{"filter":"title"}`, string(body)) + // Content-Type defaults to application/json when the caller sets none. + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + service := newService(t, server) + resp, err := service.Query(t.Context(), "search", + map[string]any{"index": "books"}, []byte(`{"filter":"title"}`)) + + validateResponse(t, resp, err, false) +} + +func TestHTTPService_QueryWithHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + + assert.Equal(t, "QUERY", r.Method) + assert.Equal(t, "/search", r.URL.Path) + assert.Equal(t, "application/sql", r.Header.Get("Content-Type")) + assert.Equal(t, "SELECT 1", string(body)) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + service := newService(t, server) + resp, err := service.QueryWithHeaders(t.Context(), "search", nil, []byte("SELECT 1"), + map[string]string{"content-type": "application/sql"}) + + validateResponse(t, resp, err, false) +} + +func TestHTTPService_Query_EmptyBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + + assert.Equal(t, "QUERY", r.Method) + assert.Empty(t, string(body)) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + service := newService(t, server) + resp, err := service.Query(t.Context(), "search", nil, nil) + + validateResponse(t, resp, err, false) +} diff --git a/pkg/gofr/service/rate_limiter.go b/pkg/gofr/service/rate_limiter.go index 79e9844d5e..b2ec6f6b36 100644 --- a/pkg/gofr/service/rate_limiter.go +++ b/pkg/gofr/service/rate_limiter.go @@ -216,3 +216,29 @@ func (rl *rateLimiter) DeleteWithHeaders(ctx context.Context, path string, body return rl.HTTP.DeleteWithHeaders(ctx, path, body, headers) } + +// Query performs rate-limited HTTP QUERY request. +func (rl *rateLimiter) Query(ctx context.Context, path string, queryParams map[string]any, + body []byte) (*http.Response, error) { + fullURL := rl.buildFullURL(path) + req, _ := http.NewRequestWithContext(ctx, methodQuery, fullURL, http.NoBody) + + if err := rl.checkRateLimit(req); err != nil { + return nil, err + } + + return rl.HTTP.Query(ctx, path, queryParams, body) +} + +// QueryWithHeaders performs rate-limited HTTP QUERY request with custom headers. +func (rl *rateLimiter) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, body []byte, + headers map[string]string) (*http.Response, error) { + fullURL := rl.buildFullURL(path) + req, _ := http.NewRequestWithContext(ctx, methodQuery, fullURL, http.NoBody) + + if err := rl.checkRateLimit(req); err != nil { + return nil, err + } + + return rl.HTTP.QueryWithHeaders(ctx, path, queryParams, body, headers) +} diff --git a/pkg/gofr/service/rate_limiter_test.go b/pkg/gofr/service/rate_limiter_test.go index 727e7d2753..b6b3294942 100644 --- a/pkg/gofr/service/rate_limiter_test.go +++ b/pkg/gofr/service/rate_limiter_test.go @@ -180,3 +180,30 @@ func TestRateLimiter_HTTPMethods(t *testing.T) { _ = resp.Body.Close() } + +func TestRateLimiter_QueryMethods(t *testing.T) { + store := &mockStore{allowed: true} + + rl := &rateLimiter{ + config: RateLimiterConfig{ + KeyFunc: func(*http.Request) string { return "svc" }, + Store: store, + }, + store: store, + HTTP: &mockHTTP{}, + } + + ctx := context.Background() + + resp, err := rl.Query(ctx, "foo", nil, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + defer resp.Body.Close() + + resp, err = rl.QueryWithHeaders(ctx, "foo", nil, nil, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + _ = resp.Body.Close() +} diff --git a/pkg/gofr/service/retry.go b/pkg/gofr/service/retry.go index b3387b28c3..817578a17e 100644 --- a/pkg/gofr/service/retry.go +++ b/pkg/gofr/service/retry.go @@ -100,6 +100,20 @@ func (rp *retryProvider) DeleteWithHeaders(ctx context.Context, path string, bod }) } +func (rp *retryProvider) Query(ctx context.Context, path string, queryParams map[string]any, body []byte) ( + *http.Response, error) { + return rp.doWithRetry(func() (*http.Response, error) { + return rp.HTTP.Query(ctx, path, queryParams, body) + }) +} + +func (rp *retryProvider) QueryWithHeaders(ctx context.Context, path string, queryParams map[string]any, body []byte, + headers map[string]string) (*http.Response, error) { + return rp.doWithRetry(func() (*http.Response, error) { + return rp.HTTP.QueryWithHeaders(ctx, path, queryParams, body, headers) + }) +} + func (rp *retryProvider) doWithRetry(reqFunc func() (*http.Response, error)) (*http.Response, error) { var ( resp *http.Response diff --git a/pkg/gofr/service/retry_test.go b/pkg/gofr/service/retry_test.go index c26a148eff..b5287596d5 100644 --- a/pkg/gofr/service/retry_test.go +++ b/pkg/gofr/service/retry_test.go @@ -72,6 +72,15 @@ func (*mockHTTP) DeleteWithHeaders(_ context.Context, _ string, _ []byte, _ map[ return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody}, nil } +func (*mockHTTP) Query(_ context.Context, _ string, _ map[string]any, _ []byte) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func (*mockHTTP) QueryWithHeaders(_ context.Context, _ string, _ map[string]any, _ []byte, + _ map[string]string) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + // Helper to create a retry HTTP instance. func newRetryHTTP() HTTP { mockHTTP := &mockHTTP{} @@ -130,6 +139,29 @@ func TestRetryProvider_PostWithHeaders(t *testing.T) { assert.Equal(t, http.StatusCreated, resp.StatusCode) } +func TestRetryProvider_Query(t *testing.T) { + retryHTTP := newRetryHTTP() + + resp, err := retryHTTP.Query(t.Context(), "/test", nil, []byte("body")) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestRetryProvider_QueryWithHeaders(t *testing.T) { + retryHTTP := newRetryHTTP() + + resp, err := retryHTTP.QueryWithHeaders(t.Context(), "/test", nil, []byte("body"), + map[string]string{"Content-Type": "application/json"}) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + func TestRetryProvider_Put(t *testing.T) { retryHTTP := newRetryHTTP() diff --git a/pkg/gofr/tools.go b/pkg/gofr/tools.go index 53a3763fa5..eda69dcaf9 100644 --- a/pkg/gofr/tools.go +++ b/pkg/gofr/tools.go @@ -1,10 +1,12 @@ package gofr import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" "path" @@ -28,6 +30,11 @@ const ( schemaTypeString = "string" schemaTypeObject = "object" + // methodQuery is the HTTP QUERY method (RFC 10008). Go's net/http has no such constant yet. + methodQuery = "QUERY" + // bodyKey is the tool argument that carries a QUERY request body (the query payload). + bodyKey = "body" + maxToolResponseBytes = 4 << 20 // cap a captured tool response at 4 MiB ) @@ -114,16 +121,17 @@ func (rt *routerTools) specFor(method, pathTemplate string) (ai.ToolSpec, bool) return ai.ToolSpec{}, false } - // Only read-only handlers (GET/HEAD/OPTIONS) are exposed as tools; write handlers are never + // Only safe handlers are exposed as tools: read-only methods (GET/HEAD/OPTIONS) and QUERY + // (RFC 10008), which is safe and idempotent. Write handlers (POST/PUT/PATCH/DELETE) are never // exposed, so an agent cannot mutate state through the MCP surface. - if !isReadOnlyMethod(method) { + if !isExposableMethod(method) { return ai.ToolSpec{}, false } return ai.ToolSpec{ Name: toolName(method, pathTemplate), Description: method + " " + pathTemplate, - InputSchema: toolSchema(pathTemplate), + InputSchema: toolSchema(method, pathTemplate), Access: ai.ReadOnly, }, true } @@ -199,6 +207,13 @@ func isReadOnlyMethod(method string) bool { } } +// isExposableMethod reports whether a route's method may be exposed as an agent tool. Read-only +// methods (GET/HEAD/OPTIONS) and QUERY (RFC 10008 — safe and idempotent, carrying its input in the +// request body) qualify; write methods never do. +func isExposableMethod(method string) bool { + return isReadOnlyMethod(method) || method == methodQuery +} + func toolName(method, pathTemplate string) string { var b strings.Builder @@ -216,24 +231,37 @@ func toolName(method, pathTemplate string) string { return b.String() } -// toolSchema builds the JSON Schema for a tool's arguments from the route's path parameters. A route -// with no path parameters gets no schema (nil). Only read-only handlers become tools, so there is no -// request body to describe. -func toolSchema(pathTemplate string) json.RawMessage { +// toolSchema builds the JSON Schema for a tool's arguments from the route's path parameters, plus a +// required "body" object for QUERY tools (RFC 10008 carries the query in the request body). A route +// with no path parameters and no body gets no schema (nil). +func toolSchema(method, pathTemplate string) json.RawMessage { params := pathParams(pathTemplate) - if len(params) == 0 { + needsBody := method == methodQuery + + if len(params) == 0 && !needsBody { return nil } props := map[string]any{} + required := make([]string, 0, len(params)+1) + for _, p := range params { props[p] = map[string]string{schemaKeyType: schemaTypeString} + required = append(required, p) // path params are always required + } + + if needsBody { + props[bodyKey] = map[string]any{ + schemaKeyType: schemaTypeObject, + "description": "The QUERY request body (RFC 10008): the query payload sent to the endpoint.", + } + required = append(required, bodyKey) } schema := map[string]any{ schemaKeyType: schemaTypeObject, "properties": props, - "required": params, // path params are always required + "required": required, } out, _ := json.Marshal(schema) @@ -279,6 +307,22 @@ func buildToolRequest(ctx context.Context, method, pathTemplate string, args jso } } + // QUERY tools carry the query payload in the request body; extract it before the remaining + // arguments are mapped to path/query so it is not double-counted as a query value. + var ( + body io.Reader = http.NoBody + hasBody bool + ) + + if method == methodQuery { + if raw, ok := fields[bodyKey]; ok { + delete(fields, bodyKey) + + body = bytes.NewReader(raw) + hasBody = true + } + } + reqPath, query, err := splitArgs(pathTemplate, fields) if err != nil { return nil, err @@ -289,13 +333,21 @@ func buildToolRequest(ctx context.Context, method, pathTemplate string, args jso target += "?" + enc } - // Read-only tools carry no request body. - return http.NewRequestWithContext(ctx, method, target, http.NoBody) + req, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return nil, err + } + + if hasBody { + req.Header.Set("Content-Type", "application/json") + } + + return req, nil } // splitArgs maps a tool's arguments to a path and query string. Path parameters are substituted into -// their route segment; every other argument becomes a query value. Only read-only tools are exposed, -// so there is no request body. +// their route segment; every other argument becomes a query value. For QUERY tools the body argument +// is extracted by the caller before this runs, so only path and query values remain here. func splitArgs(pathTemplate string, fields map[string]json.RawMessage, ) (reqPath string, query url.Values, err error) { params := make(map[string]bool)