nohead 0.7.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b3361c68d640e8b2774b8e797bbf4108e19be21f0019600788d9f45e04457fe
4
- data.tar.gz: d3c200b25b6afd1db440dced441f142da3d38b1731e34780356c373c2bc4b67c
3
+ metadata.gz: 68e5883cc4d13b186ac691a79aeb84b5a10462c2e350ddbffe17cd832ffc6d30
4
+ data.tar.gz: 702a971c87efbda7fdc438410984f93fd7f315ca655958e6510b6027ca970089
5
5
  SHA512:
6
- metadata.gz: 409ee914aaf232ce8f7109b4628f260f47f6b83861dceb48aebeaee661873c03f5ebef3ccbbd6e269504febad9325505a3b97a2f42905bb279e81d76aef70f18
7
- data.tar.gz: 87b158ed9116745555538a9ef4297c584050251e69d0bab6101b823e239b3586262f161901f519973b1b07c4f2f2b76dfcd78ac86c55eedb905b208867d96d60
6
+ metadata.gz: f8f38c6c8844668251282d375d1f20373621460aefb0ab340beffa29c8bb000fc38025b17746fae7b7dde10f7007dee92806240e3f0de996774e7ebfeef6e234
7
+ data.tar.gz: 387779fad0fd93d5eb68e4956e434ee2eedbb70ad0f00f1fbc80f23b8d34891eaf8338d27c6b97927432951846d85aa7ea921065b741043a185bf2903a1d03d4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,110 @@ Changes to the `nohead` gem that you can notice. Versions follow
5
5
  releases; a change that could break your code is a major one. Each release's
6
6
  section is its GitHub release's notes.
7
7
 
8
+ ## 0.9.0
9
+
10
+ - **Breaking:** the client no longer reads `NOHEAD_API_URL`: a stray value
11
+ sent requests, with the API key, to another host. The base URL is
12
+ `base_url:`, else `https://api.nohead.io`; pass `base_url:` to use another
13
+ API, as in `Nohead::Client.new(base_url: ENV.fetch("NOHEAD_API_URL"))`.
14
+ - A request is sent once per attempt. Net::HTTP retried a GET, PUT or DELETE
15
+ that timed out or lost its connection once more on its own, so a stalled
16
+ request waited twice the `timeout:`, `max_retries:` sent more attempts than
17
+ asked (unseen by `on_retry:`), and a stalled upload was sent again with its
18
+ stream already read.
19
+ - `Images.url` writes the same URL as the other SDKs for the same image and
20
+ options: a whole-number Float is written as an integer (`dpr: 2.0` is
21
+ `dpr:2`, not `dpr:2.0`), and `dpr: 0` or `quality: 0` is left out rather
22
+ than written as `dpr:0` or `q:0`.
23
+ - Deprecation and plan-usage warnings are given once per process, as in the
24
+ other SDKs, rather than once per client: an app that builds a client per
25
+ request no longer repeats them on every request.
26
+ - `assets.upload` of a stream that cannot seek, such as a pipe or `$stdin`,
27
+ with `byte_size:` no longer raises `Errno::ESPIPE`. Its bytes are sent
28
+ once: a storage error is not retried, since they can't be read again.
29
+ - `Webhooks.unwrap` reads headers, body and secret as bytes. A non-ASCII
30
+ `webhook-id` with a binary body (as Rack passes it) no longer raises
31
+ `Encoding::CompatibilityError`, a secret in another encoding raises
32
+ `WebhookVerificationError`, and a header that is not valid UTF-8 is refused
33
+ for its signature or timestamp, not as "The webhook secret is not base64".
34
+ - `Webhooks.unwrap` refuses an unset or empty secret with
35
+ `WebhookVerificationError` ("The webhook secret is empty"), instead of
36
+ `NoMethodError` or a signature mismatch, and verifies a repeated
37
+ `webhook-signature` header given as an Array.
38
+ - A path value of `"."` or `".."` (an ID such as `records.get(".")`) raises
39
+ `Nohead::Error` (`Invalid record_id: "."`) before anything is sent, as
40
+ `nil` or `""` does, instead of reaching a URL that a proxy could resolve to
41
+ another path.
42
+ - An empty string is the same as leaving an option out, as in the other
43
+ SDKs: `project_id: ""` looks the project up, `idempotency_key: ""`
44
+ generates a key (it was sent empty) and `change_note: ""` sends no
45
+ `Nohead-Change-Note` header.
46
+ - `base_url:` loses every trailing slash (it lost one), and a base URL that
47
+ isn't `http` or `https` raises `Nohead::Error` ("base_url must be an http or
48
+ https URL") when the client is built, instead of `NoMethodError` at the
49
+ first request.
50
+ - Headers are sent once per name, whatever its case: the client's
51
+ `headers:` replace the SDK's own, and the ones request options set
52
+ (`Content-Type`, `Idempotency-Key`, `If-Match`, `Nohead-Change-Note`)
53
+ replace both. A custom `transport:` used to get both spellings.
54
+ - `if_match:` takes a Hash with a revision (`{ revision: 3 }`) as well as a
55
+ revision number or a record or draft; anything else, such as `"3"`, raises
56
+ `ArgumentError` (a Hash or String was sent as it was written).
57
+ - The project lookup (`GET /v1/me`, without `project_id:`) runs once when
58
+ several threads need it at the same time.
59
+ - `search` with both `collections:` and a `collection` filter searches
60
+ `collections:`, as the other SDKs do (the filter won).
61
+ - `APIError#api_message` is the API's own message, without the status, type
62
+ and request ID that `message` adds.
63
+ - A redirect is an `APIError` whose message says to check the base URL
64
+ (`301 redirect: The API answered with a redirect to <Location>; check the
65
+ base URL`), instead of "Request failed with status 301".
66
+ - A malformed response (a broken status line or header,
67
+ `Net::HTTPBadResponse`, `Net::HTTPHeaderSyntaxError`, `Net::ProtocolError`,
68
+ or a broken compressed body) is a `ConnectionError`, retried like a
69
+ dropped connection, instead of escaping as Net::HTTP's own error.
70
+ - A list call whose response isn't a page raises `Nohead::Error` ("The API's
71
+ response could not be read (HTTP 200)") instead of `KeyError` or
72
+ `NoMethodError`.
73
+ - `Retry-After` is read with spaces around it (`" 5"`); anything but whole or
74
+ decimal seconds or an HTTP date is ignored, as before.
75
+ - Error details that aren't objects are kept, with the code `"unknown"` and
76
+ the value as their message, instead of left out.
77
+ - An empty `Deprecation` header no longer warns.
78
+ - `on_retry:`, a client option, is called before each retry's wait (of
79
+ uploads' bytes too) with a `Nohead::RetryEvent`: the attempt, the delay in
80
+ seconds, the URL, and the status or error that failed, as the other SDKs'
81
+ retry callbacks are.
82
+ - `with_options(timeout:, max_retries:, headers:, on_retry:)` returns a copy
83
+ of the client with other settings, sharing its project lookup.
84
+ - `timeout:` is documented as what it is: the longest wait without progress
85
+ (connecting, waiting for the response, between reads and writes of a
86
+ body), not a limit on the whole attempt.
87
+ - `assets.upload` raises `UploadError` ("Could not upload a.png: <reason>",
88
+ with the `ConnectionError` as its `cause`) when storage can't be reached
89
+ or times out, as it does when storage refuses the bytes, instead of
90
+ `ConnectionError` or `TimeoutError`.
91
+ - `assets.upload` with `filename: ""` or `content_type: ""` uses the
92
+ defaults (the file's name, else `upload`; the extension's type, else
93
+ `application/octet-stream`) instead of sending them empty.
94
+ - `Images.url` leaves out options given as `""` (`gravity: ""`,
95
+ `format: ""`...) instead of writing `g:` or a trailing `@`, and `fit: nil`
96
+ (or `""`) means the default `contain` instead of raising `ArgumentError`.
97
+
98
+ ## 0.8.0
99
+
100
+ - Assets have `file_url`, the file as uploaded, which downloads under its
101
+ filename, and for video and audio `media_url`, which plays it inline (in
102
+ `<video>` and `<audio>`, with range requests). Both are open, never expire
103
+ and are cached on a CDN, like `image_url`.
104
+
105
+ - `assets.download_url` is removed, with the API's
106
+ `GET /v1/assets/{asset_id}/download-url` and its 2-minute links: use the
107
+ asset's `file_url`.
108
+
109
+ - An upload larger than the plan allows (10 MB a file on Free, 100 MB on paid
110
+ plans) is refused with a `Nohead::PlanLimitExceededError` when it starts.
111
+
8
112
  ## 0.7.0
9
113
 
10
114
  - `assets.list(q:, kind:)`: `q` finds assets whose filename or alt text has
data/README.md CHANGED
@@ -41,24 +41,36 @@ gem "nohead"
41
41
 
42
42
  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.
43
43
 
44
+ Until 1.0, any release may break your code: pin a version, and read the changelog's **Breaking** entries before upgrading.
45
+
44
46
  ## Configuration
45
47
 
46
48
  ```ruby
47
49
  nohead = Nohead::Client.new(
48
- api_key: ENV.fetch("NOHEAD_API_KEY"), # default: NOHEAD_API_KEY
49
- base_url: "https://api.nohead.io" # default: NOHEAD_API_URL, else production
50
+ api_key: ENV.fetch("NOHEAD_API_KEY"), # the default
51
+ timeout: 10,
52
+ max_retries: 3
50
53
  )
51
54
  ```
52
55
 
53
56
  | Option | Default | |
54
57
  |---|---|---|
55
58
  | `api_key:` | `NOHEAD_API_KEY` | A project API key (`nohead_sk_...`, or `nohead_pk_...` when published-only). Required. |
56
- | `base_url:` | `NOHEAD_API_URL`, else `https://api.nohead.io` | |
59
+ | `base_url:` | `https://api.nohead.io` | For a proxy, or a mock server in tests. |
57
60
  | `project_id:` | the key's project | Looked up once with `GET /v1/me` when omitted. |
58
61
  | `max_retries:` | `2` | See [retries](#retries-and-idempotency). |
59
- | `timeout:` | `60` | Seconds per attempt. |
62
+ | `timeout:` | `60` | The longest wait in seconds without progress, per attempt: connecting, waiting for the response, and between reads and writes of a body. A response that keeps arriving doesn't time out. |
60
63
  | `headers:` | `{}` | Added to every request. |
61
64
  | `warnings:` | `true` | Warns about deprecated operations and plan usage, once each. |
65
+ | `on_retry:` | | Called before each retry's wait. See [retries](#retries-and-idempotency). |
66
+
67
+ An empty string is the same as leaving an option out (`api_key: ""` reads `NOHEAD_API_KEY`, `base_url: ""` uses `https://api.nohead.io`). The base URL comes only from `base_url:`, never from the environment. A `base_url:` that isn't `http` or `https` raises `Nohead::Error`.
68
+
69
+ `with_options` gives a copy of the client with other settings (`timeout:`, `max_retries:`, `headers:`, added to the client's, and `on_retry:`), sharing its project lookup:
70
+
71
+ ```ruby
72
+ nohead.with_options(timeout: 5, max_retries: 0).records.get(id)
73
+ ```
62
74
 
63
75
  API keys belong to a project, so methods like `collections.list` need no project ID. Collections can be named by ID or slug everywhere.
64
76
 
@@ -132,7 +144,7 @@ nohead.records.list("products", filter: { price: { lt: 50 }, category: { in: %w[
132
144
 
133
145
  ## Errors
134
146
 
135
- 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:
147
+ Every error is a `Nohead::Error`. API errors are `Nohead::APIError`s with `status`, `type`, `message`, `api_message` (the API's own message, without the status, type and request ID), `request_id`, `details` and `headers`, in a class per type:
136
148
 
137
149
  | Class | Status |
138
150
  |---|---|
@@ -148,6 +160,8 @@ Every error is a `Nohead::Error`. API errors are `Nohead::APIError`s with `statu
148
160
  | `InternalServerError` | 500 and other 5xx |
149
161
  | `ServiceUnavailableError` | 503 |
150
162
 
163
+ A redirect is an `APIError` too ("301 redirect: The API answered with a redirect to …; check the base URL"): the SDK doesn't follow redirects.
164
+
151
165
  Other errors:
152
166
 
153
167
  - `Nohead::ConnectionError`, and `Nohead::TimeoutError`, which is a kind of `ConnectionError`
@@ -167,7 +181,13 @@ end
167
181
  Failed requests are retried twice by default (`max_retries:`), with exponential backoff:
168
182
 
169
183
  - what's retried: connection errors, timeouts, 429, 500, 502, 503, 504, and a 409 for a request that is still running
170
- - `Retry-After` is honored up to 60 seconds; a longer one raises `RateLimitError` straight away
184
+ - `Retry-After` (seconds, or an HTTP date) is honored up to 60 seconds; a longer one raises `RateLimitError` straight away
185
+
186
+ `on_retry:` hears of each retry before its wait, uploads' too, as a `Nohead::RetryEvent` (`attempt`, from 1; `delay`, in seconds; `url`; `status`, nil when the connection failed; and `error`):
187
+
188
+ ```ruby
189
+ nohead = Nohead::Client.new(on_retry: ->(event) { logger.warn("Retry #{event.attempt} of #{event.url} in #{event.delay} s") })
190
+ ```
171
191
 
172
192
  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:
173
193
 
@@ -190,14 +210,13 @@ rescue Nohead::PreconditionFailedError => e
190
210
  end
191
211
  ```
192
212
 
193
- `if_match:` takes a record (or draft) or a revision number, on `records.update`, `delete`, `restore`, `publish`, `unpublish` and `revisions.revert`, and on `drafts.update`, `discard` and `merge`.
213
+ `if_match:` takes a record (or draft), a revision number or `{ revision: 3 }`, on `records.update`, `delete`, `restore`, `publish`, `unpublish` and `revisions.revert`, and on `drafts.update`, `discard` and `merge`.
194
214
 
195
215
  ## Assets
196
216
 
197
217
  ```ruby
198
218
  asset = nohead.assets.upload("cover.jpg")
199
219
  nohead.records.update(id, data: { cover: asset.id })
200
-
201
220
  ```
202
221
 
203
222
  **Images.** Image URLs are open: an image's `image_url` serves the whole image, and any of [imgproxy's processing options](https://docs.imgproxy.net/usage/processing) written into it transform it. `Nohead::Images.url` builds them without a request:
@@ -217,18 +236,18 @@ Thumbnails: an image's `preview_url` fits in 640×640, as WebP.
217
236
  2. Streams the bytes straight to storage.
218
237
  3. Completes the upload, which checks the file, and returns the `ready` asset.
219
238
 
220
- **Errors:** `UploadError` if storage refuses the bytes; `ValidationError` if the file fails the checks.
239
+ **Errors:** `UploadError` if the bytes don't reach storage: refused (`Storage refused the upload of cover.jpg (403)`, with its `status`), or unreachable or timed out (`Could not upload cover.jpg: …`, with the `ConnectionError` as its `cause`). `ValidationError` if the file fails the checks.
221
240
 
222
241
  **Resuming:** with an `idempotency_key:`, running the same upload again resumes it: the key starts the upload, and an asset that is already `ready` comes back without sending the bytes again. Once the upload's URL has expired (its `upload.expires_at`), it raises `UploadError`: start over with a new key.
223
242
 
224
- **Uploading from a browser:** create the upload on your server with `create_upload`, `PUT` the file from the browser, then `complete` it.
225
-
226
243
  **Renaming and alt text:** `update` changes an asset's `filename:` and `alt:` text, the default wherever the asset is used (`expand:` returns it with the asset). The file itself never changes.
227
244
 
228
245
  ```ruby
229
246
  nohead.assets.update(asset.id, alt: "The spring menu, on a chalkboard")
230
247
  ```
231
248
 
249
+ **Deleting:** `usage` shows which records use an asset. `delete` keeps the file until its `purge_after`, during which `restore` undoes it; `purge` removes a deleted asset for good, now.
250
+
232
251
  ## Search
233
252
 
234
253
  ```ruby
@@ -299,7 +318,7 @@ The API's resources come back as `Nohead::NoheadObject`s.
299
318
  | `collections.search_index` | `get`, `rebuild` |
300
319
  | `fields` | `list`, `get`, `create`, `update`, `delete`, `restore`, `order`, `reorder`, `remove_alias` |
301
320
  | `migrations` | `create`, `list`, `get`, `cancel` |
302
- | `assets` | `upload`, `create_upload`, `complete`, `list`, `get`, `update`, `delete`, `restore`, `purge`, `usage`, `download_url` |
321
+ | `assets` | `upload`, `create_upload`, `complete`, `list`, `get`, `update`, `delete`, `restore`, `purge`, `usage` |
303
322
  | `webhooks` | `list`, `get`, `create`, `update`, `delete`, `restore`, `rotate_secret`, `test`, `unwrap` |
304
323
  | `webhooks.secrets` | `list`, `retire` |
305
324
  | `webhooks.deliveries` | `list`, `get`, `retry` |
@@ -320,20 +339,32 @@ bundle exec rake generate # after updating openapi.json
320
339
  bundle exec rake samples # after changing test/calls.rb (the docs' code samples)
321
340
  ```
322
341
 
342
+ Before pushing, run what CI runs: the tests, RuboCop, and a check that the generated files are up to date:
343
+
344
+ ```bash
345
+ bundle exec rake test
346
+ bundle exec rubocop
347
+ bundle exec rake generate samples && git diff --exit-code # with your changes committed: a diff is a stale file
348
+ gem build nohead.gemspec
349
+ ```
350
+
323
351
  **How the code is organized:**
324
352
 
325
353
  - `openapi.json` is the API's published contract. `rake generate` derives the operation table, `lib/nohead/operations.rb`, from it.
326
354
  - The methods are written by hand.
327
355
  - `test/contract_test.rb` runs every call in `test/calls.rb`. It fails when an API-key operation in the contract has no method, when a query parameter is never sent (no method takes it, or no call passes it), or when a request doesn't match its operation (method, path, query parameters and JSON body, against their schemas). Each call gets an example of its operation's response, built from the contract's schema, and must return it.
356
+ - The unit tests answer with `test/fake_transport.rb`, which checks every request to the API and every reply against `openapi.json`, so a fixture can't drift from the API. Wrap a reply that breaks the contract on purpose (a gateway's HTML page, an unknown error type) in `raw(...)`.
357
+ - `test/network_test.rb` runs the real transport (Net::HTTP) against an HTTP server on 127.0.0.1, `test/local_server.rb`, for what goes over the wire: lengths, timeouts, redirects and broken replies.
358
+ - A test that makes no assertions fails.
328
359
 
329
- 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.
360
+ The smoke test (`smoke/smoke.rb`) runs the core flow against a real API, with the gem as installed from its built package. The Nohead API repository runs it in its end-to-end tests and before every release.
330
361
 
331
362
  ## Releasing
332
363
 
333
- 1. Bump `lib/nohead/version.rb`.
364
+ 1. Bump `lib/nohead/version.rb`, then `bundle install` (`Gemfile.lock` records the version, and CI installs frozen).
334
365
  2. Add a section for the version to `CHANGELOG.md` (`## 1.2.3`), which becomes the release's notes.
335
366
  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.
336
- 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.
367
+ 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`. A tag ruleset lets only deploy keys push `v*` tags.
337
368
  5. The tag starts `.github/workflows/release.yml`. A build job checks the tag is on `main` and matches the version and its notes, and packs the gem, with no publishing credential. 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 pushes that gem to RubyGems through trusted publishing (no API key), then creates the GitHub release with the gem.
338
369
 
339
370
  ## License
data/lib/nohead/client.rb CHANGED
@@ -6,66 +6,100 @@ require "time"
6
6
  require "uri"
7
7
 
8
8
  module Nohead
9
+ # A retry about to happen, as `on_retry:` gets it: `attempt` is 1 for a
10
+ # request's first retry, `delay` the seconds the client waits before it,
11
+ # `url` the URL retried, `status` the status that failed (nil when the
12
+ # connection did), and `error` the APIError or ConnectionError (nil for
13
+ # a status from storage).
14
+ RetryEvent = Data.define(:attempt, :delay, :url, :status, :error)
15
+
9
16
  # A client for the Nohead API, authenticated with a project API key.
10
17
  #
11
18
  # nohead = Nohead::Client.new # NOHEAD_API_KEY
12
19
  # nohead.records.list("posts").each { |post| puts post.data["title"] }
13
20
  class Client
14
21
  DEFAULT_BASE_URL = "https://api.nohead.io"
22
+
23
+ # Each warning is given once per process, as the other SDKs do: an app
24
+ # that builds a client per request would otherwise warn on every one.
25
+ WARNED = Set.new
26
+ WARNED_LOCK = Mutex.new
27
+ private_constant :WARNED_LOCK
28
+
29
+ def self.first_warning?(key) = WARNED_LOCK.synchronize { !WARNED.add?(key).nil? }
15
30
  RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
16
31
  MAX_RETRY_AFTER = 60
17
32
 
18
33
  attr_reader :records, :revisions, :drafts, :collections, :fields, :migrations, :assets, :webhooks,
19
34
  :audit_events, :feature_flags, :me, :health
20
35
 
36
+ # The key's project ID, looked up once, and once at a time, for a client
37
+ # and its copies (with_options).
38
+ class ProjectLookup
39
+ def initialize(id)
40
+ @id = id
41
+ @lock = Mutex.new
42
+ end
43
+
44
+ def get
45
+ @id || @lock.synchronize { @id ||= yield }
46
+ end
47
+ end
48
+ private_constant :ProjectLookup
49
+
21
50
  # api_key:: A project API key ("nohead_sk_...", or "nohead_pk_..." when
22
51
  # published-only). Defaults to NOHEAD_API_KEY.
23
- # base_url:: Defaults to NOHEAD_API_URL, else https://api.nohead.io.
52
+ # base_url:: The API's address, an http or https URL. Defaults to
53
+ # https://api.nohead.io (the environment isn't read for it).
24
54
  # project_id:: The key's project. Looked up once with GET /v1/me when omitted.
25
55
  # max_retries:: Retries of failed requests (see the README, "Retries").
26
- # timeout:: Seconds per attempt.
56
+ # timeout:: The longest wait in seconds without progress, per attempt:
57
+ # connecting, waiting for the response, and between reads
58
+ # and writes of a body. A response that keeps arriving
59
+ # doesn't time out.
27
60
  # headers:: Headers added to every request.
28
61
  # warnings:: Warn about deprecated operations and plan usage (once each).
62
+ # on_retry:: Called with a RetryEvent before each retry's wait, for
63
+ # example to log it.
64
+ #
65
+ # An empty string is the same as leaving an option out.
29
66
  def initialize(api_key: nil, base_url: nil, project_id: nil, max_retries: 2, timeout: 60,
30
- headers: {}, warnings: true, transport: Transport.new)
67
+ headers: {}, warnings: true, on_retry: nil, transport: Transport.new)
31
68
  @api_key = present(api_key) || present(ENV.fetch("NOHEAD_API_KEY", nil)) or
32
69
  raise Error, "Missing API key: pass api_key or set NOHEAD_API_KEY"
33
- @base_url = (present(base_url) || present(ENV.fetch("NOHEAD_API_URL", nil)) ||
34
- DEFAULT_BASE_URL).chomp("/")
35
- @project_id = project_id
70
+ @base_url = checked_base_url(present(base_url) || DEFAULT_BASE_URL)
71
+ @project = ProjectLookup.new(present(project_id))
36
72
  @max_retries = max_retries
37
73
  @timeout = timeout
38
- @headers = headers.to_h { |key, value| [key.to_s, value.to_s] }
74
+ @headers = merge_headers({}, headers.to_h)
39
75
  @warnings = warnings
76
+ @on_retry = on_retry
40
77
  @transport = transport
41
- @warned = {}
78
+ build_resources
79
+ end
42
80
 
43
- @records = Resources::Records.new(self)
44
- @revisions = Resources::RevisionsById.new(self)
45
- @drafts = Resources::Drafts.new(self)
46
- @collections = Resources::Collections.new(self)
47
- @fields = Resources::Fields.new(self)
48
- @migrations = Resources::Migrations.new(self)
49
- @assets = Resources::Assets.new(self)
50
- @webhooks = Resources::Webhooks.new(self)
51
- @audit_events = Resources::AuditEvents.new(self)
52
- @feature_flags = Resources::FeatureFlags.new(self)
53
- @me = Resources::Me.new(self)
54
- @health = Resources::Health.new(self)
81
+ # A copy of the client with other settings, which shares its transport
82
+ # and project lookup: `nohead.with_options(timeout: 5).records.get(id)`.
83
+ # `headers:` are added to the client's.
84
+ def with_options(timeout: @timeout, max_retries: @max_retries, headers: {}, on_retry: @on_retry)
85
+ dup.tap { |copy| copy.configure(timeout, max_retries, merge_headers(@headers, headers.to_h), on_retry) }
55
86
  end
56
87
 
57
88
  # Full-text search across the key's project (or some of its collections),
58
89
  # most relevant first, through the first 1,000 hits. `filter` takes
59
90
  # `published_at` ({ published_at: { exists: true } } for published
60
- # records), as records.list does.
91
+ # records), as records.list does. `collections` wins over a `collection`
92
+ # filter.
61
93
  def search(query, collections: nil, filter: nil, limit: nil, cursor: nil)
62
- filter = { collection: (collections.nil? ? nil : { in: collections }), **filter.to_h.transform_keys(&:to_sym) }
94
+ filter = filter.to_h.transform_keys(&:to_sym)
95
+ filter = filter.merge(collection: { in: collections }) unless collections.nil?
63
96
  paginate("projects_search", query: { q: query, filter: filter, limit: limit, cursor: cursor })
64
97
  end
65
98
 
66
- # The API key's project, from GET /v1/me the first time.
99
+ # The API key's project, from GET /v1/me the first time. Threads that
100
+ # need it at the same time wait for one lookup.
67
101
  def project_id
68
- @project_id ||= begin
102
+ @project.get do
69
103
  me = request("me_get")
70
104
  me.api_key or raise Error, "The credentials are not a project API key"
71
105
  me.api_key.project_id
@@ -77,9 +111,8 @@ module Nohead
77
111
  # does not wrap yet.
78
112
  def request(operation, path: {}, query: {}, body: nil, idempotency_key: nil,
79
113
  change_note: nil, if_match: nil)
80
- NoheadObject.wrap(send_request(operation, path: path, query: query, body: body,
81
- idempotency_key: idempotency_key,
82
- change_note: change_note, if_match: if_match))
114
+ NoheadObject.wrap(exchange(operation, path: path, query: query, body: body, idempotency_key: idempotency_key,
115
+ change_note: change_note, if_match: if_match).first)
83
116
  end
84
117
 
85
118
  # A list operation as a Page that fetches the following pages as needed.
@@ -87,9 +120,12 @@ module Nohead
87
120
  params = query.dup
88
121
  cursor = params.delete(:cursor)
89
122
  fetch = lambda do |next_cursor|
90
- list = send_request(operation, path: path, query: params.merge(cursor: next_cursor))
91
- Page.new(NoheadObject.wrap(list.fetch("data")), NoheadObject.wrap(list.fetch("meta")),
92
- &fetch)
123
+ list, status = exchange(operation, path: path, query: params.merge(cursor: next_cursor))
124
+ unless list.is_a?(Hash) && list["data"].is_a?(Array) && list["meta"].is_a?(Hash)
125
+ raise Error, "The API's response could not be read (HTTP #{status})"
126
+ end
127
+
128
+ Page.new(NoheadObject.wrap(list["data"]), NoheadObject.wrap(list["meta"]), &fetch)
93
129
  end
94
130
  fetch.call(cursor)
95
131
  end
@@ -104,10 +140,14 @@ module Nohead
104
140
  headers.merge("Content-Length" => source.byte_size.to_s),
105
141
  source.io, @timeout)
106
142
  return response unless RETRYABLE_STATUSES.include?(response.status) && attempt < retries
107
- rescue ConnectionError
143
+
144
+ failed = { status: response.status }
145
+ rescue ConnectionError => e
108
146
  raise if attempt >= retries
147
+
148
+ failed = { error: e }
109
149
  end
110
- pause(backoff(attempt))
150
+ wait_to_retry(url, attempt, backoff(attempt), **failed)
111
151
  attempt += 1
112
152
  source.rewind
113
153
  end
@@ -115,10 +155,41 @@ module Nohead
115
155
 
116
156
  def inspect = "#<Nohead::Client #{@base_url}>"
117
157
 
158
+ protected
159
+
160
+ # with_options' settings, on its copy.
161
+ def configure(timeout, max_retries, headers, on_retry)
162
+ @timeout = timeout
163
+ @max_retries = max_retries
164
+ @headers = headers
165
+ @on_retry = on_retry
166
+ end
167
+
118
168
  private
119
169
 
120
- def send_request(operation, path: {}, query: {}, body: nil, idempotency_key: nil,
121
- change_note: nil, if_match: nil)
170
+ def initialize_copy(source)
171
+ super
172
+ build_resources # theirs send through `source`
173
+ end
174
+
175
+ def build_resources
176
+ @records = Resources::Records.new(self)
177
+ @revisions = Resources::RevisionsById.new(self)
178
+ @drafts = Resources::Drafts.new(self)
179
+ @collections = Resources::Collections.new(self)
180
+ @fields = Resources::Fields.new(self)
181
+ @migrations = Resources::Migrations.new(self)
182
+ @assets = Resources::Assets.new(self)
183
+ @webhooks = Resources::Webhooks.new(self)
184
+ @audit_events = Resources::AuditEvents.new(self)
185
+ @feature_flags = Resources::FeatureFlags.new(self)
186
+ @me = Resources::Me.new(self)
187
+ @health = Resources::Health.new(self)
188
+ end
189
+
190
+ # The parsed body of a 2xx response, and its status.
191
+ def exchange(operation, path: {}, query: {}, body: nil, idempotency_key: nil,
192
+ change_note: nil, if_match: nil)
122
193
  method, template = OPERATIONS.fetch(operation)
123
194
  url = build_url(template, path) + query_string(query)
124
195
  headers = request_headers(method, body, idempotency_key, change_note, if_match)
@@ -127,23 +198,23 @@ module Nohead
127
198
  loop do
128
199
  begin
129
200
  response = @transport.call(method, url, headers, payload, @timeout)
130
- rescue ConnectionError
201
+ rescue ConnectionError => e
131
202
  raise if attempt >= @max_retries
132
203
 
133
- pause(backoff(attempt))
204
+ wait_to_retry(url, attempt, backoff(attempt), error: e)
134
205
  attempt += 1
135
206
  next
136
207
  end
137
208
 
138
209
  warn_about(operation, response.headers)
139
210
  parsed = parse(response)
140
- return parsed if response.status.between?(200, 299)
211
+ return [parsed, response.status] if response.status.between?(200, 299)
141
212
 
142
213
  error = Nohead.api_error(response.status, parsed, response.headers)
143
214
  delay = retry_delay(error, attempt)
144
215
  raise error if delay.nil?
145
216
 
146
- pause(delay)
217
+ wait_to_retry(url, attempt, delay, status: response.status, error: error)
147
218
  attempt += 1
148
219
  end
149
220
  end
@@ -156,10 +227,13 @@ module Nohead
156
227
 
157
228
  values["project_id"] ||= project_id if template.include?("{project_id}")
158
229
  path = template.gsub(/\{(\w+)\}/) do
159
- value = values[Regexp.last_match(1)]
160
- raise Error, "Missing #{Regexp.last_match(1)}" if value.nil? || value.to_s.empty?
230
+ name = Regexp.last_match(1)
231
+ value = values[name]
232
+ raise Error, "Missing #{name}" if value.nil? || value.to_s.empty?
233
+ # Encoded, "." and ".." would still be resolved as the path's own segments.
234
+ raise Error, %(Invalid #{name}: "#{value}") if %w[. ..].include?(value.to_s)
161
235
 
162
- URI.encode_www_form_component(value.to_s).gsub("+", "%20")
236
+ URI.encode_uri_component(value.to_s)
163
237
  end
164
238
  @base_url + path
165
239
  end
@@ -196,33 +270,69 @@ module Nohead
196
270
  case value
197
271
  when Hash then value.to_h { |key, inner| [key.to_s, json_ready(inner)] }
198
272
  when Array then value.map { |item| json_ready(item) }
199
- when Time, DateTime then value.to_time.utc.iso8601(3)
200
- when Date then value.iso8601
273
+ when Time, DateTime, Date then scalar(value)
201
274
  when Symbol then value.to_s
202
275
  else value
203
276
  end
204
277
  end
205
278
 
279
+ # The SDK's headers, then the client's `headers:`, then the ones request
280
+ # options set, which win.
206
281
  def request_headers(method, body, idempotency_key, change_note, if_match)
207
- headers = {
208
- "Accept" => "application/json",
209
- "Authorization" => "Bearer #{@api_key}",
210
- "Nohead-Client" => "sdk-ruby/#{VERSION}",
211
- "User-Agent" => "nohead-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
212
- }.merge(@headers)
213
- headers["Content-Type"] = "application/json" unless body.nil?
214
- headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid unless method == "GET"
215
- unless if_match.nil?
216
- revision = if_match.respond_to?(:revision) ? if_match.revision : if_match
217
- headers["If-Match"] = %("#{revision}")
218
- end
282
+ headers = merge_headers({
283
+ "Accept" => "application/json",
284
+ "Authorization" => "Bearer #{@api_key}",
285
+ "Nohead-Client" => "sdk-ruby/#{VERSION}",
286
+ "User-Agent" => "nohead-ruby/#{VERSION} ruby/#{RUBY_VERSION}"
287
+ }, @headers)
288
+ options = {}
289
+ options["Content-Type"] = "application/json" unless body.nil?
290
+ options["Idempotency-Key"] = present(idempotency_key) || SecureRandom.uuid unless method == "GET"
291
+ options["If-Match"] = %("#{revision_of(if_match)}") unless if_match.nil?
219
292
  # Percent-encoded UTF-8, as encodeURIComponent: header values are bytes
220
293
  # (the API decodes it). Notes in other encodings are converted first.
221
- if change_note
294
+ if present(change_note)
222
295
  note = change_note.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
223
- headers["Nohead-Change-Note"] = URI.encode_uri_component(note)
296
+ options["Nohead-Change-Note"] = URI.encode_uri_component(note)
297
+ end
298
+ merge_headers(headers, options)
299
+ end
300
+
301
+ # `extra` over `headers`, matching names without regard to case, so each
302
+ # name is sent once.
303
+ def merge_headers(headers, extra)
304
+ extra.each_with_object(headers.dup) do |(name, value), merged|
305
+ name = name.to_s
306
+ merged.delete_if { |key, _| key.casecmp?(name) }
307
+ merged[name] = value.to_s
224
308
  end
225
- headers
309
+ end
310
+
311
+ # A revision number, or a record or draft (anything with a revision, a
312
+ # Hash too).
313
+ def revision_of(if_match)
314
+ revision = if if_match.is_a?(Hash)
315
+ if_match.key?(:revision) ? if_match[:revision] : if_match["revision"]
316
+ else
317
+ if_match.respond_to?(:revision) ? if_match.revision : if_match
318
+ end
319
+ return revision if revision.is_a?(Integer)
320
+
321
+ raise ArgumentError, "if_match takes a revision number, or a record, draft or hash with a revision"
322
+ end
323
+
324
+ def checked_base_url(url)
325
+ # A loop rather than a regex, which backtracks on long runs of slashes.
326
+ url = url.to_s
327
+ stop = url.length
328
+ stop -= 1 while stop.positive? && url[stop - 1] == "/"
329
+ url = url[0, stop]
330
+ uri = URI.parse(url)
331
+ return url if %w[http https].include?(uri.scheme&.downcase) && !uri.host.to_s.empty?
332
+
333
+ raise Error, "base_url must be an http or https URL"
334
+ rescue URI::InvalidURIError
335
+ raise Error, "base_url must be an http or https URL"
226
336
  end
227
337
 
228
338
  def parse(response)
@@ -252,6 +362,11 @@ module Nohead
252
362
  [0.5 * (2**attempt), 8.0].min * (1 - (rand * 0.25))
253
363
  end
254
364
 
365
+ def wait_to_retry(url, attempt, delay, status: nil, error: nil)
366
+ @on_retry&.call(RetryEvent.new(attempt: attempt + 1, delay: delay, url: url, status: status, error: error))
367
+ pause(delay)
368
+ end
369
+
255
370
  def pause(seconds)
256
371
  sleep(seconds) if seconds.positive?
257
372
  end
@@ -259,15 +374,13 @@ module Nohead
259
374
  def warn_about(operation, headers)
260
375
  return unless @warnings
261
376
 
262
- if headers["deprecation"] && !@warned[operation]
263
- @warned[operation] = true
377
+ if !headers["deprecation"].to_s.strip.empty? && self.class.first_warning?(operation)
264
378
  sunset = headers["sunset"] ? " and will be removed after #{headers['sunset']}" : ""
265
379
  link = headers["link"].to_s[/<([^>]+)>/, 1]
266
380
  warn "[nohead] #{operation} is deprecated#{sunset}#{". See #{link}" if link}"
267
381
  end
268
- return unless headers["nohead-usage-warning"] && !@warned[:usage]
382
+ return unless headers["nohead-usage-warning"] && self.class.first_warning?(:usage)
269
383
 
270
- @warned[:usage] = true
271
384
  warn "[nohead] Over a plan limit: #{headers['nohead-usage-warning']}"
272
385
  end
273
386
 
data/lib/nohead/errors.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Nohead
4
6
  # The base of every error the SDK raises.
5
7
  class Error < StandardError; end
@@ -18,22 +20,36 @@ module Nohead
18
20
  attr_reader :headers
19
21
  # The parsed response body, or its text when it was not JSON.
20
22
  attr_reader :body
23
+ # The API's own message, without the status, type and request ID that
24
+ # `message` adds.
25
+ attr_reader :api_message
21
26
 
22
27
  def initialize(status, body, headers)
23
- error = Nohead.error_envelope(body)
28
+ # The SDK doesn't follow redirects: one means the base URL is off.
29
+ error = redirect?(status) ? {} : Nohead.error_envelope(body)
24
30
  @status = status
25
- @type = error["type"]
31
+ @type = redirect?(status) ? "redirect" : error["type"]
26
32
  @request_id = error["request_id"] || headers["x-request-id"]
27
33
  @details = Array(error["details"]).map { |detail| NoheadObject.wrap(detail) }
28
34
  @headers = headers
29
35
  @body = body
30
- super(error["message"] || "Request failed with status #{status}")
36
+ @api_message = if redirect?(status)
37
+ to = headers["location"].to_s.empty? ? "" : " to #{headers['location']}"
38
+ "The API answered with a redirect#{to}; check the base URL"
39
+ else
40
+ error["message"] || "Request failed with status #{status}"
41
+ end
42
+ super(@api_message)
31
43
  end
32
44
 
33
45
  def to_s
34
46
  suffix = request_id ? " (request #{request_id})" : ""
35
47
  "#{status} #{type || 'error'}: #{super}#{suffix}"
36
48
  end
49
+
50
+ private
51
+
52
+ def redirect?(status) = status.between?(300, 399)
37
53
  end
38
54
 
39
55
  class InvalidRequestError < APIError; end
@@ -99,7 +115,8 @@ module Nohead
99
115
 
100
116
  # The error for a response, by its `type`, or by status when it has none.
101
117
  def self.api_error(status, body, headers)
102
- klass = ERROR_CLASSES[error_envelope(body)["type"]] ||
118
+ klass = (APIError if status < 400) ||
119
+ ERROR_CLASSES[error_envelope(body)["type"]] ||
103
120
  if status == 503 then ServiceUnavailableError
104
121
  elsif status >= 500 then InternalServerError
105
122
  else APIError
@@ -114,13 +131,20 @@ module Nohead
114
131
  return {} unless error.is_a?(Hash)
115
132
 
116
133
  fields = error.slice("type", "message", "request_id").select { |_, value| value.is_a?(String) }
117
- # Only hashes: current_revision reads each detail's fields.
118
- fields["details"] = error["details"].grep(Hash) if error["details"].is_a?(Array)
134
+ fields["details"] = error["details"].map { |detail| error_detail(detail) } if error["details"].is_a?(Array)
119
135
  fields
120
136
  end
121
137
 
138
+ # A detail that isn't an object is kept, as its text, with the code "unknown".
139
+ def self.error_detail(detail)
140
+ return detail if detail.is_a?(Hash)
141
+
142
+ { "code" => "unknown", "message" => detail.is_a?(String) ? detail : JSON.generate(detail) }
143
+ end
144
+
145
+ # Seconds from `Retry-After`: a number of seconds or an HTTP date, else nil.
122
146
  def self.retry_after_seconds(headers)
123
- value = headers["retry-after"] or return nil
147
+ value = headers["retry-after"]&.strip or return nil
124
148
  return [Float(value), 0].max if value.match?(/\A\d+(\.\d+)?\z/)
125
149
 
126
150
  [Time.httpdate(value) - Time.now, 0].max
data/lib/nohead/images.rb CHANGED
@@ -13,29 +13,42 @@ module Nohead
13
13
  module_function
14
14
 
15
15
  # A rendition of an image (an asset, as an object or a Hash, or its `image_url`). fit: "contain"
16
- # fits inside the box, "cover" fills it, cropping, "fill" stretches to it.
16
+ # (the default, also for nil) fits inside the box, "cover" fills it, cropping, "fill" stretches to it.
17
17
  # gravity: is what a crop keeps ("sm" for smart, "fp:0.5:0.3" for a focal
18
18
  # point); options: any other processing options, as written in a URL
19
- # (["bl:2"]).
19
+ # (["bl:2"]). An empty string is the same as leaving an option out.
20
20
  def url(image, width: nil, height: nil, fit: "contain", format: nil, quality: nil, dpr: nil,
21
21
  gravity: nil, options: [])
22
+ gravity, format = [gravity, format].map { |value| value unless value.to_s.empty? }
23
+ options = [*options].reject { |option| option.to_s.empty? }
22
24
  base = base_of(image)
23
25
  check_options([gravity, *options])
24
26
  at = base.index("/plain/")
25
- parts = [resize(fit, width, height), ("g:#{gravity}" if gravity), ("dpr:#{dpr}" if dpr),
27
+ dpr = number(dpr)
28
+ quality = number(quality)
29
+ parts = [resize(fit, number(width), number(height)), ("g:#{gravity}" if gravity), ("dpr:#{dpr}" if dpr),
26
30
  ("q:#{quality}" if quality), *options].compact
27
31
  base[0...at] + parts.map { |part| "/#{part}" }.join + source(base[at..], format)
28
32
  end
29
33
 
30
- # rs:<fit>:<width>:<height>, or nothing without a size (0 is none).
34
+ # rs:<fit>:<width>:<height>, or nothing without a size.
31
35
  def resize(fit, width, height)
32
- type = FITS.fetch(fit.to_s) { raise ArgumentError, "fit must be one of #{FITS.keys.join(', ')}" }
33
- width = nil if width&.zero?
34
- height = nil if height&.zero?
36
+ fit = fit.to_s.empty? ? "contain" : fit.to_s
37
+ type = FITS.fetch(fit) { raise ArgumentError, "fit must be one of #{FITS.keys.join(', ')}" }
35
38
  "rs:#{type}:#{width || 0}:#{height || 0}" if width || height
36
39
  end
37
40
  private_class_method :resize
38
41
 
42
+ # A number as the other SDKs write it (2.0 is 2), or nil for none (0 and "" are none).
43
+ def number(value)
44
+ return nil if value == ""
45
+ return value unless value.is_a?(Numeric)
46
+ return nil if value.zero?
47
+
48
+ value.is_a?(Float) && value.finite? && value == value.round ? value.round : value
49
+ end
50
+ private_class_method :number
51
+
39
52
  def base_of(image)
40
53
  base = case image
41
54
  when String then image
@@ -6,7 +6,6 @@ module Nohead
6
6
  OPERATIONS = {
7
7
  "assets_complete" => ["POST", "/v1/assets/{asset_id}/complete"],
8
8
  "assets_delete" => ["DELETE", "/v1/assets/{asset_id}"],
9
- "assets_download_url" => ["GET", "/v1/assets/{asset_id}/download-url"],
10
9
  "assets_get" => ["GET", "/v1/assets/{asset_id}"],
11
10
  "assets_list" => ["GET", "/v1/projects/{project_id}/assets"],
12
11
  "assets_purge" => ["POST", "/v1/assets/{asset_id}/purge"],
@@ -7,8 +7,9 @@ module Nohead
7
7
  # Uploads a file and returns the `ready` asset: creates the upload, sends
8
8
  # the bytes straight to storage, then completes it (which checks the
9
9
  # file). `file` is a path or an IO; for bytes, pass StringIO.new(bytes).
10
- # Raises UploadError if storage refuses the bytes, and ValidationError if
11
- # the file fails the checks.
10
+ # Raises UploadError if the bytes don't reach storage (it refuses them,
11
+ # can't be reached or times out, with the ConnectionError as its
12
+ # `cause`), and ValidationError if the file fails the checks.
12
13
  #
13
14
  # With an `idempotency_key`, running the same upload again resumes it:
14
15
  # the key starts the upload, and an asset that is already `ready` is
@@ -31,7 +32,11 @@ module Nohead
31
32
  end
32
33
  end
33
34
  upload = created.upload
34
- response = @client.put_upload(upload.url, upload["method"], upload.headers.to_h, source)
35
+ response = begin
36
+ @client.put_upload(upload.url, upload["method"], upload.headers.to_h, source)
37
+ rescue ConnectionError => e
38
+ raise UploadError, "Could not upload #{source.filename}: #{e.message}"
39
+ end
35
40
  ensure
36
41
  source.close
37
42
  end
@@ -105,21 +110,6 @@ module Nohead
105
110
  def usage(asset)
106
111
  @client.request("assets_usage", path: { asset_id: asset })
107
112
  end
108
-
109
- # A link to download the original file (`url`), valid for 2 minutes.
110
- def download_url(asset)
111
- @client.request("assets_download_url", path: { asset_id: asset })
112
- end
113
-
114
- private
115
-
116
- # One value as it is, several as `[in]`; an empty list (like an asset
117
- # field's accepted_types) is no filter.
118
- def one_or_in(value)
119
- return value unless value.is_a?(Array)
120
-
121
- value.empty? ? nil : { in: value }
122
- end
123
113
  end
124
114
  end
125
115
  end
@@ -14,8 +14,7 @@ module Nohead
14
14
  # `sort` is "-created_at" (the default) or "created_at".
15
15
  def list(filter: nil, sort: nil, limit: nil, cursor: nil)
16
16
  filter = filter&.transform_keys(&:to_sym)
17
- types = filter&.[](:resource_type)
18
- filter = filter.merge(resource_type: { in: types }) if types.is_a?(Array)
17
+ filter[:resource_type] = one_or_in(filter[:resource_type]) if filter&.key?(:resource_type)
19
18
  @client.paginate("audit_events_list",
20
19
  query: { filter: filter, sort: sort, limit: limit, cursor: cursor })
21
20
  end
@@ -25,6 +25,14 @@ module Nohead
25
25
 
26
26
  params
27
27
  end
28
+
29
+ # One value as it is, several as `{ in: [...] }`; an empty list (like an
30
+ # asset field's accepted_types) is no filter.
31
+ def one_or_in(value)
32
+ return value unless value.is_a?(Array)
33
+
34
+ value.empty? ? nil : { in: value }
35
+ end
28
36
  end
29
37
 
30
38
  # Records: the content of a collection. `collection` is a collection ID or
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "openssl"
5
5
  require "uri"
6
+ require "zlib"
6
7
 
7
8
  module Nohead
8
9
  # A response: status, headers (lowercase names) and body text.
@@ -20,15 +21,23 @@ module Nohead
20
21
  elsif body
21
22
  request.body = body
22
23
  end
23
- http = Net::HTTP.new(uri.host, uri.port)
24
- http.use_ssl = uri.scheme == "https"
25
- http.open_timeout = http.read_timeout = http.write_timeout = timeout
26
- response = http.start { http.request(request) }
24
+ # The proxy comes from the environment (http_proxy, no_proxy), as Net::HTTP.new.
25
+ # No retries of its own (it retries a GET, PUT or DELETE that times out
26
+ # or loses its connection once, and a PUT with the stream already
27
+ # read): the client retries, and knows when it can.
28
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout,
29
+ read_timeout: timeout, write_timeout: timeout,
30
+ max_retries: 0) do |http|
31
+ http.request(request)
32
+ end
27
33
  Response.new(response.code.to_i, response.each_header.to_h, response.body.to_s)
28
34
  rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
29
35
  raise TimeoutError, "The request to #{uri.host} timed out after #{timeout} s"
30
36
  rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
31
37
  raise ConnectionError, "Could not reach #{uri.host}: #{e.message}"
38
+ # A broken status line, header or compressed body: retried like a dropped connection.
39
+ rescue Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError, Zlib::Error => e
40
+ raise ConnectionError, "The response from #{uri.host} could not be read: #{e.message}"
32
41
  end
33
42
  end
34
43
  end
@@ -19,16 +19,18 @@ module Nohead
19
19
 
20
20
  def initialize(file, filename: nil, content_type: nil, byte_size: nil)
21
21
  name, size = file.is_a?(String) || file.is_a?(Pathname) ? open_path(Pathname(file)) : open_io(file)
22
- @filename = filename || name || "upload"
22
+ # An empty filename or content type is the same as none.
23
+ @filename = present(filename) || present(name) || "upload"
23
24
  @byte_size = byte_size || size or
24
25
  raise UploadError, "Uploading a stream that cannot seek needs byte_size:"
25
- @content_type = content_type ||
26
+ @content_type = present(content_type) ||
26
27
  CONTENT_TYPES.fetch(File.extname(@filename).downcase, "application/octet-stream")
27
- @start = @io.respond_to?(:pos) ? @io.pos : 0
28
+ @start = position(@io)
28
29
  end
29
30
 
30
- # Whether the bytes can be sent again (for retries).
31
- def replayable? = @io.respond_to?(:rewind) && @io.respond_to?(:pos)
31
+ # Whether the bytes can be sent again (for retries): a pipe or socket
32
+ # can't seek back to where they start.
33
+ def replayable? = !@start.nil? && @io.respond_to?(:pos=)
32
34
 
33
35
  def rewind
34
36
  @io.pos = @start if replayable?
@@ -40,6 +42,10 @@ module Nohead
40
42
 
41
43
  private
42
44
 
45
+ def present(value)
46
+ value unless value.to_s.empty?
47
+ end
48
+
43
49
  def open_path(path)
44
50
  @io = path.open("rb")
45
51
  @owned = true
@@ -54,6 +60,12 @@ module Nohead
54
60
  [name, remaining(io)]
55
61
  end
56
62
 
63
+ def position(io)
64
+ io.pos if io.respond_to?(:pos)
65
+ rescue IOError, SystemCallError
66
+ nil
67
+ end
68
+
57
69
  def remaining(io)
58
70
  return io.size - io.pos if io.respond_to?(:size) && io.respond_to?(:pos)
59
71
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nohead
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -31,8 +31,8 @@ module Nohead
31
31
  raise WebhookVerificationError, "The webhook timestamp is too far from the current time"
32
32
  end
33
33
 
34
- key = secret.delete_prefix("whsec_").unpack1("m0")
35
- expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")].pack("m0")
34
+ # The body as bytes too, so a non-ASCII id and body in different encodings combine.
35
+ expected = [OpenSSL::HMAC.digest("SHA256", key(secret), "#{id}.#{timestamp}.#{body.to_s.b}")].pack("m0")
36
36
  matched = signatures.split.any? do |entry|
37
37
  version, signature = entry.split(",", 2)
38
38
  version == "v1" && signature && OpenSSL.secure_compare(signature, expected)
@@ -40,16 +40,32 @@ module Nohead
40
40
  raise WebhookVerificationError, "No webhook signature matches" unless matched
41
41
 
42
42
  NoheadObject.wrap(JSON.parse(body))
43
- rescue ArgumentError
44
- raise WebhookVerificationError, "The webhook secret is not base64"
45
43
  rescue JSON::ParserError
46
44
  raise WebhookVerificationError, "The webhook body is not JSON"
47
45
  end
48
46
 
49
- def self.header(headers, name)
50
- return headers[name] || headers[name.split("-").map(&:capitalize).join("-")] unless headers.is_a?(Hash)
47
+ def self.key(secret)
48
+ key = secret.to_s.b.delete_prefix("whsec_").unpack1("m0")
49
+ # Unset (nil) or empty, as from a missing environment variable.
50
+ raise WebhookVerificationError, "The webhook secret is empty" if key.empty?
51
+
52
+ key
53
+ rescue ArgumentError
54
+ raise WebhookVerificationError, "The webhook secret is not base64"
55
+ end
56
+ private_class_method :key
51
57
 
52
- headers.find { |key, _| key.to_s.downcase == name }&.last
58
+ # As bytes: headers come in any encoding, and not always valid, which
59
+ # must not raise ArgumentError or Encoding::CompatibilityError.
60
+ def self.header(headers, name)
61
+ value = if headers.is_a?(Hash)
62
+ headers.find { |key, _| key.to_s.downcase == name }&.last
63
+ else
64
+ headers[name] || headers[name.split("-").map(&:capitalize).join("-")]
65
+ end
66
+ # A repeated header, as some frameworks give it: its values, space-separated.
67
+ value = value.join(" ") if value.is_a?(Array)
68
+ value&.b
53
69
  end
54
70
  private_class_method :header
55
71
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nohead
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nohead