bluexpress 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bf2fd85cf12df5b1a882fae6c993366b4aa6511505ed000f485561445a926ad9
4
+ data.tar.gz: 0d7782e8f5f0f87219e39db53a2feecf6a23c7f7bf049860cf4c69e71628714a
5
+ SHA512:
6
+ metadata.gz: 46c35f2c04e62dfc4dd0d62c7b0b17f2b0bf93d52a98b9cf8473d6ec4e02c61e12e3ef6ca50a16be0a5d25bf2e85d076cc023acda366dd05b77182be2791012a
7
+ data.tar.gz: 8c2a82fc1a36e9fc6f8169561529042b1403fd20e6aa72e6ba441d63a06cd6d2bf9d8a6d19fb1aa6630a7dd786cbf314a6db7dd30dec1f25334c8e496ce7eca7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Bootic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # Bluexpress
2
+
3
+ Unofficial Ruby gem for integrating with BlueX shipping APIs.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem 'bluexpress'
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install bluexpress
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```ruby
22
+ require 'bluexpress'
23
+
24
+ client = Bluexpress::Client.new(
25
+ api_key: ENV['BLUEXPRESS_API_KEY'],
26
+ environment: :qa, # or :production, :dev
27
+ account_name: 'https://your-store.com'
28
+ )
29
+
30
+ # Get shipping pricing
31
+ pricing = client.get_pricing(
32
+ from: { country: 'CL', district: 'SCL' },
33
+ to: { country: 'CL', district: 'PRO' },
34
+ service_type: 'EX',
35
+ bultos: [{ largo: 10, ancho: 10, alto: 10, sku: 'SKU1', peso_fisico: 1, cantidad: 1 }],
36
+ declared_value: 10000,
37
+ familia_producto: 'PAQU'
38
+ )
39
+
40
+ # Resolve geolocation
41
+ geo = client.get_geolocation(
42
+ address: 'Providencia',
43
+ region_code: '13'
44
+ )
45
+
46
+ # Check integration status
47
+ status = client.validate_integration_status
48
+
49
+ # Update credentials
50
+ client.update_integration_credentials(
51
+ store_id: 'store-123',
52
+ client_key: 'your_key',
53
+ client_secret: 'your_secret'
54
+ )
55
+
56
+ # Send order webhook
57
+ client.send_order_webhook(order_payload: { id: 1001, shipping_lines: 'bluex-express' })
58
+
59
+ # Send log webhook
60
+ client.send_log_webhook(error: 'Pricing failed', order: { id: 1001 })
61
+ ```
62
+
63
+ ## Error Handling
64
+
65
+ ```ruby
66
+ begin
67
+ client.get_pricing(...)
68
+ rescue Bluexpress::ConfigError => e
69
+ puts "Configuration error: #{e.message}"
70
+ rescue Bluexpress::ValidationError => e
71
+ puts "Validation error at #{e.endpoint}: #{e.issues}"
72
+ rescue Bluexpress::ApiError => e
73
+ puts "API error #{e.http_status} at #{e.endpoint}: #{e.body}"
74
+ end
75
+ ```
76
+
77
+ ## CLI
78
+
79
+ Install the gem and use the `bluexpress` command:
80
+
81
+ ```bash
82
+ # Set environment variables
83
+ export BLUEXPRESS_API_KEY=your_api_key
84
+ export BLUEXPRESS_ENV=qa
85
+
86
+ # Get pricing
87
+ bluexpress pricing \
88
+ --from-country=CL --from-district=SCL \
89
+ --to-country=CL --to-district=PRO \
90
+ --service-type=EX --familia-producto=PAQU \
91
+ --declared-value=10000 \
92
+ --bultos='[{"largo":10,"ancho":10,"alto":10,"sku":"SKU1","pesoFisico":1,"cantidad":1}]'
93
+
94
+ # Get geolocation
95
+ bluexpress geolocation --address="Providencia" --region-code="13"
96
+
97
+ # Check integration status
98
+ bluexpress status
99
+
100
+ # Update credentials
101
+ bluexpress credentials --store-id="store-123" --client-key="key" --client-secret="secret"
102
+
103
+ # Send order webhook
104
+ bluexpress order --payload='{"id":1001}'
105
+
106
+ # Send log
107
+ bluexpress log --error="Error message" --order='{"id":1001}'
108
+ ```
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ # Install dependencies
114
+ bundle install
115
+
116
+ # Run tests
117
+ rake spec
118
+
119
+ # Run specific test types
120
+ rake spec_unit
121
+ rake spec_contract
122
+
123
+ # Lint
124
+ rake lint
125
+ ```
126
+
127
+ ## License
128
+
129
+ MIT License - see LICENSE file
data/bin/bluexpress ADDED
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bluexpress"
5
+ require "json"
6
+ require "optparse"
7
+
8
+ module Bluexpress
9
+ class CLI
10
+ def initialize
11
+ @config = {}
12
+ parse_options!
13
+ load_env_vars!
14
+ validate_config!
15
+ end
16
+
17
+ def run
18
+ case @config[:command]
19
+ when "pricing"
20
+ run_pricing
21
+ when "geolocation"
22
+ run_geolocation
23
+ when "status"
24
+ run_status
25
+ when "credentials"
26
+ run_credentials
27
+ when "order"
28
+ run_order
29
+ when "log"
30
+ run_log
31
+ else
32
+ puts help_text
33
+ exit 1
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def parse_options!
40
+ OptionParser.new do |opts|
41
+ opts.banner = "Usage: bluexpress [options] [command] [command_options]"
42
+
43
+ opts.on("--api-key=KEY", "BlueX API key") { |v| @config[:api_key] = v }
44
+ opts.on("--environment=ENV", [:production, :qa, :dev], "Environment (production, qa, dev)") { |v| @config[:environment] = v }
45
+ opts.on("--account-name=NAME", "Account name (URL)") { |v| @config[:account_name] = v }
46
+ opts.on("--base-url=URL", "Custom base URL") { |v| @config[:base_url] = v }
47
+ opts.on("--timeout=SECONDS", Integer, "Request timeout (default: 30)") { |v| @config[:timeout] = v }
48
+ opts.on("-h", "--help", "Show this help") { puts opts; exit }
49
+ end.parse!
50
+
51
+ @config[:command] = ARGV[0]
52
+ @config[:command_args] = ARGV[1..]
53
+ end
54
+
55
+ def load_env_vars!
56
+ @config[:api_key] ||= ENV["BLUEXPRESS_API_KEY"]
57
+ @config[:environment] ||= (ENV["BLUEXPRESS_ENV"] || "production").to_sym
58
+ @config[:account_name] ||= ENV["BLUEXPRESS_ACCOUNT_NAME"]
59
+ @config[:base_url] ||= ENV["BLUEXPRESS_BASE_URL"]
60
+ @config[:timeout] ||= (ENV["BLUEXPRESS_TIMEOUT"] || 30).to_i
61
+ end
62
+
63
+ def validate_config!
64
+ raise "api_key is required. Set BLUEXPRESS_API_KEY or use --api-key" unless @config[:api_key]
65
+ end
66
+
67
+ def client
68
+ @client ||= Bluexpress::Client.new(
69
+ api_key: @config[:api_key],
70
+ environment: @config[:environment],
71
+ account_name: @config[:account_name],
72
+ base_url: @config[:base_url],
73
+ timeout: @config[:timeout]
74
+ )
75
+ end
76
+
77
+ def run_pricing
78
+ args = parse_pricing_args!
79
+ puts JSON.pretty_generate(client.get_pricing(**args))
80
+ end
81
+
82
+ def parse_pricing_args!
83
+ opts = {}
84
+
85
+ OptionParser.new do |p|
86
+ p.on("--from-country=COUNTRY") { |v| opts[:from] ||= {}; opts[:from][:country] = v }
87
+ p.on("--from-district=DISTRICT") { |v| opts[:from] ||= {}; opts[:from][:district] = v }
88
+ p.on("--from-state=STATE") { |v| opts[:from] ||= {}; opts[:from][:state] = v }
89
+ p.on("--to-country=COUNTRY") { |v| opts[:to] ||= {}; opts[:to][:country] = v }
90
+ p.on("--to-district=DISTRICT") { |v| opts[:to] ||= {}; opts[:to][:district] = v }
91
+ p.on("--to-state=STATE") { |v| opts[:to] ||= {}; opts[:to][:state] = v }
92
+ p.on("--service-type=TYPE") { |v| opts[:service_type] = v }
93
+ p.on("--familia-producto=FAM") { |v| opts[:familia_producto] = v }
94
+ p.on("--declared-value=VALUE", Integer) { |v| opts[:declared_value] = v }
95
+ p.on("--domain=URL") { |v| opts[:domain] = v }
96
+ p.on("--bultos=JSON") { |v| opts[:bultos] = JSON.parse(v) }
97
+ p.on("-h", "--help", "Show pricing help") { puts p; exit }
98
+ end.parse!(@config[:command_args])
99
+
100
+ required = [:from, :to, :service_type, :bultos, :declared_value, :familia_producto]
101
+ missing = required.reject { |k| opts[k] }
102
+ raise "Missing required options: #{missing.join(', ')}" unless missing.empty?
103
+
104
+ opts[:bultos] = opts[:bultos].map do |b|
105
+ {
106
+ largo: b["largo"] || b[:largo],
107
+ ancho: b["ancho"] || b[:ancho],
108
+ alto: b["alto"] || b[:alto],
109
+ sku: b["sku"] || b[:sku],
110
+ peso_fisico: b["pesoFisico"] || b[:peso_fisico],
111
+ cantidad: b["cantidad"] || b[:cantidad]
112
+ }
113
+ end
114
+
115
+ opts
116
+ end
117
+
118
+ def run_geolocation
119
+ args = parse_geolocation_args!
120
+ puts JSON.pretty_generate(client.get_geolocation(**args))
121
+ end
122
+
123
+ def parse_geolocation_args!
124
+ opts = {}
125
+
126
+ OptionParser.new do |p|
127
+ p.on("--address=ADDRESS") { |v| opts[:address] = v }
128
+ p.on("--region-code=CODE") { |v| opts[:region_code] = v }
129
+ p.on("--is-pudo=[true|false]") { |v| opts[:is_pudo] = v == "true" }
130
+ p.on("--agency-id=ID") { |v| opts[:agency_id] = v }
131
+ p.on("--type=TYPE") { |v| opts[:type] = v }
132
+ p.on("--shop=URL") { |v| opts[:shop] = v }
133
+ p.on("-h", "--help", "Show geolocation help") { puts p; exit }
134
+ end.parse!(@config[:command_args])
135
+
136
+ raise "Missing required: --address and --region-code" unless opts[:address] && opts[:region_code]
137
+
138
+ opts
139
+ end
140
+
141
+ def run_status
142
+ puts JSON.pretty_generate(client.validate_integration_status)
143
+ end
144
+
145
+ def run_credentials
146
+ args = parse_credentials_args!
147
+ puts JSON.pretty_generate(client.update_integration_credentials(**args))
148
+ end
149
+
150
+ def parse_credentials_args!
151
+ opts = {}
152
+
153
+ OptionParser.new do |p|
154
+ p.on("--store-id=ID") { |v| opts[:store_id] = v }
155
+ p.on("--client-key=KEY") { |v| opts[:client_key] = v }
156
+ p.on("--client-secret=SECRET") { |v| opts[:client_secret] = v }
157
+ p.on("--ecommerce=NAME") { |v| opts[:ecommerce] = v }
158
+ p.on("-h", "--help", "Show credentials help") { puts p; exit }
159
+ end.parse!(@config[:command_args])
160
+
161
+ required = [:store_id, :client_key, :client_secret]
162
+ missing = required.reject { |k| opts[k] }
163
+ raise "Missing required options: #{missing.join(', ')}" unless missing.empty?
164
+
165
+ opts
166
+ end
167
+
168
+ def run_order
169
+ args = parse_order_args!
170
+ puts JSON.pretty_generate(client.send_order_webhook(**args))
171
+ end
172
+
173
+ def parse_order_args!
174
+ opts = {}
175
+
176
+ OptionParser.new do |p|
177
+ p.on("--payload=JSON") { |v| opts[:order_payload] = JSON.parse(v) }
178
+ p.on("-h", "--help", "Show order help") { puts p; exit }
179
+ end.parse!(@config[:command_args])
180
+
181
+ raise "Missing required: --payload JSON" unless opts[:order_payload]
182
+
183
+ opts
184
+ end
185
+
186
+ def run_log
187
+ args = parse_log_args!
188
+ puts JSON.pretty_generate(client.send_log_webhook(**args))
189
+ end
190
+
191
+ def parse_log_args!
192
+ opts = {}
193
+
194
+ OptionParser.new do |p|
195
+ p.on("--error=MESSAGE") { |v| opts[:error] = v }
196
+ p.on("--order=JSON") { |v| opts[:order] = JSON.parse(v) }
197
+ p.on("-h", "--help", "Show log help") { puts p; exit }
198
+ end.parse!(@config[:command_args])
199
+
200
+ required = [:error, :order]
201
+ missing = required.reject { |k| opts[k] }
202
+ raise "Missing required options: #{missing.join(', ')}" unless missing.empty?
203
+
204
+ opts
205
+ end
206
+
207
+ def help_text
208
+ <<~HELP
209
+ BlueXpress CLI - Ruby client for BlueX shipping API
210
+
211
+ Usage:
212
+ bluexpress [global options] command [command options]
213
+
214
+ Global Options:
215
+ --api-key=KEY BlueX API key (or BLUEXPRESS_API_KEY env)
216
+ --environment=ENV Environment: production, qa, dev (default: production)
217
+ --account-name=URL Account name (or BLUEXPRESS_ACCOUNT_NAME env)
218
+ --base-url=URL Custom base URL (or BLUEXPRESS_BASE_URL env)
219
+ --timeout=SECONDS Request timeout (default: 30)
220
+
221
+ Commands:
222
+ pricing Get shipping pricing
223
+ geolocation Resolve geographic codes
224
+ status Check integration status
225
+ credentials Update integration credentials
226
+ order Send order webhook
227
+ log Send log webhook
228
+
229
+ Examples:
230
+ bluexpress pricing --from-country=CL --from-district=SCL \\
231
+ --to-country=CL --to-district=PRO \\
232
+ --service-type=EX --familia-producto=PAQU \\
233
+ --declared-value=10000 \\
234
+ --bultos='[{"largo":10,"ancho":10,"alto":10,"sku":"SKU1","pesoFisico":1,"cantidad":1}]'
235
+
236
+ bluexpress geolocation --address="Providencia" --region-code="13"
237
+
238
+ bluexpress status
239
+
240
+ bluexpress credentials --store-id="store-123" --client-key="key" --client-secret="secret"
241
+
242
+ bluexpress order --payload='{"id":1001,"shipping_lines":"bluex-express"}'
243
+
244
+ bluexpress log --error="Pricing failed" --order='{"id":1001}'
245
+
246
+ Environment Variables:
247
+ BLUEXPRESS_API_KEY Your BlueX API key
248
+ BLUEXPRESS_ENV Environment (production, qa, dev)
249
+ BLUEXPRESS_ACCOUNT_NAME Your store URL
250
+ BLUEXPRESS_BASE_URL Custom base URL
251
+ BLUEXPRESS_TIMEOUT Request timeout in seconds
252
+ HELP
253
+ end
254
+ end
255
+ end
256
+
257
+ if __FILE__ == $PROGRAM_NAME
258
+ begin
259
+ Bluexpress::CLI.new.run
260
+ rescue => e
261
+ $stderr.puts "Error: #{e.message}"
262
+ exit 1
263
+ end
264
+ end
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+
5
+ module Bluexpress
6
+ class Client
7
+ attr_reader :api_key, :account_name, :base_url, :timeout
8
+
9
+ def initialize(api_key:, environment: :production, account_name: nil, base_url: nil, timeout: 30)
10
+ validate_config(api_key: api_key, base_url: base_url, environment: environment)
11
+
12
+ @api_key = api_key
13
+ @account_name = account_name
14
+ @environment = environment
15
+ @base_url = base_url || BASE_URLS.fetch(environment)
16
+ @timeout = timeout
17
+ end
18
+
19
+ def get_pricing(from:, to:, service_type:, bultos:, declared_value:, familia_producto:, domain: nil)
20
+ input = {
21
+ from: from,
22
+ to: to,
23
+ service_type: service_type,
24
+ bultos: bultos,
25
+ declared_value: declared_value,
26
+ familia_producto: familia_producto,
27
+ domain: domain || account_name
28
+ }
29
+
30
+ result = Validators.validate_pricing_request(input)
31
+ raise ValidationError.new("Payload does not match expected contract", endpoint: "/eplin/pricing/v1", issues: result.errors) unless result.valid?
32
+
33
+ payload = build_pricing_payload(input)
34
+
35
+ request(
36
+ method: :post,
37
+ path: "/eplin/pricing/v1",
38
+ body: payload,
39
+ extra_headers: { "price" => String(declared_value) },
40
+ response_schema: :pricing
41
+ )
42
+ end
43
+
44
+ def get_geolocation(address:, region_code:, is_pudo: false, agency_id: nil, type: "woocommerce", shop: nil)
45
+ input = {
46
+ address: address,
47
+ region_code: region_code,
48
+ is_pudo: is_pudo,
49
+ agency_id: agency_id,
50
+ type: type,
51
+ shop: shop || account_name
52
+ }
53
+
54
+ result = Validators.validate_geolocation_request(input)
55
+ raise ValidationError.new("Payload does not match expected contract", endpoint: "/api/ecommerce/comunas/v1/bxgeo", issues: result.errors) unless result.valid?
56
+
57
+ path = is_pudo ? "/api/ecommerce/comunas/v1/bxgeo/v2" : "/api/ecommerce/comunas/v1/bxgeo"
58
+
59
+ request(
60
+ method: :post,
61
+ path: path,
62
+ body: build_geolocation_payload(input),
63
+ response_schema: :geolocation
64
+ )
65
+ end
66
+
67
+ def validate_integration_status(ecommerce: "Woocommerce", account_name: nil)
68
+ input = {
69
+ ecommerce: ecommerce,
70
+ account_name: account_name || self.account_name
71
+ }
72
+
73
+ request(
74
+ method: :post,
75
+ path: "/api/ecommerce/token/v1/ecommerce/integration-status",
76
+ body: snake_to_camel(input.compact),
77
+ response_schema: :integration_status
78
+ )
79
+ end
80
+
81
+ def update_integration_credentials(store_id:, client_key:, client_secret:, ecommerce: "Woocommerce", account_name: nil)
82
+ input = {
83
+ store_id: store_id,
84
+ credentials: {
85
+ client_key: client_key,
86
+ client_secret: client_secret
87
+ },
88
+ ecommerce: ecommerce,
89
+ account_name: account_name || self.account_name
90
+ }
91
+
92
+ result = Validators.validate_update_credentials_request(input)
93
+ raise ValidationError.new("Payload does not match expected contract", endpoint: "/api/ecommerce/token/v1/ecommerce/update-tokens", issues: result.errors) unless result.valid?
94
+
95
+ request(
96
+ method: :post,
97
+ path: "/api/ecommerce/token/v1/ecommerce/update-tokens",
98
+ body: build_update_credentials_payload(input),
99
+ response_schema: :integration_status
100
+ )
101
+ end
102
+
103
+ def send_order_webhook(order_payload)
104
+ request(
105
+ method: :post,
106
+ path: "/api/integr/woocommerce-wh/v1/order",
107
+ body: order_payload,
108
+ response_schema: :webhook
109
+ )
110
+ end
111
+
112
+ def send_log_webhook(error:, order:)
113
+ input = { error: error, order: order }
114
+
115
+ result = Validators.validate_log_webhook_request(input)
116
+ raise ValidationError.new("Payload does not match expected contract", endpoint: "/api/ecommerce/custom/logs/v1", issues: result.errors) unless result.valid?
117
+
118
+ request(
119
+ method: :post,
120
+ path: "/api/ecommerce/custom/logs/v1",
121
+ body: snake_to_camel(input),
122
+ response_schema: :webhook
123
+ )
124
+ end
125
+
126
+ private
127
+
128
+ def validate_config(api_key:, base_url:, environment:)
129
+ raise ConfigError, "api_key is required" if api_key.nil? || api_key.to_s.strip.empty?
130
+
131
+ if base_url
132
+ begin
133
+ uri = URI.parse(base_url)
134
+ unless uri.scheme && uri.host
135
+ raise ConfigError, "base_url must be a valid absolute URL (e.g., https://api.example.com)"
136
+ end
137
+ rescue URI::Error
138
+ raise ConfigError, "base_url must be a valid absolute URL (e.g., https://api.example.com)"
139
+ end
140
+ end
141
+
142
+ unless VALID_ENVIRONMENTS.include?(environment)
143
+ raise ConfigError, "environment must be one of: #{VALID_ENVIRONMENTS.join(', ')}"
144
+ end
145
+ end
146
+
147
+ def request(method:, path:, body: nil, extra_headers: {}, response_schema: nil)
148
+ url = URI.join(base_url, path).to_s
149
+ headers = {
150
+ "Content-Type" => "application/json",
151
+ "x-api-key" => api_key
152
+ }.merge(extra_headers)
153
+
154
+ options = {
155
+ headers: headers,
156
+ timeout: timeout
157
+ }
158
+
159
+ options[:body] = body.to_json if body
160
+
161
+ begin
162
+ response = HTTParty.send(method, url, options)
163
+ handle_response(response, url, response_schema)
164
+ rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED, SocketError => e
165
+ raise ApiError.new("BlueX API request failed: #{e.message}", http_status: nil, endpoint: url, body: nil)
166
+ end
167
+ end
168
+
169
+ def handle_response(response, url, schema)
170
+ if response.code == 200 || response.code == 201
171
+ parse_response_body(response.body, schema, url)
172
+ else
173
+ error_body = parse_json(response.body)
174
+ message = extract_error_message(error_body) || response.message
175
+ raise ApiError.new(message, http_status: response.code, endpoint: url, body: error_body)
176
+ end
177
+ rescue ApiError
178
+ raise
179
+ rescue JSON::ParserError
180
+ raise ApiError.new("Invalid JSON response from BlueX API", http_status: response.code, endpoint: url, body: response.body)
181
+ end
182
+
183
+ def parse_json(body)
184
+ return {} if body.nil? || body.strip.empty?
185
+
186
+ JSON.parse(body, symbolize_names: true)
187
+ rescue JSON::ParserError
188
+ {}
189
+ end
190
+
191
+ def parse_response_body(body, schema, url)
192
+ data = parse_json(body)
193
+
194
+ case schema
195
+ when :pricing
196
+ validate_pricing_response(data, url)
197
+ when :geolocation
198
+ validate_geolocation_response(data, url)
199
+ when :integration_status
200
+ validate_integration_status_response(data, url)
201
+ when :webhook
202
+ data
203
+ else
204
+ data
205
+ end
206
+ end
207
+
208
+ def validate_pricing_response(data, url)
209
+ required_keys = [:code, :message]
210
+ missing = required_keys.reject { |k| data.key?(k) || data.key?(k.to_s) }
211
+
212
+ raise ValidationError.new("Response from BlueX does not match expected contract", endpoint: url, issues: ["Missing keys: #{missing.join(', ')}"]) unless missing.empty?
213
+
214
+ camel_to_snake(data)
215
+ end
216
+
217
+ def validate_geolocation_response(data, url)
218
+ raise ValidationError.new("Response from BlueX does not match expected contract", endpoint: url, issues: ["Empty response"]) if data.nil? || data.empty?
219
+
220
+ camel_to_snake(data)
221
+ end
222
+
223
+ def validate_integration_status_response(data, url)
224
+ camel_to_snake(data)
225
+ end
226
+
227
+ def build_pricing_payload(input)
228
+ {
229
+ from: snake_to_camel(input[:from]),
230
+ to: snake_to_camel(input[:to]),
231
+ serviceType: input[:service_type],
232
+ domain: input[:domain],
233
+ datosProducto: {
234
+ producto: "P",
235
+ familiaProducto: input[:familia_producto],
236
+ bultos: input[:bultos].map { |b| snake_to_camel(b) }
237
+ }
238
+ }
239
+ end
240
+
241
+ def build_geolocation_payload(input)
242
+ {
243
+ address: input[:address],
244
+ type: input[:type],
245
+ shop: input[:shop],
246
+ regionCode: input[:region_code],
247
+ agencyId: input[:agency_id] || ""
248
+ }
249
+ end
250
+
251
+ def build_update_credentials_payload(input)
252
+ {
253
+ storeId: input[:store_id],
254
+ ecommerce: input[:ecommerce],
255
+ credentials: {
256
+ accessToken: input[:credentials][:client_key],
257
+ secretKey: input[:credentials][:client_secret],
258
+ accountName: input[:account_name]
259
+ }
260
+ }
261
+ end
262
+
263
+ def snake_to_camel(hash)
264
+ hash.transform_keys do |key|
265
+ key.to_s.gsub(/_([a-z]|[A-Z])/) { |m| m[1].upcase }.to_sym
266
+ end
267
+ end
268
+
269
+ def camel_to_snake(hash)
270
+ return hash unless hash.is_a?(Hash)
271
+
272
+ hash.transform_keys do |key|
273
+ key.to_s
274
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
275
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
276
+ .tr("-", "_")
277
+ .downcase
278
+ .to_sym
279
+ end.transform_values do |value|
280
+ if value.is_a?(Hash)
281
+ camel_to_snake(value)
282
+ elsif value.is_a?(Array)
283
+ value.map { |v| v.is_a?(Hash) ? camel_to_snake(v) : v }
284
+ else
285
+ value
286
+ end
287
+ end
288
+ end
289
+
290
+ def extract_error_message(body)
291
+ return nil unless body.is_a?(Hash)
292
+
293
+ body["message"] || body["error"] || body["errors"]&.first
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bluexpress
4
+ class ConfigError < StandardError
5
+ def initialize(message)
6
+ super(message)
7
+ end
8
+ end
9
+
10
+ class ValidationError < StandardError
11
+ attr_reader :endpoint, :issues
12
+
13
+ def initialize(message, endpoint: nil, issues: nil)
14
+ super(message)
15
+ @endpoint = endpoint
16
+ @issues = issues
17
+ end
18
+ end
19
+
20
+ class ApiError < StandardError
21
+ attr_reader :http_status, :endpoint, :body
22
+
23
+ def initialize(message, http_status: nil, endpoint: nil, body: nil)
24
+ super(message)
25
+ @http_status = http_status
26
+ @endpoint = endpoint
27
+ @body = body
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bluexpress
4
+ module Validators
5
+ class ValidationResult
6
+ attr_reader :data, :errors
7
+
8
+ def initialize(data, errors)
9
+ @data = data
10
+ @errors = errors
11
+ end
12
+
13
+ def valid?
14
+ errors.empty?
15
+ end
16
+ end
17
+
18
+ def self.validate_pricing_request(input)
19
+ errors = []
20
+
21
+ unless input[:from].is_a?(Hash) && input[:from][:country] && input[:from][:district]
22
+ errors << "from must be a hash with :country and :district"
23
+ end
24
+
25
+ unless input[:to].is_a?(Hash) && input[:to][:country] && input[:to][:district]
26
+ errors << "to must be a hash with :country and :district"
27
+ end
28
+
29
+ unless input[:service_type].is_a?(String) && !input[:service_type].empty?
30
+ errors << "service_type is required and must be a non-empty string"
31
+ end
32
+
33
+ unless input[:bultos].is_a?(Array) && input[:bultos].any?
34
+ errors << "bultos must be a non-empty array"
35
+ end
36
+
37
+ if input[:bultos]
38
+ input[:bultos].each_with_index do |bulto, idx|
39
+ required_fields = [:largo, :ancho, :alto, :sku, :peso_fisico, :cantidad]
40
+ missing = required_fields.reject { |f| bulto[f] }
41
+ errors << "bultos[#{idx}] missing required fields: #{missing.join(', ')}" unless missing.empty?
42
+
43
+ numeric_fields = [:largo, :ancho, :alto, :peso_fisico, :cantidad]
44
+ numeric_fields.each do |field|
45
+ unless bulto[field].is_a?(Numeric) && bulto[field] > 0
46
+ errors << "bultos[#{idx}][#{field}] must be a positive number"
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ unless input[:declared_value].is_a?(Numeric) && input[:declared_value] >= 0
53
+ errors << "declared_value must be a non-negative number"
54
+ end
55
+
56
+ unless input[:familia_producto].is_a?(String) && !input[:familia_producto].empty?
57
+ errors << "familia_producto is required and must be a non-empty string"
58
+ end
59
+
60
+ ValidationResult.new(input, errors)
61
+ end
62
+
63
+ def self.validate_geolocation_request(input)
64
+ errors = []
65
+
66
+ unless input[:address].is_a?(String) && !input[:address].empty?
67
+ errors << "address is required and must be a non-empty string"
68
+ end
69
+
70
+ unless input[:region_code].is_a?(String) && !input[:region_code].empty?
71
+ errors << "region_code is required and must be a non-empty string"
72
+ end
73
+
74
+ ValidationResult.new(input, errors)
75
+ end
76
+
77
+ def self.validate_update_credentials_request(input)
78
+ errors = []
79
+
80
+ unless input[:store_id].is_a?(String) && !input[:store_id].empty?
81
+ errors << "store_id is required and must be a non-empty string"
82
+ end
83
+
84
+ unless input[:credentials].is_a?(Hash)
85
+ errors << "credentials must be a hash"
86
+ end
87
+
88
+ if input[:credentials]
89
+ unless input[:credentials][:client_key].is_a?(String) && !input[:credentials][:client_key].empty?
90
+ errors << "credentials[:client_key] is required and must be a non-empty string"
91
+ end
92
+ unless input[:credentials][:client_secret].is_a?(String) && !input[:credentials][:client_secret].empty?
93
+ errors << "credentials[:client_secret] is required and must be a non-empty string"
94
+ end
95
+ end
96
+
97
+ ValidationResult.new(input, errors)
98
+ end
99
+
100
+ def self.validate_log_webhook_request(input)
101
+ errors = []
102
+
103
+ unless input[:error].is_a?(String) && !input[:error].empty?
104
+ errors << "error is required and must be a non-empty string"
105
+ end
106
+
107
+ unless input[:order].is_a?(Hash)
108
+ errors << "order must be a hash"
109
+ end
110
+
111
+ ValidationResult.new(input, errors)
112
+ end
113
+
114
+ def self.camelize_keys(hash)
115
+ hash.transform_keys do |key|
116
+ key.to_s.gsub(/_([a-z]|[A-Z])/) { |m| m[1].upcase }.to_sym
117
+ end.transform_values do |value|
118
+ if value.is_a?(Hash)
119
+ camelize_keys(value)
120
+ elsif value.is_a?(Array)
121
+ value.map { |v| v.is_a?(Hash) ? camelize_keys(v) : v }
122
+ else
123
+ value
124
+ end
125
+ end
126
+ end
127
+
128
+ def self.underscore_keys(hash)
129
+ hash.transform_keys do |key|
130
+ key.to_s
131
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
132
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
133
+ .tr("-", "_")
134
+ .downcase
135
+ .to_sym
136
+ end.transform_values do |value|
137
+ if value.is_a?(Hash)
138
+ underscore_keys(value)
139
+ elsif value.is_a?(Array)
140
+ value.map { |v| v.is_a?(Hash) ? underscore_keys(v) : v }
141
+ else
142
+ value
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bluexpress
4
+ VERSION = "0.1.0".freeze
5
+ end
data/lib/bluexpress.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bluexpress
4
+ BASE_URLS = {
5
+ production: "https://eplin.api.blue.cl",
6
+ qa: "https://eplin.api.qa.blue.cl",
7
+ dev: "https://eplin.api.dev.blue.cl"
8
+ }.freeze
9
+
10
+ VALID_ENVIRONMENTS = BASE_URLS.keys.freeze
11
+ end
12
+
13
+ require_relative "bluexpress/version"
14
+ require "bluexpress/errors"
15
+ require "bluexpress/validators"
16
+ require "bluexpress/client"
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bluexpress
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tomás Pollak
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-18 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: httparty
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.21'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.21'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webmock
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.19'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.19'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.22'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.22'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
83
+ description: Unofficial Ruby gem for integrating with BlueX shipping APIs, based on
84
+ the official BlueX WooCommerce plugin contracts.
85
+ email:
86
+ - tomas@onbolder.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - LICENSE
92
+ - README.md
93
+ - bin/bluexpress
94
+ - lib/bluexpress.rb
95
+ - lib/bluexpress/client.rb
96
+ - lib/bluexpress/errors.rb
97
+ - lib/bluexpress/validators.rb
98
+ - lib/bluexpress/version.rb
99
+ homepage: https://github.com/bootic/bluexpress-ruby-client
100
+ licenses:
101
+ - MIT
102
+ metadata: {}
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '2.7'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.4.6
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: Ruby client for BlueX shipping API
122
+ test_files: []