diff --git a/composition/src/directive-definition-data/directive-definition-data.ts b/composition/src/directive-definition-data/directive-definition-data.ts index b6e683a92b..9e9190918e 100644 --- a/composition/src/directive-definition-data/directive-definition-data.ts +++ b/composition/src/directive-definition-data/directive-definition-data.ts @@ -25,6 +25,7 @@ import { EDFS_NATS_STREAM_CONFIGURATION, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_PUBLISH, + EDFS_PUSHER_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, ENUM_UPPER, ENUM_VALUE_UPPER, @@ -104,6 +105,7 @@ import { EDFS_NATS_REQUEST_DEFINITION, EDFS_NATS_SUBSCRIBE_DEFINITION, EDFS_REDIS_PUBLISH_DEFINITION, + EDFS_PUSHER_SUBSCRIBE_DEFINITION, EDFS_REDIS_SUBSCRIBE_DEFINITION, EXTENDS_DEFINITION, EXTERNAL_DEFINITION, @@ -804,6 +806,44 @@ export const REDIS_SUBSCRIBE_DEFINITION_DATA = newDirectiveDefinitionData({ requiredArgumentNames: new Set([CHANNELS]), }); +export const PUSHER_SUBSCRIBE_DEFINITION_DATA = newDirectiveDefinitionData({ + argumentDataByName: new Map([ + [ + CHANNELS, + newDirectiveArgumentData({ + directive: `@${EDFS_PUSHER_SUBSCRIBE}`, + name: CHANNELS, + namedTypeKind: Kind.SCALAR_TYPE_DEFINITION, + typeNode: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.LIST_TYPE, + type: REQUIRED_STRING_TYPE_NODE, + }, + }, + }), + ], + [ + PROVIDER_ID, + newDirectiveArgumentData({ + directive: `@${EDFS_PUSHER_SUBSCRIBE}`, + name: PROVIDER_ID, + namedTypeKind: Kind.SCALAR_TYPE_DEFINITION, + typeNode: REQUIRED_STRING_TYPE_NODE, + defaultValue: { + kind: Kind.STRING, + value: DEFAULT_EDFS_PROVIDER_ID, + }, + }), + ], + ]), + locations: new Set([FIELD_DEFINITION_UPPER]), + name: EDFS_PUSHER_SUBSCRIBE, + node: EDFS_PUSHER_SUBSCRIBE_DEFINITION, + optionalArgumentNames: new Set([PROVIDER_ID]), + requiredArgumentNames: new Set([CHANNELS]), +}); + export const REQUIRE_FETCH_REASONS_DEFINITION_DATA = newDirectiveDefinitionData({ isRepeatable: true, locations: new Set([FIELD_DEFINITION_UPPER, INTERFACE_UPPER, OBJECT_UPPER]), diff --git a/composition/src/router-configuration/types.ts b/composition/src/router-configuration/types.ts index e55d3c7da9..8a2d67f2c6 100644 --- a/composition/src/router-configuration/types.ts +++ b/composition/src/router-configuration/types.ts @@ -13,6 +13,8 @@ export type KafkaEventType = 'subscribe' | 'publish'; export type RedisEventType = 'subscribe' | 'publish'; +export type PusherEventType = 'subscribe'; + export type StreamConfiguration = { consumerInactiveThreshold: number; consumerName: string; @@ -44,7 +46,19 @@ export type RedisEventConfiguration = { type: RedisEventType; }; -export type EventConfiguration = KafkaEventConfiguration | NatsEventConfiguration | RedisEventConfiguration; +export type PusherEventConfiguration = { + fieldName: string; + providerId: string; + providerType: 'pusher'; + channels: string[]; + type: PusherEventType; +}; + +export type EventConfiguration = + | KafkaEventConfiguration + | NatsEventConfiguration + | RedisEventConfiguration + | PusherEventConfiguration; export type SubscriptionFilterValue = boolean | null | number | string; diff --git a/composition/src/utils/string-constants.ts b/composition/src/utils/string-constants.ts index e1221b8204..56527ccd42 100644 --- a/composition/src/utils/string-constants.ts +++ b/composition/src/utils/string-constants.ts @@ -41,6 +41,7 @@ export const EDFS_PUBLISH_RESULT = 'edfs__PublishResult'; export const EDFS_NATS_STREAM_CONFIGURATION = 'edfs__NatsStreamConfiguration'; export const EDFS_REDIS_PUBLISH = 'edfs__redisPublish'; export const EDFS_REDIS_SUBSCRIBE = 'edfs__redisSubscribe'; +export const EDFS_PUSHER_SUBSCRIBE = 'edfs__pusherSubscribe'; export const ENTITIES = 'entities'; export const ENTITIES_FIELD = '_entities'; export const OPENFED_ENTITY_CACHE = 'openfed__entityCache'; @@ -103,6 +104,7 @@ export const PROPAGATE = 'propagate'; export const PROVIDER_TYPE_KAFKA = 'kafka'; export const PROVIDER_TYPE_NATS = 'nats'; export const PROVIDER_TYPE_REDIS = 'redis'; +export const PROVIDER_TYPE_PUSHER = 'pusher'; export const NOT_APPLICABLE = 'N/A'; export const NAME = 'name'; export const NEGATIVE_CACHE_TTL = 'negativeCacheTTL'; diff --git a/composition/src/v1/constants/constants.ts b/composition/src/v1/constants/constants.ts index a91e5bf469..6781321c28 100644 --- a/composition/src/v1/constants/constants.ts +++ b/composition/src/v1/constants/constants.ts @@ -17,6 +17,7 @@ import { EDFS_NATS_REQUEST, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_PUBLISH, + EDFS_PUSHER_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, EXTENDS, EXTERNAL, @@ -58,6 +59,7 @@ import { EDFS_NATS_REQUEST_DEFINITION, EDFS_NATS_SUBSCRIBE_DEFINITION, EDFS_REDIS_PUBLISH_DEFINITION, + EDFS_PUSHER_SUBSCRIBE_DEFINITION, EDFS_REDIS_SUBSCRIBE_DEFINITION, EXTENDS_DEFINITION, EXTERNAL_DEFINITION, @@ -103,6 +105,7 @@ export const DIRECTIVE_DEFINITION_BY_NAME: ReadonlyMap = new Set, + fieldName: string, + errorMessages: string[], + ): EventConfiguration | undefined { + const channels: string[] = []; + let providerId = DEFAULT_EDFS_PROVIDER_ID; + for (const argumentNode of directive.arguments || []) { + switch (argumentNode.name.value) { + case CHANNELS: { + //@TODO list coercion + if (argumentNode.value.kind !== Kind.LIST) { + errorMessages.push(invalidEventSubjectsErrorMessage(CHANNELS)); + continue; + } + for (const value of argumentNode.value.values) { + if (value.kind !== Kind.STRING || value.value.length < 1) { + errorMessages.push(invalidEventSubjectsItemErrorMessage(CHANNELS)); + break; + } + validateArgumentTemplateReferences(value.value, argumentDataByArgumentName, errorMessages); + channels.push(value.value); + } + break; + } + case PROVIDER_ID: { + if (argumentNode.value.kind !== Kind.STRING || argumentNode.value.value.length < 1) { + errorMessages.push(invalidEventProviderIdErrorMessage); + continue; + } + providerId = argumentNode.value.value; + break; + } + } + } + if (errorMessages.length > 0) { + return; + } + return { + fieldName, + providerId, + providerType: PROVIDER_TYPE_PUSHER, + channels, + type: SUBSCRIBE, + }; + } + validateSubscriptionFilterDirectiveLocation(node: FieldDefinitionNode) { if (!node.directives) { return; @@ -3505,6 +3555,15 @@ export class NormalizationFactory { ); break; } + case EDFS_PUSHER_SUBSCRIBE: { + eventConfiguration = this.getPusherSubscribeConfiguration( + directive, + argumentDataByArgumentName, + fieldName, + errorMessages, + ); + break; + } default: continue; } @@ -3534,7 +3593,7 @@ export class NormalizationFactory { case OperationTypeNode.QUERY: return new Set([EDFS_NATS_REQUEST]); case OperationTypeNode.SUBSCRIPTION: - return new Set([EDFS_KAFKA_SUBSCRIBE, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE]); + return new Set([EDFS_KAFKA_SUBSCRIBE, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, EDFS_PUSHER_SUBSCRIBE]); } } diff --git a/composition/src/v1/normalization/utils.ts b/composition/src/v1/normalization/utils.ts index cc23287050..f5e6017aa2 100644 --- a/composition/src/v1/normalization/utils.ts +++ b/composition/src/v1/normalization/utils.ts @@ -69,6 +69,7 @@ import { OVERRIDE_DEFINITION_DATA, PROVIDES_DEFINITION_DATA, REDIS_PUBLISH_DEFINITION_DATA, + PUSHER_SUBSCRIBE_DEFINITION_DATA, REDIS_SUBSCRIBE_DEFINITION_DATA, REQUIRE_FETCH_REASONS_DEFINITION_DATA, REQUIRES_DEFINITION_DATA, @@ -96,6 +97,7 @@ import { EDFS_NATS_REQUEST, EDFS_NATS_SUBSCRIBE, EDFS_REDIS_PUBLISH, + EDFS_PUSHER_SUBSCRIBE, EDFS_REDIS_SUBSCRIBE, EXTENDS, EXTERNAL, @@ -490,6 +492,7 @@ export function initializeDirectiveDefinitionDatas(): Map = /*@__PURE__*/ messageDesc(file_wg_cosmo_node_v1_node, 50); +/** + * @generated from message wg.cosmo.node.v1.PusherEventConfiguration + */ +export type PusherEventConfiguration = Message<"wg.cosmo.node.v1.PusherEventConfiguration"> & { + /** + * @generated from field: wg.cosmo.node.v1.EngineEventConfiguration engine_event_configuration = 1; + */ + engineEventConfiguration?: EngineEventConfiguration | undefined; + + /** + * @generated from field: repeated string channels = 2; + */ + channels: string[]; +}; + +/** + * Describes the message wg.cosmo.node.v1.PusherEventConfiguration. + * Use `create(PusherEventConfigurationSchema)` to create a new message. + */ +export const PusherEventConfigurationSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_wg_cosmo_node_v1_node, 51); + /** * @generated from message wg.cosmo.node.v1.EngineEventConfiguration */ @@ -1716,7 +1738,7 @@ export type EngineEventConfiguration = Message<"wg.cosmo.node.v1.EngineEventConf * Use `create(EngineEventConfigurationSchema)` to create a new message. */ export const EngineEventConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 51); + messageDesc(file_wg_cosmo_node_v1_node, 52); /** * @generated from message wg.cosmo.node.v1.DataSourceCustomEvents @@ -1736,6 +1758,11 @@ export type DataSourceCustomEvents = Message<"wg.cosmo.node.v1.DataSourceCustomE * @generated from field: repeated wg.cosmo.node.v1.RedisEventConfiguration redis = 3; */ redis: RedisEventConfiguration[]; + + /** + * @generated from field: repeated wg.cosmo.node.v1.PusherEventConfiguration pusher = 4; + */ + pusher: PusherEventConfiguration[]; }; /** @@ -1743,7 +1770,7 @@ export type DataSourceCustomEvents = Message<"wg.cosmo.node.v1.DataSourceCustomE * Use `create(DataSourceCustomEventsSchema)` to create a new message. */ export const DataSourceCustomEventsSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 52); + messageDesc(file_wg_cosmo_node_v1_node, 53); /** * @generated from message wg.cosmo.node.v1.DataSourceCustom_Static @@ -1760,7 +1787,7 @@ export type DataSourceCustom_Static = Message<"wg.cosmo.node.v1.DataSourceCustom * Use `create(DataSourceCustom_StaticSchema)` to create a new message. */ export const DataSourceCustom_StaticSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 53); + messageDesc(file_wg_cosmo_node_v1_node, 54); /** * @generated from message wg.cosmo.node.v1.ConfigurationVariable @@ -1797,7 +1824,7 @@ export type ConfigurationVariable = Message<"wg.cosmo.node.v1.ConfigurationVaria * Use `create(ConfigurationVariableSchema)` to create a new message. */ export const ConfigurationVariableSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 54); + messageDesc(file_wg_cosmo_node_v1_node, 55); /** * @generated from message wg.cosmo.node.v1.DirectiveConfiguration @@ -1819,7 +1846,7 @@ export type DirectiveConfiguration = Message<"wg.cosmo.node.v1.DirectiveConfigur * Use `create(DirectiveConfigurationSchema)` to create a new message. */ export const DirectiveConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 55); + messageDesc(file_wg_cosmo_node_v1_node, 56); /** * @generated from message wg.cosmo.node.v1.URLQueryConfiguration @@ -1841,7 +1868,7 @@ export type URLQueryConfiguration = Message<"wg.cosmo.node.v1.URLQueryConfigurat * Use `create(URLQueryConfigurationSchema)` to create a new message. */ export const URLQueryConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 56); + messageDesc(file_wg_cosmo_node_v1_node, 57); /** * @generated from message wg.cosmo.node.v1.HTTPHeader @@ -1858,7 +1885,7 @@ export type HTTPHeader = Message<"wg.cosmo.node.v1.HTTPHeader"> & { * Use `create(HTTPHeaderSchema)` to create a new message. */ export const HTTPHeaderSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 57); + messageDesc(file_wg_cosmo_node_v1_node, 58); /** * @generated from message wg.cosmo.node.v1.MTLSConfiguration @@ -1885,7 +1912,7 @@ export type MTLSConfiguration = Message<"wg.cosmo.node.v1.MTLSConfiguration"> & * Use `create(MTLSConfigurationSchema)` to create a new message. */ export const MTLSConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 58); + messageDesc(file_wg_cosmo_node_v1_node, 59); /** * @generated from message wg.cosmo.node.v1.GraphQLSubscriptionConfiguration @@ -1924,7 +1951,7 @@ export type GraphQLSubscriptionConfiguration = Message<"wg.cosmo.node.v1.GraphQL * Use `create(GraphQLSubscriptionConfigurationSchema)` to create a new message. */ export const GraphQLSubscriptionConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 59); + messageDesc(file_wg_cosmo_node_v1_node, 60); /** * @generated from message wg.cosmo.node.v1.GraphQLFederationConfiguration @@ -1946,7 +1973,7 @@ export type GraphQLFederationConfiguration = Message<"wg.cosmo.node.v1.GraphQLFe * Use `create(GraphQLFederationConfigurationSchema)` to create a new message. */ export const GraphQLFederationConfigurationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 60); + messageDesc(file_wg_cosmo_node_v1_node, 61); /** * @generated from message wg.cosmo.node.v1.InternedString @@ -1965,7 +1992,7 @@ export type InternedString = Message<"wg.cosmo.node.v1.InternedString"> & { * Use `create(InternedStringSchema)` to create a new message. */ export const InternedStringSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 61); + messageDesc(file_wg_cosmo_node_v1_node, 62); /** * @generated from message wg.cosmo.node.v1.SingleTypeField @@ -1987,7 +2014,7 @@ export type SingleTypeField = Message<"wg.cosmo.node.v1.SingleTypeField"> & { * Use `create(SingleTypeFieldSchema)` to create a new message. */ export const SingleTypeFieldSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 62); + messageDesc(file_wg_cosmo_node_v1_node, 63); /** * @generated from message wg.cosmo.node.v1.SubscriptionFieldCondition @@ -2009,7 +2036,7 @@ export type SubscriptionFieldCondition = Message<"wg.cosmo.node.v1.SubscriptionF * Use `create(SubscriptionFieldConditionSchema)` to create a new message. */ export const SubscriptionFieldConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 63); + messageDesc(file_wg_cosmo_node_v1_node, 64); /** * @generated from message wg.cosmo.node.v1.SubscriptionFilterCondition @@ -2041,7 +2068,7 @@ export type SubscriptionFilterCondition = Message<"wg.cosmo.node.v1.Subscription * Use `create(SubscriptionFilterConditionSchema)` to create a new message. */ export const SubscriptionFilterConditionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 64); + messageDesc(file_wg_cosmo_node_v1_node, 65); /** * @generated from message wg.cosmo.node.v1.CacheWarmerOperations @@ -2058,7 +2085,7 @@ export type CacheWarmerOperations = Message<"wg.cosmo.node.v1.CacheWarmerOperati * Use `create(CacheWarmerOperationsSchema)` to create a new message. */ export const CacheWarmerOperationsSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 65); + messageDesc(file_wg_cosmo_node_v1_node, 66); /** * @generated from message wg.cosmo.node.v1.Operation @@ -2080,7 +2107,7 @@ export type Operation = Message<"wg.cosmo.node.v1.Operation"> & { * Use `create(OperationSchema)` to create a new message. */ export const OperationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 66); + messageDesc(file_wg_cosmo_node_v1_node, 67); /** * @generated from message wg.cosmo.node.v1.OperationRequest @@ -2107,7 +2134,7 @@ export type OperationRequest = Message<"wg.cosmo.node.v1.OperationRequest"> & { * Use `create(OperationRequestSchema)` to create a new message. */ export const OperationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 67); + messageDesc(file_wg_cosmo_node_v1_node, 68); /** * @generated from message wg.cosmo.node.v1.Extension @@ -2124,7 +2151,7 @@ export type Extension = Message<"wg.cosmo.node.v1.Extension"> & { * Use `create(ExtensionSchema)` to create a new message. */ export const ExtensionSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 68); + messageDesc(file_wg_cosmo_node_v1_node, 69); /** * @generated from message wg.cosmo.node.v1.PersistedQuery @@ -2146,7 +2173,7 @@ export type PersistedQuery = Message<"wg.cosmo.node.v1.PersistedQuery"> & { * Use `create(PersistedQuerySchema)` to create a new message. */ export const PersistedQuerySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 69); + messageDesc(file_wg_cosmo_node_v1_node, 70); /** * @generated from message wg.cosmo.node.v1.ClientInfo @@ -2168,7 +2195,7 @@ export type ClientInfo = Message<"wg.cosmo.node.v1.ClientInfo"> & { * Use `create(ClientInfoSchema)` to create a new message. */ export const ClientInfoSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_wg_cosmo_node_v1_node, 70); + messageDesc(file_wg_cosmo_node_v1_node, 71); /** * @generated from enum wg.cosmo.node.v1.ArgumentRenderConfiguration diff --git a/proto/wg/cosmo/node/v1/node.proto b/proto/wg/cosmo/node/v1/node.proto index 4e75ed1440..3b230b3b4a 100644 --- a/proto/wg/cosmo/node/v1/node.proto +++ b/proto/wg/cosmo/node/v1/node.proto @@ -466,6 +466,11 @@ message RedisEventConfiguration { repeated string channels = 2; } +message PusherEventConfiguration { + EngineEventConfiguration engine_event_configuration = 1; + repeated string channels = 2; +} + message EngineEventConfiguration { string provider_id = 1; EventType type = 2; @@ -477,6 +482,7 @@ message DataSourceCustomEvents { repeated NatsEventConfiguration nats = 1; repeated KafkaEventConfiguration kafka = 2; repeated RedisEventConfiguration redis = 3; + repeated PusherEventConfiguration pusher = 4; } message DataSourceCustom_Static { diff --git a/router-tests/go.mod b/router-tests/go.mod index c09adb8253..0684be1445 100644 --- a/router-tests/go.mod +++ b/router-tests/go.mod @@ -23,6 +23,7 @@ require ( github.com/redis/go-redis/v9 v9.7.3 github.com/sebdah/goldie/v2 v2.7.1 github.com/stretchr/testify v1.11.1 + github.com/tidwall/gjson v1.18.0 github.com/twmb/franz-go v1.16.1 github.com/twmb/franz-go/pkg/kadm v1.11.0 github.com/wundergraph/astjson v1.1.0 @@ -154,7 +155,6 @@ require ( github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.3 // indirect - github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect diff --git a/router-tests/modules/custom-set-wildcard-scope/module.go b/router-tests/modules/custom-set-wildcard-scope/module.go new file mode 100644 index 0000000000..5156d7f72f --- /dev/null +++ b/router-tests/modules/custom-set-wildcard-scope/module.go @@ -0,0 +1,28 @@ +package custom_set_wildcard_scope + +import ( + "net/http" + + "github.com/wundergraph/cosmo/router/core" +) + +const myModuleID = "setWildcardScopeModule" + +type SetWildcardScopeModule struct{} + +func (m *SetWildcardScopeModule) Middleware(ctx core.RequestContext, next http.Handler) { + ctx.SetWildcardScope(true) + next.ServeHTTP(ctx.ResponseWriter(), ctx.Request()) +} + +func (m *SetWildcardScopeModule) Module() core.ModuleInfo { + return core.ModuleInfo{ + ID: myModuleID, + Priority: 2, + New: func() core.Module { + return &SetWildcardScopeModule{} + }, + } +} + +var _ core.RouterMiddlewareHandler = (*SetWildcardScopeModule)(nil) diff --git a/router-tests/modules/set_wildcard_scope_test.go b/router-tests/modules/set_wildcard_scope_test.go new file mode 100644 index 0000000000..74ebe28d5a --- /dev/null +++ b/router-tests/modules/set_wildcard_scope_test.go @@ -0,0 +1,153 @@ +package module_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + wildcardModule "github.com/wundergraph/cosmo/router-tests/modules/custom-set-wildcard-scope" + "github.com/wundergraph/cosmo/router-tests/testenv" + "github.com/wundergraph/cosmo/router/core" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestCustomModuleSetWildcardScope(t *testing.T) { + t.Run("authenticated request with wildcard scope bypasses requiresScopes checks", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{}, + }, + } + + authenticators, authServer := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + token, err := authServer.Token(nil) + require.NoError(t, err) + + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + + require.Empty(t, gjson.GetBytes(data, "errors").Array()) + require.True(t, gjson.GetBytes(data, "data").Exists()) + }) + }) + + t.Run("unauthenticated request with wildcard scope still fails", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{}, + }, + } + + authenticators, _ := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", nil, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + + errors := gjson.GetBytes(data, "errors").Array() + require.Len(t, errors, 10) + for _, e := range errors { + require.Equal(t, "UNAUTHORIZED_FIELD_OR_TYPE", e.Get("extensions.code").String()) + require.Contains(t, e.Get("message").String(), "not authenticated") + } + }) + }) + + t.Run("wildcard scope with RejectOperationIfUnauthorized grants access", func(t *testing.T) { + t.Parallel() + + cfg := config.Config{ + Graph: config.Graph{}, + Modules: map[string]any{ + "setWildcardScopeModule": wildcardModule.SetWildcardScopeModule{}, + }, + } + + authenticators, authServer := configureAuth(t) + accessController, err := core.NewAccessController(core.AccessControllerOptions{ + Authenticators: authenticators, + AuthenticationRequired: false, + SkipIntrospectionQueries: false, + IntrospectionSkipSecret: "", + }) + require.NoError(t, err) + + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithAccessController(accessController), + core.WithAuthorizationConfig(&config.AuthorizationConfiguration{ + RejectOperationIfUnauthorized: true, + }), + core.WithModulesConfig(cfg.Modules), + core.WithCustomModules(&wildcardModule.SetWildcardScopeModule{}), + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + token, err := authServer.Token(nil) + require.NoError(t, err) + + header := http.Header{ + "Authorization": []string{"Bearer " + token}, + } + res, err := xEnv.MakeRequest(http.MethodPost, "/graphql", header, strings.NewReader(employeesQueryRequiringClaims)) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + + data, err := io.ReadAll(res.Body) + require.NoError(t, err) + + require.Empty(t, gjson.GetBytes(data, "errors").Array()) + require.True(t, gjson.GetBytes(data, "data").Exists()) + }) + }) +} diff --git a/router-tests/operations/plan_fallback_cache_test.go b/router-tests/operations/plan_fallback_cache_test.go index 0ff63d461e..07415a7dc2 100644 --- a/router-tests/operations/plan_fallback_cache_test.go +++ b/router-tests/operations/plan_fallback_cache_test.go @@ -69,6 +69,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -98,6 +99,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -137,6 +139,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -261,6 +264,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 100 }, @@ -316,6 +320,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 10 }, @@ -344,6 +349,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = fallbackThreshold cfg.SlowPlanCacheSize = 50 }, @@ -431,6 +437,7 @@ func TestPlanFallbackCache(t *testing.T) { testenv.Run(t, &testenv.Config{ ModifyEngineExecutionConfiguration: func(cfg *config.EngineExecutionConfiguration) { cfg.ExecutionPlanCacheSize = 1 + cfg.DisableSizeAwarePlanCache = true // count-based eviction: this suite tests the fallback trigger, not size-aware eviction cfg.SlowPlanCacheThreshold = 1 * time.Hour cfg.SlowPlanCacheSize = 100 }, diff --git a/router/core/access_log_field_handler_test.go b/router/core/access_log_field_handler_test.go index 0a14357fe1..f7bd5689a4 100644 --- a/router/core/access_log_field_handler_test.go +++ b/router/core/access_log_field_handler_test.go @@ -9,6 +9,9 @@ import ( "github.com/wundergraph/cosmo/router/internal/expr" "github.com/wundergraph/cosmo/router/internal/requestlogger" "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" + "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" "go.uber.org/zap" ) @@ -193,4 +196,48 @@ func TestAccessLogsFieldHandler(t *testing.T) { } }) + t.Run("logs operation subgraph fetch count when monday tweak is enabled", func(t *testing.T) { + t.Parallel() + + require.True(t, mondaytweaks.ExposeOperationSubgraphFetchCountContextField.Load()) + + req, err := http.NewRequest(http.MethodPost, "http://localhost:3002/graphql", nil) + require.NoError(t, err) + + rcc := buildRequestContext(requestContextOptions{r: req}) + rcc.operation = &operationContext{ + preparedPlan: &planWithMetaData{ + preparedPlan: &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{ + Fetches: resolve.Sequence( + resolve.Single(&resolve.SingleFetch{Info: &resolve.FetchInfo{DataSourceName: "monolith"}}), + resolve.Single(&resolve.SingleFetch{Info: &resolve.FetchInfo{DataSourceName: "users"}}), + resolve.Single(&resolve.SingleFetch{Info: &resolve.FetchInfo{DataSourceName: "monolith"}}), + ), + }, + }, + }, + } + req = req.WithContext(withRequestContext(req.Context(), rcc)) + + response := RouterAccessLogsFieldHandler( + &zap.Logger{}, + []config.CustomAttribute{{ + Key: "operation_subgraph_fetch_count", + ValueFrom: &config.CustomDynamicAttribute{ + ContextField: ContextFieldOperationSubgraphFetchCount, + }, + }}, + make([]requestlogger.ExpressionAttribute, 0), + nil, + req, + nil, + nil, + ) + + require.Len(t, response, 2) + require.Equal(t, "operation_subgraph_fetch_count", response[1].Key) + require.Equal(t, int64(3), response[1].Integer) + }) + } diff --git a/router/core/authorizer.go b/router/core/authorizer.go index 0dc4adf148..45fbab191f 100644 --- a/router/core/authorizer.go +++ b/router/core/authorizer.go @@ -151,6 +151,9 @@ func (a *CosmoAuthorizer) validateScopes(ctx *resolve.Context, coordinate resolv if len(requiredOrScopes) == 0 { return nil } + if hasWildcardScope(ctx.Context()) { + return nil + } WithNext: for _, requiredOrScope := range requiredOrScopes { for i := range requiredOrScope.RequiredAndScopes { diff --git a/router/core/context.go b/router/core/context.go index 14000b26c5..fa8c7f585d 100644 --- a/router/core/context.go +++ b/router/core/context.go @@ -134,6 +134,12 @@ type RequestContext interface { // If Authentication is not set, it will be initialized with the scopes SetAuthenticationScopes(scopes []string) + // SetWildcardScope marks this request as having a wildcard scope that + // satisfies all @requiresScopes checks. The request must still be + // authenticated; unauthenticated requests are rejected before scope + // checks are evaluated. + SetWildcardScope(wildcard bool) + // SetCustomFieldValueRenderer overrides the default field value rendering behavior // This can be used, e.g. to obfuscate sensitive data in the response SetCustomFieldValueRenderer(renderer resolve.FieldValueRenderer) @@ -559,6 +565,21 @@ func (c *requestContext) SetAuthenticationScopes(scopes []string) { auth.SetScopes(scopes) } +type wildcardScopeKey struct{} + +func withWildcardScope(ctx context.Context, wildcard bool) context.Context { + return context.WithValue(ctx, wildcardScopeKey{}, wildcard) +} + +func hasWildcardScope(ctx context.Context) bool { + v, ok := ctx.Value(wildcardScopeKey{}).(bool) + return ok && v +} + +func (c *requestContext) SetWildcardScope(wildcard bool) { + c.request = c.request.WithContext(withWildcardScope(c.request.Context(), wildcard)) +} + func (c *requestContext) SetForceSha256Compute() { c.forceSha256Compute = true } diff --git a/router/core/factoryresolver.go b/router/core/factoryresolver.go index bde74efe70..68dd432beb 100644 --- a/router/core/factoryresolver.go +++ b/router/core/factoryresolver.go @@ -17,6 +17,7 @@ import ( 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" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" 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" @@ -309,6 +310,7 @@ func mapProtoFilterToPlanFilter(input *nodev1.SubscriptionFilterCondition, outpu // along with any pub/sub providers that need lifecycle management. func (l *Loader) Load(engineConfig *nodev1.EngineConfiguration, subgraphs []*nodev1.Subgraph, routerEngineConfig *RouterEngineConfiguration, pluginsEnabled bool) (*plan.Configuration, []pubsub_datasource.Provider, error) { var outConfig plan.Configuration + outConfig.DisableIncludeFieldDependencies = mondaytweaks.DisableFieldDependencies.Load() // attach field usage information to the plan outConfig.DefaultFlushIntervalMillis = engineConfig.DefaultFlushInterval // EnableMultiFetch makes the planner record the subgraph operation artifacts diff --git a/router/core/graph_server.go b/router/core/graph_server.go index 6746fab2a9..f29a5f81e3 100644 --- a/router/core/graph_server.go +++ b/router/core/graph_server.go @@ -26,6 +26,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/klauspost/compress/gzhttp" "github.com/klauspost/compress/gzip" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "github.com/wundergraph/cosmo/router/pkg/routerconfig" "go.opentelemetry.io/otel/attribute" otelmetric "go.opentelemetry.io/otel/metric" @@ -725,9 +726,17 @@ func (s *graphMux) buildOperationCaches(srv *graphServer) (computeSha256 bool, e // different inputs that would generate the same execution plan if srv.engineExecutionConfiguration.ExecutionPlanCacheSize > 0 { + // planCacheMaxCost is the ExecutionPlanCacheSize entry count by default. When + // SizeAwarePlanCache is enabled the cache instead evicts by estimated retained heap + // (see estimatePlanCacheCost / planCacheCost), so MaxCost becomes a byte budget while + // NumCounters stays keyed to the expected entry count for TinyLFU admission. + planCacheMaxCost := srv.engineExecutionConfiguration.ExecutionPlanCacheSize + if sizeAwarePlanCacheEnabled(srv.engineExecutionConfiguration) { + planCacheMaxCost = srv.engineExecutionConfiguration.ExecutionPlanCacheSize * mondaytweaks.PlanCacheSizeAwareBudgetPerSlotBytes.Load() + } planCacheConfig := &ristretto.Config[uint64, *planWithMetaData]{ Metrics: srv.metricConfig.OpenTelemetry.GraphqlCache || srv.metricConfig.Prometheus.GraphqlCache, - MaxCost: srv.engineExecutionConfiguration.ExecutionPlanCacheSize, + MaxCost: planCacheMaxCost, NumCounters: srv.engineExecutionConfiguration.ExecutionPlanCacheSize * 10, IgnoreInternalCost: true, BufferItems: 64, @@ -997,15 +1006,33 @@ func (s *graphMux) Shutdown(ctx context.Context) error { // cancel the graph muxes context to close its resources like websocket connections, resolvers, etc. s.cancel() - s.planCache.Close() - s.planFallbackCache.Close() - s.persistedOperationCache.Close() - s.normalizationCache.Close() - s.variablesNormalizationCache.Close() - s.remapVariablesCache.Close() - s.complexityCalculationCache.Close() - s.validationCache.Close() - s.operationHashCache.Close() + if s.planCache != nil { + s.planCache.Close() + } + if s.planFallbackCache != nil { + s.planFallbackCache.Close() + } + if s.persistedOperationCache != nil { + s.persistedOperationCache.Close() + } + if s.normalizationCache != nil { + s.normalizationCache.Close() + } + if s.variablesNormalizationCache != nil { + s.variablesNormalizationCache.Close() + } + if s.remapVariablesCache != nil { + s.remapVariablesCache.Close() + } + if s.complexityCalculationCache != nil { + s.complexityCalculationCache.Close() + } + if s.validationCache != nil { + s.validationCache.Close() + } + if s.operationHashCache != nil { + s.operationHashCache.Close() + } var err error @@ -1500,22 +1527,30 @@ func (s *graphServer) buildGraphMux( return nil, fmt.Errorf("failed to process retry options: %w", err) } + subscriptionClientOptions := &SubscriptionClientOptions{ + PingInterval: s.engineExecutionConfiguration.WebSocketClientPingInterval, + PingTimeout: s.engineExecutionConfiguration.WebSocketClientPingTimeout, + WriteTimeout: s.engineExecutionConfiguration.WebSocketClientWriteTimeout, + AckTimeout: s.engineExecutionConfiguration.WebSocketClientAckTimeout, + ReadLimit: int64(s.engineExecutionConfiguration.WebSocketClientReadLimit), + DefaultErrorExtensionCode: s.subgraphErrorPropagation.DefaultExtensionCode, + } + // Client-facing WebSocket subscriptions are disabled; skip upstream ping loops + // that would otherwise start one goroutine per subgraph datasource factory. + if mondaytweaks.DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled.Load() && + s.webSocketConfiguration != nil && !s.webSocketConfiguration.Enabled { + subscriptionClientOptions.PingInterval = 0 + } + ecb := &ExecutorConfigurationBuilder{ - introspection: s.introspection, - baseURL: s.baseURL, - baseTripper: s.baseTransport, - subgraphTrippers: subgraphTippers, - pluginHost: s.connector, - logger: s.logger, - trackUsageInfo: s.graphqlMetricsConfig.Enabled || s.metricConfig.Prometheus.PromSchemaFieldUsage.Enabled, - subscriptionClientOptions: &SubscriptionClientOptions{ - PingInterval: s.engineExecutionConfiguration.WebSocketClientPingInterval, - PingTimeout: s.engineExecutionConfiguration.WebSocketClientPingTimeout, - WriteTimeout: s.engineExecutionConfiguration.WebSocketClientWriteTimeout, - AckTimeout: s.engineExecutionConfiguration.WebSocketClientAckTimeout, - ReadLimit: int64(s.engineExecutionConfiguration.WebSocketClientReadLimit), - DefaultErrorExtensionCode: s.subgraphErrorPropagation.DefaultExtensionCode, - }, + introspection: s.introspection, + baseURL: s.baseURL, + baseTripper: s.baseTransport, + subgraphTrippers: subgraphTippers, + pluginHost: s.connector, + logger: s.logger, + trackUsageInfo: s.graphqlMetricsConfig.Enabled || s.metricConfig.Prometheus.PromSchemaFieldUsage.Enabled, + subscriptionClientOptions: subscriptionClientOptions, transportOptions: &TransportOptions{ SubgraphTransportOptions: s.subgraphTransportOptions, PreHandlers: s.preOriginHandlers, @@ -1607,7 +1642,7 @@ func (s *graphServer) buildGraphMux( } } - operationPlanner := NewOperationPlanner(executor, gm.planCache, gm.planFallbackCache, s.planningDurationOverride) + operationPlanner := NewOperationPlanner(executor, gm.planCache, gm.planFallbackCache, s.planningDurationOverride, sizeAwarePlanCacheEnabled(s.engineExecutionConfiguration)) // We support the MCP only on the base graph. Feature flags are not supported yet. if opts.IsBaseGraph() && s.mcpServer != nil { diff --git a/router/core/operation_planner.go b/router/core/operation_planner.go index ecc595eeb9..ac3d661053 100644 --- a/router/core/operation_planner.go +++ b/router/core/operation_planner.go @@ -8,27 +8,152 @@ import ( "golang.org/x/sync/singleflight" + graphqlmetricsv1 "github.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/graphqlmetrics/v1" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/graphqlschemausage" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" + "github.com/wundergraph/cosmo/router/pkg/slowplancache" "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" - "github.com/wundergraph/cosmo/router/pkg/slowplancache" ) type planWithMetaData struct { - preparedPlan plan.Plan - operationDocument, schemaDocument *ast.Document - typeFieldUsageInfo []*graphqlschemausage.TypeFieldUsageInfo + preparedPlan plan.Plan + operationDocument *ast.Document + typeFieldUsageInfo []*graphqlschemausage.TypeFieldUsageInfo argumentUsageInfo []*graphqlmetricsv1.ArgumentUsageInfo content string operationName string planningDuration time.Duration } +// planCacheCostNodeBytes and planCacheCostUsageBytes approximate the average retained heap +// of a single AST structural element and a single usage-info entry. Ristretto cost is +// relative to MaxCost, so the constants only need to preserve ordering across cache entries; +// they are deliberately coarse and cheap to compute. +const ( + planCacheCostNodeBytes = 48 + planCacheCostUsageBytes = 64 +) + +// planCacheCostFetchBytes and planCacheCostFieldBytes approximate the retained heap of a single +// prepared-plan fetch (SingleFetch/EntityFetch/BatchEntityFetch, each carrying FetchInfo, +// FetchConfiguration and an InputTemplate — empirically ~40 KiB) and a single response field +// node (Field + FieldInfo with its []string slices — empirically ~500-800 bytes). Benchmark +// data: 200 unique plans retaining 103 MiB of plan-cache heap (~515 KiB/plan) with ~8 fetches +// and ~100-200 response fields per plan. The AST-only estimate above undercounts this by ~65x +// because the plan tree — not the operation document — holds the bulk of the memory. +const ( + planCacheCostFetchBytes = 32 * 1024 + planCacheCostFieldBytes = 768 +) + +// estimatePlanCacheCost approximates the retained heap of a cached plan entry so the +// size-aware Ristretto config (mondaytweaks.SizeAwarePlanCache) evicts by memory footprint +// instead of by entry count. It keys off operationDocument, which is always populated (the +// content string is only set when the slow-plan cache is enabled), summing the raw operation +// bytes and the lengths of the operation-side AST slices — both of which scale with operation +// complexity and therefore with the size of the prepared plan tree the entry retains. The +// estimate is intentionally an O(number-of-slices) field read, not a deep walk. +func estimatePlanCacheCost(p *planWithMetaData) int64 { + if p == nil { + return 1 + } + cost := int64(len(p.content) + len(p.operationName)) + if d := p.operationDocument; d != nil { + cost += int64(len(d.Input.RawBytes) + len(d.Input.Variables)) + nodes := len(d.RootNodes) + len(d.Arguments) + len(d.Values) + + len(d.Selections) + len(d.SelectionSets) + len(d.Fields) + + len(d.ObjectFields) + len(d.ObjectValues) + len(d.ListValues) + + len(d.VariableValues) + len(d.StringValues) + len(d.IntValues) + + len(d.FloatValues) + len(d.EnumValues) + len(d.InlineFragments) + + len(d.FragmentSpreads) + len(d.VariableDefinitions) + len(d.Directives) + cost += int64(nodes) * planCacheCostNodeBytes + } + cost += int64(len(p.typeFieldUsageInfo)+len(p.argumentUsageInfo)) * planCacheCostUsageBytes + + // The prepared plan tree retains the bulk of the entry's heap: one fetch struct per + // subgraph fetch and one Field/FieldInfo per response field. Walk it once per cache miss + // (O(fetches + fields)) so the estimate tracks actual footprint, not just operation size. + if mondaytweaks.PlanCacheCostCountsPlanTree.Load() { + if syncPlan, ok := p.preparedPlan.(*plan.SynchronousResponsePlan); ok && syncPlan.Response != nil { + fetches := countFetchTreeNodes(syncPlan.Response.Fetches) + fields := countResponseFields(syncPlan.Response.Data) + cost += int64(fetches)*planCacheCostFetchBytes + int64(fields)*planCacheCostFieldBytes + } + } + + if cost < 1 { + return 1 + } + return cost +} + +// countFetchTreeNodes returns the number of fetch nodes (Item != nil) in the fetch tree, +// including a subscription Trigger. Each corresponds to a subgraph fetch whose FetchInfo, +// FetchConfiguration and InputTemplate dominate the prepared plan's retained heap. +func countFetchTreeNodes(n *resolve.FetchTreeNode) int { + if n == nil { + return 0 + } + count := 0 + if n.Item != nil { + count++ + } + count += countFetchTreeNodes(n.Trigger) + for _, child := range n.ChildNodes { + count += countFetchTreeNodes(child) + } + return count +} + +// countResponseFields returns the number of Field nodes in the response Data tree, recursing +// through Object and Array nodes. Each Field carries a *FieldInfo whose []string slices make it +// the second-largest contributor to a cached plan's heap after the fetches. +func countResponseFields(node resolve.Node) int { + switch v := node.(type) { + case *resolve.Object: + if v == nil { + return 0 + } + count := len(v.Fields) + for _, f := range v.Fields { + count += countResponseFields(f.Value) + } + return count + case *resolve.Array: + if v == nil { + return 0 + } + return countResponseFields(v.Item) + default: + return 0 + } +} + +// sizeAwarePlanCacheEnabled reports whether the execution-plan cache should evict by estimated +// retained heap (mondaytweaks.SizeAwarePlanCache) for this engine configuration. The per-config +// DisableSizeAwarePlanCache override forces count-based eviction (tests, or a targeted +// per-router rollback) without mutating the global flag, which matters under -race. +func sizeAwarePlanCacheEnabled(cfg config.EngineExecutionConfiguration) bool { + return mondaytweaks.SizeAwarePlanCache.Load() && !cfg.DisableSizeAwarePlanCache +} + +// planCacheCost returns the Ristretto cost for a plan-cache entry: the size-aware estimate +// when size-aware eviction is enabled for this planner, or the historical unit cost of 1. The +// MaxCost configured in buildOperationCaches must use the same decision so cost and budget +// agree. +func (op *OperationPlanner) planCacheCost(p *planWithMetaData) int64 { + if op.sizeAwarePlanCache { + return estimatePlanCacheCost(p) + } + return 1 +} + type OperationPlanner struct { sf singleflight.Group planCache ExecutionPlanCache[uint64, *planWithMetaData] @@ -39,6 +164,12 @@ type OperationPlanner struct { // planningDurationOverride, when set, replaces the measured planning duration. // This is used in tests to simulate slow queries. planningDurationOverride func(content string) time.Duration + + // sizeAwarePlanCache mirrors the plan cache's eviction mode: when true, plan-cache Set + // costs are the estimated retained heap (matching the byte budget MaxCost); when false, + // the historical unit cost of 1 (count-based). Kept per-planner so it agrees with the + // cache built for the same engine configuration. + sizeAwarePlanCache bool } type operationPlannerOpts struct { @@ -61,6 +192,7 @@ func NewOperationPlanner( planCache ExecutionPlanCache[uint64, *planWithMetaData], fallbackCache *slowplancache.Cache[*planWithMetaData], planningDurationOverride func(content string) time.Duration, + sizeAwarePlanCache bool, ) *OperationPlanner { return &OperationPlanner{ planCache: planCache, @@ -68,6 +200,7 @@ func NewOperationPlanner( trackUsageInfo: executor.TrackUsageInfo, slowPlanCache: fallbackCache, planningDurationOverride: planningDurationOverride, + sizeAwarePlanCache: sizeAwarePlanCache, } } @@ -108,7 +241,6 @@ func (p *OperationPlanner) planOperation(content string, name string, includeQue return &planWithMetaData{ preparedPlan: preparedPlan, operationDocument: &doc, - schemaDocument: p.executor.RouterSchema, }, nil } @@ -181,7 +313,7 @@ func (p *OperationPlanner) plan(opContext *operationContext, options PlanOptions // found in the plan fallback cache — re-use and re-insert into main cache opContext.preparedPlan = cachedPlan opContext.planCacheHit = true - p.planCache.Set(operationID, cachedPlan, 1) + p.planCache.Set(operationID, cachedPlan, p.planCacheCost(cachedPlan)) } } @@ -204,7 +336,7 @@ func (p *OperationPlanner) plan(opContext *operationContext, options PlanOptions // Set into the main cache after planningDuration is finalized, // because the OnEvict callback reads planningDuration concurrently. - p.planCache.Set(operationID, prepared, 1) + p.planCache.Set(operationID, prepared, p.planCacheCost(prepared)) p.slowPlanCache.Set(operationID, prepared, prepared.planningDuration) return prepared, nil diff --git a/router/core/operation_planner_sizeaware_test.go b/router/core/operation_planner_sizeaware_test.go new file mode 100644 index 0000000000..d490381f7b --- /dev/null +++ b/router/core/operation_planner_sizeaware_test.go @@ -0,0 +1,148 @@ +package core + +import ( + "testing" + + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +// TestEstimatePlanCacheCost verifies the size-aware cost estimate is nil-safe, always +// positive, and monotonically larger for a structurally larger operation — the property the +// size-aware Ristretto config relies on to evict giant aliased-batch plans before hot small +// plans. +func TestEstimatePlanCacheCost(t *testing.T) { + if got := estimatePlanCacheCost(nil); got != 1 { + t.Fatalf("nil plan: want cost 1, got %d", got) + } + + small := &planWithMetaData{operationDocument: &ast.Document{}, content: "query{a}"} + small.operationDocument.Input.RawBytes = []byte("query{a}") + small.operationDocument.Fields = make([]ast.Field, 1) + small.operationDocument.Selections = make([]ast.Selection, 1) + + // Mimics the aliased-batch mutation shape: a large raw body and thousands of AST nodes. + large := &planWithMetaData{operationDocument: &ast.Document{}, content: "large"} + large.operationDocument.Input.RawBytes = make([]byte, 100_000) + large.operationDocument.Fields = make([]ast.Field, 2_000) + large.operationDocument.Arguments = make([]ast.Argument, 4_000) + large.operationDocument.Selections = make([]ast.Selection, 2_000) + large.operationDocument.Values = make([]ast.Value, 4_000) + + cs := estimatePlanCacheCost(small) + cl := estimatePlanCacheCost(large) + if cs < 1 { + t.Fatalf("small plan: want cost >= 1, got %d", cs) + } + if cl <= cs { + t.Fatalf("expected large plan to cost more than small: small=%d large=%d", cs, cl) + } +} + +// TestEstimatePlanCacheCostCountsPlanTree verifies the prepared-plan tree walk dominates the +// estimate: a plan retaining several subgraph fetches and a nested response tree must cost far +// more than the AST-only accounting for the same operation document, so Ristretto evicts by the +// heap the plan tree actually retains rather than by operation size alone. +func TestEstimatePlanCacheCostCountsPlanTree(t *testing.T) { + doc := &ast.Document{} + doc.Input.RawBytes = []byte("query{a{b{c}}}") + doc.Fields = make([]ast.Field, 3) + doc.Selections = make([]ast.Selection, 3) + + astOnly := &planWithMetaData{operationDocument: doc} + baseline := estimatePlanCacheCost(astOnly) + + // Two subgraph fetches under a Sequence node (Item != nil ⇒ counted). + fetches := &resolve.FetchTreeNode{ + Kind: resolve.FetchTreeNodeKindSequence, + ChildNodes: []*resolve.FetchTreeNode{ + {Kind: resolve.FetchTreeNodeKindSingle, Item: &resolve.FetchItem{}}, + {Kind: resolve.FetchTreeNodeKindSingle, Item: &resolve.FetchItem{}}, + }, + } + + // Nested response shape: root object → array of objects → leaf field (3 Field nodes). + data := &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("a"), + Value: &resolve.Array{ + Item: &resolve.Object{ + Fields: []*resolve.Field{ + { + Name: []byte("b"), + Value: &resolve.Object{ + Fields: []*resolve.Field{ + {Name: []byte("c"), Value: &resolve.String{}}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + withPlan := &planWithMetaData{ + operationDocument: doc, + preparedPlan: &plan.SynchronousResponsePlan{ + Response: &resolve.GraphQLResponse{Fetches: fetches, Data: data}, + }, + } + + prev := mondaytweaks.PlanCacheCostCountsPlanTree.Load() + defer mondaytweaks.PlanCacheCostCountsPlanTree.Store(prev) + + mondaytweaks.PlanCacheCostCountsPlanTree.Store(true) + withTree := estimatePlanCacheCost(withPlan) + + if withTree <= baseline { + t.Fatalf("plan tree walk must increase cost: astOnly=%d withPlan=%d", baseline, withTree) + } + // 2 fetches + 3 response fields must be accounted for on top of the AST baseline. + wantMin := baseline + 2*planCacheCostFetchBytes + 3*planCacheCostFieldBytes + if withTree < wantMin { + t.Fatalf("expected cost >= %d (baseline + 2 fetches + 3 fields), got %d", wantMin, withTree) + } + + // With the flag disabled the tree walk is skipped, so the plan-bearing entry costs the + // same as the AST-only accounting for the same operation document. + mondaytweaks.PlanCacheCostCountsPlanTree.Store(false) + if got := estimatePlanCacheCost(withPlan); got != baseline { + t.Fatalf("flag disabled: want AST-only baseline %d, got %d", baseline, got) + } +} + +// TestPlanCacheCostRespectsMode confirms a planner uses the historical unit cost when size- +// aware eviction is disabled, and the size-aware estimate when it is enabled. +func TestPlanCacheCostRespectsMode(t *testing.T) { + p := &planWithMetaData{operationDocument: &ast.Document{}} + p.operationDocument.Fields = make([]ast.Field, 100) + + countBased := &OperationPlanner{sizeAwarePlanCache: false} + if got := countBased.planCacheCost(p); got != 1 { + t.Fatalf("count-based: want cost 1, got %d", got) + } + + sizeAware := &OperationPlanner{sizeAwarePlanCache: true} + if got := sizeAware.planCacheCost(p); got <= 1 { + t.Fatalf("size-aware: want cost > 1, got %d", got) + } +} + +// TestSizeAwarePlanCacheEnabled confirms the per-config DisableSizeAwarePlanCache override +// forces count-based eviction regardless of the mondaytweaks default, and that an unset +// config follows the mondaytweaks default. It reads the global flag but never mutates it, so +// it is safe under -race alongside parallel tests. +func TestSizeAwarePlanCacheEnabled(t *testing.T) { + if sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{DisableSizeAwarePlanCache: true}) { + t.Fatal("DisableSizeAwarePlanCache must force count-based eviction") + } + if got := sizeAwarePlanCacheEnabled(config.EngineExecutionConfiguration{}); got != mondaytweaks.SizeAwarePlanCache.Load() { + t.Fatalf("unset config should follow mondaytweaks.SizeAwarePlanCache=%v, got %v", mondaytweaks.SizeAwarePlanCache.Load(), got) + } +} diff --git a/router/core/plan_generator.go b/router/core/plan_generator.go index 2cf10f1e76..2efea664e9 100644 --- a/router/core/plan_generator.go +++ b/router/core/plan_generator.go @@ -17,6 +17,7 @@ import ( "github.com/wundergraph/cosmo/router/pkg/metric" "github.com/wundergraph/cosmo/router/pkg/pubsub/kafka" "github.com/wundergraph/cosmo/router/pkg/pubsub/nats" + "github.com/wundergraph/cosmo/router/pkg/pubsub/pusher" "github.com/wundergraph/cosmo/router/pkg/pubsub/redis" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" @@ -397,6 +398,7 @@ func (pg *PlanGenerator) loadConfiguration(routerConfig *nodev1.RouterConfig, lo natSources := map[string]*nats.ProviderAdapter{} kafkaSources := map[string]*kafka.ProviderAdapter{} redisSources := map[string]*redis.ProviderAdapter{} + pusherSources := map[string]*pusher.ProviderAdapter{} for _, ds := range routerConfig.GetEngineConfig().GetDatasourceConfigurations() { if ds.GetKind() != nodev1.DataSourceKind_PUBSUB || ds.GetCustomEvents() == nil { continue @@ -428,6 +430,15 @@ func (pg *PlanGenerator) loadConfiguration(routerConfig *nodev1.RouterConfig, lo }) } } + for _, pusherConfig := range ds.GetCustomEvents().GetPusher() { + providerId := pusherConfig.GetEngineEventConfiguration().GetProviderId() + if _, ok := pusherSources[providerId]; !ok { + pusherSources[providerId] = nil + routerEngineConfig.Events.Providers.Pusher = append(routerEngineConfig.Events.Providers.Pusher, config.PusherEventSource{ + ID: providerId, + }) + } + } } ctx, cancel := context.WithCancel(context.Background()) diff --git a/router/core/request_context_fields.go b/router/core/request_context_fields.go index 5694726857..22b4f6b5a6 100644 --- a/router/core/request_context_fields.go +++ b/router/core/request_context_fields.go @@ -14,27 +14,29 @@ import ( "github.com/wundergraph/cosmo/router/internal/requestlogger" "github.com/wundergraph/cosmo/router/pkg/config" "github.com/wundergraph/cosmo/router/pkg/logging" + "github.com/wundergraph/cosmo/router/pkg/mondaytweaks" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) // Context field names used to expose information about the operation being executed. const ( - ContextFieldOperationName = "operation_name" - ContextFieldOperationHash = "operation_hash" - ContextFieldOperationType = "operation_type" - ContextFieldOperationServices = "operation_service_names" - ContextFieldGraphQLErrorCodes = "graphql_error_codes" - ContextFieldGraphQLErrorServices = "graphql_error_service_names" - ContextFieldOperationParsingTime = "operation_parsing_time" - ContextFieldOperationValidationTime = "operation_validation_time" - ContextFieldOperationPlanningTime = "operation_planning_time" - ContextFieldOperationNormalizationTime = "operation_normalization_time" - ContextFieldPersistedOperationSha256 = "persisted_operation_sha256" - ContextFieldOperationSha256 = "operation_sha256" - ContextFieldResponseErrorMessage = "response_error_message" - ContextFieldRequestError = "request_error" - ContextFieldRouterConfigVersion = "router_config_version" + ContextFieldOperationName = "operation_name" + ContextFieldOperationHash = "operation_hash" + ContextFieldOperationType = "operation_type" + ContextFieldOperationServices = "operation_service_names" + ContextFieldGraphQLErrorCodes = "graphql_error_codes" + ContextFieldGraphQLErrorServices = "graphql_error_service_names" + ContextFieldOperationParsingTime = "operation_parsing_time" + ContextFieldOperationValidationTime = "operation_validation_time" + ContextFieldOperationPlanningTime = "operation_planning_time" + ContextFieldOperationSubgraphFetchCount = "operation_subgraph_fetch_count" + ContextFieldOperationNormalizationTime = "operation_normalization_time" + ContextFieldPersistedOperationSha256 = "persisted_operation_sha256" + ContextFieldOperationSha256 = "operation_sha256" + ContextFieldResponseErrorMessage = "response_error_message" + ContextFieldRequestError = "request_error" + ContextFieldRouterConfigVersion = "router_config_version" ) // Helper functions to create zap fields for custom attributes. @@ -73,6 +75,13 @@ func NewBoolLogField(val bool, attribute config.CustomAttribute) zap.Field { return zap.Skip() } +func NewIntLogField(val int, attribute config.CustomAttribute) zap.Field { + if val != 0 { + return zap.Int(attribute.Key, val) + } + return zap.Skip() +} + func NewStringSliceLogField(val []string, attribute config.CustomAttribute) zap.Field { if v := val; len(v) > 0 { return zap.Strings(attribute.Key, v) @@ -205,6 +214,8 @@ func GetLogFieldFromCustomAttribute(field config.CustomAttribute, req *requestCo return NewStringLogField(v, field) case bool: return NewBoolLogField(v, field) + case int: + return NewIntLogField(v, field) case []string: return NewStringSliceLogField(v, field) case time.Duration: @@ -251,6 +262,15 @@ func getCustomDynamicAttributeValue( return "" } return reqContext.operation.planningTime + case ContextFieldOperationSubgraphFetchCount: + if !mondaytweaks.ExposeOperationSubgraphFetchCountContextField.Load() || reqContext.operation == nil { + return "" + } + stats, statsErr := reqContext.operation.QueryPlanStats() + if statsErr != nil { + return "" + } + return stats.TotalSubgraphFetches case ContextFieldOperationNormalizationTime: if reqContext.operation == nil { return "" diff --git a/router/core/router.go b/router/core/router.go index 25aca4e77a..c9307e4267 100644 --- a/router/core/router.go +++ b/router/core/router.go @@ -1900,6 +1900,8 @@ func (r *Router) buildManifestConfigWatcher(ctx context.Context, ll *zap.Logger) ll.Error("Failed to update server with new config", zap.Error(err)) return } + r.staticExecutionConfig = cfg + r.trackExecutionConfigUsage(cfg, true) }, }) diff --git a/router/gen/proto/wg/cosmo/node/v1/node.pb.go b/router/gen/proto/wg/cosmo/node/v1/node.pb.go index a35547141d..0080c4d7e8 100644 --- a/router/gen/proto/wg/cosmo/node/v1/node.pb.go +++ b/router/gen/proto/wg/cosmo/node/v1/node.pb.go @@ -3805,6 +3805,58 @@ func (x *RedisEventConfiguration) GetChannels() []string { return nil } +type PusherEventConfiguration struct { + state protoimpl.MessageState `protogen:"open.v1"` + EngineEventConfiguration *EngineEventConfiguration `protobuf:"bytes,1,opt,name=engine_event_configuration,json=engineEventConfiguration,proto3" json:"engine_event_configuration,omitempty"` + Channels []string `protobuf:"bytes,2,rep,name=channels,proto3" json:"channels,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PusherEventConfiguration) Reset() { + *x = PusherEventConfiguration{} + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PusherEventConfiguration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PusherEventConfiguration) ProtoMessage() {} + +func (x *PusherEventConfiguration) ProtoReflect() protoreflect.Message { + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PusherEventConfiguration.ProtoReflect.Descriptor instead. +func (*PusherEventConfiguration) Descriptor() ([]byte, []int) { + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{51} +} + +func (x *PusherEventConfiguration) GetEngineEventConfiguration() *EngineEventConfiguration { + if x != nil { + return x.EngineEventConfiguration + } + return nil +} + +func (x *PusherEventConfiguration) GetChannels() []string { + if x != nil { + return x.Channels + } + return nil +} + type EngineEventConfiguration struct { state protoimpl.MessageState `protogen:"open.v1"` ProviderId string `protobuf:"bytes,1,opt,name=provider_id,json=providerId,proto3" json:"provider_id,omitempty"` @@ -3817,7 +3869,7 @@ type EngineEventConfiguration struct { func (x *EngineEventConfiguration) Reset() { *x = EngineEventConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3829,7 +3881,7 @@ func (x *EngineEventConfiguration) String() string { func (*EngineEventConfiguration) ProtoMessage() {} func (x *EngineEventConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[51] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3842,7 +3894,7 @@ func (x *EngineEventConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use EngineEventConfiguration.ProtoReflect.Descriptor instead. func (*EngineEventConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{51} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{52} } func (x *EngineEventConfiguration) GetProviderId() string { @@ -3874,17 +3926,18 @@ func (x *EngineEventConfiguration) GetFieldName() string { } type DataSourceCustomEvents struct { - state protoimpl.MessageState `protogen:"open.v1"` - Nats []*NatsEventConfiguration `protobuf:"bytes,1,rep,name=nats,proto3" json:"nats,omitempty"` - Kafka []*KafkaEventConfiguration `protobuf:"bytes,2,rep,name=kafka,proto3" json:"kafka,omitempty"` - Redis []*RedisEventConfiguration `protobuf:"bytes,3,rep,name=redis,proto3" json:"redis,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + Nats []*NatsEventConfiguration `protobuf:"bytes,1,rep,name=nats,proto3" json:"nats,omitempty"` + Kafka []*KafkaEventConfiguration `protobuf:"bytes,2,rep,name=kafka,proto3" json:"kafka,omitempty"` + Redis []*RedisEventConfiguration `protobuf:"bytes,3,rep,name=redis,proto3" json:"redis,omitempty"` + Pusher []*PusherEventConfiguration `protobuf:"bytes,4,rep,name=pusher,proto3" json:"pusher,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *DataSourceCustomEvents) Reset() { *x = DataSourceCustomEvents{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3896,7 +3949,7 @@ func (x *DataSourceCustomEvents) String() string { func (*DataSourceCustomEvents) ProtoMessage() {} func (x *DataSourceCustomEvents) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[52] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3909,7 +3962,7 @@ func (x *DataSourceCustomEvents) ProtoReflect() protoreflect.Message { // Deprecated: Use DataSourceCustomEvents.ProtoReflect.Descriptor instead. func (*DataSourceCustomEvents) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{52} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{53} } func (x *DataSourceCustomEvents) GetNats() []*NatsEventConfiguration { @@ -3933,6 +3986,13 @@ func (x *DataSourceCustomEvents) GetRedis() []*RedisEventConfiguration { return nil } +func (x *DataSourceCustomEvents) GetPusher() []*PusherEventConfiguration { + if x != nil { + return x.Pusher + } + return nil +} + type DataSourceCustom_Static struct { state protoimpl.MessageState `protogen:"open.v1"` Data *ConfigurationVariable `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` @@ -3942,7 +4002,7 @@ type DataSourceCustom_Static struct { func (x *DataSourceCustom_Static) Reset() { *x = DataSourceCustom_Static{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3954,7 +4014,7 @@ func (x *DataSourceCustom_Static) String() string { func (*DataSourceCustom_Static) ProtoMessage() {} func (x *DataSourceCustom_Static) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[53] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3967,7 +4027,7 @@ func (x *DataSourceCustom_Static) ProtoReflect() protoreflect.Message { // Deprecated: Use DataSourceCustom_Static.ProtoReflect.Descriptor instead. func (*DataSourceCustom_Static) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{53} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{54} } func (x *DataSourceCustom_Static) GetData() *ConfigurationVariable { @@ -3990,7 +4050,7 @@ type ConfigurationVariable struct { func (x *ConfigurationVariable) Reset() { *x = ConfigurationVariable{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4002,7 +4062,7 @@ func (x *ConfigurationVariable) String() string { func (*ConfigurationVariable) ProtoMessage() {} func (x *ConfigurationVariable) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[54] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4015,7 +4075,7 @@ func (x *ConfigurationVariable) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigurationVariable.ProtoReflect.Descriptor instead. func (*ConfigurationVariable) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{54} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{55} } func (x *ConfigurationVariable) GetKind() ConfigurationVariableKind { @@ -4063,7 +4123,7 @@ type DirectiveConfiguration struct { func (x *DirectiveConfiguration) Reset() { *x = DirectiveConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4075,7 +4135,7 @@ func (x *DirectiveConfiguration) String() string { func (*DirectiveConfiguration) ProtoMessage() {} func (x *DirectiveConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[55] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4088,7 +4148,7 @@ func (x *DirectiveConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use DirectiveConfiguration.ProtoReflect.Descriptor instead. func (*DirectiveConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{55} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{56} } func (x *DirectiveConfiguration) GetDirectiveName() string { @@ -4115,7 +4175,7 @@ type URLQueryConfiguration struct { func (x *URLQueryConfiguration) Reset() { *x = URLQueryConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4127,7 +4187,7 @@ func (x *URLQueryConfiguration) String() string { func (*URLQueryConfiguration) ProtoMessage() {} func (x *URLQueryConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[56] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4140,7 +4200,7 @@ func (x *URLQueryConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use URLQueryConfiguration.ProtoReflect.Descriptor instead. func (*URLQueryConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{56} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{57} } func (x *URLQueryConfiguration) GetName() string { @@ -4166,7 +4226,7 @@ type HTTPHeader struct { func (x *HTTPHeader) Reset() { *x = HTTPHeader{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4178,7 +4238,7 @@ func (x *HTTPHeader) String() string { func (*HTTPHeader) ProtoMessage() {} func (x *HTTPHeader) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[57] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4191,7 +4251,7 @@ func (x *HTTPHeader) ProtoReflect() protoreflect.Message { // Deprecated: Use HTTPHeader.ProtoReflect.Descriptor instead. func (*HTTPHeader) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{57} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{58} } func (x *HTTPHeader) GetValues() []*ConfigurationVariable { @@ -4212,7 +4272,7 @@ type MTLSConfiguration struct { func (x *MTLSConfiguration) Reset() { *x = MTLSConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4224,7 +4284,7 @@ func (x *MTLSConfiguration) String() string { func (*MTLSConfiguration) ProtoMessage() {} func (x *MTLSConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[58] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4237,7 +4297,7 @@ func (x *MTLSConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use MTLSConfiguration.ProtoReflect.Descriptor instead. func (*MTLSConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{58} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{59} } func (x *MTLSConfiguration) GetKey() *ConfigurationVariable { @@ -4275,7 +4335,7 @@ type GraphQLSubscriptionConfiguration struct { func (x *GraphQLSubscriptionConfiguration) Reset() { *x = GraphQLSubscriptionConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4287,7 +4347,7 @@ func (x *GraphQLSubscriptionConfiguration) String() string { func (*GraphQLSubscriptionConfiguration) ProtoMessage() {} func (x *GraphQLSubscriptionConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[59] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4300,7 +4360,7 @@ func (x *GraphQLSubscriptionConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphQLSubscriptionConfiguration.ProtoReflect.Descriptor instead. func (*GraphQLSubscriptionConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{59} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{60} } func (x *GraphQLSubscriptionConfiguration) GetEnabled() bool { @@ -4348,7 +4408,7 @@ type GraphQLFederationConfiguration struct { func (x *GraphQLFederationConfiguration) Reset() { *x = GraphQLFederationConfiguration{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4360,7 +4420,7 @@ func (x *GraphQLFederationConfiguration) String() string { func (*GraphQLFederationConfiguration) ProtoMessage() {} func (x *GraphQLFederationConfiguration) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[60] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4373,7 +4433,7 @@ func (x *GraphQLFederationConfiguration) ProtoReflect() protoreflect.Message { // Deprecated: Use GraphQLFederationConfiguration.ProtoReflect.Descriptor instead. func (*GraphQLFederationConfiguration) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{60} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{61} } func (x *GraphQLFederationConfiguration) GetEnabled() bool { @@ -4400,7 +4460,7 @@ type InternedString struct { func (x *InternedString) Reset() { *x = InternedString{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4412,7 +4472,7 @@ func (x *InternedString) String() string { func (*InternedString) ProtoMessage() {} func (x *InternedString) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[61] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4425,7 +4485,7 @@ func (x *InternedString) ProtoReflect() protoreflect.Message { // Deprecated: Use InternedString.ProtoReflect.Descriptor instead. func (*InternedString) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{61} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{62} } func (x *InternedString) GetKey() string { @@ -4445,7 +4505,7 @@ type SingleTypeField struct { func (x *SingleTypeField) Reset() { *x = SingleTypeField{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4457,7 +4517,7 @@ func (x *SingleTypeField) String() string { func (*SingleTypeField) ProtoMessage() {} func (x *SingleTypeField) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[62] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4470,7 +4530,7 @@ func (x *SingleTypeField) ProtoReflect() protoreflect.Message { // Deprecated: Use SingleTypeField.ProtoReflect.Descriptor instead. func (*SingleTypeField) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{62} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{63} } func (x *SingleTypeField) GetTypeName() string { @@ -4497,7 +4557,7 @@ type SubscriptionFieldCondition struct { func (x *SubscriptionFieldCondition) Reset() { *x = SubscriptionFieldCondition{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4509,7 +4569,7 @@ func (x *SubscriptionFieldCondition) String() string { func (*SubscriptionFieldCondition) ProtoMessage() {} func (x *SubscriptionFieldCondition) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[63] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4522,7 +4582,7 @@ func (x *SubscriptionFieldCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscriptionFieldCondition.ProtoReflect.Descriptor instead. func (*SubscriptionFieldCondition) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{63} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{64} } func (x *SubscriptionFieldCondition) GetFieldPath() []string { @@ -4551,7 +4611,7 @@ type SubscriptionFilterCondition struct { func (x *SubscriptionFilterCondition) Reset() { *x = SubscriptionFilterCondition{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4563,7 +4623,7 @@ func (x *SubscriptionFilterCondition) String() string { func (*SubscriptionFilterCondition) ProtoMessage() {} func (x *SubscriptionFilterCondition) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[64] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4576,7 +4636,7 @@ func (x *SubscriptionFilterCondition) ProtoReflect() protoreflect.Message { // Deprecated: Use SubscriptionFilterCondition.ProtoReflect.Descriptor instead. func (*SubscriptionFilterCondition) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{64} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{65} } func (x *SubscriptionFilterCondition) GetAnd() []*SubscriptionFilterCondition { @@ -4616,7 +4676,7 @@ type CacheWarmerOperations struct { func (x *CacheWarmerOperations) Reset() { *x = CacheWarmerOperations{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4628,7 +4688,7 @@ func (x *CacheWarmerOperations) String() string { func (*CacheWarmerOperations) ProtoMessage() {} func (x *CacheWarmerOperations) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[65] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4641,7 +4701,7 @@ func (x *CacheWarmerOperations) ProtoReflect() protoreflect.Message { // Deprecated: Use CacheWarmerOperations.ProtoReflect.Descriptor instead. func (*CacheWarmerOperations) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{65} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{66} } func (x *CacheWarmerOperations) GetOperations() []*Operation { @@ -4661,7 +4721,7 @@ type Operation struct { func (x *Operation) Reset() { *x = Operation{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4673,7 +4733,7 @@ func (x *Operation) String() string { func (*Operation) ProtoMessage() {} func (x *Operation) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[66] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4686,7 +4746,7 @@ func (x *Operation) ProtoReflect() protoreflect.Message { // Deprecated: Use Operation.ProtoReflect.Descriptor instead. func (*Operation) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{66} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{67} } func (x *Operation) GetRequest() *OperationRequest { @@ -4714,7 +4774,7 @@ type OperationRequest struct { func (x *OperationRequest) Reset() { *x = OperationRequest{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4726,7 +4786,7 @@ func (x *OperationRequest) String() string { func (*OperationRequest) ProtoMessage() {} func (x *OperationRequest) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[67] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4739,7 +4799,7 @@ func (x *OperationRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use OperationRequest.ProtoReflect.Descriptor instead. func (*OperationRequest) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{67} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{68} } func (x *OperationRequest) GetOperationName() string { @@ -4772,7 +4832,7 @@ type Extension struct { func (x *Extension) Reset() { *x = Extension{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4784,7 +4844,7 @@ func (x *Extension) String() string { func (*Extension) ProtoMessage() {} func (x *Extension) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[68] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4797,7 +4857,7 @@ func (x *Extension) ProtoReflect() protoreflect.Message { // Deprecated: Use Extension.ProtoReflect.Descriptor instead. func (*Extension) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{68} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{69} } func (x *Extension) GetPersistedQuery() *PersistedQuery { @@ -4817,7 +4877,7 @@ type PersistedQuery struct { func (x *PersistedQuery) Reset() { *x = PersistedQuery{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4829,7 +4889,7 @@ func (x *PersistedQuery) String() string { func (*PersistedQuery) ProtoMessage() {} func (x *PersistedQuery) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[69] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4842,7 +4902,7 @@ func (x *PersistedQuery) ProtoReflect() protoreflect.Message { // Deprecated: Use PersistedQuery.ProtoReflect.Descriptor instead. func (*PersistedQuery) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{69} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{70} } func (x *PersistedQuery) GetSha256Hash() string { @@ -4869,7 +4929,7 @@ type ClientInfo struct { func (x *ClientInfo) Reset() { *x = ClientInfo{} - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4881,7 +4941,7 @@ func (x *ClientInfo) String() string { func (*ClientInfo) ProtoMessage() {} func (x *ClientInfo) ProtoReflect() protoreflect.Message { - mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[70] + mi := &file_wg_cosmo_node_v1_node_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4894,7 +4954,7 @@ func (x *ClientInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use ClientInfo.ProtoReflect.Descriptor instead. func (*ClientInfo) Descriptor() ([]byte, []int) { - return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{70} + return file_wg_cosmo_node_v1_node_proto_rawDescGZIP(), []int{71} } func (x *ClientInfo) GetName() string { @@ -5208,6 +5268,9 @@ const file_wg_cosmo_node_v1_node_proto_rawDesc = "" + "\x06topics\x18\x02 \x03(\tR\x06topics\"\x9f\x01\n" + "\x17RedisEventConfiguration\x12h\n" + "\x1aengine_event_configuration\x18\x01 \x01(\v2*.wg.cosmo.node.v1.EngineEventConfigurationR\x18engineEventConfiguration\x12\x1a\n" + + "\bchannels\x18\x02 \x03(\tR\bchannels\"\xa0\x01\n" + + "\x18PusherEventConfiguration\x12h\n" + + "\x1aengine_event_configuration\x18\x01 \x01(\v2*.wg.cosmo.node.v1.EngineEventConfigurationR\x18engineEventConfiguration\x12\x1a\n" + "\bchannels\x18\x02 \x03(\tR\bchannels\"\xa8\x01\n" + "\x18EngineEventConfiguration\x12\x1f\n" + "\vprovider_id\x18\x01 \x01(\tR\n" + @@ -5215,11 +5278,12 @@ const file_wg_cosmo_node_v1_node_proto_rawDesc = "" + "\x04type\x18\x02 \x01(\x0e2\x1b.wg.cosmo.node.v1.EventTypeR\x04type\x12\x1b\n" + "\ttype_name\x18\x03 \x01(\tR\btypeName\x12\x1d\n" + "\n" + - "field_name\x18\x04 \x01(\tR\tfieldName\"\xd8\x01\n" + + "field_name\x18\x04 \x01(\tR\tfieldName\"\x9c\x02\n" + "\x16DataSourceCustomEvents\x12<\n" + "\x04nats\x18\x01 \x03(\v2(.wg.cosmo.node.v1.NatsEventConfigurationR\x04nats\x12?\n" + "\x05kafka\x18\x02 \x03(\v2).wg.cosmo.node.v1.KafkaEventConfigurationR\x05kafka\x12?\n" + - "\x05redis\x18\x03 \x03(\v2).wg.cosmo.node.v1.RedisEventConfigurationR\x05redis\"V\n" + + "\x05redis\x18\x03 \x03(\v2).wg.cosmo.node.v1.RedisEventConfigurationR\x05redis\x12B\n" + + "\x06pusher\x18\x04 \x03(\v2*.wg.cosmo.node.v1.PusherEventConfigurationR\x06pusher\"V\n" + "\x17DataSourceCustom_Static\x12;\n" + "\x04data\x18\x01 \x01(\v2'.wg.cosmo.node.v1.ConfigurationVariableR\x04data\"\xd5\x02\n" + "\x15ConfigurationVariable\x12?\n" + @@ -5335,8 +5399,7 @@ const file_wg_cosmo_node_v1_node_proto_rawDesc = "" + "\x06DELETE\x10\x03\x12\v\n" + "\aOPTIONS\x10\x042n\n" + "\vNodeService\x12_\n" + - "\fSelfRegister\x12%.wg.cosmo.node.v1.SelfRegisterRequest\x1a&.wg.cosmo.node.v1.SelfRegisterResponse\"\x00B\xcb\x01\n" + - "\x14com.wg.cosmo.node.v1B\tNodeProtoP\x01ZEgithub.com/wundergraph/cosmo/router/gen/proto/wg/cosmo/node/v1;nodev1\xa2\x02\x03WCN\xaa\x02\x10Wg.Cosmo.Node.V1\xca\x02\x10Wg\\Cosmo\\Node\\V1\xe2\x02\x1cWg\\Cosmo\\Node\\V1\\GPBMetadata\xea\x02\x13Wg::Cosmo::Node::V1b\x06proto3" + "\fSelfRegister\x12%.wg.cosmo.node.v1.SelfRegisterRequest\x1a&.wg.cosmo.node.v1.SelfRegisterResponse\"\x00b\x06proto3" var ( file_wg_cosmo_node_v1_node_proto_rawDescOnce sync.Once @@ -5351,7 +5414,7 @@ func file_wg_cosmo_node_v1_node_proto_rawDescGZIP() []byte { } var file_wg_cosmo_node_v1_node_proto_enumTypes = make([]protoimpl.EnumInfo, 8) -var file_wg_cosmo_node_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 78) +var file_wg_cosmo_node_v1_node_proto_msgTypes = make([]protoimpl.MessageInfo, 79) var file_wg_cosmo_node_v1_node_proto_goTypes = []any{ (ArgumentRenderConfiguration)(0), // 0: wg.cosmo.node.v1.ArgumentRenderConfiguration (ArgumentSource)(0), // 1: wg.cosmo.node.v1.ArgumentSource @@ -5412,62 +5475,63 @@ var file_wg_cosmo_node_v1_node_proto_goTypes = []any{ (*NatsEventConfiguration)(nil), // 56: wg.cosmo.node.v1.NatsEventConfiguration (*KafkaEventConfiguration)(nil), // 57: wg.cosmo.node.v1.KafkaEventConfiguration (*RedisEventConfiguration)(nil), // 58: wg.cosmo.node.v1.RedisEventConfiguration - (*EngineEventConfiguration)(nil), // 59: wg.cosmo.node.v1.EngineEventConfiguration - (*DataSourceCustomEvents)(nil), // 60: wg.cosmo.node.v1.DataSourceCustomEvents - (*DataSourceCustom_Static)(nil), // 61: wg.cosmo.node.v1.DataSourceCustom_Static - (*ConfigurationVariable)(nil), // 62: wg.cosmo.node.v1.ConfigurationVariable - (*DirectiveConfiguration)(nil), // 63: wg.cosmo.node.v1.DirectiveConfiguration - (*URLQueryConfiguration)(nil), // 64: wg.cosmo.node.v1.URLQueryConfiguration - (*HTTPHeader)(nil), // 65: wg.cosmo.node.v1.HTTPHeader - (*MTLSConfiguration)(nil), // 66: wg.cosmo.node.v1.MTLSConfiguration - (*GraphQLSubscriptionConfiguration)(nil), // 67: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration - (*GraphQLFederationConfiguration)(nil), // 68: wg.cosmo.node.v1.GraphQLFederationConfiguration - (*InternedString)(nil), // 69: wg.cosmo.node.v1.InternedString - (*SingleTypeField)(nil), // 70: wg.cosmo.node.v1.SingleTypeField - (*SubscriptionFieldCondition)(nil), // 71: wg.cosmo.node.v1.SubscriptionFieldCondition - (*SubscriptionFilterCondition)(nil), // 72: wg.cosmo.node.v1.SubscriptionFilterCondition - (*CacheWarmerOperations)(nil), // 73: wg.cosmo.node.v1.CacheWarmerOperations - (*Operation)(nil), // 74: wg.cosmo.node.v1.Operation - (*OperationRequest)(nil), // 75: wg.cosmo.node.v1.OperationRequest - (*Extension)(nil), // 76: wg.cosmo.node.v1.Extension - (*PersistedQuery)(nil), // 77: wg.cosmo.node.v1.PersistedQuery - (*ClientInfo)(nil), // 78: wg.cosmo.node.v1.ClientInfo - nil, // 79: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry - nil, // 80: wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry - nil, // 81: wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry - nil, // 82: wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry - nil, // 83: wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry - nil, // 84: wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry - nil, // 85: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry - (common.EnumStatusCode)(0), // 86: wg.cosmo.common.EnumStatusCode - (common.GraphQLSubscriptionProtocol)(0), // 87: wg.cosmo.common.GraphQLSubscriptionProtocol - (common.GraphQLWebsocketSubprotocol)(0), // 88: wg.cosmo.common.GraphQLWebsocketSubprotocol + (*PusherEventConfiguration)(nil), // 59: wg.cosmo.node.v1.PusherEventConfiguration + (*EngineEventConfiguration)(nil), // 60: wg.cosmo.node.v1.EngineEventConfiguration + (*DataSourceCustomEvents)(nil), // 61: wg.cosmo.node.v1.DataSourceCustomEvents + (*DataSourceCustom_Static)(nil), // 62: wg.cosmo.node.v1.DataSourceCustom_Static + (*ConfigurationVariable)(nil), // 63: wg.cosmo.node.v1.ConfigurationVariable + (*DirectiveConfiguration)(nil), // 64: wg.cosmo.node.v1.DirectiveConfiguration + (*URLQueryConfiguration)(nil), // 65: wg.cosmo.node.v1.URLQueryConfiguration + (*HTTPHeader)(nil), // 66: wg.cosmo.node.v1.HTTPHeader + (*MTLSConfiguration)(nil), // 67: wg.cosmo.node.v1.MTLSConfiguration + (*GraphQLSubscriptionConfiguration)(nil), // 68: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration + (*GraphQLFederationConfiguration)(nil), // 69: wg.cosmo.node.v1.GraphQLFederationConfiguration + (*InternedString)(nil), // 70: wg.cosmo.node.v1.InternedString + (*SingleTypeField)(nil), // 71: wg.cosmo.node.v1.SingleTypeField + (*SubscriptionFieldCondition)(nil), // 72: wg.cosmo.node.v1.SubscriptionFieldCondition + (*SubscriptionFilterCondition)(nil), // 73: wg.cosmo.node.v1.SubscriptionFilterCondition + (*CacheWarmerOperations)(nil), // 74: wg.cosmo.node.v1.CacheWarmerOperations + (*Operation)(nil), // 75: wg.cosmo.node.v1.Operation + (*OperationRequest)(nil), // 76: wg.cosmo.node.v1.OperationRequest + (*Extension)(nil), // 77: wg.cosmo.node.v1.Extension + (*PersistedQuery)(nil), // 78: wg.cosmo.node.v1.PersistedQuery + (*ClientInfo)(nil), // 79: wg.cosmo.node.v1.ClientInfo + nil, // 80: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry + nil, // 81: wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry + nil, // 82: wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry + nil, // 83: wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry + nil, // 84: wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry + nil, // 85: wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry + nil, // 86: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry + (common.EnumStatusCode)(0), // 87: wg.cosmo.common.EnumStatusCode + (common.GraphQLSubscriptionProtocol)(0), // 88: wg.cosmo.common.GraphQLSubscriptionProtocol + (common.GraphQLWebsocketSubprotocol)(0), // 89: wg.cosmo.common.GraphQLWebsocketSubprotocol } var file_wg_cosmo_node_v1_node_proto_depIdxs = []int32{ - 79, // 0: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.config_by_feature_flag_name:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry + 80, // 0: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.config_by_feature_flag_name:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry 18, // 1: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig.engine_config:type_name -> wg.cosmo.node.v1.EngineConfiguration 8, // 2: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig.subgraphs:type_name -> wg.cosmo.node.v1.Subgraph 18, // 3: wg.cosmo.node.v1.RouterConfig.engine_config:type_name -> wg.cosmo.node.v1.EngineConfiguration 8, // 4: wg.cosmo.node.v1.RouterConfig.subgraphs:type_name -> wg.cosmo.node.v1.Subgraph 9, // 5: wg.cosmo.node.v1.RouterConfig.feature_flag_configs:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs - 86, // 6: wg.cosmo.node.v1.Response.code:type_name -> wg.cosmo.common.EnumStatusCode + 87, // 6: wg.cosmo.node.v1.Response.code:type_name -> wg.cosmo.common.EnumStatusCode 15, // 7: wg.cosmo.node.v1.RegistrationInfo.account_limits:type_name -> wg.cosmo.node.v1.AccountLimits 12, // 8: wg.cosmo.node.v1.SelfRegisterResponse.response:type_name -> wg.cosmo.node.v1.Response 14, // 9: wg.cosmo.node.v1.SelfRegisterResponse.registrationInfo:type_name -> wg.cosmo.node.v1.RegistrationInfo 19, // 10: wg.cosmo.node.v1.EngineConfiguration.datasource_configurations:type_name -> wg.cosmo.node.v1.DataSourceConfiguration 30, // 11: wg.cosmo.node.v1.EngineConfiguration.field_configurations:type_name -> wg.cosmo.node.v1.FieldConfiguration 31, // 12: wg.cosmo.node.v1.EngineConfiguration.type_configurations:type_name -> wg.cosmo.node.v1.TypeConfiguration - 80, // 13: wg.cosmo.node.v1.EngineConfiguration.string_storage:type_name -> wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry + 81, // 13: wg.cosmo.node.v1.EngineConfiguration.string_storage:type_name -> wg.cosmo.node.v1.EngineConfiguration.StringStorageEntry 2, // 14: wg.cosmo.node.v1.DataSourceConfiguration.kind:type_name -> wg.cosmo.node.v1.DataSourceKind 32, // 15: wg.cosmo.node.v1.DataSourceConfiguration.root_nodes:type_name -> wg.cosmo.node.v1.TypeField 32, // 16: wg.cosmo.node.v1.DataSourceConfiguration.child_nodes:type_name -> wg.cosmo.node.v1.TypeField 39, // 17: wg.cosmo.node.v1.DataSourceConfiguration.custom_graphql:type_name -> wg.cosmo.node.v1.DataSourceCustom_GraphQL - 61, // 18: wg.cosmo.node.v1.DataSourceConfiguration.custom_static:type_name -> wg.cosmo.node.v1.DataSourceCustom_Static - 63, // 19: wg.cosmo.node.v1.DataSourceConfiguration.directives:type_name -> wg.cosmo.node.v1.DirectiveConfiguration + 62, // 18: wg.cosmo.node.v1.DataSourceConfiguration.custom_static:type_name -> wg.cosmo.node.v1.DataSourceCustom_Static + 64, // 19: wg.cosmo.node.v1.DataSourceConfiguration.directives:type_name -> wg.cosmo.node.v1.DirectiveConfiguration 35, // 20: wg.cosmo.node.v1.DataSourceConfiguration.keys:type_name -> wg.cosmo.node.v1.RequiredField 35, // 21: wg.cosmo.node.v1.DataSourceConfiguration.provides:type_name -> wg.cosmo.node.v1.RequiredField 35, // 22: wg.cosmo.node.v1.DataSourceConfiguration.requires:type_name -> wg.cosmo.node.v1.RequiredField - 60, // 23: wg.cosmo.node.v1.DataSourceConfiguration.custom_events:type_name -> wg.cosmo.node.v1.DataSourceCustomEvents + 61, // 23: wg.cosmo.node.v1.DataSourceConfiguration.custom_events:type_name -> wg.cosmo.node.v1.DataSourceCustomEvents 36, // 24: wg.cosmo.node.v1.DataSourceConfiguration.entity_interfaces:type_name -> wg.cosmo.node.v1.EntityInterfaceConfiguration 36, // 25: wg.cosmo.node.v1.DataSourceConfiguration.interface_objects:type_name -> wg.cosmo.node.v1.EntityInterfaceConfiguration 24, // 26: wg.cosmo.node.v1.DataSourceConfiguration.cost_configuration:type_name -> wg.cosmo.node.v1.CostConfiguration @@ -5477,32 +5541,32 @@ var file_wg_cosmo_node_v1_node_proto_depIdxs = []int32{ 23, // 30: wg.cosmo.node.v1.EntityCachingConfiguration.cache_populate_configurations:type_name -> wg.cosmo.node.v1.CachePopulateConfiguration 25, // 31: wg.cosmo.node.v1.CostConfiguration.field_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration 26, // 32: wg.cosmo.node.v1.CostConfiguration.list_sizes:type_name -> wg.cosmo.node.v1.FieldListSizeConfiguration - 81, // 33: wg.cosmo.node.v1.CostConfiguration.type_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry - 82, // 34: wg.cosmo.node.v1.CostConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry - 83, // 35: wg.cosmo.node.v1.FieldWeightConfiguration.argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry - 84, // 36: wg.cosmo.node.v1.FieldWeightConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry + 82, // 33: wg.cosmo.node.v1.CostConfiguration.type_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.TypeWeightsEntry + 83, // 34: wg.cosmo.node.v1.CostConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.CostConfiguration.DirectiveArgumentWeightsEntry + 84, // 35: wg.cosmo.node.v1.FieldWeightConfiguration.argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.ArgumentWeightsEntry + 85, // 36: wg.cosmo.node.v1.FieldWeightConfiguration.directive_argument_weights:type_name -> wg.cosmo.node.v1.FieldWeightConfiguration.DirectiveArgumentWeightsEntry 1, // 37: wg.cosmo.node.v1.ArgumentConfiguration.source_type:type_name -> wg.cosmo.node.v1.ArgumentSource 28, // 38: wg.cosmo.node.v1.AuthorizationConfiguration.required_or_scopes:type_name -> wg.cosmo.node.v1.Scopes 28, // 39: wg.cosmo.node.v1.AuthorizationConfiguration.required_or_scopes_by_or:type_name -> wg.cosmo.node.v1.Scopes 27, // 40: wg.cosmo.node.v1.FieldConfiguration.arguments_configuration:type_name -> wg.cosmo.node.v1.ArgumentConfiguration 29, // 41: wg.cosmo.node.v1.FieldConfiguration.authorization_configuration:type_name -> wg.cosmo.node.v1.AuthorizationConfiguration - 72, // 42: wg.cosmo.node.v1.FieldConfiguration.subscription_filter_condition:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 73, // 42: wg.cosmo.node.v1.FieldConfiguration.subscription_filter_condition:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition 33, // 43: wg.cosmo.node.v1.FieldSetCondition.field_coordinates_path:type_name -> wg.cosmo.node.v1.FieldCoordinates 34, // 44: wg.cosmo.node.v1.RequiredField.conditions:type_name -> wg.cosmo.node.v1.FieldSetCondition - 62, // 45: wg.cosmo.node.v1.FetchConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 45: wg.cosmo.node.v1.FetchConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable 7, // 46: wg.cosmo.node.v1.FetchConfiguration.method:type_name -> wg.cosmo.node.v1.HTTPMethod - 85, // 47: wg.cosmo.node.v1.FetchConfiguration.header:type_name -> wg.cosmo.node.v1.FetchConfiguration.HeaderEntry - 62, // 48: wg.cosmo.node.v1.FetchConfiguration.body:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 64, // 49: wg.cosmo.node.v1.FetchConfiguration.query:type_name -> wg.cosmo.node.v1.URLQueryConfiguration - 66, // 50: wg.cosmo.node.v1.FetchConfiguration.mtls:type_name -> wg.cosmo.node.v1.MTLSConfiguration - 62, // 51: wg.cosmo.node.v1.FetchConfiguration.base_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 52: wg.cosmo.node.v1.FetchConfiguration.path:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 53: wg.cosmo.node.v1.FetchConfiguration.http_proxy_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 86, // 47: wg.cosmo.node.v1.FetchConfiguration.header:type_name -> wg.cosmo.node.v1.FetchConfiguration.HeaderEntry + 63, // 48: wg.cosmo.node.v1.FetchConfiguration.body:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 65, // 49: wg.cosmo.node.v1.FetchConfiguration.query:type_name -> wg.cosmo.node.v1.URLQueryConfiguration + 67, // 50: wg.cosmo.node.v1.FetchConfiguration.mtls:type_name -> wg.cosmo.node.v1.MTLSConfiguration + 63, // 51: wg.cosmo.node.v1.FetchConfiguration.base_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 52: wg.cosmo.node.v1.FetchConfiguration.path:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 53: wg.cosmo.node.v1.FetchConfiguration.http_proxy_url:type_name -> wg.cosmo.node.v1.ConfigurationVariable 37, // 54: wg.cosmo.node.v1.DataSourceCustom_GraphQL.fetch:type_name -> wg.cosmo.node.v1.FetchConfiguration - 67, // 55: wg.cosmo.node.v1.DataSourceCustom_GraphQL.subscription:type_name -> wg.cosmo.node.v1.GraphQLSubscriptionConfiguration - 68, // 56: wg.cosmo.node.v1.DataSourceCustom_GraphQL.federation:type_name -> wg.cosmo.node.v1.GraphQLFederationConfiguration - 69, // 57: wg.cosmo.node.v1.DataSourceCustom_GraphQL.upstream_schema:type_name -> wg.cosmo.node.v1.InternedString - 70, // 58: wg.cosmo.node.v1.DataSourceCustom_GraphQL.custom_scalar_type_fields:type_name -> wg.cosmo.node.v1.SingleTypeField + 68, // 55: wg.cosmo.node.v1.DataSourceCustom_GraphQL.subscription:type_name -> wg.cosmo.node.v1.GraphQLSubscriptionConfiguration + 69, // 56: wg.cosmo.node.v1.DataSourceCustom_GraphQL.federation:type_name -> wg.cosmo.node.v1.GraphQLFederationConfiguration + 70, // 57: wg.cosmo.node.v1.DataSourceCustom_GraphQL.upstream_schema:type_name -> wg.cosmo.node.v1.InternedString + 71, // 58: wg.cosmo.node.v1.DataSourceCustom_GraphQL.custom_scalar_type_fields:type_name -> wg.cosmo.node.v1.SingleTypeField 40, // 59: wg.cosmo.node.v1.DataSourceCustom_GraphQL.grpc:type_name -> wg.cosmo.node.v1.GRPCConfiguration 44, // 60: wg.cosmo.node.v1.GRPCConfiguration.mapping:type_name -> wg.cosmo.node.v1.GRPCMapping 42, // 61: wg.cosmo.node.v1.GRPCConfiguration.plugin:type_name -> wg.cosmo.node.v1.PluginConfiguration @@ -5521,40 +5585,42 @@ var file_wg_cosmo_node_v1_node_proto_depIdxs = []int32{ 51, // 74: wg.cosmo.node.v1.TypeFieldMapping.field_mappings:type_name -> wg.cosmo.node.v1.FieldMapping 52, // 75: wg.cosmo.node.v1.FieldMapping.argument_mappings:type_name -> wg.cosmo.node.v1.ArgumentMapping 54, // 76: wg.cosmo.node.v1.EnumMapping.values:type_name -> wg.cosmo.node.v1.EnumValueMapping - 59, // 77: wg.cosmo.node.v1.NatsEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 60, // 77: wg.cosmo.node.v1.NatsEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration 55, // 78: wg.cosmo.node.v1.NatsEventConfiguration.stream_configuration:type_name -> wg.cosmo.node.v1.NatsStreamConfiguration - 59, // 79: wg.cosmo.node.v1.KafkaEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration - 59, // 80: wg.cosmo.node.v1.RedisEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration - 5, // 81: wg.cosmo.node.v1.EngineEventConfiguration.type:type_name -> wg.cosmo.node.v1.EventType - 56, // 82: wg.cosmo.node.v1.DataSourceCustomEvents.nats:type_name -> wg.cosmo.node.v1.NatsEventConfiguration - 57, // 83: wg.cosmo.node.v1.DataSourceCustomEvents.kafka:type_name -> wg.cosmo.node.v1.KafkaEventConfiguration - 58, // 84: wg.cosmo.node.v1.DataSourceCustomEvents.redis:type_name -> wg.cosmo.node.v1.RedisEventConfiguration - 62, // 85: wg.cosmo.node.v1.DataSourceCustom_Static.data:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 6, // 86: wg.cosmo.node.v1.ConfigurationVariable.kind:type_name -> wg.cosmo.node.v1.ConfigurationVariableKind - 62, // 87: wg.cosmo.node.v1.HTTPHeader.values:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 88: wg.cosmo.node.v1.MTLSConfiguration.key:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 89: wg.cosmo.node.v1.MTLSConfiguration.cert:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 62, // 90: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable - 87, // 91: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.protocol:type_name -> wg.cosmo.common.GraphQLSubscriptionProtocol - 88, // 92: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.websocketSubprotocol:type_name -> wg.cosmo.common.GraphQLWebsocketSubprotocol - 72, // 93: wg.cosmo.node.v1.SubscriptionFilterCondition.and:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition - 71, // 94: wg.cosmo.node.v1.SubscriptionFilterCondition.in:type_name -> wg.cosmo.node.v1.SubscriptionFieldCondition - 72, // 95: wg.cosmo.node.v1.SubscriptionFilterCondition.not:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition - 72, // 96: wg.cosmo.node.v1.SubscriptionFilterCondition.or:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition - 74, // 97: wg.cosmo.node.v1.CacheWarmerOperations.operations:type_name -> wg.cosmo.node.v1.Operation - 75, // 98: wg.cosmo.node.v1.Operation.request:type_name -> wg.cosmo.node.v1.OperationRequest - 78, // 99: wg.cosmo.node.v1.Operation.client:type_name -> wg.cosmo.node.v1.ClientInfo - 76, // 100: wg.cosmo.node.v1.OperationRequest.extensions:type_name -> wg.cosmo.node.v1.Extension - 77, // 101: wg.cosmo.node.v1.Extension.persisted_query:type_name -> wg.cosmo.node.v1.PersistedQuery - 10, // 102: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry.value:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig - 65, // 103: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry.value:type_name -> wg.cosmo.node.v1.HTTPHeader - 16, // 104: wg.cosmo.node.v1.NodeService.SelfRegister:input_type -> wg.cosmo.node.v1.SelfRegisterRequest - 17, // 105: wg.cosmo.node.v1.NodeService.SelfRegister:output_type -> wg.cosmo.node.v1.SelfRegisterResponse - 105, // [105:106] is the sub-list for method output_type - 104, // [104:105] is the sub-list for method input_type - 104, // [104:104] is the sub-list for extension type_name - 104, // [104:104] is the sub-list for extension extendee - 0, // [0:104] is the sub-list for field type_name + 60, // 79: wg.cosmo.node.v1.KafkaEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 60, // 80: wg.cosmo.node.v1.RedisEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 60, // 81: wg.cosmo.node.v1.PusherEventConfiguration.engine_event_configuration:type_name -> wg.cosmo.node.v1.EngineEventConfiguration + 5, // 82: wg.cosmo.node.v1.EngineEventConfiguration.type:type_name -> wg.cosmo.node.v1.EventType + 56, // 83: wg.cosmo.node.v1.DataSourceCustomEvents.nats:type_name -> wg.cosmo.node.v1.NatsEventConfiguration + 57, // 84: wg.cosmo.node.v1.DataSourceCustomEvents.kafka:type_name -> wg.cosmo.node.v1.KafkaEventConfiguration + 58, // 85: wg.cosmo.node.v1.DataSourceCustomEvents.redis:type_name -> wg.cosmo.node.v1.RedisEventConfiguration + 59, // 86: wg.cosmo.node.v1.DataSourceCustomEvents.pusher:type_name -> wg.cosmo.node.v1.PusherEventConfiguration + 63, // 87: wg.cosmo.node.v1.DataSourceCustom_Static.data:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 6, // 88: wg.cosmo.node.v1.ConfigurationVariable.kind:type_name -> wg.cosmo.node.v1.ConfigurationVariableKind + 63, // 89: wg.cosmo.node.v1.HTTPHeader.values:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 90: wg.cosmo.node.v1.MTLSConfiguration.key:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 91: wg.cosmo.node.v1.MTLSConfiguration.cert:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 63, // 92: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.url:type_name -> wg.cosmo.node.v1.ConfigurationVariable + 88, // 93: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.protocol:type_name -> wg.cosmo.common.GraphQLSubscriptionProtocol + 89, // 94: wg.cosmo.node.v1.GraphQLSubscriptionConfiguration.websocketSubprotocol:type_name -> wg.cosmo.common.GraphQLWebsocketSubprotocol + 73, // 95: wg.cosmo.node.v1.SubscriptionFilterCondition.and:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 72, // 96: wg.cosmo.node.v1.SubscriptionFilterCondition.in:type_name -> wg.cosmo.node.v1.SubscriptionFieldCondition + 73, // 97: wg.cosmo.node.v1.SubscriptionFilterCondition.not:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 73, // 98: wg.cosmo.node.v1.SubscriptionFilterCondition.or:type_name -> wg.cosmo.node.v1.SubscriptionFilterCondition + 75, // 99: wg.cosmo.node.v1.CacheWarmerOperations.operations:type_name -> wg.cosmo.node.v1.Operation + 76, // 100: wg.cosmo.node.v1.Operation.request:type_name -> wg.cosmo.node.v1.OperationRequest + 79, // 101: wg.cosmo.node.v1.Operation.client:type_name -> wg.cosmo.node.v1.ClientInfo + 77, // 102: wg.cosmo.node.v1.OperationRequest.extensions:type_name -> wg.cosmo.node.v1.Extension + 78, // 103: wg.cosmo.node.v1.Extension.persisted_query:type_name -> wg.cosmo.node.v1.PersistedQuery + 10, // 104: wg.cosmo.node.v1.FeatureFlagRouterExecutionConfigs.ConfigByFeatureFlagNameEntry.value:type_name -> wg.cosmo.node.v1.FeatureFlagRouterExecutionConfig + 66, // 105: wg.cosmo.node.v1.FetchConfiguration.HeaderEntry.value:type_name -> wg.cosmo.node.v1.HTTPHeader + 16, // 106: wg.cosmo.node.v1.NodeService.SelfRegister:input_type -> wg.cosmo.node.v1.SelfRegisterRequest + 17, // 107: wg.cosmo.node.v1.NodeService.SelfRegister:output_type -> wg.cosmo.node.v1.SelfRegisterResponse + 107, // [107:108] is the sub-list for method output_type + 106, // [106:107] is the sub-list for method input_type + 106, // [106:106] is the sub-list for extension type_name + 106, // [106:106] is the sub-list for extension extendee + 0, // [0:106] is the sub-list for field type_name } func init() { file_wg_cosmo_node_v1_node_proto_init() } @@ -5571,15 +5637,15 @@ func file_wg_cosmo_node_v1_node_proto_init() { file_wg_cosmo_node_v1_node_proto_msgTypes[22].OneofWrappers = []any{} file_wg_cosmo_node_v1_node_proto_msgTypes[29].OneofWrappers = []any{} file_wg_cosmo_node_v1_node_proto_msgTypes[34].OneofWrappers = []any{} - file_wg_cosmo_node_v1_node_proto_msgTypes[59].OneofWrappers = []any{} - file_wg_cosmo_node_v1_node_proto_msgTypes[64].OneofWrappers = []any{} + file_wg_cosmo_node_v1_node_proto_msgTypes[60].OneofWrappers = []any{} + file_wg_cosmo_node_v1_node_proto_msgTypes[65].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_wg_cosmo_node_v1_node_proto_rawDesc), len(file_wg_cosmo_node_v1_node_proto_rawDesc)), NumEnums: 8, - NumMessages: 78, + NumMessages: 79, NumExtensions: 0, NumServices: 1, }, diff --git a/router/internal/pusherclient/auth.go b/router/internal/pusherclient/auth.go new file mode 100644 index 0000000000..7fcb8bd303 --- /dev/null +++ b/router/internal/pusherclient/auth.go @@ -0,0 +1,121 @@ +package pusherclient + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "go.uber.org/zap" +) + +// authResponse is the body monday's POST /pusher/auth returns on success. +type authResponse struct { + Auth string `json:"auth"` + ChannelData string `json:"channel_data"` + SharedKey string `json:"shared_secret"` +} + +// AuthError describes a failed channel authorization. +type AuthError struct { + Channel string + StatusCode int + Body string +} + +func (e *AuthError) Error() string { + return fmt.Sprintf("pusher: authorization for channel %q failed with status %d: %s", e.Channel, e.StatusCode, e.Body) +} + +// signChannel produces the subscription signature Pusher expects for a private +// channel: "::" under the +// app secret>". This is the same computation every server-side Pusher SDK performs +// in its auth endpoint, so it is re-derived on every reconnect with the new +// socket_id. +// +// A presence channel would additionally need channel_data folded into the signed +// string; monday's channels are private, so that is not implemented. +func signChannel(appKey, appSecret, socketID, channel string) string { + mac := hmac.New(sha256.New, []byte(appSecret)) + mac.Write([]byte(socketID + ":" + channel)) + return appKey + ":" + hex.EncodeToString(mac.Sum(nil)) +} + +// needsAuth reports whether a channel has to be authorized before subscribing. +// monday's encrypted channels use the "private-enc_" prefix, which is still a +// private channel as far as Pusher is concerned. +func needsAuth(channel string) bool { + return strings.HasPrefix(channel, "private-") || strings.HasPrefix(channel, "presence-") +} + +// authorize performs a single-channel POST to the configured auth endpoint. The +// monday web client batches these requests; we deliberately keep one request per +// channel here, which is the plain Pusher contract. +func (c *Client) authorize(ctx context.Context, channel, socketID string) (*authResponse, error) { + if c.opts.AppSecret != "" { + auth := signChannel(c.opts.AppKey, c.opts.AppSecret, socketID, channel) + c.logger.Debug("signed pusher channel locally", + zap.String("channel", channel), + zap.String("socket_id", socketID), + ) + return &authResponse{Auth: auth}, nil + } + if c.opts.AuthEndpoint == "" { + return nil, fmt.Errorf("pusher: channel %q requires authorization but neither an auth endpoint nor an app secret is configured", channel) + } + + form := url.Values{} + form.Set("socket_id", socketID) + form.Set("channel_name", channel) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.opts.AuthEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + for name, value := range c.opts.AuthHeaders { + req.Header.Set(name, value) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, &AuthError{Channel: channel, StatusCode: resp.StatusCode, Body: truncate(string(body), 512)} + } + + var parsed authResponse + if err := json.Unmarshal(body, &parsed); err != nil { + // Same failure mode as the key endpoint: a 200 with HTML means the monolith + // rendered the login page because the session cookie was missing or expired. + c.logger.Error("auth response is not JSON", + zap.String("channel", channel), + zap.String("endpoint", c.opts.AuthEndpoint), + zap.Int("status", resp.StatusCode), + zap.String("content_type", resp.Header.Get("Content-Type")), + zap.Strings("request_headers_sent", headerNames(c.opts.AuthHeaders)), + zap.String("body", truncate(string(body), 2048)), + zap.Error(err), + ) + return nil, fmt.Errorf("pusher: could not parse auth response for channel %q (status %d, content-type %q, body %s): %w", + channel, resp.StatusCode, resp.Header.Get("Content-Type"), truncate(string(body), 512), err) + } + if parsed.Auth == "" { + return nil, fmt.Errorf("pusher: auth response for channel %q contained no auth signature", channel) + } + + return &parsed, nil +} diff --git a/router/internal/pusherclient/client.go b/router/internal/pusherclient/client.go new file mode 100644 index 0000000000..7aded1ff32 --- /dev/null +++ b/router/internal/pusherclient/client.go @@ -0,0 +1,532 @@ +package pusherclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sync" + "time" + + "github.com/gorilla/websocket" + "go.uber.org/zap" +) + +// Pusher protocol event names. See https://pusher.com/docs/channels/library_auth_reference/pusher-websockets-protocol/ +const ( + eventConnectionEstablished = "pusher:connection_established" + eventError = "pusher:error" + eventPing = "pusher:ping" + eventPong = "pusher:pong" + eventSubscribe = "pusher:subscribe" + eventUnsubscribe = "pusher:unsubscribe" + eventSubscriptionSucceeded = "pusher_internal:subscription_succeeded" + eventSubscriptionError = "pusher_internal:subscription_error" + + protocolVersion = "7" + clientName = "cosmo-router-go" + clientVersion = "1.0.0" + + defaultActivityTimeout = 120 * time.Second +) + +// Options configures a Client. +type Options struct { + // AppKey is the public Pusher app key. + AppKey string + // Cluster is the Pusher cluster, e.g. "mt1". Ignored when WSURL is set. + Cluster string + // WSURL overrides the derived WebSocket URL. Used for tests and for + // self-hosted Pusher-protocol servers. + WSURL string + // AuthEndpoint is the absolute URL of the endpoint that signs private and + // presence channel subscriptions, e.g. https://monday.com/pusher/auth. + AuthEndpoint string + // AppSecret makes the client sign private channels itself instead of calling + // AuthEndpoint. The signature is re-derived per connection, so reconnects keep + // working. Requires AppKey, which is part of the signature. + AppSecret string + // AuthHeaders are sent with every authorization request. A session cookie + // belongs here, since monday's /pusher/auth requires an authenticated user. + AuthHeaders map[string]string + // HTTPClient is used for authorization requests. Defaults to a client with a + // 10 second timeout. + HTTPClient *http.Client + // Decryptor transforms payloads before they reach subscribers. Optional. + Decryptor Decryptor + Logger *zap.Logger + // HandshakeTimeout bounds the dial and the wait for the connection handshake. + HandshakeTimeout time.Duration + // PongTimeout is the grace period added to the server-provided activity + // timeout before the connection is considered dead. + PongTimeout time.Duration + // MinReconnectBackoff and MaxReconnectBackoff bound the reconnect delay. + MinReconnectBackoff time.Duration + MaxReconnectBackoff time.Duration + // EventBufferSize is the per-subscription buffer. Events are dropped when a + // subscriber does not keep up. + EventBufferSize int +} + +// Event is a single message received on a channel, after decryption. +type Event struct { + Channel string + Name string + Data []byte +} + +// frame is the Pusher wire format. Data is a JSON-encoded string that itself +// contains JSON, so it is decoded in two steps. +type frame struct { + Event string `json:"event"` + Channel string `json:"channel,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// Client is a Pusher Channels subscriber. A single WebSocket connection carries +// every channel, and channels are re-subscribed after a reconnect because the +// socket_id — and therefore every auth signature — changes. +type Client struct { + opts Options + httpClient *http.Client + logger *zap.Logger + + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + subs map[string][]*Subscription + session *session + closed bool + + wg sync.WaitGroup +} + +// session holds the state that is only valid for one WebSocket connection. +type session struct { + conn *websocket.Conn + socketID string + activityTimeout time.Duration + writeMu sync.Mutex +} + +func (s *session) send(f frame) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return s.conn.WriteJSON(f) +} + +// New validates the options and returns a client that is not yet connected. +func New(opts Options) (*Client, error) { + if opts.AppKey == "" && opts.WSURL == "" { + return nil, errors.New("pusher: either an app key or an explicit ws url is required") + } + if opts.WSURL == "" && opts.Cluster == "" { + return nil, errors.New("pusher: a cluster is required when no explicit ws url is given") + } + if opts.HandshakeTimeout <= 0 { + opts.HandshakeTimeout = 10 * time.Second + } + if opts.PongTimeout <= 0 { + opts.PongTimeout = 30 * time.Second + } + if opts.MinReconnectBackoff <= 0 { + opts.MinReconnectBackoff = time.Second + } + if opts.MaxReconnectBackoff <= 0 { + opts.MaxReconnectBackoff = 30 * time.Second + } + if opts.EventBufferSize <= 0 { + opts.EventBufferSize = 256 + } + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 10 * time.Second} + } + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + return &Client{ + opts: opts, + httpClient: httpClient, + logger: logger, + subs: map[string][]*Subscription{}, + }, nil +} + +// wsURL builds the connection URL the same way pusher-js does. +func (c *Client) wsURL() string { + if c.opts.WSURL != "" { + return c.opts.WSURL + } + query := url.Values{} + query.Set("protocol", protocolVersion) + query.Set("client", clientName) + query.Set("version", clientVersion) + + return fmt.Sprintf("wss://ws-%s.pusher.com/app/%s?%s", c.opts.Cluster, c.opts.AppKey, query.Encode()) +} + +// Connect establishes the first connection and returns once the handshake +// completed, so that startup failures surface to the caller. Later connection +// losses are handled by a background supervisor. +func (c *Client) Connect(ctx context.Context) error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return errors.New("pusher: client is closed") + } + if c.ctx != nil { + c.mu.Unlock() + return errors.New("pusher: client is already connected") + } + c.ctx, c.cancel = context.WithCancel(context.Background()) + clientCtx := c.ctx + c.mu.Unlock() + + sess, err := c.dial(ctx) + if err != nil { + return err + } + + c.wg.Add(1) + go func() { + defer c.wg.Done() + c.supervise(clientCtx, sess) + }() + + return nil +} + +// Close terminates the connection and stops the supervisor. +func (c *Client) Close() error { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil + } + c.closed = true + cancel := c.cancel + sess := c.session + c.session = nil + c.mu.Unlock() + + if cancel != nil { + cancel() + } + if sess != nil { + c.logger.Info("closing pusher connection", zap.String("socket_id", sess.socketID)) + _ = sess.conn.Close() + } + c.wg.Wait() + + return nil +} + +// dial opens a connection and waits for pusher:connection_established. +func (c *Client) dial(ctx context.Context) (*session, error) { + dialCtx, cancel := context.WithTimeout(ctx, c.opts.HandshakeTimeout) + defer cancel() + + dialer := websocket.Dialer{HandshakeTimeout: c.opts.HandshakeTimeout} + conn, resp, err := dialer.DialContext(dialCtx, c.wsURL(), nil) + if err != nil { + if resp != nil { + return nil, fmt.Errorf("pusher: websocket dial failed with status %d: %w", resp.StatusCode, err) + } + return nil, fmt.Errorf("pusher: websocket dial failed: %w", err) + } + + if deadline, ok := dialCtx.Deadline(); ok { + _ = conn.SetReadDeadline(deadline) + } + + sess := &session{conn: conn, activityTimeout: defaultActivityTimeout} + for { + var f frame + if err := conn.ReadJSON(&f); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("pusher: reading handshake failed: %w", err) + } + + switch f.Event { + case eventConnectionEstablished: + var payload struct { + SocketID string `json:"socket_id"` + ActivityTimeout float64 `json:"activity_timeout"` + } + if err := unmarshalFrameData(f.Data, &payload); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("pusher: could not parse connection_established: %w", err) + } + if payload.SocketID == "" { + _ = conn.Close() + return nil, errors.New("pusher: connection_established contained no socket_id") + } + sess.socketID = payload.SocketID + if payload.ActivityTimeout > 0 { + sess.activityTimeout = time.Duration(payload.ActivityTimeout) * time.Second + } + c.logger.Info("pusher connection established", + zap.String("socket_id", sess.socketID), + zap.Duration("activity_timeout", sess.activityTimeout), + ) + return sess, nil + case eventError: + protoErr := parseProtocolError(f.Data) + _ = conn.Close() + return nil, protoErr + default: + // Pusher may send other frames before the handshake completes; ignore them. + } + } +} + +// supervise serves the given session and reconnects until the client is closed. +func (c *Client) supervise(ctx context.Context, sess *session) { + backoff := c.opts.MinReconnectBackoff + + for { + c.mu.Lock() + c.session = sess + c.mu.Unlock() + + c.resubscribeAll(ctx, sess) + + err := c.serve(ctx, sess) + + c.mu.Lock() + if c.session == sess { + c.session = nil + } + c.mu.Unlock() + _ = sess.conn.Close() + + c.logger.Info("pusher connection dropped", + zap.String("socket_id", sess.socketID), + zap.Error(err), + ) + + if ctx.Err() != nil { + return + } + + var protoErr *ProtocolError + if errors.As(err, &protoErr) && !protoErr.ShouldReconnect() { + c.logger.Error("pusher connection closed permanently, not reconnecting", zap.Error(err)) + return + } + c.logger.Warn("pusher connection lost, reconnecting", zap.Error(err), zap.Duration("backoff", backoff)) + + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + + newSess, dialErr := c.dial(ctx) + if dialErr != nil { + var dialProtoErr *ProtocolError + if errors.As(dialErr, &dialProtoErr) && !dialProtoErr.ShouldReconnect() { + c.logger.Error("pusher reconnect rejected permanently", zap.Error(dialErr)) + return + } + c.logger.Warn("pusher reconnect failed", zap.Error(dialErr)) + backoff = nextBackoff(backoff, c.opts.MaxReconnectBackoff) + continue + } + + backoff = c.opts.MinReconnectBackoff + sess = newSess + } +} + +// serve reads frames until the connection fails or the context is cancelled. +func (c *Client) serve(ctx context.Context, sess *session) error { + // Close the connection when the context is cancelled so the blocking read returns. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = sess.conn.Close() + case <-done: + } + }() + + // The server pings after activity_timeout of silence, so no traffic within + // that window plus the pong grace period means the connection is dead. + readTimeout := sess.activityTimeout + c.opts.PongTimeout + + for { + if err := sess.conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil { + return err + } + + var f frame + if err := sess.conn.ReadJSON(&f); err != nil { + return err + } + + if err := c.handleFrame(sess, f); err != nil { + return err + } + } +} + +func (c *Client) handleFrame(sess *session, f frame) error { + switch f.Event { + case eventPing: + return sess.send(frame{Event: eventPong}) + case eventPong: + return nil + case eventError: + protoErr := parseProtocolError(f.Data) + if !protoErr.ShouldReconnect() { + return protoErr + } + c.logger.Warn("pusher protocol error", zap.Error(protoErr)) + return nil + case eventSubscriptionSucceeded: + c.logger.Debug("pusher subscription succeeded", zap.String("channel", f.Channel)) + return nil + case eventSubscriptionError: + c.logger.Error("pusher subscription rejected", + zap.String("channel", f.Channel), + zap.String("data", string(f.Data)), + ) + return nil + default: + if f.Channel == "" { + c.logger.Debug("ignoring pusher event without channel", zap.String("event", f.Event)) + return nil + } + c.dispatch(f) + return nil + } +} + +// dispatch decrypts the payload and hands it to every subscriber of the channel. +func (c *Client) dispatch(f frame) { + payload, err := decodeFrameData(f.Data) + if err != nil { + c.logger.Error("could not decode pusher event payload", + zap.String("channel", f.Channel), zap.String("event", f.Event), zap.Error(err)) + return + } + + c.logger.Info("pusher event received", + zap.String("channel", f.Channel), zap.String("event", f.Event), + zap.String("raw_payload", string(payload))) + + if c.opts.Decryptor != nil { + decrypted, err := c.opts.Decryptor.Decrypt(payload) + if err != nil { + c.logger.Error("could not decrypt pusher event payload", + zap.String("channel", f.Channel), zap.String("event", f.Event), zap.Error(err)) + return + } + // The decryptor passes non-encrypted payloads through unchanged; only report a + // decrypted payload when decryption actually ran. + if !bytes.Equal(decrypted, payload) { + c.logger.Info("pusher event decrypted", + zap.String("channel", f.Channel), zap.String("event", f.Event), + zap.String("decrypted_payload", string(decrypted))) + } + payload = decrypted + } + + c.mu.Lock() + subs := make([]*Subscription, len(c.subs[f.Channel])) + copy(subs, c.subs[f.Channel]) + c.mu.Unlock() + + evt := Event{Channel: f.Channel, Name: f.Event, Data: payload} + for _, sub := range subs { + sub.deliver(evt, c.logger) + } +} + +// resubscribeAll subscribes every registered channel on a fresh session. +func (c *Client) resubscribeAll(ctx context.Context, sess *session) { + c.mu.Lock() + channels := make([]string, 0, len(c.subs)) + for channel := range c.subs { + channels = append(channels, channel) + } + c.mu.Unlock() + + for _, channel := range channels { + if err := c.sendSubscribe(ctx, sess, channel); err != nil { + c.logger.Error("could not subscribe to pusher channel", + zap.String("channel", channel), zap.Error(err)) + } + } +} + +// sendSubscribe authorizes the channel if needed and sends pusher:subscribe. +func (c *Client) sendSubscribe(ctx context.Context, sess *session, channel string) error { + data := map[string]string{"channel": channel} + + if needsAuth(channel) { + auth, err := c.authorize(ctx, channel, sess.socketID) + if err != nil { + return err + } + data["auth"] = auth.Auth + if auth.ChannelData != "" { + data["channel_data"] = auth.ChannelData + } + if auth.SharedKey != "" { + data["shared_secret"] = auth.SharedKey + } + } + + encoded, err := json.Marshal(data) + if err != nil { + return err + } + + return sess.send(frame{Event: eventSubscribe, Data: encoded}) +} + +func nextBackoff(current, max time.Duration) time.Duration { + next := current * 2 + if next > max { + return max + } + return next +} + +// unmarshalFrameData decodes the double-encoded data field into target. +func unmarshalFrameData(raw json.RawMessage, target any) error { + payload, err := decodeFrameData(raw) + if err != nil { + return err + } + if len(payload) == 0 { + return nil + } + return json.Unmarshal(payload, target) +} + +// decodeFrameData unwraps the data field. Pusher sends it as a JSON string +// containing JSON, but some servers send the object directly. +func decodeFrameData(raw json.RawMessage) ([]byte, error) { + if len(raw) == 0 { + return nil, nil + } + if raw[0] != '"' { + return raw, nil + } + var asString string + if err := json.Unmarshal(raw, &asString); err != nil { + return nil, err + } + return []byte(asString), nil +} diff --git a/router/internal/pusherclient/decrypt.go b/router/internal/pusherclient/decrypt.go new file mode 100644 index 0000000000..61a11bc307 --- /dev/null +++ b/router/internal/pusherclient/decrypt.go @@ -0,0 +1,291 @@ +package pusherclient + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sort" + "sync" + "time" + + "go.uber.org/zap" +) + +// Decryptor transforms a raw event payload before it is handed to a subscriber. +// Payloads that are not encrypted must be returned unchanged. +type Decryptor interface { + Decrypt(payload []byte) ([]byte, error) +} + +// encryptedEnvelope is the payload shape monday.com publishes to its +// "private-enc_" channels. It is not Pusher's native end-to-end encryption. +type encryptedEnvelope struct { + EncryptedBase64 string `json:"encrypted_base64"` + IV string `json:"iv"` + EncDate string `json:"enc_date"` +} + +// MondayDecryptorOptions configures a MondayDecryptor. +type MondayDecryptorOptions struct { + // KeysEndpoint is the absolute URL of monday's key endpoint, which answers with + // {"pusher_enc_keys": {"YYYY-MM-DD": ""}}. + KeysEndpoint string + // StaticKey is used for every payload regardless of its enc_date. It replaces + // KeysEndpoint: when it is set no key request is made at all. Useful for local + // development, where the key endpoint needs a monolith session. + StaticKey string + // Headers are sent with every key request. A session cookie belongs here. + Headers map[string]string + // HTTPClient is optional and defaults to a client with a 10 second timeout. + HTTPClient *http.Client + // RefreshInterval is how often the key set is refetched. Defaults to one hour, + // matching the monday web client. + RefreshInterval time.Duration + Logger *zap.Logger +} + +// MondayDecryptor decrypts monday.com's encrypted channel payloads. The keys +// rotate daily and are addressed by the enc_date carried in each payload. +type MondayDecryptor struct { + opts MondayDecryptorOptions + client *http.Client + logger *zap.Logger + + mu sync.RWMutex + keys map[string]string + lastFetched time.Time +} + +var _ Decryptor = (*MondayDecryptor)(nil) + +// minKeyRefetchInterval throttles the on-miss refetch so a stream of payloads +// referencing an unknown date cannot turn into a request flood. +const minKeyRefetchInterval = 30 * time.Second + +func NewMondayDecryptor(opts MondayDecryptorOptions) (*MondayDecryptor, error) { + if opts.StaticKey == "" && opts.KeysEndpoint == "" { + return nil, errors.New("pusher: either a static encryption key or a keys endpoint is required") + } + if opts.StaticKey != "" && opts.KeysEndpoint != "" { + return nil, errors.New("pusher: a static encryption key and a keys endpoint are mutually exclusive") + } + if opts.RefreshInterval <= 0 { + opts.RefreshInterval = time.Hour + } + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + logger := opts.Logger + if logger == nil { + logger = zap.NewNop() + } + + return &MondayDecryptor{ + opts: opts, + client: client, + logger: logger, + keys: map[string]string{}, + }, nil +} + +// Start fetches the key set once and then refreshes it until ctx is done. With a +// static key it does nothing: there is no key set to fetch or rotate. +func (d *MondayDecryptor) Start(ctx context.Context) error { + if d.opts.StaticKey != "" { + return nil + } + if err := d.fetchKeys(ctx); err != nil { + return err + } + + go func() { + ticker := time.NewTicker(d.opts.RefreshInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := d.fetchKeys(ctx); err != nil { + d.logger.Error("failed to refresh pusher encryption keys", zap.Error(err)) + } + } + } + }() + + return nil +} + +func (d *MondayDecryptor) Decrypt(payload []byte) ([]byte, error) { + var envelope encryptedEnvelope + if err := json.Unmarshal(payload, &envelope); err != nil { + // Not a JSON object, so it cannot be an encrypted envelope. + return payload, nil + } + if envelope.EncryptedBase64 == "" { + return payload, nil + } + + key, err := d.keyForDate(envelope.EncDate) + if err != nil { + return nil, err + } + + return decryptAESCBC([]byte(key), []byte(envelope.IV), envelope.EncryptedBase64) +} + +func (d *MondayDecryptor) keyForDate(date string) (string, error) { + if d.opts.StaticKey != "" { + return d.opts.StaticKey, nil + } + + d.mu.RLock() + key, ok := d.keys[date] + staleEnough := time.Since(d.lastFetched) > minKeyRefetchInterval + d.mu.RUnlock() + if ok { + return key, nil + } + if !staleEnough { + return "", fmt.Errorf("pusher: no encryption key for date %q", date) + } + + // The key set rotates daily, so an unknown date most likely means our cache is + // behind. Refetch once before giving up. + if err := d.fetchKeys(context.Background()); err != nil { + return "", fmt.Errorf("pusher: no encryption key for date %q and refresh failed: %w", date, err) + } + + d.mu.RLock() + defer d.mu.RUnlock() + key, ok = d.keys[date] + if !ok { + return "", fmt.Errorf("pusher: no encryption key for date %q", date) + } + return key, nil +} + +func (d *MondayDecryptor) fetchKeys(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.opts.KeysEndpoint, nil) + if err != nil { + return err + } + for name, value := range d.opts.Headers { + req.Header.Set(name, value) + } + + resp, err := d.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("pusher: encryption keys endpoint returned %d: %s", resp.StatusCode, truncate(string(body), 256)) + } + + var parsed struct { + Keys map[string]string `json:"pusher_enc_keys"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + // A 200 with a non-JSON body is almost always the monolith login page: the + // endpoint runs authenticate_user!, so a missing or expired session cookie + // produces HTML instead of keys. Log the response so that is visible. + d.logger.Error("encryption keys response is not JSON", + zap.String("endpoint", d.opts.KeysEndpoint), + zap.Int("status", resp.StatusCode), + zap.String("content_type", resp.Header.Get("Content-Type")), + zap.Strings("request_headers_sent", headerNames(d.opts.Headers)), + zap.String("body", truncate(string(body), 2048)), + zap.Error(err), + ) + return fmt.Errorf("pusher: could not parse encryption keys response (status %d, content-type %q, body %s): %w", + resp.StatusCode, resp.Header.Get("Content-Type"), truncate(string(body), 512), err) + } + if len(parsed.Keys) == 0 { + return errors.New("pusher: encryption keys response contained no keys") + } + + d.mu.Lock() + d.keys = parsed.Keys + d.lastFetched = time.Now() + d.mu.Unlock() + + return nil +} + +// decryptAESCBC mirrors the monday web client, which calls +// CryptoJS.AES.decrypt(ciphertext, CryptoJS.enc.Utf8.parse(key), {iv: CryptoJS.enc.Utf8.parse(iv), mode: CBC}). +// Passing a WordArray as the key makes CryptoJS use it verbatim, so there is no +// EVP key derivation and no "Salted__" header: key and IV are the raw UTF-8 +// bytes of their strings. +func decryptAESCBC(key, iv []byte, ciphertextBase64 string) ([]byte, error) { + switch len(key) { + case 16, 24, 32: + default: + return nil, fmt.Errorf("pusher: encryption key must be 16, 24 or 32 bytes, got %d", len(key)) + } + if len(iv) != aes.BlockSize { + return nil, fmt.Errorf("pusher: iv must be %d bytes, got %d", aes.BlockSize, len(iv)) + } + + ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64) + if err != nil { + return nil, fmt.Errorf("pusher: could not base64 decode payload: %w", err) + } + if len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, fmt.Errorf("pusher: ciphertext length %d is not a multiple of the block size", len(ciphertext)) + } + + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + plaintext := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plaintext, ciphertext) + + return removePKCS7Padding(plaintext) +} + +func removePKCS7Padding(data []byte) ([]byte, error) { + padding := int(data[len(data)-1]) + if padding == 0 || padding > aes.BlockSize || padding > len(data) { + return nil, fmt.Errorf("pusher: invalid padding length %d", padding) + } + for _, b := range data[len(data)-padding:] { + if int(b) != padding { + return nil, errors.New("pusher: invalid padding bytes") + } + } + return data[:len(data)-padding], nil +} + +// headerNames lists the header names of a request, without their values: the +// values carry a session credential and must not reach a log. +func headerNames(headers map[string]string) []string { + names := make([]string, 0, len(headers)) + for name := range headers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/router/internal/pusherclient/protocol_error.go b/router/internal/pusherclient/protocol_error.go new file mode 100644 index 0000000000..ec8a4a6b51 --- /dev/null +++ b/router/internal/pusherclient/protocol_error.go @@ -0,0 +1,50 @@ +package pusherclient + +import ( + "encoding/json" + "fmt" +) + +// ProtocolError is a pusher:error frame. +// +// The code ranges are defined by the Pusher protocol: +// +// 4000-4099 the connection must not be retried with the same parameters +// 4100-4199 reconnect after a backoff +// 4200-4299 reconnect immediately +type ProtocolError struct { + Code int + Message string +} + +func (e *ProtocolError) Error() string { + return fmt.Sprintf("pusher: protocol error %d: %s", e.Code, e.Message) +} + +// ShouldReconnect reports whether reconnecting can succeed. Codes below 4100 +// signal a client or configuration fault, such as an unknown app key, so +// retrying with the same options is pointless. Codes without a range (0) are +// treated as retryable because they carry no guidance. +func (e *ProtocolError) ShouldReconnect() bool { + return e.Code < 4000 || e.Code >= 4100 +} + +func parseProtocolError(raw json.RawMessage) *ProtocolError { + protoErr := &ProtocolError{} + + var payload struct { + Code *int `json:"code"` + Message string `json:"message"` + } + if err := unmarshalFrameData(raw, &payload); err != nil { + protoErr.Message = string(raw) + return protoErr + } + + if payload.Code != nil { + protoErr.Code = *payload.Code + } + protoErr.Message = payload.Message + + return protoErr +} diff --git a/router/internal/pusherclient/subscription.go b/router/internal/pusherclient/subscription.go new file mode 100644 index 0000000000..a55326d070 --- /dev/null +++ b/router/internal/pusherclient/subscription.go @@ -0,0 +1,128 @@ +package pusherclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "sync" + + "go.uber.org/zap" +) + +// Subscription is a single consumer of one channel. Several subscriptions can +// share a channel; the underlying Pusher subscription is created once and +// removed when the last subscription is closed. +type Subscription struct { + client *Client + channel string + events chan Event + + closeOnce sync.Once +} + +// Channel returns the subscribed channel name. +func (s *Subscription) Channel() string { + return s.channel +} + +// Events returns the stream of events. The channel is closed on Unsubscribe. +func (s *Subscription) Events() <-chan Event { + return s.events +} + +// deliver hands an event to the consumer without blocking the read loop. A slow +// consumer loses events rather than stalling every other channel on the socket. +func (s *Subscription) deliver(evt Event, logger *zap.Logger) { + select { + case s.events <- evt: + default: + logger.Warn("dropping pusher event because the subscriber is not keeping up", + zap.String("channel", evt.Channel), zap.String("event", evt.Name)) + } +} + +// Unsubscribe removes this consumer. When it was the last one for the channel, a +// pusher:unsubscribe is sent. +func (s *Subscription) Unsubscribe() { + s.closeOnce.Do(func() { + last := s.client.removeSubscription(s) + if last { + s.client.sendUnsubscribe(s.channel) + } + close(s.events) + }) +} + +// Subscribe registers a consumer for the given channel. It returns as soon as +// the subscribe frame has been sent; the subscription confirmation is handled +// asynchronously, and a rejection is logged. +func (c *Client) Subscribe(ctx context.Context, channel string) (*Subscription, error) { + if channel == "" { + return nil, errors.New("pusher: channel must not be empty") + } + + sub := &Subscription{ + client: c, + channel: channel, + events: make(chan Event, c.opts.EventBufferSize), + } + + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, errors.New("pusher: client is closed") + } + first := len(c.subs[channel]) == 0 + c.subs[channel] = append(c.subs[channel], sub) + sess := c.session + c.mu.Unlock() + + // Only the first subscription for a channel has to talk to the server. If + // there is no live session, the supervisor subscribes on the next connect. + if first && sess != nil { + if err := c.sendSubscribe(ctx, sess, channel); err != nil { + c.removeSubscription(sub) + return nil, fmt.Errorf("pusher: could not subscribe to channel %q: %w", channel, err) + } + } + + return sub, nil +} + +// removeSubscription drops the subscription and reports whether the channel has +// no consumers left. +func (c *Client) removeSubscription(sub *Subscription) bool { + c.mu.Lock() + defer c.mu.Unlock() + + subs := c.subs[sub.channel] + if idx := slices.Index(subs, sub); idx >= 0 { + subs = slices.Delete(subs, idx, idx+1) + } + if len(subs) == 0 { + delete(c.subs, sub.channel) + return true + } + c.subs[sub.channel] = subs + + return false +} + +func (c *Client) sendUnsubscribe(channel string) { + c.mu.Lock() + sess := c.session + c.mu.Unlock() + if sess == nil { + return + } + + encoded, err := json.Marshal(map[string]string{"channel": channel}) + if err != nil { + return + } + if err := sess.send(frame{Event: eventUnsubscribe, Data: encoded}); err != nil { + c.logger.Debug("could not send pusher unsubscribe", zap.String("channel", channel), zap.Error(err)) + } +} diff --git a/router/pkg/config/config.go b/router/pkg/config/config.go index 896858f2aa..9638e1cc84 100644 --- a/router/pkg/config/config.go +++ b/router/pkg/config/config.go @@ -483,7 +483,12 @@ type EngineExecutionConfiguration struct { MaxConcurrentResolvers int `envDefault:"1024" env:"ENGINE_MAX_CONCURRENT_RESOLVERS" yaml:"max_concurrent_resolvers,omitempty"` EnableNetPoll bool `envDefault:"true" env:"ENGINE_ENABLE_NET_POLL" yaml:"enable_net_poll"` - ExecutionPlanCacheSize int64 `envDefault:"1024" env:"ENGINE_EXECUTION_PLAN_CACHE_SIZE" yaml:"execution_plan_cache_size,omitempty"` + ExecutionPlanCacheSize int64 `envDefault:"1024" env:"ENGINE_EXECUTION_PLAN_CACHE_SIZE" yaml:"execution_plan_cache_size,omitempty"` + // DisableSizeAwarePlanCache forces the execution-plan cache back to count-based eviction + // even when mondaytweaks.SizeAwarePlanCache is enabled. It is set programmatically (tests, + // or a targeted per-router rollback) and has no env/yaml binding; production leaves it + // false and follows the mondaytweaks default. See mondaytweaks.SizeAwarePlanCache. + DisableSizeAwarePlanCache bool `yaml:"-"` SlowPlanCacheSize int64 `envDefault:"300" env:"ENGINE_SLOW_PLAN_CACHE_SIZE" yaml:"slow_plan_cache_size,omitempty"` SlowPlanCacheThreshold time.Duration `envDefault:"100ms" env:"ENGINE_SLOW_PLAN_CACHE_THRESHOLD" yaml:"slow_plan_cache_threshold,omitempty"` MinifySubgraphOperations bool `envDefault:"true" env:"ENGINE_MINIFY_SUBGRAPH_OPERATIONS" yaml:"minify_subgraph_operations"` @@ -867,10 +872,91 @@ func (r RedisEventSource) GetID() string { return r.ID } +// PusherEncryptionConfiguration configures the decryption of monday.com's +// encrypted Pusher channels ("private-enc_" prefix), which use a monday-specific +// scheme rather than Pusher's native end-to-end encryption. +type PusherEncryptionConfiguration struct { + Enabled bool `yaml:"enabled"` + // EncryptionKey decrypts every payload regardless of its enc_date. It replaces + // keys_endpoint, and the two are mutually exclusive. Intended for local + // development, where the key endpoint requires a monolith session; in production + // the keys rotate daily, so a pinned key stops working after a rotation. + EncryptionKey string `yaml:"encryption_key,omitempty"` + // KeysEndpoint answers with {"pusher_enc_keys": {"YYYY-MM-DD": ""}}. + KeysEndpoint string `yaml:"keys_endpoint,omitempty"` + // RefreshInterval is how often the key set is refetched. The keys rotate daily. + RefreshInterval time.Duration `yaml:"refresh_interval,omitempty"` +} + +type PusherEventSource struct { + ID string `yaml:"id,omitempty"` + AppKey string `yaml:"app_key,omitempty"` + // Cluster is the Pusher cluster, e.g. "mt1". Ignored when ws_url is set. + Cluster string `yaml:"cluster,omitempty"` + // WSURL overrides the derived wss://ws-.pusher.com endpoint. + WSURL string `yaml:"ws_url,omitempty"` + // AuthEndpoint signs private and presence channel subscriptions. + AuthEndpoint string `yaml:"auth_endpoint,omitempty"` + // AppSecret makes the router sign private channel subscriptions itself, as + // HMAC-SHA256 of ":" under the secret, instead of calling + // auth_endpoint. The two are mutually exclusive. + // + // This skips the monolith entirely, so no session cookie is needed and reconnects + // keep working. It also skips the per-user permission check that /pusher/auth + // performs: the router can then subscribe to any channel of the app, regardless of + // who issued the GraphQL request. + AppSecret string `yaml:"app_secret,omitempty"` + // AuthHeaders are static headers sent with every authorization and encryption key + // request. Use them only for headers that are not specific to a user; a user + // session credential belongs in auth_headers_from_request. + AuthHeaders map[string]string `yaml:"auth_headers,omitempty"` + // AuthHeadersFromRequest lists header names forwarded from the incoming GraphQL + // request to the authorization and encryption key requests, e.g. ["Cookie"]. + // The auth endpoint authorizes a channel for the user behind the credential, so a + // per-user credential must travel with the subscription rather than be configured + // up front. The router keeps one Pusher connection per distinct credential. + // + // The listed headers must also be propagated to this subgraph by the header + // propagation rules; the router only forwards headers those rules produced. + AuthHeadersFromRequest []string `yaml:"auth_headers_from_request,omitempty"` + Encryption PusherEncryptionConfiguration `yaml:"encryption,omitempty"` + // EntityMappings rewrite a channel payload into an entity representation before it + // reaches the resolver, so the router resolves the requested fields from the owning + // subgraph instead of expecting them in the event itself. Without a mapping for a + // field, its payload is forwarded unchanged. + EntityMappings []PusherEntityMapping `yaml:"entity_mappings,omitempty"` +} + +// PusherEntityMapping turns the payload of one subscription field into +// {"__typename": "", "": ""}. +// +// monday's Pusher payloads are change notifications carrying the whole changed +// record, whose field names do not match the federated schema. The resolver only +// needs the entity key, so the payload is reduced to it. +type PusherEntityMapping struct { + // FieldName is the subscription root field this mapping applies to, e.g. + // "boardUpdated". + FieldName string `yaml:"field_name,omitempty"` + // TypeName is the entity type name emitted as __typename, e.g. "Board". + TypeName string `yaml:"type_name,omitempty"` + // KeyField is the field of the representation the value is written to. Defaults to + // "id". + KeyField string `yaml:"key_field,omitempty"` + // IDFrom lists the payload keys holding the entity key, in order of preference, + // e.g. ["board_id"] or ["pulse_id", "item_id"]. Dots address nested objects. + // The first key present and non-null wins. + IDFrom []string `yaml:"id_from,omitempty"` +} + +func (p PusherEventSource) GetID() string { + return p.ID +} + type EventProviders struct { - Nats []NatsEventSource `yaml:"nats,omitempty"` - Kafka []KafkaEventSource `yaml:"kafka,omitempty"` - Redis []RedisEventSource `yaml:"redis,omitempty"` + Nats []NatsEventSource `yaml:"nats,omitempty"` + Kafka []KafkaEventSource `yaml:"kafka,omitempty"` + Redis []RedisEventSource `yaml:"redis,omitempty"` + Pusher []PusherEventSource `yaml:"pusher,omitempty"` } type EventsConfiguration struct { diff --git a/router/pkg/config/config.schema.json b/router/pkg/config/config.schema.json index 381f061089..325a7e935e 100644 --- a/router/pkg/config/config.schema.json +++ b/router/pkg/config/config.schema.json @@ -3314,6 +3314,128 @@ } } } + }, + "pusher": { + "type": "array", + "description": "Configuration used by the EDFS provider to subscribe to Pusher Channels.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "app_key"], + "properties": { + "id": { + "type": "string", + "description": "The provider ID. The provider ID is used to identify the provider in the configuration." + }, + "app_key": { + "type": "string", + "description": "The Pusher application key." + }, + "cluster": { + "type": "string", + "description": "The Pusher cluster, e.g. \"mt1\". Used to build the WebSocket URL when ws_url is not set." + }, + "ws_url": { + "type": "string", + "description": "Overrides the WebSocket URL derived from the cluster. Useful for tests and self-hosted gateways." + }, + "auth_endpoint": { + "type": "string", + "description": "The URL used to authorize private and presence channels, e.g. \"https://example.monday.com/pusher/auth\"." + }, + "app_secret": { + "type": "string", + "minLength": 1, + "description": "The Pusher application secret. When set, the router signs private channel subscriptions itself (HMAC-SHA256 of \":\") instead of calling auth_endpoint, so no monolith session is needed. Mutually exclusive with auth_endpoint. This also skips the per-user permission check the auth endpoint performs." + }, + "auth_headers": { + "type": "object", + "description": "Static headers sent with every authorization and encryption key request. Use only for headers that are not specific to a user; put a user session credential in auth_headers_from_request instead.", + "additionalProperties": { + "type": "string" + } + }, + "auth_headers_from_request": { + "type": "array", + "description": "Header names forwarded from the incoming GraphQL request to the authorization and encryption key requests, e.g. [\"Cookie\"]. The auth endpoint authorizes channels for the user behind the credential, so the credential must travel with the subscription. The router keeps one Pusher connection per distinct credential. The listed headers must also be propagated to this subgraph by the header propagation rules.", + "items": { + "type": "string" + } + }, + "entity_mappings": { + "type": "array", + "description": "Rewrite the payload of a subscription field into an entity representation, so the router resolves the requested fields from the owning subgraph instead of expecting them in the event. A field without a mapping has its payload forwarded unchanged.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["field_name", "type_name", "id_from"], + "properties": { + "field_name": { + "type": "string", + "minLength": 1, + "description": "The subscription root field this mapping applies to, e.g. \"boardUpdated\"." + }, + "type_name": { + "type": "string", + "minLength": 1, + "description": "The entity type name emitted as __typename, e.g. \"Board\"." + }, + "key_field": { + "type": "string", + "minLength": 1, + "description": "The field of the representation the key is written to.", + "default": "id" + }, + "id_from": { + "type": "array", + "minItems": 1, + "description": "The payload keys holding the entity key, in order of preference, e.g. [\"board_id\"]. Dots address nested objects. The first key present and not null wins.", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "encryption": { + "type": "object", + "additionalProperties": false, + "description": "Payload decryption for monday encrypted channels (private-enc_ prefix).", + "properties": { + "enabled": { + "type": "boolean", + "description": "If enabled, encrypted payloads are decrypted before they are delivered to the subscription.", + "default": false + }, + "encryption_key": { + "type": "string", + "minLength": 1, + "description": "A single encryption key used for every payload, whatever its enc_date. Provide it instead of keys_endpoint. Intended for local development: the monday keys rotate daily, so a pinned key stops working after a rotation." + }, + "keys_endpoint": { + "type": "string", + "description": "The URL returning the encryption keys, e.g. \"https://example.monday.com/pusher/get_encryption_keys\"." + }, + "refresh_interval": { + "type": "string", + "format": "go-duration", + "description": "How often the encryption keys are refreshed. The period is specified as a string with a number and a unit, e.g. 10s, 1m, 1h.", + "duration": { + "minimum": "1s" + }, + "default": "1h" + } + }, + "not": { + "required": ["encryption_key", "keys_endpoint"] + } + } + }, + "not": { + "required": ["app_secret", "auth_endpoint"] + } + } } } }, diff --git a/router/pkg/config/testdata/config_defaults.json b/router/pkg/config/testdata/config_defaults.json index 79726489c9..f8e2335e2f 100644 --- a/router/pkg/config/testdata/config_defaults.json +++ b/router/pkg/config/testdata/config_defaults.json @@ -513,6 +513,7 @@ "MaxConcurrentResolvers": 1024, "EnableNetPoll": true, "ExecutionPlanCacheSize": 1024, + "DisableSizeAwarePlanCache": false, "SlowPlanCacheSize": 300, "SlowPlanCacheThreshold": 100000000, "MinifySubgraphOperations": true, diff --git a/router/pkg/config/testdata/config_full.json b/router/pkg/config/testdata/config_full.json index 70d3fa82e4..aca327d2e0 100644 --- a/router/pkg/config/testdata/config_full.json +++ b/router/pkg/config/testdata/config_full.json @@ -981,6 +981,7 @@ "MaxConcurrentResolvers": 32, "EnableNetPoll": true, "ExecutionPlanCacheSize": 1024, + "DisableSizeAwarePlanCache": false, "SlowPlanCacheSize": 300, "SlowPlanCacheThreshold": 100000000, "MinifySubgraphOperations": true, diff --git a/router/pkg/metric/stream_metric_store.go b/router/pkg/metric/stream_metric_store.go index 361f49388d..1e46d1c712 100644 --- a/router/pkg/metric/stream_metric_store.go +++ b/router/pkg/metric/stream_metric_store.go @@ -15,9 +15,10 @@ import ( type ProviderType string const ( - ProviderTypeKafka ProviderType = "kafka" - ProviderTypeNats ProviderType = "nats" - ProviderTypeRedis ProviderType = "redis" + ProviderTypeKafka ProviderType = "kafka" + ProviderTypeNats ProviderType = "nats" + ProviderTypeRedis ProviderType = "redis" + ProviderTypePusher ProviderType = "pusher" ) // StreamsEvent carries the values for stream metrics attributes. diff --git a/router/pkg/mondaytweaks/mondaytweaks.go b/router/pkg/mondaytweaks/mondaytweaks.go new file mode 100644 index 0000000000..befafb911f --- /dev/null +++ b/router/pkg/mondaytweaks/mondaytweaks.go @@ -0,0 +1,62 @@ +// Package mondaytweaks defines runtime-configurable feature flags for monday.com-specific +// behavioural overrides in the cosmo router. Keep only non-memory-leak behavior +// and performance toggles here; memory-reload cleanup notes live in +// `wiki/reference/cosmo-router-reload-memory-benchmark-tooling`. +// +// All flags are backed by sync/atomic so they are safe to read from concurrent +// request-handling goroutines and to write from the ignite provisioning goroutine. +// Use Flag.Store(v) to change a value (e.g. from an ignite module at boot) and +// Flag.Load() in production code paths. +package mondaytweaks + +import "sync/atomic" + +var ( + // DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled sets PingInterval=0 on + // upstream subscription clients when client-facing websocket is disabled. + // Re-enabled: client-facing websockets are disabled in prod (websocket.enabled: false), + // yet upstream subscription clients still run ping loops. A goroutine profile showed + // WSTransport.pingLoop at ~65% of all goroutines (1.5M) accumulating across reloads; + // zeroing PingInterval when client WS is disabled stops that leak. + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled atomic.Bool + + // ExposeOperationSubgraphFetchCountContextField enables the + // operation_subgraph_fetch_count access-log context field. + ExposeOperationSubgraphFetchCountContextField atomic.Bool + + // PlanCacheSizeAwareBudgetPerSlotBytes is the per-configured-slot byte budget used to + // derive the size-aware execution-plan-cache MaxCost when SizeAwarePlanCache is enabled. + PlanCacheSizeAwareBudgetPerSlotBytes atomic.Int64 + + // SizeAwarePlanCache — monday perf tweak (#7 OPEN); not an upstream memory-leak fix. + SizeAwarePlanCache atomic.Bool + + // DisableFieldDependencies skips the per-fetch CoordinateDependencies allocation during + // planning. CoordinateDependencies ([]FetchDependency per fetch) are query-plan metadata + // used only for observability/visualisation — not read during request execution, tainted- + // entity filtering, or subgraph propagation. With 200 unique cached operations this saves + // ~30-40% of per-plan heap above the schema baseline (~40 MiB / 200 plans in the + // cardinality-high benchmark, 500 KiB heap / ~1 MiB RSS per plan). + // + // Corresponds to plan.Configuration.DisableIncludeFieldDependencies. The flag is read + // once in factoryresolver.Load() so it takes effect on the next config reload. + DisableFieldDependencies atomic.Bool + // PlanCacheCostCountsPlanTree enables the fetch-tree + response-field walk in the + // execution-plan-cache cost estimator (estimatePlanCacheCost). When enabled, the estimate + // adds ~32 KiB per subgraph fetch and ~768 B per response field on top of the AST-only + // accounting, so the size-aware cache evicts by the heap the prepared plan tree actually + // retains rather than by operation-document size alone. Only has an effect when + // SizeAwarePlanCache is enabled. + PlanCacheCostCountsPlanTree atomic.Bool + +) + +func init() { + DisableUpstreamSubscriptionPingWhenClientWebSocketDisabled.Store(true) + ExposeOperationSubgraphFetchCountContextField.Store(true) + PlanCacheSizeAwareBudgetPerSlotBytes.Store(8 * 1024) + SizeAwarePlanCache.Store(true) + DisableFieldDependencies.Store(true) + PlanCacheCostCountsPlanTree.Store(true) + +} diff --git a/router/pkg/pubsub/datasource/provider.go b/router/pkg/pubsub/datasource/provider.go index c1e9fea184..4dc712691e 100644 --- a/router/pkg/pubsub/datasource/provider.go +++ b/router/pkg/pubsub/datasource/provider.go @@ -52,9 +52,10 @@ type ProviderBuilder[P, E any] interface { type ProviderType string const ( - ProviderTypeNats ProviderType = "nats" - ProviderTypeKafka ProviderType = "kafka" - ProviderTypeRedis ProviderType = "redis" + ProviderTypeNats ProviderType = "nats" + ProviderTypeKafka ProviderType = "kafka" + ProviderTypeRedis ProviderType = "redis" + ProviderTypePusher ProviderType = "pusher" ) // StreamEvents is a list of stream events coming from or going to event providers. diff --git a/router/pkg/pubsub/datasource/request_header.go b/router/pkg/pubsub/datasource/request_header.go new file mode 100644 index 0000000000..753da101db --- /dev/null +++ b/router/pkg/pubsub/datasource/request_header.go @@ -0,0 +1,29 @@ +package datasource + +import ( + "context" + "net/http" +) + +type requestHeaderContextKey struct{} + +// WithRequestHeader attaches the header set the resolver built for this subscription +// to the context handed to Adapter.Subscribe. The header set is the result of the +// subgraph header propagation rules, so it only contains headers the router was +// explicitly configured to forward. +// +// Adapters that authenticate per subscriber (Pusher) read the credential from here. +// Adapters that authenticate once per provider (NATS, Kafka, Redis) ignore it. +func WithRequestHeader(ctx context.Context, header http.Header) context.Context { + if header == nil { + return ctx + } + return context.WithValue(ctx, requestHeaderContextKey{}, header) +} + +// RequestHeaderFromContext returns the propagated request header, or nil when the +// context carries none. +func RequestHeaderFromContext(ctx context.Context) http.Header { + header, _ := ctx.Value(requestHeaderContextKey{}).(http.Header) + return header +} diff --git a/router/pkg/pubsub/datasource/subscription_datasource.go b/router/pkg/pubsub/datasource/subscription_datasource.go index 2ead86bf38..b872df94f6 100644 --- a/router/pkg/pubsub/datasource/subscription_datasource.go +++ b/router/pkg/pubsub/datasource/subscription_datasource.go @@ -52,7 +52,12 @@ func (s *PubSubSubscriptionDataSource[C]) Start(ctx *resolve.Context, header htt zap.String("field_name", conf.RootFieldName()), ) - return s.pubSub.Subscribe(ctx.Context(), conf, NewSubscriptionEventUpdater(conf, s.hooks, updater, logger, s.eventBuilder)) + // The header set is part of the trigger identity (the resolver hashes it into the + // trigger ID), so adapters that authenticate per subscriber can use it without two + // subscribers sharing a trigger. + subscribeCtx := WithRequestHeader(ctx.Context(), header) + + return s.pubSub.Subscribe(subscribeCtx, conf, NewSubscriptionEventUpdater(conf, s.hooks, updater, logger, s.eventBuilder)) } func (s *PubSubSubscriptionDataSource[C]) SubscriptionOnStart(ctx resolve.StartupHookContext, input []byte) (err error) { diff --git a/router/pkg/pubsub/pubsub.go b/router/pkg/pubsub/pubsub.go index 3ccd634fdf..49d3e30dfe 100644 --- a/router/pkg/pubsub/pubsub.go +++ b/router/pkg/pubsub/pubsub.go @@ -13,6 +13,7 @@ import ( pubsub_datasource "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" "github.com/wundergraph/cosmo/router/pkg/pubsub/kafka" "github.com/wundergraph/cosmo/router/pkg/pubsub/nats" + "github.com/wundergraph/cosmo/router/pkg/pubsub/pusher" "github.com/wundergraph/cosmo/router/pkg/pubsub/redis" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "go.uber.org/zap" @@ -73,13 +74,43 @@ func BuildProvidersAndDataSources( var pubSubProviders []pubsub_datasource.Provider var outs []plan.DataSource + // Pusher provider IDs declared in the router configuration. A kafka event whose + // provider ID names one of them is served by the pusher provider instead, with the + // kafka topics used as pusher channels. This lets a subgraph declare a pusher + // subscription with the published @edfs__kafkaSubscribe directive, so no custom + // composition build is needed. + pusherProviderIDs := make(map[string]struct{}, len(config.Providers.Pusher)) + for _, provider := range config.Providers.Pusher { + pusherProviderIDs[provider.ID] = struct{}{} + } + // initialize Kafka providers and data sources kafkaBuilder := kafka.NewProviderBuilder(ctx, logger, hostName, routerListenAddr) kafkaDsConfsWithEvents := []dsConfAndEvents[*nodev1.KafkaEventConfiguration]{} - for _, dsConf := range dsConfs { + // Kafka events redirected to the pusher provider, keyed by data source index. + redirectedToPusher := make(map[int][]*nodev1.PusherEventConfiguration, len(dsConfs)) + for i, dsConf := range dsConfs { + kafkaEvents := make([]*nodev1.KafkaEventConfiguration, 0, len(dsConf.Configuration.GetCustomEvents().GetKafka())) + for _, event := range dsConf.Configuration.GetCustomEvents().GetKafka() { + providerID := event.GetEngineEventConfiguration().GetProviderId() + if _, ok := pusherProviderIDs[providerID]; !ok { + kafkaEvents = append(kafkaEvents, event) + continue + } + logger.Info("serving kafka event with the pusher provider", + zap.String("provider_id", providerID), + zap.String("type_name", event.GetEngineEventConfiguration().GetTypeName()), + zap.String("field_name", event.GetEngineEventConfiguration().GetFieldName()), + zap.Strings("channels", event.GetTopics()), + ) + redirectedToPusher[i] = append(redirectedToPusher[i], &nodev1.PusherEventConfiguration{ + EngineEventConfiguration: event.GetEngineEventConfiguration(), + Channels: event.GetTopics(), + }) + } kafkaDsConfsWithEvents = append(kafkaDsConfsWithEvents, dsConfAndEvents[*nodev1.KafkaEventConfiguration]{ dsConf: &dsConf, - events: dsConf.Configuration.GetCustomEvents().GetKafka(), + events: kafkaEvents, }) } kafkaPubSubProviders, kafkaOuts, err := build(ctx, kafkaBuilder, config.Providers.Kafka, kafkaDsConfsWithEvents, store, hooks, logger, config.SkipUnavailableProviders) @@ -127,6 +158,28 @@ func BuildProvidersAndDataSources( } outs = append(outs, redisOuts...) + // initialize Pusher providers and data sources + pusherBuilder := pusher.NewProviderBuilder(ctx, logger, hostName, routerListenAddr) + pusherDsConfsWithEvents := []dsConfAndEvents[*nodev1.PusherEventConfiguration]{} + for i, dsConf := range dsConfs { + events := dsConf.Configuration.GetCustomEvents().GetPusher() + if redirected := redirectedToPusher[i]; len(redirected) > 0 { + events = append(append([]*nodev1.PusherEventConfiguration{}, events...), redirected...) + } + pusherDsConfsWithEvents = append(pusherDsConfsWithEvents, dsConfAndEvents[*nodev1.PusherEventConfiguration]{ + dsConf: &dsConf, + events: events, + }) + } + pusherPubSubProviders, pusherOuts, err := build(ctx, pusherBuilder, config.Providers.Pusher, pusherDsConfsWithEvents, store, hooks, logger, config.SkipUnavailableProviders) + if err != nil { + return nil, nil, err + } + for _, provider := range pusherPubSubProviders { + pubSubProviders = append(pubSubProviders, provider) + } + outs = append(outs, pusherOuts...) + return pubSubProviders, outs, nil } diff --git a/router/pkg/pubsub/pusher/adapter.go b/router/pkg/pubsub/pusher/adapter.go new file mode 100644 index 0000000000..a63f2830fc --- /dev/null +++ b/router/pkg/pubsub/pusher/adapter.go @@ -0,0 +1,395 @@ +package pusher + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "sort" + "strings" + "sync" + "time" + + "github.com/wundergraph/cosmo/router/internal/pusherclient" + "github.com/wundergraph/cosmo/router/pkg/config" + "github.com/wundergraph/cosmo/router/pkg/metric" + "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "go.uber.org/zap" +) + +const pusherReceive = "receive" + +// startupTimeout bounds a connection attempt so a black-holed broker cannot block +// the first subscription for longer than the caller's own timeout. +const startupTimeout = 3 * time.Second + +var _ datasource.Adapter = (*ProviderAdapter)(nil) + +// pooledClient is one Pusher WebSocket connection plus the credential it was +// authorized with. Channel subscriptions on a Pusher connection are authorized once +// against a single socket_id, so subscribers with different credentials cannot share +// a connection: one connection exists per distinct credential. +type pooledClient struct { + client *pusherclient.Client + // refs counts the live channel subscriptions using this connection. The + // connection is closed when it drops to zero. + refs int +} + +// ProviderAdapter subscribes to Pusher channels. It owns a pool of WebSocket +// connections keyed by the forwarded credential. +type ProviderAdapter struct { + ctx context.Context + cancel context.CancelFunc + logger *zap.Logger + source config.PusherEventSource + streamMetricStore metric.StreamMetricStore + // skipUnavailable mirrors events.skip_unavailable_providers. When true, a failed + // initial connection does not fail the subscription; the client reconnects in the + // background and the affected fields recover without a restart. + skipUnavailable bool + + // entityMapper rewrites payloads into entity representations. Nil when the provider + // configures no mapping, which forwards payloads unchanged. + entityMapper *entityMapper + + mu sync.Mutex + clients map[string]*pooledClient + closed bool + + closeWg sync.WaitGroup +} + +func NewProviderAdapter(ctx context.Context, logger *zap.Logger, source config.PusherEventSource, opts datasource.ProviderOpts) (datasource.Adapter, error) { + mapper, err := newEntityMapper(source.EntityMappings) + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(ctx) + if logger == nil { + logger = zap.NewNop() + } + + store := opts.StreamMetricStore + if store == nil { + store = metric.NewNoopStreamMetricStore() + } + + return &ProviderAdapter{ + ctx: ctx, + cancel: cancel, + logger: logger, + source: source, + streamMetricStore: store, + skipUnavailable: opts.SkipUnavailableProviders, + entityMapper: mapper, + clients: make(map[string]*pooledClient), + }, nil +} + +// Startup does not connect: the credential used to authorize channels arrives with +// the subscription request, so connections are created on first use instead. +func (p *ProviderAdapter) Startup(ctx context.Context) error { + return nil +} + +func (p *ProviderAdapter) Shutdown(ctx context.Context) error { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil + } + p.closed = true + clients := make([]*pooledClient, 0, len(p.clients)) + for key, pooled := range p.clients { + clients = append(clients, pooled) + delete(p.clients, key) + } + p.mu.Unlock() + + // Cancel the context to stop the subscriptions + p.cancel() + + // Wait for the subscriptions to be closed + p.closeWg.Wait() + + var firstErr error + for _, pooled := range clients { + if err := pooled.client.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + + return firstErr +} + +// authHeaders merges the static headers with the headers forwarded from the incoming +// request, and returns a key identifying the resulting credential. Forwarded headers +// win over static ones. +func (p *ProviderAdapter) authHeaders(ctx context.Context) (headers map[string]string, credentialKey string) { + headers = make(map[string]string, len(p.source.AuthHeaders)+len(p.source.AuthHeadersFromRequest)) + for name, value := range p.source.AuthHeaders { + headers[name] = value + } + + requestHeader := datasource.RequestHeaderFromContext(ctx) + forwarded := make([]string, 0, len(p.source.AuthHeadersFromRequest)) + for _, name := range p.source.AuthHeadersFromRequest { + value := requestHeader.Get(name) + if value == "" { + continue + } + headers[http.CanonicalHeaderKey(name)] = value + forwarded = append(forwarded, http.CanonicalHeaderKey(name)+": "+value) + } + + if len(forwarded) == 0 { + // No per-request credential: every subscriber shares the static-header client. + return headers, "" + } + + // The key is hashed so the credential itself never reaches a map key that could be + // logged or reported. + sort.Strings(forwarded) + sum := sha256.Sum256([]byte(strings.Join(forwarded, "\n"))) + return headers, hex.EncodeToString(sum[:]) +} + +// acquireClient returns the connection for the given credential, creating and +// connecting it on first use. The caller must call releaseClient once per successful +// call. +func (p *ProviderAdapter) acquireClient(ctx context.Context, credentialKey string, headers map[string]string) (*pusherclient.Client, error) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return nil, datasource.NewError("pusher provider is shut down", nil) + } + if pooled, ok := p.clients[credentialKey]; ok { + pooled.refs++ + p.mu.Unlock() + return pooled.client, nil + } + p.mu.Unlock() + + client, err := p.newClient(ctx, headers) + if err != nil { + return nil, err + } + + p.mu.Lock() + if p.closed { + p.mu.Unlock() + _ = client.Close() + return nil, datasource.NewError("pusher provider is shut down", nil) + } + // Another subscription may have created a connection for the same credential + // concurrently; keep the one already in the pool and discard ours. + if pooled, ok := p.clients[credentialKey]; ok { + pooled.refs++ + p.mu.Unlock() + _ = client.Close() + return pooled.client, nil + } + p.clients[credentialKey] = &pooledClient{client: client, refs: 1} + p.mu.Unlock() + + return client, nil +} + +func (p *ProviderAdapter) releaseClient(credentialKey string) { + p.mu.Lock() + pooled, ok := p.clients[credentialKey] + if !ok { + p.mu.Unlock() + return + } + pooled.refs-- + if pooled.refs > 0 { + p.mu.Unlock() + return + } + delete(p.clients, credentialKey) + p.mu.Unlock() + + if err := pooled.client.Close(); err != nil { + p.logger.Debug("closing idle pusher connection", zap.String("provider_id", p.source.ID), zap.Error(err)) + } +} + +// newClient builds and connects one Pusher connection for the given credential. The +// credential is retained by the client for its whole lifetime: a reconnect gets a new +// socket_id, so every channel must be re-authorized. +func (p *ProviderAdapter) newClient(ctx context.Context, headers map[string]string) (*pusherclient.Client, error) { + logger := p.logger.With(zap.String("provider_id", p.source.ID)) + + var decryptor pusherclient.Decryptor + if p.source.Encryption.Enabled { + mondayDecryptor, err := pusherclient.NewMondayDecryptor(pusherclient.MondayDecryptorOptions{ + StaticKey: p.source.Encryption.EncryptionKey, + KeysEndpoint: p.source.Encryption.KeysEndpoint, + Headers: headers, + RefreshInterval: p.source.Encryption.RefreshInterval, + Logger: logger, + }) + if err != nil { + return nil, err + } + // The key set is fetched here so a misconfigured endpoint fails on subscribe + // instead of silently emitting ciphertext on the first event. + if err := mondayDecryptor.Start(p.ctx); err != nil { + if !p.skipUnavailable { + return nil, err + } + logger.Error("could not load pusher encryption keys, events will not be decrypted until the keys become available", + zap.Error(err)) + } + decryptor = mondayDecryptor + } + + client, err := pusherclient.New(pusherclient.Options{ + AppKey: p.source.AppKey, + Cluster: p.source.Cluster, + WSURL: p.source.WSURL, + AuthEndpoint: p.source.AuthEndpoint, + AppSecret: p.source.AppSecret, + AuthHeaders: headers, + Decryptor: decryptor, + Logger: logger, + }) + if err != nil { + return nil, err + } + + connectCtx, cancel := context.WithTimeout(ctx, startupTimeout) + defer cancel() + + if err := client.Connect(connectCtx); err != nil { + if !p.skipUnavailable { + _ = client.Close() + return nil, err + } + // Lenient mode: keep the client. It reconnects in the background and + // subscribes the registered channels once the connection is up. + logger.Error("could not connect to pusher, retrying in the background", zap.Error(err)) + } + + return client, nil +} + +func (p *ProviderAdapter) Subscribe(ctx context.Context, conf datasource.SubscriptionEventConfiguration, updater datasource.SubscriptionEventUpdater) error { + subConf, ok := conf.(*SubscriptionEventConfiguration) + if !ok { + return datasource.NewError("subscription event not supported by pusher provider", nil) + } + + log := p.logger.With( + zap.String("provider_id", conf.ProviderID()), + zap.String("method", "subscribe"), + zap.Strings("channels", subConf.Channels), + ) + + headers, credentialKey := p.authHeaders(ctx) + + log.Debug("subscribing") + + client, err := p.acquireClient(ctx, credentialKey, headers) + if err != nil { + return datasource.NewError("failed to connect to pusher", err) + } + + subscriptions := make([]*pusherclient.Subscription, 0, len(subConf.Channels)) + for _, channel := range subConf.Channels { + subscription, err := client.Subscribe(ctx, channel) + if err != nil { + // Undo the subscriptions we already created so a partial failure does not + // leak channels on the shared connection. + for _, created := range subscriptions { + created.Unsubscribe() + } + p.releaseClient(credentialKey) + return datasource.NewError("failed to subscribe to pusher channel "+channel, err) + } + subscriptions = append(subscriptions, subscription) + } + + if len(subscriptions) == 0 { + p.releaseClient(credentialKey) + return nil + } + + // The pool reference taken by acquireClient is held until every channel goroutine + // of this subscription has stopped. + var channelsWg sync.WaitGroup + p.closeWg.Add(1) + go func() { + defer p.closeWg.Done() + channelsWg.Wait() + p.releaseClient(credentialKey) + }() + + for _, subscription := range subscriptions { + p.closeWg.Add(1) + channelsWg.Add(1) + + go func(subscription *pusherclient.Subscription) { + defer p.closeWg.Done() + defer channelsWg.Done() + defer subscription.Unsubscribe() + + events := subscription.Events() + + for { + select { + case evt, ok := <-events: + if !ok { + log.Debug("subscription closed, stopping", zap.String("message_channel", subscription.Channel())) + return + } + log.Debug("subscription update", + zap.String("message_channel", evt.Channel), + zap.String("event", evt.Name), + ) + p.streamMetricStore.Consume(ctx, metric.StreamsEvent{ + ProviderId: conf.ProviderID(), + StreamOperationName: pusherReceive, + ProviderType: metric.ProviderTypePusher, + DestinationName: evt.Channel, + }) + data, err := p.entityMapper.mapEvent(subConf.FieldName, evt.Data) + if err != nil { + // A payload that cannot be reduced to an entity key carries no + // usable update, so it is dropped rather than forwarded as-is: + // forwarding it would fail in the resolver instead, with less + // context about which event was at fault. + log.Error("could not map pusher event to an entity representation", + zap.String("message_channel", evt.Channel), + zap.String("event", evt.Name), + zap.Error(err), + ) + continue + } + updater.Update([]datasource.StreamEvent{ + &Event{evt: &MutableEvent{Data: data}}, + }) + case <-p.ctx.Done(): + // When the application context is done, we stop the subscription if it is not already done + log.Debug("application context done, stopping subscription") + return + case <-ctx.Done(): + // When the subscription context is done, we stop the subscription if it is not already done + log.Debug("subscription context done, stopping subscription") + return + } + } + }(subscription) + } + + return nil +} + +// Publish is not supported: monday's monolith is the only publisher to these +// channels, and the Pusher client protocol cannot publish at all. +func (p *ProviderAdapter) Publish(ctx context.Context, conf datasource.PublishEventConfiguration, events []datasource.StreamEvent) error { + return datasource.NewError("publish is not supported by the pusher provider", nil) +} diff --git a/router/pkg/pubsub/pusher/engine_datasource.go b/router/pkg/pubsub/pusher/engine_datasource.go new file mode 100644 index 0000000000..4f8f01c897 --- /dev/null +++ b/router/pkg/pubsub/pusher/engine_datasource.go @@ -0,0 +1,116 @@ +package pusher + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + + "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" +) + +type Event struct { + evt *MutableEvent +} + +func (e *Event) GetData() []byte { + if e.evt == nil { + return nil + } + return slices.Clone(e.evt.Data) +} + +func (e *Event) Clone() datasource.MutableStreamEvent { + return e.evt.Clone() +} + +type MutableEvent struct { + Data json.RawMessage `json:"data"` +} + +func (e *MutableEvent) GetData() []byte { + if e == nil { + return nil + } + return e.Data +} + +func (e *MutableEvent) SetData(data []byte) { + if e == nil { + return + } + e.Data = data +} + +func (e *MutableEvent) Clone() datasource.MutableStreamEvent { + if e == nil { + return nil + } + + return &MutableEvent{ + Data: slices.Clone(e.Data), + } +} + +// SubscriptionEventConfiguration contains configuration for subscription events +type SubscriptionEventConfiguration struct { + Provider string `json:"providerId"` + Channels []string `json:"channels"` + FieldName string `json:"rootFieldName"` +} + +// ProviderID returns the provider ID +func (s *SubscriptionEventConfiguration) ProviderID() string { + return s.Provider +} + +// ProviderType returns the provider type +func (s *SubscriptionEventConfiguration) ProviderType() datasource.ProviderType { + return datasource.ProviderTypePusher +} + +// RootFieldName returns the root field name +func (s *SubscriptionEventConfiguration) RootFieldName() string { + return s.FieldName +} + +// SubscriptionDataSource implements resolve.SubscriptionDataSource for Pusher +type SubscriptionDataSource struct { + pubSub datasource.Adapter +} + +func (s *SubscriptionDataSource) SubscriptionEventConfiguration(input []byte) datasource.SubscriptionEventConfiguration { + var subscriptionConfiguration SubscriptionEventConfiguration + err := json.Unmarshal(input, &subscriptionConfiguration) + if err != nil { + return nil + } + return &subscriptionConfiguration +} + +// Start starts the subscription +func (s *SubscriptionDataSource) Start(ctx *resolve.Context, header http.Header, input []byte, updater datasource.SubscriptionEventUpdater) error { + subConf := s.SubscriptionEventConfiguration(input) + if subConf == nil { + return fmt.Errorf("no subscription configuration found") + } + + conf, ok := subConf.(*SubscriptionEventConfiguration) + if !ok { + return fmt.Errorf("invalid subscription configuration") + } + + return s.pubSub.Subscribe(ctx.Context(), conf, updater) +} + +// LoadInitialData implements the interface method (not used for this subscription type) +func (s *SubscriptionDataSource) LoadInitialData(ctx context.Context) (initial []byte, err error) { + return nil, nil +} + +// Interface compliance checks +var _ datasource.SubscriptionEventConfiguration = (*SubscriptionEventConfiguration)(nil) +var _ datasource.StreamEvent = (*Event)(nil) +var _ datasource.MutableStreamEvent = (*MutableEvent)(nil) diff --git a/router/pkg/pubsub/pusher/engine_datasource_factory.go b/router/pkg/pubsub/pusher/engine_datasource_factory.go new file mode 100644 index 0000000000..93a321f208 --- /dev/null +++ b/router/pkg/pubsub/pusher/engine_datasource_factory.go @@ -0,0 +1,106 @@ +package pusher + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/buger/jsonparser" + "github.com/cespare/xxhash/v2" + "github.com/wundergraph/cosmo/router/pkg/pubsub/datasource" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "go.uber.org/zap" +) + +type EventType int + +const ( + EventTypeSubscribe EventType = iota +) + +// EngineDataSourceFactory implements the datasource.EngineDataSourceFactory interface for Pusher. +// Only subscriptions are supported. +type EngineDataSourceFactory struct { + PusherAdapter datasource.Adapter + + fieldName string + eventType EventType + channels []string + providerId string + logger *zap.Logger +} + +func (c *EngineDataSourceFactory) GetFieldName() string { + return c.fieldName +} + +// ResolveDataSource is only used for publishing, which Pusher does not support +func (c *EngineDataSourceFactory) ResolveDataSource() (resolve.DataSource, error) { + return nil, fmt.Errorf("failed to configure fetch: publishing is not supported for Pusher") +} + +// ResolveDataSourceInput is only used for publishing, which Pusher does not support +func (c *EngineDataSourceFactory) ResolveDataSourceInput(eventData []byte) (string, error) { + return "", fmt.Errorf("publishing is not supported for Pusher") +} + +// ResolveDataSourceSubscription returns the subscription data source +func (c *EngineDataSourceFactory) ResolveDataSourceSubscription() (datasource.SubscriptionDataSource, error) { + triggerHashInputFn := func(input []byte, xxh *xxhash.Digest) error { + val, _, _, err := jsonparser.Get(input, "channels") + if err != nil { + return err + } + + _, err = xxh.Write(val) + if err != nil { + return err + } + + val, _, _, err = jsonparser.Get(input, "providerId") + if err != nil { + return err + } + + _, err = xxh.Write(val) + return err + } + + eventCreateFn := func(data []byte) datasource.MutableStreamEvent { + return &MutableEvent{Data: data} + } + + return datasource.NewPubSubSubscriptionDataSource[*SubscriptionEventConfiguration]( + c.PusherAdapter, triggerHashInputFn, c.logger, eventCreateFn, + ), nil +} + +// ResolveDataSourceSubscriptionInput builds the input for the subscription data source +func (c *EngineDataSourceFactory) ResolveDataSourceSubscriptionInput() (string, error) { + evtCfg := SubscriptionEventConfiguration{ + Provider: c.providerId, + Channels: c.channels, + FieldName: c.fieldName, + } + object, err := json.Marshal(evtCfg) + if err != nil { + return "", fmt.Errorf("failed to marshal event subscription configuration") + } + return string(object), nil +} + +// TransformEventData expands the argument templates in the channel names +func (c *EngineDataSourceFactory) TransformEventData(extractFn datasource.ArgumentTemplateCallback) error { + extractedChannels := make([]string, 0, len(c.channels)) + for _, rawChannel := range c.channels { + extractedChannel, err := extractFn(rawChannel) + if err != nil { + return nil + } + extractedChannels = append(extractedChannels, extractedChannel) + } + slices.Sort(extractedChannels) + c.channels = extractedChannels + + return nil +} diff --git a/router/pkg/pubsub/pusher/entity.go b/router/pkg/pubsub/pusher/entity.go new file mode 100644 index 0000000000..8840fd7630 --- /dev/null +++ b/router/pkg/pubsub/pusher/entity.go @@ -0,0 +1,135 @@ +package pusher + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/wundergraph/cosmo/router/pkg/config" +) + +// entityMapper reduces a channel payload to an entity representation, keyed by the +// subscription root field the payload was delivered for. +// +// monday publishes change notifications ("project_name_change" carries name, +// pulse_id, board_id, ...), whose keys are not the fields of the federated type. The +// resolver, however, only needs the entity key: given +// {"__typename":"Board","id":"5002284778"} it resolves every requested field from the +// subgraph that owns Board. So a mapping rewrites +// +// {"name":"...","pulse_id":2536911968,"board_id":5002284778,...} +// +// into +// +// {"__typename":"Board","id":"5002284778"} +type entityMapper struct { + byField map[string]config.PusherEntityMapping +} + +func newEntityMapper(mappings []config.PusherEntityMapping) (*entityMapper, error) { + if len(mappings) == 0 { + return nil, nil + } + + byField := make(map[string]config.PusherEntityMapping, len(mappings)) + for _, mapping := range mappings { + if mapping.FieldName == "" { + return nil, fmt.Errorf("pusher: an entity mapping is missing field_name") + } + if mapping.TypeName == "" { + return nil, fmt.Errorf("pusher: entity mapping for field %q is missing type_name", mapping.FieldName) + } + if len(mapping.IDFrom) == 0 { + return nil, fmt.Errorf("pusher: entity mapping for field %q is missing id_from", mapping.FieldName) + } + if mapping.KeyField == "" { + mapping.KeyField = "id" + } + if _, exists := byField[mapping.FieldName]; exists { + return nil, fmt.Errorf("pusher: duplicate entity mapping for field %q", mapping.FieldName) + } + byField[mapping.FieldName] = mapping + } + + return &entityMapper{byField: byField}, nil +} + +// mapEvent returns the representation for the given root field. It returns the +// payload unchanged when no mapping is configured for the field. +func (m *entityMapper) mapEvent(fieldName string, payload []byte) ([]byte, error) { + if m == nil { + return payload, nil + } + mapping, ok := m.byField[fieldName] + if !ok { + return payload, nil + } + + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + return nil, fmt.Errorf("pusher: payload for field %q is not a JSON object, so it cannot be mapped to %s: %w", + fieldName, mapping.TypeName, err) + } + + for _, path := range mapping.IDFrom { + value, found := lookupPath(decoded, path) + if !found { + continue + } + id, err := scalarToString(value) + if err != nil { + return nil, fmt.Errorf("pusher: %q in the payload for field %q cannot be used as %s.%s: %w", + path, fieldName, mapping.TypeName, mapping.KeyField, err) + } + return json.Marshal(map[string]string{ + "__typename": mapping.TypeName, + mapping.KeyField: id, + }) + } + + return nil, fmt.Errorf("pusher: the payload for field %q contains none of %s, so no %s key could be derived", + fieldName, strings.Join(mapping.IDFrom, ", "), mapping.TypeName) +} + +// lookupPath resolves a dot-separated path in a decoded JSON object. A null value +// counts as absent, so the next candidate key is tried. +func lookupPath(object map[string]any, path string) (any, bool) { + current := object + segments := strings.Split(path, ".") + + for i, segment := range segments { + value, ok := current[segment] + if !ok || value == nil { + return nil, false + } + if i == len(segments)-1 { + return value, true + } + nested, ok := value.(map[string]any) + if !ok { + return nil, false + } + current = nested + } + + return nil, false +} + +// scalarToString renders a JSON scalar as the string an ID field expects. IDs arrive +// as JSON numbers in monday's payloads, and json.Unmarshal decodes those into +// float64, so an integer is formatted without an exponent or a fractional part. +func scalarToString(value any) (string, error) { + switch typed := value.(type) { + case string: + return typed, nil + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64), nil + case json.Number: + return typed.String(), nil + case bool: + return strconv.FormatBool(typed), nil + default: + return "", fmt.Errorf("unsupported type %T", value) + } +} diff --git a/router/pkg/pubsub/pusher/provider_builder.go b/router/pkg/pubsub/pusher/provider_builder.go new file mode 100644 index 0000000000..d50c9b1a27 --- /dev/null +++ b/router/pkg/pubsub/pusher/provider_builder.go @@ -0,0 +1,79 @@ +package pusher + +import ( + "context" + "fmt" + + 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/pubsub/datasource" + "go.uber.org/zap" +) + +const providerTypeID = "pusher" + +// ProviderBuilder builds Pusher PubSub providers +type ProviderBuilder struct { + ctx context.Context + logger *zap.Logger + hostName string + routerListenAddr string +} + +// NewProviderBuilder creates a new Pusher PubSub provider builder +func NewProviderBuilder( + ctx context.Context, + logger *zap.Logger, + hostName string, + routerListenAddr string, +) *ProviderBuilder { + return &ProviderBuilder{ + ctx: ctx, + logger: logger, + hostName: hostName, + routerListenAddr: routerListenAddr, + } +} + +// TypeID returns the provider type ID +func (b *ProviderBuilder) TypeID() string { + return providerTypeID +} + +// BuildEngineDataSourceFactory creates a Pusher data source for the given event configuration +func (b *ProviderBuilder) BuildEngineDataSourceFactory(data *nodev1.PusherEventConfiguration, providers map[string]datasource.Provider) (datasource.EngineDataSourceFactory, error) { + providerId := data.GetEngineEventConfiguration().GetProviderId() + provider, ok := providers[providerId] + if !ok { + return nil, fmt.Errorf("failed to get adapter for provider %s with ID %s", b.TypeID(), providerId) + } + + eventType := data.GetEngineEventConfiguration().GetType() + if eventType != nodev1.EventType_SUBSCRIBE { + return nil, fmt.Errorf("unsupported event type for Pusher: %s, only subscriptions are supported", eventType) + } + + return &EngineDataSourceFactory{ + fieldName: data.GetEngineEventConfiguration().GetFieldName(), + eventType: EventTypeSubscribe, + channels: data.GetChannels(), + providerId: providerId, + PusherAdapter: provider, + logger: b.logger, + }, nil +} + +// BuildProvider returns the Pusher PubSub provider for the given event source +func (b *ProviderBuilder) BuildProvider(provider config.PusherEventSource, providerOpts datasource.ProviderOpts) (datasource.Provider, error) { + adapter, err := NewProviderAdapter(b.ctx, b.logger, provider, providerOpts) + if err != nil { + return nil, err + } + eventBuilder := func(data []byte) datasource.MutableStreamEvent { + return &MutableEvent{Data: data} + } + + pubSubProvider := datasource.NewPubSubProvider(provider.ID, providerTypeID, adapter, b.logger, eventBuilder) + + return pubSubProvider, nil +} diff --git a/router/pkg/pubsub/redis/adapter.go b/router/pkg/pubsub/redis/adapter.go index 606a473e96..edde6019eb 100644 --- a/router/pkg/pubsub/redis/adapter.go +++ b/router/pkg/pubsub/redis/adapter.go @@ -120,7 +120,6 @@ func (p *ProviderAdapter) Subscribe(ctx context.Context, conf datasource.Subscri zap.String("method", "subscribe"), zap.Strings("channels", subConf.Channels), ) - // Guard the possibly-nil connection: in strict mode a failed Startup leaves p.conn nil // (under skip_unavailable_providers the resilient client is retained instead), so return // an error rather than panicking if Subscribe is somehow reached without a connection. @@ -128,7 +127,14 @@ func (p *ProviderAdapter) Subscribe(ctx context.Context, conf datasource.Subscri return datasource.NewError("redis connection not initialized", nil) } - sub := p.conn.PSubscribe(ctx, subConf.Channels...) + // monday fork: SUBSCRIBE, not PSUBSCRIBE. AWS ElastiCache Serverless lists + // psubscribe/punsubscribe among the commands unavailable on serverless caches, + // so a pattern subscribe is rejected server-side — and go-redis swallows it in + // the Channel() retry loop, so the subscription just silently never delivers. + // Trade-off: glob channels in @edfs__redisSubscribe(channels: [...]) no longer + // match. Channels templated from field arguments are unaffected. + log.Debug("subscribing") + sub := p.conn.Subscribe(ctx, subConf.Channels...) msgChan := sub.Channel() cleanup := func() { diff --git a/router/pkg/routerconfig/routerconfig.go b/router/pkg/routerconfig/routerconfig.go index 71ba30e846..bda805ac0f 100644 --- a/router/pkg/routerconfig/routerconfig.go +++ b/router/pkg/routerconfig/routerconfig.go @@ -18,6 +18,7 @@ package routerconfig import ( + "crypto/sha256" "encoding/json" "fmt" "io/fs" @@ -106,6 +107,16 @@ func readMapperFile(path string) (map[string]string, error) { return mapper, nil } +// ManifestMapperSHA256 returns the SHA-256 digest of mapper.json bytes. +// Used to skip manifest reload when only the file mtime changed. +func ManifestMapperSHA256(manifestConfigPath string) ([32]byte, error) { + data, err := os.ReadFile(filepath.Join(manifestConfigPath, "mapper.json")) + if err != nil { + return [32]byte{}, fmt.Errorf("failed to read mapper file: %w", err) + } + return sha256.Sum256(data), nil +} + // assembleConfig assembles the router execution config from the base config and the feature flag configs. // The base config is the latest.json file in the manifest directory. // The feature flag configs are the feature-flags/.json files in the manifest directory. diff --git a/shared/src/router-config/builder.ts b/shared/src/router-config/builder.ts index dba215d1bf..4d6ca0177a 100644 --- a/shared/src/router-config/builder.ts +++ b/shared/src/router-config/builder.ts @@ -343,12 +343,18 @@ export const buildRouterConfig = function (input: Input): RouterConfig { let kind: DataSourceKind; let customGraphql: DataSourceCustom_GraphQL | undefined; let customEvents: DataSourceCustomEvents | undefined; - if (events.kafka.length > 0 || events.nats.length > 0 || events.redis.length > 0) { + if ( + events.kafka.length > 0 || + events.nats.length > 0 || + events.redis.length > 0 || + events.pusher.length > 0 + ) { kind = DataSourceKind.PUBSUB; customEvents = create(DataSourceCustomEventsSchema, { kafka: events.kafka, nats: events.nats, redis: events.redis, + pusher: events.pusher, }); // PUBSUB data sources cannot have root nodes other than // Query/Mutation/Subscription. Filter rootNodes in place diff --git a/shared/src/router-config/graphql-configuration.ts b/shared/src/router-config/graphql-configuration.ts index 92ec3261a6..9db013b174 100644 --- a/shared/src/router-config/graphql-configuration.ts +++ b/shared/src/router-config/graphql-configuration.ts @@ -15,6 +15,7 @@ import { KafkaEventConfigurationSchema, NatsEventConfigurationSchema, NatsStreamConfigurationSchema, + PusherEventConfigurationSchema, RedisEventConfigurationSchema, RequiredFieldSchema, ScopesSchema, @@ -35,6 +36,7 @@ import type { KafkaEventConfiguration, NatsEventConfiguration, NatsStreamConfiguration, + PusherEventConfiguration, RedisEventConfiguration, RequiredField, Scopes, @@ -49,6 +51,7 @@ import { NatsEventType as CompositionEventType, PROVIDER_TYPE_KAFKA, PROVIDER_TYPE_NATS, + PROVIDER_TYPE_PUSHER, PROVIDER_TYPE_REDIS, RequiredFieldConfiguration, SubscriptionCondition, @@ -141,7 +144,7 @@ export function configurationDatasToDataSourceConfiguration( childNodes: [], keys: [], provides: [], - events: create(DataSourceCustomEventsSchema, { nats: [], kafka: [], redis: [] }), + events: create(DataSourceCustomEventsSchema, { nats: [], kafka: [], redis: [], pusher: [] }), requires: [], entityInterfaces: [], interfaceObjects: [], @@ -176,6 +179,7 @@ export function configurationDatasToDataSourceConfiguration( const natsEventConfigurations: NatsEventConfiguration[] = []; const kafkaEventConfigurations: KafkaEventConfiguration[] = []; const redisEventConfigurations: RedisEventConfiguration[] = []; + const pusherEventConfigurations: PusherEventConfiguration[] = []; for (const event of data.events ?? []) { switch (event.providerType) { case PROVIDER_TYPE_KAFKA: { @@ -229,6 +233,20 @@ export function configurationDatasToDataSourceConfiguration( ); break; } + case PROVIDER_TYPE_PUSHER: { + pusherEventConfigurations.push( + create(PusherEventConfigurationSchema, { + engineEventConfiguration: create(EngineEventConfigurationSchema, { + fieldName: event.fieldName, + providerId: event.providerId, + type: eventType(event.type), + typeName, + }), + channels: event.channels, + }), + ); + break; + } default: { throw new Error(`Fatal: Unknown event provider.`); } @@ -237,6 +255,7 @@ export function configurationDatasToDataSourceConfiguration( output.events.nats.push(...natsEventConfigurations); output.events.kafka.push(...kafkaEventConfigurations); output.events.redis.push(...redisEventConfigurations); + output.events.pusher.push(...pusherEventConfigurations); } return output; }