databasus_ruby 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9223ce08d06f8a4f29e700d9046bab99ee5aaec01131af37a12620cdec27b7ed
4
+ data.tar.gz: 1cd2c4226183d6106dd8a351e61533c2bdf513f818bb0e43f4c3dd077fe548b0
5
+ SHA512:
6
+ metadata.gz: f4c7dda68f74e63dc5833e41b2aff40c730f5462920bc8aaee57549a0b435516d187d07bd6e62591d7ef10b03f863d2492d5754da422021089bcaa4226d26fe2
7
+ data.tar.gz: 5c59d0c7e223f8904a54de677f5083c9cd94466e16e60d082b02ef42124784e52f28c09922fbe33542bf5e7b5f5c07c3b2e7a21a5d675e96e46cda29715ba7e7
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ ## [Unreleased]
2
+
3
+ ### Added
4
+
5
+ - `DatabasusRuby::Client` with bearer authentication, an optional default
6
+ `workspace_id`, an optional `agent_id`, and one memoized endpoint object per
7
+ API domain.
8
+ - Endpoint coverage for `auth`, `users` (profile, administration, instance
9
+ settings), `workspaces` (settings, membership, audit logs), `databases`,
10
+ logical `backups`, `storages` and `notifiers`.
11
+ - `DatabasusRuby::Collection`, a uniform `Enumerable` return value for every
12
+ list endpoint, normalizing the API's bare-array, paginated-envelope and
13
+ unpaginated-envelope shapes and exposing `total` / `limit` / `offset` /
14
+ `next_offset`.
15
+ - `DatabasusRuby::Object` payload wrapper: nested hashes and arrays are wrapped
16
+ on access, snake_case reads camelCase keys, and omitted fields read as `nil`.
17
+ - Error hierarchy under `DatabasusRuby::Error`: `APIError` and its per-status
18
+ subclasses carry the status and decoded body; `ConfigurationError` is raised
19
+ before a request when the client is missing a required value.
20
+ - `Databases#backups` and `Databases#backup`, listing and queueing the backups
21
+ of one database by delegating to the `backups` endpoint.
22
+ - RBS signatures for the full public API.
23
+
24
+ ## [0.0.1] - 2026-08-04
25
+
26
+ - Initial release
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "databasus_ruby" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["samuelfelipecorrea@gmail.com"](mailto:"samuelfelipecorrea@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 SamuelFCorrea
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,305 @@
1
+ # DatabasusRuby
2
+
3
+ A Ruby client for the [Databasus](https://github.com/SamuelFCorrea/databasus_ruby) backup-management API.
4
+
5
+ Each API domain is an endpoint object hanging off the client, list endpoints all
6
+ return a `Collection`, and every response is a duck-typed `Object` you can read
7
+ with plain method calls.
8
+
9
+ ## Installation
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add databasus_ruby
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install databasus_ruby
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Authentication
26
+
27
+ Databasus authenticates with a bearer token. For everything except the
28
+ verification-agent endpoints that token is a user JWT, which you get by signing
29
+ in:
30
+
31
+ ```ruby
32
+ client = DatabasusRuby::Client.new(url: "https://databasus.example.com/api/v1/")
33
+
34
+ client.auth.signin(email: "you@example.com", password: ENV["DATABASUS_PASSWORD"])
35
+ client.users.me.email # => "you@example.com"
36
+ ```
37
+
38
+ `signin` installs the returned token on the client. If you already have a
39
+ token, pass it as `api_key` instead:
40
+
41
+ ```ruby
42
+ client = DatabasusRuby::Client.new(
43
+ api_key: ENV["DATABASUS_API_KEY"],
44
+ url: ENV.fetch("DATABASUS_URL"),
45
+ workspace_id: ENV["DATABASUS_WORKSPACE_ID"] # optional default, see below
46
+ )
47
+ ```
48
+
49
+ `#signup` registers and installs the token the same way; both raise
50
+ `DatabasusRuby::Error` if the response carries no token, and the result still
51
+ exposes it as `result.token` if you want to keep it. OAuth code exchange lives
52
+ at `client.auth.github_callback` and `client.auth.google_callback`.
53
+
54
+ ### Workspaces
55
+
56
+ Almost everything in Databasus is scoped to a workspace.
57
+
58
+ ```ruby
59
+ client.workspaces.list.each { |workspace| puts workspace.name }
60
+
61
+ workspace = client.workspaces.create(name: "production")
62
+ client.workspaces.add_member(workspace.id, email: "ops@example.com", role: "WORKSPACE_ADMIN")
63
+ client.workspaces.members(workspace.id).map(&:email)
64
+ client.workspaces.audit_logs(workspace.id, limit: 50)
65
+ ```
66
+
67
+ Because the workspace-scoped list endpoints all require a workspace id, you can
68
+ set one default on the client and omit it per call:
69
+
70
+ ```ruby
71
+ client.workspace_id = workspace.id
72
+ client.databases.list # no argument needed
73
+ client.storages.list
74
+ client.notifiers.list
75
+ ```
76
+ ### Databases
77
+
78
+ ```ruby
79
+ databases = client.databases.list(workspace_id: workspace.id)
80
+ databases.total
81
+ databases.first.name
82
+
83
+ database = client.databases.create(
84
+ name: "primary",
85
+ type: "POSTGRES_LOGICAL", # DatabasusRuby::Databases::TYPES
86
+ workspaceId: workspace.id,
87
+ postgresqlLogical: {
88
+ host: "10.0.0.1",
89
+ port: 5432,
90
+ username: "backup",
91
+ password: ENV["PG_PASSWORD"],
92
+ database: "app_production",
93
+ version: "17",
94
+ sslMode: "require"
95
+ }
96
+ )
97
+
98
+ database.name
99
+ database.postgresqlLogical.host
100
+ database.workspace_id # snake_case reads camelCase keys too
101
+
102
+ client.databases.test_connection(database.id) # => true, or raises
103
+ client.databases.update(id: database.id, name: "primary-eu")
104
+ client.databases.copy(database.id)
105
+ client.databases.delete(database.id)
106
+ ```
107
+
108
+ A database can reach its own backups, which delegates to the `backups` endpoint
109
+ with the same filters as `client.backups.list`:
110
+
111
+ ```ruby
112
+ client.databases.backups(database.id, status: "COMPLETED", limit: 25)
113
+ client.databases.backup(database.id) # queue a run
114
+ ```
115
+
116
+ `update` posts to `/databases/update` with the id in the body, matching the API.
117
+ There is also `test_connection_direct`, `create_readonly_user`,
118
+ `create_replication_only_user` and `readonly?` for a payload you have not saved
119
+ yet.
120
+
121
+ ### Backups
122
+
123
+ ```ruby
124
+ backups = client.backups.list(
125
+ database_id: database.id,
126
+ status: %w[COMPLETED FAILED], # DatabasusRuby::Backups::STATUSES
127
+ limit: 25
128
+ )
129
+
130
+ backups.total # => 137
131
+ backups.next_offset # => 25
132
+ backups.first.fileName
133
+
134
+ client.backups.create(database_id: database.id) # queue a run
135
+ client.backups.cancel(backup.id)
136
+ client.backups.delete(backup.id)
137
+
138
+ filename, contents = client.backups.download(backup.id)
139
+ File.binwrite(filename, contents)
140
+ ```
141
+
142
+ `#download` mints a short-lived token and streams the file in one step; use
143
+ `#download_token` and `#file` if you need the two halves separately.
144
+
145
+ ### Storages and notifiers
146
+
147
+ Both use a single save endpoint for create and update — include `id` in the
148
+ payload to update.
149
+
150
+ ```ruby
151
+ storage = client.storages.save(
152
+ name: "offsite",
153
+ type: "S3", # DatabasusRuby::Storages::TYPES
154
+ workspaceId: workspace.id,
155
+ s3Storage: { bucket: "backups", region: "eu-west-1", accessKeyId: "...", secretAccessKey: "..." }
156
+ )
157
+ client.storages.test(storage.id)
158
+ client.storages.transfer(storage.id, target_workspace_id: other.id)
159
+
160
+ notifier = client.notifiers.save(
161
+ name: "ops",
162
+ notifierType: "SLACK", # DatabasusRuby::Notifiers::TYPES
163
+ workspaceId: workspace.id,
164
+ slackNotifier: { token: "...", channelId: "C123" }
165
+ )
166
+ client.notifiers.test(notifier.id)
167
+ ```
168
+
169
+ `direct_test` on either domain tests an unsaved payload.
170
+
171
+ ### Users
172
+
173
+ `client.users` covers both the signed-in user and instance-wide administration
174
+ (the latter needs an `ADMIN` token):
175
+
176
+ ```ruby
177
+ client.users.me.name
178
+ client.users.update_me(name: "New Name")
179
+ client.users.change_password(new_password: ENV["NEW_PASSWORD"])
180
+ client.users.invite(email: "new@example.com", intended_workspace_id: workspace.id,
181
+ intended_workspace_role: "WORKSPACE_MEMBER")
182
+
183
+ client.users.list(query: "ops", limit: 25) # ADMIN
184
+ client.users.deactivate(user_id) # ADMIN
185
+ client.users.change_role(user_id, role: "ADMIN")
186
+ client.users.settings # instance registration settings
187
+ ```
188
+
189
+ ### Collections
190
+
191
+ Every list endpoint returns a `DatabasusRuby::Collection`, regardless of which
192
+ of the API's three list shapes the endpoint uses:
193
+
194
+ ```ruby
195
+ backups = client.backups.list(database_id: database.id, limit: 25)
196
+
197
+ backups.each { |backup| ... } # Enumerable
198
+ backups.map(&:status)
199
+ backups.size # items on this page
200
+ backups.total # items overall
201
+ backups.limit # nil when the endpoint does not paginate
202
+ backups.offset
203
+ backups.paginated?
204
+ backups.next_offset # nil on the last page
205
+ backups.last_page?
206
+ backups.data # the underlying Array
207
+ ```
208
+
209
+ Paging through everything:
210
+
211
+ ```ruby
212
+ offset = 0
213
+ loop do
214
+ page = client.backups.list(database_id: database.id, limit: 100, offset: offset)
215
+ page.each { |backup| process(backup) }
216
+ break if page.last_page?
217
+
218
+ offset = page.next_offset
219
+ end
220
+ ```
221
+
222
+ ### Response objects
223
+
224
+ Responses are wrapped in `DatabasusRuby::Object`, which reads keys as methods,
225
+ wraps nested hashes and arrays as it goes, and accepts snake_case for the API's
226
+ camelCase keys:
227
+
228
+ ```ruby
229
+ database.postgresqlLogical.sslMode
230
+ database.postgresql_logical.ssl_mode # the same thing
231
+ database.notifiers.map(&:name) # arrays of objects are wrapped too
232
+ database.to_h # the raw decoded payload
233
+ database["name"]
234
+ database.key?(:workspace_id)
235
+ ```
236
+
237
+ Databasus omits empty fields, so reading a key that isn't in the payload
238
+ returns `nil` rather than raising.
239
+
240
+ ### Errors
241
+
242
+ Non-2xx responses raise a subclass of `DatabasusRuby::APIError` carrying the
243
+ status and decoded body:
244
+
245
+ | Status | Error |
246
+ | --- | --- |
247
+ | 400 | `DatabasusRuby::BadRequestError` |
248
+ | 401 | `DatabasusRuby::AuthenticationError` |
249
+ | 403 | `DatabasusRuby::ForbiddenError` |
250
+ | 404 | `DatabasusRuby::NotFoundError` |
251
+ | 409 | `DatabasusRuby::ConflictError` |
252
+ | 429 | `DatabasusRuby::RateLimitError` |
253
+ | 5xx | `DatabasusRuby::ServerError` |
254
+
255
+ ```ruby
256
+ begin
257
+ client.databases.test_connection(database.id)
258
+ rescue DatabasusRuby::BadRequestError => e
259
+ e.message # => "dial tcp 10.0.0.1:5432: connect: connection refused"
260
+ e.status # => 400
261
+ e.body # => { "error" => "dial tcp ..." }
262
+ end
263
+ ```
264
+
265
+ `DatabasusRuby::ConfigurationError` (a sibling of `APIError` under
266
+ `DatabasusRuby::Error`) is raised before any request goes out when the client is
267
+ missing something it needs, such as a workspace id.
268
+
269
+ ## API coverage
270
+
271
+ Implemented: `auth`, `users` (including user management and instance settings),
272
+ `workspaces` (including membership and audit logs), `databases`, `backups`
273
+ (logical), `storages`, `notifiers`.
274
+
275
+ Not yet wrapped: physical backups and their configs, logical backup configs,
276
+ restores, verifications and verification agents/configs, healthchecks, disk
277
+ usage, global audit logs and the `system/*` endpoints. The client is ready for
278
+ them — each is a new class in `lib/databasus_ruby/objects/` plus an accessor on
279
+ `Client`.
280
+
281
+ ## Development
282
+
283
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
284
+ `rake spec` to run the tests, or `rake` to run the tests and RuboCop. You can
285
+ also run `bin/console` for an interactive prompt with a preconfigured `client`.
286
+
287
+ The specs stub HTTP with WebMock, so they need no running Databasus instance.
288
+
289
+ To install this gem onto your local machine, run `bundle exec rake install`. To
290
+ release a new version, update the version number in `version.rb`, and then run
291
+ `bundle exec rake release`, which will create a git tag for the version, push
292
+ git commits and the created tag, and push the `.gem` file to
293
+ [rubygems.org](https://rubygems.org).
294
+
295
+ ## Contributing
296
+
297
+ Bug reports and pull requests are welcome on GitHub at https://github.com/SamuelFCorrea/databasus_ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/SamuelFCorrea/databasus_ruby/blob/main/CODE_OF_CONDUCT.md).
298
+
299
+ ## License
300
+
301
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
302
+
303
+ ## Code of Conduct
304
+
305
+ Everyone interacting in the DatabasusRuby project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/SamuelFCorrea/databasus_ruby/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabasusRuby
4
+ # Entry point for the gem. Holds the connection and hands out one endpoint
5
+ # object per API domain.
6
+ #
7
+ # client = DatabasusRuby::Client.new(
8
+ # api_key: ENV["DATABASUS_API_KEY"],
9
+ # url: "https://databasus.example.com/api/v1/"
10
+ # )
11
+ # client.databases.list(workspace_id: id)
12
+ #
13
+ # `api_key` is sent as `Authorization: Bearer <api_key>`. Databasus accepts
14
+ # two kinds of bearer token there: a user JWT (from Auth#signin, used by
15
+ # every regular endpoint) and a verification-agent token (used by the
16
+ # /agent/* endpoints, which also need `agent_id`).
17
+ class Client
18
+ attr_reader :adapter, :url
19
+ attr_accessor :api_key, :workspace_id
20
+
21
+ def initialize(api_key: nil, url: nil, workspace_id: nil, adapter: Faraday.default_adapter)
22
+ raise ConfigurationError, "url is required" if url.nil? || url.to_s.empty?
23
+
24
+ @api_key = api_key
25
+ @adapter = adapter
26
+ @url = normalize_url(url)
27
+ @workspace_id = workspace_id
28
+ end
29
+
30
+ def connection
31
+ @connection ||= Faraday.new do |conn|
32
+ conn.url_prefix = url
33
+ conn.request :json
34
+ conn.response :json, content_type: "application/json"
35
+ conn.adapter adapter
36
+ end
37
+ end
38
+
39
+ def auth
40
+ @auth ||= Auth.new(self)
41
+ end
42
+
43
+ def backups
44
+ @backups ||= Backups.new(self)
45
+ end
46
+
47
+ def databases
48
+ @databases ||= Databases.new(self)
49
+ end
50
+
51
+ def notifiers
52
+ @notifiers ||= Notifiers.new(self)
53
+ end
54
+
55
+ def storages
56
+ @storages ||= Storages.new(self)
57
+ end
58
+
59
+ def users
60
+ @users ||= Users.new(self)
61
+ end
62
+
63
+ def workspaces
64
+ @workspaces ||= Workspaces.new(self)
65
+ end
66
+
67
+ # The workspace-scoped list endpoints all require a workspace id; callers
68
+ # can either pass one per call or set a default on the client.
69
+ def workspace_id!
70
+ return workspace_id unless workspace_id.nil? || workspace_id.to_s.empty?
71
+
72
+ raise ConfigurationError,
73
+ "workspace_id is required: pass workspace_id: to this call or to Client.new"
74
+ end
75
+
76
+ # Performs the request and raises the matching APIError subclass on a
77
+ # non-successful status. Returns the raw Faraday::Response so callers can
78
+ # decide how to interpret the body.
79
+ def request(method, path, params: nil, body: nil, headers: nil)
80
+ response = connection.public_send(method, path) do |req|
81
+ req.params.update(stringify(params)) if params
82
+ req.body = body unless body.nil?
83
+ req.headers.update(request_headers(headers))
84
+ end
85
+
86
+ raise APIError.from_response(response) unless response.success?
87
+
88
+ response
89
+ end
90
+
91
+ def inspect
92
+ "#<#{self.class.name} url=#{url.inspect} workspace_id=#{workspace_id.inspect}\
93
+ api_key=#{api_key.nil? ? "nil" : "[FILTERED]"}>"
94
+ end
95
+
96
+ private
97
+
98
+ # Databasus takes the token as a bearer credential; both a user JWT and a
99
+ # verification-agent token go in the same header.
100
+ def request_headers(extra)
101
+ headers = {}
102
+ headers["Authorization"] = "Bearer #{api_key}" unless api_key.nil? || api_key.to_s.empty?
103
+ headers.update(extra) if extra
104
+ headers
105
+ end
106
+
107
+ # Faraday resolves a path without a leading slash against url_prefix, so
108
+ # the prefix has to keep its trailing slash or /api/v1 would be dropped.
109
+ def normalize_url(url)
110
+ url.to_s.end_with?("/") ? url.to_s : "#{url}/"
111
+ end
112
+
113
+ def stringify(params)
114
+ params.each_with_object({}) do |(key, value), acc|
115
+ next if value.nil?
116
+
117
+ # The only array-valued query parameter in the API (backups status)
118
+ # is declared as collectionFormat: csv.
119
+ acc[key.to_s] = value.is_a?(::Array) ? value.join(",") : value
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DatabasusRuby
4
+ # Uniform return value for every list endpoint.
5
+ #
6
+ # Databasus answers lists in three different shapes: a bare array
7
+ # (GET /databases), a paginated envelope (GET /backups) and an unpaginated
8
+ # envelope (GET /workspaces). Collection flattens all three into an
9
+ # Enumerable that always answers #total, and answers #limit / #offset when
10
+ # the endpoint actually paginates.
11
+ class Collection
12
+ include Enumerable
13
+
14
+ attr_reader :data, :total, :limit, :offset
15
+
16
+ def initialize(data: [], total: nil, limit: nil, offset: nil)
17
+ @data = data.map { |item| item.is_a?(Object) ? item : Object.new(item) }
18
+ @total = total || @data.size
19
+ @limit = limit
20
+ @offset = offset
21
+ end
22
+
23
+ # `key` names the array inside an envelope response and is ignored for
24
+ # bare-array responses.
25
+ def self.from(body, key:)
26
+ case body
27
+ when ::Array
28
+ new(data: body)
29
+ when Hash
30
+ new(data: body[key] || [], total: body["total"], limit: body["limit"], offset: body["offset"])
31
+ when nil
32
+ new
33
+ else
34
+ raise Error, "unexpected list payload: #{body.class}"
35
+ end
36
+ end
37
+
38
+ def each(&block)
39
+ return data.each unless block
40
+
41
+ data.each(&block)
42
+ self
43
+ end
44
+
45
+ def size
46
+ data.size
47
+ end
48
+ alias length size
49
+
50
+ def empty?
51
+ data.empty?
52
+ end
53
+
54
+ def [](index)
55
+ data[index]
56
+ end
57
+
58
+ def last
59
+ data.last
60
+ end
61
+
62
+ def paginated?
63
+ !limit.nil?
64
+ end
65
+
66
+ # Offset to pass to the next call, or nil when this is the last page (or
67
+ # the endpoint does not paginate at all).
68
+ def next_offset
69
+ return nil unless paginated?
70
+
71
+ following = (offset || 0) + data.size
72
+ following < total ? following : nil
73
+ end
74
+
75
+ def last_page?
76
+ next_offset.nil?
77
+ end
78
+
79
+ def inspect
80
+ "#<#{self.class.name} size=#{size} total=#{total} limit=#{limit.inspect} offset=#{offset.inspect}>"
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module DatabasusRuby
6
+ # Shared plumbing for the per-domain endpoint classes. Each domain is a
7
+ # single class (DatabasusRuby::Databases, DatabasusRuby::Backups, ...) that
8
+ # inherits Object and includes this module, so it carries both the endpoint
9
+ # methods and the payload-reading behaviour.
10
+ #
11
+ # The transport helpers are prefixed `http_` so domain classes are free to
12
+ # expose the natural #get / #create / #update / #delete names.
13
+ module Endpoint
14
+ attr_reader :client
15
+
16
+ def initialize(client)
17
+ @client = client
18
+ # Endpoint objects are callers, not payloads; leaving @attributes nil
19
+ # makes Object#method_missing raise NoMethodError on a typo instead of
20
+ # quietly answering nil.
21
+ @attributes = nil
22
+ end
23
+
24
+ def inspect
25
+ "#<#{self.class.name}>"
26
+ end
27
+
28
+ private
29
+
30
+ def http_get(path, params = nil)
31
+ client.request(:get, path, params: params)
32
+ end
33
+
34
+ def http_post(path, body = nil, params: nil)
35
+ client.request(:post, path, body: compact(body), params: params)
36
+ end
37
+
38
+ def http_put(path, body = nil, params: nil)
39
+ client.request(:put, path, body: compact(body), params: params)
40
+ end
41
+
42
+ def http_delete(path, params = nil)
43
+ client.request(:delete, path, params: params)
44
+ end
45
+
46
+ # Wraps a single-object response.
47
+ def object(response)
48
+ Object.new(response.body.is_a?(Hash) ? response.body : {})
49
+ end
50
+
51
+ # Wraps a list response; `key` names the array inside an envelope payload.
52
+ def collection(response, key:)
53
+ Collection.from(response.body, key: key)
54
+ end
55
+
56
+ # For endpoints that answer 200/204 with no meaningful body.
57
+ def acknowledged(_response)
58
+ true
59
+ end
60
+
61
+ # Endpoints answering a single-entry map whose key is not documented in the
62
+ # swagger schema, e.g. GET /databases/notifier/{id}/is-using.
63
+ def single_value(response)
64
+ body = response.body
65
+ body.is_a?(Hash) ? body.values.first : body
66
+ end
67
+
68
+ # Request bodies are built from keyword arguments, and the API distinguishes
69
+ # an absent key from a null one in a few places, so nils are dropped.
70
+ def compact(body)
71
+ return body unless body.is_a?(Hash)
72
+
73
+ body.each_with_object({}) do |(key, value), acc|
74
+ acc[key.to_s] = value unless value.nil?
75
+ end
76
+ end
77
+
78
+ # Fills in the client's default workspace for create/save payloads that
79
+ # did not name one explicitly.
80
+ def with_default_workspace(attributes)
81
+ return attributes if attributes.key?(:workspaceId) || attributes.key?("workspaceId")
82
+ return attributes if client.workspace_id.nil?
83
+
84
+ attributes.merge(workspaceId: client.workspace_id)
85
+ end
86
+
87
+ def escape(segment)
88
+ URI.encode_uri_component(require_id(segment))
89
+ end
90
+
91
+ # Same presence check as #escape for ids that travel in a query string or
92
+ # body, where Faraday does the encoding.
93
+ def require_id(segment)
94
+ raise ArgumentError, "id is required" if segment.nil? || segment.to_s.empty?
95
+
96
+ segment.to_s
97
+ end
98
+ end
99
+ end