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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +88 -0
- data/LICENSE +21 -0
- data/README.md +188 -0
- data/TUTORIAL.md +227 -0
- data/examples/app.rb +128 -0
- data/examples/config.ru +2 -0
- data/lib/roda/plugins/kabk/route_tree.rb +314 -0
- data/lib/roda/plugins/kabk.rb +41 -0
- data/public/assets/scripts.js +8638 -0
- data/public/assets/style.css +1 -0
- data/public/index.html +119 -0
- data/public/simurgh-logo.svg +33 -0
- data/roda-kabk.svg +118 -0
- metadata +85 -0
|
@@ -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
|