diff --git a/router-tests/go.mod b/router-tests/go.mod index 3de9ec5fcf..c78c581ece 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -26,7 +26,7 @@ require ( github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e github.com/wundergraph/cosmo/router v0.0.0-20251125205644-175f80c4e6d9 github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 go.opentelemetry.io/otel v1.36.0 go.opentelemetry.io/otel/sdk v1.36.0 go.opentelemetry.io/otel/sdk/metric v1.36.0 diff --git a/router-tests/go.sum b/router-tests/go.sum index dc73f635fd..87adbb2977 100644 --- a/router-tests/go.sum +++ b/router-tests/go.sum @@ -352,8 +352,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 h1:MYewlXgIhI9jusocPUeyo346J3M5cqzc6ddru1qp+S8= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 h1:B4/E6zJ5PbHNqcNw4Bvki7o+alcgyz8L5+Nlq1FZbLM= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= diff --git a/router-tests/modules/custom_trace_propagator_test.go b/router-tests/modules/custom_trace_propagator_test.go index ed269a6ff9..5ccc0d5642 100644 --- a/router-tests/modules/custom_trace_propagator_test.go +++ b/router-tests/modules/custom_trace_propagator_test.go @@ -1,4 +1,4 @@ -package module +package module_test import ( "encoding/json" diff --git a/router-tests/modules/streams_hooks_combined_test.go b/router-tests/modules/streams_hooks_combined_test.go index 47a25b48c6..8f189ea529 100644 --- a/router-tests/modules/streams_hooks_combined_test.go +++ b/router-tests/modules/streams_hooks_combined_test.go @@ -1,4 +1,4 @@ -package module +package module_test import ( "encoding/json" @@ -8,6 +8,8 @@ import ( "github.com/hasura/go-graphql-client" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + "github.com/wundergraph/cosmo/router-tests/events" stream_publish "github.com/wundergraph/cosmo/router-tests/modules/stream-publish" stream_receive "github.com/wundergraph/cosmo/router-tests/modules/stream-receive" @@ -16,7 +18,6 @@ import ( "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/cosmo/router/pkg/pubsub/kafka" - "go.uber.org/zap/zapcore" ) func TestStreamsHooksCombined(t *testing.T) { diff --git a/router-tests/modules/verify-cost-analysis/module.go b/router-tests/modules/verify-cost-analysis/module.go new file mode 100644 index 0000000000..3ff68278ab --- /dev/null +++ b/router-tests/modules/verify-cost-analysis/module.go @@ -0,0 +1,63 @@ +package verify_cost_analysis + +import ( + "net/http" + + "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/core" +) + +const myModuleID = "verifyCost" + +// CapturedCost holds the captured cost from operation context +type CapturedCost struct { + Cost core.OperationCost + Error error +} + +// VerifyCostModule captures cost for verification in tests +type VerifyCostModule struct { + ResultsChan chan CapturedCost + Logger *zap.Logger +} + +func (m *VerifyCostModule) Provision(ctx *core.ModuleContext) error { + m.Logger = ctx.Logger + if m.ResultsChan == nil { + m.ResultsChan = make(chan CapturedCost, 1) + } + return nil +} + +func (m *VerifyCostModule) Middleware(ctx core.RequestContext, next http.Handler) { + operation := ctx.Operation() + + cost, err := operation.Cost() + captured := CapturedCost{Cost: cost, Error: err} + + // Send the captured values to the test + select { + case m.ResultsChan <- captured: + default: + // Channel is full, skip + } + + next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) +} + +func (m *VerifyCostModule) Module() core.ModuleInfo { + return core.ModuleInfo{ + ID: myModuleID, + Priority: 1, + New: func() core.Module { + return &VerifyCostModule{ + ResultsChan: make(chan CapturedCost, 1), + } + }, + } +} + +var ( + _ core.RouterMiddlewareHandler = (*VerifyCostModule)(nil) +) diff --git a/router-tests/modules/verify_cost_analysis_test.go b/router-tests/modules/verify_cost_analysis_test.go new file mode 100644 index 0000000000..f967895c7a --- /dev/null +++ b/router-tests/modules/verify_cost_analysis_test.go @@ -0,0 +1,103 @@ +package module_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + verifyModule "github.com/wundergraph/cosmo/router-tests/modules/verify-cost-analysis" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestCostModuleExposition(t *testing.T) { + t.Parallel() + + t.Run("module can access cost when cost analysis is enabled", func(t *testing.T) { + t.Parallel() + + resultsChan := make(chan verifyModule.CapturedCost, 1) + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "verifyCost": verifyModule.VerifyCostModule{ + ResultsChan: resultsChan, + }, + }, + } + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&verifyModule.VerifyCostModule{}), + }, + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeMeasure, + EstimatedListSize: 10, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `query GetEmployee { employee(id: 1) { id } }`, + }) + require.NoError(t, err) + assert.Equal(t, 200, res.Response.StatusCode) + + testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedCost) { + assert.NoError(t, captured.Error, "Cost() should not return an error when cost analysis is enabled") + assert.Greater(t, captured.Cost.Estimated, 0, "Estimated cost should be greater than 0 for a query with object fields") + + // Log the cost for demonstration purposes + t.Logf("Query estimated cost: %d (could be used for rate limiting)", captured.Cost.Estimated) + // In a real module, you could use this cost for: + // - Rate limiting (deduct from user's cost budget) + // - Logging/metrics + // - Custom rejection logic + // - Billing/monetization + + }) + }) + }) + + t.Run("module receives error when cost analysis is disabled", func(t *testing.T) { + t.Parallel() + + resultsChan := make(chan verifyModule.CapturedCost, 1) + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "verifyCost": verifyModule.VerifyCostModule{ + ResultsChan: resultsChan, + }, + }, + } + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&verifyModule.VerifyCostModule{}), + }, + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = nil + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, err := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `query GetEmployee { employee(id: 1) { id } }`, + }) + require.NoError(t, err) + assert.Equal(t, 200, res.Response.StatusCode) + + testenv.AwaitChannelWithT(t, 10*time.Second, resultsChan, func(t *testing.T, captured verifyModule.CapturedCost) { + assert.Error(t, captured.Error, "Cost() should return an error when cost analysis is disabled") + assert.Equal(t, 0, captured.Cost.Estimated, "Estimated cost should be 0 when cost analysis is disabled") + }) + }) + }) +} diff --git a/router-tests/security_test.go b/router-tests/security_test.go index 74fba8fd7c..cd83d7ae39 100644 --- a/router-tests/security_test.go +++ b/router-tests/security_test.go @@ -364,4 +364,192 @@ func TestQueryNamingLimits(t *testing.T) { }) }) }) + + t.Run("cost analysis", func(t *testing.T) { + t.Parallel() + + // These tests verify that cost is calculated using default values + // when no @cost or @listSize directives are specified in the schema. + + t.Run("enforce mode blocks queries exceeding estimated cost limit", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 9, + EstimatedListSize: 5, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, _ := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 5 * (1 + 1) + require.Equal(t, 400, res.Response.StatusCode) + require.Contains(t, res.Body, "exceeds the maximum allowed estimated cost") + }) + }) + + t.Run("enforce mode allows queries under estimated cost limit", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 11, + EstimatedListSize: 5, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 5 * (1 + 1) + require.Equal(t, 200, res.Response.StatusCode) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("enforce mode with non-list query", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 2, // employee (1) + details (1) = 2 + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employee(id:1) { id details { forename } } }`, + }) + // Cost: 1 + 1 + require.Equal(t, 200, res.Response.StatusCode) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("estimated list size configuration affects cost calculation", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 2, + EstimatedListSize: 2, // Small list size + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id } }`, + }) + // cost: 2 * 1 + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("enforce mode blocks when estimated list size is zero (floored to 1)", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 1, + EstimatedListSize: 0, // will be floored to 1 + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, _ := xEnv.MakeGraphQLRequest(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 1 * (1 + 1) + require.Equal(t, 400, res.Response.StatusCode) + require.Contains(t, res.Body, "exceeds the maximum allowed estimated cost") + }) + }) + + t.Run("disabled cost analysis does not block queries", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: false, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 1, + EstimatedListSize: 10, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + // cost = 10 * (1 + 1) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("measure mode does not block queries", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeMeasure, + EstimatedLimit: 1, // Would block in enforce mode + EstimatedListSize: 10, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("enforce mode with zero estimated limit does not block", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 0, // Zero limit means no enforcement + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename surname } } }`, + }) + require.Contains(t, res.Body, `"data":`) + }) + }) + + t.Run("nested list fields multiply estimated cost", func(t *testing.T) { + // Just one additional test; more thorough testing is done in the engine. + t.Parallel() + testenv.Run(t, &testenv.Config{ + ModifySecurityConfiguration: func(securityConfiguration *config.SecurityConfiguration) { + securityConfiguration.CostAnalysis = &config.CostAnalysis{ + Enabled: true, + Mode: config.CostAnalysisModeEnforce, + EstimatedLimit: 10, + EstimatedListSize: 5, + } + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: `{ employees { id details { forename } } }`, + }) + // cost = 5 * (1 + 1) + require.Contains(t, res.Body, `"data":`) + }) + }) + }) } diff --git a/router/core/context.go b/router/core/context.go index 264e166c32..5ef95838ff 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -11,23 +11,21 @@ import ( "sync" "time" - rcontext "github.com/wundergraph/cosmo/router/internal/context" - "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" "github.com/wundergraph/astjson" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - graphqlmetrics "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + rcontext "github.com/wundergraph/cosmo/router/internal/context" "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/pkg/authentication" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" ctrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" ) var _ RequestContext = (*requestContext)(nil) @@ -504,6 +502,10 @@ type OperationContext interface { // if called too early in request chain, it may be inaccurate for modules, using // in Middleware is recommended QueryPlanStats() (QueryPlanStats, error) + + // Cost returns cost analysis results for the operation. + // This should be called after planning is complete; using in Middleware is recommended. + Cost() (OperationCost, error) } var _ OperationContext = (*operationContext)(nil) @@ -536,6 +538,7 @@ type operationContext struct { variables *astjson.Value files []*httpclient.FileUpload clientInfo *ClientInfo + planConfig plan.Configuration // preparedPlan is the prepared plan of the operation preparedPlan *planWithMetaData traceOptions resolve.TraceOptions @@ -623,6 +626,12 @@ type QueryPlanStats struct { SubgraphRootFields []SubgraphRootField } +// OperationCost holds cost analysis results for an operation. +type OperationCost struct { + // Estimated is the static cost calculated before execution based on @cost and @listSize directives. + Estimated int +} + type SubgraphRootField struct { SubgraphName string TypeName string @@ -721,6 +730,21 @@ func (o *operationContext) QueryPlanStats() (QueryPlanStats, error) { return qps, nil } +func (o *operationContext) Cost() (OperationCost, error) { + if o == nil || o.preparedPlan == nil || o.preparedPlan.preparedPlan == nil { + return OperationCost{}, errors.New("operation context or prepared plan is nil") + } + + costCalc := o.preparedPlan.preparedPlan.GetStaticCostCalculator() + if costCalc == nil { + return OperationCost{}, errors.New("cost analysis is not enabled") + } + + return OperationCost{ + Estimated: costCalc.GetStaticCost(o.planConfig, o.variables), + }, nil +} + type SubgraphResolver struct { subgraphsByURL map[string]*Subgraph subgraphsByID map[string]*Subgraph diff --git a/router/core/executor.go b/router/core/executor.go index e29ed7682b..0363bb2b83 100644 --- a/router/core/executor.go +++ b/router/core/executor.go @@ -8,6 +8,11 @@ import ( "go.uber.org/zap" + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/grpcconnector" + pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/asttransform" @@ -15,11 +20,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" - - nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" - "github.com/wundergraph/cosmo/router/pkg/config" - "github.com/wundergraph/cosmo/router/pkg/grpcconnector" - pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" ) type ExecutorConfigurationBuilder struct { @@ -243,5 +243,11 @@ func (b *ExecutorConfigurationBuilder) buildPlannerConfiguration(ctx context.Con planConfig.BuildFetchReasons = routerEngineCfg.Execution.EnableRequireFetchReasons || routerEngineCfg.Execution.ValidateRequiredExternalFields planConfig.ValidateRequiredExternalFields = routerEngineCfg.Execution.ValidateRequiredExternalFields + // Enable static cost computation when cost analysis is enabled + if routerEngineCfg.CostAnalysis != nil && routerEngineCfg.CostAnalysis.Enabled { + planConfig.ComputeStaticCost = true + planConfig.StaticCostDefaultListSize = routerEngineCfg.CostAnalysis.EstimatedListSize + } + return planConfig, providers, nil } diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index d8cfecc283..ae6f4b0767 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -8,26 +8,23 @@ import ( "net/url" "slices" - rmetric "github.com/wundergraph/cosmo/router/pkg/metric" - "github.com/buger/jsonparser" + "github.com/jensneuse/abstractlogger" + "go.uber.org/zap" + + "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/common" + nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/grpcconnector" + rmetric "github.com/wundergraph/cosmo/router/pkg/metric" "github.com/wundergraph/cosmo/router/pkg/pubsub" pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/argument_templates" - - "github.com/wundergraph/cosmo/router/pkg/config" - - "github.com/jensneuse/abstractlogger" - "go.uber.org/zap" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/argument_templates" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/graphql_datasource" grpcdatasource "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/grpc_datasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/staticdatasource" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - - "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/common" - nodev1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1" ) type Loader struct { @@ -216,6 +213,7 @@ type RouterEngineConfiguration struct { Events config.EventsConfiguration SubgraphErrorPropagation config.SubgraphErrorPropagationConfiguration StreamMetricStore rmetric.StreamMetricStore + CostAnalysis *config.CostAnalysis } func mapProtoFilterToPlanFilter(input *nodev1.SubscriptionFilterCondition, output *plan.SubscriptionFilterCondition) *plan.SubscriptionFilterCondition { @@ -555,6 +553,7 @@ func (l *Loader) dataSourceMetaData(in *nodev1.DataSourceConfiguration) *plan.Da EntityInterfaces: make([]plan.EntityInterfaceConfiguration, 0, len(in.EntityInterfaces)), InterfaceObjects: make([]plan.EntityInterfaceConfiguration, 0, len(in.InterfaceObjects)), }, + CostConfig: plan.NewDataSourceCostConfig(), } for _, node := range in.RootNodes { @@ -635,6 +634,7 @@ func (l *Loader) dataSourceMetaData(in *nodev1.DataSourceConfiguration) *plan.Da ConcreteTypeNames: interfaceObjectConfiguration.ConcreteTypeNames, }) } + // TODO: import CostConfigs from in (produced by composition) to be consumed by the router. return out } diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 87aa96331c..02585b7f6d 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -22,7 +22,6 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/klauspost/compress/gzhttp" "github.com/klauspost/compress/gzip" - "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" oteltrace "go.opentelemetry.io/otel/trace" @@ -57,6 +56,8 @@ import ( "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/cosmo/router/pkg/statistics" rtrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" ) const ( @@ -519,12 +520,12 @@ type graphMux struct { validationCache *ristretto.Cache[uint64, bool] operationHashCache *ristretto.Cache[uint64, string] - accessLogsFileLogger *logging.BufferedLogger - metricStore rmetric.Store - prometheusCacheMetrics *rmetric.CacheMetrics - otelCacheMetrics *rmetric.CacheMetrics - streamMetricStore rmetric.StreamMetricStore - prometheusMetricsExporter *graphqlmetrics.PrometheusMetricsExporter + accessLogsFileLogger *logging.BufferedLogger + metricStore rmetric.Store + prometheusCacheMetrics *rmetric.CacheMetrics + otelCacheMetrics *rmetric.CacheMetrics + streamMetricStore rmetric.StreamMetricStore + prometheusMetricsExporter *graphqlmetrics.PrometheusMetricsExporter } // buildOperationCaches creates the caches for the graph mux. @@ -1192,6 +1193,7 @@ func (s *graphServer) buildGraphMux( Events: s.eventsConfig, SubgraphErrorPropagation: s.subgraphErrorPropagation, StreamMetricStore: gm.streamMetricStore, + CostAnalysis: s.securityConfiguration.CostAnalysis, } // map[string]*http.Transport cannot be coerced into map[string]http.RoundTripper, unfortunately @@ -1295,6 +1297,7 @@ func (s *graphServer) buildGraphMux( ApolloRouterCompatibilityFlags: s.apolloRouterCompatibilityFlags, DisableExposingVariablesContentOnValidationError: s.engineExecutionConfiguration.DisableExposingVariablesContentOnValidationError, ComplexityLimits: s.securityConfiguration.ComplexityLimits, + CostAnalysis: s.securityConfiguration.CostAnalysis, }) operationPlanner := NewOperationPlanner(executor, gm.planCache) diff --git a/router/core/graphql_prehandler.go b/router/core/graphql_prehandler.go index 02a96f69e4..d818b6e193 100644 --- a/router/core/graphql_prehandler.go +++ b/router/core/graphql_prehandler.go @@ -14,29 +14,27 @@ import ( "sync" "time" - "go.opentelemetry.io/otel/codes" - "github.com/go-chi/chi/v5/middleware" "github.com/golang-jwt/jwt/v5" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" otelmetric "go.opentelemetry.io/otel/metric" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "github.com/wundergraph/astjson" - - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - "github.com/wundergraph/graphql-go-tools/v2/pkg/graphqlerrors" - "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/internal/persistedoperation" "github.com/wundergraph/cosmo/router/pkg/art" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/otel" rtrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "github.com/wundergraph/graphql-go-tools/v2/pkg/graphqlerrors" ) type PreHandlerOptions struct { @@ -1070,6 +1068,11 @@ func (h *PreHandler) handleOperation(w http.ResponseWriter, req *http.Request, v enginePlanSpan.SetAttributes(otel.WgEnginePlanCacheHit.Bool(requestContext.operation.planCacheHit)) enginePlanSpan.End() + // Check static cost limits after planning + if err := operationKit.ValidateStaticCost(requestContext.operation.preparedPlan.preparedPlan, requestContext.operation.variables); err != nil { + return err + } + planningAttrs := *requestContext.telemetry.AcquireAttributes() planningAttrs = append(planningAttrs, otel.WgEnginePlanCacheHit.Bool(requestContext.operation.planCacheHit)) planningAttrs = append(planningAttrs, requestContext.telemetry.metricAttrs...) diff --git a/router/core/modules.go b/router/core/modules.go index 7cd5507f25..130c5d0eba 100644 --- a/router/core/modules.go +++ b/router/core/modules.go @@ -105,11 +105,11 @@ type RouterMiddlewareHandler interface { } // RouterOnRequestHandler allows you to add middleware that runs before most internal router logic. -// This runs after the creation of the request context and the creatio of the recovery handler. -// This hook is useful if you want to do some custom logic before tracing or authentication, for example -// if you want to manipulate the bearer auth headers or add a header on a condition that can be logged by tracing. +// This runs after the creation of the request context and the creation of the recovery handler. +// This hook is useful if you want to do some custom logic before tracing or authentication, for example, +// if you want to manipulate the bearer auth headers or add a header on a condition that tracing can log. // The same semantics of http.Handler apply here. Don't manipulate / consume the body of the request unless -// you know what you are doing. If you consume the body of the request it will not be available for the next handler. +// you know what you are doing. If you consume the body of the request, it will not be available for the next handler. type RouterOnRequestHandler interface { RouterOnRequest(ctx RequestContext, next http.Handler) } diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index 38c3b6aac5..27d3b49c6e 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -6,14 +6,14 @@ import ( "golang.org/x/sync/singleflight" + graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/postprocess" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - - graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" - "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" ) type planWithMetaData struct { @@ -53,6 +53,9 @@ func (p *OperationPlanner) preparePlan(ctx *operationContext) (*planWithMetaData return nil, &reportError{report: &report} } + // Store plan config to access it from the operationContext.ComputeStaticCost() + ctx.planConfig = p.executor.PlanConfig + planner, err := plan.NewPlanner(p.executor.PlanConfig) if err != nil { return nil, err diff --git a/router/core/operation_processor.go b/router/core/operation_processor.go index 8ec9ba5bd1..7dc805d8cd 100644 --- a/router/core/operation_processor.go +++ b/router/core/operation_processor.go @@ -21,7 +21,11 @@ import ( "github.com/dgraph-io/ristretto/v2" "github.com/pkg/errors" "github.com/tidwall/sjson" + fastjson "github.com/wundergraph/astjson" + "github.com/wundergraph/cosmo/router/internal/persistedoperation" + "github.com/wundergraph/cosmo/router/internal/unsafebytes" + "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/graphql-go-tools/v2/pkg/apollocompatibility" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -30,13 +34,10 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/middleware/operation_complexity" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" "github.com/wundergraph/graphql-go-tools/v2/pkg/variablesvalidation" - - "github.com/wundergraph/cosmo/router/internal/persistedoperation" - "github.com/wundergraph/cosmo/router/internal/unsafebytes" - "github.com/wundergraph/cosmo/router/pkg/config" ) var ( @@ -123,6 +124,7 @@ type OperationProcessorOptions struct { ApolloRouterCompatibilityFlags config.ApolloRouterCompatibilityFlags DisableExposingVariablesContentOnValidationError bool ComplexityLimits *config.ComplexityLimits + CostAnalysis *config.CostAnalysis ParserTokenizerLimits astparser.TokenizerLimits OperationNameLengthLimit int } @@ -139,6 +141,7 @@ type OperationProcessor struct { introspectionEnabled bool parseKitOptions *parseKitOptions complexityLimits *config.ComplexityLimits + costAnalysis *config.CostAnalysis parserTokenizerLimits astparser.TokenizerLimits operationNameLengthLimit int } @@ -1361,6 +1364,40 @@ func (o *OperationKit) runComplexityComparisons(complexityLimitConfig *config.Co return nil } +// ValidateStaticCost validates that the estimated query cost is within the configured limit. +// This should be called after planning, as the cost calculator is populated during the planning phase. +func (o *OperationKit) ValidateStaticCost(preparedPlan plan.Plan, variables *fastjson.Value) error { + costAnalysis := o.operationProcessor.costAnalysis + if preparedPlan == nil || costAnalysis == nil || !costAnalysis.Enabled { + return nil + } + + // Only enforce limits in enforce mode + if costAnalysis.Mode != config.CostAnalysisModeEnforce { + return nil + } + + if costAnalysis.EstimatedLimit <= 0 { + return nil + } + + costCalc := preparedPlan.GetStaticCostCalculator() + if costCalc == nil { + return nil + } + + estimatedCost := costCalc.GetStaticCost(o.operationProcessor.executor.PlanConfig, variables) + + if estimatedCost > costAnalysis.EstimatedLimit { + return &httpGraphqlError{ + message: fmt.Sprintf("The estimated query cost %d exceeds the maximum allowed estimated cost %d", estimatedCost, costAnalysis.EstimatedLimit), + statusCode: http.StatusBadRequest, + } + } + + return nil +} + var ( literalIF = []byte("if") ) @@ -1449,6 +1486,7 @@ func NewOperationProcessor(opts OperationProcessorOptions) *OperationProcessor { parserTokenizerLimits: opts.ParserTokenizerLimits, operationNameLengthLimit: opts.OperationNameLengthLimit, complexityLimits: opts.ComplexityLimits, + costAnalysis: opts.CostAnalysis, parseKitOptions: &parseKitOptions{ apolloCompatibilityFlags: opts.ApolloCompatibilityFlags, apolloRouterCompatibilityFlags: opts.ApolloRouterCompatibilityFlags, diff --git a/router/core/websocket.go b/router/core/websocket.go index 35d6ffc304..0df395bb8f 100644 --- a/router/core/websocket.go +++ b/router/core/websocket.go @@ -20,14 +20,10 @@ import ( "github.com/gobwas/ws/wsutil" "github.com/gorilla/websocket" "github.com/tidwall/gjson" - "github.com/wundergraph/astjson" "go.uber.org/atomic" "go.uber.org/zap" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - "github.com/wundergraph/graphql-go-tools/v2/pkg/netpoll" - + "github.com/wundergraph/astjson" "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/internal/persistedoperation" "github.com/wundergraph/cosmo/router/internal/wsproto" @@ -36,6 +32,10 @@ import ( "github.com/wundergraph/cosmo/router/pkg/logging" "github.com/wundergraph/cosmo/router/pkg/statistics" rtrace "github.com/wundergraph/cosmo/router/pkg/trace" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "github.com/wundergraph/graphql-go-tools/v2/pkg/netpoll" ) var ( @@ -957,6 +957,11 @@ func (h *WebSocketConnectionHandler) parseAndPlan(registration *SubscriptionRegi opContext.planningTime = time.Since(startPlanning) + // Check static cost limits after planning + if err := operationKit.ValidateStaticCost(opContext.preparedPlan.preparedPlan, opContext.variables); err != nil { + return operationKit.parsedOperation, nil, err + } + opContext.initialPayload = h.initialPayload return operationKit.parsedOperation, opContext, nil diff --git a/router/go.mod b/router/go.mod index 415918d050..b5bacef894 100644 --- a/router/go.mod +++ b/router/go.mod @@ -31,7 +31,7 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/twmb/franz-go v1.16.1 - github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 + github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 // Do not upgrade, it renames attributes we rely on go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 go.opentelemetry.io/contrib/propagators/b3 v1.23.0 diff --git a/router/go.sum b/router/go.sum index 715b552f47..18ae41d475 100644 --- a/router/go.sum +++ b/router/go.sum @@ -322,8 +322,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083 h1:8/D7f8gKxTBjW+SZK4mhxTTBVpxcqeBgWF1Rfmltbfk= github.com/wundergraph/astjson v0.0.0-20250106123708-be463c97e083/go.mod h1:eOTL6acwctsN4F3b7YE+eE2t8zcJ/doLm9sZzsxxxrE= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245 h1:MYewlXgIhI9jusocPUeyo346J3M5cqzc6ddru1qp+S8= -github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.245/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246 h1:B4/E6zJ5PbHNqcNw4Bvki7o+alcgyz8L5+Nlq1FZbLM= +github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.246/go.mod h1:mX25ASEQiKamxaFSK6NZihh0oDCigIuzro30up4mFH4= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 8eb71bc5f1..7d169b0a8f 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -7,13 +7,12 @@ import ( "strings" "time" - "github.com/wundergraph/cosmo/router/internal/yamlmerge" - "github.com/caarlos0/env/v11" "github.com/goccy/go-yaml" "go.uber.org/zap/zapcore" "github.com/wundergraph/cosmo/router/internal/unique" + "github.com/wundergraph/cosmo/router/internal/yamlmerge" "github.com/wundergraph/cosmo/router/pkg/otel/otelconfig" ) @@ -426,6 +425,7 @@ type SecurityConfiguration struct { BlockPersistedOperations BlockOperationConfiguration `yaml:"block_persisted_operations" envPrefix:"SECURITY_BLOCK_PERSISTED_OPERATIONS_"` ComplexityCalculationCache *ComplexityCalculationCache `yaml:"complexity_calculation_cache"` ComplexityLimits *ComplexityLimits `yaml:"complexity_limits"` + CostAnalysis *CostAnalysis `yaml:"cost_analysis"` DepthLimit *QueryDepthConfiguration `yaml:"depth_limit"` ParserLimits ParserLimitsConfiguration `yaml:"parser_limits"` OperationNameLengthLimit int `yaml:"operation_name_length_limit" envDefault:"512" env:"SECURITY_OPERATION_NAME_LENGTH_LIMIT"` // 0 is disabled @@ -458,6 +458,34 @@ type ComplexityLimits struct { IgnoreIntrospection bool `yaml:"ignore_introspection" envDefault:"false" env:"SECURITY_COMPLEXITY_IGNORE_INTROSPECTION"` } +// CostAnalysisMode defines how cost analysis behaves. +type CostAnalysisMode string + +const ( + CostAnalysisModeMeasure CostAnalysisMode = "measure" + CostAnalysisModeEnforce CostAnalysisMode = "enforce" +) + +// CostAnalysis configures cost analysis based on @cost and @listSize directives. +type CostAnalysis struct { + // Enabled controls whether cost analysis is active. + // When true, the router calculates costs for every operation. + Enabled bool `yaml:"enabled" envDefault:"false" env:"SECURITY_COST_ANALYSIS_ENABLED"` + + // Mode controls cost analysis behavior: + // - "measure": calculates costs without rejecting operations (for monitoring) + // - "enforce": calculates costs and rejects operations exceeding the estimated limit + Mode CostAnalysisMode `yaml:"mode,omitempty" envDefault:"measure" env:"SECURITY_COST_ANALYSIS_MODE"` + + // EstimatedLimit is the maximum allowed estimated cost for a query. + // Requires Mode set to "enforce". Operations exceeding this limit are rejected. + EstimatedLimit int `yaml:"estimated_limit,omitempty" envDefault:"0" env:"SECURITY_COST_ANALYSIS_ESTIMATED_LIMIT"` + + // EstimatedListSize is the default assumed size for list fields when no @listSize directive + // nor slicing argument is provided. Used as a multiplier for estimated cost calculation. + EstimatedListSize int `yaml:"estimated_list_size,omitempty" envDefault:"10" env:"SECURITY_COST_ANALYSIS_ESTIMATED_LIST_SIZE"` +} + type ComplexityLimit struct { Enabled bool `yaml:"enabled" envDefault:"false"` Limit int `yaml:"limit,omitempty" envDefault:"0"` diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index a531fa4af3..7984a39df0 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -2683,6 +2683,36 @@ } } }, + "cost_analysis": { + "type": "object", + "description": "Cost analysis based on @cost and @listSize directives.", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "When enabled, the router calculates costs for every operation." + }, + "mode": { + "type": "string", + "enum": ["measure", "enforce"], + "default": "measure", + "description": "Controls cost analysis behavior: 'measure' calculates costs without rejecting operations; 'enforce' rejects operations exceeding the estimated cost limit." + }, + "estimated_limit": { + "type": "integer", + "description": "Maximum allowed estimated cost for a query. Only enforced when mode is 'enforce'.", + "default": 0, + "minimum": 0 + }, + "estimated_list_size": { + "type": "integer", + "description": "Default assumed size for list fields when no @listSize directive is specified. Used for estimated cost calculation.", + "default": 10, + "minimum": 1 + } + } + }, "operation_name_length_limit": { "type": "integer", "description": "The maximum allowed length of the operation name, 0 allows any length.", diff --git a/router/pkg/config/fixtures/full.yaml b/router/pkg/config/fixtures/full.yaml index 13628a925a..3b6659f394 100644 --- a/router/pkg/config/fixtures/full.yaml +++ b/router/pkg/config/fixtures/full.yaml @@ -446,6 +446,10 @@ security: enabled: true limit: 4 ignore_persisted_operations: true + cost_analysis: + enabled: true + static_limit: 1000 + list_size: 10 operation_name_length_limit: 2000 persisted_operations: safelist: diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index b4ddad685e..e31496a117 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -345,6 +345,7 @@ }, "ComplexityCalculationCache": null, "ComplexityLimits": null, + "CostAnalysis": null, "DepthLimit": null, "ParserLimits": { "ApproximateDepthLimit": 200, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index d4707aa1a8..31b6957597 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -728,6 +728,11 @@ }, "IgnoreIntrospection": false }, + "CostAnalysis": { + "Enabled": true, + "StaticLimit": 1000, + "ListSize": 10 + }, "DepthLimit": null, "ParserLimits": { "ApproximateDepthLimit": 200,