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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Flodesk
6
+ module Webhooks
7
+ # A verified inbound webhook event.
8
+ #
9
+ # `subscriber` is a {Flodesk::Subscriber}. Under the refetch strategy it is
10
+ # the authoritative record read back from the API rather than the copy in the
11
+ # payload.
12
+ # Declared with a real class body rather than `Data.define(...) do ... end`,
13
+ # because this class owns a constant. Constants assigned inside that block
14
+ # form bind to the *enclosing lexical scope* — they would land on
15
+ # Flodesk::Webhooks, not on Event. Subclassing opens a proper namespace.
16
+ #
17
+ # Style/DataInheritance is disabled deliberately: following it here would
18
+ # reintroduce exactly that scoping bug.
19
+ class Event < Data.define(:event_name, :event_time, :subscriber, :segment, # rubocop:disable Style/DataInheritance
20
+ :webhook_id, :raw)
21
+ # Field separator for the composed dedupe key.
22
+ #
23
+ # NUL rather than a space, because it cannot occur inside any of the joined
24
+ # values. A printable separator would let distinct events collide: with a
25
+ # space, ["a b", "c"] and ["a", "b c"] both join to "a b c".
26
+ #
27
+ # Written as an escape deliberately — an earlier automated edit left a raw
28
+ # NUL byte here, which made git treat this source file as binary.
29
+ KEY_SEPARATOR = "\0"
30
+
31
+ def self.from(payload, subscriber: nil)
32
+ new(
33
+ event_name: payload["event_name"],
34
+ event_time: Coercion.time(payload["event_time"]),
35
+ subscriber: subscriber || Subscriber.from(payload["subscriber"]),
36
+ segment: Segment.from(payload["segment"]),
37
+ webhook_id: payload["webhook_id"],
38
+ raw: Coercion.snapshot(payload)
39
+ )
40
+ end
41
+
42
+ # The payload exactly as delivered, including any field this gem does not
43
+ # declare — so a newly introduced Flodesk event stays usable.
44
+ def to_h
45
+ raw
46
+ end
47
+
48
+ # True when `event_name` is one of the three events Flodesk documents.
49
+ def known?
50
+ Enums::WEBHOOK_EVENTS.include?(event_name)
51
+ end
52
+
53
+ # A stable key for rejecting duplicate deliveries.
54
+ #
55
+ # Flodesk's event schemas define no unique event id, so the key is composed
56
+ # from the fields that together identify one delivery. It is hashed for two
57
+ # reasons: it is a fixed-length string regardless of input, and it carries
58
+ # no raw email or IP address into whatever store the application persists
59
+ # it in.
60
+ #
61
+ # The gem does not deduplicate. Only the application has storage, so
62
+ # rejecting replays is its responsibility.
63
+ def dedupe_key
64
+ Digest::SHA256.hexdigest(
65
+ [webhook_id, event_name, subscriber&.id, raw["event_time"]].join(KEY_SEPARATOR)
66
+ )
67
+ end
68
+
69
+ # Redacted: events carry `email` and `optin_ip`, and inspect output reaches
70
+ # logs and error pages.
71
+ def inspect
72
+ "#<#{self.class.name} event_name=#{event_name.inspect} " \
73
+ "webhook_id=#{webhook_id.inspect} subscriber_id=#{subscriber&.id.inspect}>"
74
+ end
75
+ alias to_s inspect
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Flodesk
7
+ # Handling of inbound webhook deliveries from Flodesk.
8
+ module Webhooks
9
+ # The verification strategies a handler can be built with.
10
+ STRATEGIES = %i[token refetch].freeze
11
+
12
+ # Verifies and parses inbound webhook deliveries.
13
+ #
14
+ # A verification strategy is **mandatory**. Flodesk signs nothing, so a
15
+ # handler that accepted any POST would let anyone who learns the callback URL
16
+ # forge subscriber events. Constructing without choosing a strategy raises
17
+ # rather than defaulting to trust.
18
+ #
19
+ # Token in the callback path — cheap, no extra API call:
20
+ #
21
+ # token = Flodesk::Webhooks::Handler.generate_token
22
+ # handler = Flodesk::Webhooks::Handler.new(token: token)
23
+ # # register post_url "https://app.example.com/flodesk/#{token}"
24
+ # event = handler.call(body: request.raw_post, token: params[:token])
25
+ #
26
+ # Note that the token then appears in server access logs and Rails request
27
+ # logs, so filter that route.
28
+ #
29
+ # Re-fetch — forgery-proof, costs one API call per event against the
30
+ # 100-per-minute budget:
31
+ #
32
+ # handler = Flodesk::Webhooks::Handler.new(verify: :refetch, client: FLODESK)
33
+ # event = handler.call(body: request.raw_post)
34
+ class Handler
35
+ # A URL-safe random token suitable for embedding in a callback path.
36
+ def self.generate_token
37
+ Verification.generate_token
38
+ end
39
+
40
+ def initialize(token: nil, verify: nil, client: nil)
41
+ @strategy = resolve_strategy(token: token, verify: verify)
42
+ validate_strategy!(token: token, client: client)
43
+
44
+ @token = token
45
+ @client = client
46
+ freeze
47
+ end
48
+
49
+ # Verifies `body` and returns a parsed {Event}.
50
+ #
51
+ # Raises {VerificationError} if authenticity cannot be established, before
52
+ # the body is parsed. Raises {InvalidPayloadError} if a verified body
53
+ # cannot be understood.
54
+ def call(body:, token: nil)
55
+ verify_token!(token) if @strategy == :token
56
+
57
+ payload = parse!(body)
58
+
59
+ if @strategy == :refetch
60
+ Event.from(payload, subscriber: refetch!(payload))
61
+ else
62
+ Event.from(payload)
63
+ end
64
+ end
65
+
66
+ # Verifies, parses, dispatches, and maps the outcome to a Rack triple.
67
+ #
68
+ # Flodesk treats any 2XX as "delivered" and anything else as "retry
69
+ # later", so the status is the only backpressure signal available. An
70
+ # exception from the application's block therefore becomes a 500: the
71
+ # event was not processed, and Flodesk should try again rather than
72
+ # consider it delivered.
73
+ #
74
+ # post "/flodesk/:token" do
75
+ # status, headers, body = HANDLER.respond(
76
+ # body: request.raw_post, token: params[:token]
77
+ # ) { |event| SubscriberSync.perform_later(event.subscriber.id) }
78
+ # end
79
+ #
80
+ # `on_error` receives any exception raised by the block, so the
81
+ # application can report its own failures rather than have them swallowed.
82
+ def respond(body:, token: nil, on_error: nil)
83
+ event = call(body: body, token: token)
84
+ yield event if block_given?
85
+ rack(200, "ok")
86
+ rescue VerificationError
87
+ rack(401, "unauthorized")
88
+ rescue InvalidPayloadError
89
+ rack(400, "bad request")
90
+ rescue StandardError => e
91
+ on_error&.call(e)
92
+ rack(500, "error")
93
+ end
94
+
95
+ private
96
+
97
+ # Response bodies are deliberately bare: the request payload holds PII and
98
+ # the token is a secret, so neither may be echoed back.
99
+ def rack(status, text)
100
+ [status, { "content-type" => "text/plain" }, [text]]
101
+ end
102
+
103
+ def resolve_strategy(token:, verify:)
104
+ return verify.to_sym if verify
105
+
106
+ return :token if token
107
+
108
+ raise ArgumentError,
109
+ "a verification strategy is required: pass token: for token-in-path " \
110
+ "verification, or verify: :refetch with a client. Flodesk does not sign " \
111
+ "webhooks, so unverified payloads cannot be trusted."
112
+ end
113
+
114
+ def validate_strategy!(token:, client:)
115
+ raise ArgumentError, "unknown verification strategy #{@strategy.inspect}" unless STRATEGIES.include?(@strategy)
116
+
117
+ validate_token!(token) if @strategy == :token
118
+
119
+ return unless @strategy == :refetch && client.nil?
120
+
121
+ raise ArgumentError, "verify: :refetch requires a client to re-fetch the subscriber"
122
+ end
123
+
124
+ def validate_token!(token)
125
+ return unless token.nil? || token.to_s.length < Verification::MIN_TOKEN_LENGTH
126
+
127
+ raise ArgumentError,
128
+ "token must be at least #{Verification::MIN_TOKEN_LENGTH} characters; " \
129
+ "use #{self.class.name}.generate_token"
130
+ end
131
+
132
+ # Raises before parsing, so a forged request's body is never interpreted.
133
+ def verify_token!(supplied)
134
+ raise VerificationError unless Verification.secure_compare(@token, supplied)
135
+ end
136
+
137
+ def parse!(body)
138
+ raise InvalidPayloadError, "webhook body was empty" if body.nil? || body.to_s.strip.empty?
139
+
140
+ payload = begin
141
+ JSON.parse(body)
142
+ rescue JSON::ParserError
143
+ # The message deliberately omits the body, which holds PII.
144
+ raise InvalidPayloadError, "webhook body was not valid JSON"
145
+ end
146
+
147
+ raise InvalidPayloadError, "webhook body was not a JSON object" unless payload.is_a?(Hash)
148
+
149
+ payload
150
+ end
151
+
152
+ # Treats the payload as an untrusted hint: takes only the subscriber id and
153
+ # reads the authoritative record back from the API.
154
+ def refetch!(payload)
155
+ id = payload.dig("subscriber", "id")
156
+ raise VerificationError if id.nil? || id.to_s.empty?
157
+
158
+ begin
159
+ @client.subscribers.retrieve(id)
160
+ rescue Flodesk::NotFoundError
161
+ # A subscriber that does not exist means the payload was not genuine.
162
+ raise VerificationError
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Flodesk
6
+ module Webhooks
7
+ # Raised when an inbound request cannot be established as genuine.
8
+ #
9
+ # Messages deliberately carry no detail: the payload holds PII and the
10
+ # comparison involves a secret, neither of which may reach a log.
11
+ class VerificationError < Error
12
+ def initialize(msg = "Webhook verification failed")
13
+ super
14
+ end
15
+ end
16
+
17
+ # Raised when a verified request's body cannot be understood.
18
+ class InvalidPayloadError < Error; end
19
+
20
+ # Helpers for establishing that an inbound webhook is genuine.
21
+ #
22
+ # Flodesk signs nothing. The API description declares `security: []` on all
23
+ # three webhook events, so there is no signature header to check and anyone
24
+ # who learns a callback URL can forge a delivery. The two viable defenses are
25
+ # a secret embedded in the callback path, and re-fetching the subscriber and
26
+ # trusting only that. IP allowlisting is not viable: Flodesk publishes no
27
+ # ranges.
28
+ module Verification
29
+ # Minimum acceptable length for a path token. 32 characters of the
30
+ # generated alphabet is comfortably beyond guessing.
31
+ MIN_TOKEN_LENGTH = 32
32
+
33
+ # Bytes of entropy for a generated token.
34
+ TOKEN_BYTES = 32
35
+
36
+ module_function
37
+
38
+ # A URL-safe random token suitable for embedding in a callback path.
39
+ def generate_token
40
+ SecureRandom.urlsafe_base64(TOKEN_BYTES).delete("=")
41
+ end
42
+
43
+ # Compares two strings in constant time, so response timing cannot be used
44
+ # to recover the expected value one character at a time.
45
+ def secure_compare(expected, supplied)
46
+ return false if expected.nil? || supplied.nil?
47
+
48
+ expected = expected.to_s.b
49
+ supplied = supplied.to_s.b
50
+
51
+ # Comparing digests rather than the raw strings keeps the comparison
52
+ # constant-time even when the lengths differ, which a plain byte-wise
53
+ # loop cannot do without leaking length.
54
+ expected_digest = OpenSSL::Digest::SHA256.digest(expected)
55
+ supplied_digest = OpenSSL::Digest::SHA256.digest(supplied)
56
+
57
+ OpenSSL.secure_compare(expected_digest, supplied_digest) && expected.bytesize == supplied.bytesize
58
+ end
59
+ end
60
+ end
61
+ end
data/lib/flodesk.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby client for the Flodesk API (https://developers.flodesk.com).
4
+ #
5
+ # The gem holds no global mutable configuration: construct a client explicitly
6
+ # and pass it around, or assign one to a constant in your application.
7
+ #
8
+ # client = Flodesk::Client.new(
9
+ # api_key: ENV.fetch("FLODESK_API_KEY"),
10
+ # app_name: "MyApp (myapp.com)"
11
+ # )
12
+ #
13
+ # A single client is safe to share across threads.
14
+ module Flodesk
15
+ # The production API root. Every request is issued relative to this.
16
+ DEFAULT_BASE_URL = "https://api.flodesk.com/v1"
17
+
18
+ # Ceiling for retry backoff, in seconds.
19
+ #
20
+ # Set to the rate-limit window length. Flodesk returns no rate-limit reset
21
+ # header, so there is no correct wait to compute; this is a documented
22
+ # heuristic and the gem makes no promise of staying within quota.
23
+ MAX_BACKOFF_SECONDS = 60
24
+ end
25
+
26
+ require_relative "flodesk/version"
27
+ require_relative "flodesk/errors"
28
+ require_relative "flodesk/redaction"
29
+ require_relative "flodesk/instrumentation"
30
+ require_relative "flodesk/enums"
31
+ require_relative "flodesk/coercion"
32
+ require_relative "flodesk/objects/segment"
33
+ require_relative "flodesk/objects/subscriber"
34
+ require_relative "flodesk/objects/custom_field"
35
+ require_relative "flodesk/objects/workflow"
36
+ require_relative "flodesk/objects/webhook"
37
+ require_relative "flodesk/objects/campaign"
38
+ require_relative "flodesk/objects/page"
39
+ require_relative "flodesk/objects/batch_item_error"
40
+ require_relative "flodesk/objects/batch_result"
41
+ require_relative "flodesk/response"
42
+ require_relative "flodesk/rate_limit"
43
+ require_relative "flodesk/auth"
44
+ require_relative "flodesk/retry_policy"
45
+ require_relative "flodesk/connection"
46
+ require_relative "flodesk/resources/base"
47
+ require_relative "flodesk/resources/subscribers"
48
+ require_relative "flodesk/resources/segments"
49
+ require_relative "flodesk/resources/custom_fields"
50
+ require_relative "flodesk/resources/workflows"
51
+ require_relative "flodesk/resources/webhooks"
52
+ require_relative "flodesk/resources/campaigns"
53
+ require_relative "flodesk/client"
54
+ require_relative "flodesk/webhooks/verification"
55
+ require_relative "flodesk/webhooks/event"
56
+ require_relative "flodesk/webhooks/handler"
57
+
58
+ # Self-guarding: loads the Railtie only when Rails is already present.
59
+ require_relative "flodesk/rails"
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Flodesk
6
+ module Generators
7
+ # Scaffolds `config/initializers/flodesk.rb`.
8
+ #
9
+ # The initializer builds a client and assigns it to a constant in the *host
10
+ # application*, rather than configuring the gem. The gem holds no global
11
+ # mutable state, which keeps per-tenant API keys trivial and leaves nothing
12
+ # process-wide to leak between tests.
13
+ class InstallGenerator < ::Rails::Generators::Base
14
+ source_root File.expand_path("templates", __dir__)
15
+
16
+ desc "Creates config/initializers/flodesk.rb wired to Rails credentials."
17
+
18
+ class_option :force, type: :boolean, default: false,
19
+ desc: "Overwrite an existing config/initializers/flodesk.rb"
20
+
21
+ INITIALIZER_PATH = "config/initializers/flodesk.rb"
22
+
23
+ # Refuses to clobber an existing initializer rather than relying on Thor's
24
+ # collision prompt, which resolves to "overwrite" on a non-interactive
25
+ # shell. This file typically holds hand-tuned client configuration, and
26
+ # re-running the generator should never silently discard it.
27
+ def create_initializer
28
+ if File.exist?(File.join(destination_root, INITIALIZER_PATH)) && !options[:force]
29
+ say_status :skip, "#{INITIALIZER_PATH} already exists (pass --force to replace)", :yellow
30
+ @skipped = true
31
+ return
32
+ end
33
+
34
+ template "initializer.rb", INITIALIZER_PATH
35
+ end
36
+
37
+ def report_next_steps
38
+ return if @skipped
39
+
40
+ say ""
41
+ say "Add your Flodesk API key to credentials:", :green
42
+ say " bin/rails credentials:edit"
43
+ say " flodesk_api_key: fd_your_key_here"
44
+ say ""
45
+ say "Create and manage API keys at:", :green
46
+ say " https://app.flodesk.com/account/integration/api"
47
+ say ""
48
+ end
49
+
50
+ private
51
+
52
+ # Used by the template to identify the application to Flodesk, which asks
53
+ # integrations to send a descriptive User-Agent.
54
+ def application_name
55
+ ::Rails.application.class.module_parent_name
56
+ rescue StandardError
57
+ "MyApp"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Flodesk API client.
4
+ #
5
+ # The gem holds no global configuration, so the client lives here as an
6
+ # application constant. That keeps ownership visible and greppable, and leaves
7
+ # per-tenant API keys straightforward: build another client where you need one.
8
+ #
9
+ # FLODESK.subscribers.upsert(email: "a@b.com")
10
+ #
11
+ # A single client is frozen and safe to share across request threads.
12
+ FLODESK = Flodesk::Client.new(
13
+ api_key: Rails.application.credentials.flodesk_api_key,
14
+
15
+ # Flodesk asks integrations to identify themselves in the User-Agent.
16
+ app_name: "<%= application_name %> (#{Rails.env})",
17
+
18
+ # Retries apply only to operations that are safe to repeat. Creating a
19
+ # segment, custom field or webhook, and publishing a campaign, are never
20
+ # retried, because repeating them would duplicate a record or send an email
21
+ # twice.
22
+ max_retries: 2
23
+ )
24
+
25
+ # Rate limits are 100 requests/minute overall and 20/minute for
26
+ # POST /subscribers/batch. Flodesk sends no rate-limit reset header, so the gem
27
+ # cannot guarantee staying within quota; check FLODESK.rate_limit&.remaining if
28
+ # you need to pace a bulk job yourself.
@@ -0,0 +1,91 @@
1
+ module Flodesk
2
+ # Authentication strategies. Only API-key (HTTP Basic) auth is implemented.
3
+ module Auth
4
+ class ApiKey
5
+ def initialize: (String) -> void
6
+ def apply: (untyped) -> untyped
7
+ def inspect: () -> String
8
+ def to_s: () -> String
9
+ end
10
+ end
11
+
12
+ # A completed HTTP response.
13
+ class Response
14
+ attr_reader status: Integer
15
+ attr_reader headers: Hash[String, untyped]
16
+ attr_reader body: untyped
17
+
18
+ def rate_limit: () -> Integer?
19
+ def rate_limit_remaining: () -> Integer?
20
+ # Always nil: the API sends no rate-limit reset header.
21
+ def rate_limit_reset: () -> nil
22
+ end
23
+
24
+ # Decides whether a failed request may be retried.
25
+ class RetryPolicy
26
+ attr_reader idempotent: bool
27
+ attr_reader retry_rate_limit: bool
28
+ attr_reader max_retries: Integer
29
+
30
+ def initialize: (
31
+ idempotent: bool, retry_rate_limit: bool, max_retries: Integer
32
+ ) -> void
33
+
34
+ def retry?: (Exception, Integer) -> bool
35
+ end
36
+
37
+ # Executes HTTP requests. Safe to share across threads.
38
+ class Connection
39
+ TIMEOUT_ERRORS: Array[Class]
40
+ CONNECTION_ERRORS: Array[Class]
41
+
42
+ def initialize: (Client) -> void
43
+
44
+ def request: (
45
+ Symbol, String,
46
+ ?query: Hash[String, untyped]?,
47
+ ?body: Hash[String, untyped]?,
48
+ ?idempotent: bool?,
49
+ ?retry_rate_limit: bool
50
+ ) -> Response
51
+ end
52
+
53
+ # Entry point to the Flodesk API. Frozen at construction.
54
+ class Client
55
+ DEFAULT_OPEN_TIMEOUT: Integer
56
+ DEFAULT_READ_TIMEOUT: Integer
57
+ DEFAULT_MAX_RETRIES: Integer
58
+ DEFAULT_BACKOFF_BASE: Float
59
+
60
+ attr_reader api_key: String
61
+ attr_reader app_name: String?
62
+ attr_reader base_url: String
63
+ attr_reader open_timeout: Numeric
64
+ attr_reader read_timeout: Numeric
65
+ attr_reader max_retries: Integer
66
+ attr_reader backoff_base: Numeric
67
+ attr_reader auth: Auth::ApiKey
68
+ attr_reader subscribers: Resources::Subscribers
69
+ attr_reader segments: Resources::Segments
70
+ attr_reader custom_fields: Resources::CustomFields
71
+ attr_reader workflows: Resources::Workflows
72
+ attr_reader webhooks: Resources::Webhooks
73
+ attr_reader campaigns: Resources::Campaigns
74
+
75
+ def initialize: (
76
+ ?api_key: String?,
77
+ ?app_name: String?,
78
+ ?base_url: String,
79
+ ?open_timeout: Numeric,
80
+ ?read_timeout: Numeric,
81
+ ?max_retries: Integer,
82
+ ?backoff_base: Numeric
83
+ ) -> void
84
+
85
+ def user_agent: () -> String
86
+ def rate_limit: () -> RateLimit::State?
87
+ def request: (*untyped, **untyped) -> Response
88
+ def inspect: () -> String
89
+ def to_s: () -> String
90
+ end
91
+ end
@@ -0,0 +1,51 @@
1
+ module Flodesk
2
+ class Error < StandardError
3
+ def self.from_response: (
4
+ status: Integer, ?body: untyped, ?headers: Hash[untyped, untyped]
5
+ ) -> APIError
6
+ end
7
+
8
+ # An error carrying an HTTP response.
9
+ class APIError < Error
10
+ attr_reader status: Integer
11
+ attr_reader code: String?
12
+ attr_reader raw_body: untyped
13
+
14
+ def initialize: (
15
+ status: Integer, ?body: untyped, ?headers: Hash[untyped, untyped]
16
+ ) -> void
17
+
18
+ def rate_limit: () -> Integer?
19
+ def rate_limit_remaining: () -> Integer?
20
+ end
21
+
22
+ class BadRequestError < APIError
23
+ end
24
+
25
+ class AuthenticationError < APIError
26
+ end
27
+
28
+ class NotFoundError < APIError
29
+ end
30
+
31
+ class ServerError < APIError
32
+ end
33
+
34
+ class RateLimitError < APIError
35
+ # Always nil: the API sends no rate-limit reset header.
36
+ def retry_after: () -> nil
37
+ end
38
+
39
+ class ConnectionError < Error
40
+ end
41
+
42
+ class TimeoutError < Error
43
+ end
44
+
45
+ # Raised when a batch operation reports per-record failures inside a 200.
46
+ class PartialFailureError < Error
47
+ attr_reader result: BatchResult
48
+
49
+ def initialize: (result: BatchResult) -> void
50
+ end
51
+ end