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
@@ -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 Sat Aug 15 15:52:07 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,255 @@
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", "created_at"]
36
+
37
+ # Concurrency & Auditing (created_by and updated_by are auto-injected from context[:current_user_id])
38
+ concurrency_field "updated_at"
39
+ audit_fields ["created_by", "updated_by", "created_at", "updated_at"]
40
+
41
+ # Permissions
42
+ permissions can_view: true, can_insert: true, can_edit: true, can_delete: false
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
+ field :email, type: :string, form_type: :email, required: true, validation: { unique: true, pattern: /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i }
48
+ end
49
+ ```
50
+
51
+ ### Resource Attributes Explained
52
+
53
+ | Attribute | Type | Description |
54
+ |-----------|------|-------------|
55
+ | **`title`** | Hash | A dictionary of localized names for the UI. e.g. `{ fa: "پست ها", en: "Posts" }` |
56
+ | **`plural_name`** | String | Used for API routing and JSON keys (e.g., `/api/admin/users`). |
57
+ | **`icon`** | String | The frontend icon identifier (e.g. Feather Icons or Material Icons name). |
58
+ | **`group`** | String | Groups resources together in the sidebar navigation. |
59
+ | **`display_in_sidebar`** | Boolean | Set to `false` to hide this resource from the main menu. |
60
+ | **`order`** | Integer | The explicit rendering order in the sidebar. |
61
+ | **`api_path`** | String | The fully qualified API path. |
62
+ | **`per_page_default`** | Integer | Default pagination size (default: 15). |
63
+ | **`default_sort`** | String | Default sorting column. Prefix with `-` for descending (e.g. `-created_at`). |
64
+ | **`searchable_fields`** | Array | Columns to search when a global `search=foo` query is sent. |
65
+ | **`sortable_fields`** | Array | Columns the frontend is allowed to sort by. |
66
+ | **`filterable_fields`** | Array | Columns the frontend is allowed to filter by. Supports exact match, comma-separated `IN`, and range queries (`gte`, `lte`, `gt`, `lt`, `eq`, `neq`). |
67
+ | **`concurrency_field`** | String | Used for Optimistic Concurrency Control (OCC). If the frontend sends an outdated timestamp, updates will be rejected with 409 Conflict. |
68
+ | **`audit_fields`** | Array | Configures standard audit fields (`created_by`, `updated_by`, `created_at`, `updated_at`). `created_by` and `updated_by` are automatically injected from `context[:current_user_id]`. |
69
+ | **`permissions`** | Hash | Boolean flags for CRUD ops (`can_view`, `can_insert`, `can_edit`, `can_delete`). |
70
+
71
+ ---
72
+
73
+ ## 2. Field Definitions
74
+
75
+ Fields map directly to your database columns and instruct the frontend on how to render inputs.
76
+
77
+ ### Basic Syntax
78
+ ```ruby
79
+ field :email, type: :string, form_type: :text, required: true, validation: { unique: true }
80
+ ```
81
+
82
+ ### Supported Data Types (`type`)
83
+ - `:string` (Text, Varchar)
84
+ - `:number` (Integer, Float)
85
+ - `:boolean` (True/False)
86
+ - `:date` (Date only)
87
+ - `:datetime` (Timestamp)
88
+ - `:file` (Images, Documents)
89
+ - `:relation` (Foreign Keys, Join Tables)
90
+
91
+ ### Supported Form Types (`form_type`)
92
+ Dictates the HTML/React component used in the frontend:
93
+ - `:text`, `:textarea`, `:number`, `:email`, `:password`
94
+ - `:select`, `:multiselect`, `:switch`
95
+ - `:date`, `:datetime`
96
+ - `:image_single`, `:file_single`
97
+ - `:relation_select`
98
+ - `:wysiwyg` (Rich text editor)
99
+
100
+ ### Field Modifiers
101
+
102
+ | Modifier | Type | Description |
103
+ |----------|------|-------------|
104
+ | **`primary_key`** | Boolean | Marks the ID field. |
105
+ | **`label`** | String | Human readable label. Defaults to titleized column name. |
106
+ | **`calendar`** | Symbol | Restricts date pickers (e.g. `:jalali` or `:gregorian`). |
107
+ | **`display_as`** | Symbol | Table rendering hints (`:badge`, `:thumbnail`, `:boolean_icon`). |
108
+ | **`hidden_in_table`** | Boolean | Hides the field from the main DataGrid. |
109
+ | **`hidden_in_form`** | Boolean | Hides the field from Create/Update forms. |
110
+ | **`col_width`** | Integer | Bootstrap grid width (1-12) for form layout. Defaults to 12. |
111
+ | **`accordion`** | Boolean | If true, places this field inside a collapsible section in the form. |
112
+ | **`depends_on`** | Hash | Conditional visibility. e.g. `{ field: "is_active", value: true }`. |
113
+
114
+ ---
115
+
116
+ ## 3. Relationships
117
+
118
+ Relations allow you to link resources together. The `RelationHydrator` automatically fetches the nested data when returning API responses.
119
+
120
+ ### Many-To-One (Foreign Key)
121
+ Used when the current resource has an `author_id` that points to a `User`.
122
+
123
+ ```ruby
124
+ field :author_id,
125
+ type: :relation,
126
+ form_type: :relation_select,
127
+ relation: {
128
+ resource: "user", # The target registered resource
129
+ cardinality: "many_to_one",
130
+ value_field: "id", # PK on target
131
+ label_field: "full_name" # What to show in the dropdown
132
+ }
133
+ ```
134
+
135
+ ### Many-To-Many (Join Tables / JSON arrays)
136
+ Used when a `NewsItem` has multiple `categories`.
137
+
138
+ ```ruby
139
+ field :category_ids,
140
+ type: :relation,
141
+ form_type: :multiselect,
142
+ relation: {
143
+ resource: "category",
144
+ cardinality: "many_to_many",
145
+ value_field: "id",
146
+ label_field: "title"
147
+ }
148
+ ```
149
+
150
+ ---
151
+
152
+ ## 4. Validation
153
+
154
+ Kabk performs strict server-side validation based on your DSL before executing database queries.
155
+
156
+ ```ruby
157
+ field :username, type: :string, form_type: :text, validation: {
158
+ min_length: 3,
159
+ max_length: 20,
160
+ unique: true,
161
+ pattern: /\A[a-z0-9_]+\z/,
162
+ custom_message: { fa: "نام کاربری نامعتبر است", en: "Username is invalid" }
163
+ }
164
+ ```
165
+
166
+ **Supported Rules:**
167
+ * `unique: true` - Queries the database adapter to ensure no duplicate exists (excludes current ID on updates).
168
+ * `min_length: N` / `max_length: N` - For string length constraints.
169
+ * `min: N` / `max: N` - For numeric value constraints (`:number` fields).
170
+ * `pattern: /.../` or `pattern: "..."` - Regular expression matching for strings.
171
+ * `custom_message: "..."` or `custom_message: { fa: "...", en: "..." }` - Custom error message (String or localized Hash) returned when validation fails.
172
+
173
+ ---
174
+
175
+ ## 5. Working with the RestEngine
176
+
177
+ 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`.
178
+
179
+ ```ruby
180
+ engine = Kabk::RestEngine.new('news_item')
181
+
182
+ # 1. Fetching a List (Includes Range/IN Filtering, Search, Pagination & Hydration)
183
+ result = engine.list({
184
+ "page" => 2,
185
+ "per_page" => 10,
186
+ "sort" => "-publish_date",
187
+ "search" => "hello",
188
+ "filter" => { "author_id" => { "gte" => 10, "lte" => 50 } }
189
+ }, context: { current_user_id: 1 })
190
+ # => { success: true, data: [...], meta: { total: 50, page: 2, per_page: 10, last_page: 5 } }
191
+
192
+ # 2. Fetching a Single Record
193
+ result = engine.get(5, context: { current_user_id: 1 })
194
+ # => { success: true, data: { id: 5, title: "...", author_id: { id: 1, full_name: "Admin" } } }
195
+
196
+ # 3. Creating a Record (Audit fields such as created_by are automatically injected from context[:current_user_id])
197
+ result = engine.create({ "title" => "New Post", "author_id" => 1 }, context: { current_user_id: 1 })
198
+ # => { success: true, data: {...} }
199
+
200
+ # 4. Updating a Record (With OCC and Uniqueness check)
201
+ # If updated_at does not match the DB, it throws a 409 ApiError
202
+ result = engine.update(5, { "title" => "Edited", "updated_at" => "2026-08-01T10:00:00Z" }, context: { current_user_id: 1 })
203
+
204
+ # 5. Deleting a Record
205
+ result = engine.delete(5, context: { current_user_id: 1 })
206
+ ```
207
+
208
+ ---
209
+
210
+ ## 6. Lifecycle Hooks
211
+
212
+ Kabk allows you to inject custom business logic immediately before or after any database action via block-based callbacks in your resource definition.
213
+
214
+ ### Available Hooks
215
+
216
+ ```ruby
217
+ Kabk.register(name: 'user', table: User) do
218
+ # --- CREATE HOOKS ---
219
+ before_create do |params, context|
220
+ # Mutate params before hitting the adapter
221
+ params['password_digest'] = BCrypt::Password.create(params.delete('password')) if params['password']
222
+ end
223
+
224
+ after_create do |record, context|
225
+ # 'record' is the newly created hash from the database
226
+ EmailService.send_welcome(record[:id])
227
+ end
228
+
229
+ # --- UPDATE HOOKS ---
230
+ before_update do |id, params, context|
231
+ # Receives the ID of the record being updated, and the incoming params
232
+ params['last_modified_by'] = context[:current_user_id]
233
+ end
234
+
235
+ after_update do |record, context|
236
+ # 'record' is the updated hash from the database
237
+ AuditLog.create(action: 'update', resource_id: record[:id])
238
+ end
239
+
240
+ # --- DELETE HOOKS ---
241
+ before_delete do |id, context|
242
+ # Receives the ID. Can halt the process by raising an error.
243
+ raise Kabk::ValidationError, 'Cannot delete super admins' if id == 1
244
+ end
245
+
246
+ after_delete do |id, context|
247
+ # Receives the ID of the successfully deleted record
248
+ AuditLog.create(action: 'delete', resource_id: id)
249
+ end
250
+ end
251
+ ```
252
+
253
+ ### Flow and Error Handling
254
+ - **Mutations:** Any changes to params within `before_create` or `before_update` are validated and passed down to the DB adapter.
255
+ - **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: ... } }`.
@@ -0,0 +1,53 @@
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
+
44
+ # @param field_name [Symbol, String] Field/column name to check
45
+ # @param value [Object] Value to check
46
+ # @param exclude_id [Integer, String, nil] Optional ID to exclude from check
47
+ # @return [Boolean]
48
+ def exists?(field_name, value, exclude_id: nil)
49
+ raise NotImplementedError, "#{self.class} must implement #exists?"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,138 @@
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
+
124
+ # @param field_name [Symbol, String]
125
+ # @param value [Object]
126
+ # @param exclude_id [Integer, String, nil]
127
+ # @return [Boolean]
128
+ def exists?(field_name, value, exclude_id: nil)
129
+ dataset = @resource.model_class.where(field_name.to_sym => value)
130
+ if exclude_id
131
+ pk = @resource.model_class.primary_key || :id
132
+ dataset = dataset.exclude(pk.to_sym => exclude_id)
133
+ end
134
+ !dataset.empty?
135
+ end
136
+ end
137
+ end
138
+ 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,68 @@
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
+ # Error raised when request parameter validation fails (HTTP 422)
28
+ class ValidationError < ApiError
29
+ def initialize(message = "The submitted data is invalid", fields: {})
30
+ super(message, code: "VALIDATION_ERROR", http_status: 422, fields: fields)
31
+ end
32
+ end
33
+
34
+ # Error raised when the current password provided during password change is incorrect (HTTP 422)
35
+ class InvalidOldPasswordError < ApiError
36
+ def initialize(message = "The current password provided is incorrect", fields: {})
37
+ super(message, code: "INVALID_OLD_PASSWORD", http_status: 422, fields: fields)
38
+ end
39
+ end
40
+
41
+ # Error raised when authentication is missing or invalid (HTTP 401)
42
+ class UnauthorizedError < ApiError
43
+ def initialize(message = "Authentication required")
44
+ super(message, code: "UNAUTHORIZED", http_status: 401)
45
+ end
46
+ end
47
+
48
+ # Error raised when an authenticated user lacks permission for an action (HTTP 403)
49
+ class ForbiddenError < ApiError
50
+ def initialize(message = "Access denied")
51
+ super(message, code: "FORBIDDEN", http_status: 403)
52
+ end
53
+ end
54
+
55
+ # Error raised when a requested resource or record cannot be found (HTTP 404)
56
+ class NotFoundError < ApiError
57
+ def initialize(message = "Resource not found")
58
+ super(message, code: "NOT_FOUND", http_status: 404)
59
+ end
60
+ end
61
+
62
+ # Error raised when optimistic concurrency control detects a conflicting modification (HTTP 409)
63
+ class ConcurrencyConflictError < ApiError
64
+ def initialize(message = "Concurrent record modification detected")
65
+ super(message, code: "CONCURRENCY_CONFLICT", http_status: 409)
66
+ end
67
+ end
68
+ end