vert-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/README.md +126 -0
- data/lib/vert/authorization/controller_methods.rb +84 -0
- data/lib/vert/authorization/dynamic_policy.rb +156 -0
- data/lib/vert/authorization/permission_resolver.rb +253 -0
- data/lib/vert/authorization/policy_finder.rb +72 -0
- data/lib/vert/clients/document_service_client.rb +104 -0
- data/lib/vert/concerns/auditable.rb +24 -0
- data/lib/vert/concerns/company_scoped.rb +48 -0
- data/lib/vert/concerns/current_attributes.rb +53 -0
- data/lib/vert/concerns/document_storeable.rb +180 -0
- data/lib/vert/concerns/multi_tenant.rb +45 -0
- data/lib/vert/concerns/soft_deletable.rb +46 -0
- data/lib/vert/concerns/uuid_primary_key.rb +42 -0
- data/lib/vert/configuration.rb +65 -0
- data/lib/vert/generators/install_generator.rb +66 -0
- data/lib/vert/generators/rls_migration_generator.rb +57 -0
- data/lib/vert/generators/templates/application_record.rb.tt +8 -0
- data/lib/vert/generators/templates/create_outbox_events.rb.tt +24 -0
- data/lib/vert/generators/templates/create_rls_functions.rb.tt +27 -0
- data/lib/vert/generators/templates/current.rb.tt +10 -0
- data/lib/vert/generators/templates/enable_rls_on_tables.rb.tt +39 -0
- data/lib/vert/generators/templates/health_controller.rb.tt +5 -0
- data/lib/vert/generators/templates/initializer.rb.tt +39 -0
- data/lib/vert/generators/templates/outbox_event.rb.tt +11 -0
- data/lib/vert/health/checker.rb +119 -0
- data/lib/vert/health/routes.rb +44 -0
- data/lib/vert/outbox/event.rb +68 -0
- data/lib/vert/outbox/publisher.rb +105 -0
- data/lib/vert/outbox/publisher_job.rb +30 -0
- data/lib/vert/railtie.rb +54 -0
- data/lib/vert/rls/connection_handler.rb +56 -0
- data/lib/vert/rls/consumer_context.rb +31 -0
- data/lib/vert/rls/context_middleware.rb +37 -0
- data/lib/vert/rls/job_context.rb +56 -0
- data/lib/vert/version.rb +5 -0
- data/lib/vert.rb +58 -0
- data/vert.gemspec +43 -0
- metadata +223 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/base"
|
|
5
|
+
|
|
6
|
+
module Vert
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Installs Vert and generates configuration files"
|
|
12
|
+
|
|
13
|
+
def create_initializer
|
|
14
|
+
template "initializer.rb.tt", "config/initializers/vert.rb"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create_current_model
|
|
18
|
+
template "current.rb.tt", "app/models/current.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_application_record
|
|
22
|
+
template "application_record.rb.tt", "app/models/application_record.rb"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_outbox_event_model
|
|
26
|
+
template "outbox_event.rb.tt", "app/models/outbox_event.rb"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_outbox_migration
|
|
30
|
+
migration_template "create_outbox_events.rb.tt",
|
|
31
|
+
"db/migrate/#{timestamp}_create_outbox_events.rb"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_health_controller
|
|
35
|
+
template "health_controller.rb.tt", "app/controllers/health_controller.rb"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_routes
|
|
39
|
+
route 'get "/health", to: "health#show"'
|
|
40
|
+
route 'get "/health/live", to: "health#live"'
|
|
41
|
+
route 'get "/health/ready", to: "health#ready"'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def show_instructions
|
|
45
|
+
say "\n"
|
|
46
|
+
say "Vert installed successfully!", :green
|
|
47
|
+
say "\n"
|
|
48
|
+
say "Next steps:", :yellow
|
|
49
|
+
say "1. Edit config/initializers/vert.rb to enable the features you need"
|
|
50
|
+
say "2. Run migrations: rails db:migrate"
|
|
51
|
+
say "3. Include Vert concerns in your models as needed:"
|
|
52
|
+
say " include Vert::Concerns::UuidPrimaryKey"
|
|
53
|
+
say " include Vert::Concerns::MultiTenant"
|
|
54
|
+
say " include Vert::Concerns::Auditable"
|
|
55
|
+
say " include Vert::Concerns::SoftDeletable"
|
|
56
|
+
say "\n"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def timestamp
|
|
62
|
+
Time.current.strftime("%Y%m%d%H%M%S")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/base"
|
|
5
|
+
|
|
6
|
+
module Vert
|
|
7
|
+
module Generators
|
|
8
|
+
class RlsMigrationGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Generates PostgreSQL Row Level Security setup migration"
|
|
12
|
+
|
|
13
|
+
class_option :tables, type: :array, default: [],
|
|
14
|
+
desc: "Tables to enable RLS on"
|
|
15
|
+
|
|
16
|
+
def create_rls_functions_migration
|
|
17
|
+
migration_template "create_rls_functions.rb.tt",
|
|
18
|
+
"db/migrate/#{timestamp}_create_rls_functions.rb"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create_enable_rls_migration
|
|
22
|
+
return if options[:tables].empty?
|
|
23
|
+
|
|
24
|
+
migration_template "enable_rls_on_tables.rb.tt",
|
|
25
|
+
"db/migrate/#{next_timestamp}_enable_rls_on_tables.rb"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show_instructions
|
|
29
|
+
say "\n"
|
|
30
|
+
say "RLS migrations generated!", :green
|
|
31
|
+
say "\n"
|
|
32
|
+
say "Next steps:", :yellow
|
|
33
|
+
say "1. Run migrations: rails db:migrate"
|
|
34
|
+
say "2. Set enable_rls = true in config/initializers/vert.rb"
|
|
35
|
+
say "3. Ensure your database user has appropriate privileges"
|
|
36
|
+
say "\n"
|
|
37
|
+
say "To enable RLS on additional tables, run:", :cyan
|
|
38
|
+
say " rails generate vert:rls_migration --tables orders invoices"
|
|
39
|
+
say "\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def timestamp
|
|
45
|
+
@timestamp ||= Time.current.strftime("%Y%m%d%H%M%S")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def next_timestamp
|
|
49
|
+
@next_timestamp ||= (Time.current + 1.second).strftime("%Y%m%d%H%M%S")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def tables_list
|
|
53
|
+
options[:tables]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateOutboxEvents < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :outbox_events, id: :uuid do |t|
|
|
6
|
+
t.uuid :tenant_id, null: false
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.string :aggregate_type, null: false
|
|
9
|
+
t.uuid :aggregate_id, null: false
|
|
10
|
+
t.jsonb :payload, null: false, default: {}
|
|
11
|
+
t.string :status, null: false, default: "pending"
|
|
12
|
+
t.integer :retry_count, null: false, default: 0
|
|
13
|
+
t.text :last_error
|
|
14
|
+
t.datetime :published_at
|
|
15
|
+
t.datetime :failed_at
|
|
16
|
+
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :outbox_events, :tenant_id
|
|
21
|
+
add_index :outbox_events, [:status, :created_at]
|
|
22
|
+
add_index :outbox_events, [:aggregate_type, :aggregate_id]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateRlsFunctions < ActiveRecord::Migration[7.1]
|
|
4
|
+
def up
|
|
5
|
+
execute <<~SQL
|
|
6
|
+
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS uuid AS $$
|
|
7
|
+
SELECT NULLIF(current_setting('app.current_tenant_id', true), '')::uuid;
|
|
8
|
+
$$ LANGUAGE SQL STABLE;
|
|
9
|
+
|
|
10
|
+
CREATE OR REPLACE FUNCTION current_company_id() RETURNS uuid AS $$
|
|
11
|
+
SELECT NULLIF(current_setting('app.current_company_id', true), '')::uuid;
|
|
12
|
+
$$ LANGUAGE SQL STABLE;
|
|
13
|
+
|
|
14
|
+
CREATE OR REPLACE FUNCTION current_user_id() RETURNS uuid AS $$
|
|
15
|
+
SELECT NULLIF(current_setting('app.current_user_id', true), '')::uuid;
|
|
16
|
+
$$ LANGUAGE SQL STABLE;
|
|
17
|
+
SQL
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def down
|
|
21
|
+
execute <<~SQL
|
|
22
|
+
DROP FUNCTION IF EXISTS current_tenant_id();
|
|
23
|
+
DROP FUNCTION IF EXISTS current_company_id();
|
|
24
|
+
DROP FUNCTION IF EXISTS current_user_id();
|
|
25
|
+
SQL
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Current - Thread-safe request context
|
|
4
|
+
#
|
|
5
|
+
# Provides access to current tenant, user, and company context.
|
|
6
|
+
#
|
|
7
|
+
class Current < Vert::Current
|
|
8
|
+
# Add any application-specific attributes here
|
|
9
|
+
# attribute :custom_attribute
|
|
10
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EnableRlsOnTables < ActiveRecord::Migration[7.1]
|
|
4
|
+
TABLES = <%= tables_list.inspect %>.freeze
|
|
5
|
+
|
|
6
|
+
def up
|
|
7
|
+
TABLES.each do |table_name|
|
|
8
|
+
enable_rls_on_table(table_name)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def down
|
|
13
|
+
TABLES.each do |table_name|
|
|
14
|
+
disable_rls_on_table(table_name)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def enable_rls_on_table(table_name)
|
|
21
|
+
execute <<~SQL
|
|
22
|
+
ALTER TABLE #{table_name} ENABLE ROW LEVEL SECURITY;
|
|
23
|
+
ALTER TABLE #{table_name} FORCE ROW LEVEL SECURITY;
|
|
24
|
+
|
|
25
|
+
DROP POLICY IF EXISTS tenant_isolation_#{table_name} ON #{table_name};
|
|
26
|
+
CREATE POLICY tenant_isolation_#{table_name} ON #{table_name}
|
|
27
|
+
FOR ALL
|
|
28
|
+
USING (tenant_id = current_tenant_id())
|
|
29
|
+
WITH CHECK (tenant_id = current_tenant_id());
|
|
30
|
+
SQL
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def disable_rls_on_table(table_name)
|
|
34
|
+
execute <<~SQL
|
|
35
|
+
DROP POLICY IF EXISTS tenant_isolation_#{table_name} ON #{table_name};
|
|
36
|
+
ALTER TABLE #{table_name} DISABLE ROW LEVEL SECURITY;
|
|
37
|
+
SQL
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Vert - Configuration
|
|
4
|
+
#
|
|
5
|
+
# Enable only the features you need. All flags default to false except enable_health.
|
|
6
|
+
#
|
|
7
|
+
Vert.configure do |config|
|
|
8
|
+
# --- Row Level Security (PostgreSQL) ---
|
|
9
|
+
# config.enable_rls = ENV.fetch("ENABLE_RLS", "false") == "true"
|
|
10
|
+
# config.rls_user = ENV.fetch("RLS_USER", "app_user")
|
|
11
|
+
|
|
12
|
+
# --- Outbox (reliable event publishing to RabbitMQ) ---
|
|
13
|
+
# config.enable_outbox = true
|
|
14
|
+
# config.rabbitmq_url = ENV.fetch("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/")
|
|
15
|
+
# config.exchange_name = ENV.fetch("RABBITMQ_EXCHANGE", "vert.events")
|
|
16
|
+
|
|
17
|
+
# --- Health checks ---
|
|
18
|
+
# config.enable_health = true
|
|
19
|
+
# config.health_check_path = "/health"
|
|
20
|
+
# config.auto_mount_health_routes = false
|
|
21
|
+
# config.health_check_database = true
|
|
22
|
+
# config.health_check_redis = false
|
|
23
|
+
# config.health_check_rabbitmq = false
|
|
24
|
+
# config.health_check_sidekiq = false
|
|
25
|
+
|
|
26
|
+
# --- Authorization (RBAC/ABAC with Pundit) ---
|
|
27
|
+
# config.enable_authorization = false
|
|
28
|
+
|
|
29
|
+
# --- Model concerns (enable if your app uses these patterns) ---
|
|
30
|
+
# config.enable_multi_tenant = false
|
|
31
|
+
# config.enable_auditable = false
|
|
32
|
+
# config.enable_soft_deletable = false
|
|
33
|
+
# config.enable_uuid_primary_key = false
|
|
34
|
+
# config.enable_company_scoped = false
|
|
35
|
+
# config.enable_document_storeable = false
|
|
36
|
+
|
|
37
|
+
# --- Document service (for has_document) ---
|
|
38
|
+
# config.document_service_url = ENV.fetch("DOCUMENT_SERVICE_URL", "http://localhost:3020")
|
|
39
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# OutboxEvent - Transactional outbox for reliable event publishing
|
|
4
|
+
#
|
|
5
|
+
# Events are stored in the database and published asynchronously
|
|
6
|
+
# to RabbitMQ. Enable enable_outbox in config/initializers/vert.rb
|
|
7
|
+
# and run the OutboxPublisherJob (e.g. via Sidekiq-Cron) to publish.
|
|
8
|
+
#
|
|
9
|
+
class OutboxEvent < ApplicationRecord
|
|
10
|
+
include Vert::Outbox::Event
|
|
11
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Health
|
|
5
|
+
class Checker
|
|
6
|
+
attr_reader :checks
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@checks = {}
|
|
10
|
+
setup_default_checks
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def add_check(name, &block)
|
|
14
|
+
@checks[name] = block
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
alias register add_check
|
|
18
|
+
|
|
19
|
+
def remove_check(name)
|
|
20
|
+
@checks.delete(name)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def check_all
|
|
24
|
+
results = {}
|
|
25
|
+
overall_healthy = true
|
|
26
|
+
|
|
27
|
+
@checks.each do |name, check|
|
|
28
|
+
result = check.call
|
|
29
|
+
results[name] = result
|
|
30
|
+
overall_healthy = false unless result[:status] == "ok"
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
results[name] = { status: "error", message: e.message }
|
|
33
|
+
overall_healthy = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
status: overall_healthy ? "healthy" : "unhealthy",
|
|
38
|
+
timestamp: Time.current.iso8601,
|
|
39
|
+
checks: results
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def liveness
|
|
44
|
+
{ status: "ok", timestamp: Time.current.iso8601 }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def readiness
|
|
48
|
+
db_ok = check_database[:status] == "ok"
|
|
49
|
+
{
|
|
50
|
+
status: db_ok ? "ready" : "not_ready",
|
|
51
|
+
timestamp: Time.current.iso8601,
|
|
52
|
+
checks: { database: check_database }
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_database
|
|
57
|
+
ActiveRecord::Base.connection.execute("SELECT 1")
|
|
58
|
+
{ status: "ok" }
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
{ status: "error", message: e.message }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_redis
|
|
64
|
+
return { status: "skipped", message: "Redis check disabled" } unless Vert.config.health_check_redis
|
|
65
|
+
return { status: "skipped", message: "Redis not configured" } unless redis_available?
|
|
66
|
+
|
|
67
|
+
Redis.current.ping == "PONG" ? { status: "ok" } : { status: "error", message: "Ping failed" }
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
{ status: "error", message: e.message }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def check_rabbitmq
|
|
73
|
+
return { status: "skipped", message: "RabbitMQ check disabled" } unless Vert.config.health_check_rabbitmq
|
|
74
|
+
|
|
75
|
+
connection = Bunny.new(Vert.config.rabbitmq_url, connection_timeout: 5)
|
|
76
|
+
connection.start
|
|
77
|
+
connection.close
|
|
78
|
+
{ status: "ok" }
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
{ status: "error", message: e.message }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def check_sidekiq
|
|
84
|
+
return { status: "skipped", message: "Sidekiq check disabled" } unless Vert.config.health_check_sidekiq
|
|
85
|
+
return { status: "skipped", message: "Sidekiq not configured" } unless sidekiq_available?
|
|
86
|
+
|
|
87
|
+
stats = Sidekiq::Stats.new
|
|
88
|
+
{ status: "ok", processed: stats.processed, failed: stats.failed, queues: stats.queues }
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
{ status: "error", message: e.message }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
class << self
|
|
94
|
+
def checker
|
|
95
|
+
@checker ||= new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
delegate :check_all, :liveness, :readiness, :add_check, :register, to: :checker
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def setup_default_checks
|
|
104
|
+
add_check(:database) { check_database } if Vert.config.health_check_database
|
|
105
|
+
add_check(:redis) { check_redis } if Vert.config.health_check_redis
|
|
106
|
+
add_check(:rabbitmq) { check_rabbitmq } if Vert.config.health_check_rabbitmq
|
|
107
|
+
add_check(:sidekiq) { check_sidekiq } if Vert.config.health_check_sidekiq
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def redis_available?
|
|
111
|
+
defined?(Redis) && Redis.respond_to?(:current)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def sidekiq_available?
|
|
115
|
+
defined?(Sidekiq::Stats)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Health
|
|
5
|
+
module Routes
|
|
6
|
+
class << self
|
|
7
|
+
def mount(router, path: nil)
|
|
8
|
+
path ||= Vert.config.health_check_path
|
|
9
|
+
router.instance_eval do
|
|
10
|
+
scope path do
|
|
11
|
+
get "/", to: "health#show", as: :health
|
|
12
|
+
get "/live", to: "health#live", as: :health_live
|
|
13
|
+
get "/ready", to: "health#ready", as: :health_ready
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module ControllerMixin
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
def show
|
|
24
|
+
result = Vert::Health.check_all
|
|
25
|
+
status = result[:status] == "healthy" ? :ok : :service_unavailable
|
|
26
|
+
render json: result, status: status
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def live
|
|
30
|
+
render json: Vert::Health.liveness, status: :ok
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ready
|
|
34
|
+
result = Vert::Health.readiness
|
|
35
|
+
status = result[:status] == "ready" ? :ok : :service_unavailable
|
|
36
|
+
render json: result, status: status
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class Controller < ActionController::API
|
|
41
|
+
include ControllerMixin
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end if defined?(ActionController::API)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Outbox
|
|
5
|
+
module Event
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
validates :event_type, presence: true
|
|
10
|
+
validates :aggregate_type, presence: true
|
|
11
|
+
validates :aggregate_id, presence: true
|
|
12
|
+
validates :payload, presence: true
|
|
13
|
+
validates :status, presence: true
|
|
14
|
+
enum :status, { pending: 0, published: 1, failed: 2 }, prefix: true
|
|
15
|
+
scope :pending_events, -> { status_pending.order(:created_at) }
|
|
16
|
+
scope :failed_events, -> { status_failed.where("retry_count < ?", max_retry_count).order(:created_at) }
|
|
17
|
+
scope :by_aggregate, ->(type, id) { where(aggregate_type: type, aggregate_id: id) }
|
|
18
|
+
scope :publishable, -> { pending_events.or(failed_events) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_methods do
|
|
22
|
+
def max_retry_count
|
|
23
|
+
5
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_event(event_type:, aggregate_type:, aggregate_id:, payload:)
|
|
27
|
+
create!(
|
|
28
|
+
tenant_id: Vert::Current.tenant_id,
|
|
29
|
+
event_type: event_type,
|
|
30
|
+
aggregate_type: aggregate_type,
|
|
31
|
+
aggregate_id: aggregate_id,
|
|
32
|
+
payload: payload,
|
|
33
|
+
status: :pending
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def publish_for(aggregate, event_type:, payload: {})
|
|
38
|
+
create_event(
|
|
39
|
+
event_type: event_type,
|
|
40
|
+
aggregate_type: aggregate.class.name,
|
|
41
|
+
aggregate_id: aggregate.id,
|
|
42
|
+
payload: payload.merge(id: aggregate.id, tenant_id: aggregate.tenant_id)
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def mark_as_published!
|
|
48
|
+
update!(status: :published, published_at: Time.current)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mark_as_failed!(error)
|
|
52
|
+
update!(status: :failed, retry_count: retry_count + 1, last_error: error.to_s, failed_at: Time.current)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def can_retry?
|
|
56
|
+
status_failed? && retry_count < self.class.max_retry_count
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def routing_key
|
|
60
|
+
event_type.tr("_", ".")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def message_headers
|
|
64
|
+
{ tenant_id: tenant_id, event_type: event_type, aggregate_type: aggregate_type, aggregate_id: aggregate_id, event_id: id, timestamp: created_at.iso8601 }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bunny"
|
|
4
|
+
|
|
5
|
+
module Vert
|
|
6
|
+
module Outbox
|
|
7
|
+
class Publisher
|
|
8
|
+
attr_reader :connection, :channel, :exchange
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@connection = nil
|
|
12
|
+
@channel = nil
|
|
13
|
+
@exchange = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def connect
|
|
17
|
+
@connection = Bunny.new(Vert.config.rabbitmq_url)
|
|
18
|
+
@connection.start
|
|
19
|
+
@channel = @connection.create_channel
|
|
20
|
+
@exchange = @channel.topic(Vert.config.exchange_name, durable: true)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def close
|
|
25
|
+
@connection&.close
|
|
26
|
+
@connection = nil
|
|
27
|
+
@channel = nil
|
|
28
|
+
@exchange = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def connected?
|
|
32
|
+
@connection&.open? && @channel&.open?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def publish(event)
|
|
36
|
+
connect unless connected?
|
|
37
|
+
message = build_message(event)
|
|
38
|
+
@exchange.publish(
|
|
39
|
+
message.to_json,
|
|
40
|
+
routing_key: event.routing_key,
|
|
41
|
+
persistent: true,
|
|
42
|
+
content_type: "application/json",
|
|
43
|
+
message_id: event.id.to_s,
|
|
44
|
+
timestamp: event.created_at.to_i,
|
|
45
|
+
headers: event.message_headers
|
|
46
|
+
)
|
|
47
|
+
event.mark_as_published!
|
|
48
|
+
true
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
event.mark_as_failed!(e)
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def publish_batch(events)
|
|
55
|
+
results = { published: 0, failed: 0 }
|
|
56
|
+
events.each { |event| publish(event) ? results[:published] += 1 : results[:failed] += 1 }
|
|
57
|
+
results
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
def with_connection
|
|
62
|
+
publisher = new
|
|
63
|
+
publisher.connect
|
|
64
|
+
yield publisher
|
|
65
|
+
ensure
|
|
66
|
+
publisher&.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def publish_pending(batch_size: 100)
|
|
70
|
+
return { published: 0, failed: 0, error: "OutboxEvent not defined" } unless outbox_event_class
|
|
71
|
+
results = { published: 0, failed: 0 }
|
|
72
|
+
with_connection do |publisher|
|
|
73
|
+
outbox_event_class.publishable.find_in_batches(batch_size: batch_size) do |events|
|
|
74
|
+
batch_results = publisher.publish_batch(events)
|
|
75
|
+
results[:published] += batch_results[:published]
|
|
76
|
+
results[:failed] += batch_results[:failed]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
results
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def outbox_event_class
|
|
85
|
+
@outbox_event_class ||= (Object.const_get("OutboxEvent") rescue nil)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def build_message(event)
|
|
92
|
+
{
|
|
93
|
+
event_id: event.id.to_s,
|
|
94
|
+
event_type: event.event_type,
|
|
95
|
+
aggregate_type: event.aggregate_type,
|
|
96
|
+
aggregate_id: event.aggregate_id.to_s,
|
|
97
|
+
tenant_id: event.tenant_id.to_s,
|
|
98
|
+
occurred_at: event.created_at.iso8601,
|
|
99
|
+
metadata: { tenant_id: event.tenant_id.to_s, published_at: Time.current.iso8601 },
|
|
100
|
+
data: event.payload.is_a?(Hash) ? (event.payload[:data] || event.payload["data"] || event.payload) : event.payload
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Outbox
|
|
5
|
+
class PublisherJob
|
|
6
|
+
include Sidekiq::Job if defined?(Sidekiq::Job)
|
|
7
|
+
sidekiq_options queue: :critical, retry: 3 if respond_to?(:sidekiq_options)
|
|
8
|
+
|
|
9
|
+
def perform
|
|
10
|
+
return unless Vert.config.enable_outbox
|
|
11
|
+
return unless outbox_event_class
|
|
12
|
+
|
|
13
|
+
outbox_event_class.pending_events.find_each { |event| publish_event(event) }
|
|
14
|
+
outbox_event_class.failed_events.find_each { |event| publish_event(event) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def publish_event(event)
|
|
20
|
+
Publisher.with_connection { |publisher| publisher.publish(event) }
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
Rails.logger.error("[Vert::Outbox] Failed #{event.event_type}: #{e.message}") if defined?(Rails)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def outbox_event_class
|
|
26
|
+
@outbox_event_class ||= (Object.const_get("OutboxEvent") rescue nil)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|