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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ module Resources
5
+ # Collections of the key's project. `collection` is an ID or slug.
6
+ class Collections < Resource
7
+ attr_reader :schema_changes, :search_index
8
+
9
+ def initialize(client)
10
+ super
11
+ @schema_changes = SchemaChanges.new(client)
12
+ @search_index = SearchIndexes.new(client)
13
+ end
14
+
15
+ # deleted: true lists deleted collections instead (restorable for 30 days).
16
+ def list(deleted: nil, limit: nil, cursor: nil)
17
+ @client.paginate("collections_list", query: { deleted: deleted, limit: limit, cursor: cursor })
18
+ end
19
+
20
+ def get(collection)
21
+ @client.request("collections_get", path: { collection_id: collection })
22
+ end
23
+
24
+ # Creates a collection, optionally with its fields.
25
+ def create(idempotency_key: nil, change_note: nil, **params)
26
+ @client.request("collections_create", body: params, idempotency_key: idempotency_key,
27
+ change_note: change_note)
28
+ end
29
+
30
+ def update(collection, idempotency_key: nil, change_note: nil, **params)
31
+ @client.request("collections_update", path: { collection_id: collection }, body: params,
32
+ idempotency_key: idempotency_key, change_note: change_note)
33
+ end
34
+
35
+ # Soft-deletes the collection and its records for 30 days.
36
+ def delete(collection, idempotency_key: nil, change_note: nil)
37
+ @client.request("collections_delete", path: { collection_id: collection },
38
+ idempotency_key: idempotency_key, change_note: change_note)
39
+ end
40
+
41
+ def restore(collection, idempotency_key: nil, change_note: nil)
42
+ @client.request("collections_restore", path: { collection_id: collection },
43
+ idempotency_key: idempotency_key, change_note: change_note)
44
+ end
45
+
46
+ # The collection's schema, now or at a past `version`.
47
+ def schema(collection, version: nil)
48
+ @client.request("collections_get_schema", path: { collection_id: collection },
49
+ query: { version: version })
50
+ end
51
+ end
52
+
53
+ # A collection's schema history, newest first.
54
+ class SchemaChanges < Resource
55
+ def list(collection, limit: nil, cursor: nil)
56
+ @client.paginate("schema_changes_list", path: { collection_id: collection },
57
+ query: { limit: limit, cursor: cursor })
58
+ end
59
+
60
+ def get(collection, schema_change)
61
+ @client.request("schema_changes_get",
62
+ path: { collection_id: collection, schema_change_id: schema_change })
63
+ end
64
+ end
65
+
66
+ # A collection's search index.
67
+ class SearchIndexes < Resource
68
+ def get(collection)
69
+ @client.request("search_index_get", path: { collection_id: collection })
70
+ end
71
+
72
+ # Rebuilds the index from the records; searches keep working meanwhile.
73
+ def rebuild(collection, idempotency_key: nil)
74
+ @client.request("search_index_rebuild", path: { collection_id: collection },
75
+ idempotency_key: idempotency_key)
76
+ end
77
+ end
78
+
79
+ # Fields of a collection. `field` is a field ID ("fld_...").
80
+ class Fields < Resource
81
+ def list(collection, deleted: nil, limit: nil, cursor: nil)
82
+ @client.paginate("fields_list", path: { collection_id: collection },
83
+ query: { deleted: deleted, limit: limit, cursor: cursor })
84
+ end
85
+
86
+ def create(collection, idempotency_key: nil, change_note: nil, **params)
87
+ @client.request("fields_create", path: { collection_id: collection }, body: params,
88
+ idempotency_key: idempotency_key, change_note: change_note)
89
+ end
90
+
91
+ # Renames, describes or loosens a field. Changes that rewrite record
92
+ # values (type, multiple, tighter rules) are `migrate`.
93
+ def update(field, idempotency_key: nil, change_note: nil, **params)
94
+ @client.request("fields_update", path: { field_id: field }, body: params,
95
+ idempotency_key: idempotency_key, change_note: change_note)
96
+ end
97
+
98
+ # Soft-deletes the field: its values return with `restore` for 30 days.
99
+ def delete(field, idempotency_key: nil, change_note: nil)
100
+ @client.request("fields_delete", path: { field_id: field },
101
+ idempotency_key: idempotency_key, change_note: change_note)
102
+ end
103
+
104
+ def restore(field, idempotency_key: nil, change_note: nil)
105
+ @client.request("fields_restore", path: { field_id: field },
106
+ idempotency_key: idempotency_key, change_note: change_note)
107
+ end
108
+
109
+ # Puts the collection's fields in this order.
110
+ def reorder(collection, field_ids, idempotency_key: nil, change_note: nil)
111
+ @client.request("fields_reorder", path: { collection_id: collection },
112
+ body: { field_ids: field_ids },
113
+ idempotency_key: idempotency_key, change_note: change_note)
114
+ end
115
+
116
+ # Stops accepting a renamed field's old API key before its 6 months end.
117
+ def remove_alias(field, alias_key, idempotency_key: nil)
118
+ @client.request("fields_remove_alias", path: { field_id: field, alias: alias_key },
119
+ idempotency_key: idempotency_key)
120
+ end
121
+
122
+ # Starts a field migration (type, multiple, tighter configuration or a
123
+ # backfill) that rewrites every record. With dry_run: true, previews it.
124
+ def migrate(field, dry_run: false, idempotency_key: nil, change_note: nil, **params)
125
+ @client.request("fields_migrate", path: { field_id: field }, query: { dry_run: dry_run || nil },
126
+ body: params, idempotency_key: idempotency_key,
127
+ change_note: change_note)
128
+ end
129
+ end
130
+
131
+ # Field migrations. While one runs, its collection is read-only.
132
+ class Migrations < Resource
133
+ def list(collection, limit: nil, cursor: nil)
134
+ @client.paginate("migrations_list", path: { collection_id: collection },
135
+ query: { limit: limit, cursor: cursor })
136
+ end
137
+
138
+ def get(migration)
139
+ @client.request("migrations_get", path: { migration_id: migration })
140
+ end
141
+
142
+ def cancel(migration, idempotency_key: nil)
143
+ @client.request("migrations_cancel", path: { migration_id: migration },
144
+ idempotency_key: idempotency_key)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Nohead
8
+ # A response: status, headers (lowercase names) and body text.
9
+ Response = Struct.new(:status, :headers, :body)
10
+
11
+ # Sends requests with Net::HTTP. A client's `transport:` can be anything
12
+ # with the same `call`, e.g. for tests.
13
+ class Transport
14
+ # `body` is a String, an IO (streamed, with Content-Length), or nil.
15
+ def call(method, url, headers, body, timeout)
16
+ uri = URI(url)
17
+ request = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri, headers)
18
+ if body.respond_to?(:read)
19
+ request.body_stream = body
20
+ elsif body
21
+ request.body = body
22
+ end
23
+ http = Net::HTTP.new(uri.host, uri.port)
24
+ http.use_ssl = uri.scheme == "https"
25
+ http.open_timeout = http.read_timeout = http.write_timeout = timeout
26
+ response = http.start { http.request(request) }
27
+ Response.new(response.code.to_i, response.each_header.to_h, response.body.to_s)
28
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout
29
+ raise TimeoutError, "The request to #{uri.host} timed out after #{timeout} s"
30
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
31
+ raise ConnectionError, "Could not reach #{uri.host}: #{e.message}"
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Nohead
6
+ # Reading files for assets.upload: a path (String or Pathname) or an IO
7
+ # opened in binary mode (File, StringIO).
8
+ class UploadSource
9
+ CONTENT_TYPES = {
10
+ ".avif" => "image/avif", ".gif" => "image/gif", ".jpeg" => "image/jpeg",
11
+ ".jpg" => "image/jpeg", ".png" => "image/png", ".svg" => "image/svg+xml",
12
+ ".webp" => "image/webp", ".pdf" => "application/pdf", ".json" => "application/json",
13
+ ".txt" => "text/plain", ".md" => "text/markdown", ".csv" => "text/csv",
14
+ ".mp3" => "audio/mpeg", ".mp4" => "video/mp4", ".webm" => "video/webm",
15
+ ".zip" => "application/zip"
16
+ }.freeze
17
+
18
+ attr_reader :filename, :content_type, :byte_size, :io
19
+
20
+ def initialize(file, filename: nil, content_type: nil, byte_size: nil)
21
+ name, size = file.is_a?(String) || file.is_a?(Pathname) ? open_path(Pathname(file)) : open_io(file)
22
+ @filename = filename || name || "upload"
23
+ @byte_size = byte_size || size or
24
+ raise UploadError, "Uploading a stream that cannot seek needs byte_size:"
25
+ @content_type = content_type ||
26
+ CONTENT_TYPES.fetch(File.extname(@filename).downcase, "application/octet-stream")
27
+ @start = @io.respond_to?(:pos) ? @io.pos : 0
28
+ end
29
+
30
+ # Whether the bytes can be sent again (for retries).
31
+ def replayable? = @io.respond_to?(:rewind) && @io.respond_to?(:pos)
32
+
33
+ def rewind
34
+ @io.pos = @start if replayable?
35
+ end
36
+
37
+ def close
38
+ @io.close if @owned
39
+ end
40
+
41
+ private
42
+
43
+ def open_path(path)
44
+ @io = path.open("rb")
45
+ @owned = true
46
+ [path.basename.to_s, path.size]
47
+ end
48
+
49
+ def open_io(io)
50
+ raise ArgumentError, "Upload a path or an IO; wrap bytes in StringIO.new(bytes)" unless io.respond_to?(:read)
51
+
52
+ @io = io
53
+ name = File.basename(io.path) if io.respond_to?(:path) && io.path
54
+ [name, remaining(io)]
55
+ end
56
+
57
+ def remaining(io)
58
+ return io.size - io.pos if io.respond_to?(:size) && io.respond_to?(:pos)
59
+
60
+ nil
61
+ rescue IOError, SystemCallError
62
+ nil
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nohead
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module Nohead
7
+ # Verifying webhook requests (Standard Webhooks), with or without a client:
8
+ #
9
+ # event = Nohead::Webhooks.unwrap(request.raw_post, request.headers,
10
+ # secret: ENV.fetch("NOHEAD_WEBHOOK_SECRET"))
11
+ # puts event.data.record.id if event.type == "record.published"
12
+ module Webhooks
13
+ # Checks a webhook request's signature and timestamp and returns its event
14
+ # (a NoheadObject: `type`, `data`...). Pass the raw body exactly as
15
+ # received: parsing and re-serializing JSON changes the bytes, and the
16
+ # signature with them. Raises WebhookVerificationError when anything does
17
+ # not match. `headers` is a Hash or anything with `[]` (any case).
18
+ def self.unwrap(body, headers, secret:, tolerance: 300)
19
+ id = header(headers, "webhook-id")
20
+ timestamp = header(headers, "webhook-timestamp")
21
+ signatures = header(headers, "webhook-signature")
22
+ unless id && timestamp && signatures
23
+ raise WebhookVerificationError, "Missing webhook-id, webhook-timestamp or webhook-signature header"
24
+ end
25
+ unless timestamp.match?(/\A\d+\z/) && (Time.now.to_i - timestamp.to_i).abs <= tolerance
26
+ raise WebhookVerificationError, "The webhook timestamp is too far from the current time"
27
+ end
28
+
29
+ key = secret.delete_prefix("whsec_").unpack1("m0")
30
+ expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")].pack("m0")
31
+ matched = signatures.split.any? do |entry|
32
+ version, signature = entry.split(",", 2)
33
+ version == "v1" && signature && OpenSSL.secure_compare(signature, expected)
34
+ end
35
+ raise WebhookVerificationError, "No webhook signature matches" unless matched
36
+
37
+ NoheadObject.wrap(JSON.parse(body))
38
+ rescue ArgumentError
39
+ raise WebhookVerificationError, "The webhook secret is not base64"
40
+ rescue JSON::ParserError
41
+ raise WebhookVerificationError, "The webhook body is not JSON"
42
+ end
43
+
44
+ def self.header(headers, name)
45
+ return headers[name] || headers[name.split("-").map(&:capitalize).join("-")] unless headers.is_a?(Hash)
46
+
47
+ headers.find { |key, _| key.to_s.downcase == name }&.last
48
+ end
49
+ private_class_method :header
50
+ end
51
+
52
+ module Resources
53
+ # Webhooks of the key's project. `webhook` is a webhook ID ("wh_...").
54
+ class Webhooks < Resource
55
+ attr_reader :deliveries
56
+
57
+ def initialize(client)
58
+ super
59
+ @deliveries = Deliveries.new(client)
60
+ end
61
+
62
+ def list(limit: nil, cursor: nil)
63
+ @client.paginate("webhooks_list", query: { limit: limit, cursor: cursor })
64
+ end
65
+
66
+ def get(webhook)
67
+ @client.request("webhooks_get", path: { webhook_id: webhook })
68
+ end
69
+
70
+ # Subscribes a URL. The response is the only time `secret` is shown.
71
+ def create(idempotency_key: nil, **params)
72
+ @client.request("webhooks_create", body: params, idempotency_key: idempotency_key)
73
+ end
74
+
75
+ def update(webhook, idempotency_key: nil, **params)
76
+ @client.request("webhooks_update", path: { webhook_id: webhook }, body: params,
77
+ idempotency_key: idempotency_key)
78
+ end
79
+
80
+ def delete(webhook, idempotency_key: nil)
81
+ @client.request("webhooks_delete", path: { webhook_id: webhook }, idempotency_key: idempotency_key)
82
+ end
83
+
84
+ # A new signing secret; the old one stops working at once.
85
+ def rotate_secret(webhook, idempotency_key: nil)
86
+ @client.request("webhooks_rotate_secret", path: { webhook_id: webhook },
87
+ idempotency_key: idempotency_key)
88
+ end
89
+
90
+ # Sends a `webhook.test` event to the URL.
91
+ def test(webhook, idempotency_key: nil)
92
+ @client.request("webhooks_test", path: { webhook_id: webhook }, idempotency_key: idempotency_key)
93
+ end
94
+
95
+ # Checks a webhook request's signature and returns its event (see
96
+ # Nohead::Webhooks.unwrap, which needs no client).
97
+ def unwrap(body, headers, secret:, tolerance: 300)
98
+ Nohead::Webhooks.unwrap(body, headers, secret: secret, tolerance: tolerance)
99
+ end
100
+ end
101
+
102
+ # Delivery attempts of a webhook's events.
103
+ class Deliveries < Resource
104
+ def list(webhook, status: nil, limit: nil, cursor: nil)
105
+ @client.paginate("webhook_deliveries_list", path: { webhook_id: webhook },
106
+ query: { status: status, limit: limit, cursor: cursor })
107
+ end
108
+
109
+ def get(delivery)
110
+ @client.request("webhook_deliveries_get", path: { delivery_id: delivery })
111
+ end
112
+
113
+ # Sends the event again.
114
+ def retry(delivery, idempotency_key: nil)
115
+ @client.request("webhook_deliveries_retry", path: { delivery_id: delivery },
116
+ idempotency_key: idempotency_key)
117
+ end
118
+ end
119
+ end
120
+ end
data/lib/nohead.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The official Ruby SDK for the Nohead API.
4
+ #
5
+ # require "nohead"
6
+ #
7
+ # nohead = Nohead::Client.new # NOHEAD_API_KEY
8
+ # nohead.records.list("posts", filter: { status: "published" }).each do |post|
9
+ # puts post.data["title"]
10
+ # end
11
+ module Nohead; end
12
+
13
+ require_relative "nohead/version"
14
+ require_relative "nohead/nohead_object"
15
+ require_relative "nohead/errors"
16
+ require_relative "nohead/operations"
17
+ require_relative "nohead/page"
18
+ require_relative "nohead/transport"
19
+ require_relative "nohead/uploads"
20
+ require_relative "nohead/resources/records"
21
+ require_relative "nohead/resources/schema"
22
+ require_relative "nohead/resources/assets"
23
+ require_relative "nohead/resources/other"
24
+ require_relative "nohead/webhooks"
25
+ require_relative "nohead/client"
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nohead
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nohead
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: 'A client for the Nohead API: records, collections, fields, assets, webhooks
13
+ and search, with pagination, safe retries, uploads and webhook verification. No
14
+ runtime dependencies.'
15
+ email:
16
+ - hello@nohead.io
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - lib/nohead.rb
25
+ - lib/nohead/client.rb
26
+ - lib/nohead/errors.rb
27
+ - lib/nohead/nohead_object.rb
28
+ - lib/nohead/operations.rb
29
+ - lib/nohead/page.rb
30
+ - lib/nohead/resources/assets.rb
31
+ - lib/nohead/resources/other.rb
32
+ - lib/nohead/resources/records.rb
33
+ - lib/nohead/resources/schema.rb
34
+ - lib/nohead/transport.rb
35
+ - lib/nohead/uploads.rb
36
+ - lib/nohead/version.rb
37
+ - lib/nohead/webhooks.rb
38
+ homepage: https://nohead.io
39
+ licenses:
40
+ - MIT
41
+ metadata:
42
+ homepage_uri: https://nohead.io
43
+ source_code_uri: https://github.com/nohead-io/nohead-ruby
44
+ changelog_uri: https://github.com/nohead-io/nohead-ruby/blob/main/CHANGELOG.md
45
+ bug_tracker_uri: https://github.com/nohead-io/nohead-ruby/issues
46
+ rubygems_mfa_required: 'true'
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.3'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 4.0.20
62
+ specification_version: 4
63
+ summary: Official Ruby SDK for the Nohead API
64
+ test_files: []