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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/Gemfile +6 -0
  4. data/Gemfile.lock +105 -0
  5. data/Kabk_logo.svg +33 -0
  6. data/LICENSE +21 -0
  7. data/README.md +112 -0
  8. data/doc/Kabk/Adapters/Base.html +989 -0
  9. data/doc/Kabk/Adapters/SequelAdapter.html +1071 -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 +234 -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 +234 -0
  20. data/doc/Kabk/InvalidFieldError.html +145 -0
  21. data/doc/Kabk/InvalidOldPasswordError.html +234 -0
  22. data/doc/Kabk/NotFoundError.html +234 -0
  23. data/doc/Kabk/QueryBuilder.html +701 -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 +887 -0
  29. data/doc/Kabk/SchemaRenderer.html +318 -0
  30. data/doc/Kabk/UnauthorizedError.html +234 -0
  31. data/doc/Kabk/UploadHandler.html +308 -0
  32. data/doc/Kabk/ValidationError.html +234 -0
  33. data/doc/Kabk/Validator.html +802 -0
  34. data/doc/Kabk.html +330 -0
  35. data/doc/_index.html +369 -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 +162 -0
  41. data/doc/file_list.html +59 -0
  42. data/doc/frames.html +22 -0
  43. data/doc/index.html +162 -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 +982 -0
  48. data/doc/top-level-namespace.html +112 -0
  49. data/docs/API_REFERENCE.md +255 -0
  50. data/lib/kabk/adapters/base.rb +53 -0
  51. data/lib/kabk/adapters/sequel_adapter.rb +138 -0
  52. data/lib/kabk/concurrency.rb +30 -0
  53. data/lib/kabk/errors.rb +68 -0
  54. data/lib/kabk/field.rb +97 -0
  55. data/lib/kabk/query_builder.rb +103 -0
  56. data/lib/kabk/registry.rb +41 -0
  57. data/lib/kabk/relation_hydrator.rb +107 -0
  58. data/lib/kabk/resource.rb +88 -0
  59. data/lib/kabk/resource_builder.rb +95 -0
  60. data/lib/kabk/rest_engine.rb +120 -0
  61. data/lib/kabk/schema_renderer.rb +69 -0
  62. data/lib/kabk/upload_handler.rb +24 -0
  63. data/lib/kabk/validator.rb +121 -0
  64. data/lib/kabk/version.rb +6 -0
  65. data/lib/kabk.rb +50 -0
  66. metadata +178 -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,103 @@
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
+ dataset = if value.is_a?(Hash)
36
+ apply_range_filter(dataset, field, value)
37
+ else
38
+ apply_value_filter(dataset, field, value)
39
+ end
40
+ end
41
+ dataset
42
+ end
43
+
44
+ def self.apply_range_filter(dataset, field, hash_val)
45
+ col = field.to_sym
46
+ hash_val.each do |op, op_val|
47
+ next if op_val.nil? || op_val.to_s.strip.empty?
48
+
49
+ case op.to_s
50
+ when "gte"
51
+ dataset = dataset.where(Sequel[col] >= op_val)
52
+ when "gt"
53
+ dataset = dataset.where(Sequel[col] > op_val)
54
+ when "lte"
55
+ dataset = dataset.where(Sequel[col] <= op_val)
56
+ when "lt"
57
+ dataset = dataset.where(Sequel[col] < op_val)
58
+ when "eq"
59
+ dataset = dataset.where(col => op_val)
60
+ when "neq"
61
+ dataset = dataset.exclude(col => op_val)
62
+ end
63
+ end
64
+ dataset
65
+ end
66
+
67
+ def self.apply_value_filter(dataset, field, value)
68
+ # Parse comma-separated values to support SQL IN clauses.
69
+ values = value.to_s.split(",")
70
+ if values.size > 1
71
+ dataset.where(field.to_sym => values)
72
+ else
73
+ dataset.where(field.to_sym => value)
74
+ end
75
+ end
76
+
77
+ def self.apply_sort(dataset, resource, sort_term)
78
+ return dataset if sort_term.nil? || sort_term.to_s.strip.empty?
79
+
80
+ is_desc = sort_term.start_with?("-")
81
+ field_name = is_desc ? sort_term[1..] : sort_term
82
+
83
+ # Ensure sorting is only applied to permitted fields.
84
+ if resource.sortable_fields&.include?(field_name)
85
+ col = field_name.to_sym
86
+ dataset = is_desc ? dataset.order(Sequel.desc(col)) : dataset.order(Sequel.asc(col))
87
+ end
88
+ dataset
89
+ end
90
+
91
+ def self.apply_pagination(dataset, resource, params)
92
+ page = (params["page"] || 1).to_i
93
+ per_page = (params["per_page"] || resource.per_page_default).to_i
94
+
95
+ page = 1 if page < 1
96
+ per_page = 15 if per_page < 1
97
+
98
+ # Depends on Sequel's pagination extension (Sequel.extension :pagination).
99
+ # Returns a paginated Sequel::Dataset.
100
+ dataset.extension(:pagination).paginate(page, per_page)
101
+ end
102
+ end
103
+ 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
+ # Clears all registered resources from the registry
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,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kabk
4
+ # Base error class for Kabk configuration errors
5
+ class Error < StandardError; end
6
+
7
+ # Error raised when a resource field definition is missing required attributes
8
+ class InvalidFieldError < Error; end
9
+
10
+ # DSL builder for configuring resources and their fields
11
+ class ResourceBuilder
12
+ def initialize(resource)
13
+ @resource = resource
14
+ end
15
+
16
+ # Delegate dynamic attribute setting (e.g. title, icon, plural_name)
17
+ def method_missing(method_name, *args, **kwargs, &)
18
+ if @resource.respond_to?("#{method_name}=")
19
+ value = if kwargs.any? && args.empty?
20
+ kwargs
21
+ else
22
+ args.first
23
+ end
24
+ @resource.send("#{method_name}=", value)
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def respond_to_missing?(method_name, include_private = false)
31
+ @resource.respond_to?("#{method_name}=") || super
32
+ end
33
+
34
+ # Define a field with attributes
35
+ def field(name, type:, form_type:, **attributes)
36
+ # Validate required keys per spec
37
+ raise InvalidFieldError, "Field '#{name}' is missing 'type'" unless type
38
+ raise InvalidFieldError, "Field '#{name}' is missing 'form_type'" unless form_type
39
+
40
+ # Extract label, if not provided, default to titleized name
41
+ label = attributes.delete(:label)
42
+ if label.nil?
43
+ label = attributes.slice(:fa, :en)
44
+ label = name.to_s.capitalize.tr("_", " ") if label.empty?
45
+ attributes.reject! { |k, _v| %i[fa en].include?(k) }
46
+ end
47
+
48
+ f = Field.new(name: name, type: type, form_type: form_type, label: label, **attributes)
49
+ @resource.fields << f
50
+ end
51
+
52
+ # Configure permissions explicitly
53
+ def permissions(**perms)
54
+ @resource.permissions.merge!(perms)
55
+ end
56
+
57
+ # --- Lifecycle Hooks ---
58
+
59
+ # Registers a callback executed before the record is created.
60
+ # @yield [params, context] The incoming parameters and host context.
61
+ def before_create(&block)
62
+ @resource.hooks[:before_create] = block
63
+ end
64
+
65
+ # Registers a callback executed after the record is created.
66
+ # @yield [record, context] The newly created record hash and host context.
67
+ def after_create(&block)
68
+ @resource.hooks[:after_create] = block
69
+ end
70
+
71
+ # Registers a callback executed before the record is updated.
72
+ # @yield [id, params, context] The record ID, incoming parameters, and host context.
73
+ def before_update(&block)
74
+ @resource.hooks[:before_update] = block
75
+ end
76
+
77
+ # Registers a callback executed after the record is updated.
78
+ # @yield [record, context] The updated record hash and host context.
79
+ def after_update(&block)
80
+ @resource.hooks[:after_update] = block
81
+ end
82
+
83
+ # Registers a callback executed before the record is deleted.
84
+ # @yield [id, context] The record ID and host context.
85
+ def before_delete(&block)
86
+ @resource.hooks[:before_delete] = block
87
+ end
88
+
89
+ # Registers a callback executed after the record is deleted.
90
+ # @yield [id, context] The deleted record ID and host context.
91
+ def after_delete(&block)
92
+ @resource.hooks[:after_delete] = block
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,120 @@
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
+ inject_audit_field(params, context, "created_by")
43
+ @resource.hooks[:before_create]&.call(params, context)
44
+ sanitized = Validator.validate_and_sanitize!(@resource, params)
45
+ validate_uniqueness!(sanitized)
46
+ result = @adapter.create(sanitized, context: context)
47
+
48
+ @resource.hooks[:after_create]&.call(result[:data], context)
49
+ result
50
+ rescue ApiError => e
51
+ e.to_h
52
+ end
53
+
54
+ # Update an existing resource. Executes lifecycle hooks and validates Optimistic Concurrency Control (OCC).
55
+ #
56
+ # @param id [Integer, String] The primary key of the record.
57
+ # @param params [Hash] The updated attributes from the request payload.
58
+ # @param context [Hash] Context provided by host framework.
59
+ # @return [Hash] A standard protocol response hash or an error hash if a hook/validation fails.
60
+ def update(id, params, context: {})
61
+ inject_audit_field(params, context, "updated_by")
62
+ @resource.hooks[:before_update]&.call(id, params, context)
63
+ sanitized = Validator.validate_and_sanitize!(@resource, params, is_update: true)
64
+ validate_uniqueness!(sanitized, exclude_id: id)
65
+ result = @adapter.update(id, sanitized, context: context)
66
+
67
+ @resource.hooks[:after_update]&.call(result[:data], context)
68
+ result
69
+ rescue ApiError => e
70
+ e.to_h
71
+ end
72
+
73
+ # Delete a resource by ID. Executes lifecycle hooks and delegates to the adapter.
74
+ #
75
+ # @param id [Integer, String] The primary key of the record.
76
+ # @param context [Hash] Context provided by host framework.
77
+ # @return [Hash] A standard protocol response hash indicating success or an error hash if a hook fails.
78
+ def delete(id, context: {})
79
+ @resource.hooks[:before_delete]&.call(id, context)
80
+ result = @adapter.delete(id, context: context)
81
+
82
+ @resource.hooks[:after_delete]&.call(id, context)
83
+ result
84
+ rescue ApiError => e
85
+ e.to_h
86
+ end
87
+
88
+ private
89
+
90
+ def inject_audit_field(params, context, field_name)
91
+ return unless @resource.audit_fields&.map(&:to_s)&.include?(field_name)
92
+
93
+ user_id = context[:current_user_id] || context["current_user_id"]
94
+ params[field_name] = user_id if user_id
95
+ end
96
+
97
+ def validate_uniqueness!(sanitized, exclude_id: nil)
98
+ errors = {}
99
+
100
+ @resource.fields.each do |field|
101
+ rules = field.validation
102
+ next unless rules && (rules[:unique] == true || rules["unique"] == true)
103
+
104
+ field_sym = field.name.to_sym
105
+ field_str = field.name.to_s
106
+ next unless sanitized.key?(field_sym) || sanitized.key?(field_str)
107
+
108
+ value = sanitized.key?(field_sym) ? sanitized[field_sym] : sanitized[field_str]
109
+ next if value.nil?
110
+
111
+ if @adapter.exists?(field_sym, value, exclude_id: exclude_id)
112
+ msg = rules[:custom_message] || rules["custom_message"] || "Already taken"
113
+ errors[field_str] = [msg]
114
+ end
115
+ end
116
+
117
+ raise ValidationError.new(fields: errors) unless errors.empty?
118
+ end
119
+ end
120
+ end