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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +79 -0
- data/LICENSE +21 -0
- data/README.md +267 -0
- data/TUTORIAL.md +271 -0
- data/examples/app.rb +218 -0
- data/examples/config.ru +2 -0
- data/lib/roda/plugins/kabk/helpers.rb +326 -0
- data/lib/roda/plugins/kabk.rb +53 -0
- data/public/assets/scripts.js +8638 -0
- data/public/assets/style.css +1 -0
- data/public/index.html +126 -0
- data/public/simurgh-logo.svg +33 -0
- data/roda-kabk.svg +118 -0
- metadata +85 -0
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
|
data/examples/config.ru
ADDED
|
@@ -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
|