diff --git a/app.go b/app.go index 9042e44..c44b4d2 100644 --- a/app.go +++ b/app.go @@ -27,6 +27,17 @@ type NewApp struct { ApnsPrivateKey string `json:"apnsPrivateKey"` // Use the Apple Push Notification service sandbox endpoint. ApnsUseSandboxEndpoint bool `json:"apnsUseSandboxEndpoint"` + // The APNs authentication type. Either "certificate" or "token". + ApnsAuthType *string `json:"apnsAuthType,omitempty"` + // The APNs signing key (.p8 file contents) for token-based authentication. + // Write-only: will not be populated by queries. + ApnsSigningKey *string `json:"apnsSigningKey,omitempty"` + // The 10-character Key ID for APNs token-based authentication. + ApnsSigningKeyId *string `json:"apnsSigningKeyId,omitempty"` + // The Team ID (issuer key) for APNs token-based authentication. + ApnsIssuerKey *string `json:"apnsIssuerKey,omitempty"` + // The bundle ID used as the APNs topic header. + ApnsTopicHeader *string `json:"apnsTopicHeader,omitempty"` } // A struct representing an Ably application. @@ -48,6 +59,8 @@ type App struct { FcmServiceAccount string `json:"fcmServiceAccount"` // The Firebase Project ID. To authenticate with firebase you must also provide a service account key. FcmProjectId string `json:"fcmProjectId"` + // Whether a Firebase service account key has been configured. + FcmServiceAccountConfigured bool `json:"fcmServiceAccountConfigured"` // The Apple Push Notification service certificate. // This field can only be used to set a new value, // it will not be populated by queries. @@ -58,6 +71,22 @@ type App struct { ApnsPrivateKey string `json:"apnsPrivateKey"` // Use the Apple Push Notification service sandbox endpoint. ApnsUseSandboxEndpoint bool `json:"apnsUseSandboxEndpoint"` + // The APNs authentication type. Either "certificate" or "token". + ApnsAuthType *string `json:"apnsAuthType,omitempty"` + // Whether an APNs certificate has been configured. + ApnsCertificateConfigured bool `json:"apnsCertificateConfigured"` + // Whether an APNs signing key has been configured. + ApnsSigningKeyConfigured bool `json:"apnsSigningKeyConfigured"` + // The 10-character Key ID for APNs token-based authentication. + ApnsSigningKeyId *string `json:"apnsSigningKeyId,omitempty"` + // The Team ID (issuer key) for APNs token-based authentication. + ApnsIssuerKey *string `json:"apnsIssuerKey,omitempty"` + // The bundle ID used as the APNs topic header. + ApnsTopicHeader *string `json:"apnsTopicHeader,omitempty"` + // Unix timestamp representing the date and time of creation of the app. + Created int `json:"created"` + // Unix timestamp representing the date and time of last modification of the app. + Modified int `json:"modified"` } // Apps fetches a list of all your Ably apps. diff --git a/me.go b/me.go index 599aa65..c238c93 100644 --- a/me.go +++ b/me.go @@ -18,6 +18,8 @@ type Token struct { Name string `json:"name"` // The capabilities of the token. Capabilities []string `json:"capabilities"` + // When the access token expires. Nil if the token does not expire. + ExpiresAt *string `json:"expires_at"` } // User associated with the used token and account. diff --git a/namespaces.go b/namespaces.go index 9229888..6a0f02f 100644 --- a/namespaces.go +++ b/namespaces.go @@ -43,6 +43,13 @@ type Namespace struct { // The key used to determine which messages should be conflated. Messages // with the same conflation key will be combined into a single message. ConflationKey string `json:"conflationKey"` + // If true, enables update and delete operations on published messages. + MutableMessages bool `json:"mutableMessages"` + // If true, channels within this namespace will be included in the + // channel registry for enumeration. + PopulateChannelRegistry bool `json:"populateChannelRegistry"` + // The owning application ID. Only present in responses. + AppID string `json:"appId,omitempty"` } // Namespaces lists the namespaces for the specified application ID. diff --git a/rules.go b/rules.go index c17140d..149923a 100644 --- a/rules.go +++ b/rules.go @@ -76,6 +76,8 @@ type Rule struct { // RequestMode. You can read more about the difference between single and batched // events in the Ably documentation. https://ably.com/documentation/general/events#batching RequestMode RequestMode `json:"requestMode,omitempty"` + // Controls when the rule is invoked relative to message publish. + InvocationMode InvocationMode `json:"invocationMode,omitempty"` // The rule source. Source Source `json:"source"` // The rule target. @@ -149,6 +151,34 @@ func (r *Rule) UnmarshalJSON(data []byte) error { var t HttpTarget err = json.Unmarshal(raw.Target, &t) r.Target = &t + case "aws/lambda/before-publish": + var t AwsLambdaBeforePublishTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t + case "http/before-publish": + var t HttpBeforePublishTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t + case "hive/text-model-only": + var t HiveTextModelOnlyTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t + case "hive/dashboard": + var t HiveDashboardTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t + case "bodyguard/text-moderation": + var t BodyguardTextModerationTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t + case "tisane/text-moderation": + var t TisaneTextModerationTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t + case "azure/text-moderation": + var t AzureTextModerationTarget + err = json.Unmarshal(raw.Target, &t) + r.Target = &t default: return fmt.Errorf("unknown rule type \"%s\"", raw.RuleType) } @@ -164,22 +194,24 @@ func (r *Rule) UnmarshalJSON(data []byte) error { r.Created = raw.Created r.Modified = raw.Modified r.RequestMode = raw.RequestMode + r.InvocationMode = raw.InvocationMode r.Source = raw.Source return nil } type rawRule struct { - ID string `json:"id,omitempty"` - AppID string `json:"appId,omitempty"` - Version string `json:"version,omitempty"` - Status string `json:"status,omitempty"` - Created int `json:"created"` - Modified int `json:"modified"` - RuleType string `json:"ruleType,omitempty"` - RequestMode RequestMode `json:"requestMode,omitempty"` - Source Source `json:"source"` - Target json.RawMessage `json:"target"` + ID string `json:"id,omitempty"` + AppID string `json:"appId,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status,omitempty"` + Created int `json:"created"` + Modified int `json:"modified"` + RuleType string `json:"ruleType,omitempty"` + RequestMode RequestMode `json:"requestMode,omitempty"` + InvocationMode InvocationMode `json:"invocationMode,omitempty"` + Source Source `json:"source"` + Target json.RawMessage `json:"target"` } // RuleType gets the type of target this rule has. @@ -187,6 +219,27 @@ func (r *NewRule) RuleType() string { return r.Target.TargetType() } +// InvocationMode controls when a rule is invoked relative to message publish. +type InvocationMode string + +// BeforePublish invokes the rule before a message is published. +const BeforePublish InvocationMode = "BEFORE_PUBLISH" + +// AfterPublish invokes the rule after a message is published. +const AfterPublish InvocationMode = "AFTER_PUBLISH" + +// BeforePublishConfig contains configuration for before-publish rules. +type BeforePublishConfig struct { + // The timeout in milliseconds for retrying a failed invocation (0-10000). + RetryTimeout int `json:"retryTimeout"` + // The maximum number of retries for a failed invocation (0-10). + MaxRetries int `json:"maxRetries"` + // The action to take when the invocation fails. Either "REJECT" or "PUBLISH". + FailedAction string `json:"failedAction"` + // The action to take when rate limited. Either "RETRY" or "FAIL". + TooManyRequestsAction string `json:"tooManyRequestsAction"` +} + // Source controls how a rule gets data from channels. type Source struct { // ChannelFilter allows you to filter your rule based on a regular expression that is matched against the complete channel name. @@ -194,6 +247,8 @@ type Source struct { ChannelFilter string `json:"channelFilter"` // Type controls the type of messages that are sent to the rule. Type SourceType `json:"type,omitempty"` + // ChatRoomFilter allows you to filter based on a regex matched against the chat room ID. + ChatRoomFilter string `json:"chatRoomFilter,omitempty"` } // The Target interface is implemented by targets and @@ -223,6 +278,8 @@ type NewRule struct { // RequestMode. You can read more about the difference between single and batched // events in the Ably documentation. https://ably.com/documentation/general/events#batching RequestMode RequestMode `json:"requestMode,omitempty"` + // Controls when the rule is invoked relative to message publish. + InvocationMode InvocationMode `json:"invocationMode,omitempty"` // The rule source. Source Source `json:"source"` // The rule target. @@ -664,6 +721,125 @@ func (s *HttpTarget) TargetType() string { return "http" } +// AwsLambdaBeforePublishTarget is the type used for aws/lambda/before-publish rules. +type AwsLambdaBeforePublishTarget struct { + // The region in which your AWS Lambda Function is hosted. + Region string `json:"region,omitempty"` + // The name of your AWS Lambda Function. + FunctionName string `json:"functionName,omitempty"` + // Authentication details. + Authentication AwsAuthentication `json:"authentication"` + // Configuration for before-publish behavior. + BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` +} + +// AwsLambdaBeforePublishTarget implements the Target interface. +func (s *AwsLambdaBeforePublishTarget) TargetType() string { + return "aws/lambda/before-publish" +} + +// HttpBeforePublishTarget is the type used for http/before-publish rules. +type HttpBeforePublishTarget struct { + // The webhook URL that Ably will POST events to. + Url string `json:"url,omitempty"` + // If you have additional information to send, you'll need to include the relevant headers. + Headers []Header `json:"headers,omitempty"` + // JSON provides a simpler text-based encoding, whereas MsgPack provides a more efficient binary encoding. + Format Format `json:"format,omitempty"` + // Configuration for before-publish behavior. + BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` +} + +// HttpBeforePublishTarget implements the Target interface. +func (s *HttpBeforePublishTarget) TargetType() string { + return "http/before-publish" +} + +// HiveTextModelOnlyTarget is the type used for hive/text-model-only rules. +type HiveTextModelOnlyTarget struct { + // The Hive API key. + ApiKey string `json:"apiKey,omitempty"` + // The URL of the Hive text classification model. + ModelUrl string `json:"modelUrl,omitempty"` + // Thresholds for text classification categories (values 1-3). + Thresholds map[string]int `json:"thresholds,omitempty"` + // Configuration for before-publish behavior. + BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` +} + +// HiveTextModelOnlyTarget implements the Target interface. +func (s *HiveTextModelOnlyTarget) TargetType() string { + return "hive/text-model-only" +} + +// HiveDashboardTarget is the type used for hive/dashboard rules. +type HiveDashboardTarget struct { + // The Hive API key. + ApiKey string `json:"apiKey,omitempty"` + // Whether to check watch lists. + CheckWatchLists *bool `json:"checkWatchLists,omitempty"` +} + +// HiveDashboardTarget implements the Target interface. +func (s *HiveDashboardTarget) TargetType() string { + return "hive/dashboard" +} + +// BodyguardTextModerationTarget is the type used for bodyguard/text-moderation rules. +type BodyguardTextModerationTarget struct { + // The Bodyguard API key. + ApiKey string `json:"apiKey,omitempty"` + // The Bodyguard channel ID. + ChannelId string `json:"channelId,omitempty"` + // The Bodyguard API URL. + ApiUrl string `json:"apiUrl,omitempty"` + // The default language for text moderation. + DefaultLanguage string `json:"defaultLanguage,omitempty"` + // Configuration for before-publish behavior. + BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` +} + +// BodyguardTextModerationTarget implements the Target interface. +func (s *BodyguardTextModerationTarget) TargetType() string { + return "bodyguard/text-moderation" +} + +// TisaneTextModerationTarget is the type used for tisane/text-moderation rules. +type TisaneTextModerationTarget struct { + // The Tisane API key. + ApiKey string `json:"apiKey,omitempty"` + // The URL of the Tisane NLP model. + ModelUrl string `json:"modelUrl,omitempty"` + // Thresholds for text moderation categories (values 0-3). + Thresholds map[string]int `json:"thresholds,omitempty"` + // The default language for text moderation. + DefaultLanguage string `json:"defaultLanguage,omitempty"` + // Configuration for before-publish behavior. + BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` +} + +// TisaneTextModerationTarget implements the Target interface. +func (s *TisaneTextModerationTarget) TargetType() string { + return "tisane/text-moderation" +} + +// AzureTextModerationTarget is the type used for azure/text-moderation rules. +type AzureTextModerationTarget struct { + // The Azure AI Content Safety API key. + ApiKey string `json:"apiKey,omitempty"` + // The Azure AI Content Safety endpoint URL. + Endpoint string `json:"endpoint,omitempty"` + // Thresholds for content safety categories (values 0-7). + Thresholds map[string]int `json:"thresholds,omitempty"` + // Configuration for before-publish behavior. + BeforePublishConfig BeforePublishConfig `json:"beforePublishConfig"` +} + +// AzureTextModerationTarget implements the Target interface. +func (s *AzureTextModerationTarget) TargetType() string { + return "azure/text-moderation" +} + // Lists the rules for the application specified by the application ID. func (c *Client) Rules(appID string) ([]Rule, error) { var rules []Rule diff --git a/rules_marshal_test.go b/rules_marshal_test.go new file mode 100644 index 0000000..1c5b296 --- /dev/null +++ b/rules_marshal_test.go @@ -0,0 +1,202 @@ +package control + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRuleMarshalRoundTrip(t *testing.T) { + tests := []struct { + name string + target Target + }{ + { + name: "aws/lambda/before-publish", + target: &AwsLambdaBeforePublishTarget{ + Region: "us-east-1", + FunctionName: "my-func", + Authentication: AwsAuthentication{ + Authentication: &AuthenticationModeAssumeRole{ + AssumeRoleArn: "arn:aws:iam::role/test", + }, + }, + BeforePublishConfig: BeforePublishConfig{ + RetryTimeout: 5000, + MaxRetries: 3, + FailedAction: "REJECT", + TooManyRequestsAction: "RETRY", + }, + }, + }, + { + name: "http/before-publish", + target: &HttpBeforePublishTarget{ + Url: "https://example.com/hook", + Headers: []Header{{Name: "X-Key", Value: "abc"}}, + Format: Json, + BeforePublishConfig: BeforePublishConfig{ + RetryTimeout: 1000, + MaxRetries: 2, + FailedAction: "PUBLISH", + TooManyRequestsAction: "FAIL", + }, + }, + }, + { + name: "hive/text-model-only", + target: &HiveTextModelOnlyTarget{ + ApiKey: "hive-key", + ModelUrl: "https://api.hive.com/model", + Thresholds: map[string]int{"sexual": 2, "violence": 1}, + BeforePublishConfig: BeforePublishConfig{ + FailedAction: "REJECT", + }, + }, + }, + { + name: "hive/dashboard", + target: &HiveDashboardTarget{ + ApiKey: "hive-key", + CheckWatchLists: boolPtr(true), + }, + }, + { + name: "bodyguard/text-moderation", + target: &BodyguardTextModerationTarget{ + ApiKey: "bg-key", + ChannelId: "chan-1", + ApiUrl: "https://api.bodyguard.ai", + DefaultLanguage: "en", + BeforePublishConfig: BeforePublishConfig{ + FailedAction: "PUBLISH", + }, + }, + }, + { + name: "tisane/text-moderation", + target: &TisaneTextModerationTarget{ + ApiKey: "tisane-key", + ModelUrl: "https://api.tisane.ai", + Thresholds: map[string]int{"hate": 2}, + DefaultLanguage: "en", + BeforePublishConfig: BeforePublishConfig{ + FailedAction: "REJECT", + }, + }, + }, + { + name: "azure/text-moderation", + target: &AzureTextModerationTarget{ + ApiKey: "azure-key", + Endpoint: "https://contentsafety.azure.com", + Thresholds: map[string]int{"Hate": 4, "Violence": 2}, + BeforePublishConfig: BeforePublishConfig{ + FailedAction: "REJECT", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newRule := NewRule{ + Status: "enabled", + RequestMode: Single, + Source: Source{ + ChannelFilter: "test.*", + Type: ChannelMessage, + }, + Target: tt.target, + } + + data, err := json.Marshal(&newRule) + assert.NoError(t, err) + + var raw map[string]interface{} + err = json.Unmarshal(data, &raw) + assert.NoError(t, err) + assert.Equal(t, tt.name, raw["ruleType"]) + + apiResponse := buildMockRuleResponse(t, data, tt.name) + + var rule Rule + err = json.Unmarshal(apiResponse, &rule) + assert.NoError(t, err) + assert.Equal(t, tt.name, rule.Target.TargetType()) + assert.Equal(t, "rule-123", rule.ID) + assert.Equal(t, "app-456", rule.AppID) + }) + } +} + +func TestRuleUnmarshalUnknownType(t *testing.T) { + data := []byte(`{ + "id": "rule-1", + "appId": "app-1", + "ruleType": "totally/unknown", + "source": {"channelFilter": "", "type": "channel.message"}, + "target": {} + }`) + + var rule Rule + err := json.Unmarshal(data, &rule) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown rule type") +} + +func TestRuleWithInvocationMode(t *testing.T) { + data := []byte(`{ + "id": "rule-1", + "appId": "app-1", + "ruleType": "http/before-publish", + "invocationMode": "BEFORE_PUBLISH", + "requestMode": "single", + "source": {"channelFilter": "", "type": "channel.message"}, + "target": {"url": "https://example.com", "beforePublishConfig": {"retryTimeout": 1000, "maxRetries": 2, "failedAction": "REJECT", "tooManyRequestsAction": "RETRY"}} + }`) + + var rule Rule + err := json.Unmarshal(data, &rule) + assert.NoError(t, err) + assert.Equal(t, BeforePublish, rule.InvocationMode) +} + +func TestSourceChatRoomFilter(t *testing.T) { + data := []byte(`{ + "id": "rule-1", + "appId": "app-1", + "ruleType": "http", + "source": {"channelFilter": "test.*", "type": "channel.message", "chatRoomFilter": "room-.*"}, + "target": {"url": "https://example.com"} + }`) + + var rule Rule + err := json.Unmarshal(data, &rule) + assert.NoError(t, err) + assert.Equal(t, "room-.*", rule.Source.ChatRoomFilter) +} + +func buildMockRuleResponse(t *testing.T, newRuleData []byte, ruleType string) []byte { + t.Helper() + + var partial map[string]interface{} + err := json.Unmarshal(newRuleData, &partial) + assert.NoError(t, err) + + partial["id"] = "rule-123" + partial["appId"] = "app-456" + partial["version"] = "1.0" + partial["created"] = 1234567890 + partial["modified"] = 1234567890 + partial["ruleType"] = ruleType + + data, err := json.Marshal(partial) + assert.NoError(t, err) + return data +} + +func boolPtr(b bool) *bool { + return &b +}