flodesk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +101 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +351 -0
  5. data/Rakefile +12 -0
  6. data/lib/flodesk/auth.rb +32 -0
  7. data/lib/flodesk/client.rb +84 -0
  8. data/lib/flodesk/coercion.rb +69 -0
  9. data/lib/flodesk/connection.rb +179 -0
  10. data/lib/flodesk/enums.rb +42 -0
  11. data/lib/flodesk/errors.rb +139 -0
  12. data/lib/flodesk/instrumentation.rb +35 -0
  13. data/lib/flodesk/objects/batch_item_error.rb +29 -0
  14. data/lib/flodesk/objects/batch_result.rb +44 -0
  15. data/lib/flodesk/objects/campaign.rb +28 -0
  16. data/lib/flodesk/objects/custom_field.rb +21 -0
  17. data/lib/flodesk/objects/page.rb +72 -0
  18. data/lib/flodesk/objects/segment.rb +36 -0
  19. data/lib/flodesk/objects/subscriber.rb +39 -0
  20. data/lib/flodesk/objects/webhook.rb +24 -0
  21. data/lib/flodesk/objects/workflow.rb +22 -0
  22. data/lib/flodesk/rails/railtie.rb +19 -0
  23. data/lib/flodesk/rails.rb +8 -0
  24. data/lib/flodesk/rate_limit.rb +35 -0
  25. data/lib/flodesk/redaction.rb +46 -0
  26. data/lib/flodesk/resources/base.rb +122 -0
  27. data/lib/flodesk/resources/campaigns.rb +121 -0
  28. data/lib/flodesk/resources/custom_fields.rb +47 -0
  29. data/lib/flodesk/resources/segments.rb +49 -0
  30. data/lib/flodesk/resources/subscribers.rb +239 -0
  31. data/lib/flodesk/resources/webhooks.rb +93 -0
  32. data/lib/flodesk/resources/workflows.rb +75 -0
  33. data/lib/flodesk/response.rb +35 -0
  34. data/lib/flodesk/retry_policy.rb +46 -0
  35. data/lib/flodesk/test_helpers.rb +152 -0
  36. data/lib/flodesk/version.rb +5 -0
  37. data/lib/flodesk/webhooks/event.rb +78 -0
  38. data/lib/flodesk/webhooks/handler.rb +167 -0
  39. data/lib/flodesk/webhooks/verification.rb +61 -0
  40. data/lib/flodesk.rb +59 -0
  41. data/lib/generators/flodesk/install_generator.rb +61 -0
  42. data/lib/generators/flodesk/templates/initializer.rb.tt +28 -0
  43. data/sig/flodesk/client.rbs +91 -0
  44. data/sig/flodesk/errors.rbs +51 -0
  45. data/sig/flodesk/objects.rbs +137 -0
  46. data/sig/flodesk/resources.rbs +129 -0
  47. data/sig/flodesk/webhooks.rbs +57 -0
  48. data/sig/flodesk.rbs +61 -0
  49. metadata +97 -0
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Operations under the `Subscriber` tag of the Flodesk API.
6
+ class Subscribers < Base
7
+ PATH = "/subscribers"
8
+
9
+ # The API caps segments per subscriber at 50.
10
+ MAX_SEGMENTS = 50
11
+
12
+ # The API caps a batch at 50 subscribers per request. Combined with the
13
+ # endpoint's 20-requests-per-minute limit, that is a ceiling of 1,000
14
+ # upserts per minute.
15
+ MAX_BATCH_SIZE = 50
16
+
17
+ # Every field `CreateOrUpdateSubscriberItem` documents, and the single
18
+ # source of truth for both building the payload and rejecting unknown
19
+ # keys — a duplicated list is how a field quietly stops being covered.
20
+ #
21
+ # The contract spec asserts this matches the schema exactly. That is what
22
+ # makes rejecting unknown keys safe rather than brittle: a field Flodesk
23
+ # adds fails the build here, instead of becoming a runtime ArgumentError
24
+ # for a value the API would have accepted.
25
+ SUBSCRIBER_FIELDS = %i[
26
+ id email first_name last_name custom_fields segment_ids
27
+ double_optin optin_ip optin_timestamp
28
+ ].freeze
29
+
30
+ # GET /subscribers
31
+ #
32
+ # Issues exactly one request. Use {#auto_paging_each} to walk every page.
33
+ def list(page: nil, per_page: nil, status: nil, segment_id: nil)
34
+ paginated_list(
35
+ PATH, klass: Subscriber, page: page, per_page: per_page,
36
+ filters: list_filters(status: status, segment_id: segment_id)
37
+ )
38
+ end
39
+
40
+ # Walks every subscriber across all pages, fetching each page on demand.
41
+ #
42
+ # Traversing a large list can consume the entire 100-requests-per-minute
43
+ # budget, which is why this is opt-in rather than the behavior of `list`.
44
+ def auto_paging_each(page: nil, per_page: nil, status: nil, segment_id: nil, &)
45
+ each_page_item(
46
+ PATH, klass: Subscriber, page: page, per_page: per_page,
47
+ filters: list_filters(status: status, segment_id: segment_id), &
48
+ )
49
+ end
50
+
51
+ # GET /subscribers/{id_or_email}
52
+ #
53
+ # `id_or_email` may be either; an email is percent-encoded into the path.
54
+ def retrieve(id_or_email)
55
+ Subscriber.from(get("#{PATH}/#{encode_segment(id_or_email)}"))
56
+ end
57
+
58
+ # POST /subscribers — creates or updates.
59
+ #
60
+ # Declared idempotent: repeating it converges on the same state, so it is
61
+ # safe to retry after a timeout or 5xx.
62
+ #
63
+ # The API returns 200 for both a creation and an update and never reports
64
+ # which occurred, so this cannot tell you whether the subscriber was new.
65
+ def upsert(**attrs)
66
+ Subscriber.from(post(PATH, body: subscriber_payload(attrs), idempotent: true))
67
+ end
68
+
69
+ # POST /subscribers/batch — up to 50 records in one request.
70
+ #
71
+ # Returns a {BatchResult}. Because the API reports per-record failures
72
+ # inside a 200 response, this raises {PartialFailureError} when any record
73
+ # failed — carrying the full result, so successes are not lost. Pass
74
+ # `raise_on_failure: false` to receive the result quietly instead.
75
+ def batch_upsert(records, raise_on_failure: true)
76
+ payload = batch_payload(records)
77
+ result = BatchResult.from(
78
+ post("#{PATH}/batch", body: { "subscribers" => payload }, idempotent: true)
79
+ )
80
+
81
+ raise PartialFailureError.new(result: result) if raise_on_failure && !result.success?
82
+
83
+ result
84
+ end
85
+
86
+ # POST /subscribers/{id_or_email}/segments
87
+ #
88
+ # Idempotent: adding a segment the subscriber already has has no further
89
+ # effect.
90
+ def add_to_segments(id_or_email, segment_ids)
91
+ Subscriber.from(
92
+ post(
93
+ "#{PATH}/#{encode_segment(id_or_email)}/segments",
94
+ body: { "segment_ids" => validate_segment_ids!(segment_ids) },
95
+ idempotent: true
96
+ )
97
+ )
98
+ end
99
+
100
+ # DELETE /subscribers/{id_or_email}/segments
101
+ #
102
+ # Idempotent: removing a segment the subscriber does not have has no
103
+ # further effect.
104
+ def remove_from_segments(id_or_email, segment_ids)
105
+ Subscriber.from(
106
+ delete(
107
+ "#{PATH}/#{encode_segment(id_or_email)}/segments",
108
+ body: { "segment_ids" => validate_segment_ids!(segment_ids) }
109
+ )
110
+ )
111
+ end
112
+
113
+ # POST /subscribers/{id_or_email}/unsubscribe
114
+ #
115
+ # Idempotent: unsubscribing is a terminal state, so repeating it is safe.
116
+ def unsubscribe(id_or_email)
117
+ Subscriber.from(
118
+ post("#{PATH}/#{encode_segment(id_or_email)}/unsubscribe", idempotent: true)
119
+ )
120
+ end
121
+
122
+ private
123
+
124
+ def list_filters(status:, segment_id:)
125
+ {
126
+ "status" => validate_enum!("status", status, Enums::SUBSCRIBER_STATUSES),
127
+ "segment_id" => segment_id
128
+ }
129
+ end
130
+
131
+ # Builds a `CreateOrUpdateSubscriberItem`. `index` is included in the error
132
+ # message when validating a batch, so a rejected record is identifiable.
133
+ def subscriber_payload(attrs, index: nil)
134
+ attrs = normalize_keys(attrs, index)
135
+ validate_known_keys!(attrs, index)
136
+ validate_identifier!(attrs, index)
137
+
138
+ SUBSCRIBER_FIELDS.each_with_object({}) do |field, payload|
139
+ value = coerce_field(field, attrs[field])
140
+ payload[field.to_s] = value unless value.nil?
141
+ end
142
+ end
143
+
144
+ # `false` is a meaningful value for `double_optin`, so only `nil` — the
145
+ # caller having said nothing — omits a field.
146
+ def coerce_field(field, value)
147
+ return nil if value.nil?
148
+
149
+ case field
150
+ when :custom_fields then stringify_custom_fields(value)
151
+ when :segment_ids then validate_segment_ids!(value)
152
+ else value
153
+ end
154
+ end
155
+
156
+ def batch_payload(records)
157
+ # Shape is checked before contents. `each_with_index` over a Hash yields
158
+ # [[key, value], 0], so a single record passed instead of an array used
159
+ # to have its *values* parsed as field names — putting a subscriber
160
+ # email into an error message.
161
+ unless records.is_a?(Array)
162
+ raise ArgumentError,
163
+ "records must be an Array of subscriber attributes, got #{records.class}"
164
+ end
165
+
166
+ raise ArgumentError, "records cannot be empty" if records.empty?
167
+
168
+ if records.size > MAX_BATCH_SIZE
169
+ raise ArgumentError,
170
+ "a batch accepts at most #{MAX_BATCH_SIZE} records, got #{records.size}"
171
+ end
172
+
173
+ records.each_with_index.map { |record, i| subscriber_payload(record, index: i) }
174
+ end
175
+
176
+ # `to_s.to_sym` rather than `to_sym`: a non-symbolizable key (an Integer,
177
+ # say) should surface as an unknown field, not a NoMethodError from deep
178
+ # inside the payload builder.
179
+ def normalize_keys(attrs, index = nil)
180
+ return {} if attrs.nil?
181
+
182
+ unless attrs.is_a?(Hash)
183
+ at = index.nil? ? "" : " at index #{index}"
184
+ raise ArgumentError,
185
+ "subscriber attributes must be a Hash#{at}, got #{attrs.class}"
186
+ end
187
+
188
+ attrs.to_h { |k, v| [k.to_s.to_sym, v] }
189
+ end
190
+
191
+ # An unrecognized key used to be dropped on the floor, so a misspelled
192
+ # `frist_name:` vanished and the request reported success. Silence is the
193
+ # worst outcome here: the caller believes they wrote a field they did not.
194
+ def validate_known_keys!(attrs, index)
195
+ unknown = attrs.keys - SUBSCRIBER_FIELDS
196
+ return if unknown.empty?
197
+
198
+ at = index.nil? ? "" : " at index #{index}"
199
+ noun = unknown.one? ? "field" : "fields"
200
+ raise ArgumentError,
201
+ "unknown subscriber #{noun}#{at}: #{unknown.join(", ")}. " \
202
+ "Accepted: #{SUBSCRIBER_FIELDS.join(", ")}"
203
+ end
204
+
205
+ def validate_identifier!(attrs, index)
206
+ return if present?(attrs[:id]) || present?(attrs[:email])
207
+
208
+ at = index.nil? ? "" : " at index #{index}"
209
+ raise ArgumentError, "either email or id must be provided#{at}"
210
+ end
211
+
212
+ def validate_segment_ids!(segment_ids)
213
+ ids = Array(segment_ids)
214
+ raise ArgumentError, "segment_ids cannot be empty" if ids.empty?
215
+
216
+ if ids.size > MAX_SEGMENTS
217
+ raise ArgumentError,
218
+ "a subscriber accepts at most #{MAX_SEGMENTS} segments, got #{ids.size}"
219
+ end
220
+
221
+ ids.map(&:to_s)
222
+ end
223
+
224
+ # Custom field values are typed `string` throughout the API, so anything
225
+ # else would simply be rejected. Coercing is friendlier than raising and
226
+ # cannot lose information. `nil` is preserved: it means "clear this
227
+ # field", which is a different instruction from the empty string.
228
+ def stringify_custom_fields(fields)
229
+ return nil if fields.nil?
230
+
231
+ fields.to_h { |key, value| [key.to_s, value&.to_s] }
232
+ end
233
+
234
+ def present?(value)
235
+ !value.nil? && !value.to_s.empty?
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Operations under the `Webhook` tag of the Flodesk API.
6
+ #
7
+ # Registering a webhook is only half the job. Flodesk signs nothing — the API
8
+ # description declares `security: []` on all three events — so the endpoint
9
+ # you register must be able to establish authenticity by other means. See
10
+ # {Flodesk::Webhooks::Handler} for the two supported strategies, and choose
11
+ # one before deciding what `post_url` to register here.
12
+ class Webhooks < Base
13
+ PATH = "/webhooks"
14
+
15
+ # GET /webhooks
16
+ def list(page: nil, per_page: nil)
17
+ paginated_list(PATH, klass: Webhook, page: page, per_page: per_page)
18
+ end
19
+
20
+ # Walks every webhook across all pages, fetching each page on demand.
21
+ def auto_paging_each(page: nil, per_page: nil, &)
22
+ each_page_item(PATH, klass: Webhook, page: page, per_page: per_page, &)
23
+ end
24
+
25
+ # GET /webhooks/{id}
26
+ def retrieve(id)
27
+ Webhook.from(get("#{PATH}/#{encode_segment(id)}"))
28
+ end
29
+
30
+ # POST /webhooks — returns 201 with the created webhook.
31
+ #
32
+ # NOT idempotent: this creates a new registration and the API offers no
33
+ # idempotency key, so a retry could leave a duplicate webhook delivering
34
+ # every event twice.
35
+ def create(name:, post_url:, events:)
36
+ raise ArgumentError, "name is required" if blank?(name)
37
+ raise ArgumentError, "post_url is required" if blank?(post_url)
38
+
39
+ Webhook.from(
40
+ post(
41
+ PATH,
42
+ body: {
43
+ "name" => name.to_s,
44
+ "post_url" => post_url.to_s,
45
+ "events" => validate_events!(events)
46
+ },
47
+ idempotent: false
48
+ )
49
+ )
50
+ end
51
+
52
+ # PUT /webhooks/{id}
53
+ #
54
+ # Idempotent: PUT replaces the registration's state, so repeating it
55
+ # converges on the same result. Every field is optional; only those
56
+ # supplied are sent.
57
+ def update(id, name: nil, post_url: nil, events: nil)
58
+ body = {
59
+ "name" => name&.to_s,
60
+ "post_url" => post_url&.to_s,
61
+ "events" => events.nil? ? nil : validate_events!(events)
62
+ }.compact
63
+
64
+ raise ArgumentError, "at least one of name, post_url or events is required" if body.empty?
65
+
66
+ Webhook.from(put("#{PATH}/#{encode_segment(id)}", body: body))
67
+ end
68
+
69
+ # DELETE /webhooks/{id} — returns 204.
70
+ #
71
+ # Idempotent. Returns nil, since a 204 carries no body to parse.
72
+ def delete(id)
73
+ super("#{PATH}/#{encode_segment(id)}")
74
+ end
75
+
76
+ private
77
+
78
+ # Flodesk delivers exactly three events. Rejecting anything else here
79
+ # avoids registering a webhook that can never fire.
80
+ def validate_events!(events)
81
+ list = Array(events).map(&:to_s)
82
+ raise ArgumentError, "events cannot be empty" if list.empty?
83
+
84
+ list.each { |event| validate_enum!("events", event, Enums::WEBHOOK_EVENTS) }
85
+ list
86
+ end
87
+
88
+ def blank?(value)
89
+ value.nil? || value.to_s.empty?
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ module Resources
5
+ # Operations under the `Workflow` tag of the Flodesk API.
6
+ class Workflows < Base
7
+ PATH = "/workflows"
8
+
9
+ # This endpoint spells its page-size parameter `perPage`, unlike every
10
+ # other list endpoint in the API, which use `per_page`. Callers pass
11
+ # `per_page:` regardless; the translation happens here.
12
+ PER_PAGE_KEY = "perPage"
13
+
14
+ # GET /workflows
15
+ #
16
+ # `statuses` accepts a single value or an array.
17
+ def list(page: nil, per_page: nil, statuses: nil)
18
+ paginated_list(
19
+ PATH, klass: Workflow, page: page, per_page: per_page,
20
+ per_page_key: PER_PAGE_KEY, filters: status_filter(statuses)
21
+ )
22
+ end
23
+
24
+ # Walks every workflow across all pages, fetching each page on demand.
25
+ def auto_paging_each(page: nil, per_page: nil, statuses: nil, &)
26
+ each_page_item(
27
+ PATH, klass: Workflow, page: page, per_page: per_page,
28
+ per_page_key: PER_PAGE_KEY, filters: status_filter(statuses), &
29
+ )
30
+ end
31
+
32
+ # POST /workflows/{workflow_id}/subscribers — returns 204.
33
+ #
34
+ # Idempotent: enrolling an already-enrolled subscriber has no further
35
+ # effect. Returns nil, since a 204 carries no body to parse.
36
+ def add_subscriber(workflow_id, id: nil, email: nil)
37
+ raise ArgumentError, "either id or email must be provided" if blank?(id) && blank?(email)
38
+
39
+ post(
40
+ "#{PATH}/#{encode_segment(workflow_id)}/subscribers",
41
+ body: { "id" => id, "email" => email }.compact,
42
+ idempotent: true
43
+ )
44
+ end
45
+
46
+ # DELETE /workflows/{workflow_id}/subscribers/{id_or_email} — returns 204.
47
+ #
48
+ # Idempotent. Returns nil, since a 204 carries no body to parse.
49
+ def remove_subscriber(workflow_id, id_or_email)
50
+ delete(
51
+ "#{PATH}/#{encode_segment(workflow_id)}/subscribers/#{encode_segment(id_or_email)}"
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ # `statuses` is an array parameter, but the API documents it as
58
+ # comma-separated (`statuses=active,paused`) rather than repeated keys.
59
+ # A single value is wrapped so callers can pass either form.
60
+ def status_filter(statuses)
61
+ return { "statuses" => nil } if statuses.nil?
62
+
63
+ validated = Array(statuses).map do |status|
64
+ validate_enum!("statuses", status, Enums::WORKFLOW_STATUSES)
65
+ end
66
+
67
+ { "statuses" => validated.join(",") }
68
+ end
69
+
70
+ def blank?(value)
71
+ value.nil? || value.to_s.empty?
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A completed HTTP response: status, normalized headers, and parsed body.
5
+ #
6
+ # Instances are immutable. `body` is nil for 204 responses, which several
7
+ # endpoints return and which must never be parsed as JSON.
8
+ Response = Data.define(:status, :headers, :body) do
9
+ # Value of `X-Fd-RateLimit-Limit`, or nil when absent.
10
+ def rate_limit
11
+ integer_header("x-fd-ratelimit-limit")
12
+ end
13
+
14
+ # Value of `X-Fd-RateLimit-Remaining`, or nil when absent.
15
+ def rate_limit_remaining
16
+ integer_header("x-fd-ratelimit-remaining")
17
+ end
18
+
19
+ # Flodesk sends no rate-limit reset header, so no reset time exists to
20
+ # report. Present to make the absence explicit rather than surprising.
21
+ def rate_limit_reset
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ def integer_header(name)
28
+ value = headers[name]
29
+ value = value.first if value.is_a?(Array)
30
+ return nil if value.nil? || value.to_s.empty?
31
+
32
+ Integer(value, exception: false)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Decides whether a failed request may be retried.
5
+ #
6
+ # This is a distinct concept in this client because Flodesk offers no
7
+ # idempotency-key header, so the only thing making a retry safe is knowing
8
+ # that the operation is naturally idempotent. That knowledge lives with each
9
+ # endpoint, and this class is where it is applied.
10
+ class RetryPolicy
11
+ # Whether repeating the operation is safe.
12
+ attr_reader :idempotent
13
+
14
+ # Whether a 429 may be retried. False only for POST /campaigns/canva, where
15
+ # a 429 cannot prove the campaign was not accepted.
16
+ attr_reader :retry_rate_limit
17
+
18
+ # Maximum retries after the initial attempt.
19
+ attr_reader :max_retries
20
+
21
+ def initialize(idempotent:, retry_rate_limit:, max_retries:)
22
+ @idempotent = idempotent
23
+ @retry_rate_limit = retry_rate_limit
24
+ @max_retries = max_retries
25
+ freeze
26
+ end
27
+
28
+ # True when `error` may be retried on attempt number `attempt`.
29
+ def retry?(error, attempt)
30
+ attempt <= max_retries && retriable_error?(error)
31
+ end
32
+
33
+ private
34
+
35
+ def retriable_error?(error)
36
+ case error
37
+ when RateLimitError then retry_rate_limit
38
+ when ServerError, TimeoutError, ConnectionError then idempotent
39
+ else
40
+ # Every other 4xx is caused by the request itself and cannot succeed on
41
+ # a second identical attempt.
42
+ false
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "webmock"
5
+
6
+ module Flodesk
7
+ # WebMock stubs and fixture payloads for testing Flodesk integrations.
8
+ #
9
+ # Opt-in: nothing here is loaded by `require "flodesk"`.
10
+ #
11
+ # require "flodesk/test_helpers"
12
+ #
13
+ # RSpec.configure { |c| c.include Flodesk::TestHelpers }
14
+ #
15
+ # stub_flodesk_upsert(email: "a@b.com")
16
+ # stub_flodesk_error(:post, "/subscribers", status: 404)
17
+ #
18
+ # Payload shapes follow the API description, so a stub cannot drift toward
19
+ # whatever the calling code happens to expect — the classic way a green suite
20
+ # hides a broken integration.
21
+ module TestHelpers
22
+ BASE_URL = Flodesk::DEFAULT_BASE_URL
23
+
24
+ # A `SubscriberRes` payload with every documented field populated.
25
+ def flodesk_subscriber(overrides = {})
26
+ {
27
+ "id" => "sub_test_1",
28
+ "status" => "active",
29
+ "email" => "subscriber@example.com",
30
+ "source" => "manual",
31
+ "first_name" => "Test",
32
+ "last_name" => "Subscriber",
33
+ "segments" => [],
34
+ "custom_fields" => {},
35
+ "optin_ip" => "203.0.113.1",
36
+ "optin_timestamp" => "2024-01-01T00:00:00.000Z",
37
+ "created_at" => "2024-01-01T00:00:00.000Z"
38
+ }.merge(stringify(overrides))
39
+ end
40
+
41
+ # A `SegmentRes` payload.
42
+ def flodesk_segment(overrides = {})
43
+ {
44
+ "id" => "seg_test_1",
45
+ "name" => "Test Segment",
46
+ "color" => "#ffeecc",
47
+ "total_active_subscribers" => 0,
48
+ "created_at" => "2024-01-01T00:00:00.000Z"
49
+ }.merge(stringify(overrides))
50
+ end
51
+
52
+ # A `WebhookRes` payload.
53
+ def flodesk_webhook(overrides = {})
54
+ {
55
+ "id" => "wh_test_1",
56
+ "post_url" => "https://example.com/flodesk",
57
+ "events" => ["subscriber.created"],
58
+ "created_at" => "2024-01-01T00:00:00.000Z"
59
+ }.merge(stringify(overrides))
60
+ end
61
+
62
+ # A paginated list envelope, with `meta` shaped as the API returns it.
63
+ def flodesk_list(items, page: 1, per_page: 20, total_pages: 1, total_items: nil)
64
+ {
65
+ "meta" => {
66
+ "page" => page, "per_page" => per_page,
67
+ "total_pages" => total_pages, "total_items" => total_items || items.size
68
+ },
69
+ "data" => items
70
+ }
71
+ end
72
+
73
+ # Stubs a successful subscriber upsert.
74
+ def stub_flodesk_upsert(email: "subscriber@example.com", **overrides)
75
+ stub_flodesk(:post, "/subscribers",
76
+ response: flodesk_subscriber(email: email, **overrides))
77
+ end
78
+
79
+ # Stubs a subscriber retrieval by id or email.
80
+ def stub_flodesk_retrieve(id_or_email, **overrides)
81
+ stub_flodesk(
82
+ :get, "/subscribers/#{encode(id_or_email)}",
83
+ response: flodesk_subscriber(id: id_or_email.to_s, **overrides)
84
+ )
85
+ end
86
+
87
+ # Stubs a subscriber list.
88
+ def stub_flodesk_list_subscribers(subscribers = [flodesk_subscriber], **pagination)
89
+ stub_flodesk(:get, "/subscribers", response: flodesk_list(subscribers, **pagination))
90
+ end
91
+
92
+ # Stubs a batch upsert.
93
+ #
94
+ # Pass `failures:` to exercise partial failure, which is the case worth
95
+ # testing: the API reports it inside a 200, and by default the gem raises
96
+ # {Flodesk::PartialFailureError} for it.
97
+ def stub_flodesk_batch(successes: [flodesk_subscriber], failures: [])
98
+ stub_flodesk(
99
+ :post, "/subscribers/batch",
100
+ response: { "successes" => successes, "failures" => failures }
101
+ )
102
+ end
103
+
104
+ # A `BatchItemError` payload.
105
+ def flodesk_batch_failure(index: 0, code: "invalid_email", **overrides)
106
+ {
107
+ "index" => index,
108
+ "email" => "bad@",
109
+ "id" => nil,
110
+ "code" => code,
111
+ "message" => "Email is invalid"
112
+ }.merge(stringify(overrides))
113
+ end
114
+
115
+ # Stubs an error response using the live API's `{code, message}` envelope.
116
+ def stub_flodesk_error(method, path, status:, code: nil, message: nil)
117
+ stub_flodesk(
118
+ method, path, status: status,
119
+ response: { "code" => code || "error", "message" => message || "Request failed" }
120
+ )
121
+ end
122
+
123
+ # Stubs a rate-limited response, including the headers the API sends. There
124
+ # is deliberately no reset header, because the API does not send one.
125
+ def stub_flodesk_rate_limited(method, path, limit: 100)
126
+ stub_flodesk(
127
+ method, path, status: 429,
128
+ response: { "code" => "rate_limited", "message" => "Too many requests" },
129
+ headers: { "X-Fd-RateLimit-Limit" => limit.to_s, "X-Fd-RateLimit-Remaining" => "0" }
130
+ )
131
+ end
132
+
133
+ # The general-purpose stub the helpers above are built on.
134
+ def stub_flodesk(method, path, response: nil, status: 200, headers: {}, base_url: BASE_URL)
135
+ WebMock::API.stub_request(method, "#{base_url}#{path}").to_return(
136
+ status: status,
137
+ body: response.nil? ? "" : JSON.generate(response),
138
+ headers: { "Content-Type" => "application/json" }.merge(headers)
139
+ )
140
+ end
141
+
142
+ private
143
+
144
+ def stringify(hash)
145
+ hash.to_h { |k, v| [k.to_s, v] }
146
+ end
147
+
148
+ def encode(value)
149
+ value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/n) { |c| format("%%%02X", c.ord) }
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ VERSION = "0.1.0"
5
+ end