roda-kabk 1.0.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.
data/examples/app.rb ADDED
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "logger"
5
+ require "roda"
6
+ require "sequel"
7
+ require "kabk"
8
+ require "roda/plugins/kabk"
9
+
10
+ DB = Sequel.sqlite
11
+ DB.extension :pagination
12
+ DB.logger = Logger.new($stdout)
13
+
14
+ DB.create_table :users do
15
+ primary_key :id
16
+ String :full_name
17
+ String :email
18
+ String :avatar
19
+ String :role, default: "editor"
20
+ String :bio
21
+ TrueClass :is_active, default: true
22
+ DateTime :created_at
23
+ DateTime :updated_at
24
+ end
25
+
26
+ DB.create_table :categories do
27
+ primary_key :id
28
+ String :title
29
+ String :slug
30
+ String :color_code
31
+ end
32
+
33
+ DB.create_table :news_items do
34
+ primary_key :id
35
+ String :title
36
+ Integer :author_id
37
+ String :category_ids
38
+ String :cover_image
39
+ String :gallery
40
+ String :content
41
+ TrueClass :published, default: false
42
+ DateTime :publish_date
43
+ DateTime :updated_at
44
+ DateTime :created_at
45
+ end
46
+
47
+ class User < Sequel::Model; end
48
+ class Category < Sequel::Model; end
49
+ class NewsItem < Sequel::Model; end
50
+
51
+ Kabk.register(name: "user", table: User) do
52
+ title en: "User Directory", fa: "مدیریت کاربران"
53
+ plural_name "users"
54
+ icon "Users"
55
+ api_path "/api/admin/users"
56
+ searchable_fields ["full_name", "email"]
57
+ sortable_fields ["id", "full_name", "role", "created_at"]
58
+ concurrency_field "updated_at"
59
+
60
+ field :id, type: :number, form_type: :number, primary_key: true, hidden_in_form: true
61
+ field :full_name, type: :string, form_type: :text, required: true, validation: { min_length: 3, max_length: 100 }
62
+ field :email, type: :string, form_type: :text, required: true, validation: { unique: true }
63
+ field :avatar, type: :file, form_type: :image_single, display_as: :thumbnail
64
+ field :role, type: :string, form_type: :select, required: true, display_as: :badge, default_value: "editor"
65
+ field :bio, type: :string, form_type: :textarea, hidden_in_table: true, accordion: true
66
+ field :is_active, type: :boolean, form_type: :switch, display_as: :boolean_icon, default_value: true
67
+ field :created_at, type: :date, form_type: :date, readonly: true, hidden_in_form: true
68
+ end
69
+
70
+ Kabk.register(name: "category", table: Category) do
71
+ title en: "Categories", fa: "دسته‌بندی‌ها"
72
+ plural_name "categories"
73
+ icon "Tags"
74
+ api_path "/api/admin/categories"
75
+ searchable_fields ["title", "slug"]
76
+
77
+ field :id, type: :number, form_type: :number, primary_key: true, hidden_in_form: true
78
+ field :title, type: :string, form_type: :text, required: true
79
+ field :slug, type: :string, form_type: :text, required: true
80
+ field :color_code, type: :string, form_type: :text, display_as: :badge
81
+ end
82
+
83
+ Kabk.register(name: "news_item", table: NewsItem) do
84
+ title en: "News & Announcements", fa: "اخبار و اطلاعیه‌ها"
85
+ plural_name "news"
86
+ icon "Newspaper"
87
+ api_path "/api/admin/news"
88
+ searchable_fields ["title", "content"]
89
+ concurrency_field "updated_at"
90
+
91
+ field :id, type: :number, form_type: :number, primary_key: true, hidden_in_form: true
92
+ field :title, type: :string, form_type: :text, required: true
93
+ field :author_id, type: :relation, form_type: :relation_select, relation: { resource: "user", cardinality: "many_to_one", value_field: "id", label_field: "full_name" }
94
+ field :category_ids, type: :relation, form_type: :multiselect, relation: { resource: "category", cardinality: "many_to_many", value_field: "id", label_field: "title" }
95
+ field :cover_image, type: :file, form_type: :image_single, display_as: :thumbnail
96
+ field :content, type: :string, form_type: :wysiwyg, hidden_in_table: true, accordion: true
97
+ field :published, type: :boolean, form_type: :switch, display_as: :boolean_icon
98
+ field :publish_date, type: :datetime, form_type: :datetime, depends_on: { field: "published", value: true }
99
+ field :updated_at, type: :datetime, form_type: :datetime, readonly: true, hidden_in_form: true
100
+ end
101
+
102
+ User.create(full_name: "Admin User", email: "admin@example.com", role: "admin")
103
+ User.create(full_name: "John Doe", email: "john@example.com", role: "editor")
104
+ Category.create(title: "Announcements", slug: "announcements")
105
+ Category.create(title: "Events", slug: "events")
106
+ NewsItem.create(title: "First Post", author_id: 2, category_ids: "[1,2]", content: "Hello World", updated_at: Time.now)
107
+
108
+ module AppAuth
109
+ def self.verify_credentials(email, _password)
110
+ User.first(email: email)
111
+ end
112
+ end
113
+
114
+ def render_root_page
115
+ <<~HTML
116
+ <!DOCTYPE html>
117
+ <html>
118
+ <head><title>Simurgh Demo</title></head>
119
+ <body>
120
+ <h1>Simurgh Admin Demo</h1>
121
+ <p><a href="/admin">Go to Admin Dashboard</a></p>
122
+ </body>
123
+ </html>
124
+ HTML
125
+ end
126
+
127
+ class App < Roda
128
+ plugin :sessions, secret: ENV.fetch("SESSION_SECRET", "super_secret_session_key_longer_than_64_bytes_1234567890")
129
+ plugin :json
130
+ plugin :kabk,
131
+ system_config: {
132
+ title: { en: "Simurgh Panel", fa: "پنل مدیریت سیمرغ" },
133
+ default_locale: "en",
134
+ supported_locales: ["en", "fa"],
135
+ direction: "ltr",
136
+ show_demo_credentials: false,
137
+ logo_url: "/simurgh-logo.svg",
138
+ endpoints: {
139
+ upload: "/api/admin/uploads"
140
+ },
141
+ auth: {
142
+ show_demo_credentials: false,
143
+ login_url: "/api/admin/auth/login",
144
+ logout_url: "/api/admin/auth/logout",
145
+ me_url: "/api/admin/auth/me"
146
+ }
147
+ }
148
+
149
+ route do |r|
150
+ # Global static assets (CSS, JS, Logo)
151
+ r.kabk.statics
152
+
153
+ # Vue.js SPA Dashboard
154
+ r.on "admin" do
155
+ r.is do
156
+ r.kabk.server
157
+ end
158
+
159
+ r.get do
160
+ r.kabk.server
161
+ end
162
+ end
163
+
164
+ # API Backend & Smart Dispatch
165
+ r.on "api" do
166
+ r.on "admin" do
167
+ r.get "schema" do
168
+ r.kabk.schema
169
+ end
170
+
171
+ r.post "uploads" do
172
+ r.kabk.upload
173
+ end
174
+
175
+ r.on "auth" do
176
+ r.post "login" do
177
+ body = JSON.parse(r.body.read) rescue {}
178
+ user = AppAuth.verify_credentials(body["email"], body["password"])
179
+ if user
180
+ session[:user_id] = user.id
181
+ { success: true, user: { id: user.id, full_name: user.full_name, email: user.email, role: user.role } }
182
+ else
183
+ response.status = 401
184
+ { success: false, error: { message: "Invalid credentials" } }
185
+ end
186
+ end
187
+
188
+ r.get "me" do
189
+ user = User[session[:user_id]]
190
+ if user
191
+ { success: true, user: { id: user.id, full_name: user.full_name, email: user.email, role: user.role } }
192
+ else
193
+ response.status = 401
194
+ { success: false, error: { message: "Unauthorized" } }
195
+ end
196
+ end
197
+
198
+ r.post "logout" do
199
+ session.clear
200
+ { success: true, message: "Logged out" }
201
+ end
202
+ end
203
+
204
+ current_user = User[session[:user_id]]
205
+ unless current_user
206
+ response.status = 401
207
+ r.halt({ success: false, error: { message: "Unauthorized" } }.to_json)
208
+ end
209
+
210
+ r.kabk.route(context: { current_user: current_user })
211
+ end
212
+ end
213
+
214
+ r.root do
215
+ render_root_page
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "app"
2
+ run App.freeze.app
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "kabk"
5
+ require "kabk/rest_engine"
6
+ require "kabk/schema_renderer"
7
+
8
+ module RodaPlugins
9
+ module Kabk
10
+ # Helper object providing Smart Dispatch routing, static asset serving, and utility endpoints for Kabk.
11
+ class Helpers
12
+ # Absolute directory path to bundled Simurgh frontend public assets.
13
+ PUBLIC_DIR = File.expand_path("../../../../public", __dir__)
14
+
15
+ # @return [Roda::RodaRequest] The current Roda request instance.
16
+ attr_reader :request
17
+
18
+ # @return [Hash] Configured plugin options.
19
+ attr_reader :options
20
+
21
+ @cached_html = {}
22
+ @cache_mutex = Mutex.new
23
+
24
+ # Clears the cached HTML templates in memory.
25
+ #
26
+ # @return [void]
27
+ def self.reset_cache!
28
+ @cache_mutex.synchronize { @cached_html.clear }
29
+ end
30
+
31
+ # Returns the memoized index.html content with embedded schema for the given options.
32
+ #
33
+ # @param opts [Hash] Plugin configuration options.
34
+ # @return [String, nil] The processed HTML string or nil if index.html is missing.
35
+ def self.cached_index_html(opts = {})
36
+ cache_key = (opts[:system_config] || {}).hash
37
+ @cache_mutex.synchronize do
38
+ @cached_html[cache_key] ||= build_index_html(opts)
39
+ end
40
+ end
41
+
42
+ # Merges default auth and endpoints configuration into system_config.
43
+ #
44
+ # @param opts [Hash] Plugin configuration options.
45
+ # @return [Hash] Normalized system configuration hash.
46
+ def self.build_system_config(opts = {})
47
+ raw_config = (opts[:system_config] || {}).dup
48
+ endpoints = { upload: "/uploads" }.merge(raw_config[:endpoints] || {})
49
+
50
+ auth_defaults = {
51
+ strategy: "session",
52
+ sso_redirect_url: nil,
53
+ login_url: "/auth/login",
54
+ logout_url: "/auth/logout",
55
+ me_url: "/auth/me",
56
+ refresh_url: "/auth/refresh",
57
+ show_demo_credentials: false,
58
+ login_fields: [
59
+ {
60
+ name: "username",
61
+ label: { en: "Username", fa: "نام کاربری" },
62
+ placeholder: { en: "Enter your username...", fa: "نام کاربری خود را وارد کنید..." },
63
+ type: "text",
64
+ required: true
65
+ },
66
+ {
67
+ name: "password",
68
+ label: { en: "Password", fa: "کلمه عبور" },
69
+ placeholder: { en: "Enter your password...", fa: "کلمه عبور خود را وارد کنید..." },
70
+ type: "password",
71
+ required: true
72
+ }
73
+ ]
74
+ }
75
+ auth = auth_defaults.merge(raw_config[:auth] || {})
76
+
77
+ base_defaults = {
78
+ direction: "ltr",
79
+ default_locale: "en",
80
+ supported_locales: ["en", "fa"],
81
+ show_demo_credentials: false
82
+ }
83
+
84
+ base_defaults.merge(raw_config).merge(endpoints: endpoints, auth: auth)
85
+ end
86
+
87
+ # Generates index.html content with injected APP_CONFIG and dynamic schema manifest.
88
+ #
89
+ # @param opts [Hash] Plugin configuration options.
90
+ # @return [String, nil] The transformed HTML string or nil if file not found.
91
+ def self.build_index_html(opts = {})
92
+ index_path = File.join(PUBLIC_DIR, "index.html")
93
+ return nil unless File.file?(index_path)
94
+
95
+ content = File.read(index_path)
96
+ sys_config = build_system_config(opts)
97
+ schema_renderer = ::Kabk::SchemaRenderer.new(system_config: sys_config)
98
+ dynamic_schema = schema_renderer.render
99
+ dynamic_schema_json = JSON.pretty_generate(dynamic_schema)
100
+
101
+ content = content.sub(
102
+ %r{<script id="simurgh-schema" type="application/json">.*?</script>}m,
103
+ "<script id=\"simurgh-schema\" type=\"application/json\">\n#{dynamic_schema_json}\n </script>"
104
+ )
105
+
106
+ config_snippet = "window.APP_CONFIG = { baseURL: '' };"
107
+ content.sub(/window\.APP_CONFIG\s*=\s*\{[^}]*\};/m, config_snippet)
108
+ end
109
+
110
+ # Determines MIME content type based on file extension.
111
+ #
112
+ # @param asset_name [String] Name or relative path of the asset.
113
+ # @return [String] Content-Type header string.
114
+ def self.asset_content_type(asset_name)
115
+ case File.extname(asset_name).downcase
116
+ when ".js" then "application/javascript; charset=utf-8"
117
+ when ".css" then "text/css; charset=utf-8"
118
+ when ".svg" then "image/svg+xml"
119
+ when ".woff2" then "font/woff2"
120
+ when ".png" then "image/png"
121
+ when ".ttf" then "font/ttf"
122
+ when ".mp4" then "video/mp4"
123
+ when ".webp" then "image/webp"
124
+ when ".gif" then "image/gif"
125
+ else "application/octet-stream"
126
+ end
127
+ end
128
+
129
+ # Initializes a new Helpers instance.
130
+ #
131
+ # @param request [Roda::RodaRequest] Current Roda request object.
132
+ # @param options [Hash] Plugin configuration options.
133
+ def initialize(request, options = {})
134
+ @request = request
135
+ @options = options || {}
136
+ end
137
+
138
+ # Serves global static assets (JS, CSS, Fonts, Images) from the public directory.
139
+ #
140
+ # @return [void] Halts the Roda request cycle with the static asset response when matched.
141
+ def statics
142
+ request.get "assets", String do |asset_name|
143
+ asset_path = File.join(PUBLIC_DIR, "assets", asset_name)
144
+ if File.file?(asset_path)
145
+ request.response["Content-Type"] = self.class.asset_content_type(asset_name)
146
+ request.halt [200, request.response.headers, [File.read(asset_path)]]
147
+ end
148
+ end
149
+
150
+ request.get "simurgh-logo.svg" do
151
+ logo_path = File.join(PUBLIC_DIR, "simurgh-logo.svg")
152
+ if File.file?(logo_path)
153
+ request.response["Content-Type"] = "image/svg+xml"
154
+ request.halt [200, request.response.headers, [File.read(logo_path)]]
155
+ end
156
+ end
157
+ end
158
+
159
+ # Serves the Vue.js SPA index.html dynamically.
160
+ #
161
+ # @return [void] Halts the Roda request cycle with the HTML response.
162
+ def server
163
+ content = self.class.cached_index_html(options)
164
+ if content
165
+ request.response["Content-Type"] = "text/html; charset=utf-8"
166
+ request.halt [200, request.response.headers, [content]]
167
+ else
168
+ handle_errors { raise ::Kabk::NotFoundError, "Simurgh UI index.html not found" }
169
+ end
170
+ end
171
+
172
+ # Renders and returns the dynamic protocol schema manifest JSON.
173
+ #
174
+ # @return [void] Halts the request cycle with JSON response.
175
+ def schema
176
+ handle_errors do
177
+ sys_config = self.class.build_system_config(options)
178
+ schema_renderer = ::Kabk::SchemaRenderer.new(system_config: sys_config)
179
+ respond_json(schema_renderer.render)
180
+ end
181
+ end
182
+
183
+ # Handles multipart file upload requests.
184
+ #
185
+ # @return [void] Halts the request cycle with upload JSON response.
186
+ def upload
187
+ handle_errors do
188
+ file_param = request.params["file"] || request.params
189
+ upload_handler = options[:upload_handler]
190
+
191
+ if upload_handler.respond_to?(:call)
192
+ result = upload_handler.call(file_param, request)
193
+ respond_json(::Kabk::UploadHandler.format_response(
194
+ url: result[:url] || result["url"],
195
+ file_name: result[:file_name] || result["file_name"] || result[:filename] || result["filename"],
196
+ size: result[:size] || result["size"],
197
+ mime_type: result[:mime_type] || result["mime_type"]
198
+ ))
199
+ elsif file_param.is_a?(Hash) && file_param[:tempfile]
200
+ respond_json(::Kabk::UploadHandler.format_response(
201
+ url: "/uploads/#{file_param[:filename]}",
202
+ file_name: file_param[:filename],
203
+ size: file_param[:tempfile].size,
204
+ mime_type: file_param[:type]
205
+ ))
206
+ else
207
+ raise ::Kabk::ValidationError, "No file uploaded or file parameter missing"
208
+ end
209
+ end
210
+ end
211
+
212
+ # Dispatches the current HTTP request to the matching registered Kabk resource based on api_path.
213
+ #
214
+ # @param context [Hash] Context hash passed to RestEngine lifecycle hooks (e.g. current_user).
215
+ # @return [void, nil] Halts with the REST response or returns nil if no route matches.
216
+ def route(context: {})
217
+ resource, id_param = find_matching_resource
218
+ return unless resource
219
+
220
+ engine = ::Kabk::RestEngine.new(resource.name)
221
+
222
+ if id_param.nil? || id_param.empty?
223
+ if request.get?
224
+ handle_errors do
225
+ respond_json(engine.list(request.params, context: context))
226
+ end
227
+ elsif request.post?
228
+ handle_errors do
229
+ body = parse_json_body
230
+ respond_json(engine.create(body, context: context))
231
+ end
232
+ end
233
+ else
234
+ record_id = id_param =~ /\A\d+\z/ ? id_param.to_i : id_param
235
+
236
+ if request.get?
237
+ handle_errors do
238
+ respond_json(engine.get(record_id, context: context))
239
+ end
240
+ elsif request.put? || request.patch?
241
+ handle_errors do
242
+ body = parse_json_body
243
+ respond_json(engine.update(record_id, body, context: context))
244
+ end
245
+ elsif request.delete?
246
+ handle_errors do
247
+ respond_json(engine.delete(record_id, context: context))
248
+ end
249
+ end
250
+ end
251
+ end
252
+
253
+ private
254
+
255
+ # Finds matching Kabk resource from registry based on request path.
256
+ #
257
+ # @return [Array(Kabk::Resource, String), nil] Tuple of resource and member ID param, or nil.
258
+ def find_matching_resource
259
+ resources = ::Kabk::Registry.instance.all
260
+ sorted_resources = resources.sort_by { |res| -(res.api_path || "").length }
261
+
262
+ path = request.path.chomp("/")
263
+
264
+ sorted_resources.each do |res|
265
+ api_path = res.api_path
266
+ next if api_path.nil? || api_path.empty?
267
+
268
+ norm_api = api_path.start_with?("/") ? api_path : "/#{api_path}"
269
+ norm_api = norm_api.chomp("/")
270
+
271
+ if path == norm_api
272
+ return [res, nil]
273
+ elsif path.start_with?("#{norm_api}/")
274
+ remainder = path.sub("#{norm_api}/", "")
275
+ return [res, remainder] unless remainder.empty?
276
+ end
277
+ end
278
+ nil
279
+ end
280
+
281
+ # Parses JSON request body safely.
282
+ #
283
+ # @return [Hash] Parsed payload hash.
284
+ def parse_json_body
285
+ body_str = request.body.read
286
+ request.body.rewind if request.body.respond_to?(:rewind)
287
+ return {} if body_str.nil? || body_str.strip.empty?
288
+
289
+ JSON.parse(body_str)
290
+ rescue JSON::ParserError
291
+ {}
292
+ end
293
+
294
+ # Halts the request with a JSON response.
295
+ #
296
+ # @param response_hash [Hash] Data to serialize to JSON.
297
+ # @param status_code [Integer] HTTP status code.
298
+ # @return [void]
299
+ def respond_json(response_hash, status_code = 200)
300
+ request.response.status = status_code
301
+ request.response["Content-Type"] = "application/json"
302
+ request.halt [status_code, request.response.headers, [response_hash.to_json]]
303
+ end
304
+
305
+ # Executes the given block and translates Kabk exceptions into standard JSON error responses.
306
+ #
307
+ # @yield The block to execute.
308
+ # @return [Object] Block return value.
309
+ def handle_errors
310
+ yield
311
+ rescue ::Kabk::ValidationError => e
312
+ err = { code: "VALIDATION_ERROR", message: e.message }
313
+ err[:fields] = e.fields if e.respond_to?(:fields)
314
+ respond_json({ success: false, error: err }, 422)
315
+ rescue ::Kabk::NotFoundError => e
316
+ respond_json({ success: false, error: { code: "NOT_FOUND", message: e.message } }, 404)
317
+ rescue StandardError => e
318
+ if e.class.name.start_with?("Kabk::") && e.respond_to?(:to_h) && e.respond_to?(:http_status)
319
+ respond_json(e.to_h, e.http_status)
320
+ else
321
+ respond_json({ success: false, error: { code: "INTERNAL_SERVER_ERROR", message: e.message } }, 500)
322
+ end
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kabk"
4
+ require_relative "kabk/helpers"
5
+
6
+ class Roda
7
+ module RodaPlugins
8
+ # Kabk plugin for Roda — Smart Dispatch routing and Simurgh Panel integration.
9
+ #
10
+ # @example Plugin Registration
11
+ # class App < Roda
12
+ # plugin :kabk,
13
+ # system_config: { logo_url: "/simurgh-logo.svg" },
14
+ # upload_handler: ->(file, req) { { url: "/uploads/#{file[:filename]}" } }
15
+ # end
16
+ module Kabk
17
+ # Loads plugin dependencies into the Roda application.
18
+ #
19
+ # @param app [Class<Roda>] The Roda application class.
20
+ # @param _opts [Hash] Configuration options.
21
+ # @return [void]
22
+ def self.load_dependencies(app, _opts = {})
23
+ app.plugin :all_verbs
24
+ end
25
+
26
+ # Configures the plugin options for the application.
27
+ #
28
+ # @param app [Class<Roda>] The Roda application class.
29
+ # @param opts [Hash] Configuration options.
30
+ # @option opts [Hash, nil] :system_config Custom system branding and localization configuration.
31
+ # @option opts [Proc, #call, nil] :upload_handler Custom file upload handler callable.
32
+ # @return [void]
33
+ def self.configure(app, opts = {})
34
+ app.opts[:kabk] ||= {}
35
+ app.opts[:kabk][:system_config] = opts[:system_config] || nil
36
+ app.opts[:kabk][:upload_handler] = opts[:upload_handler]
37
+ end
38
+
39
+ # Request methods added to the Roda request instance.
40
+ module RequestMethods
41
+ # Returns the Kabk helper object bound to the current request and plugin options.
42
+ #
43
+ # @return [RodaPlugins::Kabk::Helpers] Helper object for static assets, SPA server, and Smart Dispatch routing.
44
+ def kabk
45
+ options = roda_class.opts[:kabk] || (scope.opts[:kabk] if respond_to?(:scope)) || {}
46
+ @kabk_helpers ||= ::RodaPlugins::Kabk::Helpers.new(self, options)
47
+ end
48
+ end
49
+ end
50
+
51
+ register_plugin(:kabk, Kabk)
52
+ end
53
+ end