wowsql-sdk 1.2.0 → 3.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 +4 -4
- data/README.md +359 -417
- data/lib/wowmysql.rb +3 -2
- data/lib/wowsql/auth.rb +533 -0
- data/lib/wowsql/client.rb +54 -25
- data/lib/wowsql/exceptions.rb +15 -14
- data/lib/wowsql/query_builder.rb +239 -104
- data/lib/wowsql/schema.rb +250 -0
- data/lib/wowsql/storage.rb +380 -0
- data/lib/wowsql/table.rb +118 -9
- data/lib/wowsql/version.rb +1 -1
- data/lib/wowsql.rb +3 -2
- metadata +23 -6
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'json'
|
|
3
|
+
require_relative 'exceptions'
|
|
4
|
+
|
|
5
|
+
module WOWSQL
|
|
6
|
+
# Schema management client for PostgreSQL.
|
|
7
|
+
#
|
|
8
|
+
# Requires a SERVICE ROLE key (wowsql_service_...), not an anonymous key.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# schema = WOWSQL::WOWSQLSchema.new(
|
|
12
|
+
# "myproject",
|
|
13
|
+
# "wowsql_service_..."
|
|
14
|
+
# )
|
|
15
|
+
# schema.create_table("users", [
|
|
16
|
+
# { "name" => "id", "type" => "SERIAL", "auto_increment" => true },
|
|
17
|
+
# { "name" => "email", "type" => "VARCHAR(255)", "unique" => true, "nullable" => false },
|
|
18
|
+
# { "name" => "name", "type" => "VARCHAR(255)" },
|
|
19
|
+
# { "name" => "metadata", "type" => "JSONB", "default" => "'{}'" },
|
|
20
|
+
# { "name" => "created_at", "type" => "TIMESTAMPTZ", "default" => "CURRENT_TIMESTAMP" },
|
|
21
|
+
# ], primary_key: "id", indexes: ["email"])
|
|
22
|
+
class WOWSQLSchema
|
|
23
|
+
attr_reader :base_url
|
|
24
|
+
|
|
25
|
+
# @param project_url [String] Project subdomain or full URL
|
|
26
|
+
# @param service_key [String] Service role key (wowsql_service_...)
|
|
27
|
+
# @param base_domain [String] Base domain (default: "wowsql.com")
|
|
28
|
+
# @param secure [Boolean] Use HTTPS (default: true)
|
|
29
|
+
# @param timeout [Integer] Request timeout in seconds (default: 30)
|
|
30
|
+
# @param verify_ssl [Boolean] Verify SSL certificates (default: true)
|
|
31
|
+
def initialize(project_url, service_key, base_domain: 'wowsql.com', secure: true,
|
|
32
|
+
timeout: 30, verify_ssl: true)
|
|
33
|
+
if project_url.start_with?('http://') || project_url.start_with?('https://')
|
|
34
|
+
base = project_url.chomp('/')
|
|
35
|
+
base = base.split('/api').first if base.include?('/api')
|
|
36
|
+
@base_url = base
|
|
37
|
+
else
|
|
38
|
+
protocol = secure ? 'https' : 'http'
|
|
39
|
+
if project_url.include?(".#{base_domain}") || project_url.end_with?(base_domain)
|
|
40
|
+
@base_url = "#{protocol}://#{project_url}"
|
|
41
|
+
else
|
|
42
|
+
@base_url = "#{protocol}://#{project_url}.#{base_domain}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@timeout = timeout
|
|
47
|
+
|
|
48
|
+
ssl_options = verify_ssl ? {} : { verify: false }
|
|
49
|
+
|
|
50
|
+
@conn = Faraday.new(url: @base_url, ssl: ssl_options) do |f|
|
|
51
|
+
f.request :json
|
|
52
|
+
f.response :json
|
|
53
|
+
f.adapter Faraday.default_adapter
|
|
54
|
+
f.options.timeout = timeout
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@conn.headers['Authorization'] = "Bearer #{service_key}"
|
|
58
|
+
@conn.headers['Content-Type'] = 'application/json'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── Table operations ─────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
# Create a new table.
|
|
64
|
+
#
|
|
65
|
+
# @param table_name [String] Name of the table
|
|
66
|
+
# @param columns [Array<Hash>] Column definitions (name, type, auto_increment, unique, nullable, default)
|
|
67
|
+
# @param primary_key [String, nil] Primary key column name
|
|
68
|
+
# @param indexes [Array<String>, nil] Columns to create indexes on
|
|
69
|
+
# @return [Hash]
|
|
70
|
+
def create_table(table_name, columns, primary_key: nil, indexes: nil)
|
|
71
|
+
request('POST', '/api/v2/schema/tables', nil, {
|
|
72
|
+
table_name: table_name,
|
|
73
|
+
columns: columns,
|
|
74
|
+
primary_key: primary_key,
|
|
75
|
+
indexes: indexes
|
|
76
|
+
})
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Alter an existing table.
|
|
80
|
+
#
|
|
81
|
+
# Operations: add_column, drop_column, modify_column, rename_column
|
|
82
|
+
#
|
|
83
|
+
# @param table_name [String] Table name
|
|
84
|
+
# @param operation [String] Operation type
|
|
85
|
+
# @param column_name [String, nil] Column name
|
|
86
|
+
# @param column_type [String, nil] Column type
|
|
87
|
+
# @param new_column_name [String, nil] New column name (for rename)
|
|
88
|
+
# @param nullable [Boolean] Whether column is nullable
|
|
89
|
+
# @param default [String, nil] Default value
|
|
90
|
+
# @return [Hash]
|
|
91
|
+
def alter_table(table_name, operation, column_name: nil, column_type: nil,
|
|
92
|
+
new_column_name: nil, nullable: true, default: nil)
|
|
93
|
+
request('PATCH', "/api/v2/schema/tables/#{table_name}", nil, {
|
|
94
|
+
table_name: table_name,
|
|
95
|
+
operation: operation,
|
|
96
|
+
column_name: column_name,
|
|
97
|
+
column_type: column_type,
|
|
98
|
+
new_column_name: new_column_name,
|
|
99
|
+
nullable: nullable,
|
|
100
|
+
default: default
|
|
101
|
+
})
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Drop a table. WARNING: This cannot be undone!
|
|
105
|
+
#
|
|
106
|
+
# @param table_name [String] Table to drop
|
|
107
|
+
# @param cascade [Boolean] Also drop dependent objects
|
|
108
|
+
# @return [Hash]
|
|
109
|
+
def drop_table(table_name, cascade: false)
|
|
110
|
+
request('DELETE', "/api/v2/schema/tables/#{table_name}", { 'cascade' => cascade }, nil)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Execute raw DDL SQL.
|
|
114
|
+
#
|
|
115
|
+
# Only schema statements are allowed: CREATE TABLE, ALTER TABLE,
|
|
116
|
+
# DROP TABLE, CREATE INDEX, DROP INDEX, etc.
|
|
117
|
+
#
|
|
118
|
+
# @param sql [String] DDL SQL statement
|
|
119
|
+
# @return [Hash]
|
|
120
|
+
def execute_sql(sql)
|
|
121
|
+
request('POST', '/api/v2/schema/execute', nil, { sql: sql })
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ── Convenience methods ──────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
# Add a column to an existing table.
|
|
127
|
+
#
|
|
128
|
+
# @param table_name [String] Table name
|
|
129
|
+
# @param column_name [String] Column name
|
|
130
|
+
# @param column_type [String] Column type
|
|
131
|
+
# @param nullable [Boolean] Whether column is nullable
|
|
132
|
+
# @param default [String, nil] Default value
|
|
133
|
+
# @return [Hash]
|
|
134
|
+
def add_column(table_name, column_name, column_type, nullable: true, default: nil)
|
|
135
|
+
alter_table(
|
|
136
|
+
table_name, 'add_column',
|
|
137
|
+
column_name: column_name,
|
|
138
|
+
column_type: column_type,
|
|
139
|
+
nullable: nullable,
|
|
140
|
+
default: default
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Drop a column from a table.
|
|
145
|
+
#
|
|
146
|
+
# @param table_name [String] Table name
|
|
147
|
+
# @param column_name [String] Column name
|
|
148
|
+
# @return [Hash]
|
|
149
|
+
def drop_column(table_name, column_name)
|
|
150
|
+
alter_table(table_name, 'drop_column', column_name: column_name)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Rename a column.
|
|
154
|
+
#
|
|
155
|
+
# @param table_name [String] Table name
|
|
156
|
+
# @param old_name [String] Current column name
|
|
157
|
+
# @param new_name [String] New column name
|
|
158
|
+
# @return [Hash]
|
|
159
|
+
def rename_column(table_name, old_name, new_name)
|
|
160
|
+
alter_table(
|
|
161
|
+
table_name, 'rename_column',
|
|
162
|
+
column_name: old_name,
|
|
163
|
+
new_column_name: new_name
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Change column type, nullability, or default value.
|
|
168
|
+
#
|
|
169
|
+
# @param table_name [String] Table name
|
|
170
|
+
# @param column_name [String] Column name
|
|
171
|
+
# @param column_type [String, nil] New column type
|
|
172
|
+
# @param nullable [Boolean, nil] New nullability
|
|
173
|
+
# @param default [String, nil] New default value
|
|
174
|
+
# @return [Hash]
|
|
175
|
+
def modify_column(table_name, column_name, column_type: nil, nullable: nil, default: nil)
|
|
176
|
+
kwargs = { column_name: column_name }
|
|
177
|
+
kwargs[:column_type] = column_type unless column_type.nil?
|
|
178
|
+
kwargs[:nullable] = nullable unless nullable.nil?
|
|
179
|
+
kwargs[:default] = default unless default.nil?
|
|
180
|
+
alter_table(table_name, 'modify_column', **kwargs)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Create an index.
|
|
184
|
+
#
|
|
185
|
+
# @param table_name [String] Table to index
|
|
186
|
+
# @param columns [String, Array<String>] Column(s)
|
|
187
|
+
# @param unique [Boolean] Create a UNIQUE index
|
|
188
|
+
# @param name [String, nil] Custom index name
|
|
189
|
+
# @param using [String, nil] Index method (btree, hash, gin, gist)
|
|
190
|
+
# @return [Hash]
|
|
191
|
+
def create_index(table_name, columns, unique: false, name: nil, using: nil)
|
|
192
|
+
cols = columns.is_a?(Array) ? columns : [columns]
|
|
193
|
+
idx_name = name || "idx_#{table_name}_#{cols.join('_')}"
|
|
194
|
+
unique_kw = unique ? 'UNIQUE ' : ''
|
|
195
|
+
using_kw = using ? " USING #{using}" : ''
|
|
196
|
+
col_list = cols.map { |c| "\"#{c}\"" }.join(', ')
|
|
197
|
+
sql = "CREATE #{unique_kw}INDEX IF NOT EXISTS \"#{idx_name}\" ON \"#{table_name}\"#{using_kw} (#{col_list})"
|
|
198
|
+
execute_sql(sql)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# List all tables via the v2 REST API.
|
|
202
|
+
#
|
|
203
|
+
# @return [Array<String>]
|
|
204
|
+
def list_tables
|
|
205
|
+
resp = request('GET', '/api/v2/tables', nil, nil)
|
|
206
|
+
resp['tables'] || []
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Get column-level schema information for a table.
|
|
210
|
+
#
|
|
211
|
+
# @param table_name [String] Table name
|
|
212
|
+
# @return [Hash]
|
|
213
|
+
def get_table_schema(table_name)
|
|
214
|
+
request('GET', "/api/v2/tables/#{table_name}/schema", nil, nil)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Close the HTTP connection.
|
|
218
|
+
def close
|
|
219
|
+
@conn.close if @conn.respond_to?(:close)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def request(method, path, params = nil, json = nil)
|
|
225
|
+
response = @conn.public_send(method.downcase, path) do |req|
|
|
226
|
+
req.params = params if params
|
|
227
|
+
req.body = json if json
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
if response.status == 403
|
|
231
|
+
raise SchemaPermissionError.new(
|
|
232
|
+
'Schema operations require a SERVICE ROLE key. ' \
|
|
233
|
+
'You are using an anonymous key which cannot modify database schema.',
|
|
234
|
+
403
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
if response.status >= 400
|
|
239
|
+
error_data = response.body.is_a?(Hash) ? response.body : {}
|
|
240
|
+
error_msg = error_data['detail'] || error_data['message'] ||
|
|
241
|
+
"Request failed with status #{response.status}"
|
|
242
|
+
raise WOWSQLError.new(error_msg, response.status, error_data)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
response.body
|
|
246
|
+
rescue Faraday::Error => e
|
|
247
|
+
raise WOWSQLError.new("Request failed: #{e.message}")
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'faraday/multipart'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require_relative 'exceptions'
|
|
8
|
+
|
|
9
|
+
module WOWSQL
|
|
10
|
+
# Bucket information.
|
|
11
|
+
class StorageBucket
|
|
12
|
+
attr_reader :id, :name, :public, :file_size_limit,
|
|
13
|
+
:allowed_mime_types, :created_at, :object_count, :total_size
|
|
14
|
+
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@id = data['id'] || data[:id] || ''
|
|
17
|
+
@name = data['name'] || data[:name] || ''
|
|
18
|
+
@public = !!(data['public'] || data[:public])
|
|
19
|
+
@file_size_limit = data['file_size_limit'] || data[:file_size_limit]
|
|
20
|
+
@allowed_mime_types = data['allowed_mime_types'] || data[:allowed_mime_types]
|
|
21
|
+
@created_at = data['created_at'] || data[:created_at]
|
|
22
|
+
@object_count = data['object_count'] || data[:object_count] || 0
|
|
23
|
+
@total_size = data['total_size'] || data[:total_size] || 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_s
|
|
27
|
+
"StorageBucket(name=#{@name.inspect}, public=#{@public})"
|
|
28
|
+
end
|
|
29
|
+
alias inspect to_s
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Storage file/object information.
|
|
33
|
+
class StorageFile
|
|
34
|
+
attr_reader :id, :bucket_id, :name, :path, :mime_type,
|
|
35
|
+
:size, :metadata, :created_at, :public_url
|
|
36
|
+
|
|
37
|
+
def initialize(data)
|
|
38
|
+
@id = data['id'] || data[:id] || ''
|
|
39
|
+
@bucket_id = data['bucket_id'] || data[:bucket_id] || ''
|
|
40
|
+
@name = data['name'] || data[:name] || ''
|
|
41
|
+
@path = data['path'] || data[:path] || ''
|
|
42
|
+
@mime_type = data['mime_type'] || data[:mime_type]
|
|
43
|
+
@size = data['size'] || data[:size] || 0
|
|
44
|
+
@metadata = data['metadata'] || data[:metadata] || {}
|
|
45
|
+
@created_at = data['created_at'] || data[:created_at]
|
|
46
|
+
@public_url = data['public_url'] || data[:public_url]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def size_mb
|
|
50
|
+
@size / (1024.0 * 1024.0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def size_gb
|
|
54
|
+
@size / (1024.0 ** 3)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_s
|
|
58
|
+
"StorageFile(path=#{@path.inspect}, size=#{'%.2f' % size_mb}MB)"
|
|
59
|
+
end
|
|
60
|
+
alias inspect to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Storage quota / statistics information.
|
|
64
|
+
class StorageQuota
|
|
65
|
+
attr_reader :total_files, :total_size_bytes, :total_size_gb, :file_types
|
|
66
|
+
|
|
67
|
+
def initialize(data)
|
|
68
|
+
@total_files = data['total_files'] || data[:total_files] || 0
|
|
69
|
+
@total_size_bytes = data['total_size_bytes'] || data[:total_size_bytes] || 0
|
|
70
|
+
@total_size_gb = data['total_size_gb'] || data[:total_size_gb] || 0
|
|
71
|
+
@file_types = data['file_types'] || data[:file_types] || {}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_s
|
|
75
|
+
"StorageQuota(files=#{@total_files}, size=#{'%.4f' % @total_size_gb}GB)"
|
|
76
|
+
end
|
|
77
|
+
alias inspect to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# WowSQL Storage Client — PostgreSQL-native file storage.
|
|
81
|
+
#
|
|
82
|
+
# Files are stored as BYTEA inside each project's storage schema.
|
|
83
|
+
# No external S3 dependency — everything lives in PostgreSQL.
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# storage = WOWSQL::WOWSQLStorage.new(
|
|
87
|
+
# "https://myproject.wowsql.com",
|
|
88
|
+
# "wowsql_anon_..."
|
|
89
|
+
# )
|
|
90
|
+
# bucket = storage.create_bucket("avatars", public: true)
|
|
91
|
+
# File.open("photo.jpg", "rb") { |f| storage.upload("avatars", f, path: "users/profile.jpg") }
|
|
92
|
+
# files = storage.list_files("avatars", prefix: "users/")
|
|
93
|
+
# url = storage.get_public_url("avatars", "users/profile.jpg")
|
|
94
|
+
class WOWSQLStorage
|
|
95
|
+
attr_reader :base_url, :project_slug
|
|
96
|
+
|
|
97
|
+
# @param project_url [String] Project subdomain or full URL
|
|
98
|
+
# @param api_key [String] API key for authentication
|
|
99
|
+
# @param project_slug [String] Explicit slug (used with base_url)
|
|
100
|
+
# @param base_url [String] Explicit base URL (used with project_slug)
|
|
101
|
+
# @param base_domain [String] Base domain (default: "wowsql.com")
|
|
102
|
+
# @param secure [Boolean] Use HTTPS (default: true)
|
|
103
|
+
# @param timeout [Integer] Request timeout in seconds (default: 60)
|
|
104
|
+
# @param verify_ssl [Boolean] Verify SSL certificates (default: true)
|
|
105
|
+
def initialize(project_url = '', api_key = '', project_slug: '', base_url: '',
|
|
106
|
+
base_domain: 'wowsql.com', secure: true, timeout: 60, verify_ssl: true)
|
|
107
|
+
if !project_slug.empty? && !base_url.empty?
|
|
108
|
+
@base_url = base_url.chomp('/')
|
|
109
|
+
@project_slug = project_slug
|
|
110
|
+
elsif !project_url.empty?
|
|
111
|
+
url = project_url.strip
|
|
112
|
+
if url.start_with?('http://') || url.start_with?('https://')
|
|
113
|
+
@base_url = url.chomp('/')
|
|
114
|
+
@base_url = @base_url.split('/api').first if @base_url.include?('/api')
|
|
115
|
+
else
|
|
116
|
+
protocol = secure ? 'https' : 'http'
|
|
117
|
+
if url.include?(".#{base_domain}") || url.end_with?(base_domain)
|
|
118
|
+
@base_url = "#{protocol}://#{url}"
|
|
119
|
+
else
|
|
120
|
+
@base_url = "#{protocol}://#{url}.#{base_domain}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
@project_slug = url.split('.').first
|
|
124
|
+
.sub('https://', '')
|
|
125
|
+
.sub('http://', '')
|
|
126
|
+
else
|
|
127
|
+
raise ArgumentError, 'Either project_url or (project_slug + base_url) must be provided'
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
@timeout = timeout
|
|
131
|
+
@verify_ssl = verify_ssl
|
|
132
|
+
|
|
133
|
+
ssl_options = verify_ssl ? {} : { verify: false }
|
|
134
|
+
|
|
135
|
+
@conn = Faraday.new(url: @base_url, ssl: ssl_options) do |f|
|
|
136
|
+
f.request :multipart
|
|
137
|
+
f.request :json
|
|
138
|
+
f.response :json
|
|
139
|
+
f.adapter Faraday.default_adapter
|
|
140
|
+
f.options.timeout = timeout
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
@conn.headers['Authorization'] = "Bearer #{api_key}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ── Buckets ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
# Create a new storage bucket.
|
|
149
|
+
#
|
|
150
|
+
# @param name [String] Bucket name
|
|
151
|
+
# @param public [Boolean] Whether the bucket is public
|
|
152
|
+
# @param file_size_limit [Integer, nil] Max file size in bytes
|
|
153
|
+
# @param allowed_mime_types [Array<String>, nil] Allowed MIME types
|
|
154
|
+
# @return [StorageBucket]
|
|
155
|
+
def create_bucket(name, public: false, file_size_limit: nil, allowed_mime_types: nil)
|
|
156
|
+
data = request('POST', "/api/v1/storage/projects/#{@project_slug}/buckets", nil, {
|
|
157
|
+
name: name,
|
|
158
|
+
public: public,
|
|
159
|
+
file_size_limit: file_size_limit,
|
|
160
|
+
allowed_mime_types: allowed_mime_types
|
|
161
|
+
})
|
|
162
|
+
StorageBucket.new(data)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# List all buckets in the project.
|
|
166
|
+
#
|
|
167
|
+
# @return [Array<StorageBucket>]
|
|
168
|
+
def list_buckets
|
|
169
|
+
data = request('GET', "/api/v1/storage/projects/#{@project_slug}/buckets", nil, nil)
|
|
170
|
+
Array(data).map { |b| StorageBucket.new(b) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get a specific bucket by name.
|
|
174
|
+
#
|
|
175
|
+
# @param name [String] Bucket name
|
|
176
|
+
# @return [StorageBucket]
|
|
177
|
+
def get_bucket(name)
|
|
178
|
+
data = request('GET', "/api/v1/storage/projects/#{@project_slug}/buckets/#{name}", nil, nil)
|
|
179
|
+
StorageBucket.new(data)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Update bucket settings.
|
|
183
|
+
#
|
|
184
|
+
# @param name [String] Bucket name
|
|
185
|
+
# @param options [Hash] Settings to update
|
|
186
|
+
# @return [StorageBucket]
|
|
187
|
+
def update_bucket(name, **options)
|
|
188
|
+
data = request('PATCH', "/api/v1/storage/projects/#{@project_slug}/buckets/#{name}", nil, options)
|
|
189
|
+
StorageBucket.new(data)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Delete a bucket and all its files.
|
|
193
|
+
#
|
|
194
|
+
# @param name [String] Bucket name
|
|
195
|
+
# @return [Hash]
|
|
196
|
+
def delete_bucket(name)
|
|
197
|
+
request('DELETE', "/api/v1/storage/projects/#{@project_slug}/buckets/#{name}", nil, nil)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# ── Files ────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
# Upload a file to a bucket.
|
|
203
|
+
#
|
|
204
|
+
# @param bucket_name [String] Target bucket
|
|
205
|
+
# @param file_data [IO] File-like object (opened in binary mode)
|
|
206
|
+
# @param path [String, nil] File path within bucket
|
|
207
|
+
# @param file_name [String, nil] Override filename
|
|
208
|
+
# @return [StorageFile]
|
|
209
|
+
def upload(bucket_name, file_data, path: nil, file_name: nil)
|
|
210
|
+
content = file_data.respond_to?(:read) ? file_data.read : file_data
|
|
211
|
+
name = file_name || (file_data.respond_to?(:path) ? File.basename(file_data.path) : nil) || path || 'file'
|
|
212
|
+
name = name.split('/').last if name.include?('/')
|
|
213
|
+
|
|
214
|
+
folder = ''
|
|
215
|
+
if path && path.include?('/')
|
|
216
|
+
folder = path.rpartition('/').first
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
params = {}
|
|
220
|
+
params['folder'] = folder unless folder.empty?
|
|
221
|
+
|
|
222
|
+
upload_io = Faraday::Multipart::FilePart.new(
|
|
223
|
+
StringIO.new(content.is_a?(String) ? content : content.to_s),
|
|
224
|
+
'application/octet-stream',
|
|
225
|
+
name
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
response = @conn.post("/api/v1/storage/projects/#{@project_slug}/buckets/#{bucket_name}/files") do |req|
|
|
229
|
+
req.params = params unless params.empty?
|
|
230
|
+
req.body = { file: upload_io }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if response.status >= 400
|
|
234
|
+
handle_error(response)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
StorageFile.new(response.body)
|
|
238
|
+
rescue Faraday::Error => e
|
|
239
|
+
raise StorageError.new("Upload failed: #{e.message}")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Upload a file from a local filesystem path.
|
|
243
|
+
#
|
|
244
|
+
# @param file_path [String] Path to local file
|
|
245
|
+
# @param bucket_name [String] Target bucket
|
|
246
|
+
# @param path [String, nil] File path within bucket
|
|
247
|
+
# @return [StorageFile]
|
|
248
|
+
def upload_from_path(file_path, bucket_name: 'default', path: nil)
|
|
249
|
+
raise StorageError.new("File not found: #{file_path}") unless File.exist?(file_path)
|
|
250
|
+
|
|
251
|
+
file_name = File.basename(file_path)
|
|
252
|
+
File.open(file_path, 'rb') do |f|
|
|
253
|
+
upload(bucket_name, f, path: path || file_name, file_name: file_name)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# List files in a bucket.
|
|
258
|
+
#
|
|
259
|
+
# @param bucket_name [String] Bucket name
|
|
260
|
+
# @param prefix [String, nil] Filter by prefix/folder
|
|
261
|
+
# @param limit [Integer] Maximum files to return
|
|
262
|
+
# @param offset [Integer] Offset for pagination
|
|
263
|
+
# @return [Array<StorageFile>]
|
|
264
|
+
def list_files(bucket_name, prefix: nil, limit: 100, offset: 0)
|
|
265
|
+
params = { 'limit' => limit, 'offset' => offset }
|
|
266
|
+
params['prefix'] = prefix if prefix
|
|
267
|
+
|
|
268
|
+
data = request('GET', "/api/v1/storage/projects/#{@project_slug}/buckets/#{bucket_name}/files", params, nil)
|
|
269
|
+
items = data.is_a?(Array) ? data : (data['files'] || data['data'] || [])
|
|
270
|
+
items.map { |f| StorageFile.new(f) }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Download a file and return its binary contents.
|
|
274
|
+
#
|
|
275
|
+
# @param bucket_name [String] Bucket name
|
|
276
|
+
# @param file_path [String] File path within bucket
|
|
277
|
+
# @return [String] Binary file contents
|
|
278
|
+
def download(bucket_name, file_path)
|
|
279
|
+
url = "/api/v1/storage/projects/#{@project_slug}/files/#{bucket_name}/#{file_path}"
|
|
280
|
+
|
|
281
|
+
response = @conn.get(url)
|
|
282
|
+
if response.status >= 400
|
|
283
|
+
handle_error(response)
|
|
284
|
+
end
|
|
285
|
+
response.body
|
|
286
|
+
rescue Faraday::Error => e
|
|
287
|
+
raise StorageError.new("Download failed: #{e.message}")
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Download a file and save it to a local path.
|
|
291
|
+
#
|
|
292
|
+
# @param bucket_name [String] Bucket name
|
|
293
|
+
# @param file_path [String] File path within bucket
|
|
294
|
+
# @param local_path [String] Local destination path
|
|
295
|
+
# @return [String] The local path
|
|
296
|
+
def download_to_file(bucket_name, file_path, local_path)
|
|
297
|
+
content = download(bucket_name, file_path)
|
|
298
|
+
dir = File.dirname(local_path)
|
|
299
|
+
FileUtils.mkdir_p(dir) unless dir == '.'
|
|
300
|
+
File.open(local_path, 'wb') { |f| f.write(content) }
|
|
301
|
+
local_path
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Delete a file from a bucket.
|
|
305
|
+
#
|
|
306
|
+
# @param bucket_name [String] Bucket name
|
|
307
|
+
# @param file_path [String] File path within bucket
|
|
308
|
+
# @return [Hash]
|
|
309
|
+
def delete_file(bucket_name, file_path)
|
|
310
|
+
request('DELETE', "/api/v1/storage/projects/#{@project_slug}/files/#{bucket_name}/#{file_path}", nil, nil)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# ── Utilities ────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
# Get the public URL for a file in a public bucket (no auth required).
|
|
316
|
+
#
|
|
317
|
+
# @param bucket_name [String] Bucket name
|
|
318
|
+
# @param file_path [String] File path within bucket
|
|
319
|
+
# @return [String]
|
|
320
|
+
def get_public_url(bucket_name, file_path)
|
|
321
|
+
"#{@base_url}/api/v1/storage/projects/#{@project_slug}/files/#{bucket_name}/#{file_path}"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Get storage statistics for the project.
|
|
325
|
+
#
|
|
326
|
+
# @return [StorageQuota]
|
|
327
|
+
def get_stats
|
|
328
|
+
data = request('GET', "/api/v1/storage/projects/#{@project_slug}/stats", nil, nil)
|
|
329
|
+
StorageQuota.new(data)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Get storage quota (alias for get_stats).
|
|
333
|
+
#
|
|
334
|
+
# @return [StorageQuota]
|
|
335
|
+
def get_quota(force_refresh: false)
|
|
336
|
+
get_stats
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ── Lifecycle ────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
# Close the HTTP connection.
|
|
342
|
+
def close
|
|
343
|
+
@conn.close if @conn.respond_to?(:close)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def to_s
|
|
347
|
+
"WOWSQLStorage(project=#{@project_slug.inspect})"
|
|
348
|
+
end
|
|
349
|
+
alias inspect to_s
|
|
350
|
+
|
|
351
|
+
private
|
|
352
|
+
|
|
353
|
+
def request(method, path, params = nil, json = nil)
|
|
354
|
+
response = @conn.public_send(method.downcase, path) do |req|
|
|
355
|
+
req.params = params if params
|
|
356
|
+
req.body = json if json
|
|
357
|
+
req.headers['Content-Type'] = 'application/json' if json
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
if response.status >= 400
|
|
361
|
+
handle_error(response)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
response.body
|
|
365
|
+
rescue Faraday::Error => e
|
|
366
|
+
raise StorageError.new("Request failed: #{e.message}")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def handle_error(response)
|
|
370
|
+
error_data = response.body.is_a?(Hash) ? response.body : {}
|
|
371
|
+
error_msg = error_data['detail'] || error_data['message'] || "Request failed with status #{response.status}"
|
|
372
|
+
|
|
373
|
+
if response.status == 413
|
|
374
|
+
raise StorageLimitExceededError.new(error_msg, 413, error_data)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
raise StorageError.new(error_msg, response.status, error_data)
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|