arafa 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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +174 -0
  6. data/Rakefile +12 -0
  7. data/implementation_plan.md +610 -0
  8. data/lib/arafa/airtime/africas_talking.rb +107 -0
  9. data/lib/arafa/airtime/base.rb +107 -0
  10. data/lib/arafa/airtime/wasiliana.rb +95 -0
  11. data/lib/arafa/configuration.rb +33 -0
  12. data/lib/arafa/contracts/africas_talking_airtime_contract.rb +29 -0
  13. data/lib/arafa/contracts/africas_talking_contract.rb +27 -0
  14. data/lib/arafa/contracts/airtime_contract.rb +52 -0
  15. data/lib/arafa/contracts/base_contract.rb +21 -0
  16. data/lib/arafa/contracts/mobile_data_contract.rb +60 -0
  17. data/lib/arafa/contracts/wasiliana_contract.rb +30 -0
  18. data/lib/arafa/data/africas_talking.rb +157 -0
  19. data/lib/arafa/errors.rb +23 -0
  20. data/lib/arafa/message.rb +17 -0
  21. data/lib/arafa/phone_number.rb +25 -0
  22. data/lib/arafa/providers/africas_talking.rb +116 -0
  23. data/lib/arafa/providers/base.rb +118 -0
  24. data/lib/arafa/providers/wasiliana.rb +98 -0
  25. data/lib/arafa/schemas/airtime_request_schema.rb +28 -0
  26. data/lib/arafa/schemas/delivery_report_schema.rb +15 -0
  27. data/lib/arafa/schemas/ussd_callback_schema.rb +17 -0
  28. data/lib/arafa/send_result.rb +37 -0
  29. data/lib/arafa/types.rb +21 -0
  30. data/lib/arafa/ussd/africas_talking.rb +26 -0
  31. data/lib/arafa/ussd/request.rb +41 -0
  32. data/lib/arafa/ussd/response.rb +25 -0
  33. data/lib/arafa/ussd/wasiliana.rb +26 -0
  34. data/lib/arafa/version.rb +5 -0
  35. data/lib/arafa/whatsapp/africas_talking.rb +174 -0
  36. data/lib/arafa/whatsapp/message_body.rb +146 -0
  37. data/lib/arafa.rb +68 -0
  38. data/sig/arafa.rbs +4 -0
  39. metadata +209 -0
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../contracts/africas_talking_airtime_contract"
5
+
6
+ module Arafa
7
+ module Airtime
8
+ # Africa's Talking airtime request adapter (§1.10). A single HTTP 200 can
9
+ # still carry a mix of Sent/Failed recipients (read from `responses`), or
10
+ # reject the whole request via a top-level `errorMessage` with no
11
+ # `responses` at all (e.g. an unconfigured/invalid username) — both cases
12
+ # are handled, unlike the AT SMS adapter which only needs the former.
13
+ class AfricasTalking < Airtime::Base
14
+ CONTRACT = Contracts::AfricasTalkingAirtimeContract
15
+
16
+ option :username, optional: true, type: Types::String.optional, default: -> { Arafa.config.africas_talking.username }
17
+ option :max_num_retry, optional: true, type: Types::Integer.optional, default: -> { nil }
18
+ option :request_metadata, optional: true, type: Types::Hash.optional, default: -> { nil }
19
+ option :idempotency_key, optional: true, type: Types::String.optional, default: -> { nil }
20
+
21
+ def endpoint
22
+ "https://api.africastalking.com/version1/airtime/send"
23
+ end
24
+
25
+ def headers
26
+ headers = {
27
+ "apiKey" => Arafa.config.africas_talking.api_key,
28
+ "Content-Type" => "application/json",
29
+ "Accept" => "application/json"
30
+ }
31
+ headers["Idempotency-Key"] = idempotency_key unless idempotency_key.nil?
32
+ headers
33
+ end
34
+
35
+ def build_payload
36
+ payload = {
37
+ username: username,
38
+ recipients: phone_number.map { |number| { phoneNumber: number, amount: "#{currency_code} #{amount}" } }
39
+ }
40
+ payload[:maxNumRetry] = max_num_retry unless max_num_retry.nil?
41
+ payload[:requestMetadata] = request_metadata unless request_metadata.nil?
42
+ payload
43
+ end
44
+
45
+ def parse_response(response)
46
+ return build_failure(response) unless response.status.between?(200, 299)
47
+
48
+ body = response.body
49
+ entries = Array(body["responses"])
50
+ return Failure(Arafa::InvalidRequestError.new(body["errorMessage"] || body.to_s)) if entries.empty?
51
+
52
+ build_success(body, entries)
53
+ end
54
+
55
+ private
56
+
57
+ def build_success(body, entries)
58
+ Success(
59
+ SendResult.new(
60
+ provider: :airtime_africas_talking,
61
+ recipients: entries.map { |entry| build_recipient(entry) },
62
+ raw: body
63
+ )
64
+ )
65
+ end
66
+
67
+ def build_recipient(entry)
68
+ success = entry["status"] == "Sent"
69
+
70
+ SendResult::Recipient.new(
71
+ number: entry["phoneNumber"],
72
+ success: success,
73
+ message_id: entry["requestId"],
74
+ status: entry["status"],
75
+ cost: entry["amount"],
76
+ error: success ? nil : entry["errorMessage"]
77
+ )
78
+ end
79
+
80
+ ERROR_CLASSES_BY_STATUS = {
81
+ 401 => Arafa::AuthenticationError,
82
+ 400 => Arafa::InvalidRequestError,
83
+ 403 => Arafa::InvalidRequestError,
84
+ 406 => Arafa::InvalidRequestError,
85
+ 429 => Arafa::RateLimitError,
86
+ 500 => Arafa::ProviderServerError,
87
+ 501 => Arafa::ProviderServerError,
88
+ 502 => Arafa::ProviderServerError,
89
+ 503 => Arafa::ProviderServerError
90
+ }.freeze
91
+
92
+ def build_failure(response)
93
+ error_class = ERROR_CLASSES_BY_STATUS.fetch(response.status, Arafa::InvalidRequestError)
94
+ Failure(error_class.new(error_message(response)))
95
+ end
96
+
97
+ def error_message(response)
98
+ body = response.body
99
+ return body.to_s unless body.is_a?(Hash)
100
+
101
+ body["errorMessage"] || body.to_s
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ Arafa.register(:airtime_africas_talking, Arafa::Airtime::AfricasTalking)
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/initializer"
4
+ require "dry/monads"
5
+
6
+ require_relative "../types"
7
+ require_relative "../errors"
8
+ require_relative "../providers/base"
9
+
10
+ module Arafa
11
+ module Airtime
12
+ # Abstract superclass for airtime adapters (`Airtime::Wasiliana`,
13
+ # `Airtime::AfricasTalking`). Mirrors `Providers::Base`'s pattern
14
+ # (contract-then-HTTP-call `#send`, shared Faraday connection) but with
15
+ # an airtime-shaped payload: `phone_number`/`currency_code`/`amount`
16
+ # applied to every recipient in the batch, rather than `to`/`text`/`from`.
17
+ #
18
+ # Subclasses implement `#endpoint`, `#headers`, `#build_payload`, and
19
+ # `#parse_response`, and set a `CONTRACT` constant (a
20
+ # `Dry::Validation::Contract`, built on `Contracts::AirtimeContract`).
21
+ class Base
22
+ extend Dry::Initializer
23
+ include Dry::Monads[:result]
24
+
25
+ # Wraps a scalar `phone_number:` into an Array before per-element
26
+ # PhoneNumber coercion, same as `Providers::Base::TO_TYPE`.
27
+ PHONE_NUMBER_TYPE = Types::Array.of(Types::PhoneNumber).constructor { |value| Array(value) }
28
+
29
+ option :phone_number, type: PHONE_NUMBER_TYPE
30
+ option :currency_code, type: Types::String
31
+ option :amount, type: Types::Any
32
+ option :callback, optional: true, type: Types::String.optional, default: -> { nil }
33
+ option :airtime_uid, optional: true, type: Types::String.optional, default: -> { nil }
34
+
35
+ class << self
36
+ # Shortcut for `new(**kwargs).send`, matching `Providers::Base.send`.
37
+ def send(*args, **kwargs)
38
+ return super if args.any? || kwargs.empty?
39
+
40
+ new(**kwargs).send
41
+ end
42
+
43
+ # Reuses `Providers::Base`'s Faraday connection (retry, JSON, logger
44
+ # with apiKey redaction) rather than building a second one.
45
+ def connection
46
+ Providers::Base.connection
47
+ end
48
+ end
49
+
50
+ # Contract → HTTP call → response parsing. Returns a
51
+ # `Dry::Monads::Result`: `Success(SendResult)` or
52
+ # `Failure(Arafa::Error subclass)`.
53
+ def send(*args, **kwargs)
54
+ return super if args.any? || kwargs.any?
55
+
56
+ validation = validate_contract
57
+ return validation if validation.failure?
58
+
59
+ response = connection.post(endpoint, build_payload, headers)
60
+ parse_response(response)
61
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
62
+ Failure(Arafa::NetworkError.new(e.message))
63
+ end
64
+
65
+ def connection
66
+ self.class.connection
67
+ end
68
+
69
+ def endpoint
70
+ raise NotImplementedError, "#{self.class} must implement #endpoint"
71
+ end
72
+
73
+ def headers
74
+ raise NotImplementedError, "#{self.class} must implement #headers"
75
+ end
76
+
77
+ def build_payload
78
+ raise NotImplementedError, "#{self.class} must implement #build_payload"
79
+ end
80
+
81
+ def parse_response(_response)
82
+ raise NotImplementedError, "#{self.class} must implement #parse_response"
83
+ end
84
+
85
+ private
86
+
87
+ # Omits `callback`/`airtime_uid` entirely when nil rather than passing
88
+ # them through as explicit nils — dry-schema's `optional(...).filled`
89
+ # treats a present-but-nil key as "must be filled" rather than
90
+ # "is missing", which would wrongly fail contracts where these fields
91
+ # are genuinely optional (e.g. AT's airtime_uid, or callback for AT).
92
+ def validate_contract
93
+ result = self.class::CONTRACT.new.call(contract_params)
94
+ return Failure(Arafa::ValidationError.new(result.errors.to_h)) if result.failure?
95
+
96
+ Success(result)
97
+ end
98
+
99
+ def contract_params
100
+ params = { phone_number: phone_number, currency_code: currency_code, amount: amount }
101
+ params[:callback] = callback unless callback.nil?
102
+ params[:airtime_uid] = airtime_uid unless airtime_uid.nil?
103
+ params
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../contracts/airtime_contract"
5
+
6
+ module Arafa
7
+ module Airtime
8
+ # Wasiliana airtime request adapter (§1.6). Like Wasiliana's SMS adapter,
9
+ # the response carries no per-recipient breakdown — just an overall
10
+ # `status`/`message` pair — so success/failure is decided once for the
11
+ # whole request rather than per phone number.
12
+ class Wasiliana < Airtime::Base
13
+ CONTRACT = Contracts::AirtimeContract
14
+
15
+ def endpoint
16
+ "https://api.wasiliana.com/api/v1/airtime/request"
17
+ end
18
+
19
+ def headers
20
+ {
21
+ "Content-Type" => "application/json",
22
+ "apiKey" => Arafa.config.wasiliana.api_key
23
+ }
24
+ end
25
+
26
+ def build_payload
27
+ payload = {
28
+ phone_number: phone_number,
29
+ currency_code: currency_code,
30
+ amount: amount,
31
+ callback: callback
32
+ }
33
+ payload[:airtime_uid] = airtime_uid unless airtime_uid.nil?
34
+ payload
35
+ end
36
+
37
+ def parse_response(response)
38
+ body = response.body
39
+
40
+ if response.status.between?(200, 299) && body.is_a?(Hash) && body["status"] == "success"
41
+ build_success(body)
42
+ else
43
+ build_failure(response)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def build_success(body)
50
+ Success(
51
+ SendResult.new(
52
+ provider: :airtime_wasiliana,
53
+ recipients: phone_number.map { |number| build_recipient(number, body) },
54
+ raw: body
55
+ )
56
+ )
57
+ end
58
+
59
+ def build_recipient(number, body)
60
+ SendResult::Recipient.new(
61
+ number: number,
62
+ success: true,
63
+ message_id: airtime_uid,
64
+ status: body["message"]
65
+ )
66
+ end
67
+
68
+ ERROR_CLASSES_BY_STATUS = {
69
+ 401 => Arafa::AuthenticationError,
70
+ 400 => Arafa::InvalidRequestError,
71
+ 403 => Arafa::InvalidRequestError,
72
+ 404 => Arafa::InvalidRequestError,
73
+ 405 => Arafa::InvalidRequestError,
74
+ 406 => Arafa::InvalidRequestError,
75
+ 429 => Arafa::RateLimitError,
76
+ 500 => Arafa::ProviderServerError,
77
+ 503 => Arafa::ProviderServerError
78
+ }.freeze
79
+
80
+ def build_failure(response)
81
+ error_class = ERROR_CLASSES_BY_STATUS.fetch(response.status, Arafa::InvalidRequestError)
82
+ Failure(error_class.new(error_message(response)))
83
+ end
84
+
85
+ def error_message(response)
86
+ body = response.body
87
+ return body.to_s unless body.is_a?(Hash)
88
+
89
+ body["message"] || body["error"] || body["code"] || body.to_s
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ Arafa.register(:airtime_wasiliana, Arafa::Airtime::Wasiliana)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require "logger"
5
+
6
+ module Arafa
7
+ # Dry::Configurable settings, nested per provider.
8
+ #
9
+ # Arafa.configure do |config|
10
+ # config.wasiliana.api_key = ENV["WASILIANA_API_KEY"]
11
+ # config.africas_talking.api_key = ENV["AT_API_KEY"]
12
+ # config.africas_talking.username = ENV["AT_USERNAME"]
13
+ # end
14
+ module Configuration
15
+ extend Dry::Configurable
16
+
17
+ setting :open_timeout, default: 5
18
+ setting :timeout, default: 10
19
+ setting :default_sender, default: nil
20
+ setting :logger, default: Logger.new($stdout)
21
+
22
+ setting :africas_talking do
23
+ setting :api_key, default: nil
24
+ setting :username, default: nil
25
+ setting :wa_number, default: nil
26
+ setting :sandbox, default: false
27
+ end
28
+
29
+ setting :wasiliana do
30
+ setting :api_key, default: nil
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "airtime_contract"
4
+
5
+ module Arafa
6
+ module Contracts
7
+ # Africa's Talking's airtime API (§1.10) has no callback field at all —
8
+ # unlike Wasiliana, which needs one for delivery-report webhooks — so
9
+ # `callback` is downgraded from required to optional here. AT also caps
10
+ # requests at 1,000 recipients and requires a configured application
11
+ # username, same business-rule-against-config pattern as
12
+ # `AfricasTalkingContract` for SMS.
13
+ class AfricasTalkingAirtimeContract < AirtimeContract
14
+ option :username, default: -> { Arafa.config.africas_talking.username }
15
+
16
+ params do
17
+ optional(:callback).filled(:string)
18
+ end
19
+
20
+ rule(:phone_number) do
21
+ key.failure("must not exceed 1,000 recipients") if values[:phone_number].size > 1000
22
+ end
23
+
24
+ rule do
25
+ base.failure("Africa's Talking username is not configured") if username.to_s.empty?
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_contract"
4
+
5
+ module Arafa
6
+ module Contracts
7
+ # Africa's Talking requires a registered senderId (mapped from `from:`)
8
+ # on every send, plus a configured application username — the latter
9
+ # isn't part of the outgoing payload itself, so it's checked as a
10
+ # business rule against config/state rather than a structural field.
11
+ class AfricasTalkingContract < BaseContract
12
+ option :username, default: -> { Arafa.config.africas_talking.username }
13
+
14
+ params do
15
+ required(:from).filled(:string)
16
+ end
17
+
18
+ rule(:from) do
19
+ key.failure("must be 11 characters or fewer") if values[:from].to_s.length > 11
20
+ end
21
+
22
+ rule do
23
+ base.failure("Africa's Talking username is not configured") if username.to_s.empty?
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+ require "uri"
5
+
6
+ module Arafa
7
+ module Contracts
8
+ # Structural + business rules for an outgoing airtime request (§1.6),
9
+ # applied before any HTTP call is made. Doesn't share `BaseContract`
10
+ # since airtime payloads have nothing in common with an SMS `to:`/`text:`
11
+ # payload.
12
+ class AirtimeContract < Dry::Validation::Contract
13
+ params do
14
+ required(:phone_number).array(:string)
15
+ required(:currency_code).filled(:string)
16
+ required(:amount).filled
17
+ required(:callback).filled(:string)
18
+ optional(:airtime_uid).filled(:string)
19
+ end
20
+
21
+ rule(:phone_number) do
22
+ key.failure("must include at least one recipient") if values[:phone_number].empty?
23
+ end
24
+
25
+ rule(:currency_code) do
26
+ key.failure("must be a 3-letter ISO currency code") unless values[:currency_code].to_s.match?(/\A[A-Z]{3}\z/)
27
+ end
28
+
29
+ rule(:amount) do
30
+ numeric = begin
31
+ Float(values[:amount])
32
+ rescue ArgumentError, TypeError
33
+ nil
34
+ end
35
+
36
+ key.failure("must be a positive number") if numeric.nil? || numeric <= 0
37
+ end
38
+
39
+ rule(:callback) do
40
+ next unless key?(:callback)
41
+
42
+ uri = begin
43
+ URI.parse(values[:callback].to_s)
44
+ rescue URI::InvalidURIError
45
+ nil
46
+ end
47
+
48
+ key.failure("must be a valid http(s) URL") unless uri.is_a?(URI::HTTP) && !uri.host.nil?
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Arafa
6
+ module Contracts
7
+ # Shared `to:`/`text:`/`from:` structural and business rules applied to
8
+ # every provider's outgoing SMS payload before any HTTP call is made.
9
+ class BaseContract < Dry::Validation::Contract
10
+ params do
11
+ required(:to).array(:string)
12
+ required(:text).filled(:string)
13
+ optional(:from).filled(:string)
14
+ end
15
+
16
+ rule(:to) do
17
+ key.failure("must include at least one recipient") if values[:to].empty?
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/validation"
4
+
5
+ module Arafa
6
+ module Contracts
7
+ # Africa's Talking Mobile Data API (§1.12). Unlike airtime, each recipient
8
+ # carries its own bundle shape (`quantity`/`unit`/`validity`), so the
9
+ # enum/positivity checks run per-recipient rather than once against a
10
+ # single shared `amount`. Same "configured application username" business
11
+ # rule as `AfricasTalkingAirtimeContract`, since AT has no equivalent in
12
+ # Wasiliana for this product.
13
+ class MobileDataContract < Dry::Validation::Contract
14
+ UNITS = %w[MB GB].freeze
15
+ VALIDITIES = %w[Day Week BiWeek Month Quarterly].freeze
16
+
17
+ option :username, default: -> { Arafa.config.africas_talking.username }
18
+
19
+ params do
20
+ required(:product_name).filled(:string)
21
+ required(:recipients).array(:hash) do
22
+ required(:phone_number).filled(:string)
23
+ required(:quantity).filled
24
+ required(:unit).filled(:string)
25
+ required(:validity).filled(:string)
26
+ optional(:metadata).hash
27
+ end
28
+ end
29
+
30
+ rule(:recipients) do
31
+ key.failure("must include at least one recipient") if values[:recipients].empty?
32
+ end
33
+
34
+ rule(:recipients).each do
35
+ next unless key?
36
+
37
+ unless value[:phone_number].to_s.match?(/\A254(7|1)\d{8}\z/)
38
+ key.failure("phone_number must be a valid Kenyan MSISDN")
39
+ end
40
+
41
+ key.failure("unit must be one of MB, GB") unless UNITS.include?(value[:unit])
42
+
43
+ unless VALIDITIES.include?(value[:validity])
44
+ key.failure("validity must be one of Day, Week, BiWeek, Month, Quarterly")
45
+ end
46
+
47
+ numeric = begin
48
+ Float(value[:quantity])
49
+ rescue ArgumentError, TypeError
50
+ nil
51
+ end
52
+ key.failure("quantity must be a positive number") if numeric.nil? || numeric <= 0
53
+ end
54
+
55
+ rule do
56
+ base.failure("Africa's Talking username is not configured") if username.to_s.empty?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_contract"
4
+
5
+ module Arafa
6
+ module Contracts
7
+ # Wasiliana requires `from` (a Sender ID or ShortCode) on every send,
8
+ # caps `text` at 918 characters (6 concatenated SMS parts), and accepts
9
+ # an optional `is_otp` flag that must be a boolean or a "true"/"false"
10
+ # string per §1.4.
11
+ class WasilianaContract < BaseContract
12
+ params do
13
+ required(:from).filled(:string)
14
+ optional(:is_otp)
15
+ end
16
+
17
+ rule(:text) do
18
+ key.failure("must be 918 characters or fewer") if values[:text].to_s.length > 918
19
+ end
20
+
21
+ rule(:is_otp) do
22
+ next unless key?(:is_otp)
23
+ next if values[:is_otp].nil?
24
+
25
+ allowed = [true, false, "true", "false"]
26
+ key.failure("must be a boolean or 'true'/'false' string") unless allowed.include?(values[:is_otp])
27
+ end
28
+ end
29
+ end
30
+ end