nohead 0.8.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 +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +45 -14
- data/lib/nohead/client.rb +175 -62
- data/lib/nohead/errors.rb +31 -7
- data/lib/nohead/images.rb +20 -7
- data/lib/nohead/resources/assets.rb +8 -13
- data/lib/nohead/resources/other.rb +1 -2
- data/lib/nohead/resources/records.rb +8 -0
- data/lib/nohead/transport.rb +13 -4
- data/lib/nohead/uploads.rb +17 -5
- data/lib/nohead/version.rb +1 -1
- data/lib/nohead/webhooks.rb +23 -7
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68e5883cc4d13b186ac691a79aeb84b5a10462c2e350ddbffe17cd832ffc6d30
|
|
4
|
+
data.tar.gz: 702a971c87efbda7fdc438410984f93fd7f315ca655958e6510b6027ca970089
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f8f38c6c8844668251282d375d1f20373621460aefb0ab340beffa29c8bb000fc38025b17746fae7b7dde10f7007dee92806240e3f0de996774e7ebfeef6e234
|
|
7
|
+
data.tar.gz: 387779fad0fd93d5eb68e4956e434ee2eedbb70ad0f00f1fbc80f23b8d34891eaf8338d27c6b97927432951846d85aa7ea921065b741043a185bf2903a1d03d4
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,96 @@ 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
|
+
|
|
8
98
|
## 0.8.0
|
|
9
99
|
|
|
10
100
|
- Assets have `file_url`, the file as uploaded, which downloads under its
|
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
|
|
49
|
-
|
|
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:` | `
|
|
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` |
|
|
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)
|
|
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
|
|
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
|
|
@@ -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
|
|
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`.
|
|
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::
|
|
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::
|
|
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) ||
|
|
34
|
-
|
|
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 =
|
|
74
|
+
@headers = merge_headers({}, headers.to_h)
|
|
39
75
|
@warnings = warnings
|
|
76
|
+
@on_retry = on_retry
|
|
40
77
|
@transport = transport
|
|
41
|
-
|
|
78
|
+
build_resources
|
|
79
|
+
end
|
|
42
80
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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
|
-
@
|
|
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(
|
|
81
|
-
|
|
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 =
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
unless
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"] &&
|
|
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"] &&
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
+
# rs:<fit>:<width>:<height>, or nothing without a size.
|
|
31
35
|
def resize(fit, width, height)
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
@@ -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
|
|
11
|
-
#
|
|
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 =
|
|
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,16 +110,6 @@ module Nohead
|
|
|
105
110
|
def usage(asset)
|
|
106
111
|
@client.request("assets_usage", path: { asset_id: asset })
|
|
107
112
|
end
|
|
108
|
-
|
|
109
|
-
private
|
|
110
|
-
|
|
111
|
-
# One value as it is, several as `[in]`; an empty list (like an asset
|
|
112
|
-
# field's accepted_types) is no filter.
|
|
113
|
-
def one_or_in(value)
|
|
114
|
-
return value unless value.is_a?(Array)
|
|
115
|
-
|
|
116
|
-
value.empty? ? nil : { in: value }
|
|
117
|
-
end
|
|
118
113
|
end
|
|
119
114
|
end
|
|
120
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
|
-
|
|
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
|
data/lib/nohead/transport.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
data/lib/nohead/uploads.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
28
|
+
@start = position(@io)
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
# Whether the bytes can be sent again (for retries)
|
|
31
|
-
|
|
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
|
|
data/lib/nohead/version.rb
CHANGED
data/lib/nohead/webhooks.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
50
|
-
|
|
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
|
-
|
|
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
|