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 +7 -0
- data/LICENSE +21 -0
- data/README.md +176 -0
- data/lib/labelzoom/client.rb +295 -0
- data/lib/labelzoom/conversion_result.rb +65 -0
- data/lib/labelzoom/errors.rb +92 -0
- data/lib/labelzoom/formats.rb +86 -0
- data/lib/labelzoom/options.rb +144 -0
- data/lib/labelzoom/version.rb +7 -0
- data/lib/labelzoom.rb +26 -0
- metadata +56 -0
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
|
+

|
|
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=<JSON></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
|
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: []
|