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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8863cd35d5aaae50fb05907a1a5695db73f53ea9c89ffb2c2f098c5e51b1864
4
- data.tar.gz: 247129f90aaaf38df24440aca69fb0beb30e8f9d2e57d59f4e5d45119f64a0ba
3
+ metadata.gz: 747275a28bad0e0410f52109f1bac03d8d703fedccafe3110f21ae904421d511
4
+ data.tar.gz: 696d827a38b169e1a047b38a40d9263417cff3f111cec74d825a77236072a62c
5
5
  SHA512:
6
- metadata.gz: 648052049944a211a868972854133e2fc4fe1197ae09611fb933fe89c18e5d9cf4d97a19054ea26924b80fe4f6f70ee05b6f8dda524c8348298d6f6ceb68e190
7
- data.tar.gz: 4c3455477a8fb32bf792d9eb1d5f184876febf275a141654f68c91cff53e33f526321a6e9a187712eea6b23e538eab094e51756cbfcbf9eaaa4b5f3fcc67b85a
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: { 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
  ```
@@ -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
- 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
 
@@ -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", api_key: "title", type: "text", required: true }])
210
- nohead.fields.create("posts", name: "Summary", api_key: "summary", type: "long_text")
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** (`*_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, 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 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.
@@ -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[status]=published), arrays are comma-separated (expand=author,tags),
162
- # times are ISO 8601. nil values are left out.
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
- headers["Nohead-Change-Note"] = change_note if change_note
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 = 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,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}/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}"],
55
- "schema_changes_get" => ["GET", "/v1/collections/{collection_id}/schema-changes/{schema_change_id}"],
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
- "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`.
@@ -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; 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.
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 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)
@@ -57,9 +60,8 @@ module Nohead
57
60
  query: { limit: limit, cursor: cursor })
58
61
  end
59
62
 
60
- def get(collection, schema_change)
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` for 30 days.
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
- # Puts the collection's fields in this order.
110
- def reorder(collection, field_ids, idempotency_key: nil, change_note: nil)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nohead
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.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.2.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