databasus_ruby 0.0.1 → 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/README.md +9 -1
- data/lib/databasus_ruby/client.rb +75 -20
- data/lib/databasus_ruby/collection.rb +30 -37
- data/lib/databasus_ruby/endpoint.rb +13 -13
- 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 +43 -60
- 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/record.rb +32 -0
- data/lib/databasus_ruby/records/database.rb +22 -0
- data/lib/databasus_ruby/version.rb +1 -1
- data/lib/databasus_ruby.rb +9 -2
- data/sig/databasus_ruby.rbs +180 -100
- metadata +5 -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
|
data/README.md
CHANGED
|
@@ -109,10 +109,18 @@ A database can reach its own backups, which delegates to the `backups` endpoint
|
|
|
109
109
|
with the same filters as `client.backups.list`:
|
|
110
110
|
|
|
111
111
|
```ruby
|
|
112
|
+
database.backups(status: "COMPLETED", limit: 25)
|
|
113
|
+
database.backup # queue a run
|
|
114
|
+
|
|
115
|
+
# or by id, from the endpoint
|
|
112
116
|
client.databases.backups(database.id, status: "COMPLETED", limit: 25)
|
|
113
|
-
client.databases.backup(database.id)
|
|
117
|
+
client.databases.backup(database.id)
|
|
114
118
|
```
|
|
115
119
|
|
|
120
|
+
`list`, `get`, `create`, `update` and `copy` return `DatabasusRuby::Database`
|
|
121
|
+
records: payload objects that read exactly like any other response but also
|
|
122
|
+
carry the two methods above.
|
|
123
|
+
|
|
116
124
|
`update` posts to `/databases/update` with the id in the body, matching the API.
|
|
117
125
|
There is also `test_connection_direct`, `create_readonly_user`,
|
|
118
126
|
`create_replication_only_user` and `readonly?` for a payload you have not saved
|
|
@@ -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,38 +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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@
|
|
20
|
-
@
|
|
19
|
+
# `wrap` builds one entry from its raw hash; it defaults to a plain Object
|
|
20
|
+
# and endpoints pass a Record builder instead.
|
|
21
|
+
def initialize(data: [], next_cursor: nil, extras: {}, wrap: nil)
|
|
22
|
+
@data = data.map { |item| item.is_a?(Object) ? item : (wrap || Object.method(:new)).call(item) }
|
|
23
|
+
@next_cursor = next_cursor
|
|
24
|
+
@extras = extras.is_a?(Object) ? extras : Object.new(extras)
|
|
21
25
|
end
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def self.from(body,
|
|
27
|
+
PAGE_KEYS = %w[items nextCursor].freeze
|
|
28
|
+
|
|
29
|
+
def self.from(body, wrap: nil)
|
|
26
30
|
case body
|
|
27
|
-
when
|
|
28
|
-
|
|
29
|
-
when
|
|
30
|
-
|
|
31
|
-
when nil
|
|
32
|
-
new
|
|
33
|
-
else
|
|
34
|
-
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}"
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
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
|
+
|
|
38
44
|
def each(&block)
|
|
39
45
|
return data.each unless block
|
|
40
46
|
|
|
@@ -59,25 +65,12 @@ module DatabasusRuby
|
|
|
59
65
|
data.last
|
|
60
66
|
end
|
|
61
67
|
|
|
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
68
|
def last_page?
|
|
76
|
-
|
|
69
|
+
next_cursor.nil? || next_cursor.to_s.empty?
|
|
77
70
|
end
|
|
78
71
|
|
|
79
72
|
def inspect
|
|
80
|
-
"#<#{self.class.name} size=#{size}
|
|
73
|
+
"#<#{self.class.name} size=#{size} next_cursor=#{next_cursor.inspect}>"
|
|
81
74
|
end
|
|
82
75
|
end
|
|
83
76
|
end
|
|
@@ -43,14 +43,21 @@ module DatabasusRuby
|
|
|
43
43
|
client.request(:delete, path, params: params)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
# Wraps a single-object response.
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# Wraps a single-object response. `as` names a Record subclass for the
|
|
47
|
+
# domains whose payloads carry methods of their own.
|
|
48
|
+
def object(response, as: Object)
|
|
49
|
+
build(as, response.body.is_a?(Hash) ? response.body : {})
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
# Wraps a list response
|
|
52
|
-
def collection(response,
|
|
53
|
-
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
|
+
end
|
|
56
|
+
|
|
57
|
+
# Records need the client to reach their related endpoints; plain payload
|
|
58
|
+
# objects do not.
|
|
59
|
+
def build(klass, attributes)
|
|
60
|
+
klass <= Record ? klass.new(client, attributes) : klass.new(attributes)
|
|
54
61
|
end
|
|
55
62
|
|
|
56
63
|
# For endpoints that answer 200/204 with no meaningful body.
|
|
@@ -58,13 +65,6 @@ module DatabasusRuby
|
|
|
58
65
|
true
|
|
59
66
|
end
|
|
60
67
|
|
|
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
68
|
# Request bodies are built from keyword arguments, and the API distinguishes
|
|
69
69
|
# an absent key from a null one in a few places, so nils are dropped.
|
|
70
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
|