roda-kabk 0.1.5

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.

Potentially problematic release.


This version of roda-kabk might be problematic. Click here for more details.

@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "kabk/rest_engine"
5
+ require "kabk/schema_renderer"
6
+
7
+ module RodaPlugins
8
+ module Kabk
9
+ # The HTTP glue layer isolating Roda logic from Kabk core logic
10
+ class RouteTree
11
+ PUBLIC_DIR = File.expand_path("../../../../public", __dir__)
12
+
13
+ def self.call(r, opts)
14
+ # Helper to respond with JSON safely
15
+ respond_json = lambda do |response_hash, status_code = 200|
16
+ r.response.status = status_code
17
+ r.response["Content-Type"] = "application/json"
18
+ response_hash.to_json
19
+ end
20
+
21
+ respond_error = lambda do |error|
22
+ respond_json.call(error.to_h, error.http_status)
23
+ end
24
+
25
+ auth_strategy = opts[:auth_strategy]
26
+ serve_ui = opts[:serve_ui] != false
27
+ mount_at = opts[:mount_at] || "/api/admin"
28
+
29
+ is_auth_route = r.path.include?("/auth/")
30
+ is_login_or_refresh = is_auth_route && (r.path.end_with?("/login") || r.path.end_with?("/refresh"))
31
+
32
+ is_ui_request = serve_ui && (
33
+ r.path == mount_at ||
34
+ r.path == "#{mount_at}/" ||
35
+ r.path == "#{mount_at}/index.html" ||
36
+ r.path == "#{mount_at}/ui" ||
37
+ r.path.include?("/assets/") ||
38
+ r.path.end_with?("/simurgh-logo.svg")
39
+ )
40
+
41
+ user_context = nil
42
+ # Middleware for Authentication check if auth strategy is present and route is not login/refresh or UI
43
+ if auth_strategy && !is_login_or_refresh && !is_ui_request
44
+ auth_header = r.env["HTTP_AUTHORIZATION"]
45
+ begin
46
+ raise ::Kabk::UnauthorizedError, "Missing Authorization Header" unless auth_header&.start_with?("Bearer ")
47
+ token = auth_header.split(" ").last
48
+ user_context = auth_strategy.verify_access_token!(token)
49
+ rescue ::Kabk::ApiError => e
50
+ return respond_error.call(e)
51
+ end
52
+ end
53
+
54
+ if serve_ui
55
+ serve_index_html = lambda do
56
+ index_path = File.join(PUBLIC_DIR, "index.html")
57
+ if File.file?(index_path)
58
+ content = File.read(index_path)
59
+ base_url = mount_at
60
+
61
+ # Dynamically render registered Kabk schema
62
+ sys_config = opts[:system_config] || {}
63
+
64
+ # Strip trailing slash if present for clean concatenation, unless it's just '/'
65
+ base = mount_at == "/" ? "" : mount_at
66
+
67
+ sys_config[:endpoints] ||= {
68
+ login: "#{base}/auth/login",
69
+ me: "#{base}/auth/me",
70
+ logout: "#{base}/auth/logout",
71
+ refresh: "#{base}/auth/refresh",
72
+ upload: "#{base}/uploads"
73
+ }
74
+ schema_renderer = ::Kabk::SchemaRenderer.new(system_config: sys_config)
75
+ dynamic_schema = schema_renderer.render
76
+ dynamic_schema_json = JSON.pretty_generate(dynamic_schema)
77
+
78
+ # Replace embedded simurgh-schema script tag content
79
+ content = content.sub(%r{<script id="simurgh-schema" type="application/json">.*?</script>}m, "<script id=\"simurgh-schema\" type=\"application/json\">\n#{dynamic_schema_json}\n </script>")
80
+
81
+ # Set baseURL in APP_CONFIG
82
+ config_snippet = "window.APP_CONFIG = { baseURL: '#{base_url}' };"
83
+ content = content.sub(/window\.APP_CONFIG\s*=\s*\{[^}]*\};/m, config_snippet)
84
+
85
+ if mount_at != "" && mount_at != "/"
86
+ content = content.gsub('href="/assets/', "href=\"#{mount_at}/assets/")
87
+ .gsub('src="/assets/', "src=\"#{mount_at}/assets/")
88
+ .gsub('href="/simurgh-logo.svg"', "href=\"#{mount_at}/simurgh-logo.svg\"")
89
+ .gsub('src="/simurgh-logo.svg"', "src=\"#{mount_at}/simurgh-logo.svg\"")
90
+ end
91
+
92
+ r.response["Content-Type"] = "text/html; charset=utf-8"
93
+ content
94
+ else
95
+ respond_error.call(::Kabk::NotFoundError.new("Simurgh UI index.html not found"))
96
+ end
97
+ end
98
+
99
+ r.get "assets", String do |asset_name|
100
+ asset_path = File.join(PUBLIC_DIR, "assets", asset_name)
101
+ if File.file?(asset_path)
102
+ ext = File.extname(asset_name).downcase
103
+ content_type = case ext
104
+ when ".js" then "application/javascript; charset=utf-8"
105
+ when ".css" then "text/css; charset=utf-8"
106
+ when ".svg" then "image/svg+xml"
107
+ when ".woff2" then "font/woff2"
108
+ when ".png" then "image/png"
109
+ else "application/octet-stream"
110
+ end
111
+ r.response["Content-Type"] = content_type
112
+
113
+ if ext == ".js"
114
+ # Remove hardcoded "/api/admin/" from API calls in the JS bundle
115
+ # because the Axios frontend will prepend the dynamic APP_CONFIG.baseURL anyway.
116
+ content = File.read(asset_path)
117
+ return content.gsub('"/api/admin/', '"/')
118
+ end
119
+
120
+ return File.read(asset_path)
121
+ end
122
+ end
123
+
124
+ r.get "simurgh-logo.svg" do
125
+ logo_path = File.join(PUBLIC_DIR, "simurgh-logo.svg")
126
+ if File.file?(logo_path)
127
+ r.response["Content-Type"] = "image/svg+xml"
128
+ return File.read(logo_path)
129
+ end
130
+ end
131
+
132
+ r.is do
133
+ r.get do
134
+ return serve_index_html.call
135
+ end
136
+ end
137
+
138
+ r.is "index.html" do
139
+ r.get do
140
+ return serve_index_html.call
141
+ end
142
+ end
143
+
144
+ r.is "ui" do
145
+ r.get do
146
+ return serve_index_html.call
147
+ end
148
+ end
149
+ end
150
+
151
+ r.on "auth" do
152
+ r.post "login" do
153
+ body = JSON.parse(r.body.read) rescue {}
154
+ begin
155
+ login_handler = opts[:login_handler]
156
+ raise ::Kabk::ApiError.new("Login handler not configured", code: "NOT_SUPPORTED", http_status: 501) unless login_handler
157
+
158
+ response = auth_strategy.login(body["username"], body["password"]) do |username, password|
159
+ login_handler.call(username, password)
160
+ end
161
+ respond_json.call(response)
162
+ rescue ::Kabk::ApiError => e
163
+ respond_error.call(e)
164
+ end
165
+ end
166
+
167
+ r.post "refresh" do
168
+ body = JSON.parse(r.body.read) rescue {}
169
+ begin
170
+ response = auth_strategy.refresh(body["refresh_token"])
171
+ respond_json.call(response)
172
+ rescue ::Kabk::ApiError => e
173
+ respond_error.call(e)
174
+ end
175
+ end
176
+
177
+ r.get "me" do
178
+ respond_json.call({
179
+ success: true,
180
+ data: user_context
181
+ })
182
+ end
183
+
184
+ r.post "logout" do
185
+ # Basic logout
186
+ respond_json.call({ success: true, message: "Successfully logged out" })
187
+ end
188
+
189
+ r.post "change-password" do
190
+ body = JSON.parse(r.body.read) rescue {}
191
+ begin
192
+ cp_handler = opts[:change_password_handler]
193
+ raise ::Kabk::ApiError.new("Change password handler not configured", code: "NOT_SUPPORTED", http_status: 501) unless cp_handler
194
+
195
+ cp_handler.call(user_context, body["old_password"], body["new_password"])
196
+ respond_json.call({ success: true, message: "Password updated successfully" })
197
+ rescue ::Kabk::ApiError => e
198
+ respond_error.call(e)
199
+ end
200
+ end
201
+ end
202
+
203
+ r.post "uploads" do
204
+ file_param = r.params["file"] || r.params
205
+ upload_handler = opts[:upload_handler]
206
+
207
+ if upload_handler.respond_to?(:call)
208
+ result = upload_handler.call(file_param, r)
209
+ respond_json.call(::Kabk::UploadHandler.format_response(
210
+ url: result[:url] || result["url"],
211
+ file_name: result[:file_name] || result["file_name"] || result[:filename] || result["filename"],
212
+ size: result[:size] || result["size"],
213
+ mime_type: result[:mime_type] || result["mime_type"]
214
+ ))
215
+ elsif file_param.is_a?(Hash) && file_param[:tempfile]
216
+ respond_json.call(::Kabk::UploadHandler.format_response(
217
+ url: "/uploads/#{file_param[:filename]}",
218
+ file_name: file_param[:filename],
219
+ size: file_param[:tempfile].size,
220
+ mime_type: file_param[:type]
221
+ ))
222
+ else
223
+ respond_error.call(::Kabk::ApiError.new("No file uploaded or file parameter missing", code: "BAD_REQUEST", http_status: 400))
224
+ end
225
+ end
226
+
227
+ # Generic Resource Routes
228
+ r.on String do |plural_name|
229
+ # Match plural_name to a registered resource
230
+ resource = ::Kabk::Registry.instance.all.find { |res| res.plural_name == plural_name }
231
+ if resource.nil?
232
+ return respond_error.call(::Kabk::NotFoundError.new("Endpoint not found"))
233
+ end
234
+
235
+ engine = ::Kabk::RestEngine.new(resource.name)
236
+
237
+ r.is do
238
+ # GET /:plural_name
239
+ r.get do
240
+ # We use request.params directly, it parses the query string nicely
241
+ begin
242
+ respond_json.call(engine.list(r.params))
243
+ rescue ::Kabk::ApiError => e
244
+ respond_error.call(e)
245
+ end
246
+ end
247
+
248
+ # POST /:plural_name
249
+ r.post do
250
+ body = JSON.parse(r.body.read) rescue {}
251
+ begin
252
+ respond_json.call(engine.create(body))
253
+ rescue ::Kabk::ApiError => e
254
+ respond_error.call(e)
255
+ end
256
+ end
257
+ end
258
+
259
+ r.on "export" do
260
+ r.get do
261
+ begin
262
+ format = r.params["format"] || "csv"
263
+ # For streaming/buffering
264
+ # Just call ExportHandler
265
+ dataset = ::Kabk::QueryBuilder.build(resource, r.params)
266
+ file_data = ::Kabk::ExportHandler.export(resource, dataset, format: format)
267
+
268
+ r.response["Content-Disposition"] = "attachment; filename=\"#{plural_name}_export.#{format}\""
269
+ r.response["Content-Type"] = format == "csv" ? "text/csv" : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
270
+ file_data
271
+ rescue ::Kabk::ApiError => e
272
+ respond_error.call(e)
273
+ end
274
+ end
275
+ end
276
+
277
+ r.on String do |id|
278
+ record_id = id.to_i
279
+
280
+ r.is do
281
+ # GET /:plural_name/:id
282
+ r.get do
283
+ begin
284
+ respond_json.call(engine.get(record_id))
285
+ rescue ::Kabk::ApiError => e
286
+ respond_error.call(e)
287
+ end
288
+ end
289
+
290
+ # PUT /:plural_name/:id
291
+ r.put do
292
+ body = JSON.parse(r.body.read) rescue {}
293
+ begin
294
+ respond_json.call(engine.update(record_id, body))
295
+ rescue ::Kabk::ApiError => e
296
+ respond_error.call(e)
297
+ end
298
+ end
299
+
300
+ # DELETE /:plural_name/:id
301
+ r.delete do
302
+ begin
303
+ respond_json.call(engine.delete(record_id))
304
+ rescue ::Kabk::ApiError => e
305
+ respond_error.call(e)
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kabk"
4
+ require_relative "kabk/route_tree"
5
+
6
+ class Roda
7
+ module RodaPlugins
8
+ # Kabk plugin for Roda
9
+ module Kabk
10
+ def self.load_dependencies(app, opts = {})
11
+ app.plugin :all_verbs
12
+ end
13
+
14
+ def self.configure(app, opts = {})
15
+ app.opts[:kabk] ||= {}
16
+ app.opts[:kabk][:mount_at] = opts[:mount_at] || "/api/admin"
17
+ app.opts[:kabk][:system_config] = opts[:system_config] || nil
18
+ app.opts[:kabk][:auth_strategy] = opts[:auth_strategy] # E.g., Kabk::Auth::JwtStrategy.new(secret: '...', default_exp: 86400)
19
+ app.opts[:kabk][:login_handler] = opts[:login_handler]
20
+ app.opts[:kabk][:change_password_handler] = opts[:change_password_handler]
21
+ app.opts[:kabk][:upload_handler] = opts[:upload_handler]
22
+ app.opts[:kabk][:serve_ui] = opts.key?(:serve_ui) ? opts[:serve_ui] : true
23
+ end
24
+
25
+ module RequestMethods
26
+ # The route block that mounts all Kabk endpoints
27
+ def kabk
28
+ options = scope.opts[:kabk]
29
+ mount_at = options[:mount_at]
30
+
31
+ # Use Roda routing methods to scope everything under mount_at
32
+ on mount_at.sub(%r{^/}, "") do
33
+ ::RodaPlugins::Kabk::RouteTree.call(self, options)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ register_plugin(:kabk, Kabk)
40
+ end
41
+ end