nohead 0.2.0 → 0.4.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 +106 -0
- data/README.md +46 -21
- data/lib/nohead/client.rb +22 -11
- data/lib/nohead/errors.rb +14 -3
- data/lib/nohead/nohead_object.rb +14 -8
- data/lib/nohead/operations.rb +38 -8
- data/lib/nohead/resources/assets.rb +38 -6
- data/lib/nohead/resources/drafts.rb +67 -0
- data/lib/nohead/resources/other.rb +10 -3
- data/lib/nohead/resources/records.rb +72 -27
- data/lib/nohead/resources/schema.rb +27 -15
- data/lib/nohead/version.rb +1 -1
- data/lib/nohead/webhooks.rb +40 -8
- data/lib/nohead.rb +2 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 747275a28bad0e0410f52109f1bac03d8d703fedccafe3110f21ae904421d511
|
|
4
|
+
data.tar.gz: 696d827a38b169e1a047b38a40d9263417cff3f111cec74d825a77236072a62c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e82adbab22c4ea949feccc1c347926b5c2a6795f087b02a8915255f4c7a49f56b53e088ae4b8579374c3db27a59c8bf12c428970d28f83124f047f120c08c7bb
|
|
7
|
+
data.tar.gz: 6c0c8b4dfc63a7eb8d89cc76030e7cf9797ba28541bd677b1da117b7975039d5b3742677230057ff29c4826ac7abf47acd1e3cfba3b38bb84809c4fa4cf233da
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,112 @@ 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.4.0
|
|
9
|
+
|
|
10
|
+
- **Breaking:** a field's `api_key` is its `key` (in fields, their aliases,
|
|
11
|
+
`fields.create(..., key:)` and `fields.update(..., key:)`), as the
|
|
12
|
+
editor's "Field key" says. API keys keep their name.
|
|
13
|
+
- **Breaking:** `collections.schema_changes.get(schema_change)` takes the
|
|
14
|
+
schema change's ID alone (`GET /v1/schema-changes/{id}`).
|
|
15
|
+
- **Breaking:** `fields.update` no longer takes `type` or `multiple`; they
|
|
16
|
+
rewrite record values, so they are `fields.migrate`. The API refuses a
|
|
17
|
+
body key an operation doesn't take with `400` (`unknown_parameter`,
|
|
18
|
+
naming it), so a misspelt keyword no longer passes unnoticed.
|
|
19
|
+
- The contract types each field's `configuration` by field type; the API
|
|
20
|
+
reference lists each type's options.
|
|
21
|
+
|
|
22
|
+
## 0.3.0
|
|
23
|
+
|
|
24
|
+
- **Upgrade required:** 0.1 and 0.2 no longer work with the current API,
|
|
25
|
+
whose paths changed.
|
|
26
|
+
- `change_note` is sent percent-encoded (UTF-8), so change notes work in any
|
|
27
|
+
language: a note outside ASCII used to fail the request.
|
|
28
|
+
- Drafts: changes to a record that wait until they are merged into it, now
|
|
29
|
+
or at a set time. `drafts.create`, `list`, `get` (with `expand`, for
|
|
30
|
+
previews), `update`, `discard`, `merge`, `schedule` and `unschedule`, and
|
|
31
|
+
`records.drafts`. `records.publish` on a live record with a pending draft
|
|
32
|
+
raises `ConflictError` (`draft_pending`). Webhook events of `draft.*` carry
|
|
33
|
+
the `draft` and its `draft_id`, and a merge's `record.updated` its
|
|
34
|
+
`draft_id`.
|
|
35
|
+
- **Breaking:** the API is resource-oriented. Method names stay; what they
|
|
36
|
+
return and take follows it:
|
|
37
|
+
- Records have no `status`, `schedule` or `deleted`: a record is published
|
|
38
|
+
when `published_at` is set, and has `deleted_at` and `purge_after`.
|
|
39
|
+
Filter with `{ published_at: { exists: true } }`. `records.get` returns
|
|
40
|
+
deleted records too (no `include_deleted`).
|
|
41
|
+
- `records.schedule` replaces the whole schedule: a time left out is
|
|
42
|
+
cleared, and `clear:` is gone. It and `unschedule` return the schedule,
|
|
43
|
+
and `records.get_schedule`, `records.publication` and
|
|
44
|
+
`records.scheduled_changes` are new. A scheduled change has no
|
|
45
|
+
`last_error`. `records.bulk` returns a record batch.
|
|
46
|
+
- `search` takes `filter:` as `records.list` does
|
|
47
|
+
(`filter: { published_at: { exists: true } }`) instead of
|
|
48
|
+
`status: "published"`.
|
|
49
|
+
- `records.diff(record, from:, to:)` and
|
|
50
|
+
`fields.reorder(collection, field_ids:)` take keywords.
|
|
51
|
+
- `audit_events.list` filters by `type` (was `action`), `resource_type`
|
|
52
|
+
(a type, or an Array of them), `resource_id`, `actor_type`, `actor_id`
|
|
53
|
+
and a `created_at` range (`{ gte: ..., lt: ... }`, was `since` and
|
|
54
|
+
`until`); the API refuses other filters. Events have `object: "event"`
|
|
55
|
+
and their name in `type`, and a field's events the resource type
|
|
56
|
+
`field`.
|
|
57
|
+
- `records.revisions.list` filters by a `created_at` range (was `since`
|
|
58
|
+
and `until`), and a revision's `operation` can be `merge` (no
|
|
59
|
+
`import`).
|
|
60
|
+
- Schema changes have no `status`.
|
|
61
|
+
- Collections, fields, assets and webhooks have `deleted_at` and
|
|
62
|
+
`purge_after` instead of `deleted`, and migrations
|
|
63
|
+
`cancel_requested_at`.
|
|
64
|
+
- `assets.create_upload` returns the asset with its `upload`.
|
|
65
|
+
- `fields.reorder` returns the field order; `fields.order` reads it.
|
|
66
|
+
- Webhook event types are the same as the activity log's
|
|
67
|
+
(`record.restored`, `field.updated`, ..., and wildcards such as
|
|
68
|
+
`record.*`), and events carry
|
|
69
|
+
`actor` and `via`. `webhooks.rotate_secret` returns the new webhook
|
|
70
|
+
secret with its value; the old secret signs for 24 hours more.
|
|
71
|
+
`webhooks.secrets.list`, `webhooks.secrets.retire`, `webhooks.restore`
|
|
72
|
+
and `webhooks.list(deleted: true)` are new. There is no
|
|
73
|
+
`record.imported` event; `field.*` events carry the `field_id`, and
|
|
74
|
+
schema events' `collection` its `object`.
|
|
75
|
+
- `if_match` and `change_note` go where the API takes them:
|
|
76
|
+
`records.restore` takes `if_match` and `records.revisions.revert`
|
|
77
|
+
`change_note`. Methods that take fields as keywords
|
|
78
|
+
(`webhooks.create`, `assets.create_upload`...) raise ArgumentError for
|
|
79
|
+
an `if_match` or `change_note` they don't take, instead of sending it
|
|
80
|
+
as a field.
|
|
81
|
+
- Timestamps are the ones the API declares, so `purge_after`, a
|
|
82
|
+
scheduled change's `at` and `health.check`'s `time` are Times too, and
|
|
83
|
+
a record's field values are left as they come (a field whose name
|
|
84
|
+
ended in `_at` used to be a Time).
|
|
85
|
+
- **Breaking:** the `datetime` field type is now `date`. A plain date field holds
|
|
86
|
+
`YYYY-MM-DD`; with `include_time` it holds a moment, written in the field's
|
|
87
|
+
`time_zone` when it has one. Field migrations take `time_zone`, the zone
|
|
88
|
+
whose day each moment falls on when the time is removed.
|
|
89
|
+
- Boolean fields can't be `required`: a boolean is true or false, and no
|
|
90
|
+
value reads as false.
|
|
91
|
+
- Text fields take a `format` (`email`, `url`, `slug`; slugs are always
|
|
92
|
+
unique) and text and integer fields `unique`; a taken value's error detail
|
|
93
|
+
has the `record_id` that has it (`code: "taken"`).
|
|
94
|
+
- Filters take operators: `{ price: { lt: 50 } }`, with `eq`, `ne`, `gt`,
|
|
95
|
+
`gte`, `lt`, `lte`, `in` (an array) and `exists`, and record lists sort by
|
|
96
|
+
a field's value. Commas and backslashes in array values are escaped, so a
|
|
97
|
+
value can hold a comma.
|
|
98
|
+
- Date filters take `today`, and dates with a time `now`.
|
|
99
|
+
- `assets.usage`: where an asset is used, as counts of the records that use
|
|
100
|
+
it (and of those, the published ones) and the 10 most recently updated with
|
|
101
|
+
the fields that use it. Needs the `records:read` scope too.
|
|
102
|
+
- `assets.purge`: permanently deletes a deleted asset now, instead of at its
|
|
103
|
+
`purge_after`, and frees its storage.
|
|
104
|
+
- `fields.get`.
|
|
105
|
+
- `assets.upload` with an `idempotency_key` resumes: an asset that is
|
|
106
|
+
already `ready` comes back without sending the bytes again, and one whose
|
|
107
|
+
upload URL expired raises `UploadError` (start over with a new key).
|
|
108
|
+
- An error reads only the fields of the API's error envelope that have the
|
|
109
|
+
right type, so JSON from a proxy or gateway that merely looks like one no
|
|
110
|
+
longer gives it a non-string `message`, `type` or `request_id`, turns a
|
|
111
|
+
`details` object into key-value pairs, or makes `current_revision` raise on
|
|
112
|
+
a detail that isn't an object.
|
|
113
|
+
|
|
8
114
|
## 0.2.0
|
|
9
115
|
|
|
10
116
|
- Types follow the API's current contract. They add types only for
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ require "nohead"
|
|
|
7
7
|
|
|
8
8
|
nohead = Nohead::Client.new # reads NOHEAD_API_KEY
|
|
9
9
|
|
|
10
|
-
nohead.records.list("posts", filter: {
|
|
10
|
+
nohead.records.list("posts", filter: { published_at: { exists: true } }).each do |post|
|
|
11
11
|
puts post.data["title"]
|
|
12
12
|
end
|
|
13
13
|
```
|
|
@@ -19,6 +19,7 @@ end
|
|
|
19
19
|
- [Installation](#installation)
|
|
20
20
|
- [Configuration](#configuration)
|
|
21
21
|
- [Records](#records)
|
|
22
|
+
- [Drafts](#drafts)
|
|
22
23
|
- [Pagination](#pagination)
|
|
23
24
|
- [Errors](#errors)
|
|
24
25
|
- [Retries and idempotency](#retries-and-idempotency)
|
|
@@ -64,11 +65,12 @@ API keys belong to a project, so methods like `collections.list` need no project
|
|
|
64
65
|
## Records
|
|
65
66
|
|
|
66
67
|
```ruby
|
|
67
|
-
|
|
68
|
-
post = nohead.records.get(
|
|
68
|
+
created = nohead.records.create("posts", data: { title: "Hello", author: "rec_01J9..." })
|
|
69
|
+
post = nohead.records.get(created.id, expand: ["author"])
|
|
69
70
|
nohead.records.update(post.id, data: { title: "Hello again" }) # nil clears a field
|
|
70
71
|
nohead.records.publish(post.id)
|
|
71
|
-
nohead.records.
|
|
72
|
+
nohead.records.publication(post.id) # since when, by whom, which revision
|
|
73
|
+
nohead.records.schedule(post.id, unpublish_at: Time.utc(2027, 1, 1)) # the whole schedule: a time left out is cleared
|
|
72
74
|
nohead.records.delete(post.id) # soft delete; records.restore undoes it
|
|
73
75
|
```
|
|
74
76
|
|
|
@@ -77,8 +79,24 @@ Methods return the API's resources (see [Results](#results)) and raise on failur
|
|
|
77
79
|
**More:**
|
|
78
80
|
|
|
79
81
|
- `count`, and `bulk` (up to 100 records at once)
|
|
80
|
-
- `diff(record,
|
|
81
|
-
- `revisions.list`, `revisions.get` and `revisions.revert` (with `dry_run: true` for a preview)
|
|
82
|
+
- `diff(record, from: 1, to: 2)`
|
|
83
|
+
- `revisions.list` (`filter:` `operation`, `actor_id` and a `created_at` range such as `{ gte: Time.utc(2026, 10, 1) }`), `revisions.get` and `revisions.revert` (with `dry_run: true` for a preview)
|
|
84
|
+
|
|
85
|
+
## Drafts
|
|
86
|
+
|
|
87
|
+
A draft holds changes to a record until they are merged, now or at a set time, while the record keeps its current content: changes to review before they go live, or changes due later.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
draft = nohead.drafts.create(post.id, data: { title: "Hello, revised" })
|
|
91
|
+
nohead.drafts.update(draft.id, data: { summary: "..." }, if_match: draft)
|
|
92
|
+
preview = nohead.drafts.get(draft.id, expand: ["author"]) # data as it would read
|
|
93
|
+
nohead.drafts.merge(draft.id) # or drafts.schedule(draft.id, merge_at: ...)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
- A record has at most one draft; starting a second raises `Nohead::ConflictError` (detail code `draft_exists`, with its `draft_id`).
|
|
97
|
+
- A merge changes content only: a live record stays live, an unpublished one unpublished. Publishing a live record with a pending draft raises `ConflictError` (`draft_pending`).
|
|
98
|
+
- `records.drafts(id)` lists a record's draft, and `drafts.list(collection, record_id: ...)` a collection's (`record_id` takes up to 100 IDs).
|
|
99
|
+
- `drafts.discard` deletes one for good.
|
|
82
100
|
|
|
83
101
|
## Pagination
|
|
84
102
|
|
|
@@ -99,11 +117,14 @@ page = page.next_page while page.next_page?
|
|
|
99
117
|
nohead.records.list("posts", cursor: saved_cursor)
|
|
100
118
|
```
|
|
101
119
|
|
|
102
|
-
Filters
|
|
120
|
+
Filters take a value to equal, or operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in` (an array) and `exists`. They combine with AND. On fields with several values, equal means "contains". Values are strings, numbers, booleans, Times (for date fields with a time) and Dates (for plain date fields, sent as `YYYY-MM-DD`); dates take `"today"` too, and dates with a time `"now"`. Lists sort by a field as well, with records that have no value last:
|
|
103
121
|
|
|
104
122
|
```ruby
|
|
105
|
-
nohead.records.list("posts", filter: {
|
|
123
|
+
nohead.records.list("posts", filter: { published_at: { exists: true }, featured: true, author: "rec_01J9..." },
|
|
106
124
|
sort: "-published_at", expand: %w[author tags])
|
|
125
|
+
|
|
126
|
+
nohead.records.list("products", filter: { price: { lt: 50 }, category: { in: %w[shoes bags] }, cover: { exists: true } },
|
|
127
|
+
sort: "price")
|
|
107
128
|
```
|
|
108
129
|
|
|
109
130
|
## Errors
|
|
@@ -151,7 +172,7 @@ Every write gets an `Idempotency-Key` that stays the same across its retries, so
|
|
|
151
172
|
nohead.records.create("posts", data: data, idempotency_key: "import-#{row.id}")
|
|
152
173
|
```
|
|
153
174
|
|
|
154
|
-
Writes also take `change_note:`, a reason shown in history.
|
|
175
|
+
Writes that change records or the schema also take `change_note:`, a reason shown in history.
|
|
155
176
|
|
|
156
177
|
## Concurrency
|
|
157
178
|
|
|
@@ -166,7 +187,7 @@ rescue Nohead::PreconditionFailedError => e
|
|
|
166
187
|
end
|
|
167
188
|
```
|
|
168
189
|
|
|
169
|
-
`if_match:` takes a record or a revision number, on `update`, `delete`, `publish`, `unpublish` and `revisions.revert`.
|
|
190
|
+
`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`.
|
|
170
191
|
|
|
171
192
|
## Assets
|
|
172
193
|
|
|
@@ -187,6 +208,8 @@ url = nohead.assets.image_url(asset.id, width: 1200, format: "webp").url
|
|
|
187
208
|
|
|
188
209
|
**Errors:** `UploadError` if storage refuses the bytes; `ValidationError` if the file fails the checks.
|
|
189
210
|
|
|
211
|
+
**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.
|
|
212
|
+
|
|
190
213
|
**Uploading from a browser:** create the upload on your server with `create_upload`, `PUT` the file from the browser, then `complete` it.
|
|
191
214
|
|
|
192
215
|
## Search
|
|
@@ -196,7 +219,7 @@ url = nohead.assets.image_url(asset.id, width: 1200, format: "webp").url
|
|
|
196
219
|
nohead.records.search("posts", "content model").each { |hit| ... }
|
|
197
220
|
|
|
198
221
|
# Across the project
|
|
199
|
-
results = nohead.search("content model", collections: %w[posts pages])
|
|
222
|
+
results = nohead.search("content model", collections: %w[posts pages], filter: { published_at: { exists: true } })
|
|
200
223
|
results.meta.total_estimate
|
|
201
224
|
```
|
|
202
225
|
|
|
@@ -206,8 +229,8 @@ Search needs `search_enabled` collections and the `search:read` scope. It pages
|
|
|
206
229
|
|
|
207
230
|
```ruby
|
|
208
231
|
nohead.collections.create(name: "Posts", slug: "posts",
|
|
209
|
-
fields: [{ name: "Title",
|
|
210
|
-
nohead.fields.create("posts", name: "Summary",
|
|
232
|
+
fields: [{ name: "Title", key: "title", type: "text", required: true }])
|
|
233
|
+
nohead.fields.create("posts", name: "Summary", key: "summary", type: "long_text")
|
|
211
234
|
|
|
212
235
|
# Changes that rewrite records go through a migration; preview first
|
|
213
236
|
preview = nohead.fields.migrate("fld_...", type: "long_text", dry_run: true)
|
|
@@ -231,7 +254,7 @@ end
|
|
|
231
254
|
head :no_content
|
|
232
255
|
```
|
|
233
256
|
|
|
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.
|
|
257
|
+
Event types are the same as the activity log's (`record.published`, `record.restored`, `field.updated`, `asset.purged`, ...), and a webhook subscribes to types or wildcards (`record.*`, `*`). `event.actor` says who made the change, and `event.via` through which client. `unwrap` checks the signature and the timestamp (Standard Webhooks), and raises `Nohead::WebhookVerificationError` if either is off. After `rotate_secret`, requests carry a signature for the old secret and the new one for 24 hours, so switch the receiver's secret within that day (or end it sooner with `webhooks.secrets.retire`). Pass the raw body: parsing and re-serializing JSON changes the bytes.
|
|
235
258
|
|
|
236
259
|
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
260
|
|
|
@@ -241,7 +264,7 @@ The API's resources come back as `Nohead::NoheadObject`s.
|
|
|
241
264
|
|
|
242
265
|
- **Reading fields:** use methods (`record.data`) or `[]` (`record[:data]`, `record["data"]`), at any depth.
|
|
243
266
|
- **Fields named like Ruby's own methods** (`method`, `hash`…) need `[]`.
|
|
244
|
-
- **Timestamps** (
|
|
267
|
+
- **Timestamps** (the API's: `created_at`, `purge_after`, a scheduled change's `at`...) are `Time`s. A record's field values (`record.data`) stay as they come.
|
|
245
268
|
- **Unknown fields are kept**, because the API adds fields and values without notice.
|
|
246
269
|
- **`to_h`** gives the raw Hash.
|
|
247
270
|
|
|
@@ -249,16 +272,18 @@ The API's resources come back as `Nohead::NoheadObject`s.
|
|
|
249
272
|
|
|
250
273
|
| Resource | Methods |
|
|
251
274
|
|---|---|
|
|
252
|
-
| `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `schedule`, `unschedule`, `count`, `bulk`, `diff`, `search` |
|
|
275
|
+
| `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `publication`, `get_schedule`, `schedule`, `unschedule`, `scheduled_changes`, `drafts`, `count`, `bulk`, `diff`, `search` |
|
|
276
|
+
| `drafts` | `create`, `list`, `get`, `update`, `discard`, `merge`, `schedule`, `unschedule` |
|
|
253
277
|
| `records.revisions` | `list`, `get`, `revert` |
|
|
254
278
|
| `search` | across the project |
|
|
255
279
|
| `collections` | `list`, `get`, `create`, `update`, `delete`, `restore`, `schema` |
|
|
256
280
|
| `collections.schema_changes` | `list`, `get` |
|
|
257
281
|
| `collections.search_index` | `get`, `rebuild` |
|
|
258
|
-
| `fields` | `list`, `create`, `update`, `delete`, `restore`, `reorder`, `remove_alias`, `migrate` |
|
|
282
|
+
| `fields` | `list`, `get`, `create`, `update`, `delete`, `restore`, `order`, `reorder`, `remove_alias`, `migrate` |
|
|
259
283
|
| `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` |
|
|
284
|
+
| `assets` | `upload`, `create_upload`, `complete`, `list`, `get`, `delete`, `restore`, `purge`, `usage`, `image_url`, `download_url` |
|
|
285
|
+
| `webhooks` | `list`, `get`, `create`, `update`, `delete`, `restore`, `rotate_secret`, `test`, `unwrap` |
|
|
286
|
+
| `webhooks.secrets` | `list`, `retire` |
|
|
262
287
|
| `webhooks.deliveries` | `list`, `get`, `retry` |
|
|
263
288
|
| `audit_events` | `list` |
|
|
264
289
|
| `feature_flags` | `list` |
|
|
@@ -281,7 +306,7 @@ bundle exec rake samples # after changing test/calls.rb (the docs' code sampl
|
|
|
281
306
|
|
|
282
307
|
- `openapi.json` is the API's published contract. `rake generate` derives the operation table, `lib/nohead/operations.rb`, from it.
|
|
283
308
|
- 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, 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.
|
|
309
|
+
- `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.
|
|
285
310
|
|
|
286
311
|
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
312
|
|
|
@@ -291,7 +316,7 @@ The smoke test (`smoke/smoke.rb`) runs the core flow against a real API, with th
|
|
|
291
316
|
2. Add a section for the version to `CHANGELOG.md` (`## 1.2.3`), which becomes the release's notes.
|
|
292
317
|
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
318
|
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
|
|
319
|
+
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.
|
|
295
320
|
|
|
296
321
|
## License
|
|
297
322
|
|
data/lib/nohead/client.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Nohead
|
|
|
15
15
|
RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
16
16
|
MAX_RETRY_AFTER = 60
|
|
17
17
|
|
|
18
|
-
attr_reader :records, :collections, :fields, :migrations, :assets, :webhooks,
|
|
18
|
+
attr_reader :records, :drafts, :collections, :fields, :migrations, :assets, :webhooks,
|
|
19
19
|
:audit_events, :feature_flags, :me, :health
|
|
20
20
|
|
|
21
21
|
# api_key:: A project API key ("sk_live_..."). Defaults to NOHEAD_API_KEY.
|
|
@@ -40,6 +40,7 @@ module Nohead
|
|
|
40
40
|
@warned = {}
|
|
41
41
|
|
|
42
42
|
@records = Resources::Records.new(self)
|
|
43
|
+
@drafts = Resources::Drafts.new(self)
|
|
43
44
|
@collections = Resources::Collections.new(self)
|
|
44
45
|
@fields = Resources::Fields.new(self)
|
|
45
46
|
@migrations = Resources::Migrations.new(self)
|
|
@@ -52,12 +53,12 @@ module Nohead
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# Full-text search across the key's project (or some of its collections),
|
|
55
|
-
# most relevant first, through the first 1,000 hits.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
# most relevant first, through the first 1,000 hits. `filter` takes
|
|
57
|
+
# `published_at` ({ published_at: { exists: true } } for published
|
|
58
|
+
# records), as records.list does.
|
|
59
|
+
def search(query, collections: nil, filter: nil, limit: nil, cursor: nil)
|
|
60
|
+
filter = { collection: (collections.nil? ? nil : { in: collections }), **filter.to_h.transform_keys(&:to_sym) }
|
|
61
|
+
paginate("projects_search", query: { q: query, filter: filter, limit: limit, cursor: cursor })
|
|
61
62
|
end
|
|
62
63
|
|
|
63
64
|
# The API key's project, from GET /v1/me the first time.
|
|
@@ -147,6 +148,10 @@ module Nohead
|
|
|
147
148
|
|
|
148
149
|
def build_url(template, values)
|
|
149
150
|
values = values.transform_keys(&:to_s)
|
|
151
|
+
# A value the template doesn't take is a method out of step with its operation.
|
|
152
|
+
unknown = values.keys - template.scan(/\{(\w+)\}/).flatten
|
|
153
|
+
raise ArgumentError, "#{unknown.join(', ')} is not a path parameter of #{template}" if unknown.any?
|
|
154
|
+
|
|
150
155
|
values["project_id"] ||= project_id if template.include?("{project_id}")
|
|
151
156
|
path = template.gsub(/\{(\w+)\}/) do
|
|
152
157
|
value = values[Regexp.last_match(1)]
|
|
@@ -158,8 +163,9 @@ module Nohead
|
|
|
158
163
|
end
|
|
159
164
|
|
|
160
165
|
# Query parameters as the API reads them: hashes become key[sub]=...
|
|
161
|
-
# (filter[
|
|
162
|
-
#
|
|
166
|
+
# (filter[price][lt]=50), arrays are comma-separated (expand=author,tags)
|
|
167
|
+
# with \, for a comma inside a value and \\ for a backslash, times are
|
|
168
|
+
# ISO 8601. nil values are left out.
|
|
163
169
|
def query_string(params)
|
|
164
170
|
pairs = []
|
|
165
171
|
add = lambda do |key, value|
|
|
@@ -167,7 +173,7 @@ module Nohead
|
|
|
167
173
|
when nil then nil
|
|
168
174
|
when Hash then value.each { |name, inner| add.call("#{key}[#{name}]", inner) }
|
|
169
175
|
when Array
|
|
170
|
-
items = value.compact.map { |item| scalar(item) }
|
|
176
|
+
items = value.compact.map { |item| scalar(item).gsub(/[\\,]/) { |char| "\\#{char}" } }
|
|
171
177
|
pairs << [key, items.join(",")] unless items.empty?
|
|
172
178
|
else pairs << [key, scalar(value)]
|
|
173
179
|
end
|
|
@@ -208,7 +214,12 @@ module Nohead
|
|
|
208
214
|
revision = if_match.respond_to?(:revision) ? if_match.revision : if_match
|
|
209
215
|
headers["If-Match"] = %("#{revision}")
|
|
210
216
|
end
|
|
211
|
-
|
|
217
|
+
# Percent-encoded UTF-8, as encodeURIComponent: header values are bytes
|
|
218
|
+
# (the API decodes it). Notes in other encodings are converted first.
|
|
219
|
+
if change_note
|
|
220
|
+
note = change_note.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
221
|
+
headers["Nohead-Change-Note"] = URI.encode_uri_component(note)
|
|
222
|
+
end
|
|
212
223
|
headers
|
|
213
224
|
end
|
|
214
225
|
|
data/lib/nohead/errors.rb
CHANGED
|
@@ -20,7 +20,7 @@ module Nohead
|
|
|
20
20
|
attr_reader :body
|
|
21
21
|
|
|
22
22
|
def initialize(status, body, headers)
|
|
23
|
-
error =
|
|
23
|
+
error = Nohead.error_envelope(body)
|
|
24
24
|
@status = status
|
|
25
25
|
@type = error["type"]
|
|
26
26
|
@request_id = error["request_id"] || headers["x-request-id"]
|
|
@@ -99,8 +99,7 @@ module Nohead
|
|
|
99
99
|
|
|
100
100
|
# The error for a response, by its `type`, or by status when it has none.
|
|
101
101
|
def self.api_error(status, body, headers)
|
|
102
|
-
|
|
103
|
-
klass = ERROR_CLASSES[type] ||
|
|
102
|
+
klass = ERROR_CLASSES[error_envelope(body)["type"]] ||
|
|
104
103
|
if status == 503 then ServiceUnavailableError
|
|
105
104
|
elsif status >= 500 then InternalServerError
|
|
106
105
|
else APIError
|
|
@@ -108,6 +107,18 @@ module Nohead
|
|
|
108
107
|
klass.new(status, body, headers)
|
|
109
108
|
end
|
|
110
109
|
|
|
110
|
+
# The error envelope's fields that have the right type: a proxy or gateway
|
|
111
|
+
# in front of the API may answer with any JSON.
|
|
112
|
+
def self.error_envelope(body)
|
|
113
|
+
error = body["error"] if body.is_a?(Hash)
|
|
114
|
+
return {} unless error.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
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)
|
|
119
|
+
fields
|
|
120
|
+
end
|
|
121
|
+
|
|
111
122
|
def self.retry_after_seconds(headers)
|
|
112
123
|
value = headers["retry-after"] or return nil
|
|
113
124
|
return [Float(value), 0].max if value.match?(/\A\d+(\.\d+)?\z/)
|
data/lib/nohead/nohead_object.rb
CHANGED
|
@@ -6,28 +6,34 @@ module Nohead
|
|
|
6
6
|
# What the SDK returns for the API's resources: read fields as methods
|
|
7
7
|
# (`record.data`) or with `[]` (`record["data"]`), nested objects included.
|
|
8
8
|
# Fields named like Ruby's own methods (`method`, `hash`...) need `[]`.
|
|
9
|
-
# Timestamps (
|
|
10
|
-
#
|
|
9
|
+
# Timestamps (the contract's: `created_at`, `purge_after`, `at`...) are
|
|
10
|
+
# Times; a record's field values (`record.data`) are left as they come,
|
|
11
|
+
# but for the resources `expand` embeds in them. Unknown fields are kept:
|
|
12
|
+
# the API adds fields without notice.
|
|
11
13
|
class NoheadObject
|
|
12
14
|
ISO_TIME = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})\z/
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
# `fields`: the value is (in) a record's field values, whose keys are
|
|
17
|
+
# api_keys, so no timestamps.
|
|
18
|
+
def self.wrap(value, key = nil, fields: false)
|
|
15
19
|
case value
|
|
16
|
-
when Hash then new(value)
|
|
17
|
-
when Array then value.map { |item| wrap(item) }
|
|
20
|
+
when Hash then new(value, fields: fields && !value.key?("object"))
|
|
21
|
+
when Array then value.map { |item| wrap(item, fields: fields) }
|
|
18
22
|
when String
|
|
19
|
-
key.to_s
|
|
23
|
+
!fields && TIMESTAMPS.include?(key.to_s) && value.match?(ISO_TIME) ? Time.iso8601(value) : value
|
|
20
24
|
else value
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
|
|
24
|
-
def initialize(values)
|
|
28
|
+
def initialize(values, fields: false)
|
|
25
29
|
@values = values.to_h { |key, value| [key.to_s, value] }
|
|
30
|
+
@fields = fields
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def [](key)
|
|
29
34
|
key = key.to_s
|
|
30
|
-
|
|
35
|
+
fields = @fields || (key == "data" && RECORD_DATA_OBJECTS.include?(@values["object"]))
|
|
36
|
+
self.class.wrap(@values[key], key, fields: fields)
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def key?(key) = @values.key?(key.to_s)
|
data/lib/nohead/operations.rb
CHANGED
|
@@ -10,9 +10,11 @@ module Nohead
|
|
|
10
10
|
"assets_get" => ["GET", "/v1/assets/{asset_id}"],
|
|
11
11
|
"assets_image_url" => ["GET", "/v1/assets/{asset_id}/image-url"],
|
|
12
12
|
"assets_list" => ["GET", "/v1/projects/{project_id}/assets"],
|
|
13
|
+
"assets_purge" => ["POST", "/v1/assets/{asset_id}/purge"],
|
|
13
14
|
"assets_restore" => ["POST", "/v1/assets/{asset_id}/restore"],
|
|
14
|
-
"assets_upload" => ["POST", "/v1/projects/{project_id}/assets
|
|
15
|
-
"
|
|
15
|
+
"assets_upload" => ["POST", "/v1/projects/{project_id}/assets"],
|
|
16
|
+
"assets_usage" => ["GET", "/v1/assets/{asset_id}/usage"],
|
|
17
|
+
"audit_events_list" => ["GET", "/v1/projects/{project_id}/audit-events"],
|
|
16
18
|
"collections_create" => ["POST", "/v1/projects/{project_id}/collections"],
|
|
17
19
|
"collections_delete" => ["DELETE", "/v1/collections/{collection_id}"],
|
|
18
20
|
"collections_get" => ["GET", "/v1/collections/{collection_id}"],
|
|
@@ -21,13 +23,23 @@ module Nohead
|
|
|
21
23
|
"collections_restore" => ["POST", "/v1/collections/{collection_id}/restore"],
|
|
22
24
|
"collections_search" => ["GET", "/v1/collections/{collection_id}/search"],
|
|
23
25
|
"collections_update" => ["PATCH", "/v1/collections/{collection_id}"],
|
|
26
|
+
"drafts_create" => ["POST", "/v1/records/{record_id}/drafts"],
|
|
27
|
+
"drafts_discard" => ["DELETE", "/v1/drafts/{draft_id}"],
|
|
28
|
+
"drafts_get" => ["GET", "/v1/drafts/{draft_id}"],
|
|
29
|
+
"drafts_list" => ["GET", "/v1/collections/{collection_id}/drafts"],
|
|
30
|
+
"drafts_merge" => ["POST", "/v1/drafts/{draft_id}/merge"],
|
|
31
|
+
"drafts_schedule" => ["PUT", "/v1/drafts/{draft_id}/schedule"],
|
|
32
|
+
"drafts_unschedule" => ["DELETE", "/v1/drafts/{draft_id}/schedule"],
|
|
33
|
+
"drafts_update" => ["PATCH", "/v1/drafts/{draft_id}"],
|
|
24
34
|
"feature_flags_list" => ["GET", "/v1/feature-flags"],
|
|
25
35
|
"fields_create" => ["POST", "/v1/collections/{collection_id}/fields"],
|
|
26
36
|
"fields_delete" => ["DELETE", "/v1/fields/{field_id}"],
|
|
37
|
+
"fields_get" => ["GET", "/v1/fields/{field_id}"],
|
|
38
|
+
"fields_get_order" => ["GET", "/v1/collections/{collection_id}/field-order"],
|
|
27
39
|
"fields_list" => ["GET", "/v1/collections/{collection_id}/fields"],
|
|
28
40
|
"fields_migrate" => ["POST", "/v1/fields/{field_id}/migrations"],
|
|
29
41
|
"fields_remove_alias" => ["DELETE", "/v1/fields/{field_id}/aliases/{alias}"],
|
|
30
|
-
"fields_reorder" => ["
|
|
42
|
+
"fields_reorder" => ["PUT", "/v1/collections/{collection_id}/field-order"],
|
|
31
43
|
"fields_restore" => ["POST", "/v1/fields/{field_id}/restore"],
|
|
32
44
|
"fields_update" => ["PATCH", "/v1/fields/{field_id}"],
|
|
33
45
|
"health_check" => ["GET", "/v1/health"],
|
|
@@ -36,35 +48,53 @@ module Nohead
|
|
|
36
48
|
"migrations_get" => ["GET", "/v1/migrations/{migration_id}"],
|
|
37
49
|
"migrations_list" => ["GET", "/v1/collections/{collection_id}/migrations"],
|
|
38
50
|
"projects_search" => ["GET", "/v1/projects/{project_id}/search"],
|
|
51
|
+
"record_drafts_list" => ["GET", "/v1/records/{record_id}/drafts"],
|
|
39
52
|
"record_revisions_get" => ["GET", "/v1/records/{record_id}/revisions/{revision}"],
|
|
40
53
|
"record_revisions_list" => ["GET", "/v1/records/{record_id}/revisions"],
|
|
41
54
|
"record_revisions_revert" => ["POST", "/v1/records/{record_id}/revisions/{revision}/revert"],
|
|
42
|
-
"records_bulk" => ["POST", "/v1/collections/{collection_id}/
|
|
55
|
+
"records_bulk" => ["POST", "/v1/collections/{collection_id}/record-batches"],
|
|
43
56
|
"records_count" => ["GET", "/v1/collections/{collection_id}/records/count"],
|
|
44
57
|
"records_create" => ["POST", "/v1/collections/{collection_id}/records"],
|
|
45
58
|
"records_delete" => ["DELETE", "/v1/records/{record_id}"],
|
|
46
59
|
"records_diff" => ["GET", "/v1/records/{record_id}/diff"],
|
|
47
60
|
"records_get" => ["GET", "/v1/records/{record_id}"],
|
|
61
|
+
"records_get_publication" => ["GET", "/v1/records/{record_id}/publication"],
|
|
62
|
+
"records_get_schedule" => ["GET", "/v1/records/{record_id}/schedule"],
|
|
48
63
|
"records_list" => ["GET", "/v1/collections/{collection_id}/records"],
|
|
49
|
-
"records_publish" => ["POST", "/v1/records/{record_id}/
|
|
64
|
+
"records_publish" => ["POST", "/v1/records/{record_id}/publication"],
|
|
50
65
|
"records_restore" => ["POST", "/v1/records/{record_id}/restore"],
|
|
51
66
|
"records_schedule" => ["PUT", "/v1/records/{record_id}/schedule"],
|
|
52
|
-
"records_unpublish" => ["
|
|
67
|
+
"records_unpublish" => ["DELETE", "/v1/records/{record_id}/publication"],
|
|
53
68
|
"records_unschedule" => ["DELETE", "/v1/records/{record_id}/schedule"],
|
|
54
69
|
"records_update" => ["PATCH", "/v1/records/{record_id}"],
|
|
55
|
-
"
|
|
70
|
+
"scheduled_changes_list" => ["GET", "/v1/collections/{collection_id}/scheduled-changes"],
|
|
71
|
+
"schema_changes_get" => ["GET", "/v1/schema-changes/{schema_change_id}"],
|
|
56
72
|
"schema_changes_list" => ["GET", "/v1/collections/{collection_id}/schema-changes"],
|
|
57
73
|
"search_index_get" => ["GET", "/v1/collections/{collection_id}/search-index"],
|
|
58
74
|
"search_index_rebuild" => ["POST", "/v1/collections/{collection_id}/search-index/rebuild"],
|
|
59
75
|
"webhook_deliveries_get" => ["GET", "/v1/webhook-deliveries/{delivery_id}"],
|
|
60
76
|
"webhook_deliveries_list" => ["GET", "/v1/webhooks/{webhook_id}/deliveries"],
|
|
61
77
|
"webhook_deliveries_retry" => ["POST", "/v1/webhook-deliveries/{delivery_id}/retry"],
|
|
78
|
+
"webhook_secrets_list" => ["GET", "/v1/webhooks/{webhook_id}/secrets"],
|
|
79
|
+
"webhook_secrets_retire" => ["DELETE", "/v1/webhook-secrets/{secret_id}"],
|
|
62
80
|
"webhooks_create" => ["POST", "/v1/projects/{project_id}/webhooks"],
|
|
63
81
|
"webhooks_delete" => ["DELETE", "/v1/webhooks/{webhook_id}"],
|
|
64
82
|
"webhooks_get" => ["GET", "/v1/webhooks/{webhook_id}"],
|
|
65
83
|
"webhooks_list" => ["GET", "/v1/projects/{project_id}/webhooks"],
|
|
66
|
-
"
|
|
84
|
+
"webhooks_restore" => ["POST", "/v1/webhooks/{webhook_id}/restore"],
|
|
85
|
+
"webhooks_rotate_secret" => ["POST", "/v1/webhooks/{webhook_id}/secrets"],
|
|
67
86
|
"webhooks_test" => ["POST", "/v1/webhooks/{webhook_id}/test"],
|
|
68
87
|
"webhooks_update" => ["PATCH", "/v1/webhooks/{webhook_id}"]
|
|
69
88
|
}.freeze
|
|
89
|
+
|
|
90
|
+
# The names of the timestamp fields in responses and webhook events.
|
|
91
|
+
TIMESTAMPS = %w[
|
|
92
|
+
at cancel_requested_at completed_at created_at deleted_at expires_at
|
|
93
|
+
last_attempt_at last_built_at merge_at next_attempt_at publish_at published_at
|
|
94
|
+
purge_after rebuild_requested_at started_at succeeded_at time unpublish_at
|
|
95
|
+
updated_at uploaded_at
|
|
96
|
+
].to_set.freeze
|
|
97
|
+
|
|
98
|
+
# The objects whose `data` is a record's field values.
|
|
99
|
+
RECORD_DATA_OBJECTS = %w[draft record record_revision revert_preview].to_set.freeze
|
|
70
100
|
end
|
|
@@ -9,12 +9,27 @@ module Nohead
|
|
|
9
9
|
# file). `file` is a path or an IO; for bytes, pass StringIO.new(bytes).
|
|
10
10
|
# Raises UploadError if storage refuses the bytes, and ValidationError if
|
|
11
11
|
# the file fails the checks.
|
|
12
|
+
#
|
|
13
|
+
# With an `idempotency_key`, running the same upload again resumes it:
|
|
14
|
+
# the key starts the upload, and an asset that is already `ready` is
|
|
15
|
+
# returned without sending the bytes again. Once the upload's URL has
|
|
16
|
+
# expired (`upload.expires_at`), it raises UploadError: start over with
|
|
17
|
+
# a new key.
|
|
12
18
|
def upload(file, filename: nil, content_type: nil, byte_size: nil, idempotency_key: nil)
|
|
13
19
|
source = UploadSource.new(file, filename: filename, content_type: content_type,
|
|
14
20
|
byte_size: byte_size)
|
|
15
21
|
begin
|
|
16
22
|
created = create_upload(filename: source.filename, content_type: source.content_type,
|
|
17
23
|
byte_size: source.byte_size, idempotency_key: idempotency_key)
|
|
24
|
+
# A replayed start returns the first run's asset, which may be done.
|
|
25
|
+
if idempotency_key
|
|
26
|
+
current = get(created.id)
|
|
27
|
+
return current if current.status == "ready"
|
|
28
|
+
# The key replays the first run's upload URL, which expires sooner.
|
|
29
|
+
if created.upload.expires_at <= Time.now
|
|
30
|
+
raise UploadError, "The upload of #{source.filename} expired: start it again with a new idempotency_key"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
18
33
|
upload = created.upload
|
|
19
34
|
response = @client.put_upload(upload.url, upload["method"], upload.headers.to_h, source)
|
|
20
35
|
ensure
|
|
@@ -24,13 +39,14 @@ module Nohead
|
|
|
24
39
|
raise UploadError.new("Storage refused the upload of #{source.filename} (#{response.status})",
|
|
25
40
|
response.status)
|
|
26
41
|
end
|
|
27
|
-
complete(created.
|
|
42
|
+
complete(created.id)
|
|
28
43
|
end
|
|
29
44
|
|
|
30
|
-
# The first step of an upload: a `pending` asset
|
|
31
|
-
# PUT the bytes to (for example from a browser). Then
|
|
45
|
+
# The first step of an upload: a `pending` asset with its `upload`, a
|
|
46
|
+
# presigned URL to PUT the bytes to (for example from a browser). Then
|
|
47
|
+
# `complete` it.
|
|
32
48
|
def create_upload(idempotency_key: nil, **params)
|
|
33
|
-
@client.request("assets_upload", body: params, idempotency_key: idempotency_key)
|
|
49
|
+
@client.request("assets_upload", body: body_of(params), idempotency_key: idempotency_key)
|
|
34
50
|
end
|
|
35
51
|
|
|
36
52
|
# Checks an uploaded file and marks the asset `ready`.
|
|
@@ -42,7 +58,7 @@ module Nohead
|
|
|
42
58
|
# wildcards (["image/*", "application/pdf"]), for example the ones an
|
|
43
59
|
# asset field accepts (its `accepted_types`).
|
|
44
60
|
def list(content_type: nil, deleted: nil, limit: nil, cursor: nil)
|
|
45
|
-
@client.paginate("assets_list", query: { content_type: content_type, deleted: deleted,
|
|
61
|
+
@client.paginate("assets_list", query: { filter: { content_type: content_type, deleted: deleted },
|
|
46
62
|
limit: limit, cursor: cursor })
|
|
47
63
|
end
|
|
48
64
|
|
|
@@ -50,7 +66,8 @@ module Nohead
|
|
|
50
66
|
@client.request("assets_get", path: { asset_id: asset })
|
|
51
67
|
end
|
|
52
68
|
|
|
53
|
-
# Soft-deletes the asset
|
|
69
|
+
# Soft-deletes the asset: restore brings it back until its `purge_after`,
|
|
70
|
+
# when the file is purged.
|
|
54
71
|
def delete(asset, idempotency_key: nil)
|
|
55
72
|
@client.request("assets_delete", path: { asset_id: asset }, idempotency_key: idempotency_key)
|
|
56
73
|
end
|
|
@@ -59,6 +76,21 @@ module Nohead
|
|
|
59
76
|
@client.request("assets_restore", path: { asset_id: asset }, idempotency_key: idempotency_key)
|
|
60
77
|
end
|
|
61
78
|
|
|
79
|
+
# Deletes a deleted asset for good, now instead of at its `purge_after`,
|
|
80
|
+
# and returns it as it was. It can't be restored, and its image
|
|
81
|
+
# URLs stop working. Raises ConflictError if the asset isn't deleted.
|
|
82
|
+
def purge(asset, idempotency_key: nil)
|
|
83
|
+
@client.request("assets_purge", path: { asset_id: asset }, idempotency_key: idempotency_key)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Where the asset is used: how many undeleted records use it (`records`),
|
|
87
|
+
# how many of those are published (`published`), and the 10 most
|
|
88
|
+
# recently updated with the fields they use it in (`uses`). Needs
|
|
89
|
+
# `records:read` too.
|
|
90
|
+
def usage(asset)
|
|
91
|
+
@client.request("assets_usage", path: { asset_id: asset })
|
|
92
|
+
end
|
|
93
|
+
|
|
62
94
|
# A signed, cacheable URL of an image rendition (`url`).
|
|
63
95
|
def image_url(asset, width: nil, height: nil, fit: nil, format: nil, quality: nil)
|
|
64
96
|
@client.request("assets_image_url", path: { asset_id: asset }, query: {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nohead
|
|
4
|
+
module Resources
|
|
5
|
+
# Drafts: changes to a record's content that wait until they are merged
|
|
6
|
+
# into it, now or at a set time, while the record keeps its current
|
|
7
|
+
# content. A record has at most one. `draft` is a draft ID ("drf_..."),
|
|
8
|
+
# `record` a record ID and `collection` a collection ID or slug.
|
|
9
|
+
class Drafts < Resource
|
|
10
|
+
# Starts the record's draft: `data` over the record's values, or with
|
|
11
|
+
# `from_revision`, over an earlier revision's. A second draft raises
|
|
12
|
+
# ConflictError (detail code `draft_exists`, with its `draft_id`).
|
|
13
|
+
def create(record, data: {}, from_revision: nil, idempotency_key: nil)
|
|
14
|
+
@client.request("drafts_create", path: { record_id: record },
|
|
15
|
+
body: { data: data, from_revision: from_revision }.compact,
|
|
16
|
+
idempotency_key: idempotency_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# A collection's drafts, most recently changed first. `record_id`
|
|
20
|
+
# narrows them to one record, or to several (an Array of 1 to 100; an
|
|
21
|
+
# empty one raises ArgumentError, since it would match no record).
|
|
22
|
+
def list(collection, record_id: nil, sort: nil, limit: nil, cursor: nil)
|
|
23
|
+
raise ArgumentError, "record_id needs at least one record ID" if record_id == []
|
|
24
|
+
|
|
25
|
+
record_filter = record_id.is_a?(Array) ? { in: record_id } : record_id
|
|
26
|
+
@client.paginate("drafts_list", path: { collection_id: collection },
|
|
27
|
+
query: { filter: { record_id: record_filter }, sort: sort,
|
|
28
|
+
limit: limit, cursor: cursor })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# With `data` as the record reads after the merge, for previews.
|
|
32
|
+
def get(draft, expand: nil)
|
|
33
|
+
@client.request("drafts_get", path: { draft_id: draft }, query: { expand: expand })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sets the given fields (nil clears one), as a record update does.
|
|
37
|
+
def update(draft, data:, if_match: nil, idempotency_key: nil)
|
|
38
|
+
@client.request("drafts_update", path: { draft_id: draft }, body: { data: data },
|
|
39
|
+
if_match: if_match, idempotency_key: idempotency_key)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Deletes the draft for good; returns it as it was.
|
|
43
|
+
def discard(draft, if_match: nil, idempotency_key: nil)
|
|
44
|
+
@client.request("drafts_discard", path: { draft_id: draft }, if_match: if_match,
|
|
45
|
+
idempotency_key: idempotency_key)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Applies the draft to its record now, as one `merge` revision, and
|
|
49
|
+
# returns the record. Content only: the record stays live or not.
|
|
50
|
+
def merge(draft, if_match: nil, idempotency_key: nil, change_note: nil)
|
|
51
|
+
@client.request("drafts_merge", path: { draft_id: draft }, if_match: if_match,
|
|
52
|
+
idempotency_key: idempotency_key, change_note: change_note)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Merges the draft at `merge_at` (a Time or ISO 8601 string in the
|
|
56
|
+
# future); returns the draft.
|
|
57
|
+
def schedule(draft, merge_at:, idempotency_key: nil)
|
|
58
|
+
@client.request("drafts_schedule", path: { draft_id: draft }, body: { merge_at: merge_at },
|
|
59
|
+
idempotency_key: idempotency_key)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unschedule(draft, idempotency_key: nil)
|
|
63
|
+
@client.request("drafts_unschedule", path: { draft_id: draft }, idempotency_key: idempotency_key)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -4,10 +4,17 @@ module Nohead
|
|
|
4
4
|
module Resources
|
|
5
5
|
# The project's activity log, newest first. Needs the audit:read scope.
|
|
6
6
|
class AuditEvents < Resource
|
|
7
|
-
# `filter` takes
|
|
8
|
-
#
|
|
7
|
+
# `filter` takes `type` (exact, or a "record.*" prefix), `resource_type`
|
|
8
|
+
# (a type, or an Array of them), `resource_id`, `actor_type`, `actor_id`
|
|
9
|
+
# and a `created_at` range:
|
|
10
|
+
#
|
|
11
|
+
# audit_events.list(filter: { type: "record.*", resource_type: %w[record draft],
|
|
12
|
+
# created_at: { gte: Time.utc(2026, 10, 1) } })
|
|
9
13
|
def list(filter: nil, sort: nil, limit: nil, cursor: nil)
|
|
10
|
-
|
|
14
|
+
filter = filter&.transform_keys(&:to_sym)
|
|
15
|
+
types = filter&.[](:resource_type)
|
|
16
|
+
filter = filter.merge(resource_type: { in: types }) if types.is_a?(Array)
|
|
17
|
+
@client.paginate("audit_events_list",
|
|
11
18
|
query: { filter: filter, sort: sort, limit: limit, cursor: cursor })
|
|
12
19
|
end
|
|
13
20
|
end
|
|
@@ -4,11 +4,27 @@ module Nohead
|
|
|
4
4
|
module Resources
|
|
5
5
|
# The base of the resources: each holds the client it sends requests with.
|
|
6
6
|
class Resource
|
|
7
|
+
# Request options that are headers, not body fields.
|
|
8
|
+
OPTIONS = %i[change_note if_match].freeze
|
|
9
|
+
|
|
7
10
|
def initialize(client)
|
|
8
11
|
@client = client
|
|
9
12
|
end
|
|
10
13
|
|
|
11
14
|
def inspect = "#<#{self.class.name}>"
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# The keyword arguments a method sends as the body. SDK options would
|
|
19
|
+
# land there when the method doesn't take them (its operation doesn't
|
|
20
|
+
# declare the header), so they raise ArgumentError as Ruby does for
|
|
21
|
+
# unknown keywords.
|
|
22
|
+
def body_of(params)
|
|
23
|
+
option = OPTIONS.find { |name| params.key?(name) }
|
|
24
|
+
raise ArgumentError, "unknown keyword: :#{option}" if option
|
|
25
|
+
|
|
26
|
+
params
|
|
27
|
+
end
|
|
12
28
|
end
|
|
13
29
|
|
|
14
30
|
# Records: the content of a collection. `collection` is a collection ID or
|
|
@@ -22,43 +38,46 @@ module Nohead
|
|
|
22
38
|
end
|
|
23
39
|
|
|
24
40
|
# A collection's records, newest first by default. `filter` holds
|
|
25
|
-
#
|
|
26
|
-
# fields to embed under `expanded` (at most 5).
|
|
41
|
+
# filters ({ published_at: { exists: true } } for published records),
|
|
42
|
+
# `expand` relation or asset fields to embed under `expanded` (at most 5).
|
|
27
43
|
def list(collection, filter: nil, sort: nil, expand: nil, limit: nil, cursor: nil)
|
|
28
44
|
@client.paginate("records_list", path: { collection_id: collection }, query: {
|
|
29
45
|
filter: filter, sort: sort, expand: expand, limit: limit, cursor: cursor
|
|
30
46
|
})
|
|
31
47
|
end
|
|
32
48
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
# A deleted record too, with `deleted_at` and `purge_after`.
|
|
50
|
+
def get(record, expand: nil)
|
|
51
|
+
@client.request("records_get", path: { record_id: record }, query: { expand: expand })
|
|
36
52
|
end
|
|
37
53
|
|
|
38
|
-
# Creates
|
|
54
|
+
# Creates an unpublished record: create("posts", data: { title: "Hello" }).
|
|
39
55
|
def create(collection, idempotency_key: nil, change_note: nil, **params)
|
|
40
|
-
@client.request("records_create", path: { collection_id: collection }, body: params,
|
|
56
|
+
@client.request("records_create", path: { collection_id: collection }, body: body_of(params),
|
|
41
57
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
# Sets the given fields (nil clears one); others keep their values.
|
|
45
61
|
def update(record, if_match: nil, idempotency_key: nil, change_note: nil, **params)
|
|
46
|
-
@client.request("records_update", path: { record_id: record }, body: params,
|
|
62
|
+
@client.request("records_update", path: { record_id: record }, body: body_of(params),
|
|
47
63
|
if_match: if_match, idempotency_key: idempotency_key,
|
|
48
64
|
change_note: change_note)
|
|
49
65
|
end
|
|
50
66
|
|
|
51
|
-
# Soft-deletes the record: restore brings it back
|
|
67
|
+
# Soft-deletes the record: restore brings it back until its `purge_after`.
|
|
68
|
+
# Deleting a deleted record changes nothing.
|
|
52
69
|
def delete(record, if_match: nil, idempotency_key: nil, change_note: nil)
|
|
53
70
|
@client.request("records_delete", path: { record_id: record }, if_match: if_match,
|
|
54
71
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
55
72
|
end
|
|
56
73
|
|
|
57
|
-
def restore(record, idempotency_key: nil, change_note: nil)
|
|
58
|
-
@client.request("records_restore", path: { record_id: record },
|
|
74
|
+
def restore(record, if_match: nil, idempotency_key: nil, change_note: nil)
|
|
75
|
+
@client.request("records_restore", path: { record_id: record }, if_match: if_match,
|
|
59
76
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
60
77
|
end
|
|
61
78
|
|
|
79
|
+
# A live record with a pending draft raises ConflictError (detail code
|
|
80
|
+
# `draft_pending`): publishing never merges a draft (drafts.merge does).
|
|
62
81
|
def publish(record, if_match: nil, idempotency_key: nil, change_note: nil)
|
|
63
82
|
@client.request("records_publish", path: { record_id: record }, if_match: if_match,
|
|
64
83
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
@@ -69,13 +88,26 @@ module Nohead
|
|
|
69
88
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
70
89
|
end
|
|
71
90
|
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
91
|
+
# What is live: since when, by whom, and the revision. Raises
|
|
92
|
+
# NotFoundError (detail code `not_published`) for a record that isn't
|
|
93
|
+
# published.
|
|
94
|
+
def publication(record)
|
|
95
|
+
@client.request("records_get_publication", path: { record_id: record })
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# When the record publishes or unpublishes on its own. Raises
|
|
99
|
+
# NotFoundError (detail code `not_scheduled`) for a record with no
|
|
100
|
+
# schedule.
|
|
101
|
+
def get_schedule(record)
|
|
102
|
+
@client.request("records_get_schedule", path: { record_id: record })
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Sets when the record publishes and/or unpublishes on its own, and
|
|
106
|
+
# returns the schedule. It replaces the whole schedule: a time left out
|
|
107
|
+
# is cleared (schedule(record, publish_at: t) drops a set unpublish_at).
|
|
108
|
+
def schedule(record, publish_at: nil, unpublish_at: nil, idempotency_key: nil)
|
|
109
|
+
@client.request("records_schedule", path: { record_id: record },
|
|
110
|
+
body: { publish_at: publish_at, unpublish_at: unpublish_at }.compact,
|
|
79
111
|
idempotency_key: idempotency_key)
|
|
80
112
|
end
|
|
81
113
|
|
|
@@ -85,6 +117,19 @@ module Nohead
|
|
|
85
117
|
idempotency_key: idempotency_key)
|
|
86
118
|
end
|
|
87
119
|
|
|
120
|
+
# The record's drafts: at most one for now.
|
|
121
|
+
def drafts(record, limit: nil, cursor: nil)
|
|
122
|
+
@client.paginate("record_drafts_list", path: { record_id: record }, query: { limit: limit, cursor: cursor })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# A collection's pending scheduled publishes and unpublishes, soonest
|
|
126
|
+
# first: one per change, so a record due to publish and later unpublish
|
|
127
|
+
# appears twice.
|
|
128
|
+
def scheduled_changes(collection, sort: nil, limit: nil, cursor: nil)
|
|
129
|
+
@client.paginate("scheduled_changes_list", path: { collection_id: collection },
|
|
130
|
+
query: { sort: sort, limit: limit, cursor: cursor })
|
|
131
|
+
end
|
|
132
|
+
|
|
88
133
|
def count(collection, filter: nil)
|
|
89
134
|
@client.request("records_count", path: { collection_id: collection },
|
|
90
135
|
query: { filter: filter })
|
|
@@ -93,14 +138,13 @@ module Nohead
|
|
|
93
138
|
# Publishes, unpublishes, deletes, restores or updates up to 100 records.
|
|
94
139
|
# Each record succeeds or fails on its own; see `results`.
|
|
95
140
|
def bulk(collection, idempotency_key: nil, change_note: nil, **params)
|
|
96
|
-
@client.request("records_bulk", path: { collection_id: collection }, body: params,
|
|
141
|
+
@client.request("records_bulk", path: { collection_id: collection }, body: body_of(params),
|
|
97
142
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
98
143
|
end
|
|
99
144
|
|
|
100
|
-
# The changes between two revisions.
|
|
101
|
-
def diff(record,
|
|
102
|
-
@client.request("records_diff", path: { record_id: record },
|
|
103
|
-
query: { from: from_revision, to: to_revision })
|
|
145
|
+
# The changes between two revisions: diff(record, from: 1, to: 2).
|
|
146
|
+
def diff(record, from:, to:)
|
|
147
|
+
@client.request("records_diff", path: { record_id: record }, query: { from: from, to: to })
|
|
104
148
|
end
|
|
105
149
|
|
|
106
150
|
# Full-text search in one collection (search_enabled collections), most
|
|
@@ -115,7 +159,8 @@ module Nohead
|
|
|
115
159
|
|
|
116
160
|
# A record's version history. `revision` is a revision number.
|
|
117
161
|
class Revisions < Resource
|
|
118
|
-
# Newest first. `filter` takes operation
|
|
162
|
+
# Newest first. `filter` takes `operation`, `actor_id` and a `created_at`
|
|
163
|
+
# range ({ created_at: { gte: Time.utc(2026, 10, 1) } }).
|
|
119
164
|
def list(record, filter: nil, limit: nil, cursor: nil)
|
|
120
165
|
@client.paginate("record_revisions_list", path: { record_id: record },
|
|
121
166
|
query: { filter: filter, limit: limit, cursor: cursor })
|
|
@@ -127,10 +172,10 @@ module Nohead
|
|
|
127
172
|
|
|
128
173
|
# Restores the record's data as of `revision`, as a new revision. With
|
|
129
174
|
# dry_run: true, previews it.
|
|
130
|
-
def revert(record, revision, dry_run: false, if_match: nil, idempotency_key: nil)
|
|
175
|
+
def revert(record, revision, dry_run: false, if_match: nil, idempotency_key: nil, change_note: nil)
|
|
131
176
|
@client.request("record_revisions_revert", path: { record_id: record, revision: revision },
|
|
132
|
-
query: { dry_run: dry_run || nil },
|
|
133
|
-
|
|
177
|
+
query: { dry_run: dry_run || nil }, if_match: if_match,
|
|
178
|
+
idempotency_key: idempotency_key, change_note: change_note)
|
|
134
179
|
end
|
|
135
180
|
end
|
|
136
181
|
end
|
|
@@ -12,9 +12,11 @@ module Nohead
|
|
|
12
12
|
@search_index = SearchIndexes.new(client)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# deleted: true lists deleted collections instead (restorable
|
|
15
|
+
# deleted: true lists deleted collections instead (restorable until
|
|
16
|
+
# their `purge_after`).
|
|
16
17
|
def list(deleted: nil, limit: nil, cursor: nil)
|
|
17
|
-
@client.paginate("collections_list",
|
|
18
|
+
@client.paginate("collections_list",
|
|
19
|
+
query: { filter: { deleted: deleted }, limit: limit, cursor: cursor })
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def get(collection)
|
|
@@ -23,16 +25,17 @@ module Nohead
|
|
|
23
25
|
|
|
24
26
|
# Creates a collection, optionally with its fields.
|
|
25
27
|
def create(idempotency_key: nil, change_note: nil, **params)
|
|
26
|
-
@client.request("collections_create", body: params, idempotency_key: idempotency_key,
|
|
28
|
+
@client.request("collections_create", body: body_of(params), idempotency_key: idempotency_key,
|
|
27
29
|
change_note: change_note)
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
def update(collection, idempotency_key: nil, change_note: nil, **params)
|
|
31
|
-
@client.request("collections_update", path: { collection_id: collection }, body: params,
|
|
33
|
+
@client.request("collections_update", path: { collection_id: collection }, body: body_of(params),
|
|
32
34
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
# Soft-deletes the collection and its records
|
|
37
|
+
# Soft-deletes the collection and its records: restore brings them back
|
|
38
|
+
# until its `purge_after`.
|
|
36
39
|
def delete(collection, idempotency_key: nil, change_note: nil)
|
|
37
40
|
@client.request("collections_delete", path: { collection_id: collection },
|
|
38
41
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
@@ -57,9 +60,8 @@ module Nohead
|
|
|
57
60
|
query: { limit: limit, cursor: cursor })
|
|
58
61
|
end
|
|
59
62
|
|
|
60
|
-
def get(
|
|
61
|
-
@client.request("schema_changes_get",
|
|
62
|
-
path: { collection_id: collection, schema_change_id: schema_change })
|
|
63
|
+
def get(schema_change)
|
|
64
|
+
@client.request("schema_changes_get", path: { schema_change_id: schema_change })
|
|
63
65
|
end
|
|
64
66
|
end
|
|
65
67
|
|
|
@@ -80,22 +82,27 @@ module Nohead
|
|
|
80
82
|
class Fields < Resource
|
|
81
83
|
def list(collection, deleted: nil, limit: nil, cursor: nil)
|
|
82
84
|
@client.paginate("fields_list", path: { collection_id: collection },
|
|
83
|
-
query: { deleted: deleted, limit: limit, cursor: cursor })
|
|
85
|
+
query: { filter: { deleted: deleted }, limit: limit, cursor: cursor })
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def get(field)
|
|
89
|
+
@client.request("fields_get", path: { field_id: field })
|
|
84
90
|
end
|
|
85
91
|
|
|
86
92
|
def create(collection, idempotency_key: nil, change_note: nil, **params)
|
|
87
|
-
@client.request("fields_create", path: { collection_id: collection }, body: params,
|
|
93
|
+
@client.request("fields_create", path: { collection_id: collection }, body: body_of(params),
|
|
88
94
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
89
95
|
end
|
|
90
96
|
|
|
91
97
|
# Renames, describes or loosens a field. Changes that rewrite record
|
|
92
98
|
# values (type, multiple, tighter rules) are `migrate`.
|
|
93
99
|
def update(field, idempotency_key: nil, change_note: nil, **params)
|
|
94
|
-
@client.request("fields_update", path: { field_id: field }, body: params,
|
|
100
|
+
@client.request("fields_update", path: { field_id: field }, body: body_of(params),
|
|
95
101
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
96
102
|
end
|
|
97
103
|
|
|
98
|
-
# Soft-deletes the field: its values return with `restore`
|
|
104
|
+
# Soft-deletes the field: its values return with `restore` until its
|
|
105
|
+
# `purge_after`.
|
|
99
106
|
def delete(field, idempotency_key: nil, change_note: nil)
|
|
100
107
|
@client.request("fields_delete", path: { field_id: field },
|
|
101
108
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
@@ -106,8 +113,13 @@ module Nohead
|
|
|
106
113
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
107
114
|
end
|
|
108
115
|
|
|
109
|
-
#
|
|
110
|
-
def
|
|
116
|
+
# The collection's active fields, in order.
|
|
117
|
+
def order(collection)
|
|
118
|
+
@client.request("fields_get_order", path: { collection_id: collection })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Puts the collection's fields in this order; lists every active field once.
|
|
122
|
+
def reorder(collection, field_ids:, idempotency_key: nil, change_note: nil)
|
|
111
123
|
@client.request("fields_reorder", path: { collection_id: collection },
|
|
112
124
|
body: { field_ids: field_ids },
|
|
113
125
|
idempotency_key: idempotency_key, change_note: change_note)
|
|
@@ -123,7 +135,7 @@ module Nohead
|
|
|
123
135
|
# backfill) that rewrites every record. With dry_run: true, previews it.
|
|
124
136
|
def migrate(field, dry_run: false, idempotency_key: nil, change_note: nil, **params)
|
|
125
137
|
@client.request("fields_migrate", path: { field_id: field }, query: { dry_run: dry_run || nil },
|
|
126
|
-
body: params, idempotency_key: idempotency_key,
|
|
138
|
+
body: body_of(params), idempotency_key: idempotency_key,
|
|
127
139
|
change_note: change_note)
|
|
128
140
|
end
|
|
129
141
|
end
|
data/lib/nohead/version.rb
CHANGED
data/lib/nohead/webhooks.rb
CHANGED
|
@@ -9,6 +9,11 @@ module Nohead
|
|
|
9
9
|
# event = Nohead::Webhooks.unwrap(request.raw_post, request.headers,
|
|
10
10
|
# secret: ENV.fetch("NOHEAD_WEBHOOK_SECRET"))
|
|
11
11
|
# puts event.data.record.id if event.type == "record.published"
|
|
12
|
+
#
|
|
13
|
+
# Event types are the same as the activity log's (record.published,
|
|
14
|
+
# record.restored, field.updated, asset.purged, ...). While a secret
|
|
15
|
+
# rotation overlaps, requests carry a signature for each secret, so either
|
|
16
|
+
# one verifies.
|
|
12
17
|
module Webhooks
|
|
13
18
|
# Checks a webhook request's signature and timestamp and returns its event
|
|
14
19
|
# (a NoheadObject: `type`, `data`...). Pass the raw body exactly as
|
|
@@ -52,15 +57,18 @@ module Nohead
|
|
|
52
57
|
module Resources
|
|
53
58
|
# Webhooks of the key's project. `webhook` is a webhook ID ("wh_...").
|
|
54
59
|
class Webhooks < Resource
|
|
55
|
-
attr_reader :deliveries
|
|
60
|
+
attr_reader :deliveries, :secrets
|
|
56
61
|
|
|
57
62
|
def initialize(client)
|
|
58
63
|
super
|
|
59
64
|
@deliveries = Deliveries.new(client)
|
|
65
|
+
@secrets = Secrets.new(client)
|
|
60
66
|
end
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
# deleted: true lists deleted webhooks instead (restorable until their
|
|
69
|
+
# `purge_after`).
|
|
70
|
+
def list(deleted: nil, limit: nil, cursor: nil)
|
|
71
|
+
@client.paginate("webhooks_list", query: { filter: { deleted: deleted }, limit: limit, cursor: cursor })
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
def get(webhook)
|
|
@@ -69,19 +77,26 @@ module Nohead
|
|
|
69
77
|
|
|
70
78
|
# Subscribes a URL. The response is the only time `secret` is shown.
|
|
71
79
|
def create(idempotency_key: nil, **params)
|
|
72
|
-
@client.request("webhooks_create", body: params, idempotency_key: idempotency_key)
|
|
80
|
+
@client.request("webhooks_create", body: body_of(params), idempotency_key: idempotency_key)
|
|
73
81
|
end
|
|
74
82
|
|
|
75
83
|
def update(webhook, idempotency_key: nil, **params)
|
|
76
|
-
@client.request("webhooks_update", path: { webhook_id: webhook }, body: params,
|
|
84
|
+
@client.request("webhooks_update", path: { webhook_id: webhook }, body: body_of(params),
|
|
77
85
|
idempotency_key: idempotency_key)
|
|
78
86
|
end
|
|
79
87
|
|
|
88
|
+
# Soft-deletes the webhook: restore brings it back until its `purge_after`.
|
|
80
89
|
def delete(webhook, idempotency_key: nil)
|
|
81
90
|
@client.request("webhooks_delete", path: { webhook_id: webhook }, idempotency_key: idempotency_key)
|
|
82
91
|
end
|
|
83
92
|
|
|
84
|
-
|
|
93
|
+
def restore(webhook, idempotency_key: nil)
|
|
94
|
+
@client.request("webhooks_restore", path: { webhook_id: webhook }, idempotency_key: idempotency_key)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A new signing secret (`secret`, shown only here). The ones before it
|
|
98
|
+
# keep signing for 24 hours, so receivers can switch without rejecting
|
|
99
|
+
# deliveries; secrets.retire ends one sooner.
|
|
85
100
|
def rotate_secret(webhook, idempotency_key: nil)
|
|
86
101
|
@client.request("webhooks_rotate_secret", path: { webhook_id: webhook },
|
|
87
102
|
idempotency_key: idempotency_key)
|
|
@@ -103,18 +118,35 @@ module Nohead
|
|
|
103
118
|
class Deliveries < Resource
|
|
104
119
|
def list(webhook, status: nil, limit: nil, cursor: nil)
|
|
105
120
|
@client.paginate("webhook_deliveries_list", path: { webhook_id: webhook },
|
|
106
|
-
query: { status: status, limit: limit,
|
|
121
|
+
query: { filter: { status: status }, limit: limit,
|
|
122
|
+
cursor: cursor })
|
|
107
123
|
end
|
|
108
124
|
|
|
109
125
|
def get(delivery)
|
|
110
126
|
@client.request("webhook_deliveries_get", path: { delivery_id: delivery })
|
|
111
127
|
end
|
|
112
128
|
|
|
113
|
-
# Sends the event again.
|
|
129
|
+
# Sends the event again now, as a new attempt.
|
|
114
130
|
def retry(delivery, idempotency_key: nil)
|
|
115
131
|
@client.request("webhook_deliveries_retry", path: { delivery_id: delivery },
|
|
116
132
|
idempotency_key: idempotency_key)
|
|
117
133
|
end
|
|
118
134
|
end
|
|
135
|
+
|
|
136
|
+
# A webhook's signing secrets. `secret` is a secret ID ("whs_...").
|
|
137
|
+
class Secrets < Resource
|
|
138
|
+
# Those signing deliveries now, oldest first, without their values.
|
|
139
|
+
def list(webhook, limit: nil, cursor: nil)
|
|
140
|
+
@client.paginate("webhook_secrets_list", path: { webhook_id: webhook },
|
|
141
|
+
query: { limit: limit, cursor: cursor })
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Stops signing with it now. The newest can't be retired (ConflictError,
|
|
145
|
+
# detail code `newest_secret`): rotate first.
|
|
146
|
+
def retire(secret, idempotency_key: nil)
|
|
147
|
+
@client.request("webhook_secrets_retire", path: { secret_id: secret },
|
|
148
|
+
idempotency_key: idempotency_key)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
119
151
|
end
|
|
120
152
|
end
|
data/lib/nohead.rb
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# require "nohead"
|
|
6
6
|
#
|
|
7
7
|
# nohead = Nohead::Client.new # NOHEAD_API_KEY
|
|
8
|
-
# nohead.records.list("posts", filter: {
|
|
8
|
+
# nohead.records.list("posts", filter: { published_at: { exists: true } }).each do |post|
|
|
9
9
|
# puts post.data["title"]
|
|
10
10
|
# end
|
|
11
11
|
module Nohead; end
|
|
@@ -18,6 +18,7 @@ require_relative "nohead/page"
|
|
|
18
18
|
require_relative "nohead/transport"
|
|
19
19
|
require_relative "nohead/uploads"
|
|
20
20
|
require_relative "nohead/resources/records"
|
|
21
|
+
require_relative "nohead/resources/drafts"
|
|
21
22
|
require_relative "nohead/resources/schema"
|
|
22
23
|
require_relative "nohead/resources/assets"
|
|
23
24
|
require_relative "nohead/resources/other"
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nohead
|
|
@@ -28,6 +28,7 @@ files:
|
|
|
28
28
|
- lib/nohead/operations.rb
|
|
29
29
|
- lib/nohead/page.rb
|
|
30
30
|
- lib/nohead/resources/assets.rb
|
|
31
|
+
- lib/nohead/resources/drafts.rb
|
|
31
32
|
- lib/nohead/resources/other.rb
|
|
32
33
|
- lib/nohead/resources/records.rb
|
|
33
34
|
- lib/nohead/resources/schema.rb
|