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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/CODE_OF_CONDUCT.md +17 -0
  4. data/CONTRIBUTING.md +37 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +171 -0
  7. data/SECURITY.md +33 -0
  8. data/docs/API.md +571 -0
  9. data/docs/ENDPOINTS.md +42 -0
  10. data/docs/PUBLIC_API.md +81 -0
  11. data/docs/RELEASING.md +35 -0
  12. data/docs/RESPONSE_TESTS.md +46 -0
  13. data/docs/VCR.md +144 -0
  14. data/lib/econt/camel_case_middleware.rb +88 -0
  15. data/lib/econt/client.rb +99 -0
  16. data/lib/econt/collection.rb +59 -0
  17. data/lib/econt/error.rb +42 -0
  18. data/lib/econt/meta_resource.rb +6 -0
  19. data/lib/econt/model.rb +95 -0
  20. data/lib/econt/models/error.rb +53 -0
  21. data/lib/econt/models/hosted_file.rb +13 -0
  22. data/lib/econt/models/nomenclatures/address.rb +35 -0
  23. data/lib/econt/models/nomenclatures/address_service_info.rb +16 -0
  24. data/lib/econt/models/nomenclatures/address_service_times_response.rb +19 -0
  25. data/lib/econt/models/nomenclatures/address_validation_response.rb +19 -0
  26. data/lib/econt/models/nomenclatures/city.rb +32 -0
  27. data/lib/econt/models/nomenclatures/country.rb +16 -0
  28. data/lib/econt/models/nomenclatures/geo_location.rb +13 -0
  29. data/lib/econt/models/nomenclatures/office.rb +32 -0
  30. data/lib/econt/models/nomenclatures/quarter.rb +14 -0
  31. data/lib/econt/models/nomenclatures/serving_office_element.rb +12 -0
  32. data/lib/econt/models/nomenclatures/street.rb +14 -0
  33. data/lib/econt/models/nomenclatures/working_date_time.rb +16 -0
  34. data/lib/econt/models/nomenclatures/working_time.rb +12 -0
  35. data/lib/econt/models/payment_report/payment_report_row.rb +17 -0
  36. data/lib/econt/models/profile/cd_pay_options.rb +24 -0
  37. data/lib/econt/models/profile/client_profile.rb +29 -0
  38. data/lib/econt/models/profile/profiles_response_element.rb +16 -0
  39. data/lib/econt/models/shipments/cargo_vehicle_options.rb +16 -0
  40. data/lib/econt/models/shipments/check_possible_shipment_editions_result_element.rb +14 -0
  41. data/lib/econt/models/shipments/create_label_result_element.rb +19 -0
  42. data/lib/econt/models/shipments/customs_list_element.rb +14 -0
  43. data/lib/econt/models/shipments/delete_labels_result_element.rb +14 -0
  44. data/lib/econt/models/shipments/get_my_awb_response.rb +15 -0
  45. data/lib/econt/models/shipments/get_my_awb_result_element.rb +23 -0
  46. data/lib/econt/models/shipments/instruction.rb +20 -0
  47. data/lib/econt/models/shipments/next_shipment_element.rb +13 -0
  48. data/lib/econt/models/shipments/pack_element.rb +14 -0
  49. data/lib/econt/models/shipments/packing_list_element.rb +17 -0
  50. data/lib/econt/models/shipments/payment_addition_price.rb +14 -0
  51. data/lib/econt/models/shipments/payment_instruction.rb +11 -0
  52. data/lib/econt/models/shipments/previous_shipment.rb +14 -0
  53. data/lib/econt/models/shipments/request_courier_response.rb +13 -0
  54. data/lib/econt/models/shipments/request_courier_status.rb +14 -0
  55. data/lib/econt/models/shipments/request_courier_status_result_element.rb +14 -0
  56. data/lib/econt/models/shipments/return_instruction_params.rb +48 -0
  57. data/lib/econt/models/shipments/shipment_edition_response_element.rb +16 -0
  58. data/lib/econt/models/shipments/shipment_status.rb +64 -0
  59. data/lib/econt/models/shipments/shipment_status_result_element.rb +14 -0
  60. data/lib/econt/models/shipments/shipment_status_service.rb +16 -0
  61. data/lib/econt/models/shipments/shipment_tracking_event.rb +21 -0
  62. data/lib/econt/models/shipments/shipping_label.rb +59 -0
  63. data/lib/econt/models/shipments/shipping_label_services.rb +37 -0
  64. data/lib/econt/models/shipments/status_response.rb +11 -0
  65. data/lib/econt/models/shipments/update_label_response.rb +11 -0
  66. data/lib/econt/models/shipments/update_labels_result_element.rb +14 -0
  67. data/lib/econt/models/three_way_logistics/three_way_logistics.rb +50 -0
  68. data/lib/econt/models/three_way_logistics/three_way_logistics_response.rb +11 -0
  69. data/lib/econt/resource_attributes.rb +69 -0
  70. data/lib/econt/service_resource.rb +105 -0
  71. data/lib/econt/services/nomenclatures/address.rb +87 -0
  72. data/lib/econt/services/nomenclatures/nomenclatures.rb +122 -0
  73. data/lib/econt/services/nomenclatures.rb +11 -0
  74. data/lib/econt/services/payment_report/payment_report.rb +35 -0
  75. data/lib/econt/services/payment_report.rb +11 -0
  76. data/lib/econt/services/profile/profile.rb +45 -0
  77. data/lib/econt/services/profile.rb +11 -0
  78. data/lib/econt/services/shipments/label.rb +174 -0
  79. data/lib/econt/services/shipments/shipment.rb +109 -0
  80. data/lib/econt/services/shipments.rb +11 -0
  81. data/lib/econt/services/three_way_logistics/three_way_logistics.rb +30 -0
  82. data/lib/econt/services/three_way_logistics.rb +11 -0
  83. data/lib/econt/util.rb +63 -0
  84. data/lib/econt/version.rb +5 -0
  85. data/lib/econt.rb +118 -0
  86. data/sig/econt.rbs +465 -0
  87. 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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Econt
4
+ class MetaResource
5
+ end
6
+ end
@@ -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