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