wowsql-sdk 1.1.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad880e2257d4f66eb09d166676bb673d10fdc77d40e8649d9d21dd752c5b116d
4
- data.tar.gz: f592669e6b04a84cee0c541447d1f7124e114386647c608c940af69b6c6fbe8b
3
+ metadata.gz: 9f59248127728b52972504dd30e3f03ce86f0edaa2070f3988bf01ca9ee6485d
4
+ data.tar.gz: 1475a5cf06884e86dd4aeee607d16969ed02688383401cea40c59f8228cf5d8f
5
5
  SHA512:
6
- metadata.gz: 980b656e45ad73330ce5b87e6405013b00bc38a60a7d55ff33612c0157ae599a10721480cf018ba21903740e07c5d178d2b28b39591ea16704896fe39a3de258
7
- data.tar.gz: a621bd63b4f1c0c803dd5bf27216968aaba137b24d25a23473e290ca445eb4f9f24e50d72cfa8bf5cba442e03aa76be62add3d8e07d3177cc010d0ae0a3035c6
6
+ metadata.gz: fc0f99522a2d2c1753a72faed398a8bafee6336ab3be65a275829db58ce100bf62b55b6a36e2519537b21173cf72f0b49b9ca2fdf2a87bd79ba74834e7f788bf
7
+ data.tar.gz: 76b3cc58a7587f4ca3ad16aa2e5bd09a031cfdafdb46763d346d38122a45a4b128db4bb3b36498c50b8c3d44e14d55771513d92eb89d959fdbfda576b30d0bc4
@@ -0,0 +1,384 @@
1
+ require 'faraday'
2
+ require 'json'
3
+ require_relative 'exceptions'
4
+
5
+ module WOWSQL
6
+ # Project-level authentication client.
7
+ #
8
+ # UNIFIED AUTHENTICATION: Uses the same API keys (anon/service) as database operations.
9
+ # One project = one set of keys for ALL operations (auth + database).
10
+ #
11
+ # Key Types:
12
+ # - Anonymous Key (wowsql_anon_...): For client-side auth operations (signup, login, OAuth)
13
+ # - Service Role Key (wowsql_service_...): For server-side auth operations (admin, full access)
14
+ class ProjectAuthClient
15
+ def initialize(project_url, api_key, timeout = 30)
16
+ @api_key = api_key
17
+ @timeout = timeout
18
+ @base_url = build_auth_base_url(project_url)
19
+ @access_token = nil
20
+ @refresh_token = nil
21
+
22
+ @conn = Faraday.new(url: @base_url) do |f|
23
+ f.request :json
24
+ f.response :json
25
+ f.adapter Faraday.default_adapter
26
+ f.options.timeout = timeout
27
+ end
28
+
29
+ @conn.headers['Authorization'] = "Bearer #{api_key}"
30
+ @conn.headers['Content-Type'] = 'application/json'
31
+ end
32
+
33
+ # Sign up a new user.
34
+ #
35
+ # @param email [String] User email
36
+ # @param password [String] User password (minimum 8 characters)
37
+ # @param full_name [String, nil] Optional full name
38
+ # @param user_metadata [Hash, nil] Optional user metadata
39
+ # @return [Hash] Response with session and user data
40
+ def sign_up(email:, password:, full_name: nil, user_metadata: nil)
41
+ payload = {
42
+ email: email,
43
+ password: password
44
+ }
45
+ payload[:full_name] = full_name if full_name
46
+ payload[:user_metadata] = user_metadata if user_metadata
47
+
48
+ data = request('POST', '/signup', nil, payload)
49
+ session = persist_session(data)
50
+
51
+ {
52
+ session: session,
53
+ user: data['user'] ? normalize_user(data['user']) : nil
54
+ }
55
+ end
56
+
57
+ # Sign in an existing user.
58
+ #
59
+ # @param email [String] User email
60
+ # @param password [String] User password
61
+ # @return [Hash] Response with session and user data
62
+ def sign_in(email:, password:)
63
+ payload = {
64
+ email: email,
65
+ password: password
66
+ }
67
+
68
+ data = request('POST', '/login', nil, payload)
69
+ session = persist_session(data)
70
+
71
+ {
72
+ session: session,
73
+ user: data['user'] ? normalize_user(data['user']) : nil
74
+ }
75
+ end
76
+
77
+ # Get OAuth authorization URL.
78
+ #
79
+ # @param provider [String] OAuth provider name (e.g., 'github', 'google')
80
+ # @param redirect_uri [String, nil] Optional frontend redirect URI
81
+ # @return [Hash] Response with authorization_url and other OAuth details
82
+ def get_oauth_authorization_url(provider:, redirect_uri: nil)
83
+ raise WOWSQLException.new('provider is required and cannot be empty') if provider.nil? || provider.strip.empty?
84
+
85
+ params = {}
86
+ params['frontend_redirect_uri'] = redirect_uri.strip if redirect_uri
87
+
88
+ begin
89
+ data = request('GET', "/oauth/#{provider}", params, nil)
90
+
91
+ {
92
+ authorization_url: data['authorization_url'] || '',
93
+ provider: data['provider'] || provider,
94
+ backend_callback_url: data['backend_callback_url'] || '',
95
+ frontend_redirect_uri: data['frontend_redirect_uri'] || redirect_uri || ''
96
+ }
97
+ rescue WOWSQLException => e
98
+ if e.status_code == 502
99
+ raise WOWSQLException.new(
100
+ "Bad Gateway (502): The backend server may be down or unreachable. Check if the backend is running and accessible at #{@base_url}",
101
+ 502,
102
+ e.response
103
+ )
104
+ elsif e.status_code == 400
105
+ raise WOWSQLException.new(
106
+ "Bad Request (400): #{e.message}. Ensure OAuth provider '#{provider}' is configured and enabled for this project.",
107
+ 400,
108
+ e.response
109
+ )
110
+ end
111
+ raise
112
+ end
113
+ end
114
+
115
+ # Exchange OAuth callback code for access tokens.
116
+ #
117
+ # @param provider [String] OAuth provider name
118
+ # @param code [String] Authorization code from OAuth provider callback
119
+ # @param redirect_uri [String, nil] Optional redirect URI
120
+ # @return [Hash] Response with session and user data
121
+ def exchange_oauth_callback(provider:, code:, redirect_uri: nil)
122
+ payload = { code: code }
123
+ payload[:redirect_uri] = redirect_uri if redirect_uri
124
+
125
+ data = request('POST', "/oauth/#{provider}/callback", nil, payload)
126
+ session = persist_session(data)
127
+
128
+ {
129
+ session: session,
130
+ user: data['user'] ? normalize_user(data['user']) : nil
131
+ }
132
+ end
133
+
134
+ # Request password reset.
135
+ #
136
+ # @param email [String] User's email address
137
+ # @return [Hash] Response with success status and message
138
+ def forgot_password(email:)
139
+ payload = { email: email }
140
+ data = request('POST', '/forgot-password', nil, payload)
141
+
142
+ {
143
+ success: data['success'] != false,
144
+ message: data['message'] || 'If that email exists, a password reset link has been sent'
145
+ }
146
+ end
147
+
148
+ # Reset password with token.
149
+ #
150
+ # @param token [String] Password reset token from email
151
+ # @param new_password [String] New password (minimum 8 characters)
152
+ # @return [Hash] Response with success status and message
153
+ def reset_password(token:, new_password:)
154
+ payload = {
155
+ token: token,
156
+ new_password: new_password
157
+ }
158
+
159
+ data = request('POST', '/reset-password', nil, payload)
160
+
161
+ {
162
+ success: data['success'] != false,
163
+ message: data['message'] || 'Password reset successfully! You can now login with your new password'
164
+ }
165
+ end
166
+
167
+ # Send OTP code to user's email.
168
+ #
169
+ # @param email [String] User's email address
170
+ # @param purpose [String] Purpose of OTP - 'login', 'signup', or 'password_reset' (default: 'login')
171
+ # @return [Hash] Response with success status and message
172
+ def send_otp(email:, purpose: 'login')
173
+ unless ['login', 'signup', 'password_reset'].include?(purpose)
174
+ raise WOWSQLException.new("Purpose must be 'login', 'signup', or 'password_reset'")
175
+ end
176
+
177
+ payload = {
178
+ email: email,
179
+ purpose: purpose
180
+ }
181
+
182
+ data = request('POST', '/otp/send', nil, payload)
183
+
184
+ {
185
+ success: data['success'] != false,
186
+ message: data['message'] || 'If that email exists, an OTP code has been sent'
187
+ }
188
+ end
189
+
190
+ # Verify OTP and complete authentication.
191
+ #
192
+ # @param email [String] User's email address
193
+ # @param otp [String] 6-digit OTP code
194
+ # @param purpose [String] Purpose of OTP - 'login', 'signup', or 'password_reset' (default: 'login')
195
+ # @param new_password [String, nil] Required for password_reset purpose, new password (minimum 8 characters)
196
+ # @return [Hash] Response with session and user data (for login/signup) or success message (for password_reset)
197
+ def verify_otp(email:, otp:, purpose: 'login', new_password: nil)
198
+ unless ['login', 'signup', 'password_reset'].include?(purpose)
199
+ raise WOWSQLException.new("Purpose must be 'login', 'signup', or 'password_reset'")
200
+ end
201
+
202
+ if purpose == 'password_reset' && new_password.nil?
203
+ raise WOWSQLException.new('new_password is required for password_reset purpose')
204
+ end
205
+
206
+ payload = {
207
+ email: email,
208
+ otp: otp,
209
+ purpose: purpose
210
+ }
211
+ payload[:new_password] = new_password if new_password
212
+
213
+ data = request('POST', '/otp/verify', nil, payload)
214
+
215
+ if purpose == 'password_reset'
216
+ return {
217
+ success: data['success'] != false,
218
+ message: data['message'] || 'Password reset successfully! You can now login with your new password'
219
+ }
220
+ end
221
+
222
+ session = persist_session(data)
223
+
224
+ {
225
+ session: session,
226
+ user: data['user'] ? normalize_user(data['user']) : nil
227
+ }
228
+ end
229
+
230
+ # Send magic link to user's email.
231
+ #
232
+ # @param email [String] User's email address
233
+ # @param purpose [String] Purpose of magic link - 'login', 'signup', or 'email_verification' (default: 'login')
234
+ # @return [Hash] Response with success status and message
235
+ def send_magic_link(email:, purpose: 'login')
236
+ unless ['login', 'signup', 'email_verification'].include?(purpose)
237
+ raise WOWSQLException.new("Purpose must be 'login', 'signup', or 'email_verification'")
238
+ end
239
+
240
+ payload = {
241
+ email: email,
242
+ purpose: purpose
243
+ }
244
+
245
+ data = request('POST', '/magic-link/send', nil, payload)
246
+
247
+ {
248
+ success: data['success'] != false,
249
+ message: data['message'] || 'If that email exists, a magic link has been sent'
250
+ }
251
+ end
252
+
253
+ # Verify email using token.
254
+ #
255
+ # @param token [String] Verification token from email
256
+ # @return [Hash] Response with success status, message, and user info
257
+ def verify_email(token:)
258
+ payload = { token: token }
259
+ data = request('POST', '/verify-email', nil, payload)
260
+
261
+ {
262
+ success: data['success'] != false,
263
+ message: data['message'] || 'Email verified successfully!',
264
+ user: data['user'] ? normalize_user(data['user']) : nil
265
+ }
266
+ end
267
+
268
+ # Resend verification email.
269
+ #
270
+ # @param email [String] User's email address
271
+ # @return [Hash] Response with success status and message
272
+ def resend_verification(email:)
273
+ payload = { email: email }
274
+ data = request('POST', '/resend-verification', nil, payload)
275
+
276
+ {
277
+ success: data['success'] != false,
278
+ message: data['message'] || 'If that email exists, a verification email has been sent'
279
+ }
280
+ end
281
+
282
+ # Get current session tokens.
283
+ #
284
+ # @return [Hash] Current access_token and refresh_token
285
+ def get_session
286
+ {
287
+ access_token: @access_token,
288
+ refresh_token: @refresh_token
289
+ }
290
+ end
291
+
292
+ # Set session tokens.
293
+ #
294
+ # @param access_token [String] Access token
295
+ # @param refresh_token [String, nil] Optional refresh token
296
+ def set_session(access_token:, refresh_token: nil)
297
+ @access_token = access_token
298
+ @refresh_token = refresh_token
299
+ end
300
+
301
+ # Clear session tokens.
302
+ def clear_session
303
+ @access_token = nil
304
+ @refresh_token = nil
305
+ end
306
+
307
+ private
308
+
309
+ # Build authentication base URL from project URL.
310
+ def build_auth_base_url(project_url)
311
+ normalized = project_url.strip
312
+
313
+ # If it's already a full URL, use it as-is
314
+ if normalized.start_with?('http://') || normalized.start_with?('https://')
315
+ normalized = normalized.chomp('/')
316
+ normalized = normalized[0..-5] if normalized.end_with?('/api')
317
+ return "#{normalized}/api/auth"
318
+ end
319
+
320
+ # If it already contains the base domain, don't append it again
321
+ if normalized.include?('.wowsql.com') || normalized.end_with?('wowsql.com')
322
+ normalized = "https://#{normalized}"
323
+ else
324
+ # Just a project slug, append domain
325
+ normalized = "https://#{normalized}.wowsql.com"
326
+ end
327
+
328
+ normalized = normalized.chomp('/')
329
+ normalized = normalized[0..-5] if normalized.end_with?('/api')
330
+
331
+ "#{normalized}/api/auth"
332
+ end
333
+
334
+ # Normalize user data from API response.
335
+ def normalize_user(user)
336
+ {
337
+ id: user['id'] || user[:id],
338
+ email: user['email'] || user[:email],
339
+ full_name: user['full_name'] || user['fullName'] || user[:full_name] || user[:fullName],
340
+ avatar_url: user['avatar_url'] || user['avatarUrl'] || user[:avatar_url] || user[:avatarUrl],
341
+ email_verified: !!(user['email_verified'] || user['emailVerified'] || user[:email_verified] || user[:emailVerified]),
342
+ user_metadata: user['user_metadata'] || user['userMetadata'] || user[:user_metadata] || user[:userMetadata] || {},
343
+ app_metadata: user['app_metadata'] || user['appMetadata'] || user[:app_metadata] || user[:appMetadata] || {},
344
+ created_at: user['created_at'] || user['createdAt'] || user[:created_at] || user[:createdAt]
345
+ }
346
+ end
347
+
348
+ # Persist session tokens from API response.
349
+ def persist_session(data)
350
+ session = {
351
+ access_token: data['access_token'] || data[:access_token] || '',
352
+ refresh_token: data['refresh_token'] || data[:refresh_token] || '',
353
+ token_type: data['token_type'] || data[:token_type] || 'bearer',
354
+ expires_in: data['expires_in'] || data[:expires_in] || 0
355
+ }
356
+
357
+ @access_token = session[:access_token]
358
+ @refresh_token = session[:refresh_token]
359
+
360
+ session
361
+ end
362
+
363
+ # Make HTTP request to API.
364
+ def request(method, path, params = nil, json = nil)
365
+ begin
366
+ response = @conn.public_send(method.downcase, path) do |req|
367
+ req.params = params if params
368
+ req.body = json if json
369
+ end
370
+
371
+ if response.status >= 400
372
+ error_data = response.body.is_a?(Hash) ? response.body : {}
373
+ error_msg = error_data['detail'] || error_data['message'] || error_data['error'] || "Request failed with status #{response.status}"
374
+ raise WOWSQLException.new(error_msg, response.status, error_data)
375
+ end
376
+
377
+ response.body || {}
378
+ rescue Faraday::Error => e
379
+ raise WOWSQLException.new("Request failed: #{e.message}")
380
+ end
381
+ end
382
+ end
383
+ end
384
+
@@ -5,14 +5,26 @@ module WOWSQL
5
5
  @client = client
6
6
  @table_name = table_name
7
7
  @params = {}
8
+ @select_columns = nil
9
+ @filters = []
10
+ @group_by = nil
11
+ @having = []
12
+ @order_items = nil
8
13
  end
9
14
 
10
15
  def select(*columns)
11
- if columns.length == 1 && columns[0] == '*'
12
- @params['select'] = '*'
13
- else
14
- @params['select'] = columns.join(',')
15
- end
16
+ @select_columns = columns
17
+ self
18
+ end
19
+
20
+ # Add a filter condition (generic method).
21
+ #
22
+ # @param column [String] Column name
23
+ # @param operator [String] Operator (eq, neq, gt, gte, lt, lte, like, in, not_in, between, not_between, is, is_not)
24
+ # @param value [Object] Filter value
25
+ # @param logical_op [String] Logical operator for combining with previous filters ("AND" or "OR")
26
+ def filter(column, operator, value, logical_op = 'AND')
27
+ add_filter(column, operator, value, logical_op)
16
28
  self
17
29
  end
18
30
 
@@ -52,7 +64,52 @@ module WOWSQL
52
64
  end
53
65
 
54
66
  def is_null(column)
55
- add_filter(column, 'is', nil)
67
+ @filters << { column: column, operator: 'is', value: nil, logical_op: 'AND' }
68
+ self
69
+ end
70
+
71
+ def is_not_null(column)
72
+ @filters << { column: column, operator: 'is_not', value: nil, logical_op: 'AND' }
73
+ self
74
+ end
75
+
76
+ def in(column, values)
77
+ @filters << { column: column, operator: 'in', value: values, logical_op: 'AND' }
78
+ self
79
+ end
80
+
81
+ def not_in(column, values)
82
+ @filters << { column: column, operator: 'not_in', value: values, logical_op: 'AND' }
83
+ self
84
+ end
85
+
86
+ def between(column, min, max)
87
+ @filters << { column: column, operator: 'between', value: [min, max], logical_op: 'AND' }
88
+ self
89
+ end
90
+
91
+ def not_between(column, min, max)
92
+ @filters << { column: column, operator: 'not_between', value: [min, max], logical_op: 'AND' }
93
+ self
94
+ end
95
+
96
+ def or(column, operator, value)
97
+ @filters << { column: column, operator: operator, value: value, logical_op: 'OR' }
98
+ self
99
+ end
100
+
101
+ def group_by(*columns)
102
+ @group_by = columns
103
+ self
104
+ end
105
+
106
+ def having(column, operator, value)
107
+ @having << { column: column, operator: operator, value: value }
108
+ self
109
+ end
110
+
111
+ def order_by_multiple(*items)
112
+ @order_items = items
56
113
  self
57
114
  end
58
115
 
@@ -62,6 +119,11 @@ module WOWSQL
62
119
  self
63
120
  end
64
121
 
122
+ # Order results by column (alias for order_by, backward compatibility).
123
+ def order(column, direction = 'asc')
124
+ order_by(column, desc: direction.downcase == 'desc')
125
+ end
126
+
65
127
  def limit(limit)
66
128
  @params['limit'] = limit.to_s
67
129
  self
@@ -73,7 +135,27 @@ module WOWSQL
73
135
  end
74
136
 
75
137
  def get
76
- @client.request('GET', "/#{@table_name}", @params, nil)
138
+ # Check if we need POST endpoint (advanced features)
139
+ has_advanced_features =
140
+ (@group_by && !@group_by.empty?) ||
141
+ !@having.empty? ||
142
+ (@order_items && !@order_items.empty?) ||
143
+ @filters.any? { |f| ['in', 'not_in', 'between', 'not_between'].include?(f[:operator]) }
144
+
145
+ if has_advanced_features
146
+ # Use POST endpoint for advanced queries
147
+ body = build_query_body
148
+ @client.request('POST', "/#{@table_name}/query", nil, body)
149
+ else
150
+ # Use GET endpoint for simple queries (backward compatibility)
151
+ build_get_params
152
+ @client.request('GET', "/#{@table_name}", @params, nil)
153
+ end
154
+ end
155
+
156
+ # Execute the query (alias for get).
157
+ def execute
158
+ get
77
159
  end
78
160
 
79
161
  def first
@@ -84,12 +166,49 @@ module WOWSQL
84
166
  private
85
167
 
86
168
  def add_filter(column, op, value)
87
- filter_value = "#{column}.#{op}.#{value || 'null'}"
88
-
89
- if @params['filter']
90
- @params['filter'] += ",#{filter_value}"
91
- else
92
- @params['filter'] = filter_value
169
+ @filters << { column: column, operator: op, value: value, logical_op: 'AND' }
170
+ end
171
+
172
+ def build_query_body
173
+ body = {}
174
+
175
+ body['select'] = @select_columns if @select_columns && !@select_columns.empty?
176
+
177
+ body['filters'] = @filters if !@filters.empty?
178
+
179
+ body['group_by'] = @group_by if @group_by && !@group_by.empty?
180
+
181
+ body['having'] = @having if !@having.empty?
182
+
183
+ if @order_items && !@order_items.empty?
184
+ body['order_by'] = @order_items.map do |item|
185
+ if item.is_a?(Hash)
186
+ item
187
+ else
188
+ { column: item[0], direction: item[1] }
189
+ end
190
+ end
191
+ elsif @params['order']
192
+ body['order_by'] = @params['order']
193
+ body['order_direction'] = @params['order_direction'] || 'asc'
194
+ end
195
+
196
+ body['limit'] = @params['limit'].to_i if @params['limit']
197
+ body['offset'] = @params['offset'].to_i if @params['offset']
198
+
199
+ body
200
+ end
201
+
202
+ def build_get_params
203
+ @params['select'] = @select_columns.join(',') if @select_columns && !@select_columns.empty?
204
+
205
+ simple_filters = @filters.reject { |f| ['in', 'not_in', 'between', 'not_between'].include?(f[:operator]) }
206
+ if !simple_filters.empty?
207
+ filter_strings = simple_filters.map do |f|
208
+ value_str = f[:value] || 'null'
209
+ "#{f[:column]}.#{f[:operator]}.#{value_str}"
210
+ end
211
+ @params['filter'] = filter_strings.join(',')
93
212
  end
94
213
  end
95
214
  end
@@ -0,0 +1,229 @@
1
+ require 'faraday'
2
+ require 'faraday/multipart'
3
+ require 'json'
4
+ require 'stringio'
5
+ require 'uri'
6
+ require_relative 'exceptions'
7
+
8
+ module WOWSQL
9
+ # WOWSQL Storage Client - Manage S3 storage with automatic quota validation.
10
+ class WOWSQLStorage
11
+ def initialize(project_slug, api_key, base_url = 'https://api.wowsql.com', timeout = 60, auto_check_quota = true)
12
+ @project_slug = project_slug
13
+ @api_key = api_key
14
+ @base_url = base_url.chomp('/')
15
+ @timeout = timeout
16
+ @auto_check_quota = auto_check_quota
17
+ @quota_cache = nil
18
+
19
+ @conn = Faraday.new(url: @base_url) do |f|
20
+ f.request :multipart
21
+ f.request :json
22
+ f.response :json
23
+ f.adapter Faraday.default_adapter
24
+ f.options.timeout = timeout
25
+ end
26
+
27
+ @conn.headers['Authorization'] = "Bearer #{api_key}"
28
+ end
29
+
30
+ # Get storage quota information.
31
+ #
32
+ # @param force_refresh [Boolean] Force refresh quota from server
33
+ # @return [Hash] Storage quota details
34
+ def get_quota(force_refresh = false)
35
+ return @quota_cache if @quota_cache && !force_refresh
36
+
37
+ response = request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/quota", nil, nil)
38
+ @quota_cache = response
39
+ response
40
+ end
41
+
42
+ # Upload file from local filesystem path.
43
+ #
44
+ # @param file_path [String] Path to local file
45
+ # @param file_key [String] File name or path in bucket
46
+ # @param folder [String, nil] Optional folder path
47
+ # @param content_type [String, nil] Optional content type
48
+ # @param check_quota [Boolean, nil] Override auto quota checking
49
+ # @return [Hash] Upload result
50
+ def upload_from_path(file_path, file_key = nil, folder = nil, content_type = nil, check_quota = nil)
51
+ unless File.exist?(file_path)
52
+ raise StorageException.new("File not found: #{file_path}")
53
+ end
54
+
55
+ file_key ||= File.basename(file_path)
56
+ upload_file(File.open(file_path, 'rb'), file_key, folder, content_type, check_quota)
57
+ end
58
+
59
+ # Upload file from file object or IO stream.
60
+ #
61
+ # @param file_data [IO, String] File object or file path
62
+ # @param file_key [String] File name or path in bucket
63
+ # @param folder [String, nil] Optional folder path
64
+ # @param content_type [String, nil] Optional content type
65
+ # @param check_quota [Boolean, nil] Override auto quota checking
66
+ # @return [Hash] Upload result
67
+ def upload_file(file_data, file_key, folder = nil, content_type = nil, check_quota = nil)
68
+ should_check = check_quota.nil? ? @auto_check_quota : check_quota
69
+
70
+ # Read file data
71
+ file_bytes = file_data.respond_to?(:read) ? file_data.read : File.read(file_data)
72
+ file_size = file_bytes.bytesize
73
+
74
+ # Check quota if enabled
75
+ if should_check
76
+ quota = get_quota(true)
77
+ file_size_gb = file_size / (1024.0 * 1024.0 * 1024.0)
78
+ if file_size_gb > quota['storage_available_gb']
79
+ raise StorageLimitExceededException.new(
80
+ "Storage limit exceeded! File size: #{format('%.4f', file_size_gb)} GB, " \
81
+ "Available: #{format('%.4f', quota['storage_available_gb'])} GB."
82
+ )
83
+ end
84
+ end
85
+
86
+ # Build URL
87
+ url = "/api/v1/storage/s3/projects/#{@project_slug}/upload"
88
+ url += "?folder=#{URI.encode_www_form_component(folder)}" if folder
89
+
90
+ begin
91
+ # Create multipart form
92
+ payload = {
93
+ file: Faraday::Multipart::FilePart.new(
94
+ StringIO.new(file_bytes),
95
+ content_type || 'application/octet-stream',
96
+ file_key
97
+ ),
98
+ key: file_key
99
+ }
100
+ payload[:content_type] = content_type if content_type
101
+
102
+ response = @conn.post(url) do |req|
103
+ req.body = payload
104
+ end
105
+
106
+ if response.status >= 400
107
+ handle_error(response)
108
+ end
109
+
110
+ @quota_cache = nil # Refresh quota cache
111
+ response.body
112
+ rescue Faraday::Error => e
113
+ raise StorageException.new("Upload failed: #{e.message}")
114
+ end
115
+ end
116
+
117
+ # List files in S3 bucket.
118
+ #
119
+ # @param prefix [String, nil] Filter by prefix/folder
120
+ # @param max_keys [Integer] Maximum files to return
121
+ # @return [Array<Hash>] List of file objects
122
+ def list_files(prefix = nil, max_keys = 1000)
123
+ params = { max_keys: max_keys }
124
+ params[:prefix] = prefix if prefix
125
+
126
+ response = request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/files", params, nil)
127
+ response['files'] || []
128
+ end
129
+
130
+ # Delete a file from S3 bucket.
131
+ #
132
+ # @param file_key [String] Path to file in bucket
133
+ # @return [Hash] Deletion result
134
+ def delete_file(file_key)
135
+ response = request('DELETE', "/api/v1/storage/s3/projects/#{@project_slug}/files/#{file_key}", nil, nil)
136
+ @quota_cache = nil # Refresh quota cache
137
+ response
138
+ end
139
+
140
+ # Get presigned URL for file access with full metadata.
141
+ #
142
+ # @param file_key [String] Path to file in bucket
143
+ # @param expires_in [Integer] URL validity in seconds (default: 3600 = 1 hour)
144
+ # @return [Hash] Dict with file URL and metadata
145
+ def get_file_url(file_key, expires_in = 3600)
146
+ params = { expires_in: expires_in }
147
+ request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/files/#{file_key}/url", params, nil)
148
+ end
149
+
150
+ # Generate presigned URL for file operations.
151
+ #
152
+ # @param file_key [String] Path to file in bucket
153
+ # @param expires_in [Integer] URL validity in seconds (default: 3600)
154
+ # @param operation [String] 'get_object' (download) or 'put_object' (upload)
155
+ # @return [String] Presigned URL string
156
+ def get_presigned_url(file_key, expires_in = 3600, operation = 'get_object')
157
+ payload = {
158
+ file_key: file_key,
159
+ expires_in: expires_in,
160
+ operation: operation
161
+ }
162
+
163
+ response = request('POST', "/api/v1/storage/s3/projects/#{@project_slug}/presigned-url", nil, payload)
164
+ response['url'] || ''
165
+ end
166
+
167
+ # Get S3 storage information for the project.
168
+ #
169
+ # @return [Hash] Dict with storage info
170
+ def get_storage_info
171
+ request('GET', "/api/v1/storage/s3/projects/#{@project_slug}/info", nil, nil)
172
+ end
173
+
174
+ # Provision S3 storage for the project.
175
+ # ⚠️ IMPORTANT: Save the credentials returned! They're only shown once.
176
+ #
177
+ # @param region [String] AWS region (default: 'us-east-1')
178
+ # @return [Hash] Dict with provisioning result including credentials
179
+ def provision_storage(region = 'us-east-1')
180
+ payload = { region: region }
181
+ request('POST', "/api/v1/storage/s3/projects/#{@project_slug}/provision", nil, payload)
182
+ end
183
+
184
+ # Get list of available S3 regions with pricing.
185
+ #
186
+ # @return [Array<Hash>] List of region dictionaries with pricing info
187
+ def get_available_regions
188
+ response = request('GET', '/api/v1/storage/s3/regions', nil, nil)
189
+
190
+ # Handle both list and map responses
191
+ return response if response.is_a?(Array)
192
+ return response['regions'] if response.is_a?(Hash) && response['regions']
193
+ [response]
194
+ end
195
+
196
+ private
197
+
198
+ # Make HTTP request to Storage API.
199
+ def request(method, path, params = nil, json = nil)
200
+ begin
201
+ response = @conn.public_send(method.downcase, path) do |req|
202
+ req.params = params if params
203
+ req.body = json if json
204
+ end
205
+
206
+ if response.status >= 400
207
+ handle_error(response)
208
+ end
209
+
210
+ response.body
211
+ rescue Faraday::Error => e
212
+ raise StorageException.new("Request failed: #{e.message}")
213
+ end
214
+ end
215
+
216
+ # Handle HTTP errors
217
+ def handle_error(response)
218
+ error_data = response.body.is_a?(Hash) ? response.body : {}
219
+ error_msg = error_data['detail'] || error_data['message'] || "Request failed with status #{response.status}"
220
+
221
+ if response.status == 413
222
+ raise StorageLimitExceededException.new(error_msg, response.status, error_data)
223
+ end
224
+
225
+ raise StorageException.new(error_msg, response.status, error_data)
226
+ end
227
+ end
228
+ end
229
+
data/lib/wowsql/table.rb CHANGED
@@ -39,6 +39,14 @@ module WOWSQL
39
39
  @client.request('POST', "/#{@table_name}", nil, data)
40
40
  end
41
41
 
42
+ # Insert a new record (alias for create).
43
+ #
44
+ # @param data [Hash] Record data
45
+ # @return [Hash] Create response with new record ID
46
+ def insert(data)
47
+ create(data)
48
+ end
49
+
42
50
  # Update a record by ID.
43
51
  #
44
52
  # @param record_id [Object] Record ID
data/lib/wowsql.rb CHANGED
@@ -3,6 +3,7 @@ require_relative 'wowsql/exceptions'
3
3
  require_relative 'wowsql/client'
4
4
  require_relative 'wowsql/table'
5
5
  require_relative 'wowsql/query_builder'
6
+ require_relative 'wowsql/auth'
6
7
 
7
8
  module WOWSQL
8
9
  # Main module for WOWSQL SDK
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wowsql-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - WOWSQL Team
@@ -77,9 +77,11 @@ files:
77
77
  - README.md
78
78
  - lib/wowmysql.rb
79
79
  - lib/wowsql.rb
80
+ - lib/wowsql/auth.rb
80
81
  - lib/wowsql/client.rb
81
82
  - lib/wowsql/exceptions.rb
82
83
  - lib/wowsql/query_builder.rb
84
+ - lib/wowsql/storage.rb
83
85
  - lib/wowsql/table.rb
84
86
  - lib/wowsql/version.rb
85
87
  homepage: https://github.com/wowsql/wowsql-sdk-ruby
@@ -100,7 +102,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
100
102
  - !ruby/object:Gem::Version
101
103
  version: '0'
102
104
  requirements: []
103
- rubygems_version: 3.6.9
105
+ rubygems_version: 4.0.2
104
106
  specification_version: 4
105
107
  summary: Official Ruby client library for WOWSQL - MySQL Backend-as-a-Service with
106
108
  S3 Storage