airwallex-ruby 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.
@@ -0,0 +1,78 @@
1
+ # Airwallex API Research
2
+
3
+ Notes for building the unofficial `airwallex-ruby` SDK. Phase 1 uses this document only — no live API calls yet.
4
+
5
+ ## Official documentation
6
+
7
+ - API reference: https://www.airwallex.com/docs/api
8
+ - Authentication: https://www.airwallex.com/docs/api/authentication/api_access/login
9
+ - Developer tools: https://www.airwallex.com/docs/developer-tools/api/quickstart-with-postman
10
+ - Manage API keys: https://www.airwallex.com/docs/developer-tools/api/manage-api-keys
11
+
12
+ ## Base URLs
13
+
14
+ | Environment | Base URL |
15
+ |-------------|----------|
16
+ | Sandbox (demo) | `https://api-demo.airwallex.com/api/v1` |
17
+ | Production | `https://api.airwallex.com/api/v1` |
18
+
19
+ ## Authentication
20
+
21
+ **Endpoint:** `POST /authentication/login`
22
+
23
+ **Required headers:**
24
+
25
+ - `x-client-id` — Airwallex Client ID
26
+ - `x-api-key` — Airwallex API key
27
+
28
+ **Response:** JSON with `token` and `expires_at`. Use the token as `Authorization: Bearer <token>` on subsequent requests.
29
+
30
+ **Implementation notes:**
31
+
32
+ - Do not call the login endpoint before every request.
33
+ - Cache the token in memory and reuse it until `expires_at`.
34
+ - Refresh only when expired or after a 401 response.
35
+
36
+ Optional header for scoped keys:
37
+
38
+ - `x-login-as` — target account ID when the API key is scoped to multiple accounts
39
+
40
+ ## Current implemented resources
41
+
42
+ **Implemented:**
43
+
44
+ - Authentication
45
+ - PaymentIntents
46
+ - Refunds
47
+ - Webhook verification
48
+ - Rails initializer generator
49
+
50
+ **Planned:**
51
+
52
+ - PaymentIntent confirm/capture
53
+ - Customers
54
+ - PaymentConsents
55
+ - Transfers
56
+ - Balances
57
+ - Transactions
58
+
59
+ ## Planned resources (initial scope)
60
+
61
+ | Resource | Purpose |
62
+ |----------|---------|
63
+ | Authentication | Obtain and manage access tokens |
64
+ | PaymentIntents | Payment acceptance — create, confirm, capture, cancel |
65
+ | Refunds | Refund captured payments |
66
+ | Webhooks | Verify webhook signatures and parse events |
67
+ | Balances | Query multi-currency account balances |
68
+ | Transfers | Payouts to beneficiaries |
69
+
70
+ ## Phase roadmap
71
+
72
+ Phases 1–10 are complete (foundation, HTTP client, authentication, PaymentIntents, Refunds, idempotency, webhooks, Rails integration). Phase 11 covers documentation and developer experience. Future phases add more API resources and integration test mode.
73
+
74
+ ## References
75
+
76
+ - Postman collection uses API version header `x-api-version` (e.g. `2025-11-11`)
77
+ - Write operations typically require a `request_id` (UUID) for idempotency
78
+ - Sandbox file uploads use `https://files-demo.airwallex.com`; production uses `https://files.airwallex.com`
data/docs/release.md ADDED
@@ -0,0 +1,66 @@
1
+ # Release Checklist
2
+
3
+ ## Before release
4
+
5
+ - bundle install
6
+ - bundle exec rspec
7
+ - bundle exec rubocop
8
+ - bundle exec rake build
9
+ - gem install ./pkg/airwallex-ruby-0.1.0.gem
10
+ - irb
11
+ - require "airwallex"
12
+ - Airwallex::VERSION
13
+
14
+ ## Git release
15
+
16
+ - git status
17
+ - git add .
18
+ - git commit -m "Prepare v0.1.0 release"
19
+ - git push origin develop
20
+
21
+ Publishing a GitHub Release from **develop** triggers the [Release workflow](../.github/workflows/release.yml), which:
22
+
23
+ 1. Merges `develop` into `master`
24
+ 2. Runs RuboCop and RSpec
25
+ 3. Builds the gem
26
+ 4. Publishes to RubyGems
27
+ 5. Attaches the `.gem` file to the GitHub Release
28
+
29
+ ### Create the release on GitHub
30
+
31
+ 1. Open **Releases → Draft a new release**
32
+ 2. Set **Target** to `develop`
33
+ 3. Create tag `v0.1.0` (must match `Airwallex::VERSION`)
34
+ 4. Add release notes and click **Publish release**
35
+
36
+ ### GitHub Actions setup
37
+
38
+ Preferred: configure [RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/) for this repository with workflow file `release.yml`.
39
+
40
+ Fallback: add a repository secret:
41
+
42
+ | Secret | Description |
43
+ |--------|-------------|
44
+ | `RUBYGEMS_API_KEY` | RubyGems API key with push access |
45
+
46
+ Because this gem sets `rubygems_mfa_required`, create the API key at [rubygems.org](https://rubygems.org/sign_in) after MFA is enabled. The key must include push permission for `airwallex-ruby`.
47
+
48
+ Manual release retry (without publishing a new GitHub Release):
49
+
50
+ 1. Open **Actions → Release → Run workflow**
51
+ 2. Enter the tag (for example `v0.1.0`)
52
+
53
+ The workflow verifies that the tag matches `Airwallex::VERSION` before publishing.
54
+
55
+ ## RubyGems release
56
+
57
+ - gem push pkg/airwallex-ruby-0.1.0.gem
58
+
59
+ Only publish after final review.
60
+
61
+ ## Post-release verification
62
+
63
+ - gem install airwallex-ruby
64
+ - irb
65
+ - require "airwallex"
66
+ - Airwallex::VERSION
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Basic global configuration and client access.
4
+ #
5
+ # Usage:
6
+ # AIRWALLEX_CLIENT_ID=... AIRWALLEX_API_KEY=... ruby examples/basic_configuration.rb
7
+
8
+ require "airwallex"
9
+
10
+ Airwallex.configure do |config|
11
+ config.client_id = ENV["AIRWALLEX_CLIENT_ID"]
12
+ config.api_key = ENV["AIRWALLEX_API_KEY"]
13
+ config.login_as = ENV["AIRWALLEX_LOGIN_AS"]
14
+ config.environment = :demo
15
+ config.timeout = 30
16
+ config.open_timeout = 10
17
+ end
18
+
19
+ client = Airwallex.client
20
+
21
+ puts "Airwallex client ready (#{client.environment} environment)"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Create a PaymentIntent with an idempotency key.
4
+ #
5
+ # Usage:
6
+ # AIRWALLEX_CLIENT_ID=... AIRWALLEX_API_KEY=... ruby examples/payment_intent_create.rb
7
+
8
+ require "airwallex"
9
+
10
+ client = Airwallex::Client.new(
11
+ client_id: ENV["AIRWALLEX_CLIENT_ID"],
12
+ api_key: ENV["AIRWALLEX_API_KEY"],
13
+ login_as: ENV["AIRWALLEX_LOGIN_AS"],
14
+ environment: :demo
15
+ )
16
+
17
+ payment_intent = client.payment_intents.create(
18
+ {
19
+ amount: 1000,
20
+ currency: "PHP",
21
+ merchant_order_id: "ORDER-1001",
22
+ return_url: "https://example.com/return"
23
+ },
24
+ idempotency_key: "order-1001-create"
25
+ )
26
+
27
+ puts payment_intent["id"]
28
+ puts payment_intent["client_secret"]
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Airwallex webhook controller for Rails.
4
+ # Copy into app/controllers/airwallex_webhooks_controller.rb
5
+ #
6
+ # Route (config/routes.rb):
7
+ # post "/webhooks/airwallex", to: "airwallex_webhooks#create"
8
+ #
9
+ # class AirwallexWebhooksController < ApplicationController
10
+ # skip_before_action :verify_authenticity_token
11
+ #
12
+ # def create
13
+ # event = Airwallex::Webhook.construct_event(
14
+ # payload: request.body.read,
15
+ # signature: request.headers["x-signature"],
16
+ # timestamp: request.headers["x-timestamp"],
17
+ # secret: webhook_secret
18
+ # )
19
+ #
20
+ # case event["name"]
21
+ # when "payment_intent.succeeded"
22
+ # # handle payment success
23
+ # when "refund.accepted"
24
+ # # handle refund accepted
25
+ # end
26
+ #
27
+ # head :ok
28
+ # rescue Airwallex::WebhookSignatureError, Airwallex::InvalidResponseError
29
+ # head :bad_request
30
+ # end
31
+ #
32
+ # private
33
+ #
34
+ # def webhook_secret
35
+ # Rails.application.credentials.dig(:airwallex, :webhook_secret) ||
36
+ # ENV.fetch("AIRWALLEX_WEBHOOK_SECRET")
37
+ # end
38
+ # end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Create a refund with an idempotency key.
4
+ #
5
+ # Usage:
6
+ # AIRWALLEX_CLIENT_ID=... AIRWALLEX_API_KEY=... ruby examples/refund_create.rb
7
+
8
+ require "airwallex"
9
+
10
+ client = Airwallex::Client.new(
11
+ client_id: ENV["AIRWALLEX_CLIENT_ID"],
12
+ api_key: ENV["AIRWALLEX_API_KEY"],
13
+ login_as: ENV["AIRWALLEX_LOGIN_AS"],
14
+ environment: :demo
15
+ )
16
+
17
+ refund = client.refunds.create(
18
+ {
19
+ payment_intent_id: "int_123",
20
+ amount: 500,
21
+ reason: "requested_by_customer",
22
+ metadata: {
23
+ order_id: "ORDER-1001"
24
+ }
25
+ },
26
+ idempotency_key: "order-1001-refund-1"
27
+ )
28
+
29
+ puts refund["id"]
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Verify an Airwallex webhook payload and parse the event.
4
+ #
5
+ # Usage:
6
+ # AIRWALLEX_WEBHOOK_SECRET=... ruby examples/webhook_verification.rb
7
+
8
+ require "airwallex"
9
+ require "openssl"
10
+
11
+ secret = ENV.fetch("AIRWALLEX_WEBHOOK_SECRET")
12
+ payload = '{"name":"payment_intent.succeeded","data":{}}'
13
+ timestamp = Time.now.to_i.to_s
14
+ signature = OpenSSL::HMAC.hexdigest("SHA256", secret, timestamp + payload)
15
+
16
+ event = Airwallex::Webhook.construct_event(
17
+ payload: payload,
18
+ signature: signature,
19
+ timestamp: timestamp,
20
+ secret: secret
21
+ )
22
+
23
+ puts event["name"]
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+ require "time"
6
+
7
+ module Airwallex
8
+ class Client
9
+ DEFAULT_HEADERS = {
10
+ "Content-Type" => "application/json",
11
+ "Accept" => "application/json"
12
+ }.freeze
13
+
14
+ TOKEN_EXPIRY_BUFFER = 60
15
+
16
+ attr_reader :client_id, :api_key, :login_as, :environment, :timeout, :open_timeout, :logger,
17
+ :access_token, :token_expires_at
18
+
19
+ def initialize(**options)
20
+ config = Airwallex.configuration
21
+
22
+ @client_id = options.fetch(:client_id, config.client_id)
23
+ @api_key = options.fetch(:api_key, config.api_key)
24
+ @login_as = options.key?(:login_as) ? options[:login_as] : config.login_as
25
+ @environment = Configuration.validate_environment!(options.fetch(:environment, config.environment))
26
+ @timeout = options.fetch(:timeout, config.timeout)
27
+ @open_timeout = options.fetch(:open_timeout, config.open_timeout)
28
+ @logger = options.fetch(:logger, config.logger)
29
+ @access_token = nil
30
+ @token_expires_at = nil
31
+ end
32
+
33
+ def base_url
34
+ Configuration::ENVIRONMENTS.fetch(environment)
35
+ end
36
+
37
+ def authentication
38
+ @authentication ||= Resources::Authentication.new(self)
39
+ end
40
+
41
+ def payment_intents
42
+ @payment_intents ||= Resources::PaymentIntents.new(self)
43
+ end
44
+
45
+ def refunds
46
+ @refunds ||= Resources::Refunds.new(self)
47
+ end
48
+
49
+ def authenticate
50
+ authentication.login
51
+ end
52
+
53
+ def authenticated?
54
+ !access_token.nil? && !token_expired?
55
+ end
56
+
57
+ def auth_headers
58
+ { "Authorization" => "Bearer #{access_token}" }
59
+ end
60
+
61
+ def get(path, params = {}, headers = {}, authenticated: true)
62
+ request(:get, path, params: params, headers: headers, authenticated: authenticated)
63
+ end
64
+
65
+ def post(path, body = {}, headers = {}, authenticated: true, idempotency_key: nil)
66
+ validate_idempotency_key!(idempotency_key)
67
+ request(:post, path, body: body, headers: headers, authenticated: authenticated,
68
+ idempotency_key: idempotency_key)
69
+ end
70
+
71
+ def patch(path, body = {}, headers = {}, authenticated: true, idempotency_key: nil)
72
+ validate_idempotency_key!(idempotency_key)
73
+ request(:patch, path, body: body, headers: headers, authenticated: authenticated,
74
+ idempotency_key: idempotency_key)
75
+ end
76
+
77
+ def delete(path, params = {}, headers = {}, authenticated: true)
78
+ request(:delete, path, params: params, headers: headers, authenticated: authenticated)
79
+ end
80
+
81
+ def validate_credentials!
82
+ raise ConfigurationError, "client_id is required" if client_id.nil? || client_id.to_s.empty?
83
+ raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.to_s.empty?
84
+ end
85
+
86
+ def store_token!(response)
87
+ token = response["token"]
88
+ raise AuthenticationError, "Authentication response missing token" if token.nil? || token.to_s.empty?
89
+
90
+ @access_token = token
91
+ @token_expires_at = parse_expires_at(response["expires_at"])
92
+ end
93
+
94
+ private
95
+
96
+ def request(method, path, params: nil, body: nil, headers: {}, authenticated: true,
97
+ idempotency_key: nil)
98
+ ensure_authenticated! if authenticated
99
+
100
+ response = connection.run_request(
101
+ method,
102
+ request_url(path),
103
+ body,
104
+ merge_headers(headers, authenticated: authenticated, idempotency_key: idempotency_key)
105
+ ) do |req|
106
+ req.params.update(params) if params && !params.empty?
107
+ end
108
+
109
+ handle_response(response)
110
+ rescue Faraday::TimeoutError => e
111
+ raise TimeoutError, e.message
112
+ rescue Faraday::ConnectionFailed => e
113
+ raise TimeoutError, e.message if timeout_error?(e)
114
+
115
+ raise
116
+ end
117
+
118
+ def ensure_authenticated!
119
+ authenticate unless authenticated?
120
+ end
121
+
122
+ def token_expired?
123
+ return true if access_token.nil? || token_expires_at.nil?
124
+
125
+ Time.now >= (token_expires_at - TOKEN_EXPIRY_BUFFER)
126
+ end
127
+
128
+ def parse_expires_at(value)
129
+ if value.nil? || value.to_s.strip.empty?
130
+ raise AuthenticationError, "Authentication response has invalid expires_at"
131
+ end
132
+
133
+ case value
134
+ when Time
135
+ value
136
+ when Integer, Float
137
+ Time.at(value)
138
+ when String
139
+ Time.parse(value)
140
+ else
141
+ raise AuthenticationError, "Authentication response has invalid expires_at"
142
+ end
143
+ rescue ::ArgumentError, ::TypeError
144
+ raise AuthenticationError, "Authentication response has invalid expires_at"
145
+ end
146
+
147
+ def timeout_error?(error)
148
+ cause = error.wrapped_exception
149
+ cause.is_a?(Net::OpenTimeout) ||
150
+ cause.is_a?(Net::ReadTimeout) ||
151
+ cause.is_a?(Timeout::Error) ||
152
+ error.message.match?(/timeout|timed out/i)
153
+ end
154
+
155
+ def request_url(path)
156
+ path = path.to_s
157
+ return path if path.start_with?("http://", "https://")
158
+
159
+ path = "/#{path}" unless path.start_with?("/")
160
+ "#{base_url}#{path}"
161
+ end
162
+
163
+ def connection
164
+ @connection ||= Faraday.new do |conn|
165
+ conn.options.timeout = timeout
166
+ conn.options.open_timeout = open_timeout
167
+ conn.request :json
168
+ conn.adapter Faraday.default_adapter
169
+ end
170
+ end
171
+
172
+ def merge_headers(headers, authenticated:, idempotency_key: nil)
173
+ merged = DEFAULT_HEADERS.merge(headers)
174
+ merged = merged.merge(auth_headers) if authenticated
175
+ merged["x-idempotency-key"] = idempotency_key if idempotency_key
176
+ merged
177
+ end
178
+
179
+ def validate_idempotency_key!(idempotency_key)
180
+ return if idempotency_key.nil?
181
+
182
+ return if idempotency_key.is_a?(String) && !idempotency_key.strip.empty?
183
+
184
+ raise ArgumentError, "idempotency_key must be a non-empty String"
185
+ end
186
+
187
+ def handle_response(response)
188
+ parsed_body = parse_body(response.body)
189
+
190
+ return parsed_body if response.status.between?(200, 299)
191
+
192
+ HTTPError.raise_for_response!(response.status, parsed_body, response.body)
193
+ end
194
+
195
+ def parse_body(body)
196
+ return {} if body.nil? || body.to_s.strip.empty?
197
+
198
+ JSON.parse(body)
199
+ rescue JSON::ParserError => e
200
+ raise InvalidResponseError, "Invalid JSON response: #{e.message}"
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ class Configuration
5
+ ENVIRONMENTS = {
6
+ demo: "https://api-demo.airwallex.com/api/v1",
7
+ production: "https://api.airwallex.com/api/v1"
8
+ }.freeze
9
+
10
+ attr_accessor :client_id, :api_key, :login_as, :timeout, :open_timeout, :logger
11
+ attr_reader :environment
12
+
13
+ def initialize
14
+ @environment = :demo
15
+ @timeout = 30
16
+ @open_timeout = 10
17
+ @logger = nil
18
+ end
19
+
20
+ def environment=(value)
21
+ self.class.validate_environment!(value)
22
+ @environment = value
23
+ end
24
+
25
+ def base_url
26
+ ENVIRONMENTS.fetch(environment)
27
+ end
28
+
29
+ alias api_base_url base_url
30
+
31
+ def self.validate_environment!(environment)
32
+ return environment if ENVIRONMENTS.key?(environment)
33
+
34
+ valid = ENVIRONMENTS.keys.map(&:inspect).join(", ")
35
+ raise ConfigurationError, "Invalid environment: #{environment.inspect}. Valid environments are: #{valid}"
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ class Error < StandardError
5
+ end
6
+
7
+ class ConfigurationError < Error
8
+ end
9
+
10
+ class ArgumentError < Error
11
+ end
12
+
13
+ class AuthenticationError < Error
14
+ end
15
+
16
+ class HTTPError < Error
17
+ attr_reader :status, :code, :source, :details, :response_body
18
+
19
+ def initialize(message = nil, **attrs)
20
+ super(message)
21
+ @status = attrs[:status]
22
+ @code = attrs[:code]
23
+ @source = attrs[:source]
24
+ @details = attrs[:details]
25
+ @response_body = attrs[:response_body]
26
+ end
27
+
28
+ def self.raise_for_response!(status, parsed_body, raw_body)
29
+ fields = parsed_body.is_a?(Hash) ? parsed_body : {}
30
+ raise error_class_for(status).new(
31
+ extract_message(parsed_body, status),
32
+ status: status,
33
+ code: fields["code"],
34
+ source: fields["source"],
35
+ details: fields["details"],
36
+ response_body: raw_body
37
+ )
38
+ end
39
+
40
+ def self.error_class_for(status)
41
+ return ServerError if status.between?(500, 599)
42
+
43
+ status_error_map.fetch(status, HTTPError)
44
+ end
45
+
46
+ def self.extract_message(parsed_body, status)
47
+ return parsed_body["message"] if parsed_body.is_a?(Hash) && parsed_body["message"]
48
+ return parsed_body["code"] if parsed_body.is_a?(Hash) && parsed_body["code"]
49
+
50
+ "HTTP #{status}"
51
+ end
52
+
53
+ def self.status_error_map
54
+ @status_error_map ||= {
55
+ 400 => BadRequestError,
56
+ 401 => UnauthorizedError,
57
+ 403 => ForbiddenError,
58
+ 404 => NotFoundError,
59
+ 409 => ConflictError,
60
+ 429 => RateLimitError
61
+ }.freeze
62
+ end
63
+
64
+ private_class_method :error_class_for, :extract_message, :status_error_map
65
+ end
66
+
67
+ class BadRequestError < HTTPError; end
68
+ class UnauthorizedError < HTTPError; end
69
+ class ForbiddenError < HTTPError; end
70
+ class NotFoundError < HTTPError; end
71
+ class ConflictError < HTTPError; end
72
+ class RateLimitError < HTTPError; end
73
+ class ServerError < HTTPError; end
74
+ class TimeoutError < HTTPError; end
75
+ class InvalidResponseError < HTTPError; end
76
+ class WebhookSignatureError < Error; end
77
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ class Railtie < Rails::Railtie
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ module Resources
5
+ class Authentication < BaseResource
6
+ LOGIN_PATH = "/authentication/login"
7
+
8
+ def login
9
+ client.validate_credentials!
10
+
11
+ response = post(LOGIN_PATH, {}, authentication_headers, authenticated: false)
12
+ client.store_token!(response)
13
+ response
14
+ end
15
+
16
+ private
17
+
18
+ def authentication_headers
19
+ headers = {
20
+ "x-client-id" => client.client_id,
21
+ "x-api-key" => client.api_key
22
+ }
23
+ headers["x-login-as"] = client.login_as if client.login_as
24
+ headers
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airwallex
4
+ module Resources
5
+ class BaseResource
6
+ attr_reader :client
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ private
13
+
14
+ def get(path, params = {}, headers = {}, **options)
15
+ client.get(path, params, headers, **options)
16
+ end
17
+
18
+ def post(path, body = {}, headers = {}, **options)
19
+ client.post(path, body, headers, **options)
20
+ end
21
+
22
+ def patch(path, body = {}, headers = {}, **options)
23
+ client.patch(path, body, headers, **options)
24
+ end
25
+
26
+ def delete(path, params = {}, headers = {}, **options)
27
+ client.delete(path, params, headers, **options)
28
+ end
29
+
30
+ def validate_idempotency_key!(idempotency_key)
31
+ return if idempotency_key.nil?
32
+
33
+ return if idempotency_key.is_a?(String) && !idempotency_key.strip.empty?
34
+
35
+ raise Airwallex::ArgumentError, "idempotency_key must be a non-empty String"
36
+ end
37
+
38
+ def validate_id!(id, name = "id")
39
+ raise Airwallex::ArgumentError, "#{name} is required" if id.nil? || id.to_s.strip.empty?
40
+ end
41
+
42
+ def validate_params!(params)
43
+ raise Airwallex::ArgumentError, "params must be a Hash" unless params.is_a?(Hash)
44
+ end
45
+ end
46
+ end
47
+ end