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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +174 -0
- data/Rakefile +12 -0
- data/implementation_plan.md +610 -0
- data/lib/arafa/airtime/africas_talking.rb +107 -0
- data/lib/arafa/airtime/base.rb +107 -0
- data/lib/arafa/airtime/wasiliana.rb +95 -0
- data/lib/arafa/configuration.rb +33 -0
- data/lib/arafa/contracts/africas_talking_airtime_contract.rb +29 -0
- data/lib/arafa/contracts/africas_talking_contract.rb +27 -0
- data/lib/arafa/contracts/airtime_contract.rb +52 -0
- data/lib/arafa/contracts/base_contract.rb +21 -0
- data/lib/arafa/contracts/mobile_data_contract.rb +60 -0
- data/lib/arafa/contracts/wasiliana_contract.rb +30 -0
- data/lib/arafa/data/africas_talking.rb +157 -0
- data/lib/arafa/errors.rb +23 -0
- data/lib/arafa/message.rb +17 -0
- data/lib/arafa/phone_number.rb +25 -0
- data/lib/arafa/providers/africas_talking.rb +116 -0
- data/lib/arafa/providers/base.rb +118 -0
- data/lib/arafa/providers/wasiliana.rb +98 -0
- data/lib/arafa/schemas/airtime_request_schema.rb +28 -0
- data/lib/arafa/schemas/delivery_report_schema.rb +15 -0
- data/lib/arafa/schemas/ussd_callback_schema.rb +17 -0
- data/lib/arafa/send_result.rb +37 -0
- data/lib/arafa/types.rb +21 -0
- data/lib/arafa/ussd/africas_talking.rb +26 -0
- data/lib/arafa/ussd/request.rb +41 -0
- data/lib/arafa/ussd/response.rb +25 -0
- data/lib/arafa/ussd/wasiliana.rb +26 -0
- data/lib/arafa/version.rb +5 -0
- data/lib/arafa/whatsapp/africas_talking.rb +174 -0
- data/lib/arafa/whatsapp/message_body.rb +146 -0
- data/lib/arafa.rb +68 -0
- data/sig/arafa.rbs +4 -0
- 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
|