Skip to content

Commit 22baf57

Browse files
authored
[CHA-2961] Webhook handling spec — regenerate SDK + dual-API (#57)
* feat(webhook): regenerate with CHA-2961 helpers + dual-API on Client * chore: re-regenerate against updated chat/ templates (parse_sqs rename + namespace fix) * feat(webhook): add parse_sqs/parse_sns instance methods on Client * chore: regenerate webhook helpers with base64 fallback for plain-JSON SQS * chore: regenerate with P7 chat/ template fixes (error class split, SNS unwrap) * chore: regenerate with unified InvalidWebhookError (revert error class split) * chore: regenerate with P9 chat/ template fixes (RSpec emission, namespace fix) * chore: regenerate with RuboCop-clean spec template * chore: regenerate after chat/ restructure (build/chat-manager path)
1 parent 733a0a8 commit 22baf57

161 files changed

Lines changed: 2431 additions & 1082 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,40 @@
1+
## [Unreleased]
2+
3+
### Added
4+
5+
- Webhook handling spec helpers (CHA-2961): `UnknownEvent` class for forward-compat;
6+
`gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload` primitives;
7+
`parse_event` (returns typed event or `UnknownEvent` for unrecognized discriminators);
8+
`verify_and_parse_webhook` HTTP composite; `parse_sqs` / `parse_sns`
9+
queue composites (no signature; backend emits no HMAC for queue messages today).
10+
Security for queue-delivered payloads is enforced via AWS IAM on the SQS/SNS
11+
subscription, not in-SDK.
12+
- New `Stream::Webhook` module alias (preferred). `StreamChat::Webhook` retained as
13+
backward-compat alias for one minor-version cycle.
14+
- New unified error class: `StreamChat::Webhook::InvalidWebhookError` covering signature
15+
mismatch, invalid JSON, missing/non-string `type` field, gzip decompression failure,
16+
invalid base64 in a queue body, and malformed SNS envelopes. Distinguish failure modes
17+
via the message substring or `cause` chain rather than the class.
18+
- New instance methods on `GetStreamRuby::Client`: `verify_signature(body, signature)` and
19+
`verify_and_parse_webhook(body, signature)` — drop the `api_secret` parameter in favor
20+
of the client's stored secret. Dual API: module-level methods remain available.
21+
- New instance methods on `GetStreamRuby::Client`: `parse_sqs(message_body)` and
22+
`parse_sns(notification_body)` (no signature; AWS IAM).
23+
- Conformance fixture suite under `test/fixtures/webhooks/` (14 event-type buckets plus
24+
`_invalid/` negative cases).
25+
26+
### Changed
27+
28+
- No breaking changes.
29+
30+
### Fixed
31+
32+
- `event_class_for_type` now references `GetStream::Generated::Models::*Event`
33+
(was `StreamChat::*Event`, which raised `NameError` at runtime). `parse_event`
34+
resolves known event types correctly.
35+
36+
[Spec](https://www.notion.so/stream-wiki/Server-Side-SDK-Webhook-Handling-Spec-34b6a5d7f9f681e78003c443f227493c)
37+
138
## [6.0.0] - 2026-04-17
239

340
### major^2 changes

generate.sh

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ fi
1919
set -ex
2020

2121
# cd in API repo, generate new spec and then generate code from it
22-
( cd $SOURCE_PATH ; make openapi ; go run ./cmd/chat-manager openapi generate-client --language ruby --spec ./releases/v2/serverside-api.yaml --output $DST_PATH )
22+
( cd $SOURCE_PATH ; make openapi ; ./build/chat-manager openapi generate-client --language ruby --spec ./releases/v2/serverside-api.yaml --output $DST_PATH )
23+
24+
# Generate webhook conformance fixtures (CHA-2961)
25+
( cd $SOURCE_PATH ; ./build/chat-manager openapi generate-webhook-fixtures --output $DST_PATH/test/fixtures/webhooks )
2326

2427
# Fix any potential issues in generated code
2528
echo "Applying Ruby-specific fixes..."

lib/getstream_ruby/client.rb

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
require_relative 'generated/video_client'
1414
require_relative 'extensions/moderation_extensions'
1515
require_relative 'generated/feed'
16+
require_relative 'generated/webhook'
1617
require_relative 'stream_response'
1718

1819
module GetStreamRuby
@@ -76,6 +77,52 @@ def feed(feed_group_id, feed_id)
7677
GetStream::Generated::Feed.new(self, feed_group_id, feed_id)
7778
end
7879

80+
# Verify a webhook signature using this client's API secret (CHA-2961).
81+
#
82+
# Convenience wrapper around StreamChat::Webhook.verify_signature that
83+
# supplies the secret automatically. The module-level method is still
84+
# available for callers that need to verify with an arbitrary secret.
85+
#
86+
# @param body [String] The raw request body (already-decompressed)
87+
# @param signature [String] The signature from the X-Signature header
88+
# @return [Boolean] true if the signature is valid, false otherwise
89+
def verify_signature(body, signature)
90+
StreamChat::Webhook.verify_signature(body, signature, @configuration.api_secret)
91+
end
92+
93+
# Verify and parse a webhook payload in one call, using this client's API
94+
# secret (CHA-2961).
95+
#
96+
# Handles gzip-compressed bodies transparently. Raises
97+
# StreamChat::Webhook::InvalidWebhookError on signature mismatch or parse
98+
# failures; distinguish failure modes via the message substring.
99+
#
100+
# @param body [String] raw request body (possibly gzip-compressed)
101+
# @param signature [String] X-Signature header value
102+
# @return [Object] the typed event class instance or
103+
# StreamChat::Webhook::UnknownEvent
104+
# @raise [StreamChat::Webhook::InvalidWebhookError]
105+
def verify_and_parse_webhook(body, signature)
106+
StreamChat::Webhook.verify_and_parse_webhook(body, signature, @configuration.api_secret)
107+
end
108+
109+
# Decode + parse a Stream-delivered SQS message body.
110+
#
111+
# Convenience wrapper around StreamChat::Webhook.parse_sqs. No signature is
112+
# required; SQS deliveries are authenticated via AWS IAM.
113+
def parse_sqs(message_body)
114+
StreamChat::Webhook.parse_sqs(message_body)
115+
end
116+
117+
# Decode + parse a Stream-delivered SNS notification body.
118+
#
119+
# Accepts either the raw SNS HTTP envelope JSON or the pre-extracted Message
120+
# string. Convenience wrapper around StreamChat::Webhook.parse_sns. No signature
121+
# is required; SNS deliveries are authenticated via AWS IAM.
122+
def parse_sns(notification_body)
123+
StreamChat::Webhook.parse_sns(notification_body)
124+
end
125+
79126
# @param path [String] The API path
80127
# @param body [Hash] The request body
81128
# @return [GetStreamRuby::StreamResponse] The API response

lib/getstream_ruby/generated/common_client.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -981,6 +981,32 @@ def create_role(create_role_request)
981981
)
982982
end
983983

984+
# Searches mentionable roles (user-assignable + channel-assignable, built-in and custom) by name prefix for autocomplete
985+
#
986+
# @param query [String]
987+
# @param limit [Integer]
988+
# @param name_gt [String]
989+
# @param role_type [String]
990+
# @param include_global_roles [Boolean]
991+
# @return [Models::SearchRolesResponse]
992+
def search_roles(query, limit = nil, name_gt = nil, role_type = nil, include_global_roles = nil)
993+
path = '/api/v2/roles/search'
994+
# Build query parameters
995+
query_params = {}
996+
query_params['query'] = query unless query.nil?
997+
query_params['limit'] = limit unless limit.nil?
998+
query_params['name_gt'] = name_gt unless name_gt.nil?
999+
query_params['role_type'] = role_type unless role_type.nil?
1000+
query_params['include_global_roles'] = include_global_roles unless include_global_roles.nil?
1001+
1002+
# Make the API request
1003+
@client.make_request(
1004+
:get,
1005+
path,
1006+
query_params: query_params
1007+
)
1008+
end
1009+
9841010
# Deletes custom role
9851011
#
9861012
# @param name [String]

lib/getstream_ruby/generated/feed.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ def initialize(client, feed_group_id, feed_id)
1717
# Delete a single feed by its ID
1818
#
1919
# @param hard_delete [Boolean]
20+
# @param purge_user_activities [Boolean]
2021
# @return [Models::DeleteFeedResponse]
21-
def delete_feed(hard_delete = nil)
22+
def delete_feed(hard_delete = nil, purge_user_activities = nil)
2223
# Build query parameters
2324
query_params = {}
2425
query_params['hard_delete'] = hard_delete unless hard_delete.nil?
26+
query_params['purge_user_activities'] = purge_user_activities unless purge_user_activities.nil?
2527

2628
# Delegate to the FeedsClient
2729
@client.feeds.delete_feed(@feed_group_id, @feed_id, query_params)

lib/getstream_ruby/generated/feeds_client.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -992,15 +992,17 @@ def create_feed_group(create_feed_group_request)
992992
# @param feed_group_id [String]
993993
# @param feed_id [String]
994994
# @param hard_delete [Boolean]
995+
# @param purge_user_activities [Boolean]
995996
# @return [Models::DeleteFeedResponse]
996-
def delete_feed(feed_group_id, feed_id, hard_delete = nil)
997+
def delete_feed(feed_group_id, feed_id, hard_delete = nil, purge_user_activities = nil)
997998
path = '/api/v2/feeds/feed_groups/{feed_group_id}/feeds/{feed_id}'
998999
# Replace path parameters
9991000
path = path.gsub('{feed_group_id}', feed_group_id.to_s)
10001001
path = path.gsub('{feed_id}', feed_id.to_s)
10011002
# Build query parameters
10021003
query_params = {}
10031004
query_params['hard_delete'] = hard_delete unless hard_delete.nil?
1005+
query_params['purge_user_activities'] = purge_user_activities unless purge_user_activities.nil?
10041006

10051007
# Make the API request
10061008
@client.make_request(

lib/getstream_ruby/generated/models/async_export_error_event.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def initialize(attributes = {})
4343
@started_at = attributes[:started_at] || attributes['started_at']
4444
@task_id = attributes[:task_id] || attributes['task_id']
4545
@custom = attributes[:custom] || attributes['custom']
46-
@type = attributes[:type] || attributes['type'] || "export.users.error"
46+
@type = attributes[:type] || attributes['type'] || "export.bulk_image_moderation.error"
4747
@received_at = attributes[:received_at] || attributes['received_at'] || nil
4848
end
4949

lib/getstream_ruby/generated/models/delete_feeds_batch_request.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,24 @@ class DeleteFeedsBatchRequest < GetStream::BaseModel
1515
# @!attribute hard_delete
1616
# @return [Boolean] Whether to permanently delete the feeds instead of soft delete
1717
attr_accessor :hard_delete
18+
# @!attribute purge_user_activities
19+
# @return [Boolean] When hard-deleting, also fully delete activities authored by each feed's owner from every other feed those activities were fanned out to. Default false preserves existing fan-out. Requires 'hard_delete' to be true; the request is rejected otherwise. Feeds with no recorded owner (created_by_id is empty) are silently skipped for the purge step — owner-matching against an empty string is a safety guard, not a wildcard.
20+
attr_accessor :purge_user_activities
1821

1922
# Initialize with attributes
2023
def initialize(attributes = {})
2124
super(attributes)
2225
@feeds = attributes[:feeds] || attributes['feeds']
2326
@hard_delete = attributes[:hard_delete] || attributes['hard_delete'] || nil
27+
@purge_user_activities = attributes[:purge_user_activities] || attributes['purge_user_activities'] || nil
2428
end
2529

2630
# Override field mappings for JSON serialization
2731
def self.json_field_mappings
2832
{
2933
feeds: 'feeds',
30-
hard_delete: 'hard_delete'
34+
hard_delete: 'hard_delete',
35+
purge_user_activities: 'purge_user_activities'
3136
}
3237
end
3338
end

lib/getstream_ruby/generated/models/labels_request.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class LabelsRequest < GetStream::BaseModel
2121
# @!attribute content_type
2222
# @return [String] Type of content: 'text' (default), 'message', or 'username'. Stored as-sent; only 'username' routes to the username moderation API.
2323
attr_accessor :content_type
24+
# @!attribute dry_run
25+
# @return [Boolean] When true, run moderation and return labels without persisting the result. Useful for one-off checks (e.g. UI testers) that should not be recorded in the stored history.
26+
attr_accessor :dry_run
2427
# @!attribute policy
2528
# @return [String] Optional moderation policy key (max 128 chars)
2629
attr_accessor :policy
@@ -35,6 +38,7 @@ def initialize(attributes = {})
3538
@category = attributes[:category] || attributes['category'] || nil
3639
@content_id = attributes[:content_id] || attributes['content_id'] || nil
3740
@content_type = attributes[:content_type] || attributes['content_type'] || nil
41+
@dry_run = attributes[:dry_run] || attributes['dry_run'] || nil
3842
@policy = attributes[:policy] || attributes['policy'] || nil
3943
@user_id = attributes[:user_id] || attributes['user_id'] || nil
4044
end
@@ -46,6 +50,7 @@ def self.json_field_mappings
4650
category: 'category',
4751
content_id: 'content_id',
4852
content_type: 'content_type',
53+
dry_run: 'dry_run',
4954
policy: 'policy',
5055
user_id: 'user_id'
5156
}

lib/getstream_ruby/generated/models/query_bookmarks_request.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ class QueryBookmarksRequest < GetStream::BaseModel
2121
# @!attribute prev
2222
# @return [String]
2323
attr_accessor :prev
24+
# @!attribute user_id
25+
# @return [String]
26+
attr_accessor :user_id
2427
# @!attribute sort
2528
# @return [Array<SortParamRequest>] Sorting parameters for the query
2629
attr_accessor :sort
2730
# @!attribute filter
2831
# @return [Object] Filters to apply to the query
2932
attr_accessor :filter
33+
# @!attribute user
34+
# @return [UserRequest]
35+
attr_accessor :user
3036

3137
# Initialize with attributes
3238
def initialize(attributes = {})
@@ -35,8 +41,10 @@ def initialize(attributes = {})
3541
@limit = attributes[:limit] || attributes['limit'] || nil
3642
@next = attributes[:next] || attributes['next'] || nil
3743
@prev = attributes[:prev] || attributes['prev'] || nil
44+
@user_id = attributes[:user_id] || attributes['user_id'] || nil
3845
@sort = attributes[:sort] || attributes['sort'] || nil
3946
@filter = attributes[:filter] || attributes['filter'] || nil
47+
@user = attributes[:user] || attributes['user'] || nil
4048
end
4149

4250
# Override field mappings for JSON serialization
@@ -46,8 +54,10 @@ def self.json_field_mappings
4654
limit: 'limit',
4755
next: 'next',
4856
prev: 'prev',
57+
user_id: 'user_id',
4958
sort: 'sort',
50-
filter: 'filter'
59+
filter: 'filter',
60+
user: 'user'
5161
}
5262
end
5363
end

0 commit comments

Comments
 (0)