nohead 0.5.0 → 0.7.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: e94f698ee3cb6906c1b21535666bdefc57f6d864dbad5eb3436c9e0a7a4bff2f
4
- data.tar.gz: a47d275d02c1b5111b0eadc5f14dedf2a201c5fdcf01ae0eaba306c3bdb87ad2
3
+ metadata.gz: 9b3361c68d640e8b2774b8e797bbf4108e19be21f0019600788d9f45e04457fe
4
+ data.tar.gz: d3c200b25b6afd1db440dced441f142da3d38b1731e34780356c373c2bc4b67c
5
5
  SHA512:
6
- metadata.gz: bbac1644f34a8ea9ff5b41b342facdfea9df8de606cca48e00fcbde9adcb0d3564c65973f43f087d8d6acc4f9443057107c7eb244ab47f557d96bb6b1f1d14cb
7
- data.tar.gz: e650ba515324f59b5cd141534be18d49ae4530e5fe89d7d19bf3aac1e2a6c97fc502b6094b06e43e580b771391d110ad72e32c166235eab6492b8592aaba4a0c
6
+ metadata.gz: 409ee914aaf232ce8f7109b4628f260f47f6b83861dceb48aebeaee661873c03f5ebef3ccbbd6e269504febad9325505a3b97a2f42905bb279e81d76aef70f18
7
+ data.tar.gz: 87b158ed9116745555538a9ef4297c584050251e69d0bab6101b823e239b3586262f161901f519973b1b07c4f2f2b76dfcd78ac86c55eedb905b208867d96d60
data/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.7.0
9
+
10
+ - `assets.list(q:, kind:)`: `q` finds assets whose filename or alt text has
11
+ every word, ignoring case; `kind` lists only `image`, `video`, `audio`,
12
+ `document` or `other` assets, or several kinds as an array.
13
+
14
+ ## 0.6.0
15
+
16
+ - API keys now start `nohead_sk_`, or `nohead_pk_` for published-only keys;
17
+ keys in the old `sk_live_` format no longer work. The gem sends whatever key
18
+ it's given, so only the docs and examples change.
19
+
20
+ - `assets.update(asset, filename:, alt:)` renames an asset and sets its alt
21
+ text (`PATCH /v1/assets/{asset_id}`). Assets have `alt` (a string or
22
+ `nil`), and webhooks can subscribe to `asset.updated`.
23
+
24
+ - Image URLs are open. Assets have `image_url`, the whole image, and the new
25
+ `Nohead::Images.url` writes any of imgproxy's processing options into it,
26
+ without a request.
27
+
28
+ - **Breaking:** `assets.image_url` is gone, with the API's
29
+ `GET /v1/assets/{asset_id}/image-url`: use `Nohead::Images.url`, which
30
+ needs no request.
31
+
32
+ - **Breaking:** collections have no `search_enabled`, and search indexes no
33
+ `enabled`, and collections' `create` and `update` no longer take
34
+ `search_enabled:`: API keys search every collection they can read.
35
+
36
+ - `revisions.get(revision)` reads a revision by its own `rev_` ID, as
37
+ webhooks and the activity log name it (`GET /v1/revisions/{revision_id}`).
38
+
39
+ - `records.create` takes `upsert_on:`, a unique field's key: the record
40
+ holding that value is updated instead, if one exists, and `if_match:`
41
+ applies to that update.
42
+
43
+ - Collections have `public_search`: whether the project's search tokens find
44
+ their published records. Off by default;
45
+ `collections.create(public_search: ...)` and `collections.update` take it. A
46
+ collection's search index has `index`, the name a token searches it by.
47
+
8
48
  ## 0.5.0
9
49
 
10
50
  - **Breaking:** field migrations are created under their collection:
data/README.md CHANGED
@@ -52,7 +52,7 @@ nohead = Nohead::Client.new(
52
52
 
53
53
  | Option | Default | |
54
54
  |---|---|---|
55
- | `api_key:` | `NOHEAD_API_KEY` | A project API key (`sk_live_...`). Required. |
55
+ | `api_key:` | `NOHEAD_API_KEY` | A project API key (`nohead_sk_...`, or `nohead_pk_...` when published-only). Required. |
56
56
  | `base_url:` | `NOHEAD_API_URL`, else `https://api.nohead.io` | |
57
57
  | `project_id:` | the key's project | Looked up once with `GET /v1/me` when omitted. |
58
58
  | `max_retries:` | `2` | See [retries](#retries-and-idempotency). |
@@ -76,11 +76,14 @@ nohead.records.delete(post.id) # soft delete; records.restore undoes it
76
76
 
77
77
  Methods return the API's resources (see [Results](#results)) and raise on failure. Field values are under `data`, keyed by field API key.
78
78
 
79
+ **Upserts.** With `upsert_on:`, the key of a `unique` field, `create` updates the record holding that value instead, if one exists, so a content sync can run again without duplicates: `nohead.records.create("products", data: { sku: "TEE-01", title: "Tee" }, upsert_on: "sku")`.
80
+
79
81
  **More:**
80
82
 
81
83
  - `count`, and `bulk` (up to 100 records at once)
82
84
  - `diff(record, from: 1, to: 2)`
83
85
  - `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)
86
+ - a revision by its own ID, as webhooks and the activity log name it: `nohead.revisions.get("rev_...")`
84
87
 
85
88
  ## Drafts
86
89
 
@@ -195,9 +198,17 @@ end
195
198
  asset = nohead.assets.upload("cover.jpg")
196
199
  nohead.records.update(id, data: { cover: asset.id })
197
200
 
198
- url = nohead.assets.image_url(asset.id, width: 1200, format: "webp").url
199
201
  ```
200
202
 
203
+ **Images.** Image URLs are open: an image's `image_url` serves the whole image, and any of [imgproxy's processing options](https://docs.imgproxy.net/usage/processing) written into it transform it. `Nohead::Images.url` builds them without a request:
204
+
205
+ ```ruby
206
+ Nohead::Images.url(asset, width: 1200, height: 630, fit: "cover", gravity: "sm", format: "webp")
207
+ Nohead::Images.url(asset.image_url, width: 400, dpr: 2, options: ["bl:2"])
208
+ ```
209
+
210
+ Thumbnails: an image's `preview_url` fits in 640×640, as WebP.
211
+
201
212
  **What `upload` accepts:** a path (String or Pathname), or an IO opened in binary mode. For bytes in memory, pass `StringIO.new(bytes)`.
202
213
 
203
214
  **What it does:**
@@ -212,6 +223,12 @@ url = nohead.assets.image_url(asset.id, width: 1200, format: "webp").url
212
223
 
213
224
  **Uploading from a browser:** create the upload on your server with `create_upload`, `PUT` the file from the browser, then `complete` it.
214
225
 
226
+ **Renaming and alt text:** `update` changes an asset's `filename:` and `alt:` text, the default wherever the asset is used (`expand:` returns it with the asset). The file itself never changes.
227
+
228
+ ```ruby
229
+ nohead.assets.update(asset.id, alt: "The spring menu, on a chalkboard")
230
+ ```
231
+
215
232
  ## Search
216
233
 
217
234
  ```ruby
@@ -223,7 +240,7 @@ results = nohead.search("content model", collections: %w[posts pages], filter: {
223
240
  results.meta.total_estimate
224
241
  ```
225
242
 
226
- Search needs `search_enabled` collections and the `search:read` scope. It pages through the first 1,000 hits.
243
+ Search needs the `search:read` scope and covers every collection the key can read, matching their `searchable` fields. It pages through the first 1,000 hits. For search straight from a site's browser, without the API, use a search token from the web app with Meilisearch's own clients or InstantSearch.
227
244
 
228
245
  ## Schema
229
246
 
@@ -275,13 +292,14 @@ The API's resources come back as `Nohead::NoheadObject`s.
275
292
  | `records` | `list`, `get`, `create`, `update`, `delete`, `restore`, `publish`, `unpublish`, `publication`, `get_schedule`, `schedule`, `unschedule`, `scheduled_changes`, `drafts`, `count`, `bulk`, `diff`, `search` |
276
293
  | `drafts` | `create`, `list`, `get`, `update`, `discard`, `merge`, `schedule`, `unschedule` |
277
294
  | `records.revisions` | `list`, `get`, `revert` |
295
+ | `revisions` | `get` (by its own `rev_` ID) |
278
296
  | `search` | across the project |
279
297
  | `collections` | `list`, `get`, `create`, `update`, `delete`, `restore`, `schema` |
280
298
  | `collections.schema_changes` | `list`, `get` |
281
299
  | `collections.search_index` | `get`, `rebuild` |
282
300
  | `fields` | `list`, `get`, `create`, `update`, `delete`, `restore`, `order`, `reorder`, `remove_alias` |
283
301
  | `migrations` | `create`, `list`, `get`, `cancel` |
284
- | `assets` | `upload`, `create_upload`, `complete`, `list`, `get`, `delete`, `restore`, `purge`, `usage`, `image_url`, `download_url` |
302
+ | `assets` | `upload`, `create_upload`, `complete`, `list`, `get`, `update`, `delete`, `restore`, `purge`, `usage`, `download_url` |
285
303
  | `webhooks` | `list`, `get`, `create`, `update`, `delete`, `restore`, `rotate_secret`, `test`, `unwrap` |
286
304
  | `webhooks.secrets` | `list`, `retire` |
287
305
  | `webhooks.deliveries` | `list`, `get`, `retry` |
data/lib/nohead/client.rb CHANGED
@@ -15,10 +15,11 @@ module Nohead
15
15
  RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
16
16
  MAX_RETRY_AFTER = 60
17
17
 
18
- attr_reader :records, :drafts, :collections, :fields, :migrations, :assets, :webhooks,
18
+ attr_reader :records, :revisions, :drafts, :collections, :fields, :migrations, :assets, :webhooks,
19
19
  :audit_events, :feature_flags, :me, :health
20
20
 
21
- # api_key:: A project API key ("sk_live_..."). Defaults to NOHEAD_API_KEY.
21
+ # api_key:: A project API key ("nohead_sk_...", or "nohead_pk_..." when
22
+ # published-only). Defaults to NOHEAD_API_KEY.
22
23
  # base_url:: Defaults to NOHEAD_API_URL, else https://api.nohead.io.
23
24
  # project_id:: The key's project. Looked up once with GET /v1/me when omitted.
24
25
  # max_retries:: Retries of failed requests (see the README, "Retries").
@@ -40,6 +41,7 @@ module Nohead
40
41
  @warned = {}
41
42
 
42
43
  @records = Resources::Records.new(self)
44
+ @revisions = Resources::RevisionsById.new(self)
43
45
  @drafts = Resources::Drafts.new(self)
44
46
  @collections = Resources::Collections.new(self)
45
47
  @fields = Resources::Fields.new(self)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ # Image renditions without a request. Image URLs are open: an image asset's
5
+ # `image_url` serves the whole image, and any of imgproxy's processing
6
+ # options written into it transform it
7
+ # (https://docs.imgproxy.net/usage/processing).
8
+ #
9
+ # Nohead::Images.url(asset, width: 800, height: 600, fit: "cover", format: "webp")
10
+ module Images
11
+ FITS = { "contain" => "fit", "cover" => "fill", "fill" => "force" }.freeze
12
+
13
+ module_function
14
+
15
+ # A rendition of an image (an asset, as an object or a Hash, or its `image_url`). fit: "contain"
16
+ # fits inside the box, "cover" fills it, cropping, "fill" stretches to it.
17
+ # gravity: is what a crop keeps ("sm" for smart, "fp:0.5:0.3" for a focal
18
+ # point); options: any other processing options, as written in a URL
19
+ # (["bl:2"]).
20
+ def url(image, width: nil, height: nil, fit: "contain", format: nil, quality: nil, dpr: nil,
21
+ gravity: nil, options: [])
22
+ base = base_of(image)
23
+ check_options([gravity, *options])
24
+ at = base.index("/plain/")
25
+ parts = [resize(fit, width, height), ("g:#{gravity}" if gravity), ("dpr:#{dpr}" if dpr),
26
+ ("q:#{quality}" if quality), *options].compact
27
+ base[0...at] + parts.map { |part| "/#{part}" }.join + source(base[at..], format)
28
+ end
29
+
30
+ # rs:<fit>:<width>:<height>, or nothing without a size (0 is none).
31
+ def resize(fit, width, height)
32
+ type = FITS.fetch(fit.to_s) { raise ArgumentError, "fit must be one of #{FITS.keys.join(', ')}" }
33
+ width = nil if width&.zero?
34
+ height = nil if height&.zero?
35
+ "rs:#{type}:#{width || 0}:#{height || 0}" if width || height
36
+ end
37
+ private_class_method :resize
38
+
39
+ def base_of(image)
40
+ base = case image
41
+ when String then image
42
+ when Hash then image["image_url"] || image[:image_url]
43
+ else image.image_url
44
+ end
45
+ raise ArgumentError, "Not a ready image: it has no image_url" if base.nil? || base.empty?
46
+ raise ArgumentError, "Not a Nohead image URL: #{base}" unless base.include?("/plain/")
47
+
48
+ base
49
+ end
50
+ private_class_method :base_of
51
+
52
+ def check_options(values)
53
+ bad = values.compact.find { |value| value.to_s.match?(%r{[/@?#]}) }
54
+ raise ArgumentError, "Not an imgproxy option: #{bad}" if bad
55
+ end
56
+ private_class_method :check_options
57
+
58
+ # The source comes last; a format replaces any the URL already names.
59
+ def source(source, format)
60
+ format ? "#{source.sub(/@[a-z]+\z/, '')}@#{format}" : source
61
+ end
62
+ private_class_method :source
63
+ end
64
+ end
@@ -8,10 +8,10 @@ module Nohead
8
8
  "assets_delete" => ["DELETE", "/v1/assets/{asset_id}"],
9
9
  "assets_download_url" => ["GET", "/v1/assets/{asset_id}/download-url"],
10
10
  "assets_get" => ["GET", "/v1/assets/{asset_id}"],
11
- "assets_image_url" => ["GET", "/v1/assets/{asset_id}/image-url"],
12
11
  "assets_list" => ["GET", "/v1/projects/{project_id}/assets"],
13
12
  "assets_purge" => ["POST", "/v1/assets/{asset_id}/purge"],
14
13
  "assets_restore" => ["POST", "/v1/assets/{asset_id}/restore"],
14
+ "assets_update" => ["PATCH", "/v1/assets/{asset_id}"],
15
15
  "assets_upload" => ["POST", "/v1/projects/{project_id}/assets"],
16
16
  "assets_usage" => ["GET", "/v1/assets/{asset_id}/usage"],
17
17
  "audit_events_list" => ["GET", "/v1/projects/{project_id}/audit-events"],
@@ -67,6 +67,7 @@ module Nohead
67
67
  "records_unpublish" => ["DELETE", "/v1/records/{record_id}/publication"],
68
68
  "records_unschedule" => ["DELETE", "/v1/records/{record_id}/schedule"],
69
69
  "records_update" => ["PATCH", "/v1/records/{record_id}"],
70
+ "revisions_get" => ["GET", "/v1/revisions/{revision_id}"],
70
71
  "scheduled_changes_list" => ["GET", "/v1/collections/{collection_id}/scheduled-changes"],
71
72
  "schema_changes_get" => ["GET", "/v1/schema-changes/{schema_change_id}"],
72
73
  "schema_changes_list" => ["GET", "/v1/collections/{collection_id}/schema-changes"],
@@ -54,20 +54,33 @@ module Nohead
54
54
  @client.request("assets_complete", path: { asset_id: asset }, idempotency_key: idempotency_key)
55
55
  end
56
56
 
57
+ # `q` finds assets whose filename or alt text has every word, ignoring
58
+ # case ("hero jpg" finds `Hero-banner.jpg`). `kind` lists only assets of
59
+ # a kind ("image", "video", "audio", "document" or "other"), or of
60
+ # several (%w[image video]).
61
+ #
57
62
  # `content_type` lists only assets of a MIME type or `type/*` wildcard
58
63
  # ("image/*"), or of several (["image/*", "application/pdf"]), for
59
64
  # example the ones an asset field accepts (its `accepted_types`; an
60
65
  # empty list, like a field's, means any type).
61
- def list(content_type: nil, deleted: nil, limit: nil, cursor: nil)
62
- content_type = (content_type.empty? ? nil : { in: content_type }) if content_type.is_a?(Array)
63
- @client.paginate("assets_list", query: { filter: { content_type: content_type, deleted: deleted },
64
- limit: limit, cursor: cursor })
66
+ def list(q: nil, kind: nil, content_type: nil, deleted: nil, limit: nil, cursor: nil)
67
+ filter = { kind: one_or_in(kind), content_type: one_or_in(content_type), deleted: deleted }
68
+ @client.paginate("assets_list", query: { q: q, filter: filter, limit: limit, cursor: cursor })
65
69
  end
66
70
 
67
71
  def get(asset)
68
72
  @client.request("assets_get", path: { asset_id: asset })
69
73
  end
70
74
 
75
+ # Renames the asset (`filename:`) or sets its `alt:` text (nil or ""
76
+ # clears it). The file itself can't change: upload a new asset for that.
77
+ # `alt` is the default wherever the asset is used. A deleted asset is a
78
+ # ConflictError: restore it first.
79
+ def update(asset, idempotency_key: nil, **params)
80
+ @client.request("assets_update", path: { asset_id: asset }, body: body_of(params),
81
+ idempotency_key: idempotency_key)
82
+ end
83
+
71
84
  # Soft-deletes the asset: restore brings it back until its `purge_after`,
72
85
  # when the file is purged.
73
86
  def delete(asset, idempotency_key: nil)
@@ -93,17 +106,20 @@ module Nohead
93
106
  @client.request("assets_usage", path: { asset_id: asset })
94
107
  end
95
108
 
96
- # A signed, cacheable URL of an image rendition (`url`).
97
- def image_url(asset, width: nil, height: nil, fit: nil, format: nil, quality: nil)
98
- @client.request("assets_image_url", path: { asset_id: asset }, query: {
99
- width: width, height: height, fit: fit, format: format, quality: quality
100
- })
101
- end
102
-
103
- # A 15-minute link to download the original file (`url`).
109
+ # A link to download the original file (`url`), valid for 2 minutes.
104
110
  def download_url(asset)
105
111
  @client.request("assets_download_url", path: { asset_id: asset })
106
112
  end
113
+
114
+ private
115
+
116
+ # One value as it is, several as `[in]`; an empty list (like an asset
117
+ # field's accepted_types) is no filter.
118
+ def one_or_in(value)
119
+ return value unless value.is_a?(Array)
120
+
121
+ value.empty? ? nil : { in: value }
122
+ end
107
123
  end
108
124
  end
109
125
  end
@@ -52,9 +52,13 @@ module Nohead
52
52
  end
53
53
 
54
54
  # Creates an unpublished record: create("posts", data: { title: "Hello" }).
55
- def create(collection, idempotency_key: nil, change_note: nil, **params)
55
+ # With upsert_on:, the key of a unique field, it updates the record holding
56
+ # that value instead, if one exists, so a sync can run again without
57
+ # duplicates; if_match: then applies to that update.
58
+ def create(collection, if_match: nil, idempotency_key: nil, change_note: nil, **params)
56
59
  @client.request("records_create", path: { collection_id: collection }, body: body_of(params),
57
- idempotency_key: idempotency_key, change_note: change_note)
60
+ if_match: if_match, idempotency_key: idempotency_key,
61
+ change_note: change_note)
58
62
  end
59
63
 
60
64
  # Sets the given fields (nil clears one); others keep their values.
@@ -122,9 +126,9 @@ module Nohead
122
126
  @client.paginate("record_drafts_list", path: { record_id: record }, query: { limit: limit, cursor: cursor })
123
127
  end
124
128
 
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.
129
+ # A collection's pending scheduled changes, soonest first: publishes,
130
+ # unpublishes and draft merges, one per change, so a record due to
131
+ # publish and later unpublish appears twice.
128
132
  def scheduled_changes(collection, sort: nil, limit: nil, cursor: nil)
129
133
  @client.paginate("scheduled_changes_list", path: { collection_id: collection },
130
134
  query: { sort: sort, limit: limit, cursor: cursor })
@@ -147,7 +151,7 @@ module Nohead
147
151
  @client.request("records_diff", path: { record_id: record }, query: { from: from, to: to })
148
152
  end
149
153
 
150
- # Full-text search in one collection (search_enabled collections), most
154
+ # Full-text search in one collection, matching its searchable fields, most
151
155
  # relevant first, through the first 1,000 hits.
152
156
  def search(collection, query, filter: nil, sort: nil, expand: nil, limit: nil, cursor: nil)
153
157
  @client.paginate("collections_search", path: { collection_id: collection }, query: {
@@ -178,5 +182,13 @@ module Nohead
178
182
  idempotency_key: idempotency_key, change_note: change_note)
179
183
  end
180
184
  end
185
+
186
+ # A revision by its own ID ("rev_..."), as webhooks and the activity log name
187
+ # it. Readable while its record is deleted.
188
+ class RevisionsById < Resource
189
+ def get(revision)
190
+ @client.request("revisions_get", path: { revision_id: revision })
191
+ end
192
+ end
181
193
  end
182
194
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nohead
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/nohead.rb CHANGED
@@ -23,4 +23,5 @@ require_relative "nohead/resources/schema"
23
23
  require_relative "nohead/resources/assets"
24
24
  require_relative "nohead/resources/other"
25
25
  require_relative "nohead/webhooks"
26
+ require_relative "nohead/images"
26
27
  require_relative "nohead/client"
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.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nohead
@@ -24,6 +24,7 @@ files:
24
24
  - lib/nohead.rb
25
25
  - lib/nohead/client.rb
26
26
  - lib/nohead/errors.rb
27
+ - lib/nohead/images.rb
27
28
  - lib/nohead/nohead_object.rb
28
29
  - lib/nohead/operations.rb
29
30
  - lib/nohead/page.rb