feedkit 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.
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feedkit
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Feedkit
6
+
7
+ config.generators do |g|
8
+ g.test_framework :minitest, fixture: false
9
+ end
10
+
11
+ initializer "feedkit.set_configs" do
12
+ Feedkit.configuration.logger ||= Rails.logger
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Feedkit
6
+ module FeedsOwner
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ has_many Feedkit.configuration.association_name,
11
+ class_name: "Feedkit::Feed",
12
+ as: :owner,
13
+ dependent: :delete_all
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feedkit
4
+ class Generator
5
+ include Schedulable
6
+
7
+ class << self
8
+ def inherited(subclass)
9
+ super
10
+
11
+ Feedkit::Registry.register(subclass)
12
+ end
13
+
14
+ def owned_by(type = nil)
15
+ @owner_class = type || @owner_class
16
+ end
17
+
18
+ def owner_class
19
+ return @owner_class.constantize if @owner_class.is_a?(String)
20
+
21
+ @owner_class
22
+ end
23
+
24
+ def feed_type
25
+ name.demodulize.underscore.to_sym
26
+ end
27
+
28
+ def scheduled?
29
+ schedules.any?
30
+ end
31
+ end
32
+
33
+ def initialize(owner = nil, period_name: nil, **options)
34
+ @owner = owner
35
+ @options = options
36
+ @schedule = period_name ? self.class.find_schedule(period_name) : nil
37
+
38
+ raise ArgumentError, "Unknown schedule: #{period_name}" if period_name && !@schedule
39
+ end
40
+
41
+ def call
42
+ return if already_generated?
43
+ return unless (payload = data)
44
+
45
+ create_feed!(payload)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :owner, :options
51
+
52
+ def data
53
+ raise NotImplementedError, "#{self.class.name} must implement #data"
54
+ end
55
+
56
+ def period
57
+ @schedule&.period
58
+ end
59
+
60
+ def period_name
61
+ @schedule&.period_name
62
+ end
63
+
64
+ def already_generated?
65
+ return false unless @schedule
66
+ return false unless @owner
67
+
68
+ feed_scope.where(feed_type: self.class.feed_type, period_name: period_name)
69
+ .exists?(["created_at > ?", period.ago])
70
+ end
71
+
72
+ def create_feed!(payload)
73
+ if @owner
74
+ feed_scope.create!(feed_type: self.class.feed_type, period_name: period_name, data: payload)
75
+ else
76
+ Feedkit::Feed.create!(feed_type: self.class.feed_type, period_name: period_name, data: payload)
77
+ end
78
+ end
79
+
80
+ def feed_scope
81
+ @owner.public_send(Feedkit.configuration.association_name)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feedkit
4
+ # Tracks all generator classes via the inherited hook in Feedkit::Generator.
5
+ # Registration happens at class load time (boot), not during requests,
6
+ # so thread-safety of the underlying Set is not a concern in practice.
7
+ module Registry
8
+ class << self
9
+ def generators
10
+ @generators ||= Set.new
11
+ end
12
+
13
+ def register(klass)
14
+ generators << klass
15
+ end
16
+
17
+ def unregister(klass)
18
+ generators.delete(klass)
19
+ end
20
+
21
+ def clear!
22
+ @generators = Set.new
23
+ end
24
+
25
+ # Returns generators that have schedules defined.
26
+ def scheduled_generators
27
+ generators.select(&:scheduled?)
28
+ end
29
+
30
+ # Returns scheduled generators that also have an owner class.
31
+ # Only these can be dispatched automatically, since DispatchJob
32
+ # iterates over owner records to enqueue GenerateFeedJob.
33
+ def dispatchable_generators
34
+ scheduled_generators.select(&:owner_class)
35
+ end
36
+
37
+ def generators_for_owner(owner_class)
38
+ generators.select { |g| g.owner_class == owner_class }
39
+ end
40
+
41
+ def due_at(time = Time.current)
42
+ Feedkit.eager_load_generators!
43
+
44
+ dispatchable_generators.flat_map do |generator|
45
+ generator.schedules_due(time).map do |schedule|
46
+ { generator: generator, period_name: schedule.period_name }
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Feedkit
6
+ module Schedulable
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def schedules
11
+ @schedules ||= []
12
+ end
13
+
14
+ def schedule(every:, at:, as: nil, superseded_by: [])
15
+ schedules << Feedkit::Schedule.new(every: every, at: at, as: as, superseded_by: superseded_by)
16
+ end
17
+
18
+ def schedules_due(time = Time.current)
19
+ due = schedules.select { |s| s.due?(time) }
20
+ due_names = due.map(&:period_name)
21
+ due.reject { |s| s.superseded_by.any? { |name| due_names.include?(name) } }
22
+ end
23
+
24
+ def find_schedule(period_name)
25
+ schedules.find { |s| s.period_name == period_name&.to_s }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feedkit
4
+ class Schedule # rubocop:disable Metrics/ClassLength
5
+ VALID_CONDITION_TYPES = %i[hour day weekday week month].freeze
6
+ VALID_HOUR_RANGE = (0..23)
7
+ VALID_DAY_RANGE = (1..31)
8
+ VALID_WEEKDAY_RANGE = (0..6)
9
+ VALID_WEEK_RANGE = (1..53)
10
+ VALID_MONTH_RANGE = (1..12)
11
+ SYMBOLIC_DAY_VALUES = %i[first last].freeze
12
+ SYMBOLIC_WEEK_VALUES = %i[even odd].freeze
13
+
14
+ WEEKDAYS = {
15
+ sunday: 0,
16
+ monday: 1,
17
+ tuesday: 2,
18
+ wednesday: 3,
19
+ thursday: 4,
20
+ friday: 5,
21
+ saturday: 6
22
+ }.freeze
23
+
24
+ MONTHS = {
25
+ january: 1,
26
+ february: 2,
27
+ march: 3,
28
+ april: 4,
29
+ may: 5,
30
+ june: 6,
31
+ july: 7,
32
+ august: 8,
33
+ september: 9,
34
+ october: 10,
35
+ november: 11,
36
+ december: 12
37
+ }.freeze
38
+
39
+ CONDITION_ABBREVIATIONS = {
40
+ hour: "h",
41
+ day: "d",
42
+ weekday: "wd",
43
+ week: "w",
44
+ month: "m"
45
+ }.freeze
46
+
47
+ attr_reader :period_name, :period, :conditions, :superseded_by
48
+
49
+ def initialize(every:, at:, as: nil, superseded_by: [])
50
+ @period = every
51
+ @conditions = at
52
+ @superseded_by = Array(superseded_by).map(&:to_s)
53
+
54
+ validate_conditions!
55
+
56
+ @period_name = (as || generate_period_name).to_s
57
+ end
58
+
59
+ def due?(time = Time.current)
60
+ conditions.all? { |type, value| matches?(type, value, time) }
61
+ end
62
+
63
+ private
64
+
65
+ def validate_conditions!
66
+ raise ArgumentError, "conditions must be a Hash" unless conditions.is_a?(Hash)
67
+
68
+ conditions.each { |type, value| validate_condition!(type, value) }
69
+ end
70
+
71
+ def validate_condition!(type, value)
72
+ unless VALID_CONDITION_TYPES.include?(type)
73
+ raise ArgumentError, "Unknown condition type: #{type}. Valid types: #{VALID_CONDITION_TYPES.join(", ")}"
74
+ end
75
+
76
+ validate_condition_value!(type, value)
77
+ end
78
+
79
+ def validate_condition_value!(type, value)
80
+ case value
81
+ when Range then validate_range_value!(type, value)
82
+ when Array then value.each { |v| validate_scalar_value!(type, v) }
83
+ else validate_scalar_value!(type, value)
84
+ end
85
+ end
86
+
87
+ def validate_range_value!(type, range)
88
+ validate_scalar_value!(type, range.begin)
89
+ validate_scalar_value!(type, range.end)
90
+ end
91
+
92
+ def validate_scalar_value!(type, value)
93
+ case type
94
+ when :hour then validate_hour_value!(value)
95
+ when :day then validate_day_value!(value)
96
+ when :weekday then validate_weekday_value!(value)
97
+ when :week then validate_week_value!(value)
98
+ when :month then validate_month_value!(value)
99
+ end
100
+ end
101
+
102
+ def validate_hour_value!(value)
103
+ return if value.is_a?(Integer) && VALID_HOUR_RANGE.cover?(value)
104
+
105
+ raise ArgumentError, "Invalid hour value: #{value}. Must be integer 0-23"
106
+ end
107
+
108
+ def validate_day_value!(value)
109
+ return if SYMBOLIC_DAY_VALUES.include?(value)
110
+ return if value.is_a?(Integer) && VALID_DAY_RANGE.cover?(value)
111
+
112
+ raise ArgumentError, "Invalid day value: #{value}. Must be integer 1-31 or :first/:last"
113
+ end
114
+
115
+ def validate_weekday_value!(value)
116
+ return if WEEKDAYS.key?(value)
117
+ return if value.is_a?(Integer) && VALID_WEEKDAY_RANGE.cover?(value)
118
+
119
+ raise ArgumentError,
120
+ "Invalid weekday value: #{value}. Must be integer 0-6 or symbol (#{WEEKDAYS.keys.join(", ")})"
121
+ end
122
+
123
+ def validate_week_value!(value)
124
+ return if SYMBOLIC_WEEK_VALUES.include?(value)
125
+ return if value.is_a?(Integer) && VALID_WEEK_RANGE.cover?(value)
126
+
127
+ raise ArgumentError, "Invalid week value: #{value}. Must be integer 1-53 or :even/:odd"
128
+ end
129
+
130
+ def validate_month_value!(value)
131
+ return if MONTHS.key?(value)
132
+ return if value.is_a?(Integer) && VALID_MONTH_RANGE.cover?(value)
133
+
134
+ raise ArgumentError, "Invalid month value: #{value}. Must be integer 1-12 or symbol (#{MONTHS.keys.join(", ")})"
135
+ end
136
+
137
+ def matches?(type, value, time)
138
+ actual = actual_value_for(type, time)
139
+ value = normalize_weekday(value) if type == :weekday
140
+ value = normalize_month(value) if type == :month
141
+
142
+ case value
143
+ when Range then value.cover?(actual)
144
+ when Array then value.include?(actual)
145
+ when Symbol then symbolic_match?(value, type, time)
146
+ else value == actual
147
+ end
148
+ end
149
+
150
+ def actual_value_for(type, time)
151
+ case type
152
+ when :hour then time.hour
153
+ when :day then time.day
154
+ when :weekday then time.wday
155
+ when :week then time.to_date.cweek
156
+ when :month then time.month
157
+ end
158
+ end
159
+
160
+ def symbolic_match?(value, type, time)
161
+ case [type, value]
162
+ when %i[day last] then time.day == time.end_of_month.day
163
+ when %i[day first] then time.day == 1
164
+ when %i[week even] then time.to_date.cweek.even?
165
+ when %i[week odd] then time.to_date.cweek.odd?
166
+ end
167
+ end
168
+
169
+ def generate_period_name
170
+ parts = [period_abbreviation]
171
+ conditions.each do |type, value|
172
+ parts << "#{condition_abbreviation(type)}#{condition_value(type, value)}"
173
+ end
174
+ parts.join("_")
175
+ end
176
+
177
+ def period_abbreviation
178
+ case period
179
+ when 1.hour then "h1"
180
+ when 1.day then "d1"
181
+ when 1.week then "w1"
182
+ when 2.weeks then "w2"
183
+ when 1.month then "m1"
184
+ when 1.year then "y1"
185
+ else "s#{period.to_i}"
186
+ end
187
+ end
188
+
189
+ def condition_abbreviation(type)
190
+ CONDITION_ABBREVIATIONS[type]
191
+ end
192
+
193
+ def condition_value(type, value)
194
+ value = normalize_weekday(value) if type == :weekday
195
+ value = normalize_month(value) if type == :month
196
+
197
+ case value
198
+ when Range then "#{value.begin}-#{value.end}"
199
+ when Array then value.join("-")
200
+ else value
201
+ end
202
+ end
203
+
204
+ def normalize_weekday(value)
205
+ case value
206
+ when Symbol then WEEKDAYS.fetch(value, value)
207
+ when Range then normalize_weekday(value.begin)..normalize_weekday(value.end)
208
+ when Array then value.map { |v| normalize_weekday(v) }
209
+ else value
210
+ end
211
+ end
212
+
213
+ def normalize_month(value)
214
+ case value
215
+ when Symbol then MONTHS.fetch(value, value)
216
+ when Range then normalize_month(value.begin)..normalize_month(value.end)
217
+ when Array then value.map { |v| normalize_month(v) }
218
+ else value
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Feedkit
4
+ VERSION = "0.1.0"
5
+ end
data/lib/feedkit.rb ADDED
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+
5
+ require_relative "feedkit/version"
6
+ require_relative "feedkit/configuration"
7
+ require_relative "feedkit/schedule"
8
+ require_relative "feedkit/schedulable"
9
+ require_relative "feedkit/registry"
10
+ require_relative "feedkit/generator"
11
+ require_relative "feedkit/feeds_owner"
12
+
13
+ module Feedkit
14
+ class << self
15
+ def configuration
16
+ @configuration ||= Configuration.new
17
+ end
18
+
19
+ def configure
20
+ yield(configuration)
21
+ end
22
+
23
+ def logger
24
+ configuration.logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
25
+ end
26
+
27
+ def eager_load_generators!
28
+ return if @generators_loaded
29
+ return if defined?(Rails) && Rails.application.config.eager_load
30
+
31
+ configuration.generator_paths.each do |path|
32
+ pattern = defined?(Rails) ? Rails.root.join(path) : path
33
+ Dir[pattern].each { |file| require file }
34
+ end
35
+
36
+ @generators_loaded = true
37
+ end
38
+
39
+ def reset_eager_load!
40
+ @generators_loaded = false
41
+ end
42
+ end
43
+ end
44
+
45
+ require_relative "feedkit/engine" if defined?(Rails::Engine)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Feedkit
6
+ module Generators
7
+ class GeneratorGenerator < Rails::Generators::NamedBase
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ class_option :owner,
11
+ type: :string,
12
+ default: nil,
13
+ desc: "The owner model class name (e.g., Organization)"
14
+
15
+ def create_generator_file
16
+ template "generator.rb.tt", "app/generators/#{file_name}.rb"
17
+ end
18
+
19
+ def create_test_file
20
+ template "generator_test.rb.tt", "test/generators/#{file_name}_test.rb"
21
+ end
22
+
23
+ private
24
+
25
+ def owner_class
26
+ options[:owner]
27
+ end
28
+
29
+ def owner?
30
+ owner_class.present?
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module Feedkit
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ class_option :owner_id_type,
14
+ type: :string,
15
+ default: "bigint",
16
+ desc: "The type for owner_id column (bigint or uuid)"
17
+
18
+ def self.next_migration_number(dirname)
19
+ if ActiveRecord::Base.timestamped_migrations
20
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
21
+ else
22
+ format("%<number>.3d", number: (current_migration_number(dirname) + 1))
23
+ end
24
+ end
25
+
26
+ def create_initializer
27
+ template "initializer.rb.tt", "config/initializers/feedkit.rb"
28
+ end
29
+
30
+ def create_migration
31
+ migration_template "migration.rb.tt", "db/migrate/create_feedkit_feeds.rb"
32
+ end
33
+
34
+ def create_generators_directory
35
+ empty_directory "app/generators"
36
+ create_file "app/generators/.keep"
37
+ end
38
+
39
+ private
40
+
41
+ def owner_id_type
42
+ options[:owner_id_type]
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < Feedkit::Generator
4
+ <% if owner? -%>
5
+ owned_by <%= owner_class %>
6
+
7
+ <% end -%>
8
+ # Define schedules for automatic feed generation
9
+ # schedule every: 1.day, at: { hour: 6 }, as: :daily
10
+ # schedule every: 1.week, at: { hour: 7, weekday: :monday }, as: :weekly
11
+
12
+ private
13
+
14
+ def data
15
+ # Return a hash with the feed data, or nil to skip feed creation
16
+ # Example:
17
+ # {
18
+ # total_count: calculate_total,
19
+ # items: fetch_items
20
+ # }
21
+ raise NotImplementedError, '<%= class_name %> must implement #data'
22
+ end
23
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class <%= class_name %>Test < ActiveSupport::TestCase
6
+ <% if owner? -%>
7
+ # setup do
8
+ # @owner = <%= owner_class.underscore.pluralize %>(:one)
9
+ # end
10
+
11
+ <% end -%>
12
+ test 'owner_class returns the configured owner type' do
13
+ <% if owner? -%>
14
+ assert_equal <%= owner_class %>, <%= class_name %>.owner_class
15
+ <% else -%>
16
+ assert_nil <%= class_name %>.owner_class
17
+ <% end -%>
18
+ end
19
+
20
+ test 'feed_type returns underscored class name' do
21
+ assert_equal :<%= file_name %>, <%= class_name %>.feed_type
22
+ end
23
+
24
+ # test 'call creates a feed with expected data' do
25
+ <% if owner? -%>
26
+ # generator = <%= class_name %>.new(@owner, period_name: :daily)
27
+ <% else -%>
28
+ # generator = <%= class_name %>.new(nil)
29
+ <% end -%>
30
+ #
31
+ # assert_difference 'Feedkit::Feed.count', 1 do
32
+ # generator.call
33
+ # end
34
+ #
35
+ # feed = Feedkit::Feed.last
36
+ # assert_equal '<%= file_name %>', feed.feed_type
37
+ # end
38
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ Feedkit.configure do |config|
4
+ # Table name for feeds (default: 'feedkit_feeds')
5
+ # config.table_name = 'feedkit_feeds'
6
+
7
+ # Association name for the feeds relationship (default: :feeds)
8
+ # config.association_name = :feeds
9
+
10
+ # Paths to search for generator classes (default: ['app/generators/**/*.rb'])
11
+ # config.generator_paths = ['app/generators/**/*.rb']
12
+
13
+ # Primary key type for owner_id (default: :bigint)
14
+ # Set to :uuid if your models use UUID primary keys
15
+ config.owner_id_type = :<%= owner_id_type %>
16
+
17
+ # Logger for Feedkit (defaults to Rails.logger)
18
+ # config.logger = Rails.logger
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateFeedkitFeeds < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :feedkit_feeds do |t|
6
+ t.string :owner_type
7
+ t.<%= owner_id_type %> :owner_id
8
+ t.string :feed_type, null: false
9
+ t.string :period_name
10
+ t.jsonb :data, null: false, default: {}
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :feedkit_feeds, :created_at
16
+ add_index :feedkit_feeds, %i[owner_type owner_id feed_type created_at], name: 'idx_feedkit_feeds_lookup'
17
+ add_index :feedkit_feeds, %i[owner_type owner_id feed_type period_name], name: 'idx_feedkit_feeds_dedup'
18
+ end
19
+ end