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
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Building DataPorter #10 -- Controllers & Routing in a Rails Engine"
|
|
3
|
+
series: "Building DataPorter - A Data Import Engine for Rails"
|
|
4
|
+
part: 10
|
|
5
|
+
tags: [ruby, rails, rails-engine, gem-development, controllers, routing, strong-params]
|
|
6
|
+
published: false
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Controllers & Routing in a Rails Engine
|
|
10
|
+
|
|
11
|
+
> Engine controllers are tricky -- they need to inherit from the host app, define routes that do not collide with anything else, and stay thin while coordinating an entire import workflow. Here is the clean way.
|
|
12
|
+
|
|
13
|
+
## Context
|
|
14
|
+
|
|
15
|
+
This is part 10 of the series where we build **DataPorter**, a mountable Rails engine for data import workflows. In [part 9](#), we built the UI layer with Phlex components and scoped Tailwind styles, giving users a visual interface for previewing and managing imports.
|
|
16
|
+
|
|
17
|
+
But a UI needs a backend to drive it. We need controller actions that create imports, kick off background jobs, let users confirm or cancel, and route everything under a clean, isolated namespace. In this article, we build the ImportsController, wire up engine routes with member actions, and solve one of the trickiest problems in engine development: making the controller inherit from whichever parent class the host app wants.
|
|
18
|
+
|
|
19
|
+
## The parent controller pattern
|
|
20
|
+
|
|
21
|
+
When you build a Rails engine, your controllers need to inherit from *something*. The obvious choice is `ActionController::Base`, but that is almost never what you want. Host apps typically have authentication, authorization, error handling, and layout logic on their own `ApplicationController` (or a dedicated `AdminController`). If your engine inherits from `ActionController::Base`, none of that flows through. The user would need to separately configure auth for every engine route -- a terrible experience.
|
|
22
|
+
|
|
23
|
+
The solution is dynamic inheritance. DataPorter's Configuration class exposes a `parent_controller` setting that defaults to `"ApplicationController"`:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
# lib/data_porter/configuration.rb
|
|
27
|
+
class Configuration
|
|
28
|
+
attr_accessor :parent_controller,
|
|
29
|
+
:queue_name,
|
|
30
|
+
:storage_service,
|
|
31
|
+
:cable_channel_prefix,
|
|
32
|
+
:context_builder,
|
|
33
|
+
:preview_limit,
|
|
34
|
+
:enabled_sources,
|
|
35
|
+
:scope
|
|
36
|
+
|
|
37
|
+
def initialize
|
|
38
|
+
@parent_controller = "ApplicationController"
|
|
39
|
+
@queue_name = :imports
|
|
40
|
+
@storage_service = :local
|
|
41
|
+
@cable_channel_prefix = "data_porter"
|
|
42
|
+
@context_builder = nil
|
|
43
|
+
@preview_limit = 500
|
|
44
|
+
@enabled_sources = %i[csv json api]
|
|
45
|
+
@scope = nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The controller then uses `constantize` at class definition time to resolve the string into a class:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# app/controllers/data_porter/imports_controller.rb
|
|
54
|
+
module DataPorter
|
|
55
|
+
class ImportsController < DataPorter.configuration.parent_controller.constantize
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This single line is doing a lot. When Ruby loads the controller file, it evaluates the superclass expression. `DataPorter.configuration.parent_controller` returns a string like `"ApplicationController"` or `"Admin::BaseController"`, and `.constantize` turns it into the actual class. The result is that `ImportsController` inherits from whatever the host app configured -- picking up its before_actions, authentication, layouts, and helper methods automatically.
|
|
59
|
+
|
|
60
|
+
A host app that wants all DataPorter routes behind admin authentication just needs:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# config/initializers/data_porter.rb
|
|
64
|
+
DataPorter.configure do |config|
|
|
65
|
+
config.parent_controller = "Admin::BaseController"
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
No monkey-patching, no middleware, no route constraints. The engine's controller *is* an admin controller.
|
|
70
|
+
|
|
71
|
+
The tradeoff is that this happens once, at load time. If you change the configuration after the controller class has been loaded, the inheritance does not update. In practice this is not an issue -- initializers run before controllers are loaded in production, and in development Rails reloads everything per-request anyway. But it is worth knowing: the parent controller is resolved eagerly, not lazily.
|
|
72
|
+
|
|
73
|
+
## Engine routes
|
|
74
|
+
|
|
75
|
+
DataPorter draws its routes inside the engine's own router, completely isolated from the host app:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# config/routes.rb
|
|
79
|
+
DataPorter::Engine.routes.draw do
|
|
80
|
+
resources :imports, only: %i[index new create show] do
|
|
81
|
+
member do
|
|
82
|
+
post :parse
|
|
83
|
+
post :confirm
|
|
84
|
+
post :cancel
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The host app mounts the engine at whatever path it wants:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# Host app: config/routes.rb
|
|
94
|
+
mount DataPorter::Engine, at: "/data_porter"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This produces the following route table:
|
|
98
|
+
|
|
99
|
+
| HTTP method | Path | Action | Purpose |
|
|
100
|
+
|-------------|------|--------|---------|
|
|
101
|
+
| GET | `/data_porter/imports` | `index` | List all imports |
|
|
102
|
+
| GET | `/data_porter/imports/new` | `new` | New import form |
|
|
103
|
+
| POST | `/data_porter/imports` | `create` | Create and start parsing |
|
|
104
|
+
| GET | `/data_porter/imports/:id` | `show` | Preview/status page |
|
|
105
|
+
| POST | `/data_porter/imports/:id/parse` | `parse` | Re-parse an import |
|
|
106
|
+
| POST | `/data_porter/imports/:id/confirm` | `confirm` | Confirm and start importing |
|
|
107
|
+
| POST | `/data_porter/imports/:id/cancel` | `cancel` | Cancel an import |
|
|
108
|
+
|
|
109
|
+
Notice what is *not* here: `edit`, `update`, `destroy`. An import is not a CRUD resource in the traditional sense. You create it, you preview it, you confirm or cancel it. There is no "edit an import" workflow -- if the data is wrong, you cancel and create a new one. The routes reflect this by using `only:` to restrict the standard REST actions and adding three custom member routes for the state-transition actions.
|
|
110
|
+
|
|
111
|
+
The member routes are all `post`, not `patch` or `put`. These are command actions that trigger side effects (enqueue a job, change status), not updates to a resource's attributes. POST is semantically correct here and plays well with Turbo, which sends POST requests from `button_to` helpers by default.
|
|
112
|
+
|
|
113
|
+
Because `isolate_namespace` is set on the engine, all routes are automatically scoped. Inside the controller, you use `import_path(@import)` and `imports_path` -- no prefix needed. The engine's router handles the namespace. This also means the host app's own `imports_path` (if it has one) is completely separate.
|
|
114
|
+
|
|
115
|
+
## Each action explained
|
|
116
|
+
|
|
117
|
+
Here is the full controller, then we will walk through each action:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# app/controllers/data_porter/imports_controller.rb
|
|
121
|
+
module DataPorter
|
|
122
|
+
class ImportsController < DataPorter.configuration.parent_controller.constantize
|
|
123
|
+
before_action :set_import, only: %i[show parse confirm cancel]
|
|
124
|
+
|
|
125
|
+
def index
|
|
126
|
+
@imports = DataPorter::DataImport.order(created_at: :desc)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def new
|
|
130
|
+
@import = DataPorter::DataImport.new
|
|
131
|
+
@targets = DataPorter::Registry.available
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def create
|
|
135
|
+
@import = DataPorter::DataImport.new(import_params)
|
|
136
|
+
@import.user = current_user if respond_to?(:current_user, true)
|
|
137
|
+
@import.status = :pending
|
|
138
|
+
|
|
139
|
+
if @import.save
|
|
140
|
+
DataPorter::ParseJob.perform_later(@import.id)
|
|
141
|
+
redirect_to import_path(@import)
|
|
142
|
+
else
|
|
143
|
+
@targets = DataPorter::Registry.available
|
|
144
|
+
render :new
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def show
|
|
149
|
+
@target = @import.target_class
|
|
150
|
+
@records = @import.records
|
|
151
|
+
@grouped = @records.group_by(&:status)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse
|
|
155
|
+
@import.update!(status: :pending)
|
|
156
|
+
DataPorter::ParseJob.perform_later(@import.id)
|
|
157
|
+
redirect_to import_path(@import)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def confirm
|
|
161
|
+
DataPorter::ImportJob.perform_later(@import.id)
|
|
162
|
+
redirect_to import_path(@import)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def cancel
|
|
166
|
+
@import.update!(status: :failed)
|
|
167
|
+
redirect_to imports_path
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def set_import
|
|
173
|
+
@import = DataPorter::DataImport.find(params[:id])
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def import_params
|
|
177
|
+
params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**`index`** -- Lists all imports, newest first. No pagination yet (that is a future enhancement), but the query is simple enough that the view layer can handle it. This is the dashboard view.
|
|
184
|
+
|
|
185
|
+
**`new`** -- Builds a blank DataImport and loads the available targets from the Registry. The view uses these to render a dropdown of import types. This is where the Target DSL pays off: each registered target provides its own label and icon, and the controller just passes the list through.
|
|
186
|
+
|
|
187
|
+
**`create`** -- The most interesting action. It builds the import from strong params, optionally assigns the current user (using `respond_to?(:current_user, true)` to avoid crashing if the host app has no authentication), sets the initial status, and saves. On success, it immediately enqueues a `ParseJob` and redirects to the show page where the user will see real-time progress. On failure, it reloads the targets and re-renders the form.
|
|
188
|
+
|
|
189
|
+
The `respond_to?(:current_user, true)` check deserves attention. The second argument (`true`) includes private methods in the check. Some authentication gems define `current_user` as a private helper. By passing `true`, we catch both public and private definitions. If the host app has no authentication at all, the check returns false and the user association is simply not set. The import still works -- `user` is a polymorphic optional association.
|
|
190
|
+
|
|
191
|
+
**`show`** -- Loads the target class, the records, and groups them by status. The view uses this grouping to show separate sections for complete, partial, and missing records. This is the preview page when the import is in `previewing` status, and the results page when it is `completed`.
|
|
192
|
+
|
|
193
|
+
**`parse`** -- Re-parses an existing import. This resets the status to `pending` and enqueues a new ParseJob. Useful when the user realizes the CSV mapping was wrong and re-uploads a file, or when a previous parse failed and they want to retry.
|
|
194
|
+
|
|
195
|
+
**`confirm`** -- The user has reviewed the preview and decided to proceed. This enqueues an ImportJob and redirects back to the show page, where ActionCable will push real-time progress updates (as we built in part 8).
|
|
196
|
+
|
|
197
|
+
**`cancel`** -- Marks the import as failed and redirects to the index. No job is enqueued, no further processing happens. The import is preserved in the database for auditing but is effectively dead.
|
|
198
|
+
|
|
199
|
+
## Strong params
|
|
200
|
+
|
|
201
|
+
The `import_params` method is deliberately minimal:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
def import_params
|
|
205
|
+
params.require(:data_import).permit(:target_key, :source_type, :file, config: {})
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Four permitted fields:
|
|
210
|
+
|
|
211
|
+
- **`target_key`** -- A string like `"user_import"` that maps to a registered target. The Target DSL and Registry validate this downstream.
|
|
212
|
+
- **`source_type`** -- A string like `"csv"`, `"json"`, or `"api"`. Determines which Source class handles the data.
|
|
213
|
+
- **`file`** -- An ActiveStorage attachment. The uploaded CSV, JSON, or other file.
|
|
214
|
+
- **`config`** -- A hash for source-specific configuration (API endpoints, headers, JSON root paths). The `config: {}` syntax permits any keys inside the hash, which is intentional: different source types need different config keys, and we validate them at the Source level rather than the controller level.
|
|
215
|
+
|
|
216
|
+
Notably absent: `status`, `records`, `report`, `user_id`, `user_type`. These are all set internally -- status by the controller and orchestrator, records and report by the ParseJob, and user by the controller's `current_user` logic. Strong params prevents any of these from being injected through the form.
|
|
217
|
+
|
|
218
|
+
## Decisions & tradeoffs
|
|
219
|
+
|
|
220
|
+
| Decision | We chose | Over | Because |
|
|
221
|
+
|----------|----------|------|---------|
|
|
222
|
+
| Parent controller | Dynamic inheritance via `constantize` | Hardcoded `ActionController::Base` | Host app authentication, layouts, and helpers flow through automatically; zero extra configuration for auth |
|
|
223
|
+
| Route design | `only:` with custom member actions | Full `resources` CRUD | Imports are not a traditional CRUD resource; explicit routes match the actual workflow (create, preview, confirm/cancel) |
|
|
224
|
+
| Member action verbs | All POST | PATCH for parse, DELETE for cancel | These are command actions with side effects, not attribute updates; POST is semantically correct and works naturally with Turbo's `button_to` |
|
|
225
|
+
| User assignment | `respond_to?(:current_user, true)` guard | Requiring authentication | The engine must work with and without authentication; optional user association keeps it flexible |
|
|
226
|
+
| Config params | `config: {}` (open hash) | Explicitly listing every config key | Different source types need different config keys; validation happens at the Source level where context exists |
|
|
227
|
+
| No edit/update/destroy | Omitted from routes entirely | Including them "just in case" | An import is an immutable workflow; if it is wrong, you cancel and start over; fewer routes means fewer security surfaces |
|
|
228
|
+
|
|
229
|
+
## Testing it
|
|
230
|
+
|
|
231
|
+
Testing engine controllers without a full Rails app or a dummy app is the interesting challenge here. The approach we use: stub just enough of the Rails stack to load the controller class, then test its structure directly.
|
|
232
|
+
|
|
233
|
+
The spec helper handles the setup. It loads the minimal Rails dependencies, sets up an in-memory SQLite database, stubs `ApplicationController`, and manually requires the controller:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# spec/spec_helper.rb (relevant excerpt)
|
|
237
|
+
require "rails"
|
|
238
|
+
require "action_controller"
|
|
239
|
+
|
|
240
|
+
# Stub for controller inheritance in test context
|
|
241
|
+
class ApplicationController < ActionController::Base; end unless defined?(ApplicationController)
|
|
242
|
+
|
|
243
|
+
$LOAD_PATH.unshift File.expand_path("../app/controllers", __dir__)
|
|
244
|
+
require "data_porter/imports_controller"
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The `unless defined?(ApplicationController)` guard is important -- it prevents double-definition errors if the host app's ApplicationController has already been loaded (which happens in integration test suites).
|
|
248
|
+
|
|
249
|
+
With that foundation, the controller spec tests structure rather than HTTP behavior:
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
# spec/data_porter/imports_controller_spec.rb
|
|
253
|
+
RSpec.describe DataPorter::ImportsController do
|
|
254
|
+
let(:target_class) do
|
|
255
|
+
Class.new(DataPorter::Target) do
|
|
256
|
+
label "Test Import"
|
|
257
|
+
icon "fas fa-test"
|
|
258
|
+
model_name "TestModel"
|
|
259
|
+
|
|
260
|
+
columns do
|
|
261
|
+
column :name, type: :string, required: true
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
before do
|
|
267
|
+
DataPorter::Registry.clear
|
|
268
|
+
DataPorter::Registry.register(:test_import, target_class)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
after { DataPorter::Registry.clear }
|
|
272
|
+
|
|
273
|
+
describe "inheritance" do
|
|
274
|
+
it "inherits from the configured parent controller" do
|
|
275
|
+
expect(described_class.superclass.name).to eq(DataPorter.configuration.parent_controller)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
describe "before_actions" do
|
|
280
|
+
it "registers set_import callback" do
|
|
281
|
+
callbacks = described_class._process_action_callbacks.select do |c|
|
|
282
|
+
c.filter == :set_import
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
expect(callbacks).not_to be_empty
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
describe "action methods" do
|
|
290
|
+
it "defines index, new, create, show, parse, confirm, and cancel" do
|
|
291
|
+
actions = %i[index new create show parse confirm cancel]
|
|
292
|
+
actions.each do |action|
|
|
293
|
+
expect(described_class.instance_method(action)).to be_a(UnboundMethod)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
describe "private methods" do
|
|
299
|
+
it "defines set_import" do
|
|
300
|
+
expect(described_class.private_instance_methods).to include(:set_import)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
it "defines import_params" do
|
|
304
|
+
expect(described_class.private_instance_methods).to include(:import_params)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
There are a few things worth noting about this approach:
|
|
311
|
+
|
|
312
|
+
First, the tests verify *inheritance* rather than HTTP responses. The `"inherits from the configured parent controller"` spec confirms that the dynamic `constantize` pattern actually works and that the controller's superclass matches the configuration. If someone changes the inheritance line, this catches it.
|
|
313
|
+
|
|
314
|
+
Second, callback registration is tested by inspecting `_process_action_callbacks` directly. This is a Rails internal API, but it is stable and avoids the need to make real HTTP requests just to check that a before_action is wired up.
|
|
315
|
+
|
|
316
|
+
Third, the action methods spec iterates over all seven expected actions and verifies they exist as instance methods. This is a structural test -- it does not exercise the action logic, but it catches missing methods immediately.
|
|
317
|
+
|
|
318
|
+
The routes get their own spec that redraws the engine routes and inspects the route set:
|
|
319
|
+
|
|
320
|
+
```ruby
|
|
321
|
+
# spec/data_porter/routes_spec.rb
|
|
322
|
+
RSpec.describe "DataPorter routes" do
|
|
323
|
+
before(:all) do
|
|
324
|
+
DataPorter::Engine.routes.draw do
|
|
325
|
+
resources :imports, only: %i[index new create show] do
|
|
326
|
+
member do
|
|
327
|
+
post :parse
|
|
328
|
+
post :confirm
|
|
329
|
+
post :cancel
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
it "defines import resource routes" do
|
|
336
|
+
routes = DataPorter::Engine.routes
|
|
337
|
+
route_set = routes.routes.map { |r| [r.defaults[:action], r.path.spec.to_s] }
|
|
338
|
+
|
|
339
|
+
expect(route_set).to include(["index", "/imports(.:format)"])
|
|
340
|
+
expect(route_set).to include(["new", "/imports/new(.:format)"])
|
|
341
|
+
expect(route_set).to include(["create", "/imports(.:format)"])
|
|
342
|
+
expect(route_set).to include(["show", "/imports/:id(.:format)"])
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
it "defines member routes for parse, confirm, and cancel" do
|
|
346
|
+
routes = DataPorter::Engine.routes
|
|
347
|
+
route_set = routes.routes.map { |r| [r.defaults[:action], r.path.spec.to_s] }
|
|
348
|
+
|
|
349
|
+
expect(route_set).to include(["parse", "/imports/:id/parse(.:format)"])
|
|
350
|
+
expect(route_set).to include(["confirm", "/imports/:id/confirm(.:format)"])
|
|
351
|
+
expect(route_set).to include(["cancel", "/imports/:id/cancel(.:format)"])
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The `before(:all)` block redraws the routes inside the test environment. This is necessary because the engine's routes file is loaded differently in test versus production. By explicitly drawing the routes, we guarantee the test is exercising the exact route definitions we ship, not whatever routes happened to be loaded by a host app. The specs then map each route to an `[action, path]` pair and verify all seven are present with the correct paths.
|
|
357
|
+
|
|
358
|
+
This style of testing -- structural rather than behavioral -- may feel unusual. But for an engine, it is the right level of abstraction. Full request specs belong in the host app's integration suite, where they can exercise the real authentication stack, the real database, and the real views. The engine's specs verify that the pieces are correctly defined and wired together.
|
|
359
|
+
|
|
360
|
+
## Recap
|
|
361
|
+
|
|
362
|
+
- The **dynamic parent controller pattern** uses `constantize` to resolve the configured controller name at class-load time, allowing the engine to inherit authentication, layouts, and helpers from whatever base class the host app chooses.
|
|
363
|
+
- **Engine routes** are drawn inside `DataPorter::Engine.routes.draw`, keeping them isolated from the host app. The host mounts the engine at any path it wants.
|
|
364
|
+
- **Seven actions** map to a clean import workflow: list, create, preview, re-parse, confirm, cancel. No edit or destroy -- imports are immutable workflows.
|
|
365
|
+
- **Strong params** permit only the fields the user should control (`target_key`, `source_type`, `file`, `config`), while status, records, and user are set internally.
|
|
366
|
+
- **Controller specs** test structure (inheritance, callbacks, method existence) rather than HTTP behavior, avoiding the need for a full dummy app.
|
|
367
|
+
|
|
368
|
+
## Next up
|
|
369
|
+
|
|
370
|
+
The controller and routes give us a working web interface, but setting up DataPorter in a host app still requires creating a migration, writing an initializer, mounting routes, and knowing the right directory for target classes. In part 11, we build **install and target generators** -- `rails generate data_porter:install` that handles the entire setup in one command, and `rails generate data_porter:target` that scaffolds a new import type with columns pre-filled from the command line.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
*This is part 10 of the series "Building DataPorter - A Data Import Engine for Rails". [Previous: Building the UI with Phlex & Tailwind](#) | [Next: Generators: Install & Target Scaffolding](#)*
|