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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +101 -0
- data/LICENSE.txt +21 -0
- data/README.md +351 -0
- data/Rakefile +12 -0
- data/lib/flodesk/auth.rb +32 -0
- data/lib/flodesk/client.rb +84 -0
- data/lib/flodesk/coercion.rb +69 -0
- data/lib/flodesk/connection.rb +179 -0
- data/lib/flodesk/enums.rb +42 -0
- data/lib/flodesk/errors.rb +139 -0
- data/lib/flodesk/instrumentation.rb +35 -0
- data/lib/flodesk/objects/batch_item_error.rb +29 -0
- data/lib/flodesk/objects/batch_result.rb +44 -0
- data/lib/flodesk/objects/campaign.rb +28 -0
- data/lib/flodesk/objects/custom_field.rb +21 -0
- data/lib/flodesk/objects/page.rb +72 -0
- data/lib/flodesk/objects/segment.rb +36 -0
- data/lib/flodesk/objects/subscriber.rb +39 -0
- data/lib/flodesk/objects/webhook.rb +24 -0
- data/lib/flodesk/objects/workflow.rb +22 -0
- data/lib/flodesk/rails/railtie.rb +19 -0
- data/lib/flodesk/rails.rb +8 -0
- data/lib/flodesk/rate_limit.rb +35 -0
- data/lib/flodesk/redaction.rb +46 -0
- data/lib/flodesk/resources/base.rb +122 -0
- data/lib/flodesk/resources/campaigns.rb +121 -0
- data/lib/flodesk/resources/custom_fields.rb +47 -0
- data/lib/flodesk/resources/segments.rb +49 -0
- data/lib/flodesk/resources/subscribers.rb +239 -0
- data/lib/flodesk/resources/webhooks.rb +93 -0
- data/lib/flodesk/resources/workflows.rb +75 -0
- data/lib/flodesk/response.rb +35 -0
- data/lib/flodesk/retry_policy.rb +46 -0
- data/lib/flodesk/test_helpers.rb +152 -0
- data/lib/flodesk/version.rb +5 -0
- data/lib/flodesk/webhooks/event.rb +78 -0
- data/lib/flodesk/webhooks/handler.rb +167 -0
- data/lib/flodesk/webhooks/verification.rb +61 -0
- data/lib/flodesk.rb +59 -0
- data/lib/generators/flodesk/install_generator.rb +61 -0
- data/lib/generators/flodesk/templates/initializer.rb.tt +28 -0
- data/sig/flodesk/client.rbs +91 -0
- data/sig/flodesk/errors.rbs +51 -0
- data/sig/flodesk/objects.rbs +137 -0
- data/sig/flodesk/resources.rbs +129 -0
- data/sig/flodesk/webhooks.rbs +57 -0
- data/sig/flodesk.rbs +61 -0
- 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
|