nohead 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3b6ddca7dac0c2e2e1056aabba232a26b27fce7eae7eea1244519a88d0388fe
4
- data.tar.gz: e9c0c92435a093999388ef2a84c73870c12dead425ada3c3bdf691c9fe993069
3
+ metadata.gz: 4381c6ca769b33734069a6b117b59f03d1ebfa3b283698f2f20047b527bf376f
4
+ data.tar.gz: 9b6dbcf5e1cc441cc10356a2d4b18ae0ae7485faeb4bba79a474ce045aa0150e
5
5
  SHA512:
6
- metadata.gz: 3ab6ef2e8d3d027b07a2ff536cf3f14833970b4d80cbc51fcf2b65a3a10684012a4a1e70088b426e0c77a55927a21f2816d13991c869321bbdff84d705d30f8c
7
- data.tar.gz: 2c73018868bc99808615f989f735149122b655705eac1f0af62a9bccfab465582857bbad1fb91a5f635230f2af272404f831d20e05f2387295ada5b901b0798a
6
+ metadata.gz: c3491a32b9f27da5dc3ca67b2dbed3952508130cc04ee129954c8ecc4684e4e7fa36806e5914f34bba73ac2da4c43162ffd025891f03f77ad3d6151faa55d1fe
7
+ data.tar.gz: 5da0e9d7a4869ae460a70737dd34a4e4b9a20fa61d3524a2f5bd0a1e8fa67de2f61886e0e1f003a0b575b0e20006f5529933dc49433e2d2d3d68189ce4913947
data/CHANGELOG.md CHANGED
@@ -5,6 +5,103 @@ 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.3.0
9
+
10
+ - **Upgrade required:** 0.1 and 0.2 no longer work with the current API,
11
+ whose paths changed.
12
+ - `change_note` is sent percent-encoded (UTF-8), so change notes work in any
13
+ language: a note outside ASCII used to fail the request.
14
+ - Drafts: changes to a record that wait until they are merged into it, now
15
+ or at a set time. `drafts.create`, `list`, `get` (with `expand`, for
16
+ previews), `update`, `discard`, `merge`, `schedule` and `unschedule`, and
17
+ `records.drafts`. `records.publish` on a live record with a pending draft
18
+ raises `ConflictError` (`draft_pending`). Webhook events of `draft.*` carry
19
+ the `draft` and its `draft_id`, and a merge's `record.updated` its
20
+ `draft_id`.
21
+ - **Breaking:** the API is resource-oriented. Method names stay; what they
22
+ return and take follows it:
23
+ - Records have no `status`, `schedule` or `deleted`: a record is published
24
+ when `published_at` is set, and has `deleted_at` and `purge_after`.
25
+ Filter with `{ published_at: { exists: true } }`. `records.get` returns
26
+ deleted records too (no `include_deleted`).
27
+ - `records.schedule` replaces the whole schedule: a time left out is
28
+ cleared, and `clear:` is gone. It and `unschedule` return the schedule,
29
+ and `records.get_schedule`, `records.publication` and
30
+ `records.scheduled_changes` are new. A scheduled change has no
31
+ `last_error`. `records.bulk` returns a record batch.
32
+ - `search` takes `filter:` as `records.list` does
33
+ (`filter: { published_at: { exists: true } }`) instead of
34
+ `status: "published"`.
35
+ - `records.diff(record, from:, to:)` and
36
+ `fields.reorder(collection, field_ids:)` take keywords.
37
+ - `audit_events.list` filters by `type` (was `action`), `resource_type`
38
+ (a type, or an Array of them), `resource_id`, `actor_type`, `actor_id`
39
+ and a `created_at` range (`{ gte: ..., lt: ... }`, was `since` and
40
+ `until`); the API refuses other filters. Events have `object: "event"`
41
+ and their name in `type`, and a field's events the resource type
42
+ `field`.
43
+ - `records.revisions.list` filters by a `created_at` range (was `since`
44
+ and `until`), and a revision's `operation` can be `merge` (no
45
+ `import`).
46
+ - Schema changes have no `status`.
47
+ - Collections, fields, assets and webhooks have `deleted_at` and
48
+ `purge_after` instead of `deleted`, and migrations
49
+ `cancel_requested_at`.
50
+ - `assets.create_upload` returns the asset with its `upload`.
51
+ - `fields.reorder` returns the field order; `fields.order` reads it.
52
+ - Webhook event types are the same as the activity log's
53
+ (`record.restored`, `field.updated`, ..., and wildcards such as
54
+ `record.*`), and events carry
55
+ `actor` and `via`. `webhooks.rotate_secret` returns the new webhook
56
+ secret with its value; the old secret signs for 24 hours more.
57
+ `webhooks.secrets.list`, `webhooks.secrets.retire`, `webhooks.restore`
58
+ and `webhooks.list(deleted: true)` are new. There is no
59
+ `record.imported` event; `field.*` events carry the `field_id`, and
60
+ schema events' `collection` its `object`.
61
+ - `if_match` and `change_note` go where the API takes them:
62
+ `records.restore` takes `if_match` and `records.revisions.revert`
63
+ `change_note`. Methods that take fields as keywords
64
+ (`webhooks.create`, `assets.create_upload`...) raise ArgumentError for
65
+ an `if_match` or `change_note` they don't take, instead of sending it
66
+ as a field.
67
+ - Timestamps are the ones the API declares, so `purge_after`, a
68
+ scheduled change's `at` and `health.check`'s `time` are Times too, and
69
+ a record's field values are left as they come (a field whose name
70
+ ended in `_at` used to be a Time).
71
+ - **Breaking:** the `datetime` field type is now `date`. A plain date field holds
72
+ `YYYY-MM-DD`; with `include_time` it holds a moment, written in the field's
73
+ `time_zone` when it has one. Field migrations take `time_zone`, the zone
74
+ whose day each moment falls on when the time is removed.
75
+ - Boolean fields can't be `required`: a boolean is true or false, and no
76
+ value reads as false.
77
+ - Text fields take a `format` (`email`, `url`, `slug`; slugs are always
78
+ unique) and text and integer fields `unique`; a taken value's error detail
79
+ has the `record_id` that has it (`code: "taken"`).
80
+ - Filters take operators: `{ price: { lt: 50 } }`, with `eq`, `ne`, `gt`,
81
+ `gte`, `lt`, `lte`, `in` (an array) and `exists`, and record lists sort by
82
+ a field's value. Commas and backslashes in array values are escaped, so a
83
+ value can hold a comma.
84
+ - Date filters take `today`, and dates with a time `now`.
85
+ - `assets.usage`: where an asset is used, as counts of the records that use
86
+ it (and of those, the published ones) and the 10 most recently updated with
87
+ the fields that use it. Needs the `records:read` scope too.
88
+ - `assets.purge`: permanently deletes a deleted asset now, instead of at its
89
+ `purge_after`, and frees its storage.
90
+ - `fields.get`.
91
+ - `assets.upload` with an `idempotency_key` resumes: an asset that is
92
+ already `ready` comes back without sending the bytes again, and one whose
93
+ upload URL expired raises `UploadError` (start over with a new key).
94
+ - An error reads only the fields of the API's error envelope that have the
95
+ right type, so JSON from a proxy or gateway that merely looks like one no
96
+ longer gives it a non-string `message`, `type` or `request_id`, turns a
97
+ `details` object into key-value pairs, or makes `current_revision` raise on
98
+ a detail that isn't an object.
99
+
100
+ ## 0.2.0
101
+
102
+ - Types follow the API's current contract. They add types only for
103
+ operations an API key can't call, so no method changed.
104
+
8
105
  ## 0.1.0
9
106
 
10
107
  The first release.
data/README.md CHANGED
@@ -7,18 +7,19 @@ require "nohead"
7
7
 
8
8
  nohead = Nohead::Client.new # reads NOHEAD_API_KEY
9
9
 
10
- nohead.records.list("posts", filter: { status: "published" }).each do |post|
10
+ nohead.records.list("posts", filter: { published_at: { exists: true } }).each do |post|
11
11
  puts post.data["title"]
12
12
  end
13
13
  ```
14
14
 
15
- > **Status:** 0.x, not yet published to RubyGems. Until it is, use it from GitHub: `gem "nohead", github: "nohead-io/nohead-ruby"`.
15
+ > **Status:** 0.x until the Nohead API launches.
16
16
 
17
17
  ## Contents
18
18
 
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
- draft = nohead.records.create("posts", data: { title: "Hello", author: "rec_01J9..." })
68
- post = nohead.records.get(draft.id, expand: ["author"])
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.schedule(post.id, unpublish_at: Time.utc(2027, 1, 1))
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, from_revision, to_revision)`
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 are equality filters (for fields with several values: "contains"), and accept strings, numbers, booleans and Times:
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: { status: "published", featured: true, author: "rec_01J9..." },
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
 
@@ -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** (`*_at`) are `Time`s.
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, 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 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.
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
- 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
- })
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.
@@ -158,8 +159,9 @@ module Nohead
158
159
  end
159
160
 
160
161
  # 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.
162
+ # (filter[price][lt]=50), arrays are comma-separated (expand=author,tags)
163
+ # with \, for a comma inside a value and \\ for a backslash, times are
164
+ # ISO 8601. nil values are left out.
163
165
  def query_string(params)
164
166
  pairs = []
165
167
  add = lambda do |key, value|
@@ -167,7 +169,7 @@ module Nohead
167
169
  when nil then nil
168
170
  when Hash then value.each { |name, inner| add.call("#{key}[#{name}]", inner) }
169
171
  when Array
170
- items = value.compact.map { |item| scalar(item) }
172
+ items = value.compact.map { |item| scalar(item).gsub(/[\\,]/) { |char| "\\#{char}" } }
171
173
  pairs << [key, items.join(",")] unless items.empty?
172
174
  else pairs << [key, scalar(value)]
173
175
  end
@@ -208,7 +210,12 @@ module Nohead
208
210
  revision = if_match.respond_to?(:revision) ? if_match.revision : if_match
209
211
  headers["If-Match"] = %("#{revision}")
210
212
  end
211
- headers["Nohead-Change-Note"] = change_note if change_note
213
+ # Percent-encoded UTF-8, as encodeURIComponent: header values are bytes
214
+ # (the API decodes it). Notes in other encodings are converted first.
215
+ if change_note
216
+ note = change_note.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
217
+ headers["Nohead-Change-Note"] = URI.encode_uri_component(note)
218
+ end
212
219
  headers
213
220
  end
214
221
 
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 = body.is_a?(Hash) && body["error"].is_a?(Hash) ? body["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
- type = body.is_a?(Hash) && body["error"].is_a?(Hash) ? body["error"]["type"] : nil
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/)
@@ -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 (`*_at`) are Times. Unknown fields are kept: the API adds
10
- # fields without notice.
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
- def self.wrap(value, key = nil)
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.end_with?("_at") && value.match?(ISO_TIME) ? Time.iso8601(value) : value
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
- self.class.wrap(@values[key], key)
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)
@@ -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/uploads"],
15
- "audit_events_list_for_project" => ["GET", "/v1/projects/{project_id}/audit-events"],
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" => ["POST", "/v1/collections/{collection_id}/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,22 +48,26 @@ 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}/records/bulk"],
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}/publish"],
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" => ["POST", "/v1/records/{record_id}/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}"],
70
+ "scheduled_changes_list" => ["GET", "/v1/collections/{collection_id}/scheduled-changes"],
55
71
  "schema_changes_get" => ["GET", "/v1/collections/{collection_id}/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"],
@@ -59,12 +75,26 @@ module Nohead
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
- "webhooks_rotate_secret" => ["POST", "/v1/webhooks/{webhook_id}/rotate-secret"],
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.asset.id)
42
+ complete(created.id)
28
43
  end
29
44
 
30
- # The first step of an upload: a `pending` asset and a presigned URL to
31
- # PUT the bytes to (for example from a browser). Then `complete` it.
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`.
@@ -38,15 +54,20 @@ module Nohead
38
54
  @client.request("assets_complete", path: { asset_id: asset }, idempotency_key: idempotency_key)
39
55
  end
40
56
 
41
- def list(deleted: nil, limit: nil, cursor: nil)
42
- @client.paginate("assets_list", query: { deleted: deleted, limit: limit, cursor: cursor })
57
+ # `content_type` lists only assets of those MIME types or `type/*`
58
+ # wildcards (["image/*", "application/pdf"]), for example the ones an
59
+ # asset field accepts (its `accepted_types`).
60
+ def list(content_type: nil, deleted: nil, limit: nil, cursor: nil)
61
+ @client.paginate("assets_list", query: { filter: { content_type: content_type, deleted: deleted },
62
+ limit: limit, cursor: cursor })
43
63
  end
44
64
 
45
65
  def get(asset)
46
66
  @client.request("assets_get", path: { asset_id: asset })
47
67
  end
48
68
 
49
- # Soft-deletes the asset; the file is purged after 30 days.
69
+ # Soft-deletes the asset: restore brings it back until its `purge_after`,
70
+ # when the file is purged.
50
71
  def delete(asset, idempotency_key: nil)
51
72
  @client.request("assets_delete", path: { asset_id: asset }, idempotency_key: idempotency_key)
52
73
  end
@@ -55,6 +76,21 @@ module Nohead
55
76
  @client.request("assets_restore", path: { asset_id: asset }, idempotency_key: idempotency_key)
56
77
  end
57
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
+
58
94
  # A signed, cacheable URL of an image rendition (`url`).
59
95
  def image_url(asset, width: nil, height: nil, fit: nil, format: nil, quality: nil)
60
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 action (exact, or a "record.*" prefix), resource_type,
8
- # resource_id, actor_type, actor_id, since and until.
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
- @client.paginate("audit_events_list_for_project",
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
- # equality filters ({ status: "published" }), `expand` relation or asset
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
- def get(record, expand: nil, include_deleted: nil)
34
- @client.request("records_get", path: { record_id: record },
35
- query: { expand: expand, include_deleted: include_deleted })
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 a draft: create("posts", data: { title: "Hello" }).
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 within 30 days.
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
- # Publishes and/or unpublishes the record later. Times left out keep
73
- # their value; name them in `clear` to remove them.
74
- def schedule(record, publish_at: nil, unpublish_at: nil, clear: [], idempotency_key: nil)
75
- body = clear.to_h { |name| [name.to_s, nil] }
76
- body["publish_at"] = publish_at unless publish_at.nil?
77
- body["unpublish_at"] = unpublish_at unless unpublish_at.nil?
78
- @client.request("records_schedule", path: { record_id: record }, body: body,
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, from_revision, to_revision)
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, actor_id, since and until.
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
- if_match: if_match, idempotency_key: idempotency_key)
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 for 30 days).
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", query: { deleted: deleted, limit: limit, cursor: cursor })
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 for 30 days.
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)
@@ -80,22 +83,27 @@ module Nohead
80
83
  class Fields < Resource
81
84
  def list(collection, deleted: nil, limit: nil, cursor: nil)
82
85
  @client.paginate("fields_list", path: { collection_id: collection },
83
- query: { deleted: deleted, limit: limit, cursor: cursor })
86
+ query: { filter: { deleted: deleted }, limit: limit, cursor: cursor })
87
+ end
88
+
89
+ def get(field)
90
+ @client.request("fields_get", path: { field_id: field })
84
91
  end
85
92
 
86
93
  def create(collection, idempotency_key: nil, change_note: nil, **params)
87
- @client.request("fields_create", path: { collection_id: collection }, body: params,
94
+ @client.request("fields_create", path: { collection_id: collection }, body: body_of(params),
88
95
  idempotency_key: idempotency_key, change_note: change_note)
89
96
  end
90
97
 
91
98
  # Renames, describes or loosens a field. Changes that rewrite record
92
99
  # values (type, multiple, tighter rules) are `migrate`.
93
100
  def update(field, idempotency_key: nil, change_note: nil, **params)
94
- @client.request("fields_update", path: { field_id: field }, body: params,
101
+ @client.request("fields_update", path: { field_id: field }, body: body_of(params),
95
102
  idempotency_key: idempotency_key, change_note: change_note)
96
103
  end
97
104
 
98
- # Soft-deletes the field: its values return with `restore` for 30 days.
105
+ # Soft-deletes the field: its values return with `restore` until its
106
+ # `purge_after`.
99
107
  def delete(field, idempotency_key: nil, change_note: nil)
100
108
  @client.request("fields_delete", path: { field_id: field },
101
109
  idempotency_key: idempotency_key, change_note: change_note)
@@ -106,8 +114,13 @@ module Nohead
106
114
  idempotency_key: idempotency_key, change_note: change_note)
107
115
  end
108
116
 
109
- # Puts the collection's fields in this order.
110
- def reorder(collection, field_ids, idempotency_key: nil, change_note: nil)
117
+ # The collection's active fields, in order.
118
+ def order(collection)
119
+ @client.request("fields_get_order", path: { collection_id: collection })
120
+ end
121
+
122
+ # Puts the collection's fields in this order; lists every active field once.
123
+ def reorder(collection, field_ids:, idempotency_key: nil, change_note: nil)
111
124
  @client.request("fields_reorder", path: { collection_id: collection },
112
125
  body: { field_ids: field_ids },
113
126
  idempotency_key: idempotency_key, change_note: change_note)
@@ -123,7 +136,7 @@ module Nohead
123
136
  # backfill) that rewrites every record. With dry_run: true, previews it.
124
137
  def migrate(field, dry_run: false, idempotency_key: nil, change_note: nil, **params)
125
138
  @client.request("fields_migrate", path: { field_id: field }, query: { dry_run: dry_run || nil },
126
- body: params, idempotency_key: idempotency_key,
139
+ body: body_of(params), idempotency_key: idempotency_key,
127
140
  change_note: change_note)
128
141
  end
129
142
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nohead
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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
- def list(limit: nil, cursor: nil)
63
- @client.paginate("webhooks_list", query: { limit: limit, cursor: cursor })
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
- # A new signing secret; the old one stops working at once.
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, cursor: cursor })
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: { status: "published" }).each do |post|
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.1.0
4
+ version: 0.3.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