ezacloud-sdk 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1658a6b210b8eb35fe76419f6e663a32bf00e57608732cdc929df8bb7a3c4e82
4
+ data.tar.gz: '093119db377c253025950995267b620ff174e556ad00a0fde5f21ebb67184d07'
5
+ SHA512:
6
+ metadata.gz: ac1b28e9cf7b25e37feea948fb94c16d4cdc3c5d1cb8726881ee45b427d7c6a157255e65c79067730767fc87b738f6333a3c76622bad60e732c9d99c3f0bae9d
7
+ data.tar.gz: 95bdfd4f4d844a58634bcf918d601004da45345caeca1e6a50525022eca6522c3d6f6d25b69d3e9d4fc337f9c02fe06a9214dc45b282360bc7828bc7a5477b90
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # Eza Cloud SDK for Ruby (`ezacloud-sdk`)
2
+
3
+ Official Ruby SDK for [Eza Cloud](https://eza.co.ke): PayEdge M-Pesa STK push, transactional emails, and cryptographically signed webhook verification.
4
+
5
+ Zero third-party runtime dependencies. Built entirely with the Ruby standard library (`net/http`, `openssl`, `json`, `securerandom`).
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ Add to your `Gemfile`:
12
+
13
+ ```ruby
14
+ gem "ezacloud-sdk"
15
+ ```
16
+
17
+ Or install directly:
18
+
19
+ ```bash
20
+ gem install ezacloud-sdk
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Usage
26
+
27
+ ```ruby
28
+ require "eza"
29
+
30
+ # Auto-configures using EZA_API_KEY, EZA_EMAIL_KEY from ENV on Eza Cloud pods
31
+ eza = Eza.new(api_key: "eza_live_...")
32
+
33
+ # 1. Trigger an M-Pesa STK push
34
+ tx = eza.payedge.stk_push(
35
+ phone: "254712345678",
36
+ amount_kes: 1500,
37
+ account_reference: "INV1024"
38
+ )
39
+ puts "Transaction pending: #{tx['checkout_request_id']}"
40
+
41
+ # 2. Send a transactional email
42
+ mail = eza.emails.send(
43
+ from: "Shop <orders@mail.shop.co.ke>",
44
+ to: "wanjiku@gmail.com",
45
+ subject: "Your receipt",
46
+ html: "<p>Thank you for your order!</p>"
47
+ )
48
+ puts "Email sent: #{mail['id']}"
49
+
50
+ # 3. Verify incoming webhook signatures
51
+ event = Eza.verify_webhook(
52
+ request.raw_post,
53
+ request.headers["Eza-Signature"],
54
+ ENV["EZA_WEBHOOK_SECRET"]
55
+ )
56
+ puts "Verified event: #{event['type']}"
57
+ ```
data/lib/eza/client.rb ADDED
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module Eza
9
+ class Client
10
+ DEFAULT_EMAIL_URL = "https://email.eza.co.ke"
11
+ DEFAULT_PAYEDGE_URL = "https://payedge.eza.co.ke"
12
+
13
+ attr_reader :api_key, :email_key, :email_url, :payedge_url, :max_retries, :payedge, :emails
14
+
15
+ def initialize(
16
+ api_key: nil,
17
+ email_key: nil,
18
+ email_url: nil,
19
+ payedge_url: nil,
20
+ max_retries: 2,
21
+ adapter: nil
22
+ )
23
+ @api_key = api_key || ENV["EZA_API_KEY"]
24
+ @email_key = email_key || ENV["EZA_EMAIL_KEY"] || @api_key
25
+ @email_url = (email_url || ENV["EZA_EMAIL_URL"] || DEFAULT_EMAIL_URL).chomp("/")
26
+ @payedge_url = (payedge_url || ENV["EZA_PAYEDGE_URL"] || DEFAULT_PAYEDGE_URL).chomp("/")
27
+ @max_retries = max_retries
28
+ @adapter = adapter
29
+
30
+ @payedge = PayEdge.new(self)
31
+ @emails = Emails.new(self)
32
+ end
33
+
34
+ def request(method, url_str, service:, body: nil, idempotency_key: nil)
35
+ auth_key = service == :email ? @email_key : @api_key
36
+ if auth_key.nil? || auth_key.empty?
37
+ service_name = service == :email ? "Email" : "PayEdge"
38
+ env_var = service == :email ? "EZA_EMAIL_KEY or EZA_API_KEY" : "EZA_API_KEY"
39
+ raise ConfigurationError, "No API key configured for #{service_name}. Pass api_key/email_key or set #{env_var}."
40
+ end
41
+
42
+ uri = URI.parse(url_str)
43
+ idempotency_key ||= SecureRandom.uuid if method.to_sym == :post
44
+
45
+ attempts = 0
46
+ loop do
47
+ attempts += 1
48
+ headers = {
49
+ "Authorization" => "Bearer #{auth_key}",
50
+ "Content-Type" => "application/json",
51
+ "Accept" => "application/json",
52
+ "User-Agent" => "eza-ruby/#{VERSION}"
53
+ }
54
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
55
+
56
+ begin
57
+ res_status, res_headers, res_body = execute_http(method, uri, headers, body)
58
+ rescue StandardError => e
59
+ if attempts <= @max_retries
60
+ sleep(0.1 * (2**(attempts - 1)))
61
+ next
62
+ end
63
+ raise
64
+ end
65
+
66
+ # Handle 429 Rate Limit (honor Retry-After)
67
+ if res_status == 429 && attempts <= @max_retries
68
+ retry_after = res_headers["retry-after"]
69
+ delay = retry_after ? [retry_after.to_f, 0.05].max : 0.2 * (2**(attempts - 1))
70
+ sleep(delay)
71
+ next
72
+ end
73
+
74
+ # Handle 503 / 504 Service Unavailable / Gateway Timeout
75
+ if (res_status == 503 || res_status == 504) && attempts <= @max_retries
76
+ sleep(0.1 * (2**(attempts - 1)))
77
+ next
78
+ end
79
+
80
+ if res_status >= 200 && res_status < 300
81
+ return res_body.empty? ? {} : JSON.parse(res_body)
82
+ end
83
+
84
+ raise Error.from_response(res_status, res_body, res_headers)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def execute_http(method, uri, headers, body)
91
+ if @adapter
92
+ return @adapter.call(method, uri, headers, body)
93
+ end
94
+
95
+ http = Net::HTTP.new(uri.host, uri.port)
96
+ http.use_ssl = (uri.scheme == "https")
97
+ http.open_timeout = 10
98
+ http.read_timeout = 30
99
+
100
+ req_class = case method.to_sym
101
+ when :post then Net::HTTP::Post
102
+ when :get then Net::HTTP::Get
103
+ when :delete then Net::HTTP::Delete
104
+ else Net::HTTP::Get
105
+ end
106
+
107
+ req = req_class.new(uri.request_uri)
108
+ headers.each { |k, v| req[k] = v }
109
+ req.body = JSON.generate(body) if body
110
+
111
+ res = http.request(req)
112
+ res_headers = {}
113
+ res.each_header { |k, v| res_headers[k.downcase] = v }
114
+
115
+ [res.code.to_i, res_headers, res.body || ""]
116
+ end
117
+ end
118
+ end
data/lib/eza/emails.rb ADDED
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "uri"
5
+
6
+ module Eza
7
+ class Emails
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil, reply_to: nil, attachments: nil, idempotency_key: nil)
13
+ raise ArgumentError, "Must provide text or html content" if text.nil? && html.nil?
14
+
15
+ recipients = Array(to)
16
+ payload = {
17
+ from: from,
18
+ to: recipients,
19
+ subject: subject
20
+ }
21
+ payload[:text] = text if text
22
+ payload[:html] = html if html
23
+ payload[:cc] = Array(cc) if cc
24
+ payload[:bcc] = Array(bcc) if bcc
25
+ payload[:reply_to] = reply_to if reply_to
26
+
27
+ if attachments && !attachments.empty?
28
+ payload[:attachments] = attachments.map do |att|
29
+ content = att[:content]
30
+ encoded = if content.is_a?(String) && !att[:already_base64]
31
+ Base64.strict_encode64(content)
32
+ else
33
+ content
34
+ end
35
+ {
36
+ filename: att[:filename] || att["filename"],
37
+ content: encoded
38
+ }
39
+ end
40
+ end
41
+
42
+ @client.request(
43
+ :post,
44
+ "#{@client.email_url}/emails",
45
+ service: :email,
46
+ body: payload,
47
+ idempotency_key: idempotency_key
48
+ )
49
+ end
50
+
51
+ def get(id)
52
+ escaped = URI.encode_www_form_component(id)
53
+ @client.request(
54
+ :get,
55
+ "#{@client.email_url}/emails/#{escaped}",
56
+ service: :email
57
+ )
58
+ end
59
+ end
60
+ end
data/lib/eza/errors.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eza
4
+ class Error < StandardError
5
+ attr_reader :status, :code, :request_id, :fields
6
+
7
+ def self.from_response(status, body, headers = {})
8
+ data = begin
9
+ JSON.parse(body)
10
+ rescue StandardError
11
+ {}
12
+ end
13
+
14
+ request_id = headers["eza-request-id"] || headers["x-request-id"] || data["request_id"]
15
+ code = data["code"] || data["name"]
16
+ message = data["message"] || data["detail"] || "HTTP #{status}"
17
+ fields = data["fields"] || data["errors"]
18
+
19
+ new(message, status: status, code: code, request_id: request_id, fields: fields)
20
+ end
21
+
22
+ def initialize(message, status: nil, code: nil, request_id: nil, fields: nil)
23
+ @status = status
24
+ @code = code
25
+ @request_id = request_id
26
+ @fields = fields
27
+ super(build_message(message))
28
+ end
29
+
30
+ private
31
+
32
+ def build_message(base_message)
33
+ parts = []
34
+ parts << "[#{@code}]" if @code
35
+ parts << base_message
36
+ parts << "(status #{@status})" if @status
37
+ parts << "(request_id: #{@request_id})" if @request_id
38
+ parts.join(" ")
39
+ end
40
+ end
41
+
42
+ class SignatureError < StandardError; end
43
+ class ConfigurationError < StandardError; end
44
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Eza
6
+ class PayEdge
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def stk_push(phone:, amount_kes:, account_reference:, description: nil, config_id: nil, idempotency_key: nil)
12
+ raise ArgumentError, "amount_kes must be an integer, got #{amount_kes.inspect}" unless amount_kes.is_a?(Integer)
13
+ raise ArgumentError, "amount_kes must be at least 1 KES" if amount_kes < 1
14
+
15
+ payload = {
16
+ phone: phone,
17
+ amount_kes: amount_kes,
18
+ account_reference: account_reference
19
+ }
20
+ payload[:description] = description if description
21
+ payload[:config_id] = config_id if config_id
22
+ payload[:idempotency_key] = idempotency_key if idempotency_key
23
+
24
+ @client.request(
25
+ :post,
26
+ "#{@client.payedge_url}/v1/stk-push",
27
+ service: :payedge,
28
+ body: payload,
29
+ idempotency_key: idempotency_key
30
+ )
31
+ end
32
+
33
+ def get_transaction(reference)
34
+ escaped = URI.encode_www_form_component(reference)
35
+ @client.request(
36
+ :get,
37
+ "#{@client.payedge_url}/v1/transactions/#{escaped}",
38
+ service: :payedge
39
+ )
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eza
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "json"
5
+
6
+ module Eza
7
+ module Webhook
8
+ DEFAULT_TOLERANCE = 300
9
+
10
+ module_function
11
+
12
+ def verify(raw_body, signature_header, secret, tolerance: DEFAULT_TOLERANCE, now: nil)
13
+ raise SignatureError, "Missing raw body or signature header" if raw_body.nil? || signature_header.nil?
14
+ raise SignatureError, "Missing webhook secret" if secret.nil? || secret.empty?
15
+
16
+ timestamp = nil
17
+ signature = nil
18
+
19
+ signature_header.to_s.split(",").each do |part|
20
+ key, value = part.strip.split("=", 2)
21
+ case key
22
+ when "t"
23
+ timestamp = Integer(value, 10) rescue nil
24
+ when "v1"
25
+ signature = value
26
+ end
27
+ end
28
+
29
+ raise SignatureError, "Malformed signature header: missing timestamp or signature" if timestamp.nil? || signature.nil?
30
+
31
+ current_time = now ? now.to_i : Time.now.to_i
32
+ if (current_time - timestamp).abs > tolerance
33
+ raise SignatureError, "Webhook timestamp outside tolerance zone (#{tolerance}s)"
34
+ end
35
+
36
+ signed_payload = "t.#{timestamp}.#{raw_body}"
37
+ computed = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
38
+
39
+ unless secure_compare(computed, signature)
40
+ raise SignatureError, "Webhook signature mismatch"
41
+ end
42
+
43
+ JSON.parse(raw_body)
44
+ end
45
+
46
+ def secure_compare(a, b)
47
+ return false unless a.is_a?(String) && b.is_a?(String) && a.bytesize == b.bytesize
48
+
49
+ if OpenSSL.respond_to?(:secure_compare)
50
+ OpenSSL.secure_compare(a, b)
51
+ else
52
+ res = 0
53
+ a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
54
+ res.zero?
55
+ end
56
+ end
57
+ end
58
+ end
data/lib/eza.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "eza/version"
4
+ require_relative "eza/errors"
5
+ require_relative "eza/webhook"
6
+ require_relative "eza/payedge"
7
+ require_relative "eza/emails"
8
+ require_relative "eza/client"
9
+
10
+ module Eza
11
+ class << self
12
+ def new(**options)
13
+ Client.new(**options)
14
+ end
15
+
16
+ def verify_webhook(...)
17
+ Webhook.verify(...)
18
+ end
19
+ end
20
+ end
metadata ADDED
@@ -0,0 +1,52 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ezacloud-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Eza Cloud
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Zero-dependency Ruby client for Eza Cloud. Initiate M-Pesa STK pushes,
13
+ send transactional emails, and verify cryptographically signed webhooks.
14
+ email:
15
+ - support@eza.co.ke
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - README.md
21
+ - lib/eza.rb
22
+ - lib/eza/client.rb
23
+ - lib/eza/emails.rb
24
+ - lib/eza/errors.rb
25
+ - lib/eza/payedge.rb
26
+ - lib/eza/version.rb
27
+ - lib/eza/webhook.rb
28
+ homepage: https://eza.co.ke/docs
29
+ licenses:
30
+ - MIT
31
+ metadata:
32
+ homepage_uri: https://eza.co.ke/docs
33
+ source_code_uri: https://github.com/ezacloud/eza/tree/main/sdks/ruby
34
+ documentation_uri: https://eza.co.ke/docs
35
+ rdoc_options: []
36
+ require_paths:
37
+ - lib
38
+ required_ruby_version: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 3.1.0
43
+ required_rubygems_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ requirements: []
49
+ rubygems_version: 3.6.9
50
+ specification_version: 4
51
+ summary: Official Ruby SDK for Eza Cloud (PayEdge M-Pesa, Transactional Email, Webhooks)
52
+ test_files: []