kabk 0.2.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.
Potentially problematic release.
This version of kabk might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +108 -0
- data/Kabk_logo.svg +33 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/doc/Kabk/Adapters/Base.html +854 -0
- data/doc/Kabk/Adapters/SequelAdapter.html +948 -0
- data/doc/Kabk/Adapters.html +120 -0
- data/doc/Kabk/ApiError.html +511 -0
- data/doc/Kabk/Auth/JwtStrategy.html +551 -0
- data/doc/Kabk/Auth.html +118 -0
- data/doc/Kabk/Concurrency.html +297 -0
- data/doc/Kabk/ConcurrencyConflictError.html +225 -0
- data/doc/Kabk/Error.html +140 -0
- data/doc/Kabk/ExportHandler.html +381 -0
- data/doc/Kabk/Field.html +2160 -0
- data/doc/Kabk/ForbiddenError.html +225 -0
- data/doc/Kabk/InvalidFieldError.html +136 -0
- data/doc/Kabk/InvalidOldPasswordError.html +225 -0
- data/doc/Kabk/NotFoundError.html +225 -0
- data/doc/Kabk/QueryBuilder.html +551 -0
- data/doc/Kabk/Registry.html +608 -0
- data/doc/Kabk/RelationHydrator.html +480 -0
- data/doc/Kabk/Resource.html +1986 -0
- data/doc/Kabk/ResourceBuilder.html +1002 -0
- data/doc/Kabk/RestEngine.html +879 -0
- data/doc/Kabk/SchemaRenderer.html +318 -0
- data/doc/Kabk/UnauthorizedError.html +225 -0
- data/doc/Kabk/UploadHandler.html +308 -0
- data/doc/Kabk/ValidationError.html +225 -0
- data/doc/Kabk/Validator.html +380 -0
- data/doc/Kabk.html +330 -0
- data/doc/_index.html +376 -0
- data/doc/class_list.html +54 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +206 -0
- data/doc/css/style.css +1089 -0
- data/doc/file.README.html +149 -0
- data/doc/file_list.html +59 -0
- data/doc/frames.html +22 -0
- data/doc/index.html +149 -0
- data/doc/js/app.js +801 -0
- data/doc/js/full_list.js +334 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +910 -0
- data/doc/top-level-namespace.html +112 -0
- data/docs/API_REFERENCE.md +241 -0
- data/lib/kabk/adapters/base.rb +45 -0
- data/lib/kabk/adapters/sequel_adapter.rb +125 -0
- data/lib/kabk/concurrency.rb +30 -0
- data/lib/kabk/errors.rb +62 -0
- data/lib/kabk/export_handler.rb +43 -0
- data/lib/kabk/field.rb +97 -0
- data/lib/kabk/query_builder.rb +72 -0
- data/lib/kabk/registry.rb +41 -0
- data/lib/kabk/relation_hydrator.rb +107 -0
- data/lib/kabk/resource.rb +88 -0
- data/lib/kabk/resource_builder.rb +93 -0
- data/lib/kabk/rest_engine.rb +84 -0
- data/lib/kabk/schema_renderer.rb +69 -0
- data/lib/kabk/upload_handler.rb +24 -0
- data/lib/kabk/validator.rb +64 -0
- data/lib/kabk/version.rb +6 -0
- data/lib/kabk.rb +51 -0
- metadata +193 -0
data/lib/kabk/field.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kabk
|
|
4
|
+
# Represents field/column metadata
|
|
5
|
+
class Field
|
|
6
|
+
# @return [String] The technical name of the field (e.g., "created_at").
|
|
7
|
+
attr_accessor :name
|
|
8
|
+
# @return [String] The human-readable label for the field.
|
|
9
|
+
attr_accessor :label
|
|
10
|
+
# @return [Symbol] The underlying data type (:string, :number, :boolean, :date, :datetime, :file, :relation).
|
|
11
|
+
attr_accessor :type
|
|
12
|
+
# @return [Symbol] The frontend input component type (e.g., :text, :switch, :date, :image_single).
|
|
13
|
+
attr_accessor :form_type
|
|
14
|
+
# @return [Symbol] The calendar system to use if type is date/datetime (e.g., :jalali, :gregorian).
|
|
15
|
+
attr_accessor :calendar
|
|
16
|
+
# @return [Boolean] Indicates if this field is the primary key.
|
|
17
|
+
attr_accessor :primary_key
|
|
18
|
+
# @return [Boolean] Indicates if this field can be null.
|
|
19
|
+
attr_accessor :nullable
|
|
20
|
+
# @return [Symbol] UI hint for how to display the value (e.g., :badge, :thumbnail, :boolean_icon).
|
|
21
|
+
attr_accessor :display_as
|
|
22
|
+
# @return [Integer] Bootstrap grid column width (1 to 12).
|
|
23
|
+
attr_accessor :col_width
|
|
24
|
+
# @return [Integer] The explicit ordering weight for rendering.
|
|
25
|
+
attr_accessor :order
|
|
26
|
+
# @return [Boolean] Indicates if this field is mandatory in forms.
|
|
27
|
+
attr_accessor :required
|
|
28
|
+
# @return [Boolean] Indicates if this field is read-only.
|
|
29
|
+
attr_accessor :readonly
|
|
30
|
+
# @return [Boolean] If true, hides the field in data tables.
|
|
31
|
+
attr_accessor :hidden_in_table
|
|
32
|
+
# @return [Boolean] If true, hides the field in forms.
|
|
33
|
+
attr_accessor :hidden_in_form
|
|
34
|
+
# @return [Boolean] If true, wraps this field in an accordion section.
|
|
35
|
+
attr_accessor :accordion
|
|
36
|
+
# @return [Integer] For textarea form_type, specifies the number of rows.
|
|
37
|
+
attr_accessor :rows
|
|
38
|
+
# @return [Array, Hash] Hardcoded select options if applicable.
|
|
39
|
+
attr_accessor :options
|
|
40
|
+
# @return [Object] The default value for the field.
|
|
41
|
+
attr_accessor :default_value
|
|
42
|
+
# @return [Hash] Configures field dependency visibility (e.g., { field: "published", value: true }).
|
|
43
|
+
attr_accessor :depends_on
|
|
44
|
+
# @return [Hash] Server-side validation rules (e.g., { min_length: 5, unique: true }).
|
|
45
|
+
attr_accessor :validation
|
|
46
|
+
# @return [Hash] Relational metadata (e.g., { resource: "user", cardinality: "many_to_one", ... }).
|
|
47
|
+
attr_accessor :relation
|
|
48
|
+
# @return [Hash] Configuration for file uploads.
|
|
49
|
+
attr_accessor :upload_config
|
|
50
|
+
|
|
51
|
+
def initialize(name:, **attributes)
|
|
52
|
+
@name = name.to_s
|
|
53
|
+
@primary_key = false
|
|
54
|
+
@nullable = false
|
|
55
|
+
@col_width = 12
|
|
56
|
+
@required = false
|
|
57
|
+
@readonly = false
|
|
58
|
+
@hidden_in_table = false
|
|
59
|
+
@hidden_in_form = false
|
|
60
|
+
@accordion = false
|
|
61
|
+
|
|
62
|
+
attributes.each do |k, v|
|
|
63
|
+
send("#{k}=", v) if respond_to?("#{k}=")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
hash = {
|
|
69
|
+
name: @name,
|
|
70
|
+
label: @label,
|
|
71
|
+
type: @type,
|
|
72
|
+
form_type: @form_type
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
hash[:calendar] = @calendar if @calendar
|
|
76
|
+
hash[:primary_key] = @primary_key if @primary_key
|
|
77
|
+
hash[:nullable] = @nullable unless @nullable.nil?
|
|
78
|
+
hash[:display_as] = @display_as if @display_as
|
|
79
|
+
hash[:col_width] = @col_width if @col_width
|
|
80
|
+
hash[:order] = @order if @order
|
|
81
|
+
hash[:required] = @required if @required
|
|
82
|
+
hash[:readonly] = @readonly if @readonly
|
|
83
|
+
hash[:hidden_in_table] = @hidden_in_table if @hidden_in_table
|
|
84
|
+
hash[:hidden_in_form] = @hidden_in_form if @hidden_in_form
|
|
85
|
+
hash[:accordion] = @accordion if @accordion
|
|
86
|
+
hash[:rows] = @rows if @rows
|
|
87
|
+
hash[:options] = @options if @options
|
|
88
|
+
hash[:default_value] = @default_value unless @default_value.nil?
|
|
89
|
+
hash[:depends_on] = @depends_on if @depends_on
|
|
90
|
+
hash[:validation] = @validation if @validation
|
|
91
|
+
hash[:relation] = @relation if @relation
|
|
92
|
+
hash[:upload_config] = @upload_config if @upload_config
|
|
93
|
+
|
|
94
|
+
hash
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kabk
|
|
4
|
+
# Handles applying page, sort, search, and filters to a Sequel dataset
|
|
5
|
+
class QueryBuilder
|
|
6
|
+
# @param resource [Kabk::Resource]
|
|
7
|
+
# @param params [Hash] Request query parameters
|
|
8
|
+
# @return [Sequel::Dataset] Paginated dataset
|
|
9
|
+
def self.build(resource, params)
|
|
10
|
+
dataset = resource.model_class.dataset
|
|
11
|
+
|
|
12
|
+
dataset = apply_search(dataset, resource, params["search"])
|
|
13
|
+
dataset = apply_filters(dataset, resource, params["filter"])
|
|
14
|
+
dataset = apply_sort(dataset, resource, params["sort"] || resource.default_sort)
|
|
15
|
+
|
|
16
|
+
apply_pagination(dataset, resource, params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.apply_search(dataset, resource, search_term)
|
|
20
|
+
return dataset if search_term.nil? || search_term.to_s.strip.empty?
|
|
21
|
+
return dataset if resource.searchable_fields.nil? || resource.searchable_fields.empty?
|
|
22
|
+
|
|
23
|
+
# Apply case-insensitive global search across all searchable fields.
|
|
24
|
+
search_conditions = resource.searchable_fields.map do |field|
|
|
25
|
+
Sequel.ilike(field.to_sym, "%#{search_term}%")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
dataset.where(Sequel.|(*search_conditions))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.apply_filters(dataset, _resource, filters)
|
|
32
|
+
return dataset unless filters.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
filters.each do |field, value|
|
|
35
|
+
# Parse comma-separated values to support SQL IN clauses.
|
|
36
|
+
values = value.to_s.split(",")
|
|
37
|
+
dataset = if values.size > 1
|
|
38
|
+
dataset.where(field.to_sym => values)
|
|
39
|
+
else
|
|
40
|
+
dataset.where(field.to_sym => value)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
dataset
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.apply_sort(dataset, resource, sort_term)
|
|
47
|
+
return dataset if sort_term.nil? || sort_term.to_s.strip.empty?
|
|
48
|
+
|
|
49
|
+
is_desc = sort_term.start_with?("-")
|
|
50
|
+
field_name = is_desc ? sort_term[1..] : sort_term
|
|
51
|
+
|
|
52
|
+
# Ensure sorting is only applied to permitted fields.
|
|
53
|
+
if resource.sortable_fields&.include?(field_name)
|
|
54
|
+
col = field_name.to_sym
|
|
55
|
+
dataset = is_desc ? dataset.order(Sequel.desc(col)) : dataset.order(Sequel.asc(col))
|
|
56
|
+
end
|
|
57
|
+
dataset
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.apply_pagination(dataset, resource, params)
|
|
61
|
+
page = (params["page"] || 1).to_i
|
|
62
|
+
per_page = (params["per_page"] || resource.per_page_default).to_i
|
|
63
|
+
|
|
64
|
+
page = 1 if page < 1
|
|
65
|
+
per_page = 15 if per_page < 1
|
|
66
|
+
|
|
67
|
+
# Depends on Sequel's pagination extension (Sequel.extension :pagination).
|
|
68
|
+
# Returns a paginated Sequel::Dataset.
|
|
69
|
+
dataset.extension(:pagination).paginate(page, per_page)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent-ruby"
|
|
4
|
+
|
|
5
|
+
module Kabk
|
|
6
|
+
# Thread-safe registry for managing registered resources
|
|
7
|
+
class Registry
|
|
8
|
+
# @return [Registry]
|
|
9
|
+
def self.instance
|
|
10
|
+
@instance ||= new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@resources = Concurrent::Map.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Register a new resource
|
|
18
|
+
# @param resource [Kabk::Resource]
|
|
19
|
+
def register(resource)
|
|
20
|
+
@resources[resource.name.to_sym] = resource
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Retrieve a resource by name
|
|
24
|
+
# @param name [Symbol, String]
|
|
25
|
+
# @return [Kabk::Resource, nil]
|
|
26
|
+
def get(name)
|
|
27
|
+
@resources[name.to_sym]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Get all registered resources
|
|
31
|
+
# @return [Array<Kabk::Resource>]
|
|
32
|
+
def all
|
|
33
|
+
@resources.values
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Clear registry (useful for testing)
|
|
37
|
+
def clear
|
|
38
|
+
@resources.clear
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
|
|
5
|
+
module Kabk
|
|
6
|
+
# Hydrates `<field>_display` for relation fields to prevent N+1 queries.
|
|
7
|
+
class RelationHydrator
|
|
8
|
+
# Hydrates a collection of records in-memory or via SQL joins/eager loading
|
|
9
|
+
# @param resource [Kabk::Resource]
|
|
10
|
+
# @param records [Array<Hash>, Array<Sequel::Model>]
|
|
11
|
+
# @return [Array<Hash>] Hydrated records as hashes
|
|
12
|
+
def self.hydrate(resource, records)
|
|
13
|
+
return [] if records.empty?
|
|
14
|
+
|
|
15
|
+
# Convert all records to hashes immediately if they are models
|
|
16
|
+
record_hashes = records.map { |r| r.is_a?(Hash) ? r.dup : r.values }
|
|
17
|
+
|
|
18
|
+
relation_fields = resource.fields.select { |f| f.type == "relation" && f.relation }
|
|
19
|
+
|
|
20
|
+
relation_fields.each do |field|
|
|
21
|
+
rel_meta = field.relation
|
|
22
|
+
target_resource = Registry.instance.get(rel_meta["resource"] || rel_meta[:resource])
|
|
23
|
+
next unless target_resource
|
|
24
|
+
|
|
25
|
+
target_model = target_resource.model_class
|
|
26
|
+
value_field = (rel_meta["value_field"] || rel_meta[:value_field]).to_sym
|
|
27
|
+
label_field = (rel_meta["label_field"] || rel_meta[:label_field]).to_sym
|
|
28
|
+
display_key = (rel_meta["display_key"] || rel_meta[:display_key] || "#{field.name}_display").to_sym
|
|
29
|
+
cardinality = rel_meta["cardinality"] || rel_meta[:cardinality] || "many_to_one"
|
|
30
|
+
field_sym = field.name.to_sym
|
|
31
|
+
|
|
32
|
+
# Extract all keys to load
|
|
33
|
+
keys_to_load = record_hashes.flat_map do |rh|
|
|
34
|
+
val = rh[field_sym]
|
|
35
|
+
if val.is_a?(String) && cardinality == "many_to_many" && val.is_a?(String)
|
|
36
|
+
# Handle JSON or comma-separated string values.
|
|
37
|
+
val = begin
|
|
38
|
+
JSON.parse(val)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
val.split(",")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
val
|
|
44
|
+
end.flatten.compact.uniq
|
|
45
|
+
|
|
46
|
+
next if keys_to_load.empty?
|
|
47
|
+
|
|
48
|
+
# Load relation map.
|
|
49
|
+
# Example: SELECT id, full_name FROM users WHERE id IN (...)
|
|
50
|
+
target_data = target_model.where(value_field => keys_to_load).select(value_field, label_field).all
|
|
51
|
+
target_map = target_data.each_with_object({}) do |row, map|
|
|
52
|
+
map[row[value_field]] = row[label_field]
|
|
53
|
+
map[row[value_field].to_s] = row[label_field] # allow string keys too
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Map back to records
|
|
57
|
+
record_hashes.each do |rh|
|
|
58
|
+
val = rh[field_sym]
|
|
59
|
+
if cardinality == "many_to_many"
|
|
60
|
+
arr = if val.is_a?(Array)
|
|
61
|
+
val
|
|
62
|
+
else
|
|
63
|
+
begin
|
|
64
|
+
JSON.parse(val.to_s)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
val.to_s.split(",")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
rh[display_key] = arr.map { |v| target_map[v] }.compact if arr
|
|
70
|
+
elsif val
|
|
71
|
+
rh[display_key] = target_map[val]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Format dates to ISO8601 per standard protocol.
|
|
77
|
+
format_dates!(resource, record_hashes)
|
|
78
|
+
|
|
79
|
+
record_hashes
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.format_dates!(resource, record_hashes)
|
|
83
|
+
date_fields = resource.fields.select { |f| %w[date datetime].include?(f.type) }.map(&:name).map(&:to_sym)
|
|
84
|
+
|
|
85
|
+
record_hashes.each do |rh|
|
|
86
|
+
# Include standard audit/concurrency fields if present and not explicitly defined as fields
|
|
87
|
+
%i[created_at updated_at].each do |ts|
|
|
88
|
+
if rh[ts].respond_to?(:iso8601)
|
|
89
|
+
rh[ts] = rh[ts].iso8601
|
|
90
|
+
elsif rh[ts].is_a?(Time) || rh[ts].is_a?(Date) || rh[ts].is_a?(DateTime)
|
|
91
|
+
# Fallback formatting if it responds to strftime
|
|
92
|
+
rh[ts] = rh[ts].strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
date_fields.each do |df|
|
|
97
|
+
val = rh[df]
|
|
98
|
+
if val.respond_to?(:iso8601)
|
|
99
|
+
rh[df] = val.iso8601
|
|
100
|
+
elsif val.is_a?(Time) || val.is_a?(Date) || val.is_a?(DateTime)
|
|
101
|
+
rh[df] = val.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kabk
|
|
4
|
+
# Metadata value object for a registered resource
|
|
5
|
+
class Resource
|
|
6
|
+
# @return [String] The technical singular name of the resource (e.g., "news_item").
|
|
7
|
+
attr_accessor :name
|
|
8
|
+
# @return [String] The plural name used in routing (e.g., "news").
|
|
9
|
+
attr_accessor :plural_name
|
|
10
|
+
# @return [Hash] Localization hash for the resource title (e.g., { fa: "اخبار", en: "News" }).
|
|
11
|
+
attr_accessor :title
|
|
12
|
+
# @return [String] The identifier of the icon to render in the sidebar.
|
|
13
|
+
attr_accessor :icon
|
|
14
|
+
# @return [String] The base API path where the resource is mounted (e.g., "/api/admin/news").
|
|
15
|
+
attr_accessor :api_path
|
|
16
|
+
# @return [Boolean] Whether to display this resource in the admin sidebar menu.
|
|
17
|
+
attr_accessor :display_in_sidebar
|
|
18
|
+
# @return [String] The menu group category for this resource.
|
|
19
|
+
attr_accessor :group
|
|
20
|
+
# @return [Integer] The explicit ordering weight in the sidebar.
|
|
21
|
+
attr_accessor :order
|
|
22
|
+
# @return [String] The default sort column and direction (e.g., "-created_at").
|
|
23
|
+
attr_accessor :default_sort
|
|
24
|
+
# @return [Integer] Default number of records per page for pagination.
|
|
25
|
+
attr_accessor :per_page_default
|
|
26
|
+
# @return [Array<String>] List of field names that support full-text search.
|
|
27
|
+
attr_accessor :searchable_fields
|
|
28
|
+
# @return [Array<String>] List of field names that can be sorted by.
|
|
29
|
+
attr_accessor :sortable_fields
|
|
30
|
+
# @return [Array<String>] List of field names that can be filtered on.
|
|
31
|
+
attr_accessor :filterable_fields
|
|
32
|
+
# @return [String] The field used for Optimistic Concurrency Control (OCC), usually "updated_at".
|
|
33
|
+
attr_accessor :concurrency_field
|
|
34
|
+
# @return [Array<String>] Configures standard audit fields (created_at, updated_at).
|
|
35
|
+
attr_accessor :audit_fields
|
|
36
|
+
# @return [Hash] The baseline CRUD permissions (can_view, can_edit, etc).
|
|
37
|
+
attr_accessor :permissions
|
|
38
|
+
# @return [Array<Kabk::Field>] The array of registered field objects.
|
|
39
|
+
attr_accessor :fields
|
|
40
|
+
# @return [Class] The underlying ORM Model class (e.g., Sequel::Model).
|
|
41
|
+
attr_accessor :model_class
|
|
42
|
+
# @return [Kabk::Adapters::Base] The adapter instance for this resource.
|
|
43
|
+
attr_accessor :adapter
|
|
44
|
+
# @return [Hash] Configured lifecycle hooks.
|
|
45
|
+
attr_accessor :hooks
|
|
46
|
+
|
|
47
|
+
def initialize(name:, model_class:)
|
|
48
|
+
@name = name.to_s
|
|
49
|
+
@model_class = model_class
|
|
50
|
+
@fields = []
|
|
51
|
+
@hooks = {}
|
|
52
|
+
@display_in_sidebar = true
|
|
53
|
+
@per_page_default = 15
|
|
54
|
+
@permissions = {
|
|
55
|
+
can_view: true,
|
|
56
|
+
can_insert: true,
|
|
57
|
+
can_edit: true,
|
|
58
|
+
can_delete: true,
|
|
59
|
+
can_export: true
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def to_h
|
|
64
|
+
hash = {
|
|
65
|
+
name: @name,
|
|
66
|
+
plural_name: @plural_name,
|
|
67
|
+
title: @title,
|
|
68
|
+
icon: @icon,
|
|
69
|
+
api_path: @api_path
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
hash[:display_in_sidebar] = @display_in_sidebar unless @display_in_sidebar.nil?
|
|
73
|
+
hash[:group] = @group if @group
|
|
74
|
+
hash[:order] = @order if @order
|
|
75
|
+
hash[:default_sort] = @default_sort if @default_sort
|
|
76
|
+
hash[:per_page_default] = @per_page_default if @per_page_default
|
|
77
|
+
hash[:searchable_fields] = @searchable_fields if @searchable_fields
|
|
78
|
+
hash[:sortable_fields] = @sortable_fields if @sortable_fields
|
|
79
|
+
hash[:filterable_fields] = @filterable_fields if @filterable_fields
|
|
80
|
+
hash[:concurrency_field] = @concurrency_field if @concurrency_field
|
|
81
|
+
hash[:audit_fields] = @audit_fields if @audit_fields
|
|
82
|
+
hash[:permissions] = @permissions if @permissions
|
|
83
|
+
hash[:fields] = @fields.map(&:to_h)
|
|
84
|
+
|
|
85
|
+
hash
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kabk
|
|
4
|
+
# Custom error classes
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class InvalidFieldError < Error; end
|
|
7
|
+
|
|
8
|
+
# DSL builder for resources
|
|
9
|
+
class ResourceBuilder
|
|
10
|
+
def initialize(resource)
|
|
11
|
+
@resource = resource
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Delegate dynamic attribute setting (e.g. title, icon, plural_name)
|
|
15
|
+
def method_missing(method_name, *args, **kwargs, &)
|
|
16
|
+
if @resource.respond_to?("#{method_name}=")
|
|
17
|
+
value = if kwargs.any? && args.empty?
|
|
18
|
+
kwargs
|
|
19
|
+
else
|
|
20
|
+
args.first
|
|
21
|
+
end
|
|
22
|
+
@resource.send("#{method_name}=", value)
|
|
23
|
+
else
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
29
|
+
@resource.respond_to?("#{method_name}=") || super
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Define a field with attributes
|
|
33
|
+
def field(name, type:, form_type:, **attributes)
|
|
34
|
+
# Validate required keys per spec
|
|
35
|
+
raise InvalidFieldError, "Field '#{name}' is missing 'type'" unless type
|
|
36
|
+
raise InvalidFieldError, "Field '#{name}' is missing 'form_type'" unless form_type
|
|
37
|
+
|
|
38
|
+
# Extract label, if not provided, default to titleized name
|
|
39
|
+
label = attributes.delete(:label)
|
|
40
|
+
if label.nil?
|
|
41
|
+
label = attributes.slice(:fa, :en)
|
|
42
|
+
label = name.to_s.capitalize.tr("_", " ") if label.empty?
|
|
43
|
+
attributes.reject! { |k, _v| %i[fa en].include?(k) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
f = Field.new(name: name, type: type, form_type: form_type, label: label, **attributes)
|
|
47
|
+
@resource.fields << f
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Configure permissions explicitly
|
|
51
|
+
def permissions(**perms)
|
|
52
|
+
@resource.permissions.merge!(perms)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# --- Lifecycle Hooks ---
|
|
56
|
+
|
|
57
|
+
# Registers a callback executed before the record is created.
|
|
58
|
+
# @yield [params, context] The incoming parameters and host context.
|
|
59
|
+
def before_create(&block)
|
|
60
|
+
@resource.hooks[:before_create] = block
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Registers a callback executed after the record is created.
|
|
64
|
+
# @yield [record, context] The newly created record hash and host context.
|
|
65
|
+
def after_create(&block)
|
|
66
|
+
@resource.hooks[:after_create] = block
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registers a callback executed before the record is updated.
|
|
70
|
+
# @yield [id, params, context] The record ID, incoming parameters, and host context.
|
|
71
|
+
def before_update(&block)
|
|
72
|
+
@resource.hooks[:before_update] = block
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Registers a callback executed after the record is updated.
|
|
76
|
+
# @yield [record, context] The updated record hash and host context.
|
|
77
|
+
def after_update(&block)
|
|
78
|
+
@resource.hooks[:after_update] = block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Registers a callback executed before the record is deleted.
|
|
82
|
+
# @yield [id, context] The record ID and host context.
|
|
83
|
+
def before_delete(&block)
|
|
84
|
+
@resource.hooks[:before_delete] = block
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Registers a callback executed after the record is deleted.
|
|
88
|
+
# @yield [id, context] The deleted record ID and host context.
|
|
89
|
+
def after_delete(&block)
|
|
90
|
+
@resource.hooks[:after_delete] = block
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "validator"
|
|
4
|
+
|
|
5
|
+
module Kabk
|
|
6
|
+
# Generic CRUD engine for any registered resource, agnostic to the underlying database
|
|
7
|
+
class RestEngine
|
|
8
|
+
# @param resource_name [String, Symbol]
|
|
9
|
+
def initialize(resource_name)
|
|
10
|
+
@resource = Registry.instance.get(resource_name)
|
|
11
|
+
raise NotFoundError, "Resource not found" unless @resource
|
|
12
|
+
|
|
13
|
+
@adapter = @resource.adapter
|
|
14
|
+
raise StandardError, "Resource has no adapter configured" unless @adapter
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# List resources with pagination, sorting, filtering.
|
|
18
|
+
#
|
|
19
|
+
# @param params [Hash] Request parameters (page, per_page, sort, filters).
|
|
20
|
+
# @param context [Hash] Context provided by host framework.
|
|
21
|
+
# @return [Hash] A standard protocol response hash with success, data, and meta properties.
|
|
22
|
+
def list(params, context: {})
|
|
23
|
+
@adapter.list(params, context: context)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get single resource by ID.
|
|
27
|
+
#
|
|
28
|
+
# @param id [Integer, String] The primary key of the record.
|
|
29
|
+
# @param context [Hash] Context provided by host framework.
|
|
30
|
+
# @return [Hash] A standard protocol response hash with success and data properties.
|
|
31
|
+
# @raise [NotFoundError] If the record does not exist.
|
|
32
|
+
def get(id, context: {})
|
|
33
|
+
@adapter.get(id, context: context)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Create a new resource. Executes lifecycle hooks, validates parameters, and delegates to the adapter.
|
|
37
|
+
#
|
|
38
|
+
# @param params [Hash] The raw input attributes from the request payload.
|
|
39
|
+
# @param context [Hash] Context provided by host framework.
|
|
40
|
+
# @return [Hash] A standard protocol response hash or an error hash if a hook/validation fails.
|
|
41
|
+
def create(params, context: {})
|
|
42
|
+
@resource.hooks[:before_create]&.call(params, context)
|
|
43
|
+
sanitized = Validator.validate_and_sanitize!(@resource, params)
|
|
44
|
+
result = @adapter.create(sanitized, context: context)
|
|
45
|
+
|
|
46
|
+
@resource.hooks[:after_create]&.call(result[:data], context)
|
|
47
|
+
result
|
|
48
|
+
rescue ApiError => e
|
|
49
|
+
e.to_h
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Update an existing resource. Executes lifecycle hooks and validates Optimistic Concurrency Control (OCC).
|
|
53
|
+
#
|
|
54
|
+
# @param id [Integer, String] The primary key of the record.
|
|
55
|
+
# @param params [Hash] The updated attributes from the request payload.
|
|
56
|
+
# @param context [Hash] Context provided by host framework.
|
|
57
|
+
# @return [Hash] A standard protocol response hash or an error hash if a hook/validation fails.
|
|
58
|
+
def update(id, params, context: {})
|
|
59
|
+
@resource.hooks[:before_update]&.call(id, params, context)
|
|
60
|
+
sanitized = Validator.validate_and_sanitize!(@resource, params, is_update: true)
|
|
61
|
+
result = @adapter.update(id, sanitized, context: context)
|
|
62
|
+
|
|
63
|
+
@resource.hooks[:after_update]&.call(result[:data], context)
|
|
64
|
+
result
|
|
65
|
+
rescue ApiError => e
|
|
66
|
+
e.to_h
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Delete a resource by ID. Executes lifecycle hooks and delegates to the adapter.
|
|
70
|
+
#
|
|
71
|
+
# @param id [Integer, String] The primary key of the record.
|
|
72
|
+
# @param context [Hash] Context provided by host framework.
|
|
73
|
+
# @return [Hash] A standard protocol response hash indicating success or an error hash if a hook fails.
|
|
74
|
+
def delete(id, context: {})
|
|
75
|
+
@resource.hooks[:before_delete]&.call(id, context)
|
|
76
|
+
result = @adapter.delete(id, context: context)
|
|
77
|
+
|
|
78
|
+
@resource.hooks[:after_delete]&.call(id, context)
|
|
79
|
+
result
|
|
80
|
+
rescue ApiError => e
|
|
81
|
+
e.to_h
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
|
|
5
|
+
module Kabk
|
|
6
|
+
# Renders the JSON Schema manifest for the protocol v1.6.0
|
|
7
|
+
class SchemaRenderer
|
|
8
|
+
# Initializes the schema renderer
|
|
9
|
+
#
|
|
10
|
+
# @param system_config [Hash, nil] Optional system config overrides (e.g., title, logo_url, endpoints)
|
|
11
|
+
# @param validation_schema_url [String, nil] Custom URL for the validation schema
|
|
12
|
+
def initialize(system_config: nil, validation_schema_url: nil)
|
|
13
|
+
@system_config = system_config ? default_system_config.merge(system_config) : default_system_config
|
|
14
|
+
@validation_schema_url = validation_schema_url || "/schemas/admin-protocol-1.6.0.json"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def render
|
|
18
|
+
{
|
|
19
|
+
"$schema_version": "1.6.0",
|
|
20
|
+
system: @system_config,
|
|
21
|
+
validation_schema_url: @validation_schema_url,
|
|
22
|
+
resources: Registry.instance.all.map(&:to_h)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def default_system_config
|
|
29
|
+
{
|
|
30
|
+
title: { fa: "پنل مدیریت سیمرغ", en: "Simurgh Panel" },
|
|
31
|
+
logo_url: "/simurgh-logo.svg",
|
|
32
|
+
default_locale: "en",
|
|
33
|
+
supported_locales: %w[en fa],
|
|
34
|
+
direction: "ltr",
|
|
35
|
+
endpoints: {
|
|
36
|
+
upload: "/api/admin/uploads"
|
|
37
|
+
},
|
|
38
|
+
custom_fonts: [
|
|
39
|
+
{
|
|
40
|
+
name: "Lalezar",
|
|
41
|
+
url: "https://fonts.gstatic.com/s/lalezar/v14/OpUp1a5dqj36gZ2zXh_WfQ.woff2",
|
|
42
|
+
format: "woff2",
|
|
43
|
+
label: { en: "Lalezar (Title)", fa: "لالهزار (عنوان)" }
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "Sahel",
|
|
47
|
+
url: "https://cdn.jsdelivr.net/gh/rastikerdar/sahel-font@v3.4.0/dist/Sahel.woff2",
|
|
48
|
+
format: "woff2",
|
|
49
|
+
label: { en: "Sahel (Clean)", fa: "ساحل (خوانا)" }
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
custom_font_sizes: [
|
|
53
|
+
{
|
|
54
|
+
size: "15px",
|
|
55
|
+
label: { en: "Medium Fine (15px)", fa: "متوسط ظریف (۱۵px)" }
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
size: "20px",
|
|
59
|
+
label: { en: "Sub-heading (20px)", fa: "زیرعنوان (۲۰px)" }
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
size: "32px",
|
|
63
|
+
label: { en: "Hero Display (32px)", fa: "تیتر برجسته (۳۲px)" }
|
|
64
|
+
}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kabk
|
|
4
|
+
# Handles generic file uploads and formats them according to Protocol v1.6.0
|
|
5
|
+
class UploadHandler
|
|
6
|
+
# @param url [String] Public URL of the uploaded file
|
|
7
|
+
# @param file_name [String] Original file name
|
|
8
|
+
# @param size [Integer] File size in bytes
|
|
9
|
+
# @param mime_type [String] File mime type
|
|
10
|
+
# @return [Hash] Formatted upload response
|
|
11
|
+
def self.format_response(url:, file_name:, size:, mime_type: nil)
|
|
12
|
+
{
|
|
13
|
+
success: true,
|
|
14
|
+
message: "File uploaded successfully",
|
|
15
|
+
data: {
|
|
16
|
+
url: url,
|
|
17
|
+
file_name: file_name,
|
|
18
|
+
size: size,
|
|
19
|
+
mime_type: mime_type || "application/octet-stream"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|