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 +4 -4
- data/lib/wowsql/auth.rb +384 -0
- data/lib/wowsql/query_builder.rb +132 -13
- data/lib/wowsql/storage.rb +229 -0
- data/lib/wowsql/table.rb +8 -0
- data/lib/wowsql.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f59248127728b52972504dd30e3f03ce86f0edaa2070f3988bf01ca9ee6485d
|
|
4
|
+
data.tar.gz: 1475a5cf06884e86dd4aeee607d16969ed02688383401cea40c59f8228cf5d8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc0f99522a2d2c1753a72faed398a8bafee6336ab3be65a275829db58ce100bf62b55b6a36e2519537b21173cf72f0b49b9ca2fdf2a87bd79ba74834e7f788bf
|
|
7
|
+
data.tar.gz: 76b3cc58a7587f4ca3ad16aa2e5bd09a031cfdafdb46763d346d38122a45a4b128db4bb3b36498c50b8c3d44e14d55771513d92eb89d959fdbfda576b30d0bc4
|
data/lib/wowsql/auth.rb
ADDED
|
@@ -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
|
+
|
data/lib/wowsql/query_builder.rb
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
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.
|
|
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:
|
|
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
|