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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +108 -0
- data/Kabk_logo.svg +33 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/doc/Kabk/Adapters/Base.html +854 -0
- data/doc/Kabk/Adapters/SequelAdapter.html +948 -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 +225 -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 +225 -0
- data/doc/Kabk/InvalidFieldError.html +136 -0
- data/doc/Kabk/InvalidOldPasswordError.html +225 -0
- data/doc/Kabk/NotFoundError.html +225 -0
- data/doc/Kabk/QueryBuilder.html +551 -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 +879 -0
- data/doc/Kabk/SchemaRenderer.html +318 -0
- data/doc/Kabk/UnauthorizedError.html +225 -0
- data/doc/Kabk/UploadHandler.html +308 -0
- data/doc/Kabk/ValidationError.html +225 -0
- data/doc/Kabk/Validator.html +380 -0
- data/doc/Kabk.html +330 -0
- data/doc/_index.html +376 -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 +149 -0
- data/doc/file_list.html +59 -0
- data/doc/frames.html +22 -0
- data/doc/index.html +149 -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 +910 -0
- data/doc/top-level-namespace.html +112 -0
- data/docs/API_REFERENCE.md +241 -0
- data/lib/kabk/adapters/base.rb +45 -0
- data/lib/kabk/adapters/sequel_adapter.rb +125 -0
- data/lib/kabk/concurrency.rb +30 -0
- data/lib/kabk/errors.rb +62 -0
- data/lib/kabk/export_handler.rb +43 -0
- data/lib/kabk/field.rb +97 -0
- data/lib/kabk/query_builder.rb +72 -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 +93 -0
- data/lib/kabk/rest_engine.rb +84 -0
- data/lib/kabk/schema_renderer.rb +69 -0
- data/lib/kabk/upload_handler.rb +24 -0
- data/lib/kabk/validator.rb +64 -0
- data/lib/kabk/version.rb +6 -0
- data/lib/kabk.rb +51 -0
- 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
|
+
— 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 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
|
data/lib/kabk/errors.rb
ADDED
|
@@ -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
|