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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +105 -0
- data/Kabk_logo.svg +33 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/doc/Kabk/Adapters/Base.html +989 -0
- data/doc/Kabk/Adapters/SequelAdapter.html +1071 -0
- data/doc/Kabk/Adapters.html +120 -0
- data/doc/Kabk/ApiError.html +511 -0
- data/doc/Kabk/Auth/JwtStrategy.html +551 -0
- data/doc/Kabk/Auth.html +118 -0
- data/doc/Kabk/Concurrency.html +297 -0
- data/doc/Kabk/ConcurrencyConflictError.html +234 -0
- data/doc/Kabk/Error.html +140 -0
- data/doc/Kabk/ExportHandler.html +381 -0
- data/doc/Kabk/Field.html +2160 -0
- data/doc/Kabk/ForbiddenError.html +234 -0
- data/doc/Kabk/InvalidFieldError.html +145 -0
- data/doc/Kabk/InvalidOldPasswordError.html +234 -0
- data/doc/Kabk/NotFoundError.html +234 -0
- data/doc/Kabk/QueryBuilder.html +701 -0
- data/doc/Kabk/Registry.html +608 -0
- data/doc/Kabk/RelationHydrator.html +480 -0
- data/doc/Kabk/Resource.html +1986 -0
- data/doc/Kabk/ResourceBuilder.html +1002 -0
- data/doc/Kabk/RestEngine.html +887 -0
- data/doc/Kabk/SchemaRenderer.html +318 -0
- data/doc/Kabk/UnauthorizedError.html +234 -0
- data/doc/Kabk/UploadHandler.html +308 -0
- data/doc/Kabk/ValidationError.html +234 -0
- data/doc/Kabk/Validator.html +802 -0
- data/doc/Kabk.html +330 -0
- data/doc/_index.html +369 -0
- data/doc/class_list.html +54 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +206 -0
- data/doc/css/style.css +1089 -0
- data/doc/file.README.html +162 -0
- data/doc/file_list.html +59 -0
- data/doc/frames.html +22 -0
- data/doc/index.html +162 -0
- data/doc/js/app.js +801 -0
- data/doc/js/full_list.js +334 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +982 -0
- data/doc/top-level-namespace.html +112 -0
- data/docs/API_REFERENCE.md +255 -0
- data/lib/kabk/adapters/base.rb +53 -0
- data/lib/kabk/adapters/sequel_adapter.rb +138 -0
- data/lib/kabk/concurrency.rb +30 -0
- data/lib/kabk/errors.rb +68 -0
- data/lib/kabk/field.rb +97 -0
- data/lib/kabk/query_builder.rb +103 -0
- data/lib/kabk/registry.rb +41 -0
- data/lib/kabk/relation_hydrator.rb +107 -0
- data/lib/kabk/resource.rb +88 -0
- data/lib/kabk/resource_builder.rb +95 -0
- data/lib/kabk/rest_engine.rb +120 -0
- data/lib/kabk/schema_renderer.rb +69 -0
- data/lib/kabk/upload_handler.rb +24 -0
- data/lib/kabk/validator.rb +121 -0
- data/lib/kabk/version.rb +6 -0
- data/lib/kabk.rb +50 -0
- 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
|
+
— 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> »
|
|
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
|
data/lib/kabk/errors.rb
ADDED
|
@@ -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
|