conexa 0.1.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c71329999c6d13c4e935ef1e37a8eca6df5811c44d0326184cc1676d31fb199b
4
- data.tar.gz: c6f6946375a05712ed7d952a4314738e86a2429bf3a249e269d2fd40b254c11f
3
+ metadata.gz: 1ff60656e2731455f658e616416581ede3b7a062e184097f756bcaaca9b523d4
4
+ data.tar.gz: d3c3a28eb846199bfebfbe963b160af18baab1be9fcfeee2382466a8916c145a
5
5
  SHA512:
6
- metadata.gz: 13fc7859937cd156cd8ae68b78d6c303b62460d4f6e682f270f58927e87c8a99e29e7d43a4814318b53ed5c5e20ce5e0ea3892d46db8d4d08147e9aea79a690e
7
- data.tar.gz: c1ae89758b0cea23df6c5f5cc16895c060baef04da9a31fefd58eb7ac9c7ba1b1483dfc44b81c00ddfb592cada2e34cace170237e5f298b3ea8b3217572bd1c9
6
+ metadata.gz: 3108d62cff165810c56ae8e5ba738b54ebfa223995617cc7099abebd6511db45a1bab061f7c99df229512ef3004da6d05a0cab623e9662bed464606b9322a1dd
7
+ data.tar.gz: 1c15d0d6ed78d606e5cfb5a40b56399ab05dacaf8b28bd6562c12ec6f1cae40a87bf3d00e2f09140de061ab36a411eefd63cc2361ae3208907241ec51c23f89e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,211 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-08-11
11
+
12
+ Aligns the gem with the published API v2 contract. **0.1.1 is broken**:
13
+ `Charge.settle` and `Contract.end_contract` 404 against a live tenant, and a
14
+ successful write that answers with an empty body raises `NoMethodError` after
15
+ the operation has already taken effect — in a billing integration that invites a
16
+ retry. Upgrading is strongly recommended.
17
+
18
+ Every fix below was verified against `docs/postman-collection.json`, Conexa's own
19
+ published documentation, and is now enforced by
20
+ `spec/contract/api_contract_spec.rb`.
21
+
22
+ ### Fixed
23
+ - **Empty response bodies no longer raise, anywhere.** `Request#run` returns `{}`
24
+ for an empty, whitespace or `null` body at any status. With the Oj adapter
25
+ `MultiJson.decode("")` returns `nil` without raising, so the previous
26
+ `rescue MultiJson::ParseError` / `204` guard never fired — even a documented
27
+ `204` success (`PATCH /charge/settle/:id`) raised `NoMethodError`, which is not
28
+ a `Conexa::ConexaError` and escaped `rescue Conexa::ConexaError`.
29
+
30
+ `ConexaObject#update` treats anything carrying no attributes as "nothing to
31
+ merge" — `nil`, `{}`, and the arrays and scalars `Request#run` also produces —
32
+ and `Model#create` does the same. Fixing `Request#run` alone was not enough:
33
+ `Model#save` and `#destroy` fed the result straight into `update`, so **every**
34
+ resource's generic CRUD still raised `NoMethodError` on a documented `204`
35
+ (`DELETE /sale/:id` among them). Only the three action methods were safe, and
36
+ only because they discard their return value.
37
+ - **A write answering `200 {}` no longer wipes the object.** `update` removes
38
+ every attribute absent from the incoming hash, which is right for a full
39
+ refresh and destructive for an empty write response — it cleared the whole
40
+ object, primary key included, and reported success.
41
+ - **`Model#save` guards a blank id**, as `#destroy` already did. Without it an
42
+ id-less object issued `PATCH /customer/` instead of failing fast.
43
+ - **`Result#next_page` raises a `Conexa` error** when pagination carries no
44
+ `limit`, instead of `TypeError` from `nil + Integer`, and survives a
45
+ non-marshallable filter in the original query.
46
+ - **`Conexa::ValidationError` is usable.** It rendered as the bare string
47
+ `"Conexa::ValidationError"` and `#to_h` raised `NoMethodError`. It is only
48
+ reachable for an error body with no `message` key — which no documented
49
+ response has — but it now reports what actually arrived.
50
+ - **Exception messages read as prose.** The old format appended a dangling
51
+ `"=> Erros: "` to the 75 documented responses that carry no `errors` array, and
52
+ dumped Ruby's `#inspect` of an array of hashes for the ones that do. It now
53
+ uses the same normalisation as `#api_error_messages`:
54
+ `422 … => It was not possible to process your request — CHARGE_11: Only open charges can be settled.`
55
+ - **Error bodies that are not JSON objects no longer raise `TypeError`.** An
56
+ array or scalar error body reached `parsed_error['message']` and blew up inside
57
+ the error handler.
58
+ - **`Request#run` tolerates a top-level array or scalar body.** Defensive: every
59
+ list endpoint probed on 2026-08-12 used the `{data, pagination}` envelope, so
60
+ this shape has not been observed in the wild — it removes a crash class rather
61
+ than fixing a seen failure.
62
+ - **Action endpoints use the documented verb.** `Charge#settle`,
63
+ `Contract#end_contract` and `RecurringSale#end_recurring_sale` send `PATCH`;
64
+ `POST` 404s.
65
+ - **`Conexa::Company.all` requests the documented `/companies`.** `Model#url`
66
+ pluralizes by appending `"s"`, which produced `/companys`. A read-only probe of
67
+ a live tenant on 2026-08-12 showed the API routes **both** spellings, so this
68
+ was an undocumented path that worked rather than the 404 first reported — the
69
+ override stands because an undocumented alias can vanish without notice, but
70
+ nothing was broken by it.
71
+ - **Nested arrays are camelized.** `Util.camelize_hash` treated an `Array` as a
72
+ scalar, so snake_case keys inside arrays of objects were sent untouched and
73
+ rejected. Affects `complementaryServices`, `productQuotas`, `devices`,
74
+ `extraFields`, `bookingModels`, `visitors` and `costCenters` — ten documented
75
+ endpoints across seven resources.
76
+ - **`page`/`size` are converted to `limit`/`offset`** instead of being sent. The
77
+ API validates `page` and then ignores it, always answering `offset: 0`, so a
78
+ loop driven by `hasNext` never terminated and silently re-yielded the same
79
+ batch. Non-positive or non-integer values now raise `Conexa::RequestError`.
80
+ - **`Model#id` falls back to `attributes["id"]` again.** `primary_key_attribute`
81
+ aliased `#id` onto the resource key, making the documented fallback dead code —
82
+ write endpoints answer with `{"id": N}`, which is what `Model#create` reads.
83
+ - **`ResponseError` no longer raises `NoMethodError` on malformed bodies.** It was
84
+ handed a `RestClient::Response`, which has no `#message`.
85
+ - **A broken connection raises `Conexa::ConnectionError`** (issue #11). The
86
+ `rescue RestClient::ServerBrokeConnection` clause was unreachable —
87
+ `ServerBrokeConnection` subclasses `RestClient::Exception`, so the broader
88
+ clause listed above it always matched first, then tried to decode a `nil`
89
+ `http_body` and raised `NoMethodError` from inside the error handler.
90
+ Connection-level failures are now rescued first: `SocketError`,
91
+ `ServerBrokeConnection`, `SSLCertificateNotVerified` and
92
+ `RestClient::Exceptions::Timeout`. A real HTTP 408 stays in the response
93
+ taxonomy, since `RestClient::RequestTimeout` is a *superclass* of
94
+ `Exceptions::Timeout`.
95
+ - **An unusable id raises `Conexa::RequestError`, not `URI::InvalidURIError`**
96
+ (issue #12). `find(" 123 ")` leaked a URI error from inside RestClient, outside
97
+ the `Conexa::ConexaError` hierarchy. Surrounding whitespace is now stripped, and
98
+ `Request#full_api_url` validates the URL it builds.
99
+ - **`RecurringSale#end_recurring_sale` sends the documented `date`.** The verb was
100
+ corrected but the field was not, so `end_date:` still went out as the rejected
101
+ `endDate`, and its YARD named that field as the example. It now shares
102
+ `Util.normalize_end_date_param` with `Contract`, and gains a `set_end_date`
103
+ alias for symmetry.
104
+ - `README.md` configured a non-existent `config.subdomain`; the quick start now
105
+ runs.
106
+ - `lib/conexa/resources/supplier.rb` shipped as `0600`, breaking `require` on a
107
+ shared install.
108
+
109
+ - **`Conexa::CreditCard` is write-only.** API v2 exposes no read for it: `GET
110
+ /creditCard` answers "Unable to resolve the request" and `GET /creditCard/:id`
111
+ "unable to find the requested action" — neither is the permission wording the
112
+ API uses for a resource an account cannot see. `all`/`find`/`find_by` and their
113
+ aliases now raise `Conexa::RequestError` explaining that, instead of a bare
114
+ `NotFound` reading as "no such card", and `#create` skips `Model#create`'s
115
+ re-fetch. The integration spec that asserted those reads worked — against
116
+ stubs of endpoints that do not exist — was rewritten.
117
+ - **Listings always answer with a `Conexa::Result`.** An empty body yielded `nil`
118
+ and a bare-array body an `Array`, so `.data`/`.pagination`/`.next_page` failed
119
+ far from the cause.
120
+ - **`Model#fetch` raises instead of silently keeping stale attributes** when the
121
+ refresh comes back with no body. The nil-guard that makes an empty *write*
122
+ response harmless must not make an empty *read* look successful.
123
+ - **`Result#has_next?` returns a boolean**, not `nil`, when there is no
124
+ pagination at all.
125
+
126
+ ### Added
127
+ - **Read-only mode.** `config.read_only = true`, `CONEXA_READ_ONLY=1`, or a
128
+ `Conexa.read_only { ... }` block. Any non-`GET` request raises
129
+ `Conexa::ReadOnlyError` before it leaves the process. Settling a charge moves
130
+ money and can issue an NF-e, so this is a guard worth having when you only mean
131
+ to read.
132
+
133
+ The exemption for `POST /auth` is matched on the **path**, not on the caller's
134
+ `auth:` flag — `Request.auth` is public, so keying it off the flag would have
135
+ let any write opt out of the guard. `CONEXA_READ_ONLY` is consulted at each
136
+ check rather than captured at configure time, and an unrecognised value warns
137
+ instead of silently failing open. The block form is fiber-local and does not
138
+ reach a `Thread` or `Fiber` spawned inside it — documented in the READMEs; use
139
+ `config.read_only` for concurrent work.
140
+ - **`ResponseError#api_errors`, `#api_error_codes`, `#api_error_messages`,
141
+ `#api_response`** — normalise the API's two error shapes (`{field, messages}`
142
+ and `{code, message}`). Consumers that only handled the first rendered
143
+ business-rule errors as blank strings, which is how
144
+ `CONTRACT_RECURRING_SALE_10` stayed invisible through eight attempts.
145
+ `api_error_codes` makes documented codes such as `CHARGE_11` usable as control
146
+ flow — the way to tell an already-settled charge from a real failure.
147
+ - **`Contract#set_end_date`** (with `end_contract` kept as an alias), taking the
148
+ documented `date:`, `reason_id:` and `unlink_customer:`. The endpoint both
149
+ closes a contract and amends its end date, and a future date on a closed
150
+ contract reopens it.
151
+ - **Contract-level test coverage**: `spec/contract/api_contract_spec.rb` checks
152
+ every emitted URL and action verb against the vendored collection, with an
153
+ explicit allowlist and a guard that fails when an endpoint is added without one.
154
+ `spec/support/request_capture.rb` asserts the request the gem actually emits
155
+ rather than stubbing the expected one.
156
+ - `verify_partial_doubles = true`.
157
+ - YARD documentation of `POST /contract`'s fields, including the conditional
158
+ `due_day` rule and the atomic `firstOccurrenceSettleRetroactive` flow.
159
+
160
+ ### Changed
161
+ - **Breaking**: `end_contract` sends the documented `date`; `end_date:` still
162
+ works as a deprecated alias and will be removed in 0.3.0.
163
+ - **Breaking**: writes that answer with an empty body now return normally instead
164
+ of raising. Callers treating the exception as a signal must be reviewed.
165
+ - **Breaking**: `page`/`size` no longer reach the wire.
166
+ - Minimum Ruby is now **3.1** (was 2.6, never tested below 3.1). CI covers
167
+ 3.1–3.4.
168
+ - `rest-client` and `multi_json` are version-bounded. `multi_json`'s adapter
169
+ behaviour is load-bearing — the empty-body defect exists because Oj returns
170
+ `nil` rather than raising.
171
+ - The gemspec's development dependencies move to bundler's `:test` group, and
172
+ `debug`/`byebug` to an optional `:development` group, so a native-extension
173
+ failure in `io-console` can no longer stop `bundle exec rspec`.
174
+ - **The packaged gem is 48 KB, down from 163 KB.** `spec.files` shipped the whole
175
+ repository — including `docs/postman-collection.json` (1.7 MB) — against 58 KB
176
+ of library code. It now ships `lib/` and the documentation only.
177
+ - **`Gemfile.lock` is no longer committed**, as is conventional for a gem: a
178
+ pinned lockfile defeats testing against the range the gemspec allows, and CI
179
+ had been working around it by deleting the file before `bundle install`.
180
+ - **`rake` passes again.** The default task is `spec` + `rubocop`, and RuboCop's
181
+ ~2000 pre-existing offences made it fail, which is why CI ran lint with
182
+ `continue-on-error`. A generated `.rubocop_todo.yml` grandfathers them, so new
183
+ code is genuinely linted and CI can gate on it.
184
+ - `rake spec:all` runs the suite across every supported Ruby via mise; `rake ci`
185
+ adds RuboCop. CI uses `bundler-cache: true`.
186
+ - **Releases publish from CI via Trusted Publishing.** Pushing a `v*` tag runs
187
+ the full gate and then exchanges GitHub's OIDC token for a short-lived,
188
+ push-scoped RubyGems credential — no API key is stored anywhere. Two gates run
189
+ first, both aimed at how 0.1.1 went out: the tag must match `Conexa::VERSION`
190
+ (the `v0.1.1` tag points at code carrying `0.1.0`), and the packaged gem must
191
+ contain only `lib/` and the documentation.
192
+
193
+ ### Removed
194
+ - **Breaking**: `Conexa::Client`, `Conexa::Authenticator` and
195
+ `Conexa::TokenManager`. They referenced five `Conexa` module methods that never
196
+ existed (`credentials`, `secret_key`, `access_key`, `client_id`,
197
+ `default_client_key`), so any real use raised `NoMethodError`; their specs
198
+ passed only because they stubbed those methods into being. The `jwt` dependency
199
+ goes with them — `Conexa::Auth` (the v2 `/auth` resource) is unaffected.
200
+ - **Breaking**: `Conexa::OrderCommon`. Unlike the three above, this one *ran* —
201
+ it built `/order/:id` and `/order/:id/refund` and issued a real `DELETE`. It is
202
+ removed because **API v2 documents no `/order` endpoint at all**: nothing in
203
+ the gem used it, no spec covered it, and it targets a surface that no longer
204
+ exists. Note that its documented backwards-compatible alias never worked:
205
+ `OrderCommom = self` inside the class body defines
206
+ `Conexa::OrderCommon::OrderCommom`, not `Conexa::OrderCommom`, so the pre-0.1.0
207
+ name has raised `NameError` since the rename.
208
+
209
+ ### Testing
210
+ - VCR cassettes recorded against a real tenant are anonymised, and
211
+ `spec_helper.rb` filters the API token out of new recordings. The
212
+ `claude_scripts/sanitize_cassettes/` script does the anonymisation in place,
213
+ keeping the JSON shape so the specs still exercise the same structure.
214
+
10
215
  ## [0.1.1] - 2026-03-31
11
216
 
12
217
  ### Added
@@ -133,7 +338,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
133
338
  - Charge with settle and PIX methods
134
339
  - Pagination support
135
340
 
136
- [Unreleased]: https://github.com/guilhermegazzinelli/conexa-ruby/compare/v0.1.1...HEAD
341
+ [Unreleased]: https://github.com/guilhermegazzinelli/conexa-ruby/compare/v0.2.0...HEAD
342
+ [0.2.0]: https://github.com/guilhermegazzinelli/conexa-ruby/compare/v0.1.1...v0.2.0
137
343
  [0.1.1]: https://github.com/guilhermegazzinelli/conexa-ruby/compare/v0.1.0...v0.1.1
138
344
  [0.1.0]: https://github.com/guilhermegazzinelli/conexa-ruby/compare/v0.0.9...v0.1.0
139
345
  [0.0.9]: https://github.com/guilhermegazzinelli/conexa-ruby/compare/v0.0.8...v0.0.9
data/README.md CHANGED
@@ -25,8 +25,8 @@ gem install conexa
25
25
 
26
26
  ```ruby
27
27
  Conexa.configure do |config|
28
- config.subdomain = 'YOUR_SUBDOMAIN' # your-company.conexa.app
29
- config.api_token = 'YOUR_API_TOKEN' # Application Token from Conexa
28
+ config.api_host = 'https://your-company.conexa.app' # your tenant
29
+ config.api_token = 'YOUR_API_TOKEN' # Application Token from Conexa
30
30
  end
31
31
  ```
32
32
 
@@ -35,13 +35,45 @@ end
35
35
  1. **Application Token** (recommended): Created in Conexa at **Config > Integrações > API / Token**
36
36
  2. **Username/Password**: Use the `/auth` endpoint to get a JWT token
37
37
 
38
+ ### Read-only mode
39
+
40
+ This gem talks to a billing system: settling a charge moves money and can issue
41
+ an NF-e. When you only mean to read — auditing, reporting, investigating — turn
42
+ writing off and let the gem refuse instead of trusting yourself to be careful.
43
+
44
+ ```ruby
45
+ Conexa.configure { |config| config.read_only = true }
46
+
47
+ Conexa::Charge.all(status: 'pending') # fine
48
+ Conexa::Charge.settle(789) # raises Conexa::ReadOnlyError
49
+ ```
50
+
51
+ Set `CONEXA_READ_ONLY=1` in the environment to default it on — useful in CI, or
52
+ in any shell where you would rather not discover you were pointed at production.
53
+
54
+ For a single block, without reconfiguring the client:
55
+
56
+ ```ruby
57
+ Conexa.read_only do
58
+ report = Conexa::Charge.all(limit: 100) # anything here is read-only
59
+ end
60
+ ```
61
+
62
+ The guard runs before the request leaves the process, so a blocked call never
63
+ reaches your tenant. `GET` is allowed, and so is `POST /auth` — without it
64
+ read-only mode could not obtain a token.
65
+
66
+ **The block form is fiber-local.** It does not reach a `Thread` or `Fiber`
67
+ spawned inside it, nor external iteration on an `Enumerator`. For anything
68
+ concurrent use `config.read_only` (or `CONEXA_READ_ONLY`), which is global.
69
+
38
70
  ## Quick Start
39
71
 
40
72
  ```ruby
41
73
  require 'conexa'
42
74
 
43
75
  Conexa.configure do |config|
44
- config.subdomain = 'mycompany'
76
+ config.api_host = 'https://mycompany.conexa.app'
45
77
  config.api_token = ENV['CONEXA_API_TOKEN']
46
78
  end
47
79
 
data/README_pt-BR.md CHANGED
@@ -37,7 +37,41 @@ Conexa.configure do |config|
37
37
  end
38
38
  ```
39
39
 
40
- Certifique-se de que as variáveis de ambiente `API_TOKEN` e `API_HOST` estejam configuradas com seu token e URL da API Conexa.
40
+ Certifique-se de que as variáveis de ambiente `API_TOKEN` e `API_HOST` estejam configuradas com seu token e URL da API Conexa. O `api_host` é o subdomínio do seu tenant, por exemplo `https://minhaempresa.conexa.app`.
41
+
42
+ ### Modo somente-leitura
43
+
44
+ Esta gem conversa com um sistema de cobrança: quitar uma cobrança movimenta
45
+ dinheiro e pode emitir NF-e. Quando a intenção é apenas consultar — auditoria,
46
+ relatório, investigação — desligue a escrita e deixe a gem recusar, em vez de
47
+ confiar na própria atenção.
48
+
49
+ ```ruby
50
+ Conexa.configure { |config| config.read_only = true }
51
+
52
+ Conexa::Charge.all(status: 'pending') # ok
53
+ Conexa::Charge.settle(789) # levanta Conexa::ReadOnlyError
54
+ ```
55
+
56
+ Defina `CONEXA_READ_ONLY=1` no ambiente para ligar por padrão — útil em CI, ou em
57
+ qualquer shell onde você prefira não descobrir que estava apontando para
58
+ produção.
59
+
60
+ Para um trecho específico, sem reconfigurar o cliente:
61
+
62
+ ```ruby
63
+ Conexa.read_only do
64
+ relatorio = Conexa::Charge.all(limit: 100) # tudo aqui dentro é leitura
65
+ end
66
+ ```
67
+
68
+ A verificação acontece antes de a requisição sair do processo, então uma chamada
69
+ bloqueada nunca chega ao seu tenant. `GET` é permitido, e o `POST /auth` também —
70
+ sem ele o modo leitura não conseguiria obter token.
71
+
72
+ **O bloco é fiber-local.** Ele não alcança uma `Thread` ou `Fiber` criada lá
73
+ dentro, nem iteração externa de `Enumerator`. Para qualquer coisa concorrente use
74
+ `config.read_only` (ou `CONEXA_READ_ONLY`), que é global.
41
75
 
42
76
  ## Uso
43
77
 
data/REFERENCE.md CHANGED
@@ -42,8 +42,9 @@ gem install conexa
42
42
 
43
43
  ```ruby
44
44
  Conexa.configure do |config|
45
- config.api_host = 'https://api.conexa.com.br' # or sandbox URL
45
+ config.api_host = 'https://mycompany.conexa.app' # your tenant subdomain
46
46
  config.api_token = 'your_api_token_here'
47
+ config.read_only = false # see below
47
48
  end
48
49
 
49
50
  # Rails: use generator
@@ -51,6 +52,33 @@ rails generate conexa:install
51
52
  # Creates config/initializers/conexa.rb
52
53
  ```
53
54
 
55
+ | Setting | Default | Purpose |
56
+ |---------|---------|---------|
57
+ | `api_host` | `''` | Tenant base URL. `/index.php/api/v2` is appended automatically. |
58
+ | `api_token` | `''` | Application Token — Config > Integrações > API / Token. |
59
+ | `read_only` | `ENV['CONEXA_READ_ONLY']` | Refuse every non-GET request. |
60
+
61
+ ### Read-only mode
62
+
63
+ Any request other than `GET` raises `Conexa::ReadOnlyError` before it leaves the
64
+ process. `POST /auth` stays allowed — matched on the path, not on a caller flag —
65
+ since read-only mode would otherwise be unable to obtain a token.
66
+
67
+ `Conexa.read_only { }` is fiber-local: it does not reach a `Thread` or `Fiber`
68
+ spawned inside the block. Use `config.read_only` for concurrent work.
69
+
70
+ `CONEXA_READ_ONLY` is read at each check, so it applies even when set after
71
+ `Conexa.configure`. Recognised: `1/true/yes/on` and `0/false/no/off`; anything
72
+ else warns and is treated as off.
73
+
74
+ ```ruby
75
+ Conexa.configure { |c| c.read_only = true } # or CONEXA_READ_ONLY=1
76
+
77
+ Conexa.read_only do # block-scoped, thread-local
78
+ Conexa::Charge.all(status: 'pending')
79
+ end
80
+ ```
81
+
54
82
  ## Convention: snake_case
55
83
 
56
84
  This gem follows Ruby/Rails conventions. Use **snake_case** for all parameters - the gem automatically converts to camelCase for the API.
@@ -2,11 +2,57 @@
2
2
 
3
3
  module Conexa
4
4
  class Configuration
5
- attr_accessor :api_token, :api_host
5
+ # @return [String] the Application Token, created in Conexa under
6
+ # Config > Integrações > API / Token
7
+ attr_accessor :api_token
8
+
9
+ # @return [String] the tenant base URL, e.g. "https://mycompany.conexa.app"
10
+ attr_accessor :api_host
11
+
12
+ # Set to force read-only mode on or off, overriding the environment.
13
+ # @return [Boolean]
14
+ attr_writer :read_only
15
+
16
+ # Values that turn read-only mode on via CONEXA_READ_ONLY.
17
+ TRUTHY = %w[1 true yes on].freeze
18
+
19
+ # Values that explicitly turn it off. Anything else is a typo worth warning
20
+ # about — silently failing open on `CONEXA_READ_ONLY=treu` would leave an
21
+ # operator believing writes were blocked when they are not.
22
+ FALSEY = %w[0 false no off].freeze
6
23
 
7
24
  def initialize
8
25
  @api_token = ''
9
26
  @api_host = ''
27
+ @read_only = nil
28
+ end
29
+
30
+ # When true, any request other than GET raises {Conexa::ReadOnlyError} before
31
+ # reaching the network.
32
+ #
33
+ # Read lazily so that CONEXA_READ_ONLY still applies when it is set after
34
+ # {Conexa.configure} has run — it used to be captured once at initialization,
35
+ # which made setting it later a silent no-op. An explicit assignment always
36
+ # wins over the environment.
37
+ #
38
+ # @return [Boolean]
39
+ def read_only
40
+ return @read_only unless @read_only.nil?
41
+
42
+ env_read_only
43
+ end
44
+
45
+ private
46
+
47
+ def env_read_only
48
+ value = ENV["CONEXA_READ_ONLY"].to_s.strip.downcase
49
+ return false if value.empty?
50
+ return true if TRUTHY.include?(value)
51
+ return false if FALSEY.include?(value)
52
+
53
+ warn "Conexa: CONEXA_READ_ONLY=#{value.inspect} não é reconhecido e foi ignorado " \
54
+ "(modo somente-leitura DESLIGADO). Use um de: #{(TRUTHY + FALSEY).join(", ")}."
55
+ false
10
56
  end
11
57
  end
12
- end
58
+ end
data/lib/conexa/errors.rb CHANGED
@@ -16,40 +16,112 @@ module Conexa
16
16
  class RequestError < ConexaError
17
17
  end
18
18
 
19
+ # Raised instead of performing a mutating request while Conexa.read_only?.
20
+ # The request never reaches the network.
21
+ class ReadOnlyError < ConexaError
22
+ end
23
+
19
24
  class ResponseError < ConexaError
20
25
  attr_reader :request_params, :error
21
26
 
22
- def initialize(request_params, error, message=nil)
27
+ # The decoded error body, when the API returned one.
28
+ # @return [Hash]
29
+ attr_reader :api_response
30
+
31
+ def initialize(request_params, error, message=nil, api_response=nil)
23
32
  @request_params, @error = request_params, error
24
- msg = @error.message
33
+ @api_response = api_response.is_a?(Hash) ? api_response : {}
34
+ msg = describe_error(error)
25
35
  msg += " => " + message if message
26
36
  super msg
27
37
  end
38
+
39
+ # The API's errors, normalised across its two shapes.
40
+ #
41
+ # Field validation answers `{"field": …, "messages": [...]}`; business rules
42
+ # answer `{"code": …, "message": …}`. Consumers that only handled the first
43
+ # rendered business-rule errors as blank strings — which is how
44
+ # CONTRACT_RECURRING_SALE_10 stayed invisible through eight attempts.
45
+ #
46
+ # @return [Array<Hash{Symbol=>String,nil}>] entries of {field:, code:, message:}
47
+ def api_errors
48
+ Array(api_response["errors"]).filter_map do |entry|
49
+ next unless entry.is_a?(Hash)
50
+
51
+ { field: entry["field"],
52
+ code: entry["code"],
53
+ message: entry["message"] || Array(entry["messages"]).join("; ") }
54
+ end
55
+ end
56
+
57
+ # Documented business-rule codes, e.g. "CHARGE_11" or
58
+ # "CONTRACT_RECURRING_SALE_10". These are what a caller branches on — for
59
+ # instance to tell an already-settled charge from a real settlement failure.
60
+ #
61
+ # @return [Array<String>]
62
+ def api_error_codes
63
+ api_errors.filter_map { |entry| entry[:code] }
64
+ end
65
+
66
+ # One readable line per error, whichever shape it arrived in.
67
+ # @return [Array<String>]
68
+ def api_error_messages
69
+ api_errors.map do |entry|
70
+ label = entry[:field] || entry[:code]
71
+ label ? "#{label}: #{entry[:message]}" : entry[:message]
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ # `error` is usually a RestClient::Exception, but the malformed-body path
78
+ # hands us a RestClient::Response, which has no #message — that used to raise
79
+ # NoMethodError from inside the error constructor itself.
80
+ def describe_error(error)
81
+ return error.message if error.respond_to?(:message) && error.message
82
+ return "HTTP #{error.code}: #{error.body.to_s[0, 200]}" if error.respond_to?(:code)
83
+
84
+ error.to_s
85
+ end
28
86
  end
29
87
 
30
88
  class NotFound < ResponseError
31
89
  attr_reader :response
32
90
  def initialize(response, request_params, error)
33
91
  @response = response
34
- super request_params, error, response&.dig('message')
92
+ super request_params, error, response&.dig('message'), response
35
93
  end
36
94
  end
37
95
 
96
+ # Raised for an error body with no `message` key.
97
+ #
98
+ # Every error response the published collection documents carries a `message`,
99
+ # so in practice this is reached only by an undocumented or malformed body. It
100
+ # used to render as the bare string "Conexa::ValidationError" and `#to_h` raised
101
+ # NoMethodError; both now degrade to something a caller can act on.
38
102
  class ValidationError < ConexaError
39
103
  attr_reader :response, :errors
40
104
 
41
105
  def initialize(response)
42
106
  @response = response
43
- @errors = response['message']&.map do |msg|
44
- params = msg.values_at('message', 'parameter_name', 'type', 'url')
45
- ParamError.new(*params)
107
+ @errors = Array(response.is_a?(Hash) ? response['message'] : nil).filter_map do |msg|
108
+ next unless msg.is_a?(Hash)
109
+
110
+ ParamError.new(*msg.values_at('message', 'parameter_name', 'type', 'url'))
46
111
  end
47
- super @errors&.map(&:message)&.join(', ')
112
+
113
+ super(@errors.any? ? @errors.map(&:message).join(', ') : describe(response))
48
114
  end
49
115
 
50
116
  def to_h
51
117
  @errors.map(&:to_h)
52
118
  end
119
+
120
+ private
121
+
122
+ def describe(response)
123
+ "The API returned an error with no message: #{response.inspect[0, 200]}"
124
+ end
53
125
  end
54
126
 
55
127
  class MissingCredentialsError < ConexaError