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.

Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +27 -0
  3. data/Gemfile +6 -0
  4. data/Gemfile.lock +108 -0
  5. data/Kabk_logo.svg +33 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/doc/Kabk/Adapters/Base.html +854 -0
  9. data/doc/Kabk/Adapters/SequelAdapter.html +948 -0
  10. data/doc/Kabk/Adapters.html +120 -0
  11. data/doc/Kabk/ApiError.html +511 -0
  12. data/doc/Kabk/Auth/JwtStrategy.html +551 -0
  13. data/doc/Kabk/Auth.html +118 -0
  14. data/doc/Kabk/Concurrency.html +297 -0
  15. data/doc/Kabk/ConcurrencyConflictError.html +225 -0
  16. data/doc/Kabk/Error.html +140 -0
  17. data/doc/Kabk/ExportHandler.html +381 -0
  18. data/doc/Kabk/Field.html +2160 -0
  19. data/doc/Kabk/ForbiddenError.html +225 -0
  20. data/doc/Kabk/InvalidFieldError.html +136 -0
  21. data/doc/Kabk/InvalidOldPasswordError.html +225 -0
  22. data/doc/Kabk/NotFoundError.html +225 -0
  23. data/doc/Kabk/QueryBuilder.html +551 -0
  24. data/doc/Kabk/Registry.html +608 -0
  25. data/doc/Kabk/RelationHydrator.html +480 -0
  26. data/doc/Kabk/Resource.html +1986 -0
  27. data/doc/Kabk/ResourceBuilder.html +1002 -0
  28. data/doc/Kabk/RestEngine.html +879 -0
  29. data/doc/Kabk/SchemaRenderer.html +318 -0
  30. data/doc/Kabk/UnauthorizedError.html +225 -0
  31. data/doc/Kabk/UploadHandler.html +308 -0
  32. data/doc/Kabk/ValidationError.html +225 -0
  33. data/doc/Kabk/Validator.html +380 -0
  34. data/doc/Kabk.html +330 -0
  35. data/doc/_index.html +376 -0
  36. data/doc/class_list.html +54 -0
  37. data/doc/css/common.css +1 -0
  38. data/doc/css/full_list.css +206 -0
  39. data/doc/css/style.css +1089 -0
  40. data/doc/file.README.html +149 -0
  41. data/doc/file_list.html +59 -0
  42. data/doc/frames.html +22 -0
  43. data/doc/index.html +149 -0
  44. data/doc/js/app.js +801 -0
  45. data/doc/js/full_list.js +334 -0
  46. data/doc/js/jquery.js +4 -0
  47. data/doc/method_list.html +910 -0
  48. data/doc/top-level-namespace.html +112 -0
  49. data/docs/API_REFERENCE.md +241 -0
  50. data/lib/kabk/adapters/base.rb +45 -0
  51. data/lib/kabk/adapters/sequel_adapter.rb +125 -0
  52. data/lib/kabk/concurrency.rb +30 -0
  53. data/lib/kabk/errors.rb +62 -0
  54. data/lib/kabk/export_handler.rb +43 -0
  55. data/lib/kabk/field.rb +97 -0
  56. data/lib/kabk/query_builder.rb +72 -0
  57. data/lib/kabk/registry.rb +41 -0
  58. data/lib/kabk/relation_hydrator.rb +107 -0
  59. data/lib/kabk/resource.rb +88 -0
  60. data/lib/kabk/resource_builder.rb +93 -0
  61. data/lib/kabk/rest_engine.rb +84 -0
  62. data/lib/kabk/schema_renderer.rb +69 -0
  63. data/lib/kabk/upload_handler.rb +24 -0
  64. data/lib/kabk/validator.rb +64 -0
  65. data/lib/kabk/version.rb +6 -0
  66. data/lib/kabk.rb +51 -0
  67. 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