econt 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTING.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +171 -0
- data/SECURITY.md +33 -0
- data/docs/API.md +571 -0
- data/docs/ENDPOINTS.md +42 -0
- data/docs/PUBLIC_API.md +81 -0
- data/docs/RELEASING.md +35 -0
- data/docs/RESPONSE_TESTS.md +46 -0
- data/docs/VCR.md +144 -0
- data/lib/econt/camel_case_middleware.rb +88 -0
- data/lib/econt/client.rb +99 -0
- data/lib/econt/collection.rb +59 -0
- data/lib/econt/error.rb +42 -0
- data/lib/econt/meta_resource.rb +6 -0
- data/lib/econt/model.rb +95 -0
- data/lib/econt/models/error.rb +53 -0
- data/lib/econt/models/hosted_file.rb +13 -0
- data/lib/econt/models/nomenclatures/address.rb +35 -0
- data/lib/econt/models/nomenclatures/address_service_info.rb +16 -0
- data/lib/econt/models/nomenclatures/address_service_times_response.rb +19 -0
- data/lib/econt/models/nomenclatures/address_validation_response.rb +19 -0
- data/lib/econt/models/nomenclatures/city.rb +32 -0
- data/lib/econt/models/nomenclatures/country.rb +16 -0
- data/lib/econt/models/nomenclatures/geo_location.rb +13 -0
- data/lib/econt/models/nomenclatures/office.rb +32 -0
- data/lib/econt/models/nomenclatures/quarter.rb +14 -0
- data/lib/econt/models/nomenclatures/serving_office_element.rb +12 -0
- data/lib/econt/models/nomenclatures/street.rb +14 -0
- data/lib/econt/models/nomenclatures/working_date_time.rb +16 -0
- data/lib/econt/models/nomenclatures/working_time.rb +12 -0
- data/lib/econt/models/payment_report/payment_report_row.rb +17 -0
- data/lib/econt/models/profile/cd_pay_options.rb +24 -0
- data/lib/econt/models/profile/client_profile.rb +29 -0
- data/lib/econt/models/profile/profiles_response_element.rb +16 -0
- data/lib/econt/models/shipments/cargo_vehicle_options.rb +16 -0
- data/lib/econt/models/shipments/check_possible_shipment_editions_result_element.rb +14 -0
- data/lib/econt/models/shipments/create_label_result_element.rb +19 -0
- data/lib/econt/models/shipments/customs_list_element.rb +14 -0
- data/lib/econt/models/shipments/delete_labels_result_element.rb +14 -0
- data/lib/econt/models/shipments/get_my_awb_response.rb +15 -0
- data/lib/econt/models/shipments/get_my_awb_result_element.rb +23 -0
- data/lib/econt/models/shipments/instruction.rb +20 -0
- data/lib/econt/models/shipments/next_shipment_element.rb +13 -0
- data/lib/econt/models/shipments/pack_element.rb +14 -0
- data/lib/econt/models/shipments/packing_list_element.rb +17 -0
- data/lib/econt/models/shipments/payment_addition_price.rb +14 -0
- data/lib/econt/models/shipments/payment_instruction.rb +11 -0
- data/lib/econt/models/shipments/previous_shipment.rb +14 -0
- data/lib/econt/models/shipments/request_courier_response.rb +13 -0
- data/lib/econt/models/shipments/request_courier_status.rb +14 -0
- data/lib/econt/models/shipments/request_courier_status_result_element.rb +14 -0
- data/lib/econt/models/shipments/return_instruction_params.rb +48 -0
- data/lib/econt/models/shipments/shipment_edition_response_element.rb +16 -0
- data/lib/econt/models/shipments/shipment_status.rb +64 -0
- data/lib/econt/models/shipments/shipment_status_result_element.rb +14 -0
- data/lib/econt/models/shipments/shipment_status_service.rb +16 -0
- data/lib/econt/models/shipments/shipment_tracking_event.rb +21 -0
- data/lib/econt/models/shipments/shipping_label.rb +59 -0
- data/lib/econt/models/shipments/shipping_label_services.rb +37 -0
- data/lib/econt/models/shipments/status_response.rb +11 -0
- data/lib/econt/models/shipments/update_label_response.rb +11 -0
- data/lib/econt/models/shipments/update_labels_result_element.rb +14 -0
- data/lib/econt/models/three_way_logistics/three_way_logistics.rb +50 -0
- data/lib/econt/models/three_way_logistics/three_way_logistics_response.rb +11 -0
- data/lib/econt/resource_attributes.rb +69 -0
- data/lib/econt/service_resource.rb +105 -0
- data/lib/econt/services/nomenclatures/address.rb +87 -0
- data/lib/econt/services/nomenclatures/nomenclatures.rb +122 -0
- data/lib/econt/services/nomenclatures.rb +11 -0
- data/lib/econt/services/payment_report/payment_report.rb +35 -0
- data/lib/econt/services/payment_report.rb +11 -0
- data/lib/econt/services/profile/profile.rb +45 -0
- data/lib/econt/services/profile.rb +11 -0
- data/lib/econt/services/shipments/label.rb +174 -0
- data/lib/econt/services/shipments/shipment.rb +109 -0
- data/lib/econt/services/shipments.rb +11 -0
- data/lib/econt/services/three_way_logistics/three_way_logistics.rb +30 -0
- data/lib/econt/services/three_way_logistics.rb +11 -0
- data/lib/econt/util.rb +63 -0
- data/lib/econt/version.rb +5 -0
- data/lib/econt.rb +118 -0
- data/sig/econt.rbs +465 -0
- metadata +166 -0
data/docs/RELEASING.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Ordinary pushes and pull requests only test and package the gem. They never
|
|
4
|
+
publish it.
|
|
5
|
+
|
|
6
|
+
The manual `Release gem` workflow is intentionally gated by all of the
|
|
7
|
+
following:
|
|
8
|
+
|
|
9
|
+
1. Select an existing `vVERSION` tag when dispatching the workflow.
|
|
10
|
+
2. The tag must exactly match `Econt::VERSION`.
|
|
11
|
+
3. Type `publish` into the confirmation input.
|
|
12
|
+
4. Set the repository variable `RUBYGEMS_TRUSTED_PUBLISHING_ENABLED` to `true`.
|
|
13
|
+
5. Configure the `release` GitHub environment and a RubyGems pending trusted
|
|
14
|
+
publisher for this repository and workflow.
|
|
15
|
+
|
|
16
|
+
Until those repository-side settings exist, the publish job is skipped rather
|
|
17
|
+
than failing normal CI. Trusted Publishing uses GitHub OIDC; no long-lived
|
|
18
|
+
RubyGems API key belongs in repository secrets.
|
|
19
|
+
|
|
20
|
+
Before creating a tag, run:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle exec rake
|
|
24
|
+
bundle exec rbs -r date -I sig validate
|
|
25
|
+
bin/package_smoke_test
|
|
26
|
+
bin/check_release_tag v0.1.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Then inspect the built gem contents and confirm README examples against the
|
|
30
|
+
demo environment using sanitized contract recordings. Publishing is irreversible,
|
|
31
|
+
so do not reuse a released version number.
|
|
32
|
+
|
|
33
|
+
The package smoke test uses dependencies installed by `bundle install`, including
|
|
34
|
+
project-local Bundler caches. Its final subprocess disables Bundler and loads the
|
|
35
|
+
built gem from a temporary installation outside the source checkout.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Offline response property contracts
|
|
2
|
+
|
|
3
|
+
The endpoint response specs exercise all 25 documented API endpoints through
|
|
4
|
+
the real client transport, request middleware and service result mapping, with
|
|
5
|
+
WebMock returning synthetic JSON. No carrier requests are made.
|
|
6
|
+
|
|
7
|
+
`spec/fixtures/examples/response_schemas.json` is a 2026-10-11 snapshot of the
|
|
8
|
+
schema and endpoint response definitions from
|
|
9
|
+
[the official OpenAPI document](https://ee.econt.com/services/openapi.yaml).
|
|
10
|
+
Descriptions are omitted; property names, types, formats and references are
|
|
11
|
+
retained. These are schema-derived examples, not recorded live responses.
|
|
12
|
+
Each endpoint has its own `responses/<endpoint>/success.json` and
|
|
13
|
+
`responses/<endpoint>/null_scalars.json` under `spec/fixtures/examples/`.
|
|
14
|
+
The checked-in payloads populate every optional documented field, nested object
|
|
15
|
+
and array with synthetic values independently of the gem's field declarations.
|
|
16
|
+
The error payload is shared in `responses/error.json`. Different scalar
|
|
17
|
+
values help detect accidental assignments between fields.
|
|
18
|
+
Every endpoint also receives a synthetic documented HTTP 400 error, with a
|
|
19
|
+
nested inner error and both populated and empty primitive arrays.
|
|
20
|
+
|
|
21
|
+
The recursive assertion in `spec/support/response_properties.rb` independently
|
|
22
|
+
derives snake_case reader names and compares each raw camelCase property with
|
|
23
|
+
the value obtained through `public_send`. It checks array sizes and each nested
|
|
24
|
+
element, including false, zero and nil. ISO dates and date-times are compared
|
|
25
|
+
against Date and DateTime values based on the documentation's format, not the
|
|
26
|
+
model's declared field types. Unsupported temporal representations, including
|
|
27
|
+
numeric epochs without an authoritative unit, retain their raw value.
|
|
28
|
+
|
|
29
|
+
Collection envelopes and single-resource wrappers are explicitly unwrapped
|
|
30
|
+
according to the public service return contract. Three-way logistics documents
|
|
31
|
+
an empty object, so its contract verifies an empty response model rather than
|
|
32
|
+
inventing properties. `set_itu_code` deliberately returns a Faraday response;
|
|
33
|
+
its empty raw body is compared directly. Raw hashes never count as evidence
|
|
34
|
+
that nested dot access works. Unknown response fields continue to be ignored,
|
|
35
|
+
as documented in PUBLIC_API.md, and have a separate compatibility regression.
|
|
36
|
+
|
|
37
|
+
`get_regions` is a helper derived from `get_cities`, not an additional API
|
|
38
|
+
endpoint. A manifest assertion requires every directly exposed service endpoint
|
|
39
|
+
to appear in the examples, so adding an endpoint requires a response contract.
|
|
40
|
+
The existing VCR specs remain complementary evidence of actual API behavior.
|
|
41
|
+
|
|
42
|
+
Run these offline contracts with:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
bundle exec rspec spec/endpoint_response_properties_spec.rb spec/response_properties_spec.rb
|
|
46
|
+
```
|
data/docs/VCR.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# VCR contract testing
|
|
2
|
+
|
|
3
|
+
The opt-in contract suite records real demo responses. Normal tests never
|
|
4
|
+
contact Econt. CI replays every reviewed cassette through the public service
|
|
5
|
+
methods, without loading `.env` files or needing credentials:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
ECONT_VCR_REPLAY=1 bundle exec rspec spec/recorded_contract_spec.rb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Replay uses VCR `record: :none` and a request firewall: missing or unmatched
|
|
12
|
+
interactions fail instead of contacting Econt, even if recording options are
|
|
13
|
+
overridden. WebMock also disables network connections. The replay spec is
|
|
14
|
+
separate from the opt-in recording suite, so account-specific recording settings
|
|
15
|
+
cannot silently skip CI coverage.
|
|
16
|
+
|
|
17
|
+
Each endpoint has an independent cassette so a failed recording can be removed
|
|
18
|
+
and retried without discarding unrelated evidence.
|
|
19
|
+
|
|
20
|
+
The historical client-profile recording had whole address objects replaced with
|
|
21
|
+
strings by an earlier scrubber. Those slots are now empty objects, preserving
|
|
22
|
+
their counts and valid model shape; their nested address properties are not
|
|
23
|
+
live-verified by this cassette. New recordings retain nested structure and redact
|
|
24
|
+
only sensitive scalar values. Replay compares every present documented field,
|
|
25
|
+
not optional absent fields or undocumented fields intentionally ignored by models.
|
|
26
|
+
|
|
27
|
+
## Read-only coverage
|
|
28
|
+
|
|
29
|
+
The suite covers nomenclatures, address validation and service lookup, client
|
|
30
|
+
profiles, label calculation and validation modes, payment reports, AWB history,
|
|
31
|
+
and optional shipment/courier-status reads. Account-specific identifiers are
|
|
32
|
+
supplied through `.env` and their recordings are structurally sanitized.
|
|
33
|
+
|
|
34
|
+
Offline request-shape specs exercise every explicit public keyword at least
|
|
35
|
+
once. Large nested label and logistics hashes use generic serialization, so
|
|
36
|
+
representative documented fields and model casting are tested without trying
|
|
37
|
+
every possible field combination. Live contracts use the smallest meaningful
|
|
38
|
+
payload for each server behavior; they are not a parameter-permutation suite.
|
|
39
|
+
|
|
40
|
+
## Coverage policy
|
|
41
|
+
|
|
42
|
+
| Endpoint kind | Contract approach |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| Nomenclatures and address queries | Recorded directly, one cassette per endpoint |
|
|
45
|
+
| Profiles, reports, AWB, shipment, and courier-status reads | Recorded with dedicated demo fixtures and structural response redaction |
|
|
46
|
+
| Single label `validate`/`calculate` and bulk label `validate` modes | Recorded because these modes do not create shipments |
|
|
47
|
+
| Label creation/update/deletion/grouping, courier requests, ITU, CDA, and three-way logistics | Excluded from the read-only suite; use the lifecycle pattern below |
|
|
48
|
+
|
|
49
|
+
## Mutating endpoints
|
|
50
|
+
|
|
51
|
+
VCR does not make a mutating operation safe: the first recording is still a
|
|
52
|
+
real request. Projects that contract-test creation or updates normally use a
|
|
53
|
+
dedicated demo account and record a complete scenario in one cassette:
|
|
54
|
+
|
|
55
|
+
1. create a uniquely identifiable fixture;
|
|
56
|
+
2. exercise the update or action being tested;
|
|
57
|
+
3. query the result;
|
|
58
|
+
4. delete or cancel the fixture in an `ensure` cleanup block; and
|
|
59
|
+
5. confirm the cleanup response before accepting the cassette.
|
|
60
|
+
|
|
61
|
+
Recording is a manual maintenance operation, never a normal CI action. CI only
|
|
62
|
+
replays reviewed cassettes with network access disabled. Identifiers,
|
|
63
|
+
credentials, names, phones, addresses, payment data, cookies, and tokens must
|
|
64
|
+
be scrubbed while preserving the JSON scalar types expected by model casting.
|
|
65
|
+
If an API offers no reliable cleanup, prefer a provider sandbox reset, a
|
|
66
|
+
pre-provisioned disposable fixture, or an offline request-shape test instead of
|
|
67
|
+
recording the mutation.
|
|
68
|
+
|
|
69
|
+
Never re-record all cassettes automatically. Delete only the cassette being
|
|
70
|
+
refreshed, inspect the diff, verify cleanup through a status request, and scan the
|
|
71
|
+
result for secrets and personal data before committing it.
|
|
72
|
+
|
|
73
|
+
## Reviewed mutation example
|
|
74
|
+
|
|
75
|
+
`spec/live/label_mutation_contract_spec.rb` implements the lifecycle above for
|
|
76
|
+
label creation. It creates a synthetic office-delivery label, deletes it in an
|
|
77
|
+
`ensure` block, and then calls `getShipmentStatuses` to verify that Econt
|
|
78
|
+
recorded a `canceled` tracking event. All three interactions are stored in
|
|
79
|
+
`econt_demo/label_create_and_delete.yml`; the generated description and
|
|
80
|
+
shipment number are filtered.
|
|
81
|
+
|
|
82
|
+
The example requires an additional, deliberate opt-in:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
ECONT_LIVE_TESTS=1 ECONT_MUTATING_LIVE_TESTS=1 \
|
|
86
|
+
bundle exec rspec spec/live/label_mutation_contract_spec.rb
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Econt retains the cancelled label in status history. The example therefore
|
|
90
|
+
fails unless the final response contains a tracking event whose destination
|
|
91
|
+
type is `canceled`.
|
|
92
|
+
|
|
93
|
+
## Mutation cleanup inventory
|
|
94
|
+
|
|
95
|
+
Cleanup cancels disposable labels; it does not erase their historical records.
|
|
96
|
+
The inventory is based on the documented
|
|
97
|
+
[LabelService and ShipmentService](https://ee.econt.com/services/Shipments/),
|
|
98
|
+
[ProfileService](https://ee.econt.com/services/Profile/), and
|
|
99
|
+
[ThreeWayLogisticsService](https://ee.econt.com/services/ThreeWayLogistics/).
|
|
100
|
+
|
|
101
|
+
### Clear cleanup paths for disposable, uncollected labels
|
|
102
|
+
|
|
103
|
+
| Endpoint | Scenario and cleanup | Recording status |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| Create label and delete labels | Create without courier-request times, delete, query the `canceled` tracking event | Existing demo recording verifies cleanup |
|
|
106
|
+
|
|
107
|
+
The verified example lives in `spec/live/label_mutation_contract_spec.rb`.
|
|
108
|
+
Cleanup runs in `ensure`; deletion errors or a missing cancellation event fail
|
|
109
|
+
the example. If creation times out before returning an identifier, account
|
|
110
|
+
reconciliation is required before retrying.
|
|
111
|
+
|
|
112
|
+
Update-label and batch-create mutation specs were removed after the demo API
|
|
113
|
+
acknowledged the same `deleteLabels` operation but continued to report `prepared`
|
|
114
|
+
through six subsequent status reads. Their cleanup call used the same endpoint
|
|
115
|
+
and shipment-number array payload as the verified example. The differing status
|
|
116
|
+
responses are a demo API inconsistency; the recordings do not establish why it
|
|
117
|
+
occurs or independently confirm cancellation for those scenarios.
|
|
118
|
+
|
|
119
|
+
### Suggested cleanup with uncertain effectiveness
|
|
120
|
+
|
|
121
|
+
| Endpoint/action | Suggested path | What remains unverified |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| Update label | Update only a fresh disposable label, delete all returned numbers, query cancellation | Deletion was acknowledged, but six status reads remained `prepared`; the live spec was removed |
|
|
124
|
+
| Create labels synchronously | Collect all successful batch identifiers, delete each, query cancellation | Deletion was acknowledged for both labels, but six status reads remained `prepared`; the live spec was removed |
|
|
125
|
+
| Bulk update labels | Create disposable labels, make non-financial edits, delete every original and replacement number | The nested success model is typed, but batch edition side effects and replacement-number behavior still need live verification before automating cleanup |
|
|
126
|
+
| Grouping | Group only fresh disposable labels, call `groupingCancelation`, then delete the original labels and any surviving group label | There is an explicit undo endpoint, but status values, restoration of members, and group-label deletion behavior are not clearly documented |
|
|
127
|
+
| Grouping cancellation | Run only as the undo of a disposable group | No guarantee that cancellation erases the group or restores every member exactly |
|
|
128
|
+
| Set ITU code | Apply only to a fresh disposable label, then delete that label | Deletion is not documented to reverse code registration; no documented code-unset operation |
|
|
129
|
+
| Courier request, including times on label creation/update | Delete attached disposable labels and arrange cancellation of the courier request with the carrier | Deleting labels is not documented to cancel the request or courier visit; no courier-request cancellation endpoint is exposed |
|
|
130
|
+
| `updateLabel` with `destroy: true` | Carrier-confirmed procedure using disposable fixtures | The flag has no sufficient documented semantics to treat it as a reliable cleanup or rollback operation |
|
|
131
|
+
|
|
132
|
+
These are proposals without live mutation specs. Obtain a confirmed cleanup
|
|
133
|
+
contract before recording them; querying a status alone is not undo.
|
|
134
|
+
|
|
135
|
+
### No obvious API cleanup path
|
|
136
|
+
|
|
137
|
+
| Endpoint/action | Missing inverse |
|
|
138
|
+
| --- | --- |
|
|
139
|
+
| Create COD agreement (`createCDAgreement`) | No agreement revoke/delete endpoint in the published service |
|
|
140
|
+
| Three-way logistics | No documented undo for the complete logistics operation; deleting one generated label would not establish rollback of all side effects |
|
|
141
|
+
| Label payment blocking, payment instructions, or financial editions | No general payment-release/refund/edition-rollback endpoint; deleting a label does not establish financial rollback |
|
|
142
|
+
|
|
143
|
+
These require carrier-managed reset or explicitly disposable pre-provisioned
|
|
144
|
+
fixtures. They remain covered offline rather than by automatic live mutations.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
|
|
5
|
+
module Econt
|
|
6
|
+
class CamelCaseMiddleware < Faraday::Middleware
|
|
7
|
+
DEFAULT_ACRONYMS = {
|
|
8
|
+
'aps' => 'APS',
|
|
9
|
+
'awb' => 'AWB',
|
|
10
|
+
'bic' => 'BIC',
|
|
11
|
+
'egn' => 'EGN',
|
|
12
|
+
'eu' => 'EU',
|
|
13
|
+
'id' => 'ID',
|
|
14
|
+
'iban' => 'IBAN',
|
|
15
|
+
'itu' => 'ITU',
|
|
16
|
+
'lc' => 'LC',
|
|
17
|
+
'mps' => 'MPS',
|
|
18
|
+
'url' => 'URL'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def initialize(app, acronyms: {}, replace_defaults: false)
|
|
22
|
+
super(app)
|
|
23
|
+
@acronyms =
|
|
24
|
+
if acronyms.nil?
|
|
25
|
+
{}
|
|
26
|
+
elsif replace_defaults
|
|
27
|
+
acronyms.transform_keys(&:to_s)
|
|
28
|
+
else
|
|
29
|
+
DEFAULT_ACRONYMS.merge(acronyms.transform_keys(&:to_s))
|
|
30
|
+
end
|
|
31
|
+
# value (UPPER) -> lowercase key
|
|
32
|
+
@acronym_reverse = @acronyms.each_with_object({}) { |(k, v), h| h[v] = k }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call(env)
|
|
36
|
+
env.body = deep_transform_keys(env.body) { |key| to_camel_case(key) } if transformable?(env.body)
|
|
37
|
+
@app.call(env)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def on_complete(env)
|
|
41
|
+
return unless transformable?(env.body)
|
|
42
|
+
|
|
43
|
+
env.body = deep_transform_keys(env.body) { |key| to_snake_case(key) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# snake_case -> camelCase (request)
|
|
49
|
+
def to_camel_case(key)
|
|
50
|
+
parts = key.to_s.split('_')
|
|
51
|
+
return key.to_s if parts.empty?
|
|
52
|
+
|
|
53
|
+
first = parts.shift
|
|
54
|
+
first + parts.map { |p| @acronyms.fetch(p, p.capitalize) }.join
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# camelCase/PascalCase/acronyms -> snake_case (response) with acronym support
|
|
58
|
+
def to_snake_case(key)
|
|
59
|
+
str = key.to_s
|
|
60
|
+
# Protect acronyms (longest first)
|
|
61
|
+
@acronym_reverse.keys.sort_by { |x| -x.length }.each do |acro|
|
|
62
|
+
str = str.gsub(/#{acro}(?=[A-Z]|$)/) { "_#{@acronym_reverse[acro]}_" }
|
|
63
|
+
end
|
|
64
|
+
str = str
|
|
65
|
+
.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
66
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
67
|
+
.downcase
|
|
68
|
+
str.gsub(/__+/, '_').sub(/^_/, '').sub(/_$/, '')
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def deep_transform_keys(obj, &)
|
|
72
|
+
case obj
|
|
73
|
+
when Econt::Model
|
|
74
|
+
deep_transform_keys(obj.to_h, &)
|
|
75
|
+
when Array
|
|
76
|
+
obj.map { |item| deep_transform_keys(item, &) }
|
|
77
|
+
when Hash
|
|
78
|
+
obj.each_with_object({}) { |(k, v), h| h[yield(k)] = deep_transform_keys(v, &) }
|
|
79
|
+
else
|
|
80
|
+
obj
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def transformable?(object)
|
|
85
|
+
object.is_a?(Hash) || object.is_a?(Array)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/econt/client.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'base64'
|
|
6
|
+
require 'logger'
|
|
7
|
+
|
|
8
|
+
module Econt
|
|
9
|
+
class Client
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :instance
|
|
12
|
+
|
|
13
|
+
def configure
|
|
14
|
+
@instance ||= new
|
|
15
|
+
yield(@instance) if block_given?
|
|
16
|
+
@instance.setup_connection!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def connection
|
|
20
|
+
instance&.connection || raise('Econt::Client is not configured. Call Econt::Client.configure first.')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_accessor :username, :password, :base_url, :adapter,
|
|
25
|
+
:acronyms, :replace_acronym_defaults,
|
|
26
|
+
:debug, :debug_bodies, :debug_logger,
|
|
27
|
+
:open_timeout, :read_timeout, :write_timeout
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@base_url = 'http://demo.econt.com/ee/services/' # Default to demo
|
|
31
|
+
@adapter = Faraday.default_adapter
|
|
32
|
+
@acronyms = {} # nil to disable, {} to keep defaults
|
|
33
|
+
@replace_acronym_defaults = false
|
|
34
|
+
@debug = false
|
|
35
|
+
@debug_bodies = false
|
|
36
|
+
@debug_logger = nil
|
|
37
|
+
@open_timeout = 30
|
|
38
|
+
@read_timeout = 30
|
|
39
|
+
@write_timeout = 30
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add_acronyms(hash)
|
|
43
|
+
@acronyms = (@acronyms || {}).merge(hash.transform_keys(&:to_s))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def setup_connection!
|
|
47
|
+
validate_credentials!
|
|
48
|
+
|
|
49
|
+
@connection = Faraday.new(base_url) do |conn|
|
|
50
|
+
configure_timeouts(conn)
|
|
51
|
+
conn.use Econt::CamelCaseMiddleware,
|
|
52
|
+
acronyms: @acronyms,
|
|
53
|
+
replace_defaults: @replace_acronym_defaults
|
|
54
|
+
conn.request :json
|
|
55
|
+
conn.response :json, content_type: 'application/json'
|
|
56
|
+
configure_debug_logging(conn) if debug
|
|
57
|
+
conn.adapter adapter
|
|
58
|
+
configure_headers(conn)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def connection
|
|
63
|
+
@connection || raise('Connection is not initialized. Call Econt::Client.configure first.')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def configure_debug_logging(connection)
|
|
69
|
+
connection.response :logger,
|
|
70
|
+
debug_logger || Logger.new($stdout),
|
|
71
|
+
headers: true,
|
|
72
|
+
bodies: debug_bodies do |formatter|
|
|
73
|
+
formatter.filter(encoded_credentials, '<ECONT_BASIC_TOKEN>')
|
|
74
|
+
formatter.filter(username.to_s, '<ECONT_USERNAME>')
|
|
75
|
+
formatter.filter(password.to_s, '<ECONT_PASSWORD>')
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def configure_timeouts(connection)
|
|
80
|
+
connection.options.open_timeout = open_timeout
|
|
81
|
+
connection.options.timeout = read_timeout
|
|
82
|
+
connection.options.write_timeout = write_timeout if connection.options.respond_to?(:write_timeout=)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def configure_headers(connection)
|
|
86
|
+
connection.headers['Authorization'] = "Basic #{encoded_credentials}"
|
|
87
|
+
connection.headers['User-Agent'] = "EcontRubyClient/#{Econt::VERSION}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_credentials!
|
|
91
|
+
creds_missing = [username, password].any? { |credential| credential.to_s.empty? }
|
|
92
|
+
raise 'Username and password must be set' if creds_missing
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def encoded_credentials
|
|
96
|
+
Base64.strict_encode64("#{username}:#{password}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Econt
|
|
4
|
+
class Collection < MetaResource
|
|
5
|
+
include Enumerable
|
|
6
|
+
|
|
7
|
+
def initialize(source, model:, key:)
|
|
8
|
+
@model = model
|
|
9
|
+
@key = key
|
|
10
|
+
@error = Econt::Models::Error.build_from(source)
|
|
11
|
+
@items = build_items(source)
|
|
12
|
+
super()
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :error
|
|
16
|
+
|
|
17
|
+
def error?
|
|
18
|
+
!error.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def each(&)
|
|
22
|
+
@items.each(&)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_a
|
|
26
|
+
@items
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def size
|
|
30
|
+
@items.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def empty?
|
|
34
|
+
@items.empty?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
alias length size
|
|
38
|
+
alias count size
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_items(source)
|
|
43
|
+
extract_raw_items(source).map do |item|
|
|
44
|
+
item.is_a?(@model) ? item : @model.new(item)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_raw_items(source)
|
|
49
|
+
case source
|
|
50
|
+
when Hash
|
|
51
|
+
Econt::Util.underscore_keys(source)[@key.to_s] || []
|
|
52
|
+
when Array
|
|
53
|
+
source
|
|
54
|
+
else
|
|
55
|
+
[]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/econt/error.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Econt
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :error, :status
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, error: nil, status: nil)
|
|
8
|
+
@error = error
|
|
9
|
+
@status = status
|
|
10
|
+
|
|
11
|
+
super(message || error_message || status_message || self.class.name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def error_message
|
|
17
|
+
return unless error.respond_to?(:full_message)
|
|
18
|
+
|
|
19
|
+
message = error.full_message
|
|
20
|
+
message unless message.to_s.strip.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def status_message
|
|
24
|
+
return if status.nil?
|
|
25
|
+
|
|
26
|
+
"Econt API request failed with status #{status}."
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A request failed before a usable HTTP response was available.
|
|
31
|
+
# The original adapter exception is retained as #cause and may contain secrets.
|
|
32
|
+
class TransportError < Error; end
|
|
33
|
+
|
|
34
|
+
# The client could not establish or maintain a connection.
|
|
35
|
+
class ConnectionError < TransportError; end
|
|
36
|
+
|
|
37
|
+
# The configured connection or request deadline was exceeded.
|
|
38
|
+
class TimeoutError < TransportError; end
|
|
39
|
+
|
|
40
|
+
# TLS negotiation or certificate verification failed.
|
|
41
|
+
class SSLError < TransportError; end
|
|
42
|
+
end
|
data/lib/econt/model.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Econt
|
|
4
|
+
class Model
|
|
5
|
+
include Econt::ResourceAttributes
|
|
6
|
+
include Econt::Util
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def collection_key(value = nil)
|
|
10
|
+
return @collection_key || Econt::Util.pluralize(name).to_sym unless value
|
|
11
|
+
|
|
12
|
+
@collection_key = value
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def collection(source)
|
|
16
|
+
Econt::Collection.new(source, model: self, key: collection_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def build(source, key: nil)
|
|
20
|
+
data = extract_source(source, key)
|
|
21
|
+
new(data.is_a?(Hash) ? data : {})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def extract_source(source, key)
|
|
27
|
+
case source
|
|
28
|
+
when Hash
|
|
29
|
+
normalized = Econt::Util.underscore_keys(source)
|
|
30
|
+
if key && normalized.key?(key.to_s)
|
|
31
|
+
normalized[key.to_s]
|
|
32
|
+
else
|
|
33
|
+
source
|
|
34
|
+
end
|
|
35
|
+
else
|
|
36
|
+
source
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(attributes = {})
|
|
42
|
+
@attributes = {}
|
|
43
|
+
@error = Econt::Models::Error.build_from(attributes) unless is_a?(Econt::Models::Error)
|
|
44
|
+
apply_attributes(attributes)
|
|
45
|
+
self.error = @error if @error && respond_to?(:error=)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :error
|
|
49
|
+
|
|
50
|
+
def error?
|
|
51
|
+
!error.nil?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def method_missing(method_name, *args, &)
|
|
55
|
+
if @attributes.key?(method_name)
|
|
56
|
+
@attributes[method_name]
|
|
57
|
+
else
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
63
|
+
@attributes.key?(method_name) || super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
deep_to_h(@attributes)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_json(*)
|
|
71
|
+
camelize_keys(to_h).to_json(*)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def apply_attributes(new_attributes)
|
|
75
|
+
underscore_keys(new_attributes).each do |key, value|
|
|
76
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def deep_to_h(value)
|
|
83
|
+
case value
|
|
84
|
+
when Econt::Model
|
|
85
|
+
value.to_h
|
|
86
|
+
when Hash
|
|
87
|
+
value.transform_values { |item| deep_to_h(item) }
|
|
88
|
+
when Array
|
|
89
|
+
value.map { |item| deep_to_h(item) }
|
|
90
|
+
else
|
|
91
|
+
value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Econt
|
|
4
|
+
module Models
|
|
5
|
+
class Error < Econt::Model
|
|
6
|
+
field :type, type: :string
|
|
7
|
+
field :message, type: :string
|
|
8
|
+
field :fields, type: :array, of: :string
|
|
9
|
+
field :inner_errors, type: :array, of: Econt::Models::Error
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def build_from(source)
|
|
13
|
+
return unless error_body?(source)
|
|
14
|
+
|
|
15
|
+
new(source)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error_body?(source)
|
|
19
|
+
return false unless source.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
key?(source, :type) && (key?(source, :fields) || key?(source, :inner_errors))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def key?(hash, key)
|
|
27
|
+
hash.key?(key) || hash.key?(Econt::Util.camel_case(key.to_s))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def fields
|
|
32
|
+
Array(@attributes[:fields])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inner_errors
|
|
36
|
+
Array(@attributes[:inner_errors]).map do |error|
|
|
37
|
+
error.is_a?(Econt::Models::Error) ? error : Econt::Models::Error.new(error)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def messages
|
|
42
|
+
[
|
|
43
|
+
message,
|
|
44
|
+
*inner_errors.flat_map(&:messages)
|
|
45
|
+
].reject { |value| value.to_s.strip.empty? }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def full_message
|
|
49
|
+
messages.join(' ')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Econt
|
|
4
|
+
module Models
|
|
5
|
+
class HostedFile < Econt::Model
|
|
6
|
+
field :id, type: :integer
|
|
7
|
+
field :filename, type: :string
|
|
8
|
+
field :mime_type, type: :string
|
|
9
|
+
field :url, type: :string
|
|
10
|
+
field :content, type: :string
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|