nohead 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ # The base of every error the SDK raises.
5
+ class Error < StandardError; end
6
+
7
+ # The API answered with an error status. Subclasses follow the error `type`.
8
+ class APIError < Error
9
+ # The HTTP status.
10
+ attr_reader :status
11
+ # The error `type`, e.g. "validation_error"; nil if the body had none.
12
+ attr_reader :type
13
+ # The request's ID ("req_..."), for support requests and logs.
14
+ attr_reader :request_id
15
+ # Field-level details: objects with `field`, `code` and `message`.
16
+ attr_reader :details
17
+ # The response headers (lowercase names).
18
+ attr_reader :headers
19
+ # The parsed response body, or its text when it was not JSON.
20
+ attr_reader :body
21
+
22
+ def initialize(status, body, headers)
23
+ error = body.is_a?(Hash) && body["error"].is_a?(Hash) ? body["error"] : {}
24
+ @status = status
25
+ @type = error["type"]
26
+ @request_id = error["request_id"] || headers["x-request-id"]
27
+ @details = Array(error["details"]).map { |detail| NoheadObject.wrap(detail) }
28
+ @headers = headers
29
+ @body = body
30
+ super(error["message"] || "Request failed with status #{status}")
31
+ end
32
+
33
+ def to_s
34
+ suffix = request_id ? " (request #{request_id})" : ""
35
+ "#{status} #{type || 'error'}: #{super}#{suffix}"
36
+ end
37
+ end
38
+
39
+ class InvalidRequestError < APIError; end
40
+ class AuthenticationError < APIError; end
41
+ class PlanLimitExceededError < APIError; end
42
+ class AuthorizationError < APIError; end
43
+ class NotFoundError < APIError; end
44
+ class ConflictError < APIError; end
45
+
46
+ # An `if_match` revision was not the current one: reload and try again.
47
+ class PreconditionFailedError < APIError
48
+ # The resource's current revision, when the API reports it.
49
+ def current_revision
50
+ details.find { |detail| detail["code"] == "revision_mismatch" }&.[]("current_revision")
51
+ end
52
+ end
53
+
54
+ class ValidationError < APIError; end
55
+
56
+ class RateLimitError < APIError
57
+ # Seconds to wait before retrying, from `Retry-After`.
58
+ def retry_after
59
+ Nohead.retry_after_seconds(headers)
60
+ end
61
+ end
62
+
63
+ class InternalServerError < APIError; end
64
+ class ServiceUnavailableError < APIError; end
65
+
66
+ # The request never got a response: DNS, TLS, a reset connection.
67
+ class ConnectionError < Error; end
68
+
69
+ # An attempt took longer than the `timeout` option.
70
+ class TimeoutError < ConnectionError; end
71
+
72
+ # A presigned upload to storage failed (`assets.upload`).
73
+ class UploadError < Error
74
+ # Storage's HTTP status, when it answered.
75
+ attr_reader :status
76
+
77
+ def initialize(message, status = nil)
78
+ @status = status
79
+ super(message)
80
+ end
81
+ end
82
+
83
+ # A webhook request's signature or timestamp did not check out.
84
+ class WebhookVerificationError < Error; end
85
+
86
+ ERROR_CLASSES = {
87
+ "invalid_request" => InvalidRequestError,
88
+ "authentication_error" => AuthenticationError,
89
+ "plan_limit_exceeded" => PlanLimitExceededError,
90
+ "authorization_error" => AuthorizationError,
91
+ "not_found" => NotFoundError,
92
+ "conflict" => ConflictError,
93
+ "precondition_failed" => PreconditionFailedError,
94
+ "validation_error" => ValidationError,
95
+ "rate_limited" => RateLimitError,
96
+ "internal_error" => InternalServerError,
97
+ "service_unavailable" => ServiceUnavailableError
98
+ }.freeze
99
+
100
+ # The error for a response, by its `type`, or by status when it has none.
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] ||
104
+ if status == 503 then ServiceUnavailableError
105
+ elsif status >= 500 then InternalServerError
106
+ else APIError
107
+ end
108
+ klass.new(status, body, headers)
109
+ end
110
+
111
+ def self.retry_after_seconds(headers)
112
+ value = headers["retry-after"] or return nil
113
+ return [Float(value), 0].max if value.match?(/\A\d+(\.\d+)?\z/)
114
+
115
+ [Time.httpdate(value) - Time.now, 0].max
116
+ rescue ArgumentError
117
+ nil
118
+ end
119
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Nohead
6
+ # What the SDK returns for the API's resources: read fields as methods
7
+ # (`record.data`) or with `[]` (`record["data"]`), nested objects included.
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.
11
+ class NoheadObject
12
+ ISO_TIME = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})\z/
13
+
14
+ def self.wrap(value, key = nil)
15
+ case value
16
+ when Hash then new(value)
17
+ when Array then value.map { |item| wrap(item) }
18
+ when String
19
+ key.to_s.end_with?("_at") && value.match?(ISO_TIME) ? Time.iso8601(value) : value
20
+ else value
21
+ end
22
+ end
23
+
24
+ def initialize(values)
25
+ @values = values.to_h { |key, value| [key.to_s, value] }
26
+ end
27
+
28
+ def [](key)
29
+ key = key.to_s
30
+ self.class.wrap(@values[key], key)
31
+ end
32
+
33
+ def key?(key) = @values.key?(key.to_s)
34
+ def keys = @values.keys
35
+
36
+ def dig(key, *rest)
37
+ value = self[key]
38
+ rest.empty? || value.nil? ? value : value.dig(*rest)
39
+ end
40
+
41
+ # The fields as a plain Hash with string keys (raw values).
42
+ def to_h = @values
43
+
44
+ def ==(other) = other.is_a?(NoheadObject) && other.to_h == to_h
45
+
46
+ def respond_to_missing?(name, include_private = false)
47
+ @values.key?(name.to_s) || super
48
+ end
49
+
50
+ def method_missing(name, *args)
51
+ key = name.to_s
52
+ return super unless args.empty? && @values.key?(key)
53
+
54
+ self[key]
55
+ end
56
+
57
+ def inspect
58
+ label = @values["object"] ? "#{@values['object']} " : ""
59
+ "#<Nohead::NoheadObject #{label}#{@values.inspect}>"
60
+ end
61
+ alias to_s inspect
62
+ end
63
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by scripts/generate.rb from openapi.json. Do not edit.
4
+ module Nohead
5
+ # Every operation an API key can call: [method, path].
6
+ OPERATIONS = {
7
+ "assets_complete" => ["POST", "/v1/assets/{asset_id}/complete"],
8
+ "assets_delete" => ["DELETE", "/v1/assets/{asset_id}"],
9
+ "assets_download_url" => ["GET", "/v1/assets/{asset_id}/download-url"],
10
+ "assets_get" => ["GET", "/v1/assets/{asset_id}"],
11
+ "assets_image_url" => ["GET", "/v1/assets/{asset_id}/image-url"],
12
+ "assets_list" => ["GET", "/v1/projects/{project_id}/assets"],
13
+ "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"],
16
+ "collections_create" => ["POST", "/v1/projects/{project_id}/collections"],
17
+ "collections_delete" => ["DELETE", "/v1/collections/{collection_id}"],
18
+ "collections_get" => ["GET", "/v1/collections/{collection_id}"],
19
+ "collections_get_schema" => ["GET", "/v1/collections/{collection_id}/schema"],
20
+ "collections_list" => ["GET", "/v1/projects/{project_id}/collections"],
21
+ "collections_restore" => ["POST", "/v1/collections/{collection_id}/restore"],
22
+ "collections_search" => ["GET", "/v1/collections/{collection_id}/search"],
23
+ "collections_update" => ["PATCH", "/v1/collections/{collection_id}"],
24
+ "feature_flags_list" => ["GET", "/v1/feature-flags"],
25
+ "fields_create" => ["POST", "/v1/collections/{collection_id}/fields"],
26
+ "fields_delete" => ["DELETE", "/v1/fields/{field_id}"],
27
+ "fields_list" => ["GET", "/v1/collections/{collection_id}/fields"],
28
+ "fields_migrate" => ["POST", "/v1/fields/{field_id}/migrations"],
29
+ "fields_remove_alias" => ["DELETE", "/v1/fields/{field_id}/aliases/{alias}"],
30
+ "fields_reorder" => ["POST", "/v1/collections/{collection_id}/fields/reorder"],
31
+ "fields_restore" => ["POST", "/v1/fields/{field_id}/restore"],
32
+ "fields_update" => ["PATCH", "/v1/fields/{field_id}"],
33
+ "health_check" => ["GET", "/v1/health"],
34
+ "me_get" => ["GET", "/v1/me"],
35
+ "migrations_cancel" => ["POST", "/v1/migrations/{migration_id}/cancel"],
36
+ "migrations_get" => ["GET", "/v1/migrations/{migration_id}"],
37
+ "migrations_list" => ["GET", "/v1/collections/{collection_id}/migrations"],
38
+ "projects_search" => ["GET", "/v1/projects/{project_id}/search"],
39
+ "record_revisions_get" => ["GET", "/v1/records/{record_id}/revisions/{revision}"],
40
+ "record_revisions_list" => ["GET", "/v1/records/{record_id}/revisions"],
41
+ "record_revisions_revert" => ["POST", "/v1/records/{record_id}/revisions/{revision}/revert"],
42
+ "records_bulk" => ["POST", "/v1/collections/{collection_id}/records/bulk"],
43
+ "records_count" => ["GET", "/v1/collections/{collection_id}/records/count"],
44
+ "records_create" => ["POST", "/v1/collections/{collection_id}/records"],
45
+ "records_delete" => ["DELETE", "/v1/records/{record_id}"],
46
+ "records_diff" => ["GET", "/v1/records/{record_id}/diff"],
47
+ "records_get" => ["GET", "/v1/records/{record_id}"],
48
+ "records_list" => ["GET", "/v1/collections/{collection_id}/records"],
49
+ "records_publish" => ["POST", "/v1/records/{record_id}/publish"],
50
+ "records_restore" => ["POST", "/v1/records/{record_id}/restore"],
51
+ "records_schedule" => ["PUT", "/v1/records/{record_id}/schedule"],
52
+ "records_unpublish" => ["POST", "/v1/records/{record_id}/unpublish"],
53
+ "records_unschedule" => ["DELETE", "/v1/records/{record_id}/schedule"],
54
+ "records_update" => ["PATCH", "/v1/records/{record_id}"],
55
+ "schema_changes_get" => ["GET", "/v1/collections/{collection_id}/schema-changes/{schema_change_id}"],
56
+ "schema_changes_list" => ["GET", "/v1/collections/{collection_id}/schema-changes"],
57
+ "search_index_get" => ["GET", "/v1/collections/{collection_id}/search-index"],
58
+ "search_index_rebuild" => ["POST", "/v1/collections/{collection_id}/search-index/rebuild"],
59
+ "webhook_deliveries_get" => ["GET", "/v1/webhook-deliveries/{delivery_id}"],
60
+ "webhook_deliveries_list" => ["GET", "/v1/webhooks/{webhook_id}/deliveries"],
61
+ "webhook_deliveries_retry" => ["POST", "/v1/webhook-deliveries/{delivery_id}/retry"],
62
+ "webhooks_create" => ["POST", "/v1/projects/{project_id}/webhooks"],
63
+ "webhooks_delete" => ["DELETE", "/v1/webhooks/{webhook_id}"],
64
+ "webhooks_get" => ["GET", "/v1/webhooks/{webhook_id}"],
65
+ "webhooks_list" => ["GET", "/v1/projects/{project_id}/webhooks"],
66
+ "webhooks_rotate_secret" => ["POST", "/v1/webhooks/{webhook_id}/rotate-secret"],
67
+ "webhooks_test" => ["POST", "/v1/webhooks/{webhook_id}/test"],
68
+ "webhooks_update" => ["PATCH", "/v1/webhooks/{webhook_id}"]
69
+ }.freeze
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ # One page of a list. `each` (and so `map`, `first`, `to_a`...) walks every
5
+ # item from here on, fetching later pages as needed:
6
+ #
7
+ # nohead.records.list("posts").each { |record| puts record.data["title"] }
8
+ #
9
+ # page = nohead.records.list("posts", limit: 100)
10
+ # page.data # this page's items
11
+ # page = page.next_page while page.next_page?
12
+ class Page
13
+ include Enumerable
14
+
15
+ # The items on this page.
16
+ attr_reader :data
17
+ # `next_cursor` and `has_more` (and `total_estimate` for search).
18
+ attr_reader :meta
19
+
20
+ def initialize(data, meta, &fetch)
21
+ @data = data
22
+ @meta = meta
23
+ @fetch = fetch
24
+ end
25
+
26
+ def next_page?
27
+ meta["has_more"] == true && !meta["next_cursor"].nil?
28
+ end
29
+
30
+ # The next page; raises Nohead::Error when there is none.
31
+ def next_page
32
+ raise Error, "There is no next page" unless next_page?
33
+
34
+ @fetch.call(meta["next_cursor"])
35
+ end
36
+
37
+ def each(&block)
38
+ return enum_for(:each) unless block
39
+
40
+ page = self
41
+ loop do
42
+ page.data.each(&block)
43
+ break unless page.next_page?
44
+
45
+ page = page.next_page
46
+ end
47
+ self
48
+ end
49
+
50
+ def inspect = "#<Nohead::Page #{data.size} items, more: #{next_page?}>"
51
+ end
52
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ module Resources
5
+ # Files of the key's project. `asset` is an asset ID ("ast_...").
6
+ class Assets < Resource
7
+ # Uploads a file and returns the `ready` asset: creates the upload, sends
8
+ # the bytes straight to storage, then completes it (which checks the
9
+ # file). `file` is a path or an IO; for bytes, pass StringIO.new(bytes).
10
+ # Raises UploadError if storage refuses the bytes, and ValidationError if
11
+ # the file fails the checks.
12
+ def upload(file, filename: nil, content_type: nil, byte_size: nil, idempotency_key: nil)
13
+ source = UploadSource.new(file, filename: filename, content_type: content_type,
14
+ byte_size: byte_size)
15
+ begin
16
+ created = create_upload(filename: source.filename, content_type: source.content_type,
17
+ byte_size: source.byte_size, idempotency_key: idempotency_key)
18
+ upload = created.upload
19
+ response = @client.put_upload(upload.url, upload["method"], upload.headers.to_h, source)
20
+ ensure
21
+ source.close
22
+ end
23
+ unless response.status.between?(200, 299)
24
+ raise UploadError.new("Storage refused the upload of #{source.filename} (#{response.status})",
25
+ response.status)
26
+ end
27
+ complete(created.asset.id)
28
+ end
29
+
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.
32
+ def create_upload(idempotency_key: nil, **params)
33
+ @client.request("assets_upload", body: params, idempotency_key: idempotency_key)
34
+ end
35
+
36
+ # Checks an uploaded file and marks the asset `ready`.
37
+ def complete(asset, idempotency_key: nil)
38
+ @client.request("assets_complete", path: { asset_id: asset }, idempotency_key: idempotency_key)
39
+ end
40
+
41
+ def list(deleted: nil, limit: nil, cursor: nil)
42
+ @client.paginate("assets_list", query: { deleted: deleted, limit: limit, cursor: cursor })
43
+ end
44
+
45
+ def get(asset)
46
+ @client.request("assets_get", path: { asset_id: asset })
47
+ end
48
+
49
+ # Soft-deletes the asset; the file is purged after 30 days.
50
+ def delete(asset, idempotency_key: nil)
51
+ @client.request("assets_delete", path: { asset_id: asset }, idempotency_key: idempotency_key)
52
+ end
53
+
54
+ def restore(asset, idempotency_key: nil)
55
+ @client.request("assets_restore", path: { asset_id: asset }, idempotency_key: idempotency_key)
56
+ end
57
+
58
+ # A signed, cacheable URL of an image rendition (`url`).
59
+ def image_url(asset, width: nil, height: nil, fit: nil, format: nil, quality: nil)
60
+ @client.request("assets_image_url", path: { asset_id: asset }, query: {
61
+ width: width, height: height, fit: fit, format: format, quality: quality
62
+ })
63
+ end
64
+
65
+ # A 15-minute link to download the original file (`url`).
66
+ def download_url(asset)
67
+ @client.request("assets_download_url", path: { asset_id: asset })
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ module Resources
5
+ # The project's activity log, newest first. Needs the audit:read scope.
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.
9
+ def list(filter: nil, sort: nil, limit: nil, cursor: nil)
10
+ @client.paginate("audit_events_list_for_project",
11
+ query: { filter: filter, sort: sort, limit: limit, cursor: cursor })
12
+ end
13
+ end
14
+
15
+ # Feature flags evaluated for the key's project.
16
+ class FeatureFlags < Resource
17
+ # Flags evaluated for the key's project.
18
+ def list
19
+ @client.request("feature_flags_list")
20
+ end
21
+ end
22
+
23
+ # The credentials the client uses.
24
+ class Me < Resource
25
+ # The API key: its project, scopes, published_only and expiry.
26
+ def get
27
+ @client.request("me_get")
28
+ end
29
+ end
30
+
31
+ # Whether the API is up.
32
+ class Health < Resource
33
+ def check
34
+ @client.request("health_check")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ module Resources
5
+ # The base of the resources: each holds the client it sends requests with.
6
+ class Resource
7
+ def initialize(client)
8
+ @client = client
9
+ end
10
+
11
+ def inspect = "#<#{self.class.name}>"
12
+ end
13
+
14
+ # Records: the content of a collection. `collection` is a collection ID or
15
+ # slug; `record` a record ID ("rec_...").
16
+ class Records < Resource
17
+ attr_reader :revisions
18
+
19
+ def initialize(client)
20
+ super
21
+ @revisions = Revisions.new(client)
22
+ end
23
+
24
+ # 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).
27
+ def list(collection, filter: nil, sort: nil, expand: nil, limit: nil, cursor: nil)
28
+ @client.paginate("records_list", path: { collection_id: collection }, query: {
29
+ filter: filter, sort: sort, expand: expand, limit: limit, cursor: cursor
30
+ })
31
+ end
32
+
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 })
36
+ end
37
+
38
+ # Creates a draft: create("posts", data: { title: "Hello" }).
39
+ def create(collection, idempotency_key: nil, change_note: nil, **params)
40
+ @client.request("records_create", path: { collection_id: collection }, body: params,
41
+ idempotency_key: idempotency_key, change_note: change_note)
42
+ end
43
+
44
+ # Sets the given fields (nil clears one); others keep their values.
45
+ def update(record, if_match: nil, idempotency_key: nil, change_note: nil, **params)
46
+ @client.request("records_update", path: { record_id: record }, body: params,
47
+ if_match: if_match, idempotency_key: idempotency_key,
48
+ change_note: change_note)
49
+ end
50
+
51
+ # Soft-deletes the record: restore brings it back within 30 days.
52
+ def delete(record, if_match: nil, idempotency_key: nil, change_note: nil)
53
+ @client.request("records_delete", path: { record_id: record }, if_match: if_match,
54
+ idempotency_key: idempotency_key, change_note: change_note)
55
+ end
56
+
57
+ def restore(record, idempotency_key: nil, change_note: nil)
58
+ @client.request("records_restore", path: { record_id: record },
59
+ idempotency_key: idempotency_key, change_note: change_note)
60
+ end
61
+
62
+ def publish(record, if_match: nil, idempotency_key: nil, change_note: nil)
63
+ @client.request("records_publish", path: { record_id: record }, if_match: if_match,
64
+ idempotency_key: idempotency_key, change_note: change_note)
65
+ end
66
+
67
+ def unpublish(record, if_match: nil, idempotency_key: nil, change_note: nil)
68
+ @client.request("records_unpublish", path: { record_id: record }, if_match: if_match,
69
+ idempotency_key: idempotency_key, change_note: change_note)
70
+ end
71
+
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,
79
+ idempotency_key: idempotency_key)
80
+ end
81
+
82
+ # Clears both scheduled times.
83
+ def unschedule(record, idempotency_key: nil)
84
+ @client.request("records_unschedule", path: { record_id: record },
85
+ idempotency_key: idempotency_key)
86
+ end
87
+
88
+ def count(collection, filter: nil)
89
+ @client.request("records_count", path: { collection_id: collection },
90
+ query: { filter: filter })
91
+ end
92
+
93
+ # Publishes, unpublishes, deletes, restores or updates up to 100 records.
94
+ # Each record succeeds or fails on its own; see `results`.
95
+ def bulk(collection, idempotency_key: nil, change_note: nil, **params)
96
+ @client.request("records_bulk", path: { collection_id: collection }, body: params,
97
+ idempotency_key: idempotency_key, change_note: change_note)
98
+ end
99
+
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 })
104
+ end
105
+
106
+ # Full-text search in one collection (search_enabled collections), most
107
+ # relevant first, through the first 1,000 hits.
108
+ def search(collection, query, filter: nil, sort: nil, expand: nil, limit: nil, cursor: nil)
109
+ @client.paginate("collections_search", path: { collection_id: collection }, query: {
110
+ q: query, filter: filter, sort: sort, expand: expand, limit: limit,
111
+ cursor: cursor
112
+ })
113
+ end
114
+ end
115
+
116
+ # A record's version history. `revision` is a revision number.
117
+ class Revisions < Resource
118
+ # Newest first. `filter` takes operation, actor_id, since and until.
119
+ def list(record, filter: nil, limit: nil, cursor: nil)
120
+ @client.paginate("record_revisions_list", path: { record_id: record },
121
+ query: { filter: filter, limit: limit, cursor: cursor })
122
+ end
123
+
124
+ def get(record, revision)
125
+ @client.request("record_revisions_get", path: { record_id: record, revision: revision })
126
+ end
127
+
128
+ # Restores the record's data as of `revision`, as a new revision. With
129
+ # dry_run: true, previews it.
130
+ def revert(record, revision, dry_run: false, if_match: nil, idempotency_key: nil)
131
+ @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)
134
+ end
135
+ end
136
+ end
137
+ end