parse-stack-next 4.5.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 (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,203 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+ require "moneta"
6
+ require "digest"
7
+ require_relative "protocol"
8
+
9
+ module Parse
10
+ module Middleware
11
+ # This is a caching middleware for Parse queries using Moneta. The caching
12
+ # middleware will cache all GET requests made to the Parse REST API as long
13
+ # as the API responds with a successful non-empty result payload.
14
+ #
15
+ # Whenever an object is created or updated, the corresponding entry in the cache
16
+ # when fetching the particular record (using the specific non-Query based API)
17
+ # will be cleared.
18
+ class Caching < Faraday::Middleware
19
+ include Parse::Protocol
20
+
21
+ # List of status codes that can be cached:
22
+ # * 200 - 'OK'
23
+ # * 203 - 'Non-Authoritative Information'
24
+ # * 300 - 'Multiple Choices'
25
+ # * 301 - 'Moved Permanently'
26
+ # * 302 - 'Found'
27
+ # * 404 - 'Not Found' - removed
28
+ # * 410 - 'Gone' - removed
29
+ CACHEABLE_HTTP_CODES = [200, 203, 300, 301, 302].freeze
30
+ # Cache control header
31
+ CACHE_CONTROL = "Cache-Control"
32
+ # Request env key for the content length
33
+ CONTENT_LENGTH_KEY = "content-length"
34
+ # Header in response that is sent if this is a cached result
35
+ CACHE_RESPONSE_HEADER = "X-Cache-Response"
36
+ # Header in request to set caching information for the middleware.
37
+ CACHE_EXPIRES_DURATION = "X-Parse-Stack-Cache-Expires"
38
+ # Header in request to enable write-only cache mode (skip read, still write)
39
+ CACHE_WRITE_ONLY = "X-Parse-Stack-Cache-Write-Only"
40
+
41
+ class << self
42
+ # @!attribute enabled
43
+ # @return [Boolean] whether the caching middleware should be enabled.
44
+ attr_writer :enabled
45
+
46
+ # @!attribute logging
47
+ # @return [Boolean] whether the logging should be enabled.
48
+ attr_accessor :logging
49
+
50
+ def enabled
51
+ @enabled = true if @enabled.nil?
52
+ @enabled
53
+ end
54
+
55
+ # @return [Boolean] whether caching is enabled.
56
+ def caching?
57
+ @enabled
58
+ end
59
+ end
60
+
61
+ # @!attribute [rw] store
62
+ # The internal moneta cache store instance.
63
+ # @return [Moneta::Transformer,Moneta::Expires]
64
+ attr_accessor :store
65
+
66
+ # @!attribute [rw] expires
67
+ # The expiration time in seconds for this particular request.
68
+ # @return [Integer]
69
+ attr_accessor :expires
70
+
71
+ # Creates a new caching middleware.
72
+ # @param adapter [Faraday::Adapter] An instance of the Faraday adapter
73
+ # used for the connection. Defaults Faraday::Adapter::NetHttp.
74
+ # @param store [Moneta] An instance of the Moneta cache store to use.
75
+ # @param opts [Hash] additional options.
76
+ # @option opts [Integer] :expires the default expiration for a cache entry.
77
+ # @raise ArgumentError, if `store` is not a Moneta::Transformer or Moneta::Expires instance.
78
+ def initialize(adapter, store, opts = {})
79
+ super(adapter)
80
+ @store = store
81
+ @opts = { expires: 0 }
82
+ @opts.merge!(opts) if opts.is_a?(Hash)
83
+ @expires = @opts[:expires]
84
+
85
+ unless [:key?, :[], :delete, :store].all? { |method| @store.respond_to?(method) }
86
+ raise ArgumentError, "Caching store object must a Moneta key/value store."
87
+ end
88
+ end
89
+
90
+ # Thread-safety
91
+ # @!visibility private
92
+ def call(env)
93
+ dup.call!(env)
94
+ end
95
+
96
+ # @!visibility private
97
+ def call!(env)
98
+ @request_headers = env[:request_headers]
99
+
100
+ # get default caching state
101
+ @enabled = self.class.enabled
102
+ # disable cache for this request if "no-cache" was passed
103
+ if @request_headers[CACHE_CONTROL] == "no-cache"
104
+ @enabled = false
105
+ end
106
+
107
+ # Check for write-only mode (skip cache read, still write to cache)
108
+ # This is useful for fetch!/reload! which want fresh data but should update cache
109
+ @write_only = @request_headers[CACHE_WRITE_ONLY] == "true"
110
+
111
+ # get the expires information from header (per-request) or instance default
112
+ if @request_headers[CACHE_EXPIRES_DURATION].to_i > 0
113
+ @expires = @request_headers[CACHE_EXPIRES_DURATION].to_i
114
+ end
115
+
116
+ # cleanup
117
+ @request_headers.delete(CACHE_CONTROL)
118
+ @request_headers.delete(CACHE_EXPIRES_DURATION)
119
+ @request_headers.delete(CACHE_WRITE_ONLY)
120
+
121
+ # if caching is enabled and we have a valid cache duration, use cache
122
+ # otherwise work as a passthrough.
123
+ return @app.call(env) unless @enabled && @store.present? && @expires > 0
124
+
125
+ url = env.url
126
+ method = env.method
127
+ @cache_key = url.to_s
128
+
129
+ if @request_headers.key?(SESSION_TOKEN)
130
+ @session_token = @request_headers[SESSION_TOKEN]
131
+ hashed_token = Digest::SHA256.hexdigest(@session_token.to_s)[0, 32]
132
+ @cache_key = "#{hashed_token}:#{@cache_key}" # prefix with hashed token
133
+ elsif @request_headers.key?(MASTER_KEY)
134
+ @cache_key = "mk:#{@cache_key}" # prefix for master key requests
135
+ end
136
+
137
+ begin
138
+ # Skip cache read if write_only mode is enabled
139
+ if method == :get && @cache_key.present? && !@write_only && @store.key?(@cache_key)
140
+ puts("[Parse::Cache] Hit >> #{url}") if self.class.logging.present?
141
+ response = Faraday::Response.new
142
+ begin
143
+ cache_data = @store[@cache_key] # previous cached response
144
+ rescue => e
145
+ puts "[Parse::Cache] Error: #{e}"
146
+ cache_data = nil
147
+ end
148
+
149
+ # check if the store was from a legacy parse-stack cache value which
150
+ # is stored as Faraday::Env. T\he new system stores less content in a simple hash
151
+ # for improved interoperability and access time.
152
+ if cache_data.is_a?(Faraday::Env)
153
+ body = cache_data.respond_to?(:body) ? cache_data.body : nil
154
+ response_headers = cache_data.response_headers || {}
155
+ elsif cache_data.is_a?(Hash)
156
+ body = cache_data[:body]
157
+ response_headers = cache_data[:headers] || {}
158
+ end
159
+
160
+ if cache_data.present? && body.present?
161
+ response_headers[CACHE_RESPONSE_HEADER] = "true"
162
+ response.finish({ status: 200, response_headers: response_headers, body: body })
163
+ return response
164
+ else
165
+ @store.delete @cache_key
166
+ end
167
+ elsif @cache_key.present?
168
+ #non GET requets should clear the cache for that same resource path.
169
+ #ex. a POST to /1/classes/Artist/<objectId> should delete the cache for a GET
170
+ # request for the same '/1/classes/Artist/<objectId>' where objectId are equivalent
171
+ @store.delete url.to_s # regular
172
+ @store.delete "mk:#{url.to_s}" # master key cache-key
173
+ @store.delete @cache_key # final key
174
+ end
175
+ rescue ::TypeError, Errno::EINVAL, Redis::CannotConnectError, Redis::TimeoutError => e
176
+ # if the cache store fails to connect, catch the exception but proceed
177
+ # with the regular request, but turn off caching for this request. It is possible
178
+ # that the cache connection resumes at a later point, so this is temporary.
179
+ @enabled = false
180
+ puts "[Parse::Cache] Error: #{e}"
181
+ end
182
+
183
+ @app.call(env).on_complete do |response_env|
184
+ # Only cache GET requests with valid HTTP status codes whose content-length
185
+ # is between 20 bytes and 1MB. Otherwise they could be errors, successes and empty result sets.
186
+
187
+ if @enabled && method == :get && CACHEABLE_HTTP_CODES.include?(response_env.status) &&
188
+ response_env.body.present? && response_env.response_headers[CONTENT_LENGTH_KEY].to_i.between?(20, 1_250_000)
189
+ begin
190
+ @store.store(@cache_key,
191
+ { headers: response_env.response_headers, body: response_env.body },
192
+ expires: @expires)
193
+ rescue => e
194
+ puts "[Parse::Cache] Store Error: #{e}"
195
+ end
196
+ end # if
197
+ # do something with the response
198
+ # response_env[:response_headers].merge!(...)
199
+ end
200
+ end
201
+ end #Caching
202
+ end #Middleware
203
+ end
@@ -0,0 +1,293 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+ require "logger"
6
+
7
+ module Parse
8
+ module Middleware
9
+ # Faraday middleware that logs Parse API requests and responses.
10
+ #
11
+ # This middleware provides detailed logging of HTTP requests and responses
12
+ # with configurable log levels and optional body truncation for large payloads.
13
+ #
14
+ # @example Basic setup
15
+ # Parse.logging = true
16
+ #
17
+ # @example Detailed configuration
18
+ # Parse.configure do |config|
19
+ # config.logging = true
20
+ # config.log_level = :debug
21
+ # config.logger = Rails.logger # or Logger.new(STDOUT)
22
+ # end
23
+ #
24
+ # Log levels:
25
+ # - :info - Logs request method, URL, status, and timing
26
+ # - :debug - Also logs headers and truncated body content
27
+ # - :warn - Only logs errors and warnings
28
+ #
29
+ class Logging < Faraday::Middleware
30
+ # Maximum length of body content to log before truncation
31
+ MAX_BODY_LENGTH = 500
32
+
33
+ class << self
34
+ # @return [Boolean] Whether logging is enabled
35
+ attr_accessor :enabled
36
+
37
+ # @return [Symbol] The log level (:info, :debug, :warn)
38
+ attr_accessor :log_level
39
+
40
+ # @return [Logger] The logger instance to use
41
+ attr_accessor :logger
42
+
43
+ # @return [Integer] Maximum body length to log (defaults to MAX_BODY_LENGTH)
44
+ attr_accessor :max_body_length
45
+
46
+ # Default logger instance
47
+ # @return [Logger]
48
+ def default_logger
49
+ @default_logger ||= begin
50
+ l = Logger.new(STDOUT)
51
+ l.progname = "Parse"
52
+ l.formatter = proc do |severity, datetime, progname, msg|
53
+ "[#{progname}] #{msg}\n"
54
+ end
55
+ l
56
+ end
57
+ end
58
+
59
+ # Get the configured logger or default
60
+ # @return [Logger]
61
+ def current_logger
62
+ logger || default_logger
63
+ end
64
+
65
+ # Get the current log level (defaults to :info)
66
+ # @return [Symbol]
67
+ def current_log_level
68
+ log_level || :info
69
+ end
70
+
71
+ # Get the max body length (defaults to MAX_BODY_LENGTH)
72
+ # @return [Integer]
73
+ def current_max_body_length
74
+ max_body_length || MAX_BODY_LENGTH
75
+ end
76
+ end
77
+
78
+ # Thread-safety: duplicate the middleware for each request
79
+ # @!visibility private
80
+ def call(env)
81
+ dup.call!(env)
82
+ end
83
+
84
+ # @!visibility private
85
+ def call!(env)
86
+ return @app.call(env) unless self.class.enabled
87
+
88
+ start_time = Time.now
89
+ log_request(env)
90
+
91
+ @app.call(env).on_complete do |response_env|
92
+ elapsed_ms = ((Time.now - start_time) * 1000).round(2)
93
+ log_response(response_env, elapsed_ms)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def log_request(env)
100
+ logger = self.class.current_logger
101
+ level = self.class.current_log_level
102
+
103
+ method = env[:method].to_s.upcase
104
+ url = sanitize_url(env[:url].to_s)
105
+
106
+ case level
107
+ when :debug
108
+ logger.debug "▶ #{method} #{url}"
109
+ log_headers(env[:request_headers], "Request")
110
+ log_body(env[:body], "Request")
111
+ when :info
112
+ logger.info "▶ #{method} #{url}"
113
+ end
114
+ end
115
+
116
+ def log_response(response_env, elapsed_ms)
117
+ logger = self.class.current_logger
118
+ level = self.class.current_log_level
119
+ status = response_env[:status]
120
+
121
+ # Determine if this is an error response
122
+ is_error = status >= 400
123
+
124
+ case level
125
+ when :debug
126
+ log_debug_response(logger, response_env, elapsed_ms, is_error)
127
+ when :info
128
+ log_info_response(logger, response_env, elapsed_ms, is_error)
129
+ when :warn
130
+ log_warn_response(logger, response_env, elapsed_ms) if is_error
131
+ end
132
+ end
133
+
134
+ def log_debug_response(logger, response_env, elapsed_ms, is_error)
135
+ status = response_env[:status]
136
+ status_indicator = is_error ? "✗" : "◀"
137
+
138
+ logger.debug "#{status_indicator} #{status} (#{elapsed_ms}ms)"
139
+ log_body(response_body_content(response_env), "Response")
140
+ end
141
+
142
+ def log_info_response(logger, response_env, elapsed_ms, is_error)
143
+ status = response_env[:status]
144
+ status_indicator = is_error ? "✗" : "◀"
145
+
146
+ if is_error
147
+ logger.info "#{status_indicator} #{status} (#{elapsed_ms}ms) - #{error_summary(response_env)}"
148
+ else
149
+ logger.info "#{status_indicator} #{status} (#{elapsed_ms}ms)"
150
+ end
151
+ end
152
+
153
+ def log_warn_response(logger, response_env, elapsed_ms)
154
+ status = response_env[:status]
155
+ logger.warn "✗ #{status} (#{elapsed_ms}ms) - #{error_summary(response_env)}"
156
+ end
157
+
158
+ def log_headers(headers, prefix)
159
+ return unless headers
160
+ logger = self.class.current_logger
161
+ headers.each do |key, value|
162
+ # Don't log sensitive headers. Reuses the canonical denylist on
163
+ # Parse::Middleware::BodyBuilder so Authorization, Cookie, and
164
+ # X-Parse-JavaScript-Key are also redacted (the prior regex only
165
+ # caught master-key / api-key / session-token shaped names).
166
+ if Parse::Middleware::BodyBuilder::REDACTED_HEADERS.include?(key.to_s.downcase)
167
+ logger.debug " [#{prefix} Header] #{key}: [FILTERED]"
168
+ else
169
+ logger.debug " [#{prefix} Header] #{key}: #{value}"
170
+ end
171
+ end
172
+ end
173
+
174
+ def log_body(body, prefix)
175
+ return unless body
176
+ logger = self.class.current_logger
177
+ max_length = self.class.current_max_body_length
178
+
179
+ content = if body.is_a?(String)
180
+ body
181
+ else
182
+ begin
183
+ body.to_json
184
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
185
+ body.to_s
186
+ end
187
+ end
188
+
189
+ if content.length > max_length
190
+ logger.debug " [#{prefix} Body] #{content[0...max_length]}... (truncated, #{content.length} total)"
191
+ elsif content.length > 0
192
+ logger.debug " [#{prefix} Body] #{content}"
193
+ end
194
+ end
195
+
196
+ def response_body_content(response_env)
197
+ body = response_env[:body]
198
+ if body.is_a?(Parse::Response)
199
+ begin
200
+ body.result.to_json
201
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
202
+ body.to_s
203
+ end
204
+ else
205
+ body
206
+ end
207
+ end
208
+
209
+ def error_summary(response_env)
210
+ body = response_env[:body]
211
+ if body.is_a?(Parse::Response) && body.error?
212
+ "#{body.code}: #{body.error}"
213
+ elsif body.is_a?(Hash)
214
+ body["error"] || body[:error] || "Unknown error"
215
+ else
216
+ "HTTP #{response_env[:status]}"
217
+ end
218
+ end
219
+
220
+ def sanitize_url(url)
221
+ # Remove sensitive query parameters from logged URLs
222
+ url.gsub(/([?&])(sessionToken|masterKey|apiKey)=[^&]*/, '\1\2=[FILTERED]')
223
+ end
224
+ end
225
+ end
226
+
227
+ # Module-level configuration methods for logging
228
+ class << self
229
+ # Enable or disable request/response logging
230
+ # @example Enable logging
231
+ # Parse.logging_enabled = true
232
+ # @param value [Boolean]
233
+ def logging_enabled=(value)
234
+ Middleware::Logging.enabled = value
235
+ end
236
+
237
+ # @return [Boolean] whether logging is enabled
238
+ def logging_enabled
239
+ Middleware::Logging.enabled
240
+ end
241
+
242
+ # Set the log level for Parse requests
243
+ # @example Set debug level
244
+ # Parse.log_level = :debug
245
+ # @param value [Symbol] one of :info, :debug, :warn
246
+ def log_level=(value)
247
+ unless [:info, :debug, :warn].include?(value)
248
+ raise ArgumentError, "Invalid log level: #{value}. Use :info, :debug, or :warn"
249
+ end
250
+ Middleware::Logging.log_level = value
251
+ end
252
+
253
+ # @return [Symbol] the current log level
254
+ def log_level
255
+ Middleware::Logging.current_log_level
256
+ end
257
+
258
+ # Set a custom logger for Parse requests
259
+ # @example Use Rails logger
260
+ # Parse.logger = Rails.logger
261
+ # @param value [Logger]
262
+ def logger=(value)
263
+ Middleware::Logging.logger = value
264
+ end
265
+
266
+ # @return [Logger] the current logger
267
+ def logger
268
+ Middleware::Logging.current_logger
269
+ end
270
+
271
+ # Set the maximum body length to log before truncation
272
+ # @param value [Integer]
273
+ def log_max_body_length=(value)
274
+ Middleware::Logging.max_body_length = value.to_i
275
+ end
276
+
277
+ # @return [Integer] the maximum body length
278
+ def log_max_body_length
279
+ Middleware::Logging.current_max_body_length
280
+ end
281
+
282
+ # Configure Parse logging with a block
283
+ # @example
284
+ # Parse.configure_logging do |config|
285
+ # config.enabled = true
286
+ # config.log_level = :debug
287
+ # config.logger = Rails.logger
288
+ # end
289
+ def configure_logging
290
+ yield Middleware::Logging if block_given?
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,181 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "faraday"
5
+
6
+ module Parse
7
+ module Middleware
8
+ # Faraday middleware that profiles Parse API requests.
9
+ #
10
+ # This middleware provides detailed timing information for HTTP requests
11
+ # including network time and overall request duration.
12
+ #
13
+ # @example Enable profiling
14
+ # Parse.profiling_enabled = true
15
+ #
16
+ # @example Access profile data in callbacks
17
+ # Parse.on_request_complete do |profile|
18
+ # puts "Request to #{profile[:url]} took #{profile[:duration_ms]}ms"
19
+ # end
20
+ #
21
+ # @example Get recent profiles
22
+ # Parse.recent_profiles.each do |profile|
23
+ # puts "#{profile[:method]} #{profile[:url]}: #{profile[:duration_ms]}ms"
24
+ # end
25
+ #
26
+ class Profiling < Faraday::Middleware
27
+ # Maximum number of profiles to keep in memory
28
+ MAX_PROFILES = 100
29
+
30
+ class << self
31
+ # @return [Boolean] Whether profiling is enabled
32
+ attr_accessor :enabled
33
+
34
+ # @return [Array<Hash>] Recent profile data
35
+ def profiles
36
+ @profiles ||= []
37
+ end
38
+
39
+ # Clear all stored profiles
40
+ def clear_profiles!
41
+ @profiles = []
42
+ end
43
+
44
+ # @return [Array<Proc>] Callbacks to execute on request completion
45
+ def callbacks
46
+ @callbacks ||= []
47
+ end
48
+
49
+ # Register a callback to be executed when a request completes
50
+ # @yield [Hash] the profile data for the completed request
51
+ def on_request_complete(&block)
52
+ callbacks << block if block_given?
53
+ end
54
+
55
+ # Clear all registered callbacks
56
+ def clear_callbacks!
57
+ @callbacks = []
58
+ end
59
+
60
+ # Add a profile entry
61
+ # @param profile [Hash] the profile data
62
+ def add_profile(profile)
63
+ profiles << profile
64
+ # Keep only the most recent profiles
65
+ profiles.shift while profiles.size > MAX_PROFILES
66
+
67
+ # Execute callbacks
68
+ callbacks.each { |cb| cb.call(profile) }
69
+ end
70
+
71
+ # Get aggregate statistics for recent profiles
72
+ # @return [Hash] statistics including count, avg, min, max durations
73
+ def statistics
74
+ return {} if profiles.empty?
75
+
76
+ durations = profiles.map { |p| p[:duration_ms] }
77
+ {
78
+ count: profiles.size,
79
+ total_ms: durations.sum,
80
+ avg_ms: (durations.sum.to_f / durations.size).round(2),
81
+ min_ms: durations.min,
82
+ max_ms: durations.max,
83
+ by_method: profiles.group_by { |p| p[:method] }.transform_values(&:size),
84
+ by_status: profiles.group_by { |p| p[:status] }.transform_values(&:size),
85
+ }
86
+ end
87
+ end
88
+
89
+ # Thread-safety: duplicate the middleware for each request
90
+ # @!visibility private
91
+ def call(env)
92
+ dup.call!(env)
93
+ end
94
+
95
+ # @!visibility private
96
+ def call!(env)
97
+ return @app.call(env) unless self.class.enabled
98
+
99
+ start_time = Time.now
100
+
101
+ @app.call(env).on_complete do |response_env|
102
+ end_time = Time.now
103
+ duration_ms = ((end_time - start_time) * 1000).round(2)
104
+
105
+ profile = {
106
+ method: env[:method].to_s.upcase,
107
+ url: sanitize_url(env[:url].to_s),
108
+ status: response_env[:status],
109
+ duration_ms: duration_ms,
110
+ started_at: start_time.iso8601(3),
111
+ completed_at: end_time.iso8601(3),
112
+ request_size: env[:body].to_s.bytesize,
113
+ response_size: response_body_size(response_env),
114
+ }
115
+
116
+ self.class.add_profile(profile)
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ def sanitize_url(url)
123
+ # Remove sensitive query parameters
124
+ url.gsub(/([?&])(sessionToken|masterKey|apiKey)=[^&]*/, '\1\2=[FILTERED]')
125
+ end
126
+
127
+ def response_body_size(response_env)
128
+ body = response_env[:body]
129
+ if body.is_a?(Parse::Response)
130
+ body.result.to_json.bytesize rescue 0
131
+ elsif body.is_a?(String)
132
+ body.bytesize
133
+ else
134
+ body.to_s.bytesize
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ # Module-level profiling configuration
141
+ class << self
142
+ # Enable or disable request profiling
143
+ # @param value [Boolean]
144
+ def profiling_enabled=(value)
145
+ Middleware::Profiling.enabled = value
146
+ end
147
+
148
+ # @return [Boolean] whether profiling is enabled
149
+ def profiling_enabled
150
+ Middleware::Profiling.enabled
151
+ end
152
+
153
+ # Get recent profile data
154
+ # @return [Array<Hash>]
155
+ def recent_profiles
156
+ Middleware::Profiling.profiles
157
+ end
158
+
159
+ # Clear all stored profiles
160
+ def clear_profiles!
161
+ Middleware::Profiling.clear_profiles!
162
+ end
163
+
164
+ # Get profiling statistics
165
+ # @return [Hash]
166
+ def profiling_statistics
167
+ Middleware::Profiling.statistics
168
+ end
169
+
170
+ # Register a callback for request completion
171
+ # @yield [Hash] profile data
172
+ def on_request_complete(&block)
173
+ Middleware::Profiling.on_request_complete(&block)
174
+ end
175
+
176
+ # Clear all profiling callbacks
177
+ def clear_profiling_callbacks!
178
+ Middleware::Profiling.clear_callbacks!
179
+ end
180
+ end
181
+ end