labelzoom 1.0.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: cb5cacebc8dce60a6d1202a23295d51237e834593f029aa10b4ce32d1c9f32e9
4
+ data.tar.gz: 329926fc32ac63622ef6e24fe530241c7771dc231b0bf24b45c16a20e2c73ff4
5
+ SHA512:
6
+ metadata.gz: d8faac862c608c291a469f0f7657911abd74df7504d3882cac124bb2de9bb914cece3a3ac121ae5effcd5f3d4ad80b50050d504a8b4ccd4d399f253af247ced9
7
+ data.tar.gz: 6fe2f0af475e5844c217e6df13cc1f799af72e0ccaf17034590f81040fa5e10622f8b8bc23d4c99f6c102a0112da1d7d8bc8fe1ebc24b8283317c4b214564cd3
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LabelZoom
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,176 @@
1
+ ![LabelZoom Logo](../docs/LabelZoom_Logo_f_400px.png)
2
+
3
+ # LabelZoom Ruby SDK
4
+
5
+ Official Ruby client for the [LabelZoom API](https://api.labelzoom.com). Converts barcode labels
6
+ between ZPL, EPL, TSPL, DPL, PDF, LabelZoom XML/JSON, and raster images.
7
+
8
+ Ruby 3.1+. **No runtime dependencies** — `net/http`, `json`, `uri` and `openssl` are all standard
9
+ library.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ gem install labelzoom
15
+ ```
16
+
17
+ ```ruby
18
+ gem "labelzoom", "~> 1.0"
19
+ ```
20
+
21
+ <details>
22
+ <summary>Build from source</summary>
23
+
24
+ ```sh
25
+ git clone https://github.com/labelzoom/labelzoom-sdk.git
26
+ cd labelzoom-sdk/ruby
27
+ bundle install && bundle exec rake
28
+ ```
29
+ </details>
30
+
31
+ ## Quick start
32
+
33
+ **An API key is optional.** Without one you get the free tier — watermarked output, first label
34
+ only, a 1 MB request cap, and no multi-page, JSON-target, or image-to-image conversion.
35
+
36
+ ```ruby
37
+ require "labelzoom"
38
+
39
+ client = LabelZoom::Client.new # anonymous; this works
40
+
41
+ result = client.convert(:zpl, :png, "^XA^FO20,20^A0N,28^FDHello^FS^XZ",
42
+ dpi: 300,
43
+ label: { width: 4, height: 6 })
44
+
45
+ result.save("label.png")
46
+ ```
47
+
48
+ With a credential:
49
+
50
+ ```ruby
51
+ client = LabelZoom::Client.new(api_key: "lz_live_...")
52
+ ```
53
+
54
+ Passing nothing reads `LABELZOOM_API_KEY` from the environment. Passing `api_key: nil` — or an
55
+ empty string — forces the free tier and suppresses that fallback.
56
+
57
+ ## Formats
58
+
59
+ **Sources (13):** `:zpl` `:epl` `:tspl` `:dpl` `:xml` `:json` `:pdf` `:png` `:bmp` `:gif` `:jpeg`
60
+ `:jpg` `:url`
61
+
62
+ **Targets (11):** `:zpl` `:epl` `:tspl` `:dpl` `:xml` `:json` `:pdf` `:png` `:bmp` `:gif` `:jpeg`
63
+
64
+ `:jpg` is an input spelling that normalizes to `jpeg` on the wire, and `:url` tells the server to
65
+ go fetch a document rather than naming a format — so neither is a target. The statically typed
66
+ SDKs make that a compile error; Ruby raises `LabelZoom::ValidationError` (an `ArgumentError`) on
67
+ the call, before any request goes out.
68
+
69
+ The printer languages round-trip: `pdf`→`epl` and `zpl`→`tspl` are real conversions. Their output
70
+ is `text/plain`, but EPL's `GW` and TSPL's `BITMAP` commands inline raw binary, so read
71
+ `result.bytes` rather than `result.text` whenever a label might carry graphics.
72
+
73
+ ## Options
74
+
75
+ Options are keyword arguments with **nested hashes**, and **only what you pass is sent** — the SDK
76
+ never fills in a client-side default, so a change to a server default reaches you without a gem
77
+ upgrade.
78
+
79
+ ```ruby
80
+ client.convert(:zpl, :png, zpl,
81
+ dpi: 300, # server default 203
82
+ rotation: 90, # must be a multiple of 90
83
+ scaling: 75.0, # percent; server default 100
84
+ color_mode: "GRAYSCALE",
85
+ darkness: 60, # 0-100 luminance threshold
86
+ position: { x: 5, y: 15 },
87
+ watermark: false, # an explicit false IS sent
88
+ label: { width: 4.0, height: 6.0 },
89
+ pdf: { conversion_mode: "IMAGE", page_number: 0 },
90
+ zpl: { commands_to_ignore: ["^PQ"], image_compression: "Z64" },
91
+ data: [{ sku: "1234" }])
92
+ ```
93
+
94
+ Two units are routinely misread and are pinned by the shared fixtures:
95
+
96
+ - `label:` is in **inches**, not dots. Omit it entirely to have the server detect the size.
97
+ - `pdf: { page_number: }` is **0-based**. Omit it to convert every page.
98
+
99
+ `data:` is one record per output label; a single Hash is wrapped rather than rejected. `extra:`
100
+ carries anything the SDK does not model yet — unknown keys are ignored server-side, so it is a
101
+ safe forward-compatibility hatch.
102
+
103
+ A misspelled key raises rather than vanishing. Ruby has no compiler to catch
104
+ `label: { widht: 4 }`, and the server ignores keys it does not recognize, so a silent drop would
105
+ hand you a wrong label and no signal.
106
+
107
+ ## Errors
108
+
109
+ Every non-2xx response becomes a typed error carrying the status, the message, the raw body, and
110
+ the `X-LZ-Request-Id` support handle:
111
+
112
+ ```ruby
113
+ begin
114
+ client.convert(:zpl, :json, zpl)
115
+ rescue LabelZoom::ForbiddenError => e
116
+ warn "paywall" if e.paid_feature?
117
+ rescue LabelZoom::APIError => e
118
+ warn "request #{e.request_id} failed with #{e.status}: #{e.message}"
119
+ end
120
+ ```
121
+
122
+ `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `PayloadTooLargeError`,
123
+ `RateLimitedError` and `ServerError` all descend from `LabelZoom::APIError`, so one `rescue`
124
+ catches the lot.
125
+
126
+ `LabelZoom::ValidationError` deliberately does **not**: it reports a request rejected locally,
127
+ before any network call, which is a bug in the calling code rather than a server response. It
128
+ descends from `ArgumentError`, so rescuing `APIError` to implement a fallback will not swallow it.
129
+
130
+ ## Retries
131
+
132
+ 429s, 5xx responses and transport failures are retried automatically — twice by default, for three
133
+ attempts — with a 1s/2s/4s backoff under full jitter. A `Retry-After` header is honoured on any
134
+ retryable status when it asks for longer than the backoff would wait. No other 4xx is ever retried.
135
+
136
+ ```ruby
137
+ client = LabelZoom::Client.new(max_retries: 0) # disable retrying
138
+ ```
139
+
140
+ ## Testing your own code
141
+
142
+ The sleeper and the environment lookup are both injectable, so a test never sleeps and never picks
143
+ up a developer's real key:
144
+
145
+ ```ruby
146
+ slept = []
147
+ client = LabelZoom::Client.new(
148
+ sleeper: ->(seconds) { slept << seconds },
149
+ jitter: false,
150
+ env: {}
151
+ )
152
+ ```
153
+
154
+ Stub HTTP with [WebMock](https://github.com/bblimke/webmock), which is what this gem's own suite
155
+ uses — it intercepts at the `Net::HTTP` layer, so the real request-construction path is exercised
156
+ rather than bypassed.
157
+
158
+ ## Development
159
+
160
+ ```sh
161
+ bundle install
162
+ bundle exec rake # rubocop + rspec
163
+ ```
164
+
165
+ The spec suite is the shared conformance fixtures in [`../conformance/`](../conformance/) — the
166
+ same cases the .NET, Node, Java, Python, PHP and Go suites run — plus an assertion that it
167
+ executed every one of them.
168
+
169
+ Ruby declares the two `typecheck/*` cases skipped in
170
+ [`../conformance/skips/ruby.json`](../conformance/skips/ruby.json), because Ruby has no compile
171
+ step. `spec/labelzoom/formats_spec.rb` is what makes that skip's stated reason true rather than
172
+ merely convenient: it asserts the runtime guard the skip claims exists.
173
+
174
+ ## License
175
+
176
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+
9
+ module LabelZoom
10
+ # Converts labels through the LabelZoom API.
11
+ #
12
+ # Authentication is optional. Without a credential the API serves a free tier:
13
+ # watermarked output, the first label only, a 1 MB request cap, and no multi-page,
14
+ # JSON-target or image-to-image conversion. Constructing a client with no key is
15
+ # therefore a supported, tested path rather than an error.
16
+ class Client
17
+ # The production API host.
18
+ DEFAULT_BASE_URL = "https://api.labelzoom.com"
19
+
20
+ # The environment variable consulted when no credential is configured.
21
+ API_KEY_ENVIRONMENT_VARIABLE = "LABELZOOM_API_KEY"
22
+
23
+ # The support handle the gateway stamps on every response. Net::HTTP looks headers up
24
+ # case-insensitively, which matters here: the gateway sets X-LZ-Request-Id but CORS
25
+ # exposes it as X-LZ-Request-ID.
26
+ REQUEST_ID_HEADER = "x-lz-request-id"
27
+
28
+ # E2 caps the derived message here. APIError#raw_body keeps the whole body.
29
+ MAX_MESSAGE_LENGTH = 512
30
+
31
+ # Statuses worth retrying. Rule F1: nothing else, ever.
32
+ RETRYABLE_STATUSES = ->(status) { status == 429 || status >= 500 }
33
+
34
+ # E2's last resort, when a body is empty and the reason phrase is too. Net::HTTP does
35
+ # not always surface a phrase, so this is derived from a table rather than the wire.
36
+ REASON_PHRASES = {
37
+ 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found",
38
+ 406 => "Not Acceptable", 413 => "Payload Too Large", 429 => "Too Many Requests",
39
+ 500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable",
40
+ 504 => "Gateway Timeout"
41
+ }.freeze
42
+
43
+ # @param api_key [String, nil, LabelZoom::UNSET] an lz_live_/lz_test_ key or a JWT.
44
+ # Left at +UNSET+ the client reads +LABELZOOM_API_KEY+ from +env+. Passing +nil+ or
45
+ # an empty String forces anonymous mode and suppresses that fallback.
46
+ # @param base_url [String] a path prefix is preserved, so a reverse proxy at
47
+ # https://proxy.example.com/labelzoom works.
48
+ # @param max_retries [Integer] retries after the initial attempt. 0 disables retrying.
49
+ # @param timeout [Numeric] per-attempt read and open timeout, in seconds.
50
+ # @param user_agent_suffix [String, nil] appended to the SDK's own User-Agent.
51
+ # @param sleeper [#call, nil] replaces the delay between retries. Substitute a
52
+ # recording no-op in tests so the retry paths cost no wall-clock time.
53
+ # @param jitter [Boolean] full jitter on the retry backoff. Turn it off for
54
+ # deterministic tests; leave it on in production, where it is what stops a fleet
55
+ # retrying in lockstep.
56
+ # @param env [#[]] the environment lookup. Injecting it keeps a developer's real key
57
+ # out of a test's outcome.
58
+ # @param http_builder [#call, nil] builds the Net::HTTP instance. An escape hatch for
59
+ # proxies and custom TLS; the conformance suite uses WebMock instead.
60
+ def initialize(api_key: UNSET, base_url: DEFAULT_BASE_URL, max_retries: 2, timeout: 60,
61
+ user_agent_suffix: nil, sleeper: nil, jitter: true, env: ENV,
62
+ http_builder: nil)
63
+ raise ArgumentError, "max_retries cannot be negative" if max_retries.negative?
64
+
65
+ # All trailing slashes, not one: a base URL of "https://api.labelzoom.com///" must
66
+ # still produce a single-slash path.
67
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
68
+ @credential = resolve_credential(api_key, env)
69
+ @max_retries = max_retries
70
+ @timeout = timeout
71
+ @sleeper = sleeper || ->(seconds) { Kernel.sleep(seconds) }
72
+ @jitter = jitter
73
+ @http_builder = http_builder
74
+
75
+ # The server parses a "LabelZoomStudio/" User-Agent prefix as a Studio version and
76
+ # silently changes PDF handling for versions <= 1.8.2, so the SDK's own token must
77
+ # come first.
78
+ suffix = user_agent_suffix.to_s.strip
79
+ @user_agent = "labelzoom-ruby-sdk/#{VERSION} (ruby/#{RUBY_VERSION})"
80
+ @user_agent += " #{suffix}" unless suffix.empty?
81
+ end
82
+
83
+ # Whether a credential was resolved. False means requests go out on the anonymous free
84
+ # tier, which is a supported mode rather than an error.
85
+ def authenticated? = !@credential.nil?
86
+
87
+ # Runs one conversion.
88
+ #
89
+ # @param source [Symbol] the format of +body+.
90
+ # @param target [Symbol] the format to produce.
91
+ # @param body [String] the document. For +:url+ it is the URL to fetch, as text.
92
+ # @param base64_text [Boolean] send +body+ as base64 +text/plain+ rather than the
93
+ # source's own media type. Only the binary sources support it.
94
+ # @param options [Hash] conversion parameters, as nested keyword arguments --
95
+ # +label: { width: 4, height: 6 }+ rather than Python's flattened +label_width+.
96
+ # Only what you pass is sent; the SDK never fills in a client-side default, so a
97
+ # change to a server default reaches you without a gem upgrade.
98
+ #
99
+ # +label+ is in INCHES, not dots, and omitting it entirely asks the server to detect
100
+ # the size. +pdf: { page_number: }+ is 0-BASED; omit it to convert every page.
101
+ #
102
+ # @return [ConversionResult]
103
+ # @raise [ValidationError] if the request is rejected locally, before any network call.
104
+ # @raise [APIError] on any non-2xx response.
105
+ def convert(source, target, body, base64_text: false, **options)
106
+ source = Formats.source!(source)
107
+ target = Formats.target!(target)
108
+ body = body.to_s
109
+
110
+ if body.empty?
111
+ # The gateway rejects a zero-length body with 400; catching it here saves a round
112
+ # trip and gives a clearer message.
113
+ raise ValidationError.new("body",
114
+ "Source body cannot be empty; the API rejects zero-length " \
115
+ "requests.")
116
+ end
117
+
118
+ params = Options.serialize(**options)
119
+ uri = build_uri(source, target, params)
120
+ headers = build_headers(source, base64_text)
121
+
122
+ execute(uri, headers, body)
123
+ end
124
+
125
+ # The URL a given conversion would be posted to. Exposed because it is the first thing
126
+ # anyone debugging a proxy or a base-URL override wants to see.
127
+ def build_uri(source, target, params = nil)
128
+ # Concatenated, not resolved: a base URL carrying a path prefix
129
+ # (https://proxy.example.com/labelzoom) must keep it, which URI.join would discard.
130
+ url = "#{@base_url}/api/v2/convert/#{Formats.source_token(source)}/to/" \
131
+ "#{Formats.target_token(target)}"
132
+ # Rule C7: no options means a bare URL, not an empty query string.
133
+ url += "?#{URI.encode_www_form("params" => params)}" unless params.nil?
134
+ URI.parse(url)
135
+ end
136
+
137
+ private
138
+
139
+ def resolve_credential(api_key, env)
140
+ if UNSET.equal?(api_key)
141
+ value = env[API_KEY_ENVIRONMENT_VARIABLE]
142
+ return value.nil? || value.empty? ? nil : value
143
+ end
144
+
145
+ # An explicit nil or "" forces anonymous and must not fall back to the environment.
146
+ api_key.nil? || api_key.empty? ? nil : api_key
147
+ end
148
+
149
+ def build_headers(source, base64_text)
150
+ headers = {
151
+ "Content-Type" => base64_text ? "text/plain" : Formats.media_type(source),
152
+ # Accept must be */*. The server's `produces` list omits image/gif, image/bmp and
153
+ # image/jpeg, so naming the target's exact media type yields a 406 from content
154
+ # negotiation before the handler ever runs.
155
+ "Accept" => "*/*",
156
+ "User-Agent" => @user_agent
157
+ }
158
+ headers["Authorization"] = "Bearer #{@credential}" unless @credential.nil?
159
+ headers
160
+ end
161
+
162
+ def execute(uri, headers, body)
163
+ attempts = @max_retries + 1
164
+
165
+ (1..).each do |attempt|
166
+ begin
167
+ response = perform(uri, headers, body)
168
+ rescue *transport_exceptions => e
169
+ if attempt >= attempts
170
+ raise TransportError, "labelzoom: request to #{uri} failed: #{e.message}"
171
+ end
172
+
173
+ delay(attempt, nil)
174
+ next
175
+ end
176
+
177
+ status = response.code.to_i
178
+ return build_result(response, status) if status.between?(200, 299)
179
+
180
+ retry_after = retry_after_seconds(response)
181
+ if attempt >= attempts || !RETRYABLE_STATUSES.call(status)
182
+ raise error_for(response, status, retry_after)
183
+ end
184
+
185
+ delay(attempt, retry_after)
186
+ end
187
+ end
188
+
189
+ def perform(uri, headers, body)
190
+ http = @http_builder&.call(uri) || Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = uri.scheme == "https"
192
+ http.open_timeout = @timeout
193
+ http.read_timeout = @timeout
194
+
195
+ request = Net::HTTP::Post.new(uri)
196
+ headers.each { |name, value| request[name] = value }
197
+ request.body = body
198
+
199
+ http.start { |session| session.request(request) }
200
+ end
201
+
202
+ def transport_exceptions
203
+ [SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout, IOError,
204
+ OpenSSL::SSL::SSLError]
205
+ end
206
+
207
+ def build_result(response, status)
208
+ ConversionResult.new(
209
+ bytes: (response.body || "").dup.force_encoding(Encoding::BINARY),
210
+ status: status,
211
+ content_type: response["content-type"],
212
+ request_id: response[REQUEST_ID_HEADER]
213
+ )
214
+ end
215
+
216
+ def error_for(response, status, retry_after)
217
+ raw_body = (response.body || "").dup.force_encoding(Encoding::BINARY)
218
+ message = extract_message(raw_body, REASON_PHRASES[status])
219
+ common = { status: status, request_id: response[REQUEST_ID_HEADER], raw_body: raw_body }
220
+
221
+ case status
222
+ when 400 then BadRequestError.new(message, **common)
223
+ when 401 then UnauthorizedError.new(message, **common)
224
+ when 403
225
+ ForbiddenError.new(message, paid_feature: message.match?(/paid feature/i), **common)
226
+ when 404 then NotFoundError.new(message, **common)
227
+ when 413 then PayloadTooLargeError.new(message, **common)
228
+ when 429 then RateLimitedError.new(message, retry_after_seconds: retry_after, **common)
229
+ when 500.. then ServerError.new(message, **common)
230
+ else
231
+ # Anything else non-2xx still becomes a typed error rather than being swallowed --
232
+ # a 406 from content negotiation is the one that actually shows up in practice.
233
+ APIError.new(message, **common)
234
+ end
235
+ end
236
+
237
+ # Both shapes in play put the detail on "message": the gateway returns
238
+ # {"message": "..."} and Spring returns {timestamp, status, error, message, path}.
239
+ # Anything else -- a rate-limit body keyed on "error", an HTML 502, a truncated JSON
240
+ # fragment -- falls through to the raw text, then to the reason phrase.
241
+ def extract_message(raw_body, reason_phrase)
242
+ text = raw_body.dup.force_encoding(Encoding::UTF_8).scrub
243
+ unless text.strip.empty?
244
+ begin
245
+ parsed = JSON.parse(text)
246
+ detail = parsed["message"] if parsed.is_a?(Hash)
247
+ return truncate(detail) if detail.is_a?(String) && !detail.strip.empty?
248
+ rescue JSON::ParserError
249
+ # Not JSON, or malformed. The raw body is still the most useful thing available,
250
+ # and a parse failure must not mask the HTTP error.
251
+ end
252
+ return truncate(text.strip)
253
+ end
254
+
255
+ return reason_phrase if reason_phrase && !reason_phrase.strip.empty?
256
+
257
+ "The LabelZoom API returned an error with no response body."
258
+ end
259
+
260
+ def truncate(value)
261
+ value.length <= MAX_MESSAGE_LENGTH ? value : value[0, MAX_MESSAGE_LENGTH]
262
+ end
263
+
264
+ # Read from the RESPONSE, on any retryable status. Deliberately not read off the typed
265
+ # 429 error: RFC 9110 allows Retry-After on a 503 too, and a 503 asking for 5 seconds
266
+ # must not be retried after a 1-second backoff.
267
+ def retry_after_seconds(response)
268
+ header = response["retry-after"]
269
+ return nil if header.nil? || header.strip.empty?
270
+
271
+ begin
272
+ return Float(header.strip)
273
+ rescue ArgumentError
274
+ # Not a number; try the HTTP-date form below.
275
+ end
276
+
277
+ begin
278
+ [0.0, Time.httpdate(header) - Time.now].max.ceil.to_f
279
+ rescue ArgumentError
280
+ nil
281
+ end
282
+ end
283
+
284
+ # 1s, 2s, 4s with full jitter, overridden by a longer Retry-After.
285
+ def delay(attempt, retry_after)
286
+ backoff = 2.0**(attempt - 1)
287
+ wait = @jitter ? backoff * Kernel.rand : backoff
288
+
289
+ # The server knows better than the backoff curve when it tells us how long to wait.
290
+ wait = retry_after if retry_after && retry_after > wait
291
+
292
+ @sleeper.call(wait)
293
+ end
294
+ end
295
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LabelZoom
4
+ # The outcome of a successful conversion.
5
+ #
6
+ # {#bytes} is authoritative. PDF, PNG, BMP, GIF and JPEG targets are binary, so treating
7
+ # the response as text would silently corrupt five of the eleven targets -- and EPL and
8
+ # TSPL reach the same hazard through a +text/plain+ response, because their +GW+ and
9
+ # +BITMAP+ commands inline a raw 1-bpp payload.
10
+ class ConversionResult
11
+ # @return [String] the response body as binary (ASCII-8BIT), exactly as the server
12
+ # sent it. Note this is a String of bytes, not String#bytes' Array of Integers.
13
+ attr_reader :bytes
14
+
15
+ # @return [String, nil] the response Content-Type header.
16
+ attr_reader :content_type
17
+
18
+ # @return [Integer] the HTTP status code, always 2xx here.
19
+ attr_reader :status
20
+
21
+ # @return [String, nil] the X-LZ-Request-Id response header. The support handle.
22
+ attr_reader :request_id
23
+
24
+ def initialize(bytes:, status:, content_type: nil, request_id: nil)
25
+ @bytes = bytes
26
+ @status = status
27
+ @content_type = content_type
28
+ @request_id = request_id
29
+ end
30
+
31
+ # {#bytes} decoded with the response charset, defaulting to UTF-8.
32
+ #
33
+ # Safe for the textual targets. For a binary target, or an EPL/TSPL label that might
34
+ # carry graphics, read {#bytes} instead.
35
+ #
36
+ # Memoized rather than computed in the constructor: a 5 MB PNG's bytes should never be
37
+ # run through a decoder just because someone built a result object.
38
+ #
39
+ # @return [String]
40
+ def text
41
+ @text ||= decode
42
+ end
43
+
44
+ # Writes {#bytes} to +path+, in binary mode.
45
+ def save(path)
46
+ File.binwrite(path, bytes)
47
+ path
48
+ end
49
+
50
+ def to_s = text
51
+
52
+ private
53
+
54
+ # Not a hardcoded UTF-8 decode: the API serves ISO-8859-1 for some conversions, and a
55
+ # byte like 0xE9 is not valid UTF-8 -- it would come back as U+FFFD rather than "é".
56
+ def decode
57
+ charset = content_type.to_s[/charset\s*=\s*"?([^;"]+)"?/i, 1]&.strip
58
+ source = charset.nil? || charset.empty? ? Encoding::UTF_8 : Encoding.find(charset)
59
+ bytes.dup.force_encoding(source).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
60
+ rescue ArgumentError, Encoding::ConverterNotFoundError
61
+ # An unrecognized charset is not worth failing an otherwise good conversion.
62
+ bytes.dup.force_encoding(Encoding::UTF_8).scrub
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LabelZoom
4
+ # Base class for everything this gem raises on its own behalf.
5
+ class Error < StandardError; end
6
+
7
+ # Base class for every error the LabelZoom API returns.
8
+ #
9
+ # Rescue this to handle them all. Note that {ValidationError} deliberately does *not*
10
+ # descend from it.
11
+ class APIError < Error
12
+ # @return [Integer] the HTTP status code the API returned.
13
+ attr_reader :status
14
+
15
+ # @return [String, nil] the X-LZ-Request-Id response header, if present. Quote this to
16
+ # LabelZoom support -- it identifies the exact request server-side.
17
+ attr_reader :request_id
18
+
19
+ # @return [String] the raw response body, untruncated. {#message} is derived from it
20
+ # and capped at 512 characters; this is not.
21
+ attr_reader :raw_body
22
+
23
+ def initialize(message, status:, request_id: nil, raw_body: "")
24
+ super(message)
25
+ @status = status
26
+ @request_id = request_id
27
+ @raw_body = raw_body
28
+ end
29
+ end
30
+
31
+ # HTTP 400. The request was malformed or the conversion path is invalid.
32
+ class BadRequestError < APIError; end
33
+
34
+ # HTTP 401. The supplied credential was rejected.
35
+ class UnauthorizedError < APIError; end
36
+
37
+ # HTTP 403. The credential is valid but not entitled to this operation.
38
+ class ForbiddenError < APIError
39
+ def initialize(message, paid_feature: false, **kwargs)
40
+ super(message, **kwargs)
41
+ @paid_feature = paid_feature
42
+ end
43
+
44
+ # True when this 403 is a paywall rather than a permissions problem -- "JSON export is
45
+ # a paid feature" and friends. By far the most common anonymous-tier failure, so it
46
+ # gets a predicate instead of leaving callers to match strings.
47
+ def paid_feature? = @paid_feature
48
+ end
49
+
50
+ # HTTP 404. The conversion path does not exist.
51
+ class NotFoundError < APIError; end
52
+
53
+ # HTTP 413. The body exceeded the tier's limit -- 1 MB on the anonymous free tier.
54
+ class PayloadTooLargeError < APIError; end
55
+
56
+ # HTTP 429. Too many requests.
57
+ class RateLimitedError < APIError
58
+ # @return [Float, nil] Retry-After in seconds, when the server sent one. The client
59
+ # already honours this during its own retries; this exposes it for callers doing
60
+ # their own.
61
+ attr_reader :retry_after_seconds
62
+
63
+ def initialize(message, retry_after_seconds: nil, **kwargs)
64
+ super(message, **kwargs)
65
+ @retry_after_seconds = retry_after_seconds
66
+ end
67
+ end
68
+
69
+ # HTTP 5xx. Retried automatically before surfacing.
70
+ class ServerError < APIError; end
71
+
72
+ # A transport-level failure: the request never got a response.
73
+ class TransportError < Error; end
74
+
75
+ # A request rejected locally, before any network call.
76
+ #
77
+ # Deliberately an ArgumentError and *not* an {APIError}: this is a bug in the calling
78
+ # code, not a server response. It carries no status, it is never retried, and a caller
79
+ # rescuing {APIError} to implement fallback behaviour should not swallow it.
80
+ #
81
+ # This is also what makes conformance/skips/ruby.json's stated reason literally true:
82
+ # Ruby has no compile step, so a source-only format passed as a target is caught here.
83
+ class ValidationError < ArgumentError
84
+ # @return [String] the conversion parameter at fault, named as it appears on the wire.
85
+ attr_reader :parameter
86
+
87
+ def initialize(parameter, message)
88
+ super(message)
89
+ @parameter = parameter
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LabelZoom
4
+ # The format metadata table, defined once.
5
+ #
6
+ # Deliberately the only place a format's media type appears. The superseded .NET design
7
+ # had a builder class per format, each independently knowing this mapping, and they
8
+ # drifted -- one of them emitted the source type as the target.
9
+ module Formats
10
+ # Every accepted source, in the contract's order. +:jpg+ is an input spelling that
11
+ # normalizes to +:jpeg+ on the wire; +:url+ tells the server to go fetch a document
12
+ # rather than naming a format.
13
+ SOURCE_FORMATS = %i[zpl epl tspl dpl xml json pdf png bmp gif jpeg jpg url].freeze
14
+
15
+ # Every accepted target. There is no +:url+ -- it is a fetch instruction, not an output
16
+ # format -- and no +:jpg+, which is a source-side spelling only.
17
+ #
18
+ # The printer languages round-trip: +:epl+, +:tspl+ and +:dpl+ became targets in
19
+ # contract 1.1.0, when the printer-language writers shipped.
20
+ TARGET_FORMATS = %i[zpl epl tspl dpl xml json pdf png bmp gif jpeg].freeze
21
+
22
+ MEDIA_TYPES = {
23
+ zpl: "text/plain",
24
+ epl: "text/plain",
25
+ tspl: "text/plain",
26
+ dpl: "text/plain",
27
+ xml: "application/xml",
28
+ json: "application/json",
29
+ pdf: "application/pdf",
30
+ png: "image/png",
31
+ bmp: "image/bmp",
32
+ gif: "image/gif",
33
+ jpeg: "image/jpeg",
34
+ jpg: "image/jpeg",
35
+ # The body is the URL itself.
36
+ url: "text/plain"
37
+ }.freeze
38
+
39
+ # Colour reduction. Server default GRAYSCALE.
40
+ COLOR_MODES = %w[BW GRAYSCALE COLOR].freeze
41
+
42
+ # How a PDF source is interpreted. Server default IMAGE.
43
+ PDF_CONVERSION_MODES = %w[IMAGE NATIVE].freeze
44
+
45
+ # Encoding of images embedded in ZPL output. Server default Z64.
46
+ ZPL_IMAGE_COMPRESSIONS = %w[Z64 COMPRESSED_HEX].freeze
47
+
48
+ module_function
49
+
50
+ # @return [Symbol] the validated source format.
51
+ # @raise [ValidationError] if it is not a source the API accepts.
52
+ def source!(format)
53
+ symbol = format.to_s.downcase.to_sym
54
+ return symbol if SOURCE_FORMATS.include?(symbol)
55
+
56
+ raise ValidationError.new("source",
57
+ "#{format.inspect} is not a source format the LabelZoom API " \
58
+ "accepts. Expected one of: #{SOURCE_FORMATS.join(", ")}.")
59
+ end
60
+
61
+ # @return [Symbol] the validated target format.
62
+ # @raise [ValidationError] if it is not a target the API produces. This is where Ruby
63
+ # enforces what the statically typed SDKs enforce at compile time -- passing +:url+
64
+ # or +:jpg+ as a target is caught here.
65
+ def target!(format)
66
+ symbol = format.to_s.downcase.to_sym
67
+ return symbol if TARGET_FORMATS.include?(symbol)
68
+
69
+ raise ValidationError.new("target",
70
+ "#{format.inspect} is not a target format the LabelZoom API " \
71
+ "produces. Expected one of: #{TARGET_FORMATS.join(", ")}.")
72
+ end
73
+
74
+ # The path segment for a source. +:jpg+ normalizes to "jpeg" (rule A2).
75
+ def source_token(format) = format == :jpg ? "jpeg" : format.to_s
76
+
77
+ # The path segment for a target. Targets need no normalization.
78
+ def target_token(format) = format.to_s
79
+
80
+ # The request Content-Type for a source.
81
+ def media_type(format) = MEDIA_TYPES.fetch(format)
82
+ end
83
+
84
+ SOURCE_FORMATS = Formats::SOURCE_FORMATS
85
+ TARGET_FORMATS = Formats::TARGET_FORMATS
86
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # See lib/labelzoom.rb for the module overview.
6
+ module LabelZoom
7
+ # The sentinel for "the caller did not pass this".
8
+ #
9
+ # Not +nil+, and not a missing key in a +**options+ splat. +watermark: false+ and
10
+ # +pdf: { page_number: 0 }+ are meaningful values a caller sets deliberately, so
11
+ # falsiness cannot stand in for absence -- the same reason the Python SDK carries an
12
+ # +UNSET+ singleton.
13
+ UNSET = Object.new
14
+ def UNSET.inspect = "LabelZoom::UNSET"
15
+ UNSET.freeze
16
+
17
+ # Renders conversion options as the +params+ JSON value.
18
+ #
19
+ # Everything travels in one <tt>?params=&lt;JSON&gt;</tt> parameter, never dot-notation.
20
+ # The API accepts both and merges them, but dot-notation cannot express every parameter
21
+ # -- <tt>?data=[{}]</tt> is rejected with 400 while <tt>?params={"data":[{}]}</tt>
22
+ # succeeds. One serialization path, no special cases.
23
+ module Options
24
+ # Nested option hashes, and the wire key each of their keys maps to. Anything not
25
+ # listed raises rather than being dropped: Ruby has no compiler to catch
26
+ # `label: { widht: 4 }`, and the server ignores unknown keys, so a silent drop would
27
+ # hand the caller a wrong label and no signal.
28
+ NESTED_KEYS = {
29
+ position: { x: "x", y: "y" },
30
+ label: { width: "width", height: "height" },
31
+ pdf: { conversion_mode: "conversionMode", page_number: "pageNumber" },
32
+ zpl: { commands_to_ignore: "commandsToIgnore", image_compression: "imageCompression" }
33
+ }.freeze
34
+
35
+ SCALAR_KEYS = {
36
+ dpi: "dpi",
37
+ rotation: "rotation",
38
+ scaling: "scaling",
39
+ color_mode: "colorMode",
40
+ darkness: "darkness",
41
+ watermark: "watermark",
42
+ dialect: "dialect"
43
+ }.freeze
44
+
45
+ module_function
46
+
47
+ # @return [String, nil] the params JSON, or nil when nothing was set -- in which case
48
+ # no query parameter is emitted at all (rule C7).
49
+ def serialize(**options)
50
+ params = {}
51
+
52
+ SCALAR_KEYS.each do |key, wire_key|
53
+ value = options.fetch(key, UNSET)
54
+ next if UNSET.equal?(value)
55
+
56
+ params[wire_key] = validate_scalar(key, value)
57
+ end
58
+
59
+ NESTED_KEYS.each do |key, mapping|
60
+ value = options.fetch(key, UNSET)
61
+ next if UNSET.equal?(value)
62
+
63
+ params[key.to_s] = nested(key, value, mapping)
64
+ end
65
+
66
+ data = options.fetch(:data, UNSET)
67
+ params["data"] = normalize_data(data) unless UNSET.equal?(data)
68
+
69
+ extra = options.fetch(:extra, UNSET)
70
+ params.merge!(stringify(extra)) unless UNSET.equal?(extra)
71
+
72
+ params.empty? ? nil : JSON.generate(params)
73
+ end
74
+
75
+ def validate_scalar(key, value)
76
+ case key
77
+ when :rotation
78
+ # Rejected locally: the server would 400, and this is unambiguously a caller bug.
79
+ unless value.is_a?(Numeric) && (value % 90).zero?
80
+ raise ValidationError.new("rotation",
81
+ "Rotation must be a multiple of 90 degrees, but was #{value}.")
82
+ end
83
+ when :darkness
84
+ unless value.is_a?(Numeric) && value.between?(0, 100)
85
+ raise ValidationError.new("darkness",
86
+ "Darkness must be between 0 and 100, but was #{value}.")
87
+ end
88
+ end
89
+ value.is_a?(Symbol) ? value.to_s : value
90
+ end
91
+
92
+ def nested(key, value, mapping)
93
+ unless value.is_a?(Hash)
94
+ raise ValidationError.new(key.to_s, "#{key} must be a Hash, but was #{describe(value)}.")
95
+ end
96
+
97
+ value.each_with_object({}) do |(nested_key, nested_value), out|
98
+ wire_key = mapping[nested_key.to_sym]
99
+ unless wire_key
100
+ raise ValidationError.new(key.to_s,
101
+ "#{key}[#{nested_key.inspect}] is not a recognized option. " \
102
+ "Expected one of: #{mapping.keys.join(", ")}.")
103
+ end
104
+ next if UNSET.equal?(nested_value)
105
+
106
+ out[wire_key] = nested_value.is_a?(Symbol) ? nested_value.to_s : nested_value
107
+ end
108
+ end
109
+
110
+ # +data+ is always an array -- one entry produces one label. A caller passing a single
111
+ # record means "one label", so a bare Hash is wrapped rather than rejected.
112
+ def normalize_data(value)
113
+ records = value.is_a?(Array) ? value : [value]
114
+
115
+ records.each_with_index.map do |record, index|
116
+ unless record.is_a?(Hash)
117
+ raise ValidationError.new("data",
118
+ "data[#{index}] is #{describe(record)}; every entry must be " \
119
+ "an object whose keys are the label's variable field names.")
120
+ end
121
+ stringify(record)
122
+ end
123
+ end
124
+
125
+ def stringify(hash)
126
+ unless hash.is_a?(Hash)
127
+ raise ValidationError.new("extra", "extra must be a Hash, but was #{describe(hash)}.")
128
+ end
129
+
130
+ hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
131
+ end
132
+
133
+ def describe(value)
134
+ case value
135
+ when nil then "null"
136
+ when Array then "an array"
137
+ when String then "a string"
138
+ when Numeric then "a number"
139
+ when true, false then "a boolean"
140
+ else "a #{value.class}"
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LabelZoom
4
+ # The gem version. It appears in the User-Agent of every request, so the release
5
+ # workflow asserts that it matches the `ruby/vX.Y.Z` tag being published.
6
+ VERSION = "1.0.0"
7
+ end
data/lib/labelzoom.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "labelzoom/version"
4
+ require_relative "labelzoom/errors"
5
+ require_relative "labelzoom/formats"
6
+ require_relative "labelzoom/options"
7
+ require_relative "labelzoom/conversion_result"
8
+ require_relative "labelzoom/client"
9
+
10
+ # Official Ruby client for the LabelZoom API.
11
+ #
12
+ # LabelZoom converts barcode labels between printer languages (ZPL, EPL, TSPL, DPL),
13
+ # LabelZoom's own XML/JSON model, PDF, and raster images. Almost everything the API does
14
+ # happens at one endpoint:
15
+ #
16
+ # POST https://api.labelzoom.com/api/v2/convert/{sourceFormat}/to/{targetFormat}
17
+ #
18
+ # client = LabelZoom::Client.new
19
+ # result = client.convert(:zpl, :png, "^XA^FO20,20^A0N,28^FDhello^FS^XZ",
20
+ # label: { width: 4, height: 6 })
21
+ # result.save("label.png")
22
+ #
23
+ # The behaviour of every LabelZoom SDK is specified in docs/API_CONTRACT.md and checked by
24
+ # the shared fixtures in conformance/, which this gem's spec suite executes.
25
+ module LabelZoom
26
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: labelzoom
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - RJF Technology Solutions LLC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Converts barcode labels between ZPL, EPL, TSPL, DPL, PDF, LabelZoom XML/JSON,
13
+ and raster images via the LabelZoom API.
14
+ email:
15
+ - support@labelzoom.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/labelzoom.rb
23
+ - lib/labelzoom/client.rb
24
+ - lib/labelzoom/conversion_result.rb
25
+ - lib/labelzoom/errors.rb
26
+ - lib/labelzoom/formats.rb
27
+ - lib/labelzoom/options.rb
28
+ - lib/labelzoom/version.rb
29
+ homepage: https://www.labelzoom.com
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://www.labelzoom.com
34
+ source_code_uri: https://github.com/labelzoom/labelzoom-sdk
35
+ documentation_uri: https://docs.labelzoom.com
36
+ bug_tracker_uri: https://github.com/labelzoom/labelzoom-sdk/issues
37
+ changelog_uri: https://github.com/labelzoom/labelzoom-sdk/releases
38
+ rubygems_mfa_required: 'true'
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.1'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.6.9
54
+ specification_version: 4
55
+ summary: Official Ruby client for the LabelZoom label conversion API.
56
+ test_files: []