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,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Flodesk
8
+ # Executes HTTP requests against the Flodesk API.
9
+ #
10
+ # A Connection holds no per-request mutable state, so one instance is safe to
11
+ # share across threads. Each request opens its own `Net::HTTP` session rather
12
+ # than reusing a socket: simple, and correct under concurrency. Connection
13
+ # pooling is a possible later optimization, not a v1 requirement.
14
+ class Connection
15
+ # Exceptions Net::HTTP raises when a request never completed.
16
+ TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
17
+
18
+ CONNECTION_ERRORS = [
19
+ EOFError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH,
20
+ Errno::ENETUNREACH, IOError, SocketError
21
+ ].freeze
22
+
23
+ def initialize(client)
24
+ @client = client
25
+ freeze
26
+ end
27
+
28
+ # Issues a request and returns a Response, retrying per the retry policy.
29
+ #
30
+ # `idempotent` declares whether repeating this operation is safe.
31
+ #
32
+ # For POST it must be stated explicitly and defaults to false, because in
33
+ # this API retry safety is a property of the endpoint rather than the verb:
34
+ # POST /subscribers and POST /subscribers/batch are upserts and safe to
35
+ # repeat, while POST /segments, POST /custom-fields, POST /webhooks and
36
+ # POST /campaigns/canva all create, so repeating them duplicates a record or
37
+ # sends a campaign twice. Assuming POST is retryable is the single most
38
+ # damaging mistake a client of this API can make.
39
+ #
40
+ # GET, PUT and DELETE are idempotent by HTTP definition and default to true.
41
+ def request(method, path, query: nil, body: nil, idempotent: nil, retry_rate_limit: true)
42
+ policy = RetryPolicy.new(
43
+ idempotent: idempotent.nil? ? method != :post : idempotent,
44
+ retry_rate_limit: retry_rate_limit,
45
+ max_retries: @client.max_retries
46
+ )
47
+ attempt = 0
48
+
49
+ loop do
50
+ attempt += 1
51
+ error = attempt_once(method, path, query, body, attempt) { |response| return response }
52
+
53
+ raise error unless policy.retry?(error, attempt)
54
+
55
+ backoff(attempt)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ # Yields the response to the caller's block on success. Returns the error to
62
+ # weigh for retry on failure, rather than raising, so the retry decision is
63
+ # made in one place.
64
+ def attempt_once(method, path, query, body, attempt)
65
+ response = execute(method, path, query, body, attempt)
66
+ if response.status < 400
67
+ yield response
68
+ return nil
69
+ end
70
+
71
+ Error.from_response(
72
+ status: response.status, body: response.body, headers: response.headers
73
+ )
74
+ rescue *TIMEOUT_ERRORS => e
75
+ TimeoutError.new("#{method.to_s.upcase} #{Redaction.path(path)} timed out (#{e.class})")
76
+ rescue *CONNECTION_ERRORS => e
77
+ ConnectionError.new("#{method.to_s.upcase} #{Redaction.path(path)} failed (#{e.class})")
78
+ end
79
+
80
+ def execute(method, path, query, body, attempt)
81
+ uri = build_uri(path, query)
82
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+
84
+ raw = http_for(uri).request(build_request(method, uri, body))
85
+ response = to_response(raw)
86
+
87
+ Instrumentation.request(
88
+ method: method,
89
+ path: path,
90
+ status: response.status,
91
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started,
92
+ attempt: attempt,
93
+ rate_limit_remaining: response.rate_limit_remaining
94
+ )
95
+ RateLimit.record(@client, response)
96
+ response
97
+ end
98
+
99
+ def build_uri(path, query)
100
+ uri = URI.parse("#{@client.base_url}#{path}")
101
+ uri.query = URI.encode_www_form(query) if query && !query.empty?
102
+ uri
103
+ end
104
+
105
+ def build_request(method, uri, body)
106
+ request = Net::HTTP.const_get(method.to_s.capitalize).new(uri)
107
+
108
+ request["Accept"] = "application/json"
109
+ request["User-Agent"] = @client.user_agent
110
+ @client.auth.apply(request)
111
+
112
+ if body
113
+ request["Content-Type"] = "application/json"
114
+ request.body = JSON.generate(body)
115
+ end
116
+
117
+ request
118
+ end
119
+
120
+ def http_for(uri)
121
+ http = Net::HTTP.new(uri.host, uri.port)
122
+ http.use_ssl = uri.scheme == "https"
123
+ http.open_timeout = @client.open_timeout
124
+ http.read_timeout = @client.read_timeout
125
+ http
126
+ end
127
+
128
+ def to_response(raw)
129
+ status = raw.code.to_i
130
+
131
+ Response.new(
132
+ status: status,
133
+ headers: raw.each_header.to_h { |k, v| [k.to_s.downcase, v] },
134
+ body: parse_body(raw, status)
135
+ )
136
+ end
137
+
138
+ # 204 responses carry no body, and several endpoints use them; parsing one
139
+ # as JSON would raise on an empty string.
140
+ def parse_body(raw, status)
141
+ return nil if status == 204
142
+
143
+ text = raw.body
144
+ return nil if text.nil? || text.strip.empty?
145
+
146
+ begin
147
+ JSON.parse(text)
148
+ rescue JSON::ParserError
149
+ # A 2xx that is not parseable is a genuine failure. An error response
150
+ # with an HTML body (from an edge proxy, say) must still become a typed
151
+ # error, so hand the raw text through for the error to carry.
152
+ raise Error, "Malformed JSON in #{status} response" if status < 400
153
+
154
+ text
155
+ end
156
+ end
157
+
158
+ def backoff(attempt)
159
+ delay = backoff_delay(attempt)
160
+ sleep(delay) if delay
161
+ end
162
+
163
+ # Exponential backoff with jitter, capped at the rate-limit window length.
164
+ #
165
+ # Flodesk sends no rate-limit reset header, so no correct wait is
166
+ # computable; this is a documented heuristic. Returns nil when backoff is
167
+ # disabled. Kept pure and separate from the sleep so it can be asserted
168
+ # directly without a timing-dependent test.
169
+ def backoff_delay(attempt)
170
+ base = @client.backoff_base
171
+ return nil if base.nil? || base.zero?
172
+
173
+ capped = [base * (2**(attempt - 1)), MAX_BACKOFF_SECONDS].min
174
+ # Jitter over the lower half, so concurrent callers desynchronize instead
175
+ # of retrying in lockstep.
176
+ capped * (0.5 + (Kernel.rand * 0.5))
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Closed enumerations documented by the API description.
5
+ #
6
+ # These live in one namespace rather than on the individual value objects for
7
+ # two reasons. Constants assigned inside a `Data.define do ... end` block bind
8
+ # to the *enclosing lexical scope*, not to the resulting class — so a
9
+ # `STATUSES` written inside two such blocks silently defines and then clobbers
10
+ # a single `Flodesk::STATUSES`. Collecting them here also gives the contract
11
+ # spec one place to verify against `openapi.json`.
12
+ module Enums
13
+ # `SubscriberRes.status`. `bounced`, `complained` and `cleaned` are terminal
14
+ # delivery states rather than subscriber actions; `archived` is an action
15
+ # the account owner took on the record.
16
+ #
17
+ # Order is significant: the contract spec compares this against the
18
+ # specification's enum array for equality, not as a set.
19
+ SUBSCRIBER_STATUSES = %w[
20
+ active unsubscribed unconfirmed bounced complained cleaned archived
21
+ ].freeze
22
+
23
+ # `SubscriberRes.source`.
24
+ SUBSCRIBER_SOURCES = %w[manual csv form_optin integration checkout].freeze
25
+
26
+ # The `statuses` filter on `GET /workflows`. Note that the workflow response
27
+ # schema exposes no status field, so these are only ever sent, never parsed.
28
+ WORKFLOW_STATUSES = %w[active paused draft].freeze
29
+
30
+ # `CampaignItem.status`, also accepted as the `Status` list filter.
31
+ CAMPAIGN_STATUSES = %w[
32
+ draft pending scheduled composing sending done failed
33
+ ].freeze
34
+
35
+ # The only events Flodesk can deliver to a webhook.
36
+ WEBHOOK_EVENTS = %w[
37
+ subscriber.created
38
+ subscriber.added_to_segment
39
+ subscriber.unsubscribed
40
+ ].freeze
41
+ end
42
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Base class for everything this gem raises. Rescue this to catch any Flodesk
5
+ # failure, including transport-level ones.
6
+ class Error < StandardError
7
+ # Maps an HTTP response to the appropriate error instance.
8
+ #
9
+ # The Flodesk OpenAPI description declares *no* schema for any error
10
+ # response — every 4xx documents only an empty description. The
11
+ # `{"code": ..., "message": ...}` envelope parsed here was established by
12
+ # probing the live API, so it is not contract-verifiable and must never be
13
+ # assumed present. An unexpected body degrades to a status-derived message
14
+ # rather than raising while building the error.
15
+ def self.from_response(status:, body: nil, headers: {})
16
+ klass_for(status).new(status: status, body: body, headers: headers)
17
+ end
18
+
19
+ def self.klass_for(status)
20
+ case status
21
+ when 400 then BadRequestError
22
+ when 401, 403 then AuthenticationError
23
+ when 404 then NotFoundError
24
+ when 429 then RateLimitError
25
+ when 500..599 then ServerError
26
+ else APIError
27
+ end
28
+ end
29
+ private_class_method :klass_for
30
+ end
31
+
32
+ # Raised when Flodesk returns an HTTP error response, as opposed to the
33
+ # request failing before a response was received.
34
+ class APIError < Error
35
+ # The HTTP status code of the response.
36
+ attr_reader :status
37
+
38
+ # The `code` field from the error envelope, or nil when absent.
39
+ attr_reader :code
40
+
41
+ # The response body exactly as parsed, for debugging unexpected shapes.
42
+ attr_reader :raw_body
43
+
44
+ def initialize(status:, body: nil, headers: {})
45
+ @status = status
46
+ @raw_body = body
47
+ @headers = normalize_headers(headers)
48
+ @code = extract(body, "code")
49
+
50
+ super(extract(body, "message") || "HTTP #{status}")
51
+ end
52
+
53
+ # Value of `X-Fd-RateLimit-Limit`, or nil when the header was absent.
54
+ def rate_limit
55
+ integer_header("x-fd-ratelimit-limit")
56
+ end
57
+
58
+ # Value of `X-Fd-RateLimit-Remaining`, or nil when the header was absent.
59
+ def rate_limit_remaining
60
+ integer_header("x-fd-ratelimit-remaining")
61
+ end
62
+
63
+ private
64
+
65
+ # Reads a field from the envelope, tolerating any body shape. A body may be
66
+ # a parsed Hash, an HTML string from an edge proxy, or nil.
67
+ def extract(body, key)
68
+ return nil unless body.is_a?(Hash)
69
+
70
+ value = body[key] || body[key.to_sym]
71
+ value.is_a?(String) && !value.empty? ? value : nil
72
+ end
73
+
74
+ def normalize_headers(headers)
75
+ return {} unless headers.respond_to?(:each_pair)
76
+
77
+ headers.each_pair.to_h { |k, v| [k.to_s.downcase, v] }
78
+ end
79
+
80
+ def integer_header(name)
81
+ value = @headers[name]
82
+ value = value.first if value.is_a?(Array)
83
+ return nil if value.nil? || value.to_s.empty?
84
+
85
+ Integer(value, exception: false)
86
+ end
87
+ end
88
+
89
+ # 400 — the request payload was rejected. Never retried: the same payload
90
+ # cannot succeed on a second attempt.
91
+ class BadRequestError < APIError; end
92
+
93
+ # 401 or 403 — the API key was missing, malformed, or not accepted.
94
+ class AuthenticationError < APIError; end
95
+
96
+ # 404 — the addressed resource does not exist.
97
+ class NotFoundError < APIError; end
98
+
99
+ # 5xx — Flodesk failed to process an otherwise valid request.
100
+ class ServerError < APIError; end
101
+
102
+ # 429 — the rate limit was exceeded.
103
+ #
104
+ # Flodesk returns `X-Fd-RateLimit-Limit` and `X-Fd-RateLimit-Remaining` but
105
+ # *no reset header*, so there is no way to compute when the window reopens.
106
+ # `retry_after` therefore always returns nil, and any backoff this gem applies
107
+ # is a documented heuristic rather than a computed wait.
108
+ class RateLimitError < APIError
109
+ # Always nil: the API exposes no reset time. Present so callers can ask
110
+ # without special-casing, and to document the absence explicitly.
111
+ def retry_after
112
+ nil
113
+ end
114
+ end
115
+
116
+ # The request failed before a response was received.
117
+ class ConnectionError < Error; end
118
+
119
+ # The request exceeded a configured connect or read timeout.
120
+ #
121
+ # For non-idempotent operations a timeout is genuinely ambiguous: the write
122
+ # may have been applied. Such operations are never retried.
123
+ class TimeoutError < Error; end
124
+
125
+ # Raised when a batch operation returns 200 while reporting per-record
126
+ # failures. Carries the complete result, so records that succeeded remain
127
+ # available to the caller and no work is lost by raising.
128
+ class PartialFailureError < Error
129
+ # The BatchResult describing both successes and failures.
130
+ attr_reader :result
131
+
132
+ def initialize(result:)
133
+ @result = result
134
+
135
+ super("#{result.failures.size} of " \
136
+ "#{result.failures.size + result.successes.size} records failed")
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # Emits `flodesk.request` notifications when ActiveSupport is available.
5
+ #
6
+ # One event is emitted per *attempt*, not per logical call, so retries are
7
+ # visible in whatever collects them. Payloads carry no PII: no request body, no
8
+ # response body, no API key, and any email embedded in a path is redacted.
9
+ module Instrumentation
10
+ EVENT_NAME = "flodesk.request"
11
+
12
+ module_function
13
+
14
+ # True when ActiveSupport::Notifications can be published to. Checked per
15
+ # call rather than cached, since a host application may load ActiveSupport
16
+ # after this gem.
17
+ def available?
18
+ defined?(ActiveSupport::Notifications) ? true : false
19
+ end
20
+
21
+ def request(method:, path:, status:, duration:, attempt:, rate_limit_remaining:)
22
+ return unless available?
23
+
24
+ ActiveSupport::Notifications.instrument(
25
+ EVENT_NAME,
26
+ method: method.to_s.upcase,
27
+ endpoint: Redaction.path(path),
28
+ status: status,
29
+ duration: duration,
30
+ attempt: attempt,
31
+ rate_limit_remaining: rate_limit_remaining
32
+ )
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A single record's failure within a batch operation.
5
+ #
6
+ # `index` is the zero-based position in the submitted array, which is what
7
+ # makes a failure actionable: it identifies which of your inputs was rejected
8
+ # even when neither `id` nor `email` came back.
9
+ BatchItemError = Data.define(:index, :email, :id, :code, :message, :raw) do
10
+ def self.from(payload)
11
+ return nil if payload.nil?
12
+
13
+ new(
14
+ index: payload["index"],
15
+ email: payload["email"],
16
+ id: payload["id"],
17
+ code: payload["code"],
18
+ message: payload["message"],
19
+ raw: Coercion.snapshot(payload)
20
+ )
21
+ end
22
+
23
+ # The payload exactly as the API sent it, including any field this gem does
24
+ # not declare.
25
+ def to_h
26
+ raw
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # The outcome of a batch operation.
5
+ #
6
+ # `POST /subscribers/batch` is the only endpoint in this API where success and
7
+ # failure arrive in the *same* 200 response. A client that treats 2xx as
8
+ # success silently drops subscribers, which is why this is a first-class object
9
+ # with an explicit {#success?} rather than a bare hash, and why
10
+ # `batch_upsert` raises {PartialFailureError} unless told not to.
11
+ BatchResult = Data.define(:successes, :failures, :raw) do
12
+ def self.from(payload)
13
+ payload ||= {}
14
+
15
+ new(
16
+ successes: Coercion.array_of(Subscriber, payload["successes"]),
17
+ failures: Coercion.array_of(BatchItemError, payload["failures"]),
18
+ raw: Coercion.snapshot(payload)
19
+ )
20
+ end
21
+
22
+ # True when every submitted record was accepted.
23
+ def success?
24
+ failures.empty?
25
+ end
26
+
27
+ # Total records accounted for in the response.
28
+ def size
29
+ successes.size + failures.size
30
+ end
31
+
32
+ # Emails of the records that failed, for assembling a retry batch. Failures
33
+ # that carry no email — matched by id — are omitted.
34
+ def failed_emails
35
+ failures.filter_map(&:email)
36
+ end
37
+
38
+ # The payload exactly as the API sent it, including any field this gem does
39
+ # not declare.
40
+ def to_h
41
+ raw
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # An email campaign.
5
+ Campaign = Data.define(:id, :name, :subject, :status, :created_at, :updated_at, :raw) do
6
+ def self.from(payload)
7
+ return nil if payload.nil?
8
+
9
+ new(
10
+ id: payload["id"],
11
+ name: payload["name"],
12
+ subject: payload["subject"],
13
+ # Coerced to a symbol like the subscriber enums, despite the list
14
+ # endpoint spelling its filter parameter `Status`.
15
+ status: Coercion.enum(payload["status"], Enums::CAMPAIGN_STATUSES),
16
+ created_at: Coercion.time(payload["created_at"]),
17
+ updated_at: Coercion.time(payload["updated_at"]),
18
+ raw: Coercion.snapshot(payload)
19
+ )
20
+ end
21
+
22
+ # The payload exactly as the API sent it, including any field this gem does
23
+ # not declare.
24
+ def to_h
25
+ raw
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A custom field definition.
5
+ #
6
+ # The API identifies custom fields by `key`, not by an id. Values stored
7
+ # against a field are always strings.
8
+ CustomField = Data.define(:key, :label, :raw) do
9
+ def self.from(payload)
10
+ return nil if payload.nil?
11
+
12
+ new(key: payload["key"], label: payload["label"], raw: Coercion.snapshot(payload))
13
+ end
14
+
15
+ # The payload exactly as the API sent it, including any field this gem does
16
+ # not declare.
17
+ def to_h
18
+ raw
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # One page of a list response.
5
+ #
6
+ # Holds the items plus the `meta` block the API returns. A Page never fetches
7
+ # anything by itself: `list` issues exactly one request, and traversing
8
+ # further pages is the explicit opt-in of `auto_paging_each`. Walking a large
9
+ # collection can consume the whole 100-requests-per-minute budget, and that
10
+ # cost should be visible at the call site rather than hidden behind `each`.
11
+ Page = Data.define(
12
+ :items, :page, :per_page, :total_pages, :total_items, :raw, :fetcher
13
+ ) do
14
+ include Enumerable
15
+
16
+ # `fetcher` is a callable taking a page number and returning the next Page.
17
+ # It is supplied by the resource that built this Page, and is what
18
+ # `auto_paging_each` walks.
19
+ def self.from(payload, klass:, fetcher: nil)
20
+ payload ||= {}
21
+ meta = payload["meta"] || {}
22
+
23
+ new(
24
+ items: Coercion.array_of(klass, payload["data"]),
25
+ page: meta["page"],
26
+ per_page: meta["per_page"],
27
+ total_pages: meta["total_pages"],
28
+ total_items: meta["total_items"],
29
+ raw: Coercion.snapshot(payload),
30
+ fetcher: fetcher
31
+ )
32
+ end
33
+
34
+ def each(&)
35
+ items.each(&)
36
+ end
37
+
38
+ def empty?
39
+ items.empty?
40
+ end
41
+
42
+ # True when the metadata shows a page after this one. False when metadata is
43
+ # absent, since nothing then indicates another page exists.
44
+ def more_pages?
45
+ return false if page.nil? || total_pages.nil?
46
+
47
+ page < total_pages
48
+ end
49
+
50
+ # The payload exactly as the API sent it.
51
+ def to_h
52
+ raw
53
+ end
54
+
55
+ # Walks this page and every page after it, fetching each on demand.
56
+ #
57
+ # Returns a lazy Enumerator, so `.first(5)` fetches only what it needs
58
+ # instead of the whole collection. Deliberately not the behavior of `each`.
59
+ def auto_paging_each(&block)
60
+ return to_enum(:auto_paging_each) unless block_given?
61
+
62
+ current = self
63
+ loop do
64
+ current.items.each(&block)
65
+ break unless current.more_pages? && current.fetcher
66
+
67
+ current = current.fetcher.call(current.page + 1)
68
+ break if current.nil?
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A segment.
5
+ #
6
+ # Covers both `SegmentRes` and the smaller `SegmentMini` the API nests inside
7
+ # subscriber payloads: fields absent from the narrower shape simply read nil.
8
+ #
9
+ # `segment_type` is `"static"` or `"dynamic"`. It is deliberately not coerced
10
+ # to a Symbol: the specification documents those two values in prose only and
11
+ # declares no enum array, so there is nothing to validate against and
12
+ # inventing a closed set here would be a guess.
13
+ Segment = Data.define(
14
+ :id, :name, :color, :total_active_subscribers, :created_at, :segment_type, :raw
15
+ ) do
16
+ def self.from(payload)
17
+ return nil if payload.nil?
18
+
19
+ new(
20
+ id: payload["id"],
21
+ name: payload["name"],
22
+ color: payload["color"],
23
+ total_active_subscribers: payload["total_active_subscribers"],
24
+ created_at: Coercion.time(payload["created_at"]),
25
+ segment_type: payload["segment_type"],
26
+ raw: Coercion.snapshot(payload)
27
+ )
28
+ end
29
+
30
+ # The payload exactly as the API sent it, including any field this gem does
31
+ # not declare.
32
+ def to_h
33
+ raw
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A subscriber.
5
+ Subscriber = Data.define(
6
+ :id, :status, :email, :source, :first_name, :last_name,
7
+ :segments, :custom_fields, :optin_ip, :optin_timestamp, :created_at, :raw
8
+ ) do
9
+ def self.from(payload)
10
+ return nil if payload.nil?
11
+
12
+ new(
13
+ id: payload["id"],
14
+ status: Coercion.enum(payload["status"], Enums::SUBSCRIBER_STATUSES),
15
+ email: payload["email"],
16
+ source: Coercion.enum(payload["source"], Enums::SUBSCRIBER_SOURCES),
17
+ first_name: payload["first_name"],
18
+ last_name: payload["last_name"],
19
+ segments: Coercion.array_of(Segment, payload["segments"]),
20
+ custom_fields: Coercion.string_hash(payload["custom_fields"]),
21
+ optin_ip: payload["optin_ip"],
22
+ optin_timestamp: Coercion.time(payload["optin_timestamp"]),
23
+ created_at: Coercion.time(payload["created_at"]),
24
+ raw: Coercion.snapshot(payload)
25
+ )
26
+ end
27
+
28
+ # The payload exactly as the API sent it, including any field this gem does
29
+ # not declare.
30
+ def to_h
31
+ raw
32
+ end
33
+
34
+ # True when this subscriber can receive marketing email.
35
+ def active?
36
+ status == :active
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flodesk
4
+ # A registered webhook.
5
+ Webhook = Data.define(:id, :post_url, :events, :created_at, :raw) do
6
+ def self.from(payload)
7
+ return nil if payload.nil?
8
+
9
+ new(
10
+ id: payload["id"],
11
+ post_url: payload["post_url"],
12
+ events: Coercion.snapshot(payload["events"] || []),
13
+ created_at: Coercion.time(payload["created_at"]),
14
+ raw: Coercion.snapshot(payload)
15
+ )
16
+ end
17
+
18
+ # The payload exactly as the API sent it, including any field this gem does
19
+ # not declare.
20
+ def to_h
21
+ raw
22
+ end
23
+ end
24
+ end