data_porter 2.1.1 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '04931dd3e74ae9a12b2225ddb1c5467022458013d49fb35a07282121296ec22b'
4
- data.tar.gz: 4d0d04002509ae87358375f0df64bec0d8c134201131ead1a1a07460c9e19297
3
+ metadata.gz: 14b97e1d544f1169ed2e19763f1ae4ae1d15373cd809f5c6ddb46ebe286cc804
4
+ data.tar.gz: 5e7fd19fdf843d6f7721ef91d3147197d06d453771990408354a38d8758f647b
5
5
  SHA512:
6
- metadata.gz: 11ba16dcc818425722fc20e1a919833e4c4f77def52c07b46068707a1e511bb045bf7801edfb9b3186750fbf242a633d526e81e6b1bf3cd4b54b806c47e701aa
7
- data.tar.gz: 4c99d6a1c4d04e4cee197199db9dd3517bc7d44b283e511a7289e46703ddf3d69f91a8cd7e4fb4f43ce5e8fdaaccbe805b5a649846d1c5e096a7803eafb07442
6
+ metadata.gz: b23a128b472327079631531f020fda16ea7399bb6c2f09437c8976ddf738800e1738c9afb9167db359c662d02e29ca7994bc7709d97ecb4a46e57df8e691a02f
7
+ data.tar.gz: 1bb9f3cc08b7fee3678638cfb047e191c495447afc93db7115c5a6c2914851f127ed9bf4ab7c44d110cc2585ae2de0fd9ee67d6a469082e9ede3d71315a9655a
data/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.3.0] - 2026-02-20
9
+
10
+ ### Added
11
+
12
+ - **Webhooks** -- Per-target HTTP callbacks on import lifecycle events (`import.started`, `import.parsed`, `import.completed`, `import.failed`). Declarative DSL via `webhooks do webhook(url, events:, headers:, payload:) end`. HMAC-SHA256 request signing via `config.webhook_secret`. Async delivery via `WebhookJob` (fire-and-forget, 10s timeout). Custom payload lambdas and per-webhook headers supported
13
+
14
+ ### Changed
15
+
16
+ - 508 RSpec examples (up from 466), 0 failures
17
+
18
+ ## [2.2.0] - 2026-02-20
19
+
20
+ ### Added
21
+
22
+ - **Column transformers** -- Declarative per-column transformation pipeline via `transform: [:strip, :downcase]` in the columns DSL. Applied automatically before the target's `transform` method. Ships with 9 built-in transformers (`strip`, `downcase`, `upcase`, `titleize`, `normalize_phone`, `parse_date`, `parse_boolean`, `parse_integer`, `parse_decimal`). Custom transformers via `DataPorter::ColumnTransformer.register(:name) { |v| ... }`
23
+
24
+ ### Changed
25
+
26
+ - 466 RSpec examples (up from 438), 0 failures
27
+
8
28
  ## [2.1.1] - 2026-02-20
9
29
 
10
30
  ### Fixed
data/ROADMAP.md CHANGED
@@ -2,28 +2,6 @@
2
2
 
3
3
  ## Next
4
4
 
5
- ### Column transformers
6
-
7
- Built-in transformation pipeline applied per-column before the target's `transform` method. Declarative DSL in the target:
8
-
9
- ```ruby
10
- columns do
11
- column :email, type: :string, transform: [:strip, :downcase]
12
- column :phone, type: :string, transform: [:strip, :normalize_phone]
13
- column :born_on, type: :date, transform: [:parse_date]
14
- end
15
- ```
16
-
17
- Ships with common transformers (`strip`, `downcase`, `titleize`, `normalize_phone`, `parse_date`). Custom transformers via a registry.
18
-
19
- ### Webhooks
20
-
21
- HTTP callbacks on import lifecycle events (started, completed, failed). Configurable per-target with URL, headers, and payload template. Enables integration with Slack notifications, CI pipelines, or external dashboards.
22
-
23
- ---
24
-
25
- ## Planned
26
-
27
5
  ### Bulk import
28
6
 
29
7
  High-volume import support using `insert_all` / `upsert_all` for batch persistence. Opt-in per target to bypass per-record `persist` calls, enabling 10-100x throughput for simple create/upsert scenarios. Configurable batch size, with fallback to per-record mode on conflict.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module DataPorter
7
+ class WebhookJob < ActiveJob::Base
8
+ queue_as { DataPorter.configuration.queue_name }
9
+
10
+ def perform(url, payload_json, headers = {})
11
+ uri = URI.parse(url)
12
+ request = build_request(uri, payload_json, headers)
13
+ execute_request(uri, request)
14
+ end
15
+
16
+ private
17
+
18
+ def build_request(uri, payload_json, headers)
19
+ request = Net::HTTP::Post.new(uri.request_uri)
20
+ request["Content-Type"] = "application/json"
21
+ headers.each { |key, value| request[key] = value }
22
+ sign_request(request, payload_json)
23
+ request.body = payload_json
24
+ request
25
+ end
26
+
27
+ def sign_request(request, payload_json)
28
+ secret = DataPorter.configuration.webhook_secret
29
+ return unless secret
30
+
31
+ digest = OpenSSL::HMAC.hexdigest("SHA256", secret, payload_json)
32
+ request["X-DataPorter-Signature"] = "sha256=#{digest}"
33
+ end
34
+
35
+ def execute_request(uri, request)
36
+ http = Net::HTTP.new(uri.host, uri.port)
37
+ http.use_ssl = uri.scheme == "https"
38
+ http.open_timeout = 10
39
+ http.read_timeout = 10
40
+ http.request(request)
41
+ rescue StandardError => e
42
+ Rails.logger.error("[DataPorter] Webhook delivery failed: #{e.message}")
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module ColumnTransformer
5
+ BUILT_IN = {
6
+ strip: :strip.to_proc,
7
+ downcase: :downcase.to_proc,
8
+ upcase: :upcase.to_proc,
9
+ titleize: :titleize.to_proc,
10
+ normalize_phone: ->(value) { value.gsub(/[\s\-().]+/, "") },
11
+ parse_date: ->(value) { Date.parse(value).iso8601 rescue value }, # rubocop:disable Style/RescueModifier
12
+ parse_boolean: ->(value) { %w[true 1 yes oui].include?(value.downcase) ? "true" : "false" },
13
+ parse_integer: ->(value) { Float(value, exception: false) ? value.to_f.to_i.to_s : value },
14
+ parse_decimal: ->(value) { Float(value, exception: false)&.to_s || value }
15
+ }.freeze
16
+
17
+ def self.apply(value, transformer_name)
18
+ return value if value.nil?
19
+
20
+ transformer = BUILT_IN[transformer_name] || custom_transformers[transformer_name]
21
+ raise Error, "Unknown transformer: #{transformer_name}" unless transformer
22
+
23
+ transformer.call(value.to_s)
24
+ end
25
+
26
+ def self.register(name, &block)
27
+ custom_transformers[name.to_sym] = block
28
+ end
29
+
30
+ def self.apply_all(record, columns)
31
+ columns.each do |col|
32
+ next if col.transform.empty?
33
+
34
+ key = resolve_key(record.data, col.name)
35
+ next unless key
36
+
37
+ col.transform.each do |t|
38
+ record.data[key] = apply(record.data[key], t)
39
+ end
40
+ end
41
+ end
42
+
43
+ def self.resolve_key(data, name)
44
+ return name.to_s if data.key?(name.to_s)
45
+ return name if data.key?(name)
46
+
47
+ nil
48
+ end
49
+
50
+ def self.custom_transformers
51
+ @custom_transformers ||= {}
52
+ end
53
+
54
+ private_class_method :custom_transformers, :resolve_key
55
+ end
56
+ end
@@ -13,7 +13,8 @@ module DataPorter
13
13
  :purge_after,
14
14
  :max_file_size,
15
15
  :max_records,
16
- :transaction_mode
16
+ :transaction_mode,
17
+ :webhook_secret
17
18
 
18
19
  def initialize
19
20
  @parent_controller = "ActionController::Base"
@@ -28,6 +29,7 @@ module DataPorter
28
29
  @max_file_size = 10.megabytes
29
30
  @max_records = 10_000
30
31
  @transaction_mode = :per_record
32
+ @webhook_secret = nil
31
33
  end
32
34
  end
33
35
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module DataPorter
4
4
  module DSL
5
- Column = Struct.new(:name, :type, :required, :label, :options, keyword_init: true) do
6
- def initialize(name:, type: :string, required: false, label: nil, **options)
5
+ Column = Struct.new(:name, :type, :required, :label, :transform, :options, keyword_init: true) do
6
+ def initialize(name:, type: :string, required: false, label: nil, transform: [], **options)
7
7
  super(
8
8
  name: name.to_sym,
9
9
  type: type.to_sym,
10
10
  required: required,
11
11
  label: label || name.to_s.humanize,
12
+ transform: Array(transform),
12
13
  options: options
13
14
  )
14
15
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module DSL
5
+ VALID_WEBHOOK_EVENTS = %i[started completed failed parsed].freeze
6
+
7
+ Webhook = Struct.new(:url, :events, :headers, :payload, keyword_init: true) do
8
+ def initialize(url:, events: VALID_WEBHOOK_EVENTS, headers: {}, payload: nil)
9
+ validate_url!(url)
10
+ events = events.map(&:to_sym)
11
+ validate_events!(events)
12
+ super
13
+ end
14
+
15
+ private
16
+
17
+ def validate_url!(url)
18
+ raise ArgumentError, "url is required" if url.nil? || url.to_s.strip.empty?
19
+ end
20
+
21
+ def validate_events!(events)
22
+ events.each do |event|
23
+ next if VALID_WEBHOOK_EVENTS.include?(event)
24
+
25
+ raise ArgumentError,
26
+ "invalid webhook event: #{event}. Must be one of: #{VALID_WEBHOOK_EVENTS.join(", ")}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -45,6 +45,7 @@ module DataPorter
45
45
  def finalize_import(results)
46
46
  @data_import.update!(status: :completed)
47
47
  @broadcaster.success
48
+ WebhookNotifier.notify(@data_import, "import.completed")
48
49
  results
49
50
  end
50
51
 
@@ -30,6 +30,7 @@ module DataPorter
30
30
  line_number: index + 1,
31
31
  data: extract_data(row, columns)
32
32
  )
33
+ ColumnTransformer.apply_all(record, columns)
33
34
  record = @target.transform(record)
34
35
  @target.validate(record)
35
36
  validator.validate(record)
@@ -32,12 +32,14 @@ module DataPorter
32
32
  records = build_records
33
33
  @data_import.update!(records: records, status: :previewing)
34
34
  build_report
35
+ WebhookNotifier.notify(@data_import, "import.parsed")
35
36
  rescue StandardError => e
36
37
  handle_failure(e)
37
38
  end
38
39
 
39
40
  def import!
40
41
  @data_import.importing!
42
+ WebhookNotifier.notify(@data_import, "import.started")
41
43
  results = import_records
42
44
  update_import_report(results)
43
45
  @target.after_import(results, context: build_context)
@@ -102,6 +104,7 @@ module DataPorter
102
104
  )
103
105
  @data_import.update!(status: :failed, report: report)
104
106
  @broadcaster.failure(error.message)
107
+ WebhookNotifier.notify(@data_import, "import.failed")
105
108
  end
106
109
  end
107
110
  end
@@ -3,13 +3,14 @@
3
3
  require_relative "dsl/column"
4
4
  require_relative "dsl/param"
5
5
  require_relative "dsl/api_config"
6
+ require_relative "dsl/webhook"
6
7
 
7
8
  module DataPorter
8
9
  class Target
9
10
  class << self
10
11
  attr_reader :_label, :_model_name, :_icon, :_sources,
11
12
  :_columns, :_csv_mappings, :_dedup_keys, :_json_root,
12
- :_api_config, :_dry_run_enabled, :_params
13
+ :_api_config, :_dry_run_enabled, :_params, :_webhooks
13
14
 
14
15
  def label(value)
15
16
  @_label = value
@@ -72,6 +73,15 @@ module DataPorter
72
73
  @_params << DSL::Param.new(name: name, **)
73
74
  end
74
75
 
76
+ def webhooks(&)
77
+ @_webhooks = []
78
+ instance_eval(&)
79
+ end
80
+
81
+ def webhook(url, **)
82
+ @_webhooks << DSL::Webhook.new(url: url, **)
83
+ end
84
+
75
85
  private
76
86
 
77
87
  def auto_register
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DataPorter
4
- VERSION = "2.1.1"
4
+ VERSION = "2.3.0"
5
5
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataPorter
4
+ module WebhookNotifier
5
+ EVENT_MAP = {
6
+ "import.started" => :started,
7
+ "import.completed" => :completed,
8
+ "import.failed" => :failed,
9
+ "import.parsed" => :parsed
10
+ }.freeze
11
+
12
+ module_function
13
+
14
+ def notify(data_import, event)
15
+ webhooks = resolve_webhooks(data_import)
16
+ return if webhooks.nil? || webhooks.empty?
17
+
18
+ event_sym = EVENT_MAP.fetch(event)
19
+ matching = webhooks.select { |w| w.events.include?(event_sym) }
20
+ return if matching.empty?
21
+
22
+ matching.each do |webhook|
23
+ payload = build_payload(data_import, event, webhook)
24
+ DataPorter::WebhookJob.perform_later(webhook.url, payload, webhook.headers.dup)
25
+ end
26
+ end
27
+
28
+ def resolve_webhooks(data_import)
29
+ data_import.target_class._webhooks
30
+ end
31
+
32
+ def build_payload(data_import, event, webhook)
33
+ data = default_payload(data_import, event)
34
+ data = webhook.payload.call(data_import, event, data) if webhook.payload
35
+ data.to_json
36
+ end
37
+
38
+ def default_payload(data_import, event)
39
+ {
40
+ "event" => event,
41
+ "timestamp" => Time.current.iso8601,
42
+ "import" => import_data(data_import, event)
43
+ }
44
+ end
45
+
46
+ def import_data(data_import, event)
47
+ base = base_import_data(data_import)
48
+ merge_event_data(base, data_import, event)
49
+ end
50
+
51
+ def base_import_data(data_import)
52
+ {
53
+ "id" => data_import.id,
54
+ "target_key" => data_import.target_key,
55
+ "source_type" => data_import.source_type
56
+ }
57
+ end
58
+
59
+ def merge_event_data(base, data_import, event)
60
+ case event
61
+ when "import.completed" then merge_completed(base, data_import)
62
+ when "import.failed" then merge_failed(base, data_import)
63
+ when "import.parsed" then merge_parsed(base, data_import)
64
+ when "import.started" then merge_started(base, data_import)
65
+ else base
66
+ end
67
+ end
68
+
69
+ def merge_completed(base, data_import)
70
+ report = data_import.report
71
+ base.merge(
72
+ "imported_count" => report&.imported_count.to_i,
73
+ "errored_count" => report&.errored_count.to_i
74
+ )
75
+ end
76
+
77
+ def merge_failed(base, data_import)
78
+ message = first_error_message(data_import.report)
79
+ base.merge("error_message" => message)
80
+ end
81
+
82
+ def first_error_message(report)
83
+ errors = report&.error_reports
84
+ errors&.first&.message
85
+ end
86
+
87
+ def merge_parsed(base, data_import)
88
+ report = data_import.report
89
+ base.merge(
90
+ "records_count" => report&.records_count.to_i,
91
+ "complete_count" => report&.complete_count.to_i,
92
+ "partial_count" => report&.partial_count.to_i
93
+ )
94
+ end
95
+
96
+ def merge_started(base, data_import)
97
+ base.merge("records_count" => data_import.records.size)
98
+ end
99
+ end
100
+ end
data/lib/data_porter.rb CHANGED
@@ -9,6 +9,7 @@ end
9
9
  require_relative "data_porter/version"
10
10
  require_relative "data_porter/configuration"
11
11
  require_relative "data_porter/type_validator"
12
+ require_relative "data_porter/column_transformer"
12
13
  require_relative "data_porter/store_models/error"
13
14
  require_relative "data_porter/store_models/report"
14
15
  require_relative "data_porter/store_models/import_record"
@@ -17,6 +18,7 @@ require_relative "data_porter/registry"
17
18
  require_relative "data_porter/sources"
18
19
  require_relative "data_porter/record_validator"
19
20
  require_relative "data_porter/broadcaster"
21
+ require_relative "data_porter/webhook_notifier"
20
22
  require_relative "data_porter/orchestrator"
21
23
  require_relative "data_porter/rejects_csv_builder"
22
24
  require_relative "data_porter/components"
@@ -36,4 +36,9 @@ DataPorter.configure do |config|
36
36
  # Auto-purge completed/failed imports older than this duration.
37
37
  # Set to nil to disable auto-purge. Run `rake data_porter:purge` manually or via cron.
38
38
  # config.purge_after = 60.days
39
+
40
+ # HMAC-SHA256 secret for signing webhook payloads.
41
+ # When set, every webhook request includes an X-DataPorter-Signature header.
42
+ # Set to nil to disable signing (default).
43
+ # config.webhook_secret = ENV["DATA_PORTER_WEBHOOK_SECRET"]
39
44
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: data_porter
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Seryl Lounis
@@ -129,6 +129,7 @@ files:
129
129
  - app/jobs/data_porter/extract_headers_job.rb
130
130
  - app/jobs/data_porter/import_job.rb
131
131
  - app/jobs/data_porter/parse_job.rb
132
+ - app/jobs/data_porter/webhook_job.rb
132
133
  - app/models/data_porter/data_import.rb
133
134
  - app/models/data_porter/mapping_template.rb
134
135
  - app/views/data_porter/imports/index.html.erb
@@ -144,6 +145,7 @@ files:
144
145
  - config/routes.rb
145
146
  - lib/data_porter.rb
146
147
  - lib/data_porter/broadcaster.rb
148
+ - lib/data_porter/column_transformer.rb
147
149
  - lib/data_porter/components.rb
148
150
  - lib/data_porter/components/base.rb
149
151
  - lib/data_porter/components/mapping/column_row.rb
@@ -160,6 +162,7 @@ files:
160
162
  - lib/data_porter/dsl/api_config.rb
161
163
  - lib/data_porter/dsl/column.rb
162
164
  - lib/data_porter/dsl/param.rb
165
+ - lib/data_porter/dsl/webhook.rb
163
166
  - lib/data_porter/engine.rb
164
167
  - lib/data_porter/orchestrator.rb
165
168
  - lib/data_porter/orchestrator/dry_runner.rb
@@ -180,6 +183,7 @@ files:
180
183
  - lib/data_porter/target.rb
181
184
  - lib/data_porter/type_validator.rb
182
185
  - lib/data_porter/version.rb
186
+ - lib/data_porter/webhook_notifier.rb
183
187
  - lib/generators/data_porter/install/install_generator.rb
184
188
  - lib/generators/data_porter/install/templates/create_data_porter_imports.rb.erb
185
189
  - lib/generators/data_porter/install/templates/create_data_porter_mapping_templates.rb.erb