data_porter 0.1.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/.claude/commands/blog-status.md +10 -0
- data/.claude/commands/blog.md +109 -0
- data/.claude/commands/task-done.md +27 -0
- data/.claude/commands/tm/add-dependency.md +58 -0
- data/.claude/commands/tm/add-subtask.md +79 -0
- data/.claude/commands/tm/add-task.md +81 -0
- data/.claude/commands/tm/analyze-complexity.md +124 -0
- data/.claude/commands/tm/analyze-project.md +100 -0
- data/.claude/commands/tm/auto-implement-tasks.md +100 -0
- data/.claude/commands/tm/command-pipeline.md +80 -0
- data/.claude/commands/tm/complexity-report.md +120 -0
- data/.claude/commands/tm/convert-task-to-subtask.md +74 -0
- data/.claude/commands/tm/expand-all-tasks.md +52 -0
- data/.claude/commands/tm/expand-task.md +52 -0
- data/.claude/commands/tm/fix-dependencies.md +82 -0
- data/.claude/commands/tm/help.md +101 -0
- data/.claude/commands/tm/init-project-quick.md +49 -0
- data/.claude/commands/tm/init-project.md +53 -0
- data/.claude/commands/tm/install-taskmaster.md +118 -0
- data/.claude/commands/tm/learn.md +106 -0
- data/.claude/commands/tm/list-tasks-by-status.md +42 -0
- data/.claude/commands/tm/list-tasks-with-subtasks.md +30 -0
- data/.claude/commands/tm/list-tasks.md +46 -0
- data/.claude/commands/tm/next-task.md +69 -0
- data/.claude/commands/tm/parse-prd-with-research.md +51 -0
- data/.claude/commands/tm/parse-prd.md +52 -0
- data/.claude/commands/tm/project-status.md +67 -0
- data/.claude/commands/tm/quick-install-taskmaster.md +23 -0
- data/.claude/commands/tm/remove-all-subtasks.md +94 -0
- data/.claude/commands/tm/remove-dependency.md +65 -0
- data/.claude/commands/tm/remove-subtask.md +87 -0
- data/.claude/commands/tm/remove-subtasks.md +89 -0
- data/.claude/commands/tm/remove-task.md +110 -0
- data/.claude/commands/tm/setup-models.md +52 -0
- data/.claude/commands/tm/show-task.md +85 -0
- data/.claude/commands/tm/smart-workflow.md +58 -0
- data/.claude/commands/tm/sync-readme.md +120 -0
- data/.claude/commands/tm/tm-main.md +147 -0
- data/.claude/commands/tm/to-cancelled.md +58 -0
- data/.claude/commands/tm/to-deferred.md +50 -0
- data/.claude/commands/tm/to-done.md +47 -0
- data/.claude/commands/tm/to-in-progress.md +39 -0
- data/.claude/commands/tm/to-pending.md +35 -0
- data/.claude/commands/tm/to-review.md +43 -0
- data/.claude/commands/tm/update-single-task.md +122 -0
- data/.claude/commands/tm/update-task.md +75 -0
- data/.claude/commands/tm/update-tasks-from-id.md +111 -0
- data/.claude/commands/tm/validate-dependencies.md +72 -0
- data/.claude/commands/tm/view-models.md +52 -0
- data/.env.example +12 -0
- data/.mcp.json +24 -0
- data/.taskmaster/CLAUDE.md +435 -0
- data/.taskmaster/config.json +44 -0
- data/.taskmaster/docs/prd.txt +2044 -0
- data/.taskmaster/state.json +6 -0
- data/.taskmaster/tasks/task_001.md +19 -0
- data/.taskmaster/tasks/task_002.md +19 -0
- data/.taskmaster/tasks/task_003.md +19 -0
- data/.taskmaster/tasks/task_004.md +19 -0
- data/.taskmaster/tasks/task_005.md +19 -0
- data/.taskmaster/tasks/task_006.md +19 -0
- data/.taskmaster/tasks/task_007.md +19 -0
- data/.taskmaster/tasks/task_008.md +19 -0
- data/.taskmaster/tasks/task_009.md +19 -0
- data/.taskmaster/tasks/task_010.md +19 -0
- data/.taskmaster/tasks/task_011.md +19 -0
- data/.taskmaster/tasks/task_012.md +19 -0
- data/.taskmaster/tasks/task_013.md +19 -0
- data/.taskmaster/tasks/task_014.md +19 -0
- data/.taskmaster/tasks/task_015.md +19 -0
- data/.taskmaster/tasks/task_016.md +19 -0
- data/.taskmaster/tasks/task_017.md +19 -0
- data/.taskmaster/tasks/task_018.md +19 -0
- data/.taskmaster/tasks/task_019.md +19 -0
- data/.taskmaster/tasks/task_020.md +19 -0
- data/.taskmaster/tasks/tasks.json +299 -0
- data/.taskmaster/templates/example_prd.txt +47 -0
- data/.taskmaster/templates/example_prd_rpg.txt +511 -0
- data/CHANGELOG.md +29 -0
- data/CLAUDE.md +65 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +49 -0
- data/LICENSE +21 -0
- data/README.md +463 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/data_porter/application.css +646 -0
- data/app/channels/data_porter/import_channel.rb +10 -0
- data/app/controllers/data_porter/imports_controller.rb +68 -0
- data/app/javascript/data_porter/progress_controller.js +33 -0
- data/app/jobs/data_porter/dry_run_job.rb +12 -0
- data/app/jobs/data_porter/import_job.rb +12 -0
- data/app/jobs/data_porter/parse_job.rb +12 -0
- data/app/models/data_porter/data_import.rb +49 -0
- data/app/views/data_porter/imports/index.html.erb +142 -0
- data/app/views/data_porter/imports/new.html.erb +88 -0
- data/app/views/data_porter/imports/show.html.erb +49 -0
- data/config/database.yml +3 -0
- data/config/routes.rb +12 -0
- data/docs/SPEC.md +2012 -0
- data/docs/UI.md +32 -0
- data/docs/blog/001-why-build-a-data-import-engine.md +166 -0
- data/docs/blog/002-scaffolding-a-rails-engine.md +188 -0
- data/docs/blog/003-configuration-dsl.md +222 -0
- data/docs/blog/004-store-model-jsonb.md +237 -0
- data/docs/blog/005-target-dsl.md +284 -0
- data/docs/blog/006-parsing-csv-sources.md +300 -0
- data/docs/blog/007-orchestrator.md +247 -0
- data/docs/blog/008-actioncable-stimulus.md +376 -0
- data/docs/blog/009-phlex-ui-components.md +446 -0
- data/docs/blog/010-controllers-routing.md +374 -0
- data/docs/blog/011-generators.md +364 -0
- data/docs/blog/012-json-api-sources.md +323 -0
- data/docs/blog/013-testing-rails-engine.md +618 -0
- data/docs/blog/014-dry-run.md +307 -0
- data/docs/blog/015-publishing-retro.md +264 -0
- data/docs/blog/016-erb-view-templates.md +431 -0
- data/docs/blog/017-showcase-final-retro.md +220 -0
- data/docs/blog/BACKLOG.md +8 -0
- data/docs/blog/SERIES.md +154 -0
- data/docs/screenshots/index-with-previewing.jpg +0 -0
- data/docs/screenshots/index.jpg +0 -0
- data/docs/screenshots/modal-new-import.jpg +0 -0
- data/docs/screenshots/preview.jpg +0 -0
- data/lib/data_porter/broadcaster.rb +29 -0
- data/lib/data_porter/components/base.rb +10 -0
- data/lib/data_porter/components/failure_alert.rb +20 -0
- data/lib/data_porter/components/preview_table.rb +54 -0
- data/lib/data_porter/components/progress_bar.rb +33 -0
- data/lib/data_porter/components/results_summary.rb +19 -0
- data/lib/data_porter/components/status_badge.rb +16 -0
- data/lib/data_porter/components/summary_cards.rb +30 -0
- data/lib/data_porter/components.rb +14 -0
- data/lib/data_porter/configuration.rb +25 -0
- data/lib/data_porter/dsl/api_config.rb +25 -0
- data/lib/data_porter/dsl/column.rb +17 -0
- data/lib/data_porter/engine.rb +15 -0
- data/lib/data_porter/orchestrator.rb +141 -0
- data/lib/data_porter/record_validator.rb +32 -0
- data/lib/data_porter/registry.rb +33 -0
- data/lib/data_porter/sources/api.rb +49 -0
- data/lib/data_porter/sources/base.rb +35 -0
- data/lib/data_porter/sources/csv.rb +43 -0
- data/lib/data_porter/sources/json.rb +45 -0
- data/lib/data_porter/sources.rb +20 -0
- data/lib/data_porter/store_models/error.rb +13 -0
- data/lib/data_porter/store_models/import_record.rb +52 -0
- data/lib/data_porter/store_models/report.rb +21 -0
- data/lib/data_porter/target.rb +89 -0
- data/lib/data_porter/type_validator.rb +46 -0
- data/lib/data_porter/version.rb +5 -0
- data/lib/data_porter.rb +32 -0
- data/lib/generators/data_porter/install/install_generator.rb +33 -0
- data/lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb +21 -0
- data/lib/generators/data_porter/install/templates/initializer.rb +30 -0
- data/lib/generators/data_porter/target/target_generator.rb +44 -0
- data/lib/generators/data_porter/target/templates/target.rb.tt +20 -0
- data/sig/data_porter.rbs +4 -0
- metadata +274 -0
data/README.md
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
# DataPorter
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine for 3-step data import workflows: **Upload**, **Preview**, **Import**.
|
|
4
|
+
|
|
5
|
+
Supports CSV, JSON, and API sources with a declarative DSL for defining import targets. Business-agnostic by design -- all domain logic lives in your host app.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- Ruby >= 3.2
|
|
16
|
+
- Rails >= 7.0
|
|
17
|
+
- ActionCable (for real-time progress updates)
|
|
18
|
+
- ActiveStorage (for file uploads)
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Add the gem to your Gemfile:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle add data_porter
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run the install generator:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bin/rails generate data_porter:install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This will:
|
|
35
|
+
- Create the migration for `data_porter_imports`
|
|
36
|
+
- Add an initializer at `config/initializers/data_porter.rb`
|
|
37
|
+
- Create the `app/importers/` directory
|
|
38
|
+
- Mount the engine at `/imports`
|
|
39
|
+
|
|
40
|
+
Run the migration:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bin/rails db:migrate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
Generate a target:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bin/rails generate data_porter:target Product name:string:required price:integer sku:string
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Implement the `persist` method in `app/importers/product_target.rb`:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
# frozen_string_literal: true
|
|
58
|
+
|
|
59
|
+
class ProductTarget < DataPorter::Target
|
|
60
|
+
label "Product"
|
|
61
|
+
model_name "Product"
|
|
62
|
+
icon "fas fa-file-import"
|
|
63
|
+
sources :csv
|
|
64
|
+
|
|
65
|
+
columns do
|
|
66
|
+
column :name, type: :string, required: true
|
|
67
|
+
column :price, type: :integer
|
|
68
|
+
column :sku, type: :string
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def persist(record, context:)
|
|
72
|
+
Product.create!(record.attributes)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Visit `/imports` and start importing.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
All options are set in `config/initializers/data_porter.rb`:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
DataPorter.configure do |config|
|
|
85
|
+
# Parent controller for the engine's controllers to inherit from.
|
|
86
|
+
# Controls authentication, layouts, and helpers.
|
|
87
|
+
config.parent_controller = "ApplicationController"
|
|
88
|
+
|
|
89
|
+
# ActiveJob queue name for import jobs.
|
|
90
|
+
config.queue_name = :imports
|
|
91
|
+
|
|
92
|
+
# ActiveStorage service for uploaded files.
|
|
93
|
+
config.storage_service = :local
|
|
94
|
+
|
|
95
|
+
# ActionCable channel prefix.
|
|
96
|
+
config.cable_channel_prefix = "data_porter"
|
|
97
|
+
|
|
98
|
+
# Context builder: inject business data into targets.
|
|
99
|
+
# Receives the current controller instance.
|
|
100
|
+
config.context_builder = ->(controller) {
|
|
101
|
+
OpenStruct.new(user: controller.current_user)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Maximum number of records displayed in preview.
|
|
105
|
+
config.preview_limit = 500
|
|
106
|
+
|
|
107
|
+
# Enabled source types.
|
|
108
|
+
config.enabled_sources = %i[csv json api]
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
| Option | Default | Description |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| `parent_controller` | `"ApplicationController"` | Controller class the engine inherits from |
|
|
115
|
+
| `queue_name` | `:imports` | ActiveJob queue for import jobs |
|
|
116
|
+
| `storage_service` | `:local` | ActiveStorage service name |
|
|
117
|
+
| `cable_channel_prefix` | `"data_porter"` | ActionCable stream prefix |
|
|
118
|
+
| `context_builder` | `nil` | Lambda receiving the controller, returns context passed to target methods |
|
|
119
|
+
| `preview_limit` | `500` | Max records shown in the preview step |
|
|
120
|
+
| `enabled_sources` | `%i[csv json api]` | Source types available in the UI |
|
|
121
|
+
|
|
122
|
+
## Defining Targets
|
|
123
|
+
|
|
124
|
+
Targets are plain Ruby classes in `app/importers/` that inherit from `DataPorter::Target`.
|
|
125
|
+
|
|
126
|
+
### Class-level DSL
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class OrderTarget < DataPorter::Target
|
|
130
|
+
label "Orders"
|
|
131
|
+
model_name "Order"
|
|
132
|
+
icon "fas fa-shopping-cart"
|
|
133
|
+
sources :csv, :json, :api
|
|
134
|
+
|
|
135
|
+
columns do
|
|
136
|
+
column :order_number, type: :string, required: true
|
|
137
|
+
column :total, type: :decimal
|
|
138
|
+
column :placed_at, type: :date
|
|
139
|
+
column :active, type: :boolean
|
|
140
|
+
column :quantity, type: :integer
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
csv_mapping do
|
|
144
|
+
map "Order #" => :order_number
|
|
145
|
+
map "Total ($)" => :total
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
json_root "data.orders"
|
|
149
|
+
|
|
150
|
+
api_config do
|
|
151
|
+
endpoint "https://api.example.com/orders"
|
|
152
|
+
headers({ "Authorization" => "Bearer token" })
|
|
153
|
+
response_root "data.orders"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
deduplicate_by :order_number
|
|
157
|
+
|
|
158
|
+
dry_run_enabled
|
|
159
|
+
|
|
160
|
+
# ...
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### `label(value)`
|
|
165
|
+
|
|
166
|
+
Human-readable name shown in the UI.
|
|
167
|
+
|
|
168
|
+
#### `model_name(value)`
|
|
169
|
+
|
|
170
|
+
The ActiveRecord model name this target imports into (for display purposes).
|
|
171
|
+
|
|
172
|
+
#### `icon(value)`
|
|
173
|
+
|
|
174
|
+
CSS icon class (e.g. FontAwesome) shown in the UI.
|
|
175
|
+
|
|
176
|
+
#### `sources(*types)`
|
|
177
|
+
|
|
178
|
+
Accepted source types: `:csv`, `:json`, `:api`.
|
|
179
|
+
|
|
180
|
+
#### `columns { ... }`
|
|
181
|
+
|
|
182
|
+
Defines the expected columns for this import. Each column accepts:
|
|
183
|
+
|
|
184
|
+
| Parameter | Type | Default | Description |
|
|
185
|
+
|---|---|---|---|
|
|
186
|
+
| `name` | Symbol | (required) | Column identifier |
|
|
187
|
+
| `type` | Symbol | `:string` | One of `:string`, `:integer`, `:decimal`, `:boolean`, `:date` |
|
|
188
|
+
| `required` | Boolean | `false` | Whether the column must have a value |
|
|
189
|
+
| `label` | String | Humanized name | Display label in the preview |
|
|
190
|
+
|
|
191
|
+
#### `csv_mapping { ... }`
|
|
192
|
+
|
|
193
|
+
Maps CSV header names to column names when they don't match:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
csv_mapping do
|
|
197
|
+
map "First Name" => :first_name
|
|
198
|
+
map "E-mail" => :email
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### `json_root(path)`
|
|
203
|
+
|
|
204
|
+
Dot-separated path to the array of records within a JSON document:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
json_root "data.users"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Given `{ "data": { "users": [...] } }`, records are extracted from `data.users`.
|
|
211
|
+
|
|
212
|
+
#### `api_config { ... }`
|
|
213
|
+
|
|
214
|
+
Configures the API source:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
api_config do
|
|
218
|
+
endpoint "https://api.example.com/records"
|
|
219
|
+
headers({ "Authorization" => "Bearer token", "Accept" => "application/json" })
|
|
220
|
+
response_root "data.items"
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### `deduplicate_by(*keys)`
|
|
225
|
+
|
|
226
|
+
Skip records that share the same value(s) for the given column(s):
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
deduplicate_by :email
|
|
230
|
+
deduplicate_by :first_name, :last_name
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
#### `dry_run_enabled`
|
|
234
|
+
|
|
235
|
+
Enables dry run mode for this target (see [Dry Run](#dry-run)).
|
|
236
|
+
|
|
237
|
+
### Instance Methods
|
|
238
|
+
|
|
239
|
+
Override these in your target to customize behavior:
|
|
240
|
+
|
|
241
|
+
#### `transform(record)`
|
|
242
|
+
|
|
243
|
+
Transform a record before validation. Must return the (modified) record.
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
def transform(record)
|
|
247
|
+
record.attributes["email"] = record.attributes["email"]&.downcase
|
|
248
|
+
record
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### `validate(record)`
|
|
253
|
+
|
|
254
|
+
Add custom validation errors to a record:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
def validate(record)
|
|
258
|
+
record.add_error("Email is invalid") unless record.attributes["email"]&.include?("@")
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### `persist(record, context:)`
|
|
263
|
+
|
|
264
|
+
**Required.** Save the record to your database. Raises `NotImplementedError` if not overridden.
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
def persist(record, context:)
|
|
268
|
+
User.create!(record.attributes)
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
#### `after_import(results, context:)`
|
|
273
|
+
|
|
274
|
+
Called once after all records have been processed:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
def after_import(results, context:)
|
|
278
|
+
AdminMailer.import_complete(context.user, results).deliver_later
|
|
279
|
+
end
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
#### `on_error(record, error, context:)`
|
|
283
|
+
|
|
284
|
+
Called when a record fails to import:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
def on_error(record, error, context:)
|
|
288
|
+
Sentry.capture_exception(error, extra: { record: record.attributes })
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Source Types
|
|
293
|
+
|
|
294
|
+
### CSV
|
|
295
|
+
|
|
296
|
+
Upload a CSV file. Configure header mappings with `csv_mapping` when headers don't match your column names.
|
|
297
|
+
|
|
298
|
+
### JSON
|
|
299
|
+
|
|
300
|
+
Upload a JSON file. Use `json_root` to specify the path to the records array. Raw JSON arrays are supported without `json_root`.
|
|
301
|
+
|
|
302
|
+
### API
|
|
303
|
+
|
|
304
|
+
Fetch records from an external API endpoint. No file upload is needed -- the engine calls the API directly.
|
|
305
|
+
|
|
306
|
+
#### Basic usage
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
api_config do
|
|
310
|
+
endpoint "https://api.example.com/data"
|
|
311
|
+
headers({ "Authorization" => "Bearer token" })
|
|
312
|
+
response_root "results"
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
| Option | Type | Description |
|
|
317
|
+
|---|---|---|
|
|
318
|
+
| `endpoint` | String or Proc | URL to fetch records from |
|
|
319
|
+
| `headers` | Hash or Proc | HTTP headers sent with the request |
|
|
320
|
+
| `response_root` | String | Key in the JSON response containing the records array (omit for top-level arrays) |
|
|
321
|
+
|
|
322
|
+
#### Dynamic endpoints and headers
|
|
323
|
+
|
|
324
|
+
Both `endpoint` and `headers` accept lambdas for runtime values. The endpoint lambda receives the import's `config` hash (populated from the form):
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
api_config do
|
|
328
|
+
endpoint ->(params) { "https://api.example.com/events?page=#{params[:page]}" }
|
|
329
|
+
headers -> { { "Authorization" => "Bearer #{ENV['API_TOKEN']}" } }
|
|
330
|
+
response_root "data"
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
#### Example: importing from a paginated API
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
class EventTarget < DataPorter::Target
|
|
338
|
+
label "Events"
|
|
339
|
+
model_name "Event"
|
|
340
|
+
sources :api
|
|
341
|
+
|
|
342
|
+
api_config do
|
|
343
|
+
endpoint "https://api.example.com/events"
|
|
344
|
+
headers -> { { "Authorization" => "Bearer #{ENV['EVENTS_API_KEY']}", "Accept" => "application/json" } }
|
|
345
|
+
response_root "events"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
columns do
|
|
349
|
+
column :name, type: :string, required: true
|
|
350
|
+
column :date, type: :date
|
|
351
|
+
column :venue, type: :string
|
|
352
|
+
column :capacity, type: :integer
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def persist(record, context:)
|
|
356
|
+
Event.create!(record.attributes)
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
When a user creates an import with source type **API**, the engine skips file upload entirely, calls the configured endpoint, parses the JSON response, and feeds the records through the same preview/validate/import pipeline as CSV and JSON sources.
|
|
362
|
+
|
|
363
|
+
## Import Workflow
|
|
364
|
+
|
|
365
|
+
Each import progresses through these statuses:
|
|
366
|
+
|
|
367
|
+
```
|
|
368
|
+
pending -> parsing -> previewing -> importing -> completed
|
|
369
|
+
\-> failed
|
|
370
|
+
pending -> parsing -> dry_running -> previewing
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
| Status | Description |
|
|
374
|
+
|---|---|
|
|
375
|
+
| `pending` | Import created, waiting for file/source |
|
|
376
|
+
| `parsing` | Source is being read and records extracted |
|
|
377
|
+
| `previewing` | Records parsed and ready for review |
|
|
378
|
+
| `importing` | Records are being persisted |
|
|
379
|
+
| `completed` | All records processed |
|
|
380
|
+
| `failed` | Import encountered a fatal error |
|
|
381
|
+
| `dry_running` | Dry run validation in progress |
|
|
382
|
+
|
|
383
|
+
### Routes
|
|
384
|
+
|
|
385
|
+
The engine provides these routes (mounted at your chosen path):
|
|
386
|
+
|
|
387
|
+
| Method | Path | Action |
|
|
388
|
+
|---|---|---|
|
|
389
|
+
| GET | `/imports` | List imports |
|
|
390
|
+
| GET | `/imports/new` | New import form |
|
|
391
|
+
| POST | `/imports` | Create import |
|
|
392
|
+
| GET | `/imports/:id` | Show import |
|
|
393
|
+
| POST | `/imports/:id/parse` | Parse uploaded source |
|
|
394
|
+
| POST | `/imports/:id/confirm` | Confirm and run import |
|
|
395
|
+
| POST | `/imports/:id/cancel` | Cancel import |
|
|
396
|
+
| POST | `/imports/:id/dry_run` | Run dry validation |
|
|
397
|
+
|
|
398
|
+
## Dry Run
|
|
399
|
+
|
|
400
|
+
When `dry_run_enabled` is declared on a target, a "Dry Run" button appears in the preview step. Dry run executes the full import pipeline (transform, validate, persist) inside a rolled-back transaction, giving you a validation report without modifying the database.
|
|
401
|
+
|
|
402
|
+
## Real-time Updates
|
|
403
|
+
|
|
404
|
+
DataPorter broadcasts import progress via ActionCable. The channel streams on:
|
|
405
|
+
|
|
406
|
+
```
|
|
407
|
+
#{cable_channel_prefix}/imports/#{import_id}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
The default prefix is `data_porter`, so a typical stream name is `data_porter/imports/42`.
|
|
411
|
+
|
|
412
|
+
The engine ships with a Stimulus controller that automatically subscribes to the channel and updates the UI during parsing and importing.
|
|
413
|
+
|
|
414
|
+
## Generators
|
|
415
|
+
|
|
416
|
+
### `data_porter:install`
|
|
417
|
+
|
|
418
|
+
```bash
|
|
419
|
+
bin/rails generate data_porter:install
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Sets up the migration, initializer, `app/importers/` directory, and mounts the engine.
|
|
423
|
+
|
|
424
|
+
### `data_porter:target`
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
bin/rails generate data_porter:target ModelName column:type[:required] ...
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Examples:
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
bin/rails generate data_porter:target User email:string:required name:string age:integer
|
|
434
|
+
bin/rails generate data_porter:target Product name:string price:decimal
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
Column format: `name:type[:required]`
|
|
438
|
+
|
|
439
|
+
Supported types: `string`, `integer`, `decimal`, `boolean`, `date`.
|
|
440
|
+
|
|
441
|
+
## Development
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
git clone https://github.com/SerylLns/data_porter.git
|
|
445
|
+
cd data_porter
|
|
446
|
+
bin/setup
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Run the test suite:
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
bundle exec rspec
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Run the linter:
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
bundle exec rubocop
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## License
|
|
462
|
+
|
|
463
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|