qrdot 0.1.1

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: '0982551dc56a50a959a76a8d326ad0c41e2fffb11e3f8126dced5f4b946058a7'
4
+ data.tar.gz: 91403796803dcb2102418b6e391372dc500dea7b45e142374d53c3df9381d1ef
5
+ SHA512:
6
+ metadata.gz: 8f3417cab48970b32af8a7ca837a5b316c6f28eead703de0a70aa58fcbd530438b645393c7ff8a0c59ec77f6c4bf9c8e41a4ead287b07019c130cb06063a6fdb
7
+ data.tar.gz: 1d61e77849757de5ec29e7d674894c275f287ff3b99b01360667d805d9437d905be4ab2e216f02ec3e53e75f4ce054d6305087d781e6ac7534d07003813a4289
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QR. / intuitix
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,32 @@
1
+ # qrdot (Ruby)
2
+
3
+ Official Ruby client for the [QR.](https://qrdot.dev) API.
4
+
5
+ ```bash
6
+ gem install qrdot
7
+ # or: bundle add qrdot
8
+ ```
9
+
10
+ ## Quick start
11
+
12
+ ```ruby
13
+ require "qrdot"
14
+
15
+ client = Qrdot::Client.new(ENV.fetch("QRDOT_API_KEY"))
16
+
17
+ qr = client.qr.create({
18
+ "targetUrl" => "https://example.com",
19
+ "name" => "Launch",
20
+ "style" => { "dark_color" => "#0F766E", "light_color" => "#FFFFFF" },
21
+ })
22
+
23
+ png, mime = client.qr.image(qr["id"], "png")
24
+ zip, = client.qr.export_images([qr["id"]], "png")
25
+
26
+ ok = Qrdot.verify_webhook_signature(secret, raw_body, request.get_header("HTTP_X_QRDOT_SIGNATURE"))
27
+ ```
28
+
29
+ ## Docs
30
+
31
+ - Guides: https://qrdot.dev/docs/
32
+ - OpenAPI: https://api.qrdot.dev/v1/openapi.json
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qrdot
4
+ class Client
5
+ attr_reader :qr, :assets, :analytics, :webhooks, :domains
6
+
7
+ def initialize(api_key, base_url: "https://api.qrdot.dev")
8
+ unless api_key.start_with?("sk_test_", "sk_live_")
9
+ raise ArgumentError, "api_key must start with sk_test_ or sk_live_"
10
+ end
11
+
12
+ @api_key = api_key
13
+ @base_url = base_url.sub(%r{/\z}, "")
14
+ @qr = QrResource.new(self)
15
+ @assets = AssetsResource.new(self)
16
+ @analytics = AnalyticsResource.new(self)
17
+ @webhooks = WebhooksResource.new(self)
18
+ @domains = DomainsResource.new(self)
19
+ end
20
+
21
+ # Convenience — same as qr.create
22
+ def create_qr(payload, idempotency_key: nil)
23
+ qr.create(payload, idempotency_key: idempotency_key)
24
+ end
25
+
26
+ def request(method, path, body: nil, headers: {})
27
+ data, status, = raw(method, path, body: body, headers: headers)
28
+ return nil if status == 204 || data.nil? || data.empty?
29
+
30
+ JSON.parse(data)
31
+ end
32
+
33
+ def request_bytes(method, path, body: nil)
34
+ data, _status, ct = raw(method, path, body: body, headers: {})
35
+ mime = (ct || "application/octet-stream").split(";").first.strip
36
+ [data, mime]
37
+ end
38
+
39
+ def put_external(url, body, headers)
40
+ uri = URI(url)
41
+ http = Net::HTTP.new(uri.host, uri.port)
42
+ http.use_ssl = uri.scheme == "https"
43
+ req = Net::HTTP::Put.new(uri)
44
+ headers.each { |k, v| req[k] = v }
45
+ req.body = body
46
+ res = http.request(req)
47
+ res.code.to_i
48
+ end
49
+
50
+ def raw(method, path, body:, headers:)
51
+ uri = URI("#{@base_url}#{path}")
52
+ http = Net::HTTP.new(uri.host, uri.port)
53
+ http.use_ssl = uri.scheme == "https"
54
+ http.open_timeout = 30
55
+ http.read_timeout = 60
56
+
57
+ req = Net::HTTPGenericRequest.new(
58
+ method,
59
+ !body.nil?,
60
+ true,
61
+ uri.request_uri
62
+ )
63
+ req["Authorization"] = "Bearer #{@api_key}"
64
+ req["Accept"] = "application/json"
65
+ headers.each { |k, v| req[k] = v }
66
+ if body
67
+ req["Content-Type"] = "application/json"
68
+ req.body = JSON.generate(body)
69
+ end
70
+
71
+ res = http.request(req)
72
+ status = res.code.to_i
73
+ if status < 200 || status >= 300
74
+ code = "internal"
75
+ message = res.message
76
+ begin
77
+ parsed = JSON.parse(res.body.to_s)
78
+ code = parsed.dig("error", "code") || code
79
+ message = parsed.dig("error", "message") || message
80
+ rescue JSON::ParserError
81
+ # ignore
82
+ end
83
+ raise ApiError.new(code, message, status)
84
+ end
85
+ [res.body.to_s, status, res["content-type"]]
86
+ end
87
+ end
88
+
89
+ class QrResource
90
+ def initialize(client)
91
+ @c = client
92
+ end
93
+
94
+ def create(payload, idempotency_key: nil)
95
+ headers = {}
96
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
97
+ @c.request("POST", "/v1/qr", body: payload, headers: headers)
98
+ end
99
+
100
+ def list(query = {})
101
+ qs = query.empty? ? "" : "?#{URI.encode_www_form(query)}"
102
+ @c.request("GET", "/v1/qr#{qs}")
103
+ end
104
+
105
+ def batch(items, idempotency_key: nil)
106
+ headers = {}
107
+ headers["Idempotency-Key"] = idempotency_key if idempotency_key
108
+ @c.request("POST", "/v1/qr/batch", body: { items: items }, headers: headers)
109
+ end
110
+
111
+ def get(id)
112
+ @c.request("GET", "/v1/qr/#{URI.encode_www_form_component(id)}")
113
+ end
114
+
115
+ def update(id, payload)
116
+ @c.request("PATCH", "/v1/qr/#{URI.encode_www_form_component(id)}", body: payload)
117
+ end
118
+
119
+ def delete(id)
120
+ @c.request("DELETE", "/v1/qr/#{URI.encode_www_form_component(id)}")
121
+ end
122
+
123
+ def duplicate(id)
124
+ @c.request("POST", "/v1/qr/#{URI.encode_www_form_component(id)}/duplicate")
125
+ end
126
+
127
+ def image(id, format = "png")
128
+ @c.request_bytes("GET", "/v1/qr/#{URI.encode_www_form_component(id)}/image.#{format}")
129
+ end
130
+
131
+ def export_images(ids, format = "png")
132
+ @c.request_bytes("POST", "/v1/qr/export/images", body: { ids: ids, format: format })
133
+ end
134
+ end
135
+
136
+ class AssetsResource
137
+ def initialize(client)
138
+ @c = client
139
+ end
140
+
141
+ def presign_logo(content_type, filename: nil)
142
+ body = { content_type: content_type }
143
+ body[:filename] = filename if filename
144
+ @c.request("POST", "/v1/assets/logo/presign", body: body)
145
+ end
146
+
147
+ def complete_logo(id, filename: nil)
148
+ complete = {}
149
+ complete[:filename] = filename if filename
150
+ @c.request(
151
+ "POST",
152
+ "/v1/assets/logo/#{URI.encode_www_form_component(id)}/complete",
153
+ body: complete
154
+ )
155
+ end
156
+
157
+ def list_logos
158
+ @c.request("GET", "/v1/assets/logo")
159
+ end
160
+
161
+ def get_logo(id)
162
+ @c.request("GET", "/v1/assets/logo/#{URI.encode_www_form_component(id)}")
163
+ end
164
+
165
+ def delete_logo(id)
166
+ @c.request("DELETE", "/v1/assets/logo/#{URI.encode_www_form_component(id)}")
167
+ end
168
+
169
+ def upload_logo(bytes, content_type, filename: nil)
170
+ presign = presign_logo(content_type, filename: filename)
171
+ status = @c.put_external(presign["upload_url"], bytes, presign["headers"] || {})
172
+ if status < 200 || status >= 300
173
+ raise ApiError.new("internal", "Logo storage upload failed (#{status})", status)
174
+ end
175
+ complete_logo(presign["asset_id"], filename: filename)
176
+ end
177
+ end
178
+
179
+ class AnalyticsResource
180
+ def initialize(client)
181
+ @c = client
182
+ end
183
+
184
+ def summary(query = {})
185
+ qs = query.empty? ? "" : "?#{URI.encode_www_form(query)}"
186
+ @c.request("GET", "/v1/analytics/summary#{qs}")
187
+ end
188
+
189
+ def qr(id, query = {})
190
+ qs = query.empty? ? "" : "?#{URI.encode_www_form(query)}"
191
+ @c.request("GET", "/v1/analytics/qr/#{URI.encode_www_form_component(id)}#{qs}")
192
+ end
193
+
194
+ def scans(id, query = {})
195
+ qs = query.empty? ? "" : "?#{URI.encode_www_form(query)}"
196
+ @c.request("GET", "/v1/analytics/qr/#{URI.encode_www_form_component(id)}/scans#{qs}")
197
+ end
198
+ end
199
+
200
+ class WebhooksResource
201
+ def initialize(client)
202
+ @c = client
203
+ end
204
+
205
+ def create(payload)
206
+ payload = payload.dup
207
+ payload[:events] ||= payload["events"] || ["qr.scanned"]
208
+ @c.request("POST", "/v1/webhooks", body: payload)
209
+ end
210
+
211
+ def list
212
+ @c.request("GET", "/v1/webhooks")
213
+ end
214
+
215
+ def update(id, patch)
216
+ @c.request("PATCH", "/v1/webhooks/#{URI.encode_www_form_component(id)}", body: patch)
217
+ end
218
+
219
+ def delete(id)
220
+ @c.request("DELETE", "/v1/webhooks/#{URI.encode_www_form_component(id)}")
221
+ end
222
+
223
+ def test(id, payload = {})
224
+ @c.request("POST", "/v1/webhooks/#{URI.encode_www_form_component(id)}/test", body: payload)
225
+ end
226
+
227
+ def list_deliveries(id, limit: nil)
228
+ qs = limit.nil? ? "" : "?limit=#{URI.encode_www_form_component(limit.to_s)}"
229
+ @c.request("GET", "/v1/webhooks/#{URI.encode_www_form_component(id)}/deliveries#{qs}")
230
+ end
231
+
232
+ def replay(id, payload)
233
+ @c.request("POST", "/v1/webhooks/#{URI.encode_www_form_component(id)}/replay", body: payload)
234
+ end
235
+
236
+ def verify_signature(secret, raw_body, header, tolerance_sec: 300)
237
+ Qrdot.verify_webhook_signature(secret, raw_body, header, tolerance_sec: tolerance_sec)
238
+ end
239
+ end
240
+
241
+ class DomainsResource
242
+ def initialize(client)
243
+ @c = client
244
+ end
245
+
246
+ def create(payload)
247
+ @c.request("POST", "/v1/domains", body: payload)
248
+ end
249
+
250
+ def list
251
+ @c.request("GET", "/v1/domains")
252
+ end
253
+
254
+ def get(id)
255
+ @c.request("GET", "/v1/domains/#{URI.encode_www_form_component(id)}")
256
+ end
257
+
258
+ def dns(id)
259
+ @c.request("GET", "/v1/domains/#{URI.encode_www_form_component(id)}/dns")
260
+ end
261
+
262
+ def dns_provider(id)
263
+ @c.request("GET", "/v1/domains/#{URI.encode_www_form_component(id)}/dns-provider")
264
+ end
265
+
266
+ def domain_connect_start(id)
267
+ @c.request("POST", "/v1/domains/#{URI.encode_www_form_component(id)}/domain-connect/start")
268
+ end
269
+
270
+ def forward_dns(id, payload)
271
+ @c.request("POST", "/v1/domains/#{URI.encode_www_form_component(id)}/dns/forward", body: payload)
272
+ end
273
+
274
+ def refresh(id)
275
+ @c.request("POST", "/v1/domains/#{URI.encode_www_form_component(id)}/refresh")
276
+ end
277
+
278
+ def set_default(id)
279
+ @c.request("POST", "/v1/domains/#{URI.encode_www_form_component(id)}/default")
280
+ end
281
+
282
+ def delete(id)
283
+ @c.request("DELETE", "/v1/domains/#{URI.encode_www_form_component(id)}")
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Qrdot
4
+ VERSION = "0.1.1"
5
+ end
data/lib/qrdot.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+ require_relative "qrdot/version"
8
+ require_relative "qrdot/client"
9
+
10
+ module Qrdot
11
+ class Error < StandardError; end
12
+
13
+ class ApiError < Error
14
+ attr_reader :code, :status
15
+
16
+ def initialize(code, message, status)
17
+ super(message)
18
+ @code = code
19
+ @status = status
20
+ end
21
+ end
22
+
23
+ # Verify X-Qrdot-Signature: t=…,v1=… over "#{t}.#{raw_body}"
24
+ def self.verify_webhook_signature(secret, raw_body, header, tolerance_sec: 300)
25
+ parts = {}
26
+ header.to_s.split(",").each do |piece|
27
+ k, v = piece.strip.split("=", 2)
28
+ parts[k] = v if k && v
29
+ end
30
+ t = Integer(parts["t"], exception: false)
31
+ v1 = parts["v1"]
32
+ return false if t.nil? || t.zero? || v1.nil? || v1.empty?
33
+ return false if (Time.now.to_i - t).abs > tolerance_sec
34
+
35
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
36
+ return false if expected.bytesize != v1.bytesize
37
+
38
+ res = 0
39
+ expected.bytes.zip(v1.bytes) { |a, b| res |= a ^ b }
40
+ res.zero?
41
+ end
42
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: qrdot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - QR.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ description: Thin REST client for qrdot.dev — create dynamic QR codes, images, logos,
28
+ webhooks, domains.
29
+ email:
30
+ - hello@qrdot.dev
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE
36
+ - README.md
37
+ - lib/qrdot.rb
38
+ - lib/qrdot/client.rb
39
+ - lib/qrdot/version.rb
40
+ homepage: https://qrdot.dev/libraries/
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ source_code_uri: https://github.com/intuitix/qrdot-clients/tree/main/ruby
45
+ changelog_uri: https://github.com/intuitix/qrdot-clients/releases
46
+ bug_tracker_uri: https://github.com/intuitix/qrdot-clients/issues
47
+ documentation_uri: https://qrdot.dev/docs/
48
+ rubygems_mfa_required: 'true'
49
+ post_install_message:
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 3.1.0
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 3.5.3
65
+ signing_key:
66
+ specification_version: 4
67
+ summary: Official Ruby client for the QR. API
68
+ test_files: []