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
@@ -0,0 +1,112 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>
7
+ Top Level Namespace
8
+
9
+ &mdash; Documentation by YARD 0.9.45
10
+
11
+ </title>
12
+
13
+ <link rel="stylesheet" href="css/style.css" type="text/css">
14
+
15
+ <link rel="stylesheet" href="css/common.css" type="text/css">
16
+
17
+ <script type="text/javascript">
18
+ pathId = "";
19
+ relpath = '';
20
+ </script>
21
+
22
+
23
+ <script type="text/javascript" charset="utf-8" src="js/jquery.js"></script>
24
+
25
+ <script type="text/javascript" charset="utf-8" src="js/app.js"></script>
26
+
27
+
28
+ </head>
29
+ <body>
30
+ <div id="main_progress" aria-hidden="true"></div>
31
+
32
+ <div class="nav_wrap">
33
+ <iframe id="nav" src="class_list.html?1"></iframe>
34
+ <div id="resizer"></div>
35
+ </div>
36
+
37
+ <div id="main" tabindex="-1">
38
+ <div id="header">
39
+ <div id="menu">
40
+
41
+ <a href="_index.html">Index</a> &raquo;
42
+
43
+
44
+ <span class="title">Top Level Namespace</span>
45
+
46
+ </div>
47
+
48
+ <div id="search">
49
+
50
+ <a class="full_list_link" id="class_list_link"
51
+ href="class_list.html">
52
+
53
+ <svg width="24" height="24">
54
+ <rect x="0" y="4" width="24" height="4" rx="1" ry="1"></rect>
55
+ <rect x="0" y="12" width="24" height="4" rx="1" ry="1"></rect>
56
+ <rect x="0" y="20" width="24" height="4" rx="1" ry="1"></rect>
57
+ </svg>
58
+ </a>
59
+
60
+ </div>
61
+ <div class="clear"></div>
62
+ </div>
63
+
64
+ <div id="content"><h1>Top Level Namespace
65
+
66
+
67
+
68
+ </h1>
69
+ <div class="box_info">
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+ </div>
82
+
83
+ <h2>Defined Under Namespace</h2>
84
+ <p class="children">
85
+
86
+
87
+ <strong class="modules">Modules:</strong> <span class='object_link'><a href="Kabk.html" title="Kabk (module)">Kabk</a></span>
88
+
89
+
90
+
91
+
92
+ </p>
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+ </div>
103
+
104
+ <div id="footer">
105
+ Generated on Tue Aug 11 21:17:06 2026 by
106
+ <a href="https://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
107
+ 0.9.45 (ruby-4.0.6).
108
+ </div>
109
+
110
+ </div>
111
+ </body>
112
+ </html>
@@ -0,0 +1,241 @@
1
+ # Kabk - API & DSL Reference
2
+
3
+ This guide provides a comprehensive overview of the Kabk Domain Specific Language (DSL) used to define your resources, as well as the underlying architectural features like Validation, Relations, and the RestEngine.
4
+
5
+ For exact method signatures and class structures, please refer to the YARD documentation (`bundle exec yard server`).
6
+
7
+ ---
8
+
9
+ ## 1. Registering a Resource
10
+
11
+ Resources are registered into the global `Kabk::Registry` using the `Kabk.register` block. This maps your data model (e.g., via a Sequel Model) to the Kabk schema and assigns an appropriate adapter.
12
+
13
+ ```ruby
14
+ require 'kabk'
15
+
16
+ class User < Sequel::Model; end
17
+
18
+ Kabk.register(name: "user", table: User) do
19
+ # --- 1. General Configuration ---
20
+ title fa: "مدیریت کاربران", en: "Users"
21
+ plural_name "users"
22
+ icon "Users"
23
+ group "Administration"
24
+ display_in_sidebar true
25
+ order 1
26
+
27
+ # --- 2. API & Data Configuration ---
28
+ api_path "/api/admin/users"
29
+ per_page_default 20
30
+ default_sort "-created_at"
31
+
32
+ # Search & Filter
33
+ searchable_fields ["full_name", "email"]
34
+ sortable_fields ["id", "created_at", "role"]
35
+ filterable_fields ["role", "is_active"]
36
+
37
+ # Concurrency & Auditing
38
+ concurrency_field "updated_at"
39
+ audit_fields ["created_at", "updated_at"]
40
+
41
+ # Permissions
42
+ permissions can_view: true, can_insert: true, can_edit: true, can_delete: false, can_export: true
43
+
44
+ # --- 3. Field Definitions ---
45
+ field :id, type: :number, primary_key: true, hidden_in_form: true
46
+ field :full_name, type: :string, form_type: :text, required: true
47
+ end
48
+ ```
49
+
50
+ ### Resource Attributes Explained
51
+
52
+ | Attribute | Type | Description |
53
+ |-----------|------|-------------|
54
+ | **`title`** | Hash | A dictionary of localized names for the UI. e.g. `{ fa: "پست ها", en: "Posts" }` |
55
+ | **`plural_name`** | String | Used for API routing and JSON keys (e.g., `/api/admin/users`). |
56
+ | **`icon`** | String | The frontend icon identifier (e.g. Feather Icons or Material Icons name). |
57
+ | **`group`** | String | Groups resources together in the sidebar navigation. |
58
+ | **`display_in_sidebar`** | Boolean | Set to `false` to hide this resource from the main menu. |
59
+ | **`order`** | Integer | The explicit rendering order in the sidebar. |
60
+ | **`api_path`** | String | The fully qualified API path. |
61
+ | **`per_page_default`** | Integer | Default pagination size (default: 15). |
62
+ | **`default_sort`** | String | Default sorting column. Prefix with `-` for descending (e.g. `-created_at`). |
63
+ | **`searchable_fields`** | Array | Columns to search when a global `search=foo` query is sent. |
64
+ | **`sortable_fields`** | Array | Columns the frontend is allowed to sort by. |
65
+ | **`filterable_fields`** | Array | Columns the frontend is allowed to filter by. |
66
+ | **`concurrency_field`** | String | Used for Optimistic Concurrency Control (OCC). If the frontend sends an outdated timestamp, updates will be rejected with 409 Conflict. |
67
+ | **`permissions`** | Hash | Boolean flags for CRUD ops (`can_view`, `can_insert`, `can_edit`, `can_delete`, `can_export`). |
68
+
69
+ ---
70
+
71
+ ## 2. Field Definitions
72
+
73
+ Fields map directly to your database columns and instruct the frontend on how to render inputs.
74
+
75
+ ### Basic Syntax
76
+ ```ruby
77
+ field :email, type: :string, form_type: :text, required: true, validation: { unique: true }
78
+ ```
79
+
80
+ ### Supported Data Types (`type`)
81
+ - `:string` (Text, Varchar)
82
+ - `:number` (Integer, Float)
83
+ - `:boolean` (True/False)
84
+ - `:date` (Date only)
85
+ - `:datetime` (Timestamp)
86
+ - `:file` (Images, Documents)
87
+ - `:relation` (Foreign Keys, Join Tables)
88
+
89
+ ### Supported Form Types (`form_type`)
90
+ Dictates the HTML/React component used in the frontend:
91
+ - `:text`, `:textarea`, `:number`, `:email`, `:password`
92
+ - `:select`, `:multiselect`, `:switch`
93
+ - `:date`, `:datetime`
94
+ - `:image_single`, `:file_single`
95
+ - `:relation_select`
96
+ - `:wysiwyg` (Rich text editor)
97
+
98
+ ### Field Modifiers
99
+
100
+ | Modifier | Type | Description |
101
+ |----------|------|-------------|
102
+ | **`primary_key`** | Boolean | Marks the ID field. |
103
+ | **`label`** | String | Human readable label. Defaults to titleized column name. |
104
+ | **`calendar`** | Symbol | Restricts date pickers (e.g. `:jalali` or `:gregorian`). |
105
+ | **`display_as`** | Symbol | Table rendering hints (`:badge`, `:thumbnail`, `:boolean_icon`). |
106
+ | **`hidden_in_table`** | Boolean | Hides the field from the main DataGrid. |
107
+ | **`hidden_in_form`** | Boolean | Hides the field from Create/Update forms. |
108
+ | **`col_width`** | Integer | Bootstrap grid width (1-12) for form layout. Defaults to 12. |
109
+ | **`accordion`** | Boolean | If true, places this field inside a collapsible section in the form. |
110
+ | **`depends_on`** | Hash | Conditional visibility. e.g. `{ field: "is_active", value: true }`. |
111
+
112
+ ---
113
+
114
+ ## 3. Relationships
115
+
116
+ Relations allow you to link resources together. The `RelationHydrator` automatically fetches the nested data when returning API responses.
117
+
118
+ ### Many-To-One (Foreign Key)
119
+ Used when the current resource has an `author_id` that points to a `User`.
120
+
121
+ ```ruby
122
+ field :author_id,
123
+ type: :relation,
124
+ form_type: :relation_select,
125
+ relation: {
126
+ resource: "user", # The target registered resource
127
+ cardinality: "many_to_one",
128
+ value_field: "id", # PK on target
129
+ label_field: "full_name" # What to show in the dropdown
130
+ }
131
+ ```
132
+
133
+ ### Many-To-Many (Join Tables / JSON arrays)
134
+ Used when a `NewsItem` has multiple `categories`.
135
+
136
+ ```ruby
137
+ field :category_ids,
138
+ type: :relation,
139
+ form_type: :multiselect,
140
+ relation: {
141
+ resource: "category",
142
+ cardinality: "many_to_many",
143
+ value_field: "id",
144
+ label_field: "title"
145
+ }
146
+ ```
147
+
148
+ ---
149
+
150
+ ## 4. Validation
151
+
152
+ Kabk performs strict server-side validation based on your DSL before executing database queries.
153
+
154
+ ```ruby
155
+ field :username, type: :string, form_type: :text, validation: { min_length: 3, max_length: 20, unique: true }
156
+ ```
157
+
158
+ **Supported Rules:**
159
+ * `unique: true` - Checks the DB to ensure no duplicate exists.
160
+ * `min_length: N` / `max_length: N` - For strings.
161
+ * `min: N` / `max: N` - For numbers.
162
+ * `regex: "/^[a-z]+$/"` - Custom pattern matching.
163
+
164
+ ---
165
+
166
+ ## 5. Working with the RestEngine
167
+
168
+ If you are not using `roda-kabk` and want to build your own HTTP layer (like Rails or Sinatra), you can interface directly with `Kabk::RestEngine`.
169
+
170
+ ```ruby
171
+ engine = Kabk::RestEngine.new('news_item')
172
+
173
+ # 1. Fetching a List (Includes Pagination & Hydration)
174
+ result = engine.list({ "page" => 2, "per_page" => 10, "sort" => "-publish_date", "search" => "hello" }, context: { current_user: user })
175
+ # => { success: true, data: [...], meta: { total: 50, page: 2, per_page: 10, last_page: 5 } }
176
+
177
+ # 2. Fetching a Single Record
178
+ result = engine.get(5, context: { current_user: user })
179
+ # => { success: true, data: { id: 5, title: "...", author_id: { id: 1, full_name: "Admin" } } }
180
+
181
+ # 3. Creating a Record
182
+ result = engine.create({ "title" => "New Post", "author_id" => 1 }, context: { current_user: user })
183
+ # => { success: true, data: {...} }
184
+
185
+ # 4. Updating a Record (With OCC)
186
+ # If updated_at does not match the DB, it throws a 409 ApiError
187
+ result = engine.update(5, { "title" => "Edited", "updated_at" => "2026-08-01T10:00:00Z" }, context: { current_user: user })
188
+
189
+ # 5. Deleting a Record
190
+ result = engine.delete(5, context: { current_user: user })
191
+ ```
192
+
193
+ ---
194
+
195
+ ## 6. Lifecycle Hooks
196
+
197
+ Kabk allows you to inject custom business logic immediately before or after any database action via block-based callbacks in your resource definition.
198
+
199
+ ### Available Hooks
200
+
201
+ `
202
+ Kabk.register(name: 'user', table: User) do
203
+ # --- CREATE HOOKS ---
204
+ before_create do |params, context|
205
+ # Mutate params before hitting the adapter
206
+ params['password_digest'] = BCrypt::Password.create(params.delete('password')) if params['password']
207
+ end
208
+
209
+ after_create do |record, context|
210
+ # 'record' is the newly created hash from the database
211
+ EmailService.send_welcome(record[:id])
212
+ end
213
+
214
+ # --- UPDATE HOOKS ---
215
+ before_update do |id, params, context|
216
+ # Receives the ID of the record being updated, and the incoming params
217
+ params['last_modified_by'] = context[:current_user_id]
218
+ end
219
+
220
+ after_update do |record, context|
221
+ # 'record' is the updated hash from the database
222
+ AuditLog.create(action: 'update', resource_id: record[:id])
223
+ end
224
+
225
+ # --- DELETE HOOKS ---
226
+ before_delete do |id, context|
227
+ # Receives the ID. Can halt the process by raising an error.
228
+ raise Kabk::ValidationError, 'Cannot delete super admins' if id == 1
229
+ end
230
+
231
+ after_delete do |id, context|
232
+ # Receives the ID of the successfully deleted record
233
+ AuditLog.create(action: 'delete', resource_id: id)
234
+ end
235
+ end
236
+ `
237
+
238
+ ### Flow and Error Handling
239
+ - **Mutations:** Any changes to params within efore_create or efore_update are validated and passed down to the DB adapter.
240
+ - **Halting Flow:** If a custom hook raises a Kabk::ApiError (e.g., Kabk::ValidationError), the RestEngine immediately halts, skips the database operation, and returns a standard error payload: { success: false, error: { message: ... } }.
241
+
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kabk
4
+ module Adapters
5
+ # Abstract base class for ORM Adapters
6
+ class Base
7
+ attr_reader :resource
8
+
9
+ def initialize(resource)
10
+ @resource = resource
11
+ end
12
+
13
+ # @param params [Hash] Request parameters (page, per_page, sort, filters).
14
+ # @param context [Hash] Context provided by host framework (e.g. current_user).
15
+ def list(params, context: {})
16
+ raise NotImplementedError, "#{self.class} must implement #list"
17
+ end
18
+
19
+ # @param id [Integer, String] Primary key
20
+ # @param context [Hash] Context provided by host framework (e.g. current_user).
21
+ def get(id, context: {})
22
+ raise NotImplementedError, "#{self.class} must implement #get"
23
+ end
24
+
25
+ # @param attributes [Hash] Sanitized attributes
26
+ # @param context [Hash] Context provided by host framework (e.g. current_user).
27
+ def create(attributes, context: {})
28
+ raise NotImplementedError, "#{self.class} must implement #create"
29
+ end
30
+
31
+ # @param id [Integer, String] Primary key
32
+ # @param attributes [Hash] Sanitized attributes
33
+ # @param context [Hash] Context provided by host framework (e.g. current_user).
34
+ def update(id, attributes, context: {})
35
+ raise NotImplementedError, "#{self.class} must implement #update"
36
+ end
37
+
38
+ # @param id [Integer, String] Primary key
39
+ # @param context [Hash] Context provided by host framework (e.g. current_user).
40
+ def delete(id, context: {})
41
+ raise NotImplementedError, "#{self.class} must implement #delete"
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../query_builder"
5
+ require_relative "../relation_hydrator"
6
+ require_relative "../concurrency"
7
+
8
+ module Kabk
9
+ module Adapters
10
+ # Sequel adapter for Kabk resources
11
+ class SequelAdapter < Base
12
+ # @param params [Hash] Request parameters (page, per_page, sort, filters).
13
+ # @param context [Hash]
14
+ # @return [Hash] Protocol response hash with success, data, meta
15
+ def list(params, context: {})
16
+ dataset = QueryBuilder.build(@resource, params)
17
+
18
+ if dataset.respond_to?(:pagination_record_count)
19
+ total = dataset.pagination_record_count
20
+ page = dataset.current_page
21
+ per_page = dataset.page_size
22
+ last_page = dataset.page_count
23
+ records = dataset.all
24
+ else
25
+ records = dataset.all
26
+ total = records.size
27
+ page = 1
28
+ per_page = total
29
+ last_page = 1
30
+ end
31
+
32
+ hydrated_records = RelationHydrator.hydrate(@resource, records)
33
+
34
+ {
35
+ success: true,
36
+ data: hydrated_records,
37
+ meta: {
38
+ total: total,
39
+ page: page,
40
+ per_page: per_page,
41
+ last_page: last_page
42
+ }
43
+ }
44
+ end
45
+
46
+ # @param id [Integer, String] Primary key
47
+ # @param context [Hash]
48
+ # @return [Hash] Protocol response hash with success, data
49
+ # @raise [NotFoundError] If record doesn't exist
50
+ def get(id, context: {})
51
+ record = @resource.model_class[id]
52
+ raise NotFoundError, "Record not found" unless record
53
+
54
+ hydrated_records = RelationHydrator.hydrate(@resource, [record])
55
+
56
+ {
57
+ success: true,
58
+ data: hydrated_records.first
59
+ }
60
+ end
61
+
62
+ # @param attributes [Hash] Sanitized attributes
63
+ # @param context [Hash]
64
+ # @return [Hash] Protocol response hash with success, message, data
65
+ # @raise [ApiError] If unique constraint violated
66
+ def create(attributes, context: {})
67
+ record = @resource.model_class.create(attributes)
68
+
69
+ hydrated_records = RelationHydrator.hydrate(@resource, [record])
70
+
71
+ {
72
+ success: true,
73
+ message: "Operation completed successfully",
74
+ data: hydrated_records.first
75
+ }
76
+ rescue Sequel::UniqueConstraintViolation
77
+ raise ApiError.new("Unique constraint violated", code: "CONFLICT", http_status: 409)
78
+ end
79
+
80
+ # @param id [Integer, String] Primary key
81
+ # @param attributes [Hash] Sanitized attributes (with concurrency_field included if passed)
82
+ # @param context [Hash]
83
+ # @return [Hash] Protocol response hash with success, message, data
84
+ # @raise [NotFoundError] If record doesn't exist
85
+ # @raise [ApiError] If OCC fails or unique constraint violated
86
+ def update(id, attributes, context: {})
87
+ record = @resource.model_class[id]
88
+ raise NotFoundError, "Record not found" unless record
89
+
90
+ Concurrency.check!(@resource, record, attributes)
91
+
92
+ c_field = @resource.concurrency_field&.to_sym
93
+ attributes.delete(c_field) if c_field
94
+
95
+ record.update(attributes)
96
+
97
+ hydrated_records = RelationHydrator.hydrate(@resource, [record])
98
+
99
+ {
100
+ success: true,
101
+ message: "Operation completed successfully",
102
+ data: hydrated_records.first
103
+ }
104
+ rescue Sequel::UniqueConstraintViolation
105
+ raise ApiError.new("Unique constraint violated", code: "CONFLICT", http_status: 409)
106
+ end
107
+
108
+ # @param id [Integer, String] Primary key
109
+ # @param context [Hash]
110
+ # @return [Hash] Protocol response hash indicating success
111
+ # @raise [NotFoundError] If record doesn't exist
112
+ def delete(id, context: {})
113
+ record = @resource.model_class[id]
114
+ raise NotFoundError, "Record not found" unless record
115
+
116
+ record.destroy
117
+
118
+ {
119
+ success: true,
120
+ message: "Record successfully deleted"
121
+ }
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Kabk
6
+ # Handles Optimistic Concurrency Control (OCC)
7
+ class Concurrency
8
+ # Check if the provided version matches the database state
9
+ # @param resource [Kabk::Resource]
10
+ # @param current_record [Object] The existing record in the database
11
+ # @param submitted_params [Hash] The payload from the client
12
+ # @raise [Kabk::ConcurrencyConflictError] if there is a mismatch
13
+ def self.check!(resource, current_record, submitted_params)
14
+ return unless resource.concurrency_field
15
+
16
+ field = resource.concurrency_field.to_sym
17
+ return unless submitted_params.key?(field) || submitted_params.key?(field.to_s)
18
+
19
+ client_version = submitted_params[field] || submitted_params[field.to_s]
20
+ db_version = current_record.send(field)
21
+
22
+ # Handle Time object comparison by converting DB to ISO8601 (which is what the client sends)
23
+ db_version_str = db_version.is_a?(Time) || db_version.respond_to?(:iso8601) ? db_version.iso8601 : db_version.to_s
24
+
25
+ return unless client_version.to_s != db_version_str
26
+
27
+ raise ConcurrencyConflictError, "Record has been updated by another user."
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kabk
4
+ # Defines standardized API errors that map to protocol error codes
5
+ class ApiError < StandardError
6
+ attr_reader :code, :http_status, :fields
7
+
8
+ def initialize(message, code: "SERVER_ERROR", http_status: 500, fields: {})
9
+ super(message)
10
+ @code = code
11
+ @http_status = http_status
12
+ @fields = fields
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ success: false,
18
+ error: {
19
+ code: @code,
20
+ message: message,
21
+ fields: @fields
22
+ }
23
+ }
24
+ end
25
+ end
26
+
27
+ class ValidationError < ApiError
28
+ def initialize(message = "The submitted data is invalid", fields: {})
29
+ super(message, code: "VALIDATION_ERROR", http_status: 422, fields: fields)
30
+ end
31
+ end
32
+
33
+ class InvalidOldPasswordError < ApiError
34
+ def initialize(message = "The current password provided is incorrect", fields: {})
35
+ super(message, code: "INVALID_OLD_PASSWORD", http_status: 422, fields: fields)
36
+ end
37
+ end
38
+
39
+ class UnauthorizedError < ApiError
40
+ def initialize(message = "Authentication required")
41
+ super(message, code: "UNAUTHORIZED", http_status: 401)
42
+ end
43
+ end
44
+
45
+ class ForbiddenError < ApiError
46
+ def initialize(message = "Access denied")
47
+ super(message, code: "FORBIDDEN", http_status: 403)
48
+ end
49
+ end
50
+
51
+ class NotFoundError < ApiError
52
+ def initialize(message = "Resource not found")
53
+ super(message, code: "NOT_FOUND", http_status: 404)
54
+ end
55
+ end
56
+
57
+ class ConcurrencyConflictError < ApiError
58
+ def initialize(message = "Concurrent record modification detected")
59
+ super(message, code: "CONCURRENCY_CONFLICT", http_status: 409)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Kabk
6
+ # Handles generating CSV or XLSX exports from a query
7
+ class ExportHandler
8
+ # @param resource [Kabk::Resource]
9
+ # @param dataset [Sequel::Dataset]
10
+ # @param format [String] "csv" or "xlsx"
11
+ # @return [String] Binary/Text data of the exported file
12
+ def self.export(resource, dataset, format: "csv")
13
+ raise ApiError.new("Export format not supported", code: "VALIDATION_ERROR", http_status: 422) unless %w[csv
14
+ xlsx].include?(format)
15
+
16
+ # We fetch all records for the export query (ignoring pagination limits usually, or applying large limit)
17
+ # For now, buffer everything in memory as requested.
18
+ records = dataset.all
19
+ hydrated_records = RelationHydrator.hydrate(resource, records)
20
+
21
+ columns = resource.fields.map { |f| f.name.to_s }
22
+ display_columns = resource.fields.select { |f| f.type == "relation" && f.relation }.map do |f|
23
+ rel = f.relation
24
+ (rel["display_key"] || rel[:display_key] || "#{f.name}_display").to_s
25
+ end
26
+
27
+ all_columns = columns + display_columns
28
+
29
+ # Stub for XLSX - requires external gem like caxlsx. We just return CSV for now as a fallback
30
+ # or raise an error asking to install caxlsx.
31
+ generate_csv(all_columns, hydrated_records)
32
+ end
33
+
34
+ def self.generate_csv(columns, records)
35
+ CSV.generate do |csv|
36
+ csv << columns
37
+ records.each do |record|
38
+ csv << columns.map { |col| record[col.to_sym] || record[col] }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end