api_keys 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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +393 -0
  5. data/Rakefile +32 -0
  6. data/api_keys_dashboard.webp +0 -0
  7. data/api_keys_permissions.webp +0 -0
  8. data/api_keys_token.webp +0 -0
  9. data/app/controllers/api_keys/application_controller.rb +62 -0
  10. data/app/controllers/api_keys/keys_controller.rb +129 -0
  11. data/app/controllers/api_keys/security_controller.rb +16 -0
  12. data/app/views/api_keys/keys/_form.html.erb +106 -0
  13. data/app/views/api_keys/keys/_key_row.html.erb +72 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +52 -0
  15. data/app/views/api_keys/keys/_show_token.html.erb +88 -0
  16. data/app/views/api_keys/keys/edit.html.erb +5 -0
  17. data/app/views/api_keys/keys/index.html.erb +26 -0
  18. data/app/views/api_keys/keys/new.html.erb +5 -0
  19. data/app/views/api_keys/keys/show.html.erb +12 -0
  20. data/app/views/api_keys/security/best_practices.html.erb +70 -0
  21. data/app/views/layouts/api_keys/application.html.erb +115 -0
  22. data/config/routes.rb +18 -0
  23. data/lib/api_keys/authentication.rb +160 -0
  24. data/lib/api_keys/configuration.rb +125 -0
  25. data/lib/api_keys/controller.rb +47 -0
  26. data/lib/api_keys/engine.rb +76 -0
  27. data/lib/api_keys/jobs/callbacks_job.rb +69 -0
  28. data/lib/api_keys/jobs/update_stats_job.rb +58 -0
  29. data/lib/api_keys/logging.rb +42 -0
  30. data/lib/api_keys/models/api_key.rb +209 -0
  31. data/lib/api_keys/models/concerns/has_api_keys.rb +144 -0
  32. data/lib/api_keys/services/authenticator.rb +255 -0
  33. data/lib/api_keys/services/digestor.rb +68 -0
  34. data/lib/api_keys/services/token_generator.rb +32 -0
  35. data/lib/api_keys/tenant_resolution.rb +40 -0
  36. data/lib/api_keys/version.rb +5 -0
  37. data/lib/api_keys.rb +49 -0
  38. data/lib/generators/api_keys/install_generator.rb +70 -0
  39. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +100 -0
  40. data/lib/generators/api_keys/templates/initializer.rb +160 -0
  41. metadata +184 -0
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "services/authenticator"
5
+ require_relative "logging"
6
+ require_relative "jobs/update_stats_job"
7
+ require_relative "jobs/callbacks_job"
8
+
9
+ module ApiKeys
10
+ # Controller concern for handling API key authentication.
11
+ # Provides `authenticate_api_key!` method and helper methods.
12
+ module Authentication
13
+ extend ActiveSupport::Concern
14
+ include ApiKeys::Logging
15
+
16
+ included do
17
+ # Helper methods to access the authenticated key and its owner
18
+ helper_method :current_api_key, :current_api_owner, :current_api_user
19
+ end
20
+
21
+ # Returns the currently authenticated API key instance if authentication was
22
+ # successful, otherwise returns nil.
23
+ #
24
+ # @return [ApiKeys::ApiKey, nil]
25
+ def current_api_key
26
+ @current_api_key
27
+ end
28
+
29
+ # Returns the owner of the currently authenticated ApiKey, if any.
30
+ # @return [Object, nil] The polymorphic owner instance (e.g., User).
31
+ def current_api_owner
32
+ current_api_key&.owner
33
+ end
34
+
35
+ # Convenience helper: returns the owner if it's a User instance.
36
+ # @return [User, nil]
37
+ def current_api_user
38
+ owner = current_api_owner
39
+ owner if owner.is_a?(::User) # Assumes a User class exists
40
+ end
41
+
42
+ private
43
+
44
+ # The core authentication method.
45
+ def authenticate_api_key!(scope: nil)
46
+ log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
47
+
48
+ # Enqueue before_authentication callback asynchronously
49
+ enqueue_callback(:before_authentication, { request_uuid: request.uuid })
50
+
51
+ # Perform synchronous authentication
52
+ result = Services::Authenticator.call(request)
53
+ log_debug "[ApiKeys Auth] Authenticator result: #{result.inspect}"
54
+
55
+ # Prepare context for after_authentication callback
56
+ after_auth_context = {
57
+ success: result.success?,
58
+ error_code: result.error_code,
59
+ message: result.message,
60
+ api_key_id: result.api_key&.id # Pass ID only, not the full object
61
+ }
62
+
63
+ if result.success?
64
+ @current_api_key = result.api_key
65
+ log_debug "[ApiKeys Auth] Authentication successful. Key ID: #{@current_api_key.id}"
66
+
67
+ if scope && !check_api_key_scopes(scope)
68
+ log_debug "[ApiKeys Auth] Scope check failed. Required: #{scope}, Key scopes: #{@current_api_key.scopes}"
69
+ # Add required scope info to context before rendering/enqueueing
70
+ after_auth_context[:required_scope_check] = { required: scope, passed: false }
71
+ render_unauthorized(error_code: :missing_scope, message: "API key does not have the required scope(s): #{scope}", required_scope: scope)
72
+ else
73
+ after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
74
+ # Authentication and scope check successful, enqueue stats update
75
+ update_key_usage_stats # Enqueues UpdateStatsJob
76
+ end
77
+ else
78
+ # Authentication failed
79
+ log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}"
80
+ render_unauthorized(error_code: result.error_code, message: result.message)
81
+ end
82
+
83
+ # Enqueue after_authentication callback asynchronously regardless of success/failure
84
+ enqueue_callback(:after_authentication, after_auth_context)
85
+ end
86
+
87
+ # Checks if the current_api_key has the required scope(s).
88
+ # Handles single scope string or array of scopes.
89
+ #
90
+ # @param required_scopes [String, Array<String>] The required scope(s).
91
+ # @return [Boolean] True if the key has all required scopes, false otherwise.
92
+ def check_api_key_scopes(required_scopes)
93
+ return true unless current_api_key # Should not happen if authenticate_api_key! ran
94
+ return true if required_scopes.blank?
95
+
96
+ Array(required_scopes).all? do |req_scope|
97
+ current_api_key.allows_scope?(req_scope)
98
+ end
99
+ end
100
+
101
+ # Renders a standard JSON error response for authentication failures.
102
+ def render_unauthorized(error_code:, message:, status: :unauthorized, required_scope: nil)
103
+ error_message = I18n.t("api_keys.errors.#{error_code}", default: message) rescue message
104
+ response_body = { error: error_code, message: error_message }
105
+ response_body[:required_scope] = required_scope if error_code == :missing_scope && required_scope
106
+ render json: response_body, status: status
107
+ end
108
+
109
+ # Enqueues the UpdateStatsJob.
110
+ def update_key_usage_stats
111
+ # Return early if async operations are globally disabled
112
+ return unless ApiKeys.configuration.enable_async_operations
113
+ return unless current_api_key
114
+
115
+ # Check ActiveJob configuration and warn if using suboptimal adapters
116
+ adapter = ActiveJob::Base.queue_adapter
117
+ if adapter.is_a?(ActiveJob::QueueAdapters::InlineAdapter)
118
+ log_warn "[ApiKeys] ActiveJob adapter is :inline. ApiKey stats updates will run synchronously within the request cycle, potentially impacting performance."
119
+ elsif adapter.is_a?(ActiveJob::QueueAdapters::AsyncAdapter)
120
+ log_warn "[ApiKeys] ActiveJob adapter is :async. ApiKey stats updates run in-process and may be lost on application restarts. Configure a persistent backend (Sidekiq, GoodJob, SolidQueue, etc.) for reliability."
121
+ end
122
+
123
+ begin
124
+ timestamp = Time.current # Capture time once for the job
125
+ log_debug "[ApiKeys Auth] Enqueuing UpdateStatsJob for ApiKey ID: #{current_api_key.id} at #{timestamp}"
126
+ ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
127
+ rescue StandardError => e
128
+ log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id}: #{e.message}"
129
+ end
130
+ end
131
+
132
+ # Helper to safely enqueue callback jobs.
133
+ def enqueue_callback(callback_type, context)
134
+ # Return early if async operations are globally disabled
135
+ return unless ApiKeys.configuration.enable_async_operations
136
+
137
+ # Determine the configuration method name (e.g., :before_authentication)
138
+ config_method = callback_type # Assuming callback_type directly matches config accessor
139
+
140
+ # Get the configured proc
141
+ callback_proc = ApiKeys.configuration.public_send(config_method)
142
+
143
+ # Skip enqueueing if the callback is the default empty proc
144
+ if callback_proc == ApiKeys::Configuration::DEFAULT_CALLBACK
145
+ log_debug "[ApiKeys Auth] Skipping enqueue for default empty callback: #{callback_type}"
146
+ return
147
+ end
148
+
149
+ # Proceed with enqueueing if it's a configured callback
150
+ begin
151
+ log_debug "[ApiKeys Auth] Enqueuing CallbacksJob for type: #{callback_type} with context: #{context.inspect}"
152
+ ApiKeys::Jobs::CallbacksJob.perform_later(callback_type, context)
153
+ rescue StandardError => e
154
+ log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type}: #{e.message}"
155
+ # Don't fail the request if callback enqueueing fails
156
+ end
157
+ end
158
+
159
+ end
160
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/numeric/time"
4
+ require "active_support/security_utils"
5
+
6
+ module ApiKeys
7
+ # Defines the configuration options for the ApiKeys gem.
8
+ # These options can be set in an initializer, e.g., config/initializers/api_keys.rb
9
+ class Configuration
10
+ # Default empty callback proc
11
+ DEFAULT_CALLBACK = ->(_context){}.freeze
12
+
13
+ # == Accessors ==
14
+
15
+ # Core Authentication
16
+ attr_accessor :header, :query_param
17
+
18
+ # Token Generation
19
+ attr_accessor :token_prefix, :token_length, :token_alphabet
20
+
21
+ # Storage & Verification
22
+ attr_accessor :hash_strategy, :secure_compare_proc, :key_store_adapter, :policy_provider
23
+
24
+ # Engine Configuration
25
+ attr_accessor :parent_controller
26
+
27
+ # Optional Behaviors
28
+ attr_accessor :default_max_keys_per_owner, :require_key_name
29
+ attr_accessor :expire_after, :default_scopes, :track_requests_count
30
+
31
+ # Performance
32
+ attr_accessor :cache_ttl
33
+
34
+ # Security
35
+ attr_accessor :https_only_production, :https_strict_mode
36
+
37
+ # Tenant Resolution
38
+ attr_accessor :tenant_resolver
39
+
40
+ # Callbacks (Placeholders for future extension)
41
+ attr_accessor :before_authentication, :after_authentication
42
+
43
+ # Background Job Queues
44
+ attr_accessor :stats_job_queue, :callbacks_job_queue
45
+
46
+ # Global Async Toggle
47
+ attr_accessor :enable_async_operations
48
+
49
+ # Engine UI Configuration
50
+ attr_accessor :return_url, :return_text
51
+
52
+ # Debugging
53
+ attr_accessor :debug_logging
54
+
55
+ # == Initialization ==
56
+
57
+ def initialize
58
+ set_defaults
59
+ end
60
+
61
+ private
62
+
63
+ def set_defaults
64
+ # Core Authentication
65
+ @header = "Authorization" # Expects "Bearer <token>"
66
+ @query_param = nil # No query param lookup by default
67
+
68
+ # Token Generation
69
+ @token_prefix = -> { "ak_" }
70
+ @token_length = 24 # Bytes of entropy
71
+ @token_alphabet = :base58 # Avoid ambiguous chars (0, O, I, l)
72
+
73
+ # Storage & Verification
74
+ @hash_strategy = :sha256 # sha256 or :bcrypt
75
+ @secure_compare_proc = ->(a, b) { ActiveSupport::SecurityUtils.secure_compare(a, b) }
76
+ @key_store_adapter = :active_record # Default storage backend
77
+ # TODO: Define and implement ApiKeys::BasePolicy in later versions
78
+ # This will define the authorization policy class used to check if a key is valid beyond basic checks.
79
+ # Allows injecting custom logic (IP allow-listing, time-of-day checks, etc.).
80
+ # Must be a class name (String or Class) responding to `.new(api_key, request).valid?`
81
+ # Default: "ApiKeys::BasePolicy" (a basic implementation should be provided)
82
+ @policy_provider = "ApiKeys::BasePolicy" # Default authorization policy class name
83
+
84
+ # Engine Configuration
85
+ @parent_controller = '::ApplicationController'
86
+
87
+ # Optional Behaviors
88
+ @default_max_keys_per_owner = nil # No global key limit per owner
89
+ @require_key_name = false # Don't require names for keys globally
90
+ @expire_after = nil # Keys do not expire by default (e.g., 90.days)
91
+ @default_scopes = [] # No default scopes assigned globally
92
+
93
+ # Performance
94
+ @cache_ttl = 5.seconds # Good balance: fast revocation, mostly-cached – still allows most repeated requests to benefit from cache
95
+
96
+ # Security
97
+ @https_only_production = true # Warn if used over HTTP in production
98
+ @https_strict_mode = false # Don't raise error, just warn
99
+
100
+ # Background Job Queues
101
+ @stats_job_queue = :default
102
+ @callbacks_job_queue = :default
103
+
104
+ # Global Async Toggle
105
+ @enable_async_operations = true # Default to true to enable jobs
106
+
107
+ # Usage Statistics
108
+ @track_requests_count = false # Don't increment `requests_count` by default
109
+
110
+ # Callbacks
111
+ @before_authentication = DEFAULT_CALLBACK
112
+ @after_authentication = DEFAULT_CALLBACK
113
+
114
+ # Engine UI Configuration
115
+ @return_url = "/" # Default fallback path
116
+ @return_text = "‹ Home" # Default link text
117
+
118
+ # Debugging
119
+ @debug_logging = false # Disable debug logging by default (warn and error get logged regardless of this)
120
+
121
+ # Tenant Resolution
122
+ @tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "authentication" # Include authentication logic
5
+ require_relative "tenant_resolution" # Include tenant resolution logic
6
+
7
+ module ApiKeys
8
+ # Unified controller concern that bundles common ApiKeys functionality
9
+ # for easy inclusion in controllers.
10
+ #
11
+ # Includes:
12
+ # - ApiKeys::Authentication (provides authenticate_api_key!, current_api_key, etc.)
13
+ # - ApiKeys::TenantResolution (provides current_api_tenant)
14
+ #
15
+ # == Usage
16
+ #
17
+ # class Api::BaseController < ActionController::API
18
+ # include ApiKeys::Controller
19
+ #
20
+ # before_action :authenticate_api_key!
21
+ #
22
+ # def show
23
+ # # Access helpers provided by the included concerns
24
+ # key = current_api_key
25
+ # owner = current_api_owner
26
+ # tenant = current_api_tenant
27
+ # # ...
28
+ # end
29
+ # end
30
+ #
31
+ module Controller
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ # Bring in the functionality from the specific concerns
36
+ include ApiKeys::Authentication
37
+ include ApiKeys::TenantResolution
38
+
39
+ # You could add further convenience methods here if needed,
40
+ # potentially combining logic from both included concerns.
41
+ end
42
+
43
+ # Add any class methods specific to this unified concern if necessary
44
+ # module ClassMethods
45
+ # end
46
+ end
47
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require_relative "models/concerns/has_api_keys" # Ensure concern is loaded
5
+
6
+ module ApiKeys
7
+ # Rails engine for ApiKeys
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace ApiKeys
10
+
11
+ # Allows configuring the parent controller for the engine's controllers
12
+ # Defaults to ::ApplicationController, assuming a standard Rails app structure.
13
+ config.parent_controller = '::ApplicationController'
14
+
15
+ # Ensure our models load first
16
+ config.autoload_paths << File.expand_path("../models", __dir__)
17
+ config.autoload_paths << File.expand_path("../models/concerns", __dir__)
18
+
19
+ # Set up autoloading paths
20
+ initializer "api_keys.autoload", before: :set_autoload_paths do |app|
21
+ app.config.autoload_paths << root.join("lib")
22
+ app.config.autoload_paths << root.join("lib/api_keys/models")
23
+ app.config.autoload_paths << root.join("lib/api_keys/models/concerns")
24
+ end
25
+
26
+ # Add has_api_keys method to ActiveRecord::Base
27
+ initializer "api_keys.active_record" do
28
+ ActiveSupport.on_load(:active_record) do
29
+ # Extend all AR models with the ClassMethods module from HasApiKeys
30
+ # This makes the `has_api_keys` method available directly on models like User.
31
+ extend ApiKeys::Models::Concerns::HasApiKeys::ClassMethods
32
+ end
33
+ end
34
+
35
+
36
+ # Load JSON attribute types after ActiveRecord initialization
37
+ # and database connection is established.
38
+ initializer "api_keys.model_attributes" do
39
+ ActiveSupport.on_load(:active_record) do
40
+ ApiKeys::ApiKey.class_eval do
41
+ # Define JSON attributes for ApiKey model
42
+ # Ensure the ApiKey model class is loaded before reopening
43
+ # Use require_dependency for development/test, rely on autoloading in production
44
+ # Or simply let Zeitwerk handle loading if structure is correct.
45
+ require_dependency "api_keys/models/api_key" if defined?(Rails) && !Rails.env.production?
46
+
47
+ # == JSON Attribute Casting ==
48
+ # Make the gem work in any database (postgres, sqlite3, mysql...)
49
+ # Configure the right json-like attributes for the different databases
50
+ # according to the migration (jsonb = postgres; text = elsewhere)
51
+ # So that the JSON attributes work in any database
52
+ # and the gem works everywhere, transparent to end users
53
+ json_col_type = ApiKeys::ApiKey.connection.adapter_name.downcase.include?('postg') ? :jsonb : :json
54
+ ApiKeys::ApiKey.attribute :scopes, json_col_type, default: []
55
+ ApiKeys::ApiKey.attribute :metadata, json_col_type, default: {}
56
+
57
+ end
58
+ end
59
+ end
60
+
61
+
62
+ # Add other initializers here if needed (e.g., for configuration loading,
63
+ # middleware injection, asset precompilation, etc.)
64
+
65
+ # Example: Load configuration defaults
66
+ # initializer "api_keys.configuration" do
67
+ # require_relative "../config"
68
+ # # Potentially load default config values here
69
+ # end
70
+
71
+ # Example: Add middleware if needed
72
+ # initializer "api_keys.middleware" do |app|
73
+ # # app.middleware.use ApiKeys::Middleware::SomeMiddleware
74
+ # end
75
+ end
76
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require_relative "../logging"
5
+ require_relative "../configuration" # Access configuration for callbacks
6
+
7
+ module ApiKeys
8
+ module Jobs
9
+ # Background job to execute configured lifecycle callbacks asynchronously.
10
+ class CallbacksJob < ActiveJob::Base
11
+ include ApiKeys::Logging
12
+
13
+ # Use the queue name specified in the configuration (evaluated at load time)
14
+ queue_as ApiKeys.configuration.callbacks_job_queue
15
+
16
+ # Executes the appropriate callback based on the type.
17
+ #
18
+ # @param callback_type [Symbol] :before_authentication or :after_authentication
19
+ # @param context [Hash] Serializable context data for the callback.
20
+ def perform(callback_type, context = {})
21
+ config = ApiKeys.configuration
22
+
23
+ case callback_type
24
+ when :before_authentication
25
+ execute_callback(config.before_authentication, context)
26
+ when :after_authentication
27
+ execute_callback(config.after_authentication, context)
28
+ else
29
+ log_warn "[ApiKeys::Jobs::CallbacksJob] Unknown callback type: #{callback_type}"
30
+ end
31
+ rescue StandardError => e
32
+ log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} with context #{context.inspect}: #{e.class}: #{e.message}
33
+ #{e.backtrace.join("
34
+ ")}"
35
+ # Avoid retrying callback errors by default, as the original request succeeded.
36
+ # Depending on callback importance, users might configure retries separately.
37
+ end
38
+
39
+ private
40
+
41
+ # Safely executes a user-provided callback lambda.
42
+ #
43
+ # @param callback_proc [Proc, Lambda] The configured callback.
44
+ # @param context [Hash] The context data to pass.
45
+ def execute_callback(callback_proc, context)
46
+ unless callback_proc.is_a?(Proc)
47
+ log_debug "[ApiKeys::Jobs::CallbacksJob] Callback is not a Proc, skipping execution."
48
+ return
49
+ end
50
+
51
+ arity = callback_proc.arity
52
+ log_debug "[ApiKeys::Jobs::CallbacksJob] Executing callback with arity #{arity}"
53
+
54
+ begin
55
+ if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
56
+ callback_proc.call(context)
57
+ elsif arity == 0 # Handle procs accepting no args
58
+ callback_proc.call
59
+ else
60
+ log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
61
+ end
62
+ rescue StandardError => e
63
+ # Log the specific error from the user's callback code
64
+ raise # Re-raise to be caught by the main perform rescue block for logging
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+ require_relative "../models/api_key"
5
+ require_relative "../logging"
6
+
7
+ module ApiKeys
8
+ module Jobs
9
+ # Background job to update API key usage statistics (last_used_at, requests_count).
10
+ # Enqueued by the Authentication concern after a successful request.
11
+ class UpdateStatsJob < ActiveJob::Base
12
+ include ApiKeys::Logging # Include logging helpers
13
+
14
+ # Use the queue name specified in the configuration (evaluated at load time)
15
+ queue_as ApiKeys.configuration.stats_job_queue
16
+
17
+ # Perform the database updates for the given ApiKey.
18
+ #
19
+ # @param api_key_id [Integer, String] The ID of the ApiKey to update.
20
+ # @param timestamp [Time] The timestamp of the request (when it was authenticated).
21
+ def perform(api_key_id, timestamp)
22
+ api_key = ApiKey.find_by(id: api_key_id)
23
+
24
+ unless api_key
25
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] ApiKey not found with ID: #{api_key_id}. Skipping stats update."
26
+ return
27
+ end
28
+
29
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id} at #{timestamp}"
30
+
31
+ # Use provided timestamp for consistency
32
+ # Use update_column to skip validations/callbacks for performance
33
+ api_key.update_column(:last_used_at, timestamp)
34
+
35
+ # Conditionally increment requests_count if configured
36
+ if ApiKeys.configuration.track_requests_count
37
+ # Use increment_counter for atomic updates
38
+ ApiKey.increment_counter(:requests_count, api_key.id)
39
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Incremented requests_count for ApiKey ID: #{api_key_id}"
40
+ end
41
+
42
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Finished updating stats for ApiKey ID: #{api_key_id}"
43
+
44
+ rescue ActiveRecord::ActiveRecordError => e
45
+ # Log error but don't automatically retry unless configured to do so.
46
+ # Frequent stats updates might tolerate occasional failures better than endless retries.
47
+ log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id}. Error: #{e.message}"
48
+ # Depending on ActiveJob adapter, specific retry logic might be needed here
49
+ # or configured globally. For now, just log.
50
+ rescue StandardError => e
51
+ log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id}. Error: #{e.class}: #{e.message}
52
+ #{e.backtrace.join("
53
+ ")}"
54
+ # Consider re-raising or using a dead-letter queue strategy depending on job system
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApiKeys
4
+ # Shared logging utilities for the ApiKeys gem.
5
+ module Logging
6
+ private
7
+
8
+ # Helper for conditional debug logging based on configuration.
9
+ #
10
+ # @param message [String] The message to log.
11
+ def log_debug(message)
12
+ # Only log if debug_logging is enabled and a logger is available
13
+ if ApiKeys.configuration.debug_logging && logger
14
+ logger.debug(message)
15
+ end
16
+ end
17
+
18
+ # Helper for conditional warning logging.
19
+ # Warnings are logged regardless of debug flag, if logger available.
20
+ #
21
+ # @param message [String] The message to log.
22
+ def log_warn(message)
23
+ logger.warn(message) if logger
24
+ end
25
+
26
+ # Helper for logging errors.
27
+ # Errors are logged regardless of debug flag, if logger available.
28
+ #
29
+ # @param message [String] The message to log.
30
+ def log_error(message)
31
+ logger.error(message) if logger
32
+ end
33
+
34
+ # Provides access to the logger instance (Rails.logger if defined).
35
+ #
36
+ # @return [Logger, nil] The logger instance or nil.
37
+ def logger
38
+ # Memoize the logger instance for performance
39
+ @_api_keys_logger ||= defined?(Rails) ? Rails.logger : nil
40
+ end
41
+ end
42
+ end