nohead 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b3b6ddca7dac0c2e2e1056aabba232a26b27fce7eae7eea1244519a88d0388fe
4
+ data.tar.gz: e9c0c92435a093999388ef2a84c73870c12dead425ada3c3bdf691c9fe993069
5
+ SHA512:
6
+ metadata.gz: 3ab6ef2e8d3d027b07a2ff536cf3f14833970b4d80cbc51fcf2b65a3a10684012a4a1e70088b426e0c77a55927a21f2816d13991c869321bbdff84d705d30f8c
7
+ data.tar.gz: 2c73018868bc99808615f989f735149122b655705eac1f0af62a9bccfab465582857bbad1fb91a5f635230f2af272404f831d20e05f2387295ada5b901b0798a
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ Changes to the `nohead` gem that you can notice. Versions follow
4
+ [Semantic Versioning](https://semver.org): additive API changes are minor
5
+ releases; a change that could break your code is a major one. Each release's
6
+ section is its GitHub release's notes.
7
+
8
+ ## 0.1.0
9
+
10
+ The first release.
11
+
12
+ - `Nohead::Client`, with methods for every operation an API key can call:
13
+ records (with revisions, scheduling, bulk changes and search),
14
+ collections, fields and migrations, assets, webhooks and their deliveries,
15
+ the audit log, feature flags.
16
+ - Results you read with methods or `[]`, pages that are Enumerable across
17
+ every page, timestamps as Times.
18
+ - Typed errors per API error type, retries with idempotency keys,
19
+ `if_match` and change notes.
20
+ - `assets.upload` in one call (paths or IO), and webhook verification
21
+ (`Nohead::Webhooks.unwrap`). No runtime dependencies.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nohead
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,298 @@
1
+ # Nohead Ruby SDK
2
+
3
+ The official Ruby client for the [Nohead](https://nohead.io) API: pages you can enumerate, retries that are safe for writes, one-call uploads and webhook verification. No runtime dependencies.
4
+
5
+ ```ruby
6
+ require "nohead"
7
+
8
+ nohead = Nohead::Client.new # reads NOHEAD_API_KEY
9
+
10
+ nohead.records.list("posts", filter: { status: "published" }).each do |post|
11
+ puts post.data["title"]
12
+ end
13
+ ```
14
+
15
+ > **Status:** 0.x, not yet published to RubyGems. Until it is, use it from GitHub: `gem "nohead", github: "nohead-io/nohead-ruby"`.
16
+
17
+ ## Contents
18
+
19
+ - [Installation](#installation)
20
+ - [Configuration](#configuration)
21
+ - [Records](#records)
22
+ - [Pagination](#pagination)
23
+ - [Errors](#errors)
24
+ - [Retries and idempotency](#retries-and-idempotency)
25
+ - [Concurrency](#concurrency)
26
+ - [Assets](#assets)
27
+ - [Search](#search)
28
+ - [Schema](#schema)
29
+ - [Webhooks](#webhooks)
30
+ - [Results](#results)
31
+ - [Reference](#reference)
32
+ - [Development](#development)
33
+ - [Releasing](#releasing)
34
+
35
+ ## Installation
36
+
37
+ ```ruby
38
+ gem "nohead"
39
+ ```
40
+
41
+ It needs Ruby 3.3 or newer. It uses only Ruby's standard library (Net::HTTP, JSON, OpenSSL). Use it on servers: API keys are secrets.
42
+
43
+ ## Configuration
44
+
45
+ ```ruby
46
+ nohead = Nohead::Client.new(
47
+ api_key: ENV.fetch("NOHEAD_API_KEY"), # default: NOHEAD_API_KEY
48
+ base_url: "https://api.nohead.io" # default: NOHEAD_API_URL, else production
49
+ )
50
+ ```
51
+
52
+ | Option | Default | |
53
+ |---|---|---|
54
+ | `api_key:` | `NOHEAD_API_KEY` | A project API key (`sk_live_...`). Required. |
55
+ | `base_url:` | `NOHEAD_API_URL`, else `https://api.nohead.io` | |
56
+ | `project_id:` | the key's project | Looked up once with `GET /v1/me` when omitted. |
57
+ | `max_retries:` | `2` | See [retries](#retries-and-idempotency). |
58
+ | `timeout:` | `60` | Seconds per attempt. |
59
+ | `headers:` | `{}` | Added to every request. |
60
+ | `warnings:` | `true` | Warns about deprecated operations and plan usage, once each. |
61
+
62
+ API keys belong to a project, so methods like `collections.list` need no project ID. Collections can be named by ID or slug everywhere.
63
+
64
+ ## Records
65
+
66
+ ```ruby
67
+ draft = nohead.records.create("posts", data: { title: "Hello", author: "rec_01J9..." })
68
+ post = nohead.records.get(draft.id, expand: ["author"])
69
+ nohead.records.update(post.id, data: { title: "Hello again" }) # nil clears a field
70
+ nohead.records.publish(post.id)
71
+ nohead.records.schedule(post.id, unpublish_at: Time.utc(2027, 1, 1))
72
+ nohead.records.delete(post.id) # soft delete; records.restore undoes it
73
+ ```
74
+
75
+ Methods return the API's resources (see [Results](#results)) and raise on failure. Field values are under `data`, keyed by field API key.
76
+
77
+ **More:**
78
+
79
+ - `count`, and `bulk` (up to 100 records at once)
80
+ - `diff(record, from_revision, to_revision)`
81
+ - `revisions.list`, `revisions.get` and `revisions.revert` (with `dry_run: true` for a preview)
82
+
83
+ ## Pagination
84
+
85
+ List methods return the first page, a `Nohead::Page`. It's Enumerable: `each`, `map`, `first` and so on walk every item across pages, fetching the next page only when needed.
86
+
87
+ ```ruby
88
+ # Every record
89
+ nohead.records.list("posts").each { |record| ... }
90
+ nohead.records.list("posts").first(10) # fetches only what it needs
91
+
92
+ # One page at a time
93
+ page = nohead.records.list("posts", limit: 100)
94
+ page.data # this page's records
95
+ page.meta.next_cursor
96
+ page = page.next_page while page.next_page?
97
+
98
+ # Resume from a saved cursor
99
+ nohead.records.list("posts", cursor: saved_cursor)
100
+ ```
101
+
102
+ Filters are equality filters (for fields with several values: "contains"), and accept strings, numbers, booleans and Times:
103
+
104
+ ```ruby
105
+ nohead.records.list("posts", filter: { status: "published", featured: true, author: "rec_01J9..." },
106
+ sort: "-published_at", expand: %w[author tags])
107
+ ```
108
+
109
+ ## Errors
110
+
111
+ Every error is a `Nohead::Error`. API errors are `Nohead::APIError`s with `status`, `type`, `message`, `request_id`, `details` and `headers`, in a class per type:
112
+
113
+ | Class | Status |
114
+ |---|---|
115
+ | `InvalidRequestError` | 400 |
116
+ | `AuthenticationError` | 401 |
117
+ | `PlanLimitExceededError` | 402 |
118
+ | `AuthorizationError` | 403 |
119
+ | `NotFoundError` | 404 |
120
+ | `ConflictError` | 409 |
121
+ | `PreconditionFailedError` | 412 (`current_revision`) |
122
+ | `ValidationError` | 422 |
123
+ | `RateLimitError` | 429 (`retry_after`) |
124
+ | `InternalServerError` | 500 and other 5xx |
125
+ | `ServiceUnavailableError` | 503 |
126
+
127
+ Other errors:
128
+
129
+ - `Nohead::ConnectionError`, and `Nohead::TimeoutError`, which is a kind of `ConnectionError`
130
+ - `Nohead::UploadError`
131
+ - `Nohead::WebhookVerificationError`
132
+
133
+ ```ruby
134
+ begin
135
+ nohead.records.create("posts", data: {})
136
+ rescue Nohead::ValidationError => e
137
+ e.details.each { |detail| puts "#{detail.field}: #{detail.message}" }
138
+ end
139
+ ```
140
+
141
+ ## Retries and idempotency
142
+
143
+ Failed requests are retried twice by default (`max_retries:`), with exponential backoff:
144
+
145
+ - what's retried: connection errors, timeouts, 429, 500, 502, 503, 504, and a 409 for a request that is still running
146
+ - `Retry-After` is honored up to 60 seconds; a longer one raises `RateLimitError` straight away
147
+
148
+ Every write gets an `Idempotency-Key` that stays the same across its retries, so a retry after a lost response never writes twice. To make a write safe across your own retries (a job that may run twice), pass a key:
149
+
150
+ ```ruby
151
+ nohead.records.create("posts", data: data, idempotency_key: "import-#{row.id}")
152
+ ```
153
+
154
+ Writes also take `change_note:`, a reason shown in history.
155
+
156
+ ## Concurrency
157
+
158
+ Pass the revision you read to make sure nobody changed the record since:
159
+
160
+ ```ruby
161
+ post = nohead.records.get(id)
162
+ begin
163
+ nohead.records.update(id, data: { title: title }, if_match: post)
164
+ rescue Nohead::PreconditionFailedError => e
165
+ # changed since (now at e.current_revision): reload, and merge or ask
166
+ end
167
+ ```
168
+
169
+ `if_match:` takes a record or a revision number, on `update`, `delete`, `publish`, `unpublish` and `revisions.revert`.
170
+
171
+ ## Assets
172
+
173
+ ```ruby
174
+ asset = nohead.assets.upload("cover.jpg")
175
+ nohead.records.update(id, data: { cover: asset.id })
176
+
177
+ url = nohead.assets.image_url(asset.id, width: 1200, format: "webp").url
178
+ ```
179
+
180
+ **What `upload` accepts:** a path (String or Pathname), or an IO opened in binary mode. For bytes in memory, pass `StringIO.new(bytes)`.
181
+
182
+ **What it does:**
183
+
184
+ 1. Creates the upload.
185
+ 2. Streams the bytes straight to storage.
186
+ 3. Completes the upload, which checks the file, and returns the `ready` asset.
187
+
188
+ **Errors:** `UploadError` if storage refuses the bytes; `ValidationError` if the file fails the checks.
189
+
190
+ **Uploading from a browser:** create the upload on your server with `create_upload`, `PUT` the file from the browser, then `complete` it.
191
+
192
+ ## Search
193
+
194
+ ```ruby
195
+ # One collection
196
+ nohead.records.search("posts", "content model").each { |hit| ... }
197
+
198
+ # Across the project
199
+ results = nohead.search("content model", collections: %w[posts pages])
200
+ results.meta.total_estimate
201
+ ```
202
+
203
+ Search needs `search_enabled` collections and the `search:read` scope. It pages through the first 1,000 hits.
204
+
205
+ ## Schema
206
+
207
+ ```ruby
208
+ nohead.collections.create(name: "Posts", slug: "posts",
209
+ fields: [{ name: "Title", api_key: "title", type: "text", required: true }])
210
+ nohead.fields.create("posts", name: "Summary", api_key: "summary", type: "long_text")
211
+
212
+ # Changes that rewrite records go through a migration; preview first
213
+ preview = nohead.fields.migrate("fld_...", type: "long_text", dry_run: true)
214
+ migration = nohead.fields.migrate("fld_...", type: "long_text")
215
+ nohead.migrations.get(migration.id)
216
+ ```
217
+
218
+ For schema as code, see the `nohead` CLI (`nohead schema pull/diff/push`).
219
+
220
+ ## Webhooks
221
+
222
+ Verify a webhook request, then use its event:
223
+
224
+ ```ruby
225
+ # e.g. in a Rails controller
226
+ event = Nohead::Webhooks.unwrap(request.raw_post, request.headers,
227
+ secret: ENV.fetch("NOHEAD_WEBHOOK_SECRET"))
228
+ if event.type == "record.published"
229
+ RebuildJob.perform_later(event.data.record.collection)
230
+ end
231
+ head :no_content
232
+ ```
233
+
234
+ `unwrap` checks the signature and the timestamp (Standard Webhooks), and raises `Nohead::WebhookVerificationError` if either is off. Pass the raw body: parsing and re-serializing JSON changes the bytes.
235
+
236
+ It's also available as `nohead.webhooks.unwrap` on a client. Events can arrive more than once, so deduplicate by the `webhook-id` header.
237
+
238
+ ## Results
239
+
240
+ The API's resources come back as `Nohead::NoheadObject`s.
241
+
242
+ - **Reading fields:** use methods (`record.data`) or `[]` (`record[:data]`, `record["data"]`), at any depth.
243
+ - **Fields named like Ruby's own methods** (`method`, `hash`…) need `[]`.
244
+ - **Timestamps** (`*_at`) are `Time`s.
245
+ - **Unknown fields are kept**, because the API adds fields and values without notice.
246
+ - **`to_h`** gives the raw Hash.
247
+
248
+ ## Reference
249
+
250
+ | Resource | Methods |
251
+ |---|---|
252
+ | `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `schedule`, `unschedule`, `count`, `bulk`, `diff`, `search` |
253
+ | `records.revisions` | `list`, `get`, `revert` |
254
+ | `search` | across the project |
255
+ | `collections` | `list`, `get`, `create`, `update`, `delete`, `restore`, `schema` |
256
+ | `collections.schema_changes` | `list`, `get` |
257
+ | `collections.search_index` | `get`, `rebuild` |
258
+ | `fields` | `list`, `create`, `update`, `delete`, `restore`, `reorder`, `remove_alias`, `migrate` |
259
+ | `migrations` | `list`, `get`, `cancel` |
260
+ | `assets` | `upload`, `create_upload`, `complete`, `list`, `get`, `delete`, `restore`, `image_url`, `download_url` |
261
+ | `webhooks` | `list`, `get`, `create`, `update`, `delete`, `rotate_secret`, `test`, `unwrap` |
262
+ | `webhooks.deliveries` | `list`, `get`, `retry` |
263
+ | `audit_events` | `list` |
264
+ | `feature_flags` | `list` |
265
+ | `me` | `get` |
266
+ | `health` | `check` |
267
+
268
+ The SDK covers every operation an API key can call. Organizations, projects, members and API keys are managed in the web app. The full API is documented at [docs.nohead.io](https://docs.nohead.io).
269
+
270
+ ## Development
271
+
272
+ ```bash
273
+ bundle install
274
+ bundle exec rake test # unit and contract tests
275
+ bundle exec rubocop
276
+ bundle exec rake generate # after updating openapi.json
277
+ bundle exec rake samples # after changing test/calls.rb (the docs' code samples)
278
+ ```
279
+
280
+ **How the code is organized:**
281
+
282
+ - `openapi.json` is the API's published contract. `rake generate` derives the operation table, `lib/nohead/operations.rb`, from it.
283
+ - The methods are written by hand.
284
+ - `test/contract_test.rb` runs every call in `test/calls.rb`. It fails when an API-key operation in the contract has no method, or when a request doesn't match its operation.
285
+
286
+ The smoke test (`smoke/smoke.rb`) runs the core flow against a real API, with the gem as installed from its built package. Nohead's own CI runs it on every API contract change.
287
+
288
+ ## Releasing
289
+
290
+ 1. Bump `lib/nohead/version.rb`.
291
+ 2. Add a section for the version to `CHANGELOG.md` (`## 1.2.3`), which becomes the release's notes.
292
+ 3. Merge to `main`. Its ruleset requires the **CI passed** check, so the commit goes through a pull request or a branch whose CI passed, and force pushes are refused.
293
+ 4. Run the **SDK release** workflow in the Nohead API repository. It runs this commit's smoke test against the API and pushes the tag `v1.2.3`. Nobody else can push `v*` tags: a tag ruleset lets only that workflow's deploy key through.
294
+ 5. The tag starts `.github/workflows/release.yml`. Its publishing job runs in the `release` environment, which only `v*` tags can use, and the registry's trusted publisher accepts only that environment. It checks the version and its notes, tests, and pushes the gem to RubyGems through trusted publishing (no API key, with an attestation). Then it creates the GitHub release.
295
+
296
+ ## License
297
+
298
+ MIT
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Nohead
9
+ # A client for the Nohead API, authenticated with a project API key.
10
+ #
11
+ # nohead = Nohead::Client.new # NOHEAD_API_KEY
12
+ # nohead.records.list("posts").each { |post| puts post.data["title"] }
13
+ class Client
14
+ DEFAULT_BASE_URL = "https://api.nohead.io"
15
+ RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
16
+ MAX_RETRY_AFTER = 60
17
+
18
+ attr_reader :records, :collections, :fields, :migrations, :assets, :webhooks,
19
+ :audit_events, :feature_flags, :me, :health
20
+
21
+ # api_key:: A project API key ("sk_live_..."). Defaults to NOHEAD_API_KEY.
22
+ # base_url:: Defaults to NOHEAD_API_URL, else https://api.nohead.io.
23
+ # project_id:: The key's project. Looked up once with GET /v1/me when omitted.
24
+ # max_retries:: Retries of failed requests (see the README, "Retries").
25
+ # timeout:: Seconds per attempt.
26
+ # headers:: Headers added to every request.
27
+ # warnings:: Warn about deprecated operations and plan usage (once each).
28
+ def initialize(api_key: nil, base_url: nil, project_id: nil, max_retries: 2, timeout: 60,
29
+ headers: {}, warnings: true, transport: Transport.new)
30
+ @api_key = present(api_key) || present(ENV.fetch("NOHEAD_API_KEY", nil)) or
31
+ raise Error, "Missing API key: pass api_key or set NOHEAD_API_KEY"
32
+ @base_url = (present(base_url) || present(ENV.fetch("NOHEAD_API_URL", nil)) ||
33
+ DEFAULT_BASE_URL).chomp("/")
34
+ @project_id = project_id
35
+ @max_retries = max_retries
36
+ @timeout = timeout
37
+ @headers = headers.to_h { |key, value| [key.to_s, value.to_s] }
38
+ @warnings = warnings
39
+ @transport = transport
40
+ @warned = {}
41
+
42
+ @records = Resources::Records.new(self)
43
+ @collections = Resources::Collections.new(self)
44
+ @fields = Resources::Fields.new(self)
45
+ @migrations = Resources::Migrations.new(self)
46
+ @assets = Resources::Assets.new(self)
47
+ @webhooks = Resources::Webhooks.new(self)
48
+ @audit_events = Resources::AuditEvents.new(self)
49
+ @feature_flags = Resources::FeatureFlags.new(self)
50
+ @me = Resources::Me.new(self)
51
+ @health = Resources::Health.new(self)
52
+ end
53
+
54
+ # Full-text search across the key's project (or some of its collections),
55
+ # most relevant first, through the first 1,000 hits.
56
+ def search(query, collections: nil, status: nil, limit: nil, cursor: nil)
57
+ paginate("projects_search", query: {
58
+ q: query, collections: collections, filter: { status: status }, limit: limit,
59
+ cursor: cursor
60
+ })
61
+ end
62
+
63
+ # The API key's project, from GET /v1/me the first time.
64
+ def project_id
65
+ @project_id ||= begin
66
+ me = request("me_get")
67
+ me.api_key or raise Error, "The credentials are not a project API key"
68
+ me.api_key.project_id
69
+ end
70
+ end
71
+
72
+ # Sends one operation (as listed in Nohead::OPERATIONS) and returns its
73
+ # result. The resources call this; it is public for operations the SDK
74
+ # does not wrap yet.
75
+ def request(operation, path: {}, query: {}, body: nil, idempotency_key: nil,
76
+ change_note: nil, if_match: nil)
77
+ NoheadObject.wrap(send_request(operation, path: path, query: query, body: body,
78
+ idempotency_key: idempotency_key,
79
+ change_note: change_note, if_match: if_match))
80
+ end
81
+
82
+ # A list operation as a Page that fetches the following pages as needed.
83
+ def paginate(operation, path: {}, query: {})
84
+ params = query.dup
85
+ cursor = params.delete(:cursor)
86
+ fetch = lambda do |next_cursor|
87
+ list = send_request(operation, path: path, query: params.merge(cursor: next_cursor))
88
+ Page.new(NoheadObject.wrap(list.fetch("data")), NoheadObject.wrap(list.fetch("meta")),
89
+ &fetch)
90
+ end
91
+ fetch.call(cursor)
92
+ end
93
+
94
+ # Sends a file to a presigned storage URL (no API credentials).
95
+ def put_upload(url, method, headers, source)
96
+ retries = source.replayable? ? @max_retries : 0
97
+ attempt = 0
98
+ loop do
99
+ begin
100
+ response = @transport.call(method, url,
101
+ headers.merge("Content-Length" => source.byte_size.to_s),
102
+ source.io, @timeout)
103
+ return response unless RETRYABLE_STATUSES.include?(response.status) && attempt < retries
104
+ rescue ConnectionError
105
+ raise if attempt >= retries
106
+ end
107
+ pause(backoff(attempt))
108
+ attempt += 1
109
+ source.rewind
110
+ end
111
+ end
112
+
113
+ def inspect = "#<Nohead::Client #{@base_url}>"
114
+
115
+ private
116
+
117
+ def send_request(operation, path: {}, query: {}, body: nil, idempotency_key: nil,
118
+ change_note: nil, if_match: nil)
119
+ method, template = OPERATIONS.fetch(operation)
120
+ url = build_url(template, path) + query_string(query)
121
+ headers = request_headers(method, body, idempotency_key, change_note, if_match)
122
+ payload = body.nil? ? nil : JSON.generate(json_ready(body))
123
+ attempt = 0
124
+ loop do
125
+ begin
126
+ response = @transport.call(method, url, headers, payload, @timeout)
127
+ rescue ConnectionError
128
+ raise if attempt >= @max_retries
129
+
130
+ pause(backoff(attempt))
131
+ attempt += 1
132
+ next
133
+ end
134
+
135
+ warn_about(operation, response.headers)
136
+ parsed = parse(response)
137
+ return parsed if response.status.between?(200, 299)
138
+
139
+ error = Nohead.api_error(response.status, parsed, response.headers)
140
+ delay = retry_delay(error, attempt)
141
+ raise error if delay.nil?
142
+
143
+ pause(delay)
144
+ attempt += 1
145
+ end
146
+ end
147
+
148
+ def build_url(template, values)
149
+ values = values.transform_keys(&:to_s)
150
+ values["project_id"] ||= project_id if template.include?("{project_id}")
151
+ path = template.gsub(/\{(\w+)\}/) do
152
+ value = values[Regexp.last_match(1)]
153
+ raise Error, "Missing #{Regexp.last_match(1)}" if value.nil? || value.to_s.empty?
154
+
155
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
156
+ end
157
+ @base_url + path
158
+ end
159
+
160
+ # Query parameters as the API reads them: hashes become key[sub]=...
161
+ # (filter[status]=published), arrays are comma-separated (expand=author,tags),
162
+ # times are ISO 8601. nil values are left out.
163
+ def query_string(params)
164
+ pairs = []
165
+ add = lambda do |key, value|
166
+ case value
167
+ when nil then nil
168
+ when Hash then value.each { |name, inner| add.call("#{key}[#{name}]", inner) }
169
+ when Array
170
+ items = value.compact.map { |item| scalar(item) }
171
+ pairs << [key, items.join(",")] unless items.empty?
172
+ else pairs << [key, scalar(value)]
173
+ end
174
+ end
175
+ params.each { |key, value| add.call(key.to_s, value) }
176
+ pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
177
+ end
178
+
179
+ def scalar(value)
180
+ case value
181
+ when Time, DateTime then value.to_time.utc.iso8601(3)
182
+ when Date then value.iso8601
183
+ else value.to_s
184
+ end
185
+ end
186
+
187
+ def json_ready(value)
188
+ case value
189
+ when Hash then value.to_h { |key, inner| [key.to_s, json_ready(inner)] }
190
+ when Array then value.map { |item| json_ready(item) }
191
+ when Time, DateTime then value.to_time.utc.iso8601(3)
192
+ when Date then value.iso8601
193
+ when Symbol then value.to_s
194
+ else value
195
+ end
196
+ end
197
+
198
+ def request_headers(method, body, idempotency_key, change_note, if_match)
199
+ headers = {
200
+ "Accept" => "application/json",
201
+ "Authorization" => "Bearer #{@api_key}",
202
+ "Nohead-Client" => "sdk-ruby/#{VERSION}",
203
+ "User-Agent" => "nohead-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
204
+ }.merge(@headers)
205
+ headers["Content-Type"] = "application/json" unless body.nil?
206
+ headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid unless method == "GET"
207
+ unless if_match.nil?
208
+ revision = if_match.respond_to?(:revision) ? if_match.revision : if_match
209
+ headers["If-Match"] = %("#{revision}")
210
+ end
211
+ headers["Nohead-Change-Note"] = change_note if change_note
212
+ headers
213
+ end
214
+
215
+ def parse(response)
216
+ return nil if response.body.nil? || response.body.empty?
217
+ return response.body unless response.headers["content-type"].to_s.include?("json")
218
+
219
+ JSON.parse(response.body)
220
+ rescue JSON::ParserError
221
+ response.body
222
+ end
223
+
224
+ # Seconds to wait before retrying `error`, or nil to raise it.
225
+ def retry_delay(error, attempt)
226
+ return nil if attempt >= @max_retries
227
+
228
+ in_progress = error.status == 409 && error.details.any? { |d| d["code"] == "in_progress" }
229
+ return nil unless in_progress || RETRYABLE_STATUSES.include?(error.status)
230
+
231
+ retry_after = Nohead.retry_after_seconds(error.headers)
232
+ return backoff(attempt) if retry_after.nil?
233
+
234
+ retry_after <= MAX_RETRY_AFTER ? retry_after : nil
235
+ end
236
+
237
+ # Exponential backoff with jitter: about 0.5 s, 1 s, 2 s... up to 8 s.
238
+ def backoff(attempt)
239
+ [0.5 * (2**attempt), 8.0].min * (1 - (rand * 0.25))
240
+ end
241
+
242
+ def pause(seconds)
243
+ sleep(seconds) if seconds.positive?
244
+ end
245
+
246
+ def warn_about(operation, headers)
247
+ return unless @warnings
248
+
249
+ if headers["deprecation"] && !@warned[operation]
250
+ @warned[operation] = true
251
+ sunset = headers["sunset"] ? " and will be removed after #{headers['sunset']}" : ""
252
+ link = headers["link"].to_s[/<([^>]+)>/, 1]
253
+ warn "[nohead] #{operation} is deprecated#{sunset}#{". See #{link}" if link}"
254
+ end
255
+ return unless headers["nohead-usage-warning"] && !@warned[:usage]
256
+
257
+ @warned[:usage] = true
258
+ warn "[nohead] Over a plan limit: #{headers['nohead-usage-warning']}"
259
+ end
260
+
261
+ def present(value)
262
+ value.nil? || value.to_s.empty? ? nil : value
263
+ end
264
+ end
265
+ end