databasus_ruby 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +58 -0
- data/lib/databasus_ruby/client.rb +75 -20
- data/lib/databasus_ruby/collection.rb +27 -36
- data/lib/databasus_ruby/endpoint.rb +3 -10
- data/lib/databasus_ruby/error.rb +39 -9
- data/lib/databasus_ruby/objects/auth.rb +31 -26
- data/lib/databasus_ruby/objects/backups.rb +54 -29
- data/lib/databasus_ruby/objects/databases.rb +39 -56
- data/lib/databasus_ruby/objects/notifiers.rb +14 -22
- data/lib/databasus_ruby/objects/restores.rb +72 -0
- data/lib/databasus_ruby/objects/storages.rb +19 -18
- data/lib/databasus_ruby/objects/users.rb +24 -41
- data/lib/databasus_ruby/objects/workspaces.rb +26 -33
- data/lib/databasus_ruby/records/database.rb +1 -0
- data/lib/databasus_ruby/version.rb +1 -1
- data/lib/databasus_ruby.rb +6 -2
- data/sig/databasus_ruby.rbs +159 -101
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c06a2f99b31ebac53becd79a06a01790a65e106890f2275f0645548197c33611
|
|
4
|
+
data.tar.gz: 14433d80684697a781025fb4dce7f0839fe5448dbc6cbef9f7c52e257b52c8b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 25a821696e164674b457941926ae8940ac8656af9106409fe9d63c7c2bb2f429805e52ab057725876f8e57697566d6327a4fe0b55604342cd9ac3793939953c1
|
|
7
|
+
data.tar.gz: a2b67bc8e87d04c3306b4c1198f4352f7cdd2f5af7177c456df0e5c23c46d808a38f17aa35ab1875b2cd5db281c4c4718193f14225ed4265347b6189cc4fcf06
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-11-13
|
|
4
|
+
|
|
5
|
+
Targets the REST API of Databasus 3.65 and later. Databasus 3.65 replaced its
|
|
6
|
+
API (new paths, cursor pagination, RFC 9457 problem responses), so this release
|
|
7
|
+
does not talk to 3.64 or earlier: pin `~> 0.1.0` for those.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Restores` endpoint (`start`, `get`, `list`, `cancel`) and
|
|
12
|
+
`Restores.postgresql_target`, which restores without ownership and
|
|
13
|
+
privileges by default.
|
|
14
|
+
- `Backups#download_to(id, io)` and `Client#stream`: backups stream to an IO
|
|
15
|
+
instead of being read into memory.
|
|
16
|
+
- `Backups#get`; `Databases#list_all`, which follows the cursor; `Databases#test`
|
|
17
|
+
/ `#connected?`; `Storages#backup_usage`; `Auth#verify_sign_in_code`.
|
|
18
|
+
- `APIError#code` (the problem's `code`); validation field errors are appended
|
|
19
|
+
to the message.
|
|
20
|
+
- `SignInCodeRequiredError`, raised by `Auth#signin` when the account needs an
|
|
21
|
+
emailed sign-in code.
|
|
22
|
+
- `Collection#next_cursor`, `#last_page?` and `#extras` (page-level fields such
|
|
23
|
+
as the backups page's `totalUsageMb`).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Every path follows the 3.65 API: `POST /sessions`, `/logical-backups`,
|
|
28
|
+
`POST /download-tokens`, `PUT /databases/{id}`, `/workspace-members`,
|
|
29
|
+
`/audit-events`, `/instance-settings`, `/password-resets`.
|
|
30
|
+
- List methods take `limit:` / `after:` instead of `limit:` / `offset:`.
|
|
31
|
+
- Array query parameters repeat the key (`status=A&status=B`).
|
|
32
|
+
- `Notifiers` and `Storages` split `save` into `create` and `update(id, ...)`.
|
|
33
|
+
- Renamed: `Auth#send_reset_password_code` → `#request_password_reset`,
|
|
34
|
+
`Databases#test_connection` → `#test`, `Workspaces#audit_logs` → `#audit_events` (filter with `author_user_id:`).
|
|
35
|
+
- Methods now take the target id positionally: `Databases#update(id, ...)`,
|
|
36
|
+
`Users#update(id, name:, email:)`, `Users#change_password(id, new_password:)`,
|
|
37
|
+
`Workspaces#update(id, name:)`.
|
|
38
|
+
- Workspace membership is addressed by member id: `remove_member(member_id)`,
|
|
39
|
+
`change_member_role(member_id, role:)`, and
|
|
40
|
+
`transfer_ownership(id, new_owner_member_id:)` replaces `new_owner_email:`.
|
|
41
|
+
- `Backups#list` filters with `created_before:` (was `before_date:`);
|
|
42
|
+
`Databases#list` accepts `notifier_id:`.
|
|
43
|
+
- `APIError` messages come from the problem's `detail` / `title`;
|
|
44
|
+
`APIError.from_response` accepts a `body:` override for streamed responses.
|
|
45
|
+
- JSON responses are parsed for any `*json*` content type
|
|
46
|
+
(e.g. `application/problem+json`).
|
|
47
|
+
|
|
48
|
+
### Removed
|
|
49
|
+
|
|
50
|
+
- Offset pagination (`Collection#total`, `#limit`, `#offset`, `#next_offset`).
|
|
51
|
+
- Endpoints the API dropped: OAuth callbacks (`Auth#github_callback`,
|
|
52
|
+
`#google_callback`), `Users#me`, `#update_me`, `#invite`, readonly and
|
|
53
|
+
replication users (`Databases#create_readonly_user`,
|
|
54
|
+
`#create_replication_only_user`, `#readonly`, `#readonly?`), `Databases#test_connection_direct`,
|
|
55
|
+
notifier usage counts (`Databases#notifier_databases_count`,
|
|
56
|
+
`#notifier_using?`), and the `direct_test` methods of `Notifiers` and
|
|
57
|
+
`Storages`.
|
|
58
|
+
|
|
59
|
+
## [0.1.0]
|
|
60
|
+
|
|
3
61
|
### Added
|
|
4
62
|
|
|
5
63
|
- `DatabasusRuby::Client` with bearer authentication, an optional default
|
|
@@ -4,16 +4,14 @@ module DatabasusRuby
|
|
|
4
4
|
# Entry point for the gem. Holds the connection and hands out one endpoint
|
|
5
5
|
# object per API domain.
|
|
6
6
|
#
|
|
7
|
-
# client = DatabasusRuby::Client.new(
|
|
8
|
-
#
|
|
9
|
-
# url: "https://databasus.example.com/api/v1/"
|
|
10
|
-
# )
|
|
7
|
+
# client = DatabasusRuby::Client.new(url: "https://databasus.example.com/api/v1/")
|
|
8
|
+
# client.auth.signin(email: "ops@example.com", password: "secret")
|
|
11
9
|
# client.databases.list(workspace_id: id)
|
|
12
10
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
11
|
+
# Targets the Databasus REST API introduced in 3.65 (OpenAPI, cursor
|
|
12
|
+
# pagination, RFC 9457 problem responses). `api_key` is sent as
|
|
13
|
+
# `Authorization: Bearer <api_key>`; Auth#signin installs the session token
|
|
14
|
+
# there.
|
|
17
15
|
class Client
|
|
18
16
|
attr_reader :adapter, :url
|
|
19
17
|
attr_accessor :api_key, :workspace_id
|
|
@@ -30,8 +28,11 @@ module DatabasusRuby
|
|
|
30
28
|
def connection
|
|
31
29
|
@connection ||= Faraday.new do |conn|
|
|
32
30
|
conn.url_prefix = url
|
|
31
|
+
# Array filters (`status=A&status=B`) are form-style with explode,
|
|
32
|
+
# which the nested encoder would send as `status[]=`.
|
|
33
|
+
conn.options.params_encoder = Faraday::FlatParamsEncoder
|
|
33
34
|
conn.request :json
|
|
34
|
-
conn.response :json, content_type:
|
|
35
|
+
conn.response :json, content_type: /\bjson\b/
|
|
35
36
|
conn.adapter adapter
|
|
36
37
|
end
|
|
37
38
|
end
|
|
@@ -52,6 +53,10 @@ module DatabasusRuby
|
|
|
52
53
|
@notifiers ||= Notifiers.new(self)
|
|
53
54
|
end
|
|
54
55
|
|
|
56
|
+
def restores
|
|
57
|
+
@restores ||= Restores.new(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
55
60
|
def storages
|
|
56
61
|
@storages ||= Storages.new(self)
|
|
57
62
|
end
|
|
@@ -64,8 +69,8 @@ module DatabasusRuby
|
|
|
64
69
|
@workspaces ||= Workspaces.new(self)
|
|
65
70
|
end
|
|
66
71
|
|
|
67
|
-
# The workspace-scoped list endpoints
|
|
68
|
-
#
|
|
72
|
+
# The workspace-scoped list endpoints filter by workspace; callers can
|
|
73
|
+
# either pass one per call or set a default on the client.
|
|
69
74
|
def workspace_id!
|
|
70
75
|
return workspace_id unless workspace_id.nil? || workspace_id.to_s.empty?
|
|
71
76
|
|
|
@@ -78,9 +83,8 @@ module DatabasusRuby
|
|
|
78
83
|
# decide how to interpret the body.
|
|
79
84
|
def request(method, path, params: nil, body: nil, headers: nil)
|
|
80
85
|
response = connection.public_send(method, path) do |req|
|
|
81
|
-
req
|
|
86
|
+
prepare(req, params: params, headers: headers)
|
|
82
87
|
req.body = body unless body.nil?
|
|
83
|
-
req.headers.update(request_headers(headers))
|
|
84
88
|
end
|
|
85
89
|
|
|
86
90
|
raise APIError.from_response(response) unless response.success?
|
|
@@ -88,15 +92,67 @@ module DatabasusRuby
|
|
|
88
92
|
response
|
|
89
93
|
end
|
|
90
94
|
|
|
95
|
+
# GETs `path` and writes the body to `io` chunk by chunk, so a multi-GB
|
|
96
|
+
# backup never sits in memory. An error answer is collected instead and
|
|
97
|
+
# raised as the matching APIError. Returns the number of bytes written.
|
|
98
|
+
def stream(path, io:, params: nil)
|
|
99
|
+
sink = StreamSink.new(io)
|
|
100
|
+
response = connection.get(path) do |req|
|
|
101
|
+
prepare(req, params: params)
|
|
102
|
+
# Faraday streams only when on_data is a Proc (RequestOptions#stream_response?).
|
|
103
|
+
req.options.on_data = sink.to_proc
|
|
104
|
+
end
|
|
105
|
+
return sink.written if response.success?
|
|
106
|
+
|
|
107
|
+
raise APIError.from_response(response, body: sink.decoded_error_body)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Routes streamed chunks: a 2xx body goes to the caller's io, anything else
|
|
111
|
+
# is kept so it can be raised as an APIError.
|
|
112
|
+
class StreamSink
|
|
113
|
+
attr_reader :written, :error_body
|
|
114
|
+
|
|
115
|
+
def initialize(io)
|
|
116
|
+
@io = io
|
|
117
|
+
@written = 0
|
|
118
|
+
@error_body = +""
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def to_proc
|
|
122
|
+
proc { |chunk, _received, env| write(chunk, env) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def decoded_error_body
|
|
126
|
+
return nil if error_body.empty?
|
|
127
|
+
|
|
128
|
+
JSON.parse(error_body)
|
|
129
|
+
rescue JSON::ParserError
|
|
130
|
+
error_body
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def write(chunk, env)
|
|
136
|
+
return @error_body << chunk unless env.nil? || (200..299).cover?(env.status)
|
|
137
|
+
|
|
138
|
+
@io.write(chunk)
|
|
139
|
+
@written += chunk.bytesize
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
private_constant :StreamSink
|
|
143
|
+
|
|
91
144
|
def inspect
|
|
92
|
-
"#<#{self.class.name} url=#{url.inspect} workspace_id=#{workspace_id.inspect}\
|
|
93
|
-
|
|
145
|
+
"#<#{self.class.name} url=#{url.inspect} workspace_id=#{workspace_id.inspect} " \
|
|
146
|
+
"api_key=#{api_key.nil? ? "nil" : "[FILTERED]"}>"
|
|
94
147
|
end
|
|
95
148
|
|
|
96
149
|
private
|
|
97
150
|
|
|
98
|
-
|
|
99
|
-
|
|
151
|
+
def prepare(req, params: nil, headers: nil)
|
|
152
|
+
req.params.update(stringify(params)) if params
|
|
153
|
+
req.headers.update(request_headers(headers))
|
|
154
|
+
end
|
|
155
|
+
|
|
100
156
|
def request_headers(extra)
|
|
101
157
|
headers = {}
|
|
102
158
|
headers["Authorization"] = "Bearer #{api_key}" unless api_key.nil? || api_key.to_s.empty?
|
|
@@ -110,13 +166,12 @@ module DatabasusRuby
|
|
|
110
166
|
url.to_s.end_with?("/") ? url.to_s : "#{url}/"
|
|
111
167
|
end
|
|
112
168
|
|
|
169
|
+
# Drops nils; arrays stay arrays so FlatParamsEncoder repeats the key.
|
|
113
170
|
def stringify(params)
|
|
114
171
|
params.each_with_object({}) do |(key, value), acc|
|
|
115
172
|
next if value.nil?
|
|
116
173
|
|
|
117
|
-
|
|
118
|
-
# is declared as collectionFormat: csv.
|
|
119
|
-
acc[key.to_s] = value.is_a?(::Array) ? value.join(",") : value
|
|
174
|
+
acc[key.to_s] = value.is_a?(::Array) ? value.map(&:to_s) : value
|
|
120
175
|
end
|
|
121
176
|
end
|
|
122
177
|
end
|
|
@@ -3,40 +3,44 @@
|
|
|
3
3
|
module DatabasusRuby
|
|
4
4
|
# Uniform return value for every list endpoint.
|
|
5
5
|
#
|
|
6
|
-
# Databasus answers
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Databasus answers every list as a cursor page: `{ items: [...],
|
|
7
|
+
# nextCursor: "..." }`, plus page-level extras on some endpoints (the logical
|
|
8
|
+
# backups page carries `totalUsageMb`). Pass #next_cursor back as `after:` to
|
|
9
|
+
# read the next page; it is nil on the last one.
|
|
10
|
+
#
|
|
11
|
+
# page = client.backups.list(database_id: id, limit: 50)
|
|
12
|
+
# page.extras.total_usage_mb
|
|
13
|
+
# page.next_cursor
|
|
11
14
|
class Collection
|
|
12
15
|
include Enumerable
|
|
13
16
|
|
|
14
|
-
attr_reader :data, :
|
|
17
|
+
attr_reader :data, :next_cursor, :extras
|
|
15
18
|
|
|
16
19
|
# `wrap` builds one entry from its raw hash; it defaults to a plain Object
|
|
17
20
|
# and endpoints pass a Record builder instead.
|
|
18
|
-
def initialize(data: [],
|
|
21
|
+
def initialize(data: [], next_cursor: nil, extras: {}, wrap: nil)
|
|
19
22
|
@data = data.map { |item| item.is_a?(Object) ? item : (wrap || Object.method(:new)).call(item) }
|
|
20
|
-
@
|
|
21
|
-
@
|
|
22
|
-
@offset = offset
|
|
23
|
+
@next_cursor = next_cursor
|
|
24
|
+
@extras = extras.is_a?(Object) ? extras : Object.new(extras)
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def self.from(body,
|
|
27
|
+
PAGE_KEYS = %w[items nextCursor].freeze
|
|
28
|
+
|
|
29
|
+
def self.from(body, wrap: nil)
|
|
28
30
|
case body
|
|
29
|
-
when
|
|
30
|
-
|
|
31
|
-
when
|
|
32
|
-
|
|
33
|
-
when nil
|
|
34
|
-
new
|
|
35
|
-
else
|
|
36
|
-
raise Error, "unexpected list payload: #{body.class}"
|
|
31
|
+
when Hash then from_page(body, wrap)
|
|
32
|
+
when ::Array then new(data: body, wrap: wrap)
|
|
33
|
+
when nil, "" then new
|
|
34
|
+
else raise Error, "unexpected list payload: #{body.class}"
|
|
37
35
|
end
|
|
38
36
|
end
|
|
39
37
|
|
|
38
|
+
def self.from_page(page, wrap)
|
|
39
|
+
extras = page.reject { |key, _| PAGE_KEYS.include?(key.to_s) }
|
|
40
|
+
new(data: page["items"] || [], next_cursor: page["nextCursor"], extras: extras, wrap: wrap)
|
|
41
|
+
end
|
|
42
|
+
private_class_method :from_page
|
|
43
|
+
|
|
40
44
|
def each(&block)
|
|
41
45
|
return data.each unless block
|
|
42
46
|
|
|
@@ -61,25 +65,12 @@ module DatabasusRuby
|
|
|
61
65
|
data.last
|
|
62
66
|
end
|
|
63
67
|
|
|
64
|
-
def paginated?
|
|
65
|
-
!limit.nil?
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Offset to pass to the next call, or nil when this is the last page (or
|
|
69
|
-
# the endpoint does not paginate at all).
|
|
70
|
-
def next_offset
|
|
71
|
-
return nil unless paginated?
|
|
72
|
-
|
|
73
|
-
following = (offset || 0) + data.size
|
|
74
|
-
following < total ? following : nil
|
|
75
|
-
end
|
|
76
|
-
|
|
77
68
|
def last_page?
|
|
78
|
-
|
|
69
|
+
next_cursor.nil? || next_cursor.to_s.empty?
|
|
79
70
|
end
|
|
80
71
|
|
|
81
72
|
def inspect
|
|
82
|
-
"#<#{self.class.name} size=#{size}
|
|
73
|
+
"#<#{self.class.name} size=#{size} next_cursor=#{next_cursor.inspect}>"
|
|
83
74
|
end
|
|
84
75
|
end
|
|
85
76
|
end
|
|
@@ -49,9 +49,9 @@ module DatabasusRuby
|
|
|
49
49
|
build(as, response.body.is_a?(Hash) ? response.body : {})
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
# Wraps a list response
|
|
53
|
-
def collection(response,
|
|
54
|
-
Collection.from(response.body,
|
|
52
|
+
# Wraps a cursor-page list response.
|
|
53
|
+
def collection(response, as: Object)
|
|
54
|
+
Collection.from(response.body, wrap: ->(item) { build(as, item) })
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Records need the client to reach their related endpoints; plain payload
|
|
@@ -65,13 +65,6 @@ module DatabasusRuby
|
|
|
65
65
|
true
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
# Endpoints answering a single-entry map whose key is not documented in the
|
|
69
|
-
# swagger schema, e.g. GET /databases/notifier/{id}/is-using.
|
|
70
|
-
def single_value(response)
|
|
71
|
-
body = response.body
|
|
72
|
-
body.is_a?(Hash) ? body.values.first : body
|
|
73
|
-
end
|
|
74
|
-
|
|
75
68
|
# Request bodies are built from keyword arguments, and the API distinguishes
|
|
76
69
|
# an absent key from a null one in a few places, so nils are dropped.
|
|
77
70
|
def compact(body)
|
data/lib/databasus_ruby/error.rb
CHANGED
|
@@ -5,24 +5,44 @@ module DatabasusRuby
|
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
7
|
# Raised when the client is missing something it needs before it can talk to
|
|
8
|
-
# the API at all (
|
|
8
|
+
# the API at all (a workspace id, a url, ...).
|
|
9
9
|
class ConfigurationError < Error; end
|
|
10
10
|
|
|
11
|
+
# Raised by Auth#signin when the account requires an emailed sign-in code
|
|
12
|
+
# (two-factor). An unattended client cannot answer it; use an account
|
|
13
|
+
# without 2FA or finish with Auth#verify_sign_in_code.
|
|
14
|
+
class SignInCodeRequiredError < Error
|
|
15
|
+
attr_reader :pending_sign_in_id, :email
|
|
16
|
+
|
|
17
|
+
def initialize(pending_sign_in_id:, email:)
|
|
18
|
+
@pending_sign_in_id = pending_sign_in_id
|
|
19
|
+
@email = email
|
|
20
|
+
super("sign-in for #{email} needs the emailed code (pending sign-in #{pending_sign_in_id})")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
11
24
|
# Raised when Databasus answers with a non-successful status code.
|
|
25
|
+
#
|
|
26
|
+
# Databasus answers errors as RFC 9457 problems:
|
|
27
|
+
# `{ type, title, status, detail, code, errors }`. `code` is the stable
|
|
28
|
+
# machine-readable reason (`database_connection_failed`,
|
|
29
|
+
# `insufficient_disk_space`, `already_running`, ...).
|
|
12
30
|
class APIError < Error
|
|
13
|
-
attr_reader :status, :body, :response
|
|
31
|
+
attr_reader :status, :body, :response, :code
|
|
14
32
|
|
|
15
|
-
def initialize(message = nil, status: nil, body: nil, response: nil)
|
|
33
|
+
def initialize(message = nil, status: nil, body: nil, response: nil, code: nil)
|
|
16
34
|
@status = status
|
|
17
35
|
@body = body
|
|
18
36
|
@response = response
|
|
37
|
+
@code = code
|
|
19
38
|
super(message || "Databasus API returned #{status}")
|
|
20
39
|
end
|
|
21
40
|
|
|
22
41
|
# Maps a Faraday response onto the most specific error class available.
|
|
23
|
-
|
|
42
|
+
# `body` overrides the response body (streamed responses carry none).
|
|
43
|
+
def self.from_response(response, body: response.body)
|
|
24
44
|
klass = for_status(response.status)
|
|
25
|
-
klass.new(message_from(
|
|
45
|
+
klass.new(message_from(body), status: response.status, body: body, response: response, code: code_from(body))
|
|
26
46
|
end
|
|
27
47
|
|
|
28
48
|
def self.for_status(status)
|
|
@@ -31,15 +51,25 @@ module DatabasusRuby
|
|
|
31
51
|
STATUS_ERRORS.fetch(status, APIError)
|
|
32
52
|
end
|
|
33
53
|
|
|
34
|
-
#
|
|
35
|
-
#
|
|
54
|
+
# A validation problem's `detail` is generic ("the request breaks a
|
|
55
|
+
# validation rule"); the field errors are what say which rule.
|
|
36
56
|
def self.message_from(body)
|
|
37
57
|
case body
|
|
38
|
-
when Hash then body
|
|
58
|
+
when Hash then problem_message(body)
|
|
39
59
|
when String then body.empty? ? nil : body[0, 500]
|
|
40
60
|
end
|
|
41
61
|
end
|
|
42
|
-
|
|
62
|
+
|
|
63
|
+
def self.problem_message(problem)
|
|
64
|
+
message = problem["detail"] || problem["title"] || problem["error"] || problem["message"]
|
|
65
|
+
fields = Array(problem["errors"]).filter_map { |e| "#{e["field"]}: #{e["detail"]}" if e.is_a?(Hash) }
|
|
66
|
+
fields.empty? ? message : [message, fields.join("; ")].compact.join(" — ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.code_from(body)
|
|
70
|
+
body["code"] if body.is_a?(Hash)
|
|
71
|
+
end
|
|
72
|
+
private_class_method :message_from, :problem_message, :code_from
|
|
43
73
|
end
|
|
44
74
|
|
|
45
75
|
class BadRequestError < APIError; end
|
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module DatabasusRuby
|
|
4
|
-
# Obtaining a token. Every other endpoint needs the
|
|
5
|
-
# return, so
|
|
4
|
+
# Obtaining a session token. Every other endpoint needs the token these
|
|
5
|
+
# methods return, so a completed sign-in installs it on the client as
|
|
6
|
+
# `api_key`.
|
|
6
7
|
#
|
|
7
8
|
# client.auth.signin(email: "me@example.com", password: "secret")
|
|
8
|
-
# client.
|
|
9
|
+
# client.databases.list(workspace_id: id)
|
|
9
10
|
#
|
|
10
|
-
#
|
|
11
|
+
# An account with two-factor sign-in answers CODE_REQUIRED: #signin raises
|
|
12
|
+
# SignInCodeRequiredError and #verify_sign_in_code finishes it.
|
|
11
13
|
class Auth < Object
|
|
12
14
|
include Endpoint
|
|
13
15
|
|
|
14
|
-
# POST /
|
|
16
|
+
# POST /sessions
|
|
17
|
+
#
|
|
18
|
+
# @return [DatabasusRuby::Object] the completed sign-in (`token`, `userId`, `email`)
|
|
19
|
+
# @raise [DatabasusRuby::SignInCodeRequiredError] when the account needs the emailed code
|
|
15
20
|
def signin(email:, password:, cloudflare_turnstile_token: nil)
|
|
16
|
-
|
|
21
|
+
outcome = object(http_post("sessions", {
|
|
17
22
|
email: email,
|
|
18
23
|
password: password,
|
|
19
24
|
cloudflareTurnstileToken: cloudflare_turnstile_token
|
|
20
25
|
}))
|
|
26
|
+
if outcome.outcome == "CODE_REQUIRED"
|
|
27
|
+
raise SignInCodeRequiredError.new(pending_sign_in_id: outcome.pending_sign_in_id, email: outcome.email)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
authorize outcome
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# POST /pending-sign-ins/{id}/verify
|
|
34
|
+
def verify_sign_in_code(pending_sign_in_id, code:, cloudflare_turnstile_token: nil)
|
|
35
|
+
authorize object(http_post("pending-sign-ins/#{escape(pending_sign_in_id)}/verify", {
|
|
36
|
+
code: code,
|
|
37
|
+
cloudflareTurnstileToken: cloudflare_turnstile_token
|
|
38
|
+
}))
|
|
21
39
|
end
|
|
22
40
|
|
|
23
|
-
# POST /users
|
|
41
|
+
# POST /users — self-registration, when the instance allows it.
|
|
24
42
|
def signup(email:, name:, password:, cloudflare_turnstile_token: nil)
|
|
25
|
-
authorize object(http_post("users
|
|
43
|
+
authorize object(http_post("users", {
|
|
26
44
|
email: email,
|
|
27
45
|
name: name,
|
|
28
46
|
password: password,
|
|
@@ -30,27 +48,14 @@ module DatabasusRuby
|
|
|
30
48
|
}))
|
|
31
49
|
end
|
|
32
50
|
|
|
33
|
-
# POST /
|
|
34
|
-
def
|
|
35
|
-
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# POST /auth/google/callback
|
|
39
|
-
def google_callback(code:, redirect_uri:)
|
|
40
|
-
object(http_post("auth/google/callback", { code: code, redirectUri: redirect_uri }))
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# POST /users/send-reset-password-code
|
|
44
|
-
def send_reset_password_code(email:, cloudflare_turnstile_token: nil)
|
|
45
|
-
object(http_post("users/send-reset-password-code", {
|
|
46
|
-
email: email,
|
|
47
|
-
cloudflareTurnstileToken: cloudflare_turnstile_token
|
|
48
|
-
}))
|
|
51
|
+
# POST /password-resets — emails a reset code.
|
|
52
|
+
def request_password_reset(email:, cloudflare_turnstile_token: nil)
|
|
53
|
+
acknowledged(http_post("password-resets", { email: email, cloudflareTurnstileToken: cloudflare_turnstile_token }))
|
|
49
54
|
end
|
|
50
55
|
|
|
51
|
-
# POST /
|
|
56
|
+
# POST /password-reset-completions
|
|
52
57
|
def reset_password(email:, code:, new_password:)
|
|
53
|
-
|
|
58
|
+
acknowledged(http_post("password-reset-completions", { email: email, code: code, newPassword: new_password }))
|
|
54
59
|
end
|
|
55
60
|
|
|
56
61
|
private
|
|
@@ -3,65 +3,90 @@
|
|
|
3
3
|
module DatabasusRuby
|
|
4
4
|
# Logical backups.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# backups
|
|
8
|
-
#
|
|
6
|
+
# page = client.backups.list(database_id: id, status: "COMPLETED", limit: 20)
|
|
7
|
+
# page.extras.total_usage_mb # storage used by all of the database's backups
|
|
8
|
+
# page.first.raw_database_size_mb # size of the database when it was dumped
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# File.open("dump", "wb") { |io| client.backups.download_to(backup.id, io) }
|
|
11
|
+
#
|
|
12
|
+
# A download is a single-use token (POST /download-tokens, valid five
|
|
13
|
+
# minutes) spent on GET /logical-backups/{id}/file. #download_to streams the
|
|
14
|
+
# file; #download reads it into memory and is only fit for small dumps.
|
|
12
15
|
class Backups < Object
|
|
13
16
|
include Endpoint
|
|
14
17
|
|
|
15
18
|
STATUSES = %w[IN_PROGRESS COMPLETED FAILED CANCELED].freeze
|
|
16
|
-
ENCRYPTIONS = %w[NONE ENCRYPTED].freeze
|
|
17
19
|
VERIFICATION_STATUSES = %w[NOT_VERIFIED VERIFIED_SUCCESSFUL VERIFICATION_FAILED].freeze
|
|
18
20
|
|
|
19
|
-
# GET /backups — `status`
|
|
20
|
-
def list(database_id:,
|
|
21
|
-
response = http_get("backups", {
|
|
22
|
-
|
|
23
|
-
limit: limit,
|
|
24
|
-
offset: offset,
|
|
21
|
+
# GET /logical-backups — newest first. `status` takes one value or an array.
|
|
22
|
+
def list(database_id:, status: nil, created_before: nil, limit: nil, after: nil)
|
|
23
|
+
response = http_get("logical-backups", {
|
|
24
|
+
databaseId: require_id(database_id),
|
|
25
25
|
status: status,
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
createdBefore: created_before,
|
|
27
|
+
limit: limit,
|
|
28
|
+
after: after
|
|
28
29
|
})
|
|
29
|
-
collection(response
|
|
30
|
+
collection(response)
|
|
30
31
|
end
|
|
31
32
|
alias all list
|
|
32
33
|
|
|
33
|
-
#
|
|
34
|
+
# GET /logical-backups/{id}
|
|
35
|
+
def get(id)
|
|
36
|
+
object(http_get("logical-backups/#{escape(id)}"))
|
|
37
|
+
end
|
|
38
|
+
alias find get
|
|
39
|
+
|
|
40
|
+
# POST /logical-backups — queues a backup run for the database.
|
|
34
41
|
def create(database_id:)
|
|
35
|
-
object(http_post("backups", {
|
|
42
|
+
object(http_post("logical-backups", { databaseId: require_id(database_id) }))
|
|
36
43
|
end
|
|
37
44
|
alias trigger create
|
|
38
45
|
|
|
39
|
-
# DELETE /backups/{id}
|
|
46
|
+
# DELETE /logical-backups/{id}
|
|
40
47
|
def delete(id)
|
|
41
|
-
acknowledged(http_delete("backups/#{escape(id)}"))
|
|
48
|
+
acknowledged(http_delete("logical-backups/#{escape(id)}"))
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
# POST /backups/{id}/cancel
|
|
51
|
+
# POST /logical-backups/{id}/cancel
|
|
45
52
|
def cancel(id)
|
|
46
|
-
|
|
53
|
+
object(http_post("logical-backups/#{escape(id)}/cancel"))
|
|
47
54
|
end
|
|
48
55
|
|
|
49
|
-
# POST /
|
|
56
|
+
# POST /download-tokens -> { token, fileName, expiresAt }
|
|
50
57
|
def download_token(id)
|
|
51
|
-
object(http_post("
|
|
58
|
+
object(http_post("download-tokens", { backupId: require_id(id) }))
|
|
52
59
|
end
|
|
53
60
|
|
|
54
|
-
# GET /backups/{id}/file —
|
|
55
|
-
# #download_token.
|
|
61
|
+
# GET /logical-backups/{id}/file — the raw body, in memory.
|
|
56
62
|
def file(id, token:)
|
|
57
|
-
http_get("backups/#{escape(id)}/file", { token: token }).body
|
|
63
|
+
http_get("logical-backups/#{escape(id)}/file", { token: token }).body
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Mints a token and streams the file into `io`.
|
|
67
|
+
#
|
|
68
|
+
# @return [DatabasusRuby::Object] the token grant (`file_name`, `expires_at`)
|
|
69
|
+
# plus `bytes`, the number of bytes written
|
|
70
|
+
def download_to(id, io)
|
|
71
|
+
grant = download_token(id)
|
|
72
|
+
bytes = file_to(id, io, token: grant.token)
|
|
73
|
+
Object.new(grant.to_h.except("token").merge("bytes" => bytes))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Streams the file into `io` with a token from #download_token. Mint the
|
|
77
|
+
# token first when the caller must fail before it starts answering (an
|
|
78
|
+
# HTTP response whose headers are already sent cannot turn into an error).
|
|
79
|
+
#
|
|
80
|
+
# @return [Integer] bytes written
|
|
81
|
+
def file_to(id, io, token:)
|
|
82
|
+
client.stream("logical-backups/#{escape(id)}/file", io: io, params: { token: token })
|
|
58
83
|
end
|
|
59
84
|
|
|
60
|
-
# Mints a token and fetches the file in one call. Returns
|
|
61
|
-
# [
|
|
85
|
+
# Mints a token and fetches the file in one call, in memory. Returns
|
|
86
|
+
# [file_name, contents]. Prefer #download_to for real dumps.
|
|
62
87
|
def download(id)
|
|
63
88
|
grant = download_token(id)
|
|
64
|
-
[grant.
|
|
89
|
+
[grant.file_name, file(id, token: grant.token)]
|
|
65
90
|
end
|
|
66
91
|
end
|
|
67
92
|
end
|