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,610 @@
1
+ # Arafa Implementation Plan
2
+
3
+ `arafa` is a Ruby gem giving a single, uniform interface to multiple SMS/USSD/Airtime
4
+ gateways used in Kenya. This document is the reference for implementing the gem,
5
+ starting with two providers: **Africa's Talking** and **Wasiliana**. Additional
6
+ providers plug into the same interface later without changing calling code.
7
+
8
+ Africa's Talking exposes more than SMS/USSD — §1.10–§1.13 cover its Airtime, Voice,
9
+ Mobile Data, and WhatsApp APIs, since Wasiliana only offers SMS/USSD/Airtime and this gem
10
+ otherwise undersells what AT alone can do.
11
+
12
+ Target usage:
13
+
14
+ ```ruby
15
+ Arafa.configure do |config|
16
+ config.wasiliana.api_key = ENV["WASILIANA_API_KEY"]
17
+ config.africas_talking.api_key = ENV["AT_API_KEY"]
18
+ config.africas_talking.username = ENV["AT_USERNAME"]
19
+ end
20
+
21
+ provider = Arafa::Wasiliana.new(text: "Hello", to: "0728833100")
22
+ result = provider.send
23
+ result.success? # => true
24
+ result.value!.message_id
25
+ ```
26
+
27
+ ## 1. Provider API reference
28
+
29
+ These fields were captured directly from each provider's live documentation
30
+ (`developers.africastalking.com`, `docs.wasiliana.ke`) — not guessed.
31
+
32
+ ### 1.1 Africa's Talking — Bulk SMS
33
+
34
+ | | |
35
+ |---|---|
36
+ | Endpoint (live) | `POST https://api.africastalking.com/version1/messaging/bulk` |
37
+ | Endpoint (sandbox) | `POST https://api.sandbox.africastalking.com/version1/messaging/bulk` — marked "coming soon" in AT's own docs, treat as unavailable for now |
38
+ | Headers | `apiKey: <API_KEY>`, `Content-Type: application/json`, `Accept: application/json` |
39
+
40
+ Request body (JSON):
41
+
42
+ | Field | Type | Required | Notes |
43
+ |---|---|---|---|
44
+ | `username` | String | yes | AT application username |
45
+ | `phoneNumbers` | Array\<String\> | yes | E.164 numbers |
46
+ | `message` | String | yes | |
47
+ | `senderId` | String | yes | Registered short code or alphanumeric |
48
+ | `enqueue` | Integer | no | 1 = async queue (default), 0 = sync |
49
+
50
+ Response:
51
+
52
+ ```json
53
+ {
54
+ "SMSMessageData": {
55
+ "Message": "Sent to 1/1 Total Cost: KES 0.8000",
56
+ "Recipients": [
57
+ {"statusCode": 101, "number": "+254711XXXYYY", "status": "Success", "cost": "KES 0.8000", "messageId": "ATPid_SampleTxnId123"}
58
+ ]
59
+ }
60
+ }
61
+ ```
62
+
63
+ `statusCode` values: `100` Processed, `101` Sent, `102` Queued (all treated as success);
64
+ `401` RiskHold, `402` InvalidSenderId, `403` InvalidPhoneNumber, `404` UnsupportedNumberType,
65
+ `405` InsufficientBalance, `406` UserInBlacklist, `407` CouldNotRoute, `409` DoNotDisturbRejection,
66
+ `500` InternalServerError, `501` GatewayError, `502` RejectedByGateway (all treated as
67
+ per-recipient failures — the HTTP call itself can still return 200 with some recipients
68
+ failed, so failures must be read out of `Recipients`, not just the HTTP status).
69
+
70
+ ### 1.2 Africa's Talking — USSD (inbound only)
71
+
72
+ AT does not expose an outbound "send USSD" call. Instead:
73
+
74
+ 1. You register a service code and a callback URL with AT.
75
+ 2. AT `POST`s to your callback URL as `application/x-www-form-urlencoded` with:
76
+ `sessionId`, `phoneNumber`, `serviceCode`, `text`, `networkCode`.
77
+ 3. You must respond within 10 seconds with `Content-Type: text/plain`, body prefixed
78
+ `CON ` (session continues, more input expected) or `END ` (session terminates).
79
+
80
+ ### 1.3 Wasiliana — Authentication
81
+
82
+ | | |
83
+ |---|---|
84
+ | Header | `apiKey: <API_KEY>` (obtained from the Wasiliana account dashboard) |
85
+ | Content type | `application/json` for all requests |
86
+
87
+ ### 1.4 Wasiliana — SMS send
88
+
89
+ Same endpoint is documented under both "Sender ID" and "ShortCode" pages — it is one
90
+ unified send endpoint; `from` carries either a registered Sender ID or a ShortCode.
91
+
92
+ | | |
93
+ |---|---|
94
+ | Endpoint | `POST https://api.wasiliana.com/api/v1/send/sms` |
95
+ | Headers | `Content-Type: application/json`, `apiKey: <API_KEY>` |
96
+
97
+ Request body:
98
+
99
+ | Field | Type | Required | Notes |
100
+ |---|---|---|---|
101
+ | `recipients` | Array\<String\> | yes | Phone numbers |
102
+ | `from` | String | yes | Sender ID or ShortCode |
103
+ | `message` | String | yes | |
104
+ | `linkid` | String | no | Returned on inbound-triggered replies |
105
+ | `message_uid` | String | no | Your own idempotency/tracking id, echoed to delivery-report callback |
106
+ | `is_otp` | String/Bool | no | Marks message as an OTP type |
107
+
108
+ Response (success):
109
+
110
+ ```json
111
+ {"status": "success", "data": "Successfully Dispatched the sms to process"}
112
+ ```
113
+
114
+ Failure shape is not documented beyond the HTTP status code table (see 1.7) — implement
115
+ defensively: any non-2xx, or a 2xx body where `status != "success"`, is a failure.
116
+
117
+ ### 1.5 Wasiliana — USSD (inbound only)
118
+
119
+ Same inbound-webhook model as AT. Wasiliana `POST`s JSON to your registered callback:
120
+
121
+ ```json
122
+ {"sessionId": "453720679", "phoneNumber": "25479XXX2541", "serviceCode": "665", "text": "3", "ussdId": "158"}
123
+ ```
124
+
125
+ **Not documented**: the expected response contract (whether it follows the same
126
+ `CON`/`END` text convention as AT, a different prefix, or a JSON response). Treat as
127
+ `CON`/`END` by default (industry-standard convention shared by AT-compatible gateways)
128
+ but flag this explicitly to be verified against a live Wasiliana sandbox/service before
129
+ relying on it in production.
130
+
131
+ ### 1.6 Wasiliana — Airtime
132
+
133
+ | | |
134
+ |---|---|
135
+ | Endpoint | `POST https://api.wasiliana.com/api/v1/airtime/request` |
136
+ | Headers | `Content-Type: application/json`, `apiKey: <API_KEY>` |
137
+
138
+ | Field | Type | Required |
139
+ |---|---|---|
140
+ | `phone_number` | String (docs show it array-wrapped — verify empirically) | yes |
141
+ | `currency_code` | String, e.g. `"KES"` | yes |
142
+ | `amount` | String/Number | yes |
143
+ | `callback` | String (URL) | yes |
144
+ | `airtime_uid` | String | no |
145
+
146
+ Response: `{"status": "success", "message": "Request has been sent successfully"}`.
147
+
148
+ ### 1.7 Wasiliana — Delivery reports (inbound callback)
149
+
150
+ ```json
151
+ {"phone": ["25472x xxx xxx"], "correlator": "message_15883801465eacc1f2643a9", "deliveryStatus": "0"}
152
+ ```
153
+
154
+ ### 1.8 Wasiliana — HTTP error codes
155
+
156
+ | Code | Meaning |
157
+ |---|---|
158
+ | 400 | Bad Request — invalid request |
159
+ | 401 | Unauthorized — wrong API key |
160
+ | 403 | Forbidden — escalated access |
161
+ | 404 | Not Found |
162
+ | 405 | Method Not Allowed |
163
+ | 406 | Not Acceptable — non-JSON format requested |
164
+ | 429 | Too Many Requests |
165
+ | 500 | Internal Server Error |
166
+ | 503 | Service Unavailable |
167
+
168
+ Error body shape: `{"message": "...", "error": "...", "code": "401"}`.
169
+
170
+ ### 1.9 Phone number normalization
171
+
172
+ Neither provider documents accepting local Kenyan formats (`07...`, `01...`). The gem
173
+ must normalize `to:`/`recipients` before sending: `07XXXXXXXX` / `01XXXXXXXX` → `2547XXXXXXXX`
174
+ / `2541XXXXXXXX`; `+254...` → `254...` (strip leading `+`); reject anything that doesn't
175
+ match a valid Kenyan MSISDN after normalization.
176
+
177
+ ### 1.10 Africa's Talking — Airtime
178
+
179
+ Contrary to §8's original open item, AT does document an outbound Airtime API — implement
180
+ it alongside Wasiliana's.
181
+
182
+ | | |
183
+ |---|---|
184
+ | Endpoint (live) | `POST https://api.africastalking.com/version1/airtime/send` |
185
+ | Endpoint (sandbox) | `POST https://api.sandbox.africastalking.com/version1/airtime/send` |
186
+ | Headers | `apiKey`, `Content-Type: application/json`, `Accept: application/json`, optional `Idempotency-Key` |
187
+
188
+ Request body:
189
+
190
+ | Field | Type | Required | Notes |
191
+ |---|---|---|---|
192
+ | `username` | String | yes | |
193
+ | `recipients` | Array\<Hash\> | yes | `[{"phoneNumber": "+254711XXXYYY", "amount": "KES 100.50"}]` — amount is `<3-letter currency> <decimal>` as one string |
194
+ | `maxNumRetry` | Integer | no | Retry window in hours, default 8, retried every 60s |
195
+ | `requestMetadata` | Hash\<String,String\> | no | Free-form string metadata echoed back |
196
+
197
+ Response: `numSent`, `totalAmount`, `totalDiscount`, `responses` (per-recipient `phoneNumber`,
198
+ `amount`, `discount`, `status` [`Sent`/`Failed`], `requestId` or `None`, `errorMessage` or
199
+ `None`), top-level `errorMessage` if the whole request was rejected. Max 1,000 recipients per
200
+ request. Duplicate requests within 5 minutes are rejected unless an `Idempotency-Key` header
201
+ is supplied (shared mechanism, also used by Mobile Data — see §1.12).
202
+
203
+ ### 1.11 Africa's Talking — Voice
204
+
205
+ Outbound calling, unlike USSD this *does* have a send call, plus an inbound callback model
206
+ for handling the call once connected.
207
+
208
+ | | |
209
+ |---|---|
210
+ | Endpoint (live) | `POST https://voice.africastalking.com/call` |
211
+ | Headers | `apiKey`, `Content-Type: application/x-www-form-urlencoded` (or `multipart/form-data`), `Accept: application/json` |
212
+
213
+ Request body (form-encoded, not JSON — differs from every other AT product):
214
+
215
+ | Field | Type | Required | Notes |
216
+ |---|---|---|---|
217
+ | `username` | String | yes | |
218
+ | `from` | String | yes | AT phone number, `+XXXYYYYYY` |
219
+ | `to` | String | yes | Comma-separated recipient numbers |
220
+ | `clientRequestId` | String | no | Echoed to the events callback URL |
221
+
222
+ Response is XML (not JSON, another outlier): `entries` (list of `{phoneNumber, status,
223
+ sessionId}`, `status` one of `Queued`/`InvalidPhoneNumber`/`DestinationNotSupported`/
224
+ `InsufficientCredit`) and optional top-level `errorMessage`. Response header
225
+ `X-Current-Queue-Size` reports queue depth.
226
+
227
+ Inbound: AT calls the registered callback URL as the call progresses (call connected, digits
228
+ gathered, call ended); the callback response body is XML built from a small DSL of verbs
229
+ (`Say`, `Play`, `GetDigits`, `Dial`, `Record`, `Enqueue`/`Call Queue`, `Redirect`, `Reject`,
230
+ `Conference`) — same shape as the USSD callback model in §1.2 but XML-bodied rather than
231
+ `CON`/`END` text. Treat as a second callback-only surface, out of scope for the first pass
232
+ (flagged in §8) given its size; land Voice *sending* (`#send`-shaped, matches existing
233
+ providers) before the call-control DSL.
234
+
235
+ ### 1.12 Africa's Talking — Mobile Data
236
+
237
+ | | |
238
+ |---|---|
239
+ | Endpoint (live) | `POST https://bundles.africastalking.com/mobile/data/request` |
240
+ | Endpoint (sandbox) | `POST https://bundles.sandbox.africastalking.com/mobile/data/request` |
241
+ | Headers | `apiKey`, `Content-Type: application/json`, `Accept: application/json`, optional `Idempotency-Key` |
242
+
243
+ Request body:
244
+
245
+ | Field | Type | Required | Notes |
246
+ |---|---|---|---|
247
+ | `username` | String | yes | |
248
+ | `productName` | String | yes | AT application product name |
249
+ | `recipients` | Array\<Hash\> | yes | `{phoneNumber, quantity, unit ("MB"/"GB"), validity ("Day"/"Week"/"BiWeek"/"Month"/"Quarterly"), metadata: Hash<String,String>}` |
250
+
251
+ Response: `entries` (per-recipient `phoneNumber`, `provider`, `status` [`Queued`], `transactionId`,
252
+ `value`). Same idempotency window/header as Airtime (§1.10).
253
+
254
+ ### 1.13 Africa's Talking — WhatsApp
255
+
256
+ | | |
257
+ |---|---|
258
+ | Endpoint (live) | `POST https://chat.africastalking.com/whatsapp/message/send` |
259
+ | Endpoint (sandbox) | marked "coming soon" — production only for now, same caveat as §1.1's sandbox note |
260
+ | Headers | `apikey` (lowercase, unlike every other AT product's `apiKey`), `Content-Type: application/json` |
261
+
262
+ Request body: `username`, `waNumber` (the registered AT WhatsApp number), `phoneNumber`
263
+ (recipient), `body` — a polymorphic object depending on message type:
264
+
265
+ | Message type | `body` shape |
266
+ |---|---|
267
+ | Text | `{message}` |
268
+ | Media | `{mediaType: Image\|Video\|Audio\|Sticker\|Document\|Voice, url, caption}` |
269
+ | Template | `{templateId, headerValue, bodyValues: Array<String>}` |
270
+ | Interactive buttons | `{action: {buttons: [{id, title}]}, body: {text}, header: {text}}` |
271
+ | Interactive list | `{action: {button, sections: [{title, rows: [{id, title, description}], product_items}]}, body: {text}, header: {text}, footer: {text}}` |
272
+
273
+ Response: `status` (`SENT`/`DELIVERED`/`FAILED`/`READ`/`DELETED`), `phoneNumber`, `messageId`
274
+ (only present on success). Given the five message shapes, `body` is modeled as a
275
+ `Dry::Struct` per message kind (`MessageBody::Text`/`Media`/`Template`, plus
276
+ `MessageBody::Interactive::Buttons`/`List` for the two nested interactive shapes) rather
277
+ than one contract covering all fields as optional (mirrors how `dry-types` sum types are
278
+ already used for the Wasiliana airtime `phone_number` ambiguity in §5.2).
279
+
280
+ ## 2. Gem dependencies
281
+
282
+ Runtime (`arafa.gemspec`):
283
+
284
+ | Gem | Purpose |
285
+ |---|---|
286
+ | `faraday` (~> 2.0) | HTTP client |
287
+ | `faraday-retry` | Retry transient 5xx/429 responses |
288
+ | `dry-configurable` (~> 1.0) | `Arafa.configure` block, per-provider settings |
289
+ | `dry-initializer` (~> 3.0) | Keyword-arg constructors (`Wasiliana.new(text:, to:, from: nil)`) |
290
+ | `dry-types` (~> 1.8) | Leaf value types with coercion/constraints (`PhoneNumber`, `SenderId`, `MessageText`) |
291
+ | `dry-struct` (~> 1.0) | Typed value objects built from `dry-types` (`Message`, `SendResult`) |
292
+ | `dry-schema` (~> 1.14) | Structural coercion/validation of *raw* input hashes: inbound USSD/delivery-report webhook params, airtime request params |
293
+ | `dry-validation` (~> 1.11) | Per-provider `Contract`s applying business rules to the *outgoing* send/airtime payload before any HTTP call is made |
294
+ | `dry-monads` (~> 1.0) | `Success`/`Failure` results from `#send` instead of raising on expected provider errors |
295
+ | `ox` or stdlib `rexml` | Parse Voice's XML response body (§1.11) — only AT product that isn't JSON; stdlib `rexml` avoids a new dependency and Voice's response shape is small enough not to need a faster parser |
296
+
297
+ Dev (`Gemfile`, existing minitest/rubocop setup): add `webmock` for HTTP stubbing in tests,
298
+ and `vcr` for recorded-cassette integration tests (see §8).
299
+
300
+ ## 3. File layout
301
+
302
+ ```
303
+ lib/arafa.rb # requires everything, top-level module
304
+ lib/arafa/version.rb # existing
305
+ lib/arafa/configuration.rb # Dry::Configurable settings, nested per provider
306
+ lib/arafa/types.rb # Dry::Types module (PhoneNumber, SenderId, MessageText, ...)
307
+ lib/arafa/errors.rb # Error hierarchy
308
+ lib/arafa/phone_number.rb # Kenyan MSISDN normalizer (used by the PhoneNumber type's constructor)
309
+ lib/arafa/message.rb # Dry::Struct: to (Array<PhoneNumber>), text, from
310
+ lib/arafa/send_result.rb # Dry::Struct: normalized success/failure result
311
+ lib/arafa/schemas/ussd_callback_schema.rb # Dry::Schema::Params — shared shape for inbound USSD webhook params
312
+ lib/arafa/schemas/delivery_report_schema.rb # Dry::Schema::JSON — Wasiliana delivery-report callback body
313
+ lib/arafa/schemas/airtime_request_schema.rb # Dry::Schema::JSON — Wasiliana airtime request params
314
+ lib/arafa/contracts/base_contract.rb # Dry::Validation::Contract — shared to:/text: rules
315
+ lib/arafa/contracts/africas_talking_contract.rb # + senderId/username rules
316
+ lib/arafa/contracts/wasiliana_contract.rb # + from/is_otp rules
317
+ lib/arafa/contracts/airtime_contract.rb # amount/currency_code/callback URL rules
318
+ lib/arafa/providers/base.rb # dry-initializer ctor, Faraday builder, contract validation, #send template method
319
+ lib/arafa/providers/africas_talking.rb # Arafa::AfricasTalking < Base
320
+ lib/arafa/providers/wasiliana.rb # Arafa::Wasiliana < Base
321
+ lib/arafa/ussd/request.rb # Dry::Struct: normalized inbound USSD request
322
+ lib/arafa/ussd/response.rb # .continue(text) / .end(text) -> "CON "/"END " string
323
+ lib/arafa/ussd/africas_talking.rb # parses AT's form-encoded callback params via UssdCallbackSchema
324
+ lib/arafa/ussd/wasiliana.rb # parses Wasiliana's JSON callback params via UssdCallbackSchema
325
+ lib/arafa/airtime/wasiliana.rb # Arafa::Airtime::Wasiliana — airtime request wrapper
326
+ lib/arafa/airtime/africas_talking.rb # Arafa::Airtime::AfricasTalking per §1.10
327
+ lib/arafa/contracts/mobile_data_contract.rb # quantity/unit/validity rules, §1.12
328
+ lib/arafa/data/africas_talking.rb # Arafa::Data::AfricasTalking — mobile data send, §1.12
329
+ lib/arafa/voice/africas_talking.rb # Arafa::Voice::AfricasTalking — outbound call, §1.11 (send only, no call-control DSL yet)
330
+ lib/arafa/whatsapp/africas_talking.rb # Arafa::Whatsapp::AfricasTalking — text/media/template/interactive send, §1.13
331
+ lib/arafa/whatsapp/message_body.rb # Dry::Struct sum type covering all five body shapes, §1.13
332
+ ```
333
+
334
+ `Arafa::AfricasTalking` and `Arafa::Wasiliana` are the public entry points matching the
335
+ requested call shape; everything else (USSD parsing, airtime) lives under sub-namespaces
336
+ since they aren't the `#send` SMS flow.
337
+
338
+ ## 4. Architecture
339
+
340
+ - **Configuration** — `Arafa.configure { |c| c.wasiliana.api_key = ...; c.africas_talking.api_key = ...; c.africas_talking.username = ... }` via `dry-configurable`, so provider instances don't need credentials passed on every call.
341
+ - **`Providers::Base`** — `dry-initializer` constructor accepting `text:`, `to:` (String or Array, run through `PhoneNumber` normalization), optional `from:` (falls back to configured default sender). Defines the template method:
342
+
343
+ ```ruby
344
+ def send
345
+ response = connection.post(endpoint, build_payload, headers)
346
+ parse_response(response)
347
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
348
+ Dry::Monads::Failure(Arafa::NetworkError.new(e.message))
349
+ end
350
+
351
+ def self.send(**kwargs) = new(**kwargs).send
352
+ ```
353
+
354
+ Subclasses implement `#endpoint`, `#headers`, `#build_payload`, `#parse_response`.
355
+ `#send` returns a `Dry::Monads::Result` — `Success(SendResult)` or `Failure(Arafa::Error subclass)`.
356
+ The class-level `.send(text:, to:)` shortcut (borrowed from `sms_kit`'s `.deliver`) avoids
357
+ the `new(...).send` two-step for the common one-off case.
358
+
359
+ - **Duck-typed messages** — `Base#initialize` accepts either the `text:`/`to:`/`from:`
360
+ keywords directly, or a single positional object responding to `#to_arafa` returning a
361
+ hash of the same keywords (mirrors `sms_kit`'s `#to_sms` convention) — lets a calling
362
+ app pass a domain object (e.g. `OtpMessage#to_arafa`) straight into any provider.
363
+
364
+ - **Provider registry for dynamic dispatch** — `Arafa.register(:wasiliana, Arafa::Wasiliana)`
365
+ (providers self-register on load) backing `Arafa.send(:wasiliana, text:, to:)`, so the
366
+ provider can be chosen at runtime from config/env rather than hardcoding the class name —
367
+ same idea as `sms_kit`'s `SmsKit::Delivery#deliver(provider, options)`.
368
+
369
+ - **Shared Faraday connection** — built once in `Base` with JSON request/response
370
+ middleware and `faraday-retry` (retries 429/5xx with backoff); timeouts pulled from
371
+ `Arafa.configure`; a `User-Agent: arafa/<VERSION>` header is set on every request
372
+ (borrowed from `sms_kit`'s `HTTP::USER_AGENT`, useful for providers' own request logs).
373
+
374
+ - **Logging** — `Arafa.logger` (configurable, defaults to `Logger.new($stdout)`, same
375
+ pattern as `sms_kit`'s `Logging` module) wired into the Faraday connection as a
376
+ `f.response :logger, Arafa.logger` middleware so every request/response is logged at
377
+ `debug`, without each adapter needing to log manually. API keys must be redacted from
378
+ logged headers.
379
+
380
+ - **Error hierarchy** — `Arafa::Error` (base) →
381
+ `Arafa::ValidationError` (payload failed a `dry-schema`/`dry-validation` check, no HTTP
382
+ call made) · `Arafa::AuthenticationError` (401) · `Arafa::InvalidRequestError` (400/403/406) ·
383
+ `Arafa::RateLimitError` (429) · `Arafa::ProviderServerError` (500/503) ·
384
+ `Arafa::NetworkError` (connection-level). Each adapter's `#parse_response` maps the
385
+ documented status codes/response bodies (§1.1, §1.8) onto these.
386
+
387
+ - **USSD** — no outbound HTTP call. `Ussd::Request.from_africas_talking(params)` /
388
+ `Ussd::Request.from_wasiliana(params)` run the raw webhook params through
389
+ `Schemas::UssdCallbackSchema` (§5) then normalize into one shape (`session_id`,
390
+ `phone_number`, `service_code`, `text`); `Ussd::Response.continue(text)` /
391
+ `Ussd::Response.end(text)` build the `CON `/`END `-prefixed plain-text reply. This is
392
+ documented clearly as a parse/build helper, not a network client, so it isn't confused
393
+ with `#send`.
394
+
395
+ - **Airtime** — implemented for both Wasiliana (§1.6) and Africa's Talking (§1.10); both
396
+ ride the same `Airtime::Base` shape (`recipients`, per-recipient `amount` string,
397
+ `Dry::Monads::Result` return) even though the wire formats differ, same pattern as
398
+ `Providers::Base` unifying SMS.
399
+
400
+ - **Mobile Data** (`Data::AfricasTalking`, §1.12) and **WhatsApp**
401
+ (`Whatsapp::AfricasTalking`, §1.13) are AT-only for now — Wasiliana doesn't offer either
402
+ product. They don't extend `Providers::Base` (different payload/response shapes entirely)
403
+ but follow the same conventions: `dry-initializer` constructor, contract-then-HTTP-call
404
+ `#send`, `Dry::Monads::Result` return, shared Faraday connection/logger/error hierarchy.
405
+
406
+ - **Voice** (`Voice::AfricasTalking`, §1.11) ships *send* only in this pass — `POST
407
+ /call` is a `#send`-shaped call that fits the same `Result`-returning pattern, but it is
408
+ form-urlencoded and XML-responding rather than JSON like every other AT product, so its
409
+ adapter needs its own request/response encoding (no reuse of the JSON Faraday builder
410
+ used elsewhere) and its own XML parser for the `entries`/`errorMessage` response. The
411
+ inbound call-control callback (the `Say`/`Play`/`GetDigits`/`Dial`/`Record`/`Enqueue`/
412
+ `Redirect`/`Reject`/`Conference` XML-response DSL) is out of scope for this pass — it's
413
+ effectively a second product (a full IVR builder) and deserves its own implementation
414
+ plan once outbound calling and the rest of this plan have shipped.
415
+
416
+ ## 5. Validation with `dry-types`, `dry-schema`, `dry-validation`
417
+
418
+ These three libraries cover three different jobs and are used in that order, each one
419
+ building on the previous — they are not interchangeable:
420
+
421
+ | Library | Job | Used for |
422
+ |---|---|---|
423
+ | `dry-types` | Define a single **leaf value's** coercion + constraint | `PhoneNumber`, `SenderId`, `MessageText`, `MsisdnList` |
424
+ | `dry-schema` | Validate the **shape** of a raw hash (presence, type-coerce every key) | Inbound webhook params (USSD callbacks, delivery reports) and the Wasiliana airtime request, where input arrives as an untyped `Hash` (HTTP params/JSON body) |
425
+ | `dry-validation` | Layer **business rules** on top of a schema, with access to config/state | The outgoing SMS/airtime payload, right before a provider adapter makes its HTTP call |
426
+
427
+ ### 5.1 `dry-types` — leaf types (`lib/arafa/types.rb`)
428
+
429
+ ```ruby
430
+ module Arafa
431
+ module Types
432
+ include Dry.Types()
433
+
434
+ PhoneNumber = Types::String
435
+ .constructor { |value| Arafa::PhoneNumber.normalize(value) }
436
+ .constrained(format: /\A254(7|1)\d{8}\z/)
437
+
438
+ MsisdnList = Types::Array.of(PhoneNumber).constrained(min_size: 1)
439
+
440
+ SenderId = Types::String.constrained(min_size: 1, max_size: 11)
441
+ MessageText = Types::String.constrained(min_size: 1, max_size: 918) # 6 concatenated SMS parts
442
+ end
443
+ end
444
+ ```
445
+
446
+ `Message`/`SendResult` (`dry-struct`) attributes are declared against these types, so a
447
+ malformed phone number or empty message raises `Dry::Struct::Error` the moment the value
448
+ object is built — this is the same coercion mechanism already in §3/§4, just spelled out.
449
+
450
+ ### 5.2 `dry-schema` — raw webhook/param shapes (`lib/arafa/schemas/*.rb`)
451
+
452
+ Both providers POST inbound webhooks as loosely-typed params (form-encoded for AT USSD,
453
+ JSON for Wasiliana USSD/delivery-reports) — `dry-schema` coerces and validates that shape
454
+ before it's touched by domain code:
455
+
456
+ ```ruby
457
+ module Arafa
458
+ module Schemas
459
+ UssdCallbackSchema = Dry::Schema.Params do
460
+ required(:session_id).filled(:string)
461
+ required(:phone_number).filled(:string)
462
+ required(:service_code).filled(:string)
463
+ required(:text).maybe(:string) # empty text on the very first USSD screen
464
+ end
465
+ end
466
+ end
467
+ ```
468
+
469
+ Since AT and Wasiliana use different key names (`sessionId`/`ussdId` vs `sessionId`/
470
+ `ussdId` — actually identical here, but `networkCode` only exists for AT), each
471
+ `Ussd::<Provider>.parse(params)` remaps provider-specific keys to the schema's canonical
472
+ keys first, then calls `UssdCallbackSchema.call(remapped_params)` and reads
473
+ `result.errors.to_h` / `result.to_h`. Same pattern for `DeliveryReportSchema` (§1.7) and
474
+ `AirtimeRequestSchema` (§1.6, including the array-vs-string `phone_number` ambiguity —
475
+ the schema accepts either shape via a sum type and normalizes to an array).
476
+
477
+ ### 5.3 `dry-validation` — per-provider send contracts (`lib/arafa/contracts/*.rb`)
478
+
479
+ A `Contract` wraps a `params` schema (structural checks) with `rule` blocks (business
480
+ checks that need config/state, e.g. "is a sender ID configured at all"). Adapters call
481
+ their contract **before** touching the network, so bad input never burns an HTTP call:
482
+
483
+ ```ruby
484
+ module Arafa
485
+ module Contracts
486
+ class BaseContract < Dry::Validation::Contract
487
+ params do
488
+ required(:to).array(:string)
489
+ required(:text).filled(:string)
490
+ optional(:from).filled(:string)
491
+ end
492
+
493
+ rule(:to) do
494
+ key.failure("must include at least one recipient") if values[:to].empty?
495
+ end
496
+ end
497
+
498
+ class AfricasTalkingContract < BaseContract
499
+ option :username, default: -> { Arafa.config.africas_talking.username }
500
+
501
+ rule do
502
+ key(:from).failure("senderId is required for Africa's Talking") if values[:from].to_s.empty?
503
+ base.failure("Africa's Talking username is not configured") if username.to_s.empty?
504
+ end
505
+ end
506
+
507
+ class WasilianaContract < BaseContract
508
+ rule(:text) do
509
+ key.failure("must be 918 characters or fewer") if values[:text].to_s.length > 918
510
+ end
511
+ end
512
+ end
513
+ end
514
+ ```
515
+
516
+ `Providers::Base#send` calls `self.class::CONTRACT.new.call(to:, text:, from:)` first; on
517
+ failure it short-circuits with `Dry::Monads::Failure(Arafa::ValidationError.new(result.errors.to_h))`
518
+ — matching the `Dry::Monads::Result` return shape used for network-level failures, so
519
+ callers handle both the same way:
520
+
521
+ ```ruby
522
+ result = Arafa::Wasiliana.new(text: "", to: "0712345678").send
523
+ result.failure? # => true, no HTTP request was ever sent
524
+ ```
525
+
526
+ ## 6. Implementation steps
527
+
528
+ 1. Fill in gemspec `summary`/`description`/`homepage`/`source_code_uri` (currently `TODO`
529
+ placeholders — blocks clean `bundle install`); add the runtime dependencies from §2.
530
+ 2. Core types/errors: `types.rb` (§5.1), `errors.rb` (incl. `Arafa::ValidationError`),
531
+ `phone_number.rb` (+ unit tests covering `07...`, `01...`, `+254...`, `254...`, invalid
532
+ input), `message.rb`, `send_result.rb`.
533
+ 3. `configuration.rb` with nested `africas_talking`/`wasiliana` settings blocks.
534
+ 4. Schemas (§5.2): `UssdCallbackSchema`, `DeliveryReportSchema`, `AirtimeRequestSchema`.
535
+ 5. Contracts (§5.3): `BaseContract`, `AfricasTalkingContract`, `WasilianaContract`, `AirtimeContract`
536
+ (+ unit tests: each documented required/optional field, each business rule, valid/invalid cases).
537
+ 6. `Providers::Base` per §4, wired to run its contract before building the HTTP request.
538
+ 7. `Providers::AfricasTalking` per §1.1.
539
+ 8. `Providers::Wasiliana` per §1.4.
540
+ 9. `Ussd::Request`/`Ussd::Response` + per-provider parsers per §1.2/§1.5, built on `UssdCallbackSchema`.
541
+ 10. `Airtime::Wasiliana` per §1.6, built on `AirtimeRequestSchema`/`AirtimeContract`.
542
+ 11. Minitest coverage (stub HTTP with WebMock, fixtures built from the exact bodies in
543
+ §1): success + every documented failure status code for AT SMS and Wasiliana SMS;
544
+ `PhoneNumber` edge cases; contract validation failures short-circuiting before any
545
+ HTTP stub is hit; USSD parse/build round trips; the `.send` class shortcut,
546
+ `#to_arafa` duck-typing, and `Arafa.send(:provider, ...)` registry dispatch from §7.
547
+ Add VCR cassettes (§7) for one recorded success/failure pair per provider.
548
+ 12. README: `Arafa::Wasiliana.new(text:, to:).send` example, `Arafa.configure` example,
549
+ a note that USSD is callback-only (no `#send`-style call), and one example of reading
550
+ a validation failure from `result.failure`.
551
+ 13. `Airtime::AfricasTalking` per §1.10, sharing `Airtime::Base`/`AirtimeContract` shape
552
+ with the existing Wasiliana adapter; extend `AirtimeContract` for AT's
553
+ `"<currency> <amount>"` string format and 1,000-recipient cap.
554
+ 14. `Data::AfricasTalking` per §1.12: `MobileDataContract` (quantity/unit/validity rules),
555
+ adapter, tests (success + per-recipient `Queued` status, `Idempotency-Key` header
556
+ passthrough).
557
+ 15. `Whatsapp::AfricasTalking` per §1.13, all five message kinds (text, media, template,
558
+ interactive buttons, interactive list); `Whatsapp::MessageBody` sum type; tests per
559
+ message kind plus the `status`/`messageId` response parse.
560
+ 16. `Voice::AfricasTalking` per §1.11, outbound `#send` only: form-urlencoded request
561
+ builder (distinct from the shared JSON Faraday connection), XML response parser,
562
+ `entries`/`errorMessage`/`X-Current-Queue-Size` mapped onto `SendResult`/error
563
+ hierarchy. Inbound call-control DSL explicitly deferred — do not start it in this pass.
564
+ 17. README: add one example each for Airtime, Mobile Data, and WhatsApp sends, and a note
565
+ that Voice inbound call handling isn't implemented yet.
566
+
567
+ ## 7. Prior art: `sms_kit` review
568
+
569
+ [`jamii-Technologies/sms_kit`](https://github.com/jamii-Technologies/sms_kit) is an older,
570
+ similarly-scoped Ruby gem (one interface over multiple SMS gateway adapters, config +
571
+ Faraday-based). It doesn't cover Kenyan providers or USSD/airtime, but several of its
572
+ patterns are directly reusable and are folded into §4 above:
573
+
574
+ | Idea from `sms_kit` | Where it lands in this plan |
575
+ |---|---|
576
+ | `Provider.deliver(options)` class-method shortcut, skipping `new(...).deliver` | `Base.send(**kwargs)` class method (§4) |
577
+ | `#to_sms` duck-typing so a domain object can be sent directly | `#to_arafa` duck-typing on `Base#initialize` (§4) |
578
+ | `SmsKit::Delivery#deliver(provider_symbol, options)` registry lookup | `Arafa.register` / `Arafa.send(:provider, ...)` registry (§4) |
579
+ | `HTTP::USER_AGENT` constant sent on every request | `User-Agent: arafa/<VERSION>` header on the shared Faraday connection (§4) |
580
+ | `SmsKit::Logging` configurable logger + Faraday `:logger` middleware | `Arafa.logger` + `f.response :logger, Arafa.logger` (§4) |
581
+ | VCR cassettes per provider (`test/fixtures/vcr_cassettes/<provider>/{success,failure}.yml`) alongside plain stub tests | Add `vcr` dev dependency (§2); one recorded cassette pair per provider as a belt-and-suspenders check against the WebMock-stubbed unit tests (step 9) |
582
+ | `Railtie` auto-wiring a Rails log file | Not adopted — out of scope until there's a Rails-integration request; note as a future extension only |
583
+
584
+ Not adopted: `sms_kit`'s open `Config::Store` (a `Hash` with `method_missing`, untyped) —
585
+ `dry-configurable` already covers this with proper typing/defaults, which is why it was
586
+ chosen over hand-rolling config.
587
+
588
+ ## 8. Open items — needs verification against a live account before shipping
589
+
590
+ - Wasiliana's USSD callback response contract (`CON`/`END` assumed, unconfirmed).
591
+ - Wasiliana airtime `phone_number` — single string vs. array (docs show it array-wrapped
592
+ inconsistently with the rest of their examples).
593
+ - Wasiliana failure response body shape for SMS send (only the generic HTTP error-code
594
+ table is documented, not a send-specific error payload).
595
+ - AT sandbox bulk-SMS endpoint is marked "coming soon" — only production is usable today.
596
+ - AT sandbox WhatsApp endpoint is likewise marked "coming soon" (§1.13) — production only.
597
+ - Gemspec `homepage`/`allowed_push_host`/`source_code_uri` need a real repo URL before
598
+ this can be published to RubyGems. *(Resolved — see `arafa.gemspec`, points at
599
+ `github.com/kamalogudah/arafa`.)*
600
+ - Voice's inbound call-control response DSL (§1.11) isn't scoped by this plan at all —
601
+ needs its own design pass covering the `Say`/`Play`/`GetDigits`/`Dial`/`Record`/
602
+ `Enqueue`/`Redirect`/`Reject`/`Conference` verbs before implementation starts.
603
+ ## 9. Adding a third provider later
604
+
605
+ Implement `Providers::Base` subclass with `#endpoint`, `#headers`, `#build_payload`,
606
+ `#parse_response`; add a `dry-configurable` settings block for its credentials; a
607
+ `Contracts::<Provider>Contract < Contracts::BaseContract` for any provider-specific rules;
608
+ reuse `PhoneNumber`, `Message`, `SendResult`, and the shared error hierarchy unchanged. If
609
+ the provider has inbound USSD, add a `Ussd::Request.from_<provider>(params)` parser reusing
610
+ the existing `UssdCallbackSchema`/`Ussd::Request`/`Ussd::Response` shapes.