Class: Parse::Webhooks

Inherits:
Object
  • Object
show all
Extended by:
Registration
Includes:
Client::Connectable
Defined in:
lib/parse/webhooks.rb,
lib/parse/webhooks/payload.rb,
lib/parse/webhooks/registration.rb,
lib/parse/webhooks/trigger_audit.rb,
lib/parse/webhooks/replay_protection.rb

Overview

Interface to the CloudCode webhooks API.

Defined Under Namespace

Modules: Registration, ReplayProtection Classes: Payload, ResponseError, TriggerAudit

Constant Summary collapse

AUTH_TRIGGERS =

The authentication-side triggers (local underscore form). These carry a _User / _Session as the payload object but are NOT object save/delete triggers: the router runs no ActiveModel save/create/destroy callbacks for them, and Parse Server ignores their response body.

%i[
  before_login after_login after_logout before_password_reset_request
].freeze
LIVE_QUERY_TRIGGERS =

The LiveQuery triggers (local underscore form). Connection-global or event-scoped; Parse Server ignores their response body. Delivered over an HTTP webhook only in a co-located single-process LiveQuery setup.

%i[before_connect before_subscribe after_event].freeze
NON_OBJECT_TRIGGERS =

Every trigger whose payload is not an object save/delete/find shape. Parse Server's webhook response handler resolves {} for all of these (the body is ignored), so the router normalizes their handler result to a success no-op rather than serializing a returned object into the response.

(AUTH_TRIGGERS + LIVE_QUERY_TRIGGERS).freeze
REJECTABLE_NON_OBJECT_TRIGGERS =

The before* subset of NON_OBJECT_TRIGGERS for which a handler can DENY the operation. Parse Server only treats an {error} response as a rejection -- a {success:false} body resolves and lets the login / connect / subscribe / reset proceed. So, mirroring the before_save convention, the router converts a false return from one of these into a ResponseError (which serializes to {error}). error! works for any trigger; the after* variants fire after the fact and cannot undo it.

%i[
  before_login before_password_reset_request before_connect before_subscribe
].freeze
HTTP_PARSE_WEBHOOK =

The name of the incoming env containing the webhook key.

"HTTP_X_PARSE_WEBHOOK_KEY"
HTTP_PARSE_APPLICATION_ID =

The name of the incoming env containing the application id key.

"HTTP_X_PARSE_APPLICATION_ID"
CONTENT_TYPE =

The content type that needs to be sent back to Parse server.

"application/json"

Constants included from Registration

Registration::ALLOWED_HOOKS

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Registration

register_functions!, register_triggers!, register_webhook!, remove_all_functions!, remove_all_triggers!

Methods included from Client::Connectable

#client

Class Attribute Details

.abort_after_callbacks_on_errorBoolean

Returns:

  • (Boolean)


170
171
172
173
# File 'lib/parse/webhooks.rb', line 170

def abort_after_callbacks_on_error
  return @abort_after_callbacks_on_error unless @abort_after_callbacks_on_error.nil?
  true
end

.allow_private_webhook_urlsObject



733
734
735
736
# File 'lib/parse/webhooks.rb', line 733

def allow_private_webhook_urls
  return @allow_private_webhook_urls unless @allow_private_webhook_urls.nil?
  ENV["PARSE_WEBHOOK_ALLOW_PRIVATE_URLS"] == "true"
end

.allow_unauthenticatedObject



716
717
718
719
# File 'lib/parse/webhooks.rb', line 716

def allow_unauthenticated
  return @allow_unauthenticated unless @allow_unauthenticated.nil?
  ENV["PARSE_WEBHOOK_ALLOW_UNAUTHENTICATED"] == "true"
end

.keyString

Returns the configured webhook key if available. By default it will use the value of ENV if not configured.

Returns:



699
700
701
702
703
704
# File 'lib/parse/webhooks.rb', line 699

def key=(value)
  @key = value
  # Reset the warn-once flag so a deployment that configures the key
  # after startup gets a clean state if the key is later cleared.
  @missing_key_warned = nil
end

.loggingBoolean

Returns whether to print additional logging information. You may also set this to :debug for additional verbosity.

Returns:

  • (Boolean)

    whether to print additional logging information. You may also set this to :debug for additional verbosity.



182
183
184
# File 'lib/parse/webhooks.rb', line 182

def logging
  @logging
end

Class Method Details

.call(env) ⇒ Array

Standard Rack call method. This method processes an incoming cloud code webhook request from Parse Server, validates it and executes any registered handlers for it. The result of the handler for the matching webhook request is sent back to Parse Server. If the handler raises a ResponseError, it will return the proper error response.

Parameters:

  • env (Hash)

    the environment hash in a Rack request.

Returns:

Raises:

  • Parse::Webhooks::ResponseError whenever Object, ActiveModel::ValidationError



746
747
748
749
# File 'lib/parse/webhooks.rb', line 746

def call(env)
  # Thraed safety
  dup.call!(env)
end

.call_route(type, className, payload = nil) ⇒ Object

Calls the set of registered webhook trigger blocks or the specific function block. This method is usually called when an incoming request from Parse Server is received.

Parameters:

  • payload (Parse::Webhooks::Payload) (defaults to: nil)

    the payload object received from the server.

  • type (Symbol)

    The type of cloud code webhook to register. This can be any of the supported routes. These are :before_save, :after_save,

  • className (String)

    if type is not :function, then this registers a trigger for the given className. Otherwise, className is treated to be the function name to register with Parse server.

Returns:

  • (Object)

    the result of the trigger or function.



421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
# File 'lib/parse/webhooks.rb', line 421

def call_route(type, className, payload = nil)
  type = type.to_s.underscore.to_sym #support camelcase
  className = className.parse_class if className.respond_to?(:parse_class)
  className = className.to_s

  return unless routes[type].present? && routes[type][className].present?
  registry = routes[type][className]

  # Track the header-derived ruby_initiated flag on the payload so
  # user code can introspect it (`payload.ruby_initiated?`). For the
  # framework's own callback-deduplication logic below we use the
  # stricter `trusted_ruby_initiated`, which additionally requires the
  # master key. The X-Parse-Request-Id header is client-controllable,
  # so honoring `_RB_` alone would let any client send `_RB_attacker`
  # and trick the framework into skipping server-side callbacks.
  # Server-side Parse-Stack saves use the master key by default, so
  # the AND is a safe condition for legitimate Ruby-initiated traffic.
  if payload
    request_id = payload&.raw&.dig(:headers, "x-parse-request-id") ||
                 payload&.raw&.dig("headers", "x-parse-request-id") ||
                 payload&.raw&.dig(:headers, "X-Parse-Request-Id") ||
                 payload&.raw&.dig("headers", "X-Parse-Request-Id")
    ruby_initiated = request_id&.start_with?("_RB_") || false
    payload.instance_variable_set(:@ruby_initiated, ruby_initiated)
    trusted_ruby_initiated = ruby_initiated && (payload.master? == true)
  else
    ruby_initiated = false
    trusted_ruby_initiated = false
  end

  # Pre-block: apply declarative write protection (guard :field, :mode)
  # to the parse_object that the handler will receive. Running BEFORE
  # the handler block means trusted server-side writes performed inside
  # the block are preserved -- only client-supplied values for guarded
  # fields are reverted.
  #
  # Notably we do NOT gate this on ruby_initiated. That flag derives
  # from a client-controlled X-Parse-Request-Id header, so trusting it
  # to bypass write protection would allow a one-header attack. Master
  # key requests still bypass via the master:/payload.master? check.
  if type == :before_save && payload && payload.object?
    klass = (className.present? && className != "*") ? Parse::Object.find_class(className) : nil
    if klass && klass.respond_to?(:field_guards) && klass.field_guards.any?
      pre_obj = payload.parse_object # memoized; the handler sees this same instance
      if pre_obj.respond_to?(:apply_field_guards!)
        pre_obj.apply_field_guards!(
          master: payload.master? || false,
          is_new: payload.original.blank?,
        )
      end
    end
  end

  if registry.is_a?(Array)
    # An Array registry only ever exists for the accumulating,
    # non-rejectable `after_*` triggers, so isolating handlers here
    # cannot affect `before_*` rejection semantics.
    result = dispatch_composed(payload, registry, type)
  else
    result = invoke_handler(payload, registry)
  end

  if result.is_a?(Parse::Object)
    # if it is a Parse::Object, we will call the registered ActiveModel callbacks
    if type == :before_save
      # returning false from the callback block only runs the before_* callback
      # Skip prepare_save! when this request is trusted-Ruby-initiated
      # (both `_RB_` header AND master key), since Parse-Stack already
      # ran ActiveModel before_save callbacks locally. A client-spoofed
      # `_RB_` without master falls through and runs them here.
      unless trusted_ruby_initiated
        before_save_result = result.run_before_save_callbacks
        # If a before_save callback halted the chain (returned false), reject the save.
        if before_save_result == false
          raise Parse::Webhooks::ResponseError, "Save halted by before_save callback"
        end
        # Parse Server exposes no separate beforeCreate trigger, so the
        # beforeSave hook is the single point at which before_create must
        # run for a client-initiated create. Run it AFTER before_save, for
        # new objects only -- matching ActiveModel order (before_save wraps
        # before_create) and mirroring the afterSave hook, which runs
        # after_create then after_save. `original.nil?` marks a create.
        if payload && payload.original.nil?
          create_result = result.run_before_create_callbacks
          if create_result == false
            raise Parse::Webhooks::ResponseError, "Save halted by before_create callback"
          end
        end
      end
      # For before_save, return the changes payload (what Parse Server expects)
      result = result.changes_payload
    elsif type == :before_delete
      result.run_callbacks(:destroy) { false }
      result = true
    end
  elsif type == :before_save && result == false
    # If webhook block returns false, halt the save by throwing an error
    raise Parse::Webhooks::ResponseError, "Save halted by before_save webhook"
  elsif type == :before_save && (result == true || result.nil?)
    # Open Source Parse server does not accept true results on before_save hooks.
    result = {}
  end

  # Auth- and LiveQuery-trigger dispatch (beforeLogin/afterLogin/
  # afterLogout/beforePasswordResetRequest, beforeConnect/beforeSubscribe/
  # afterEvent). Parse Server IGNORES the response body for all of these --
  # its webhook response handler resolves {} regardless -- so the ONLY way
  # a handler can affect the operation is the error path, and only for the
  # "before" variants (a login/connect/subscribe/reset can be denied; an
  # after_* fires after the fact and cannot be undone).
  #
  # Crucially, Parse Server treats only an {error} response as a rejection:
  # a {success:false} body RESOLVES and lets the operation proceed. So a
  # handler that returns `false` to "deny login" would silently allow it.
  # We mirror the before_save convention and convert that false into a
  # ResponseError (=> {error} => Parse Server denies). `error!` works for
  # any of them (the call! rescue converts it). Every other return value --
  # including a Parse::Object a handler happened to return (e.g. the _User
  # from beforeLogin) -- is normalized to a success no-op so we never
  # serialize an object into the response or the redacted request log.
  if NON_OBJECT_TRIGGERS.include?(type)
    if result == false && REJECTABLE_NON_OBJECT_TRIGGERS.include?(type)
      raise Parse::Webhooks::ResponseError, "#{type} rejected by webhook handler"
    end
    result = true
  end

  # Guard-injection: when a handler returns a Hash (or true/nil normalized
  # to {}) for a class with field_guards, Parse Server would otherwise
  # merge the response with the client's original payload and persist
  # the client-supplied values for guarded fields. Inject the pre-built
  # parse_object's changes_payload entries for any guarded field so the
  # response carries the appropriate revert (Delete op on create, prior
  # value on update). The Parse::Object return path already runs through
  # changes_payload on the same memoized instance and therefore needs no
  # extra injection.
  if type == :before_save && result.is_a?(Hash) && payload && payload.object?
    guard_klass = (className.present? && className != "*") ? Parse::Object.find_class(className) : nil
    if guard_klass && guard_klass.respond_to?(:field_guards) && guard_klass.field_guards.any?
      pre_obj = payload.parse_object # same memoized instance the pre-block step mutated
      if pre_obj.respond_to?(:changes_payload)
        guard_payload = pre_obj.changes_payload
        field_map = guard_klass.respond_to?(:field_map) ? guard_klass.field_map : {}
        guard_klass.field_guards.each_key do |field|
          remote = (field_map[field.to_sym] || field).to_s
          result[remote] = guard_payload[remote] if guard_payload.key?(remote)
        end
      end
    end
  end

  if type == :after_save && payload&.parse_object.present? && payload.parse_object.is_a?(Parse::Object)
    # The chained ActiveModel after_save/after_create callbacks are NOT
    # fired here. `call!` dispatches every trigger twice -- once for the
    # specific class route and once for the generic `"*"` route -- so
    # firing the model callbacks inside this per-route block double-fired
    # them for any app that registered BOTH a class route and a `"*"`
    # route (e.g. an `after_save :send_email` would send two emails per
    # save). The dispatch now lives in `run_after_save_chain`, which
    # `call!` invokes exactly once per delivery after both route calls.
    #
    # We still normalize the result to `true` so a handler that returned
    # the parse_object (the recommended before_save pattern, easy to copy
    # by mistake) never leaks an object into the response or the log.
    result = true
  end

  result
end

.dispatch_composed(payload, registry, type) ⇒ Object

Evaluate a single registered handler block in the scope of the payload.

The block runs with self bound to the Payload, so a handler can call parse_object, params, error!, etc. directly -- exactly as it could under the historical payload.instance_exec(payload, &block) invocation. The difference is the return semantics:

  • return value returns value as the handler result (instead of the LocalJumpError: unexpected return that bare instance_exec raised when the block was defined inside a method).
  • The legacy idioms still work unchanged: the last expression's value, next value, and break value all return value, and raise propagates untouched (so error! / before_save rejections behave the same).

This is achieved by attaching the block as a singleton method on the per-request payload (so return gets method semantics) and removing it afterward. The payload is a per-request instance, so this neither leaks nor mutates shared state across threads.

Arity is matched to the old instance_exec(payload, ...) contract: a zero-arity block (do ... end / proc { }) is called with no args; a block that declares a parameter (do |payload| ... end) or a splat receives the payload.

Run every handler registered for one accumulating after_* trigger.

.last is preserved as the composed result because Parse Server ignores the response body for these triggers and #call_route normalizes it anyway, so which handler's value survives is not observable.

When abort_after_callbacks_on_error is false, a handler that raises is reported and skipped rather than taking the rest of the trigger down with it. Nothing is reverted in either mode: the write these triggers fire on has already committed.

Parameters:

Returns:

  • (Object)

    the last handler result.



304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/parse/webhooks.rb', line 304

def dispatch_composed(payload, registry, type)
  return registry.map { |hook| invoke_handler(payload, hook) }.last if
    abort_after_callbacks_on_error

  last = nil
  registry.each do |hook|
    begin
      last = invoke_handler(payload, hook)
    rescue StandardError => e
      report_handler_error(type, e)
    end
  end
  last
end

.dispatch_deferred(env, payload) ⇒ void

This method returns an undefined value.

Run any Parse::Webhooks::Payload#after_response callbacks a handler registered, AFTER the response has been produced. Prefers the server's rack.after_reply hook (Puma / Unicorn), which fires once the response is flushed to the socket on the same worker thread; falls back to a detached thread when the server does not provide it (e.g. WEBrick). Each callback is isolated so one raising neither aborts the others nor reaches the client. No-op when nothing was deferred.

Parameters:



382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/parse/webhooks.rb', line 382

def dispatch_deferred(env, payload)
  return if payload.nil? || !payload.respond_to?(:deferred_callbacks)
  callbacks = payload.deferred_callbacks
  return if callbacks.blank?

  runner = proc do
    callbacks.each do |cb|
      begin
        cb.call
      rescue => e
        warn "[Webhooks::after_response] deferred callback raised: #{e.class}: #{e.message}"
      end
    end
  end

  # Enqueueing must never break an otherwise-successful response: this runs
  # just before `response.finish`, so a raise here (a frozen after_reply
  # array, thread exhaustion) would discard the buffered reply and surface
  # as a 500. Failing to schedule deferred work degrades to "not run",
  # never to a failed response.
  begin
    after_reply = env.is_a?(Hash) ? env["rack.after_reply"] : nil
    if after_reply.respond_to?(:<<)
      after_reply << runner
    else
      Thread.new(&runner)
    end
  rescue => e
    warn "[Webhooks::after_response] could not schedule deferred work: #{e.class}: #{e.message}"
  end
  nil
end

.error(data = false) ⇒ Hash

Generates an error response for Parse Server.

Parameters:

  • data (Object) (defaults to: false)

    the data to send back with the error.

Returns:

  • (Hash)

    a error data payload



691
692
693
# File 'lib/parse/webhooks.rb', line 691

def error(data = false)
  { error: data }.to_json
end

.invoke_handler(payload, block) ⇒ Object

Returns the handler's result value.

Parameters:

  • payload (Parse::Webhooks::Payload)

    the request payload (becomes self).

  • block (Proc)

    the registered handler block.

Returns:

  • (Object)

    the handler's result value.



345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/parse/webhooks.rb', line 345

def invoke_handler(payload, block)
  name = :"__parse_webhook_handler_#{block.object_id}__"
  payload.define_singleton_method(name, &block)
  handler = payload.method(name)
  begin
    # Match the old `payload.instance_exec(payload, &block)` arity
    # leniency: a zero-arity block is called bare; otherwise it receives
    # the payload, plus a nil for each additional REQUIRED positional so a
    # block declaring `|payload, extra|` (or more) does not raise — under
    # instance_exec those surplus params were silently nil. `arity` is
    # negative for optional/splat params (e.g. -1 for `|*a|`, -2 for
    # `|a, *b|`); `~arity` gives the required count in that case.
    if handler.arity == 0
      handler.call
    else
      required = handler.arity.negative? ? ~handler.arity : handler.arity
      handler.call(payload, *Array.new([required - 1, 0].max))
    end
  ensure
    singleton = payload.singleton_class
    if singleton.method_defined?(name) || singleton.private_method_defined?(name)
      singleton.send(:remove_method, name)
    end
  end
end

.report_handler_error(type, error) ⇒ void

This method returns an undefined value.

Report a handler failure that was isolated rather than propagated.

The message is included because an application's own handler raised it and the application needs it to debug; this is not the SDK's internal guard, which deliberately omits messages that can carry a cache key.

Parameters:

  • type (Symbol)

    the trigger being dispatched.

  • error (StandardError)

    the raised error.



328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/parse/webhooks.rb', line 328

def report_handler_error(type, error)
  warn "[Parse::Webhooks] #{type} handler raised #{error.class}: #{error.message}; " \
       "continuing with the remaining handlers " \
       "(Parse::Webhooks.abort_after_callbacks_on_error is false)"
  return unless defined?(ActiveSupport::Notifications)
  begin
    ActiveSupport::Notifications.instrument(
      "parse.webhooks.handler_error", trigger: type, error: error.class.name,
    )
  rescue StandardError
    nil
  end
end

.route(type, className) { ... } ⇒ OpenStruct

Internally registers a route for a specific webhook trigger or function. :before_delete, :after_delete or :function.

Parameters:

  • type (Symbol)

    The type of cloud code webhook to register. This can be any of the supported routes. These are :before_save, :after_save,

  • className (String)

    if type is not :function, then this registers a trigger for the given className. Otherwise, className is treated to be the function name to register with Parse server.

Yields:

  • the block that will handle of the webhook trigger or function.

Returns:

  • (OpenStruct)


203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/parse/webhooks.rb', line 203

def route(type, className, &block)
  type = type.to_s.underscore.to_sym #support camelcase
  if type != :function && className.respond_to?(:parse_class)
    className = className.parse_class
  end
  className = className.to_s
  # Parse Server has no beforeCreate/afterCreate webhook trigger; the
  # create variants are ActiveModel callbacks that run inside the
  # beforeSave/afterSave handler for new objects. Point callers there
  # rather than registering a route that can never fire.
  if type == :before_create || type == :after_create
    save = type == :before_create ? :before_save : :after_save
    raise ArgumentError,
          "There is no #{type} webhook. Register `webhook :#{save}` instead — " \
          "your #{type} ActiveModel callbacks run inside the #{save} handler " \
          "for new objects (registering #{save} enables BOTH the #{save} and " \
          "#{type} callbacks)."
  end
  if routes[type].nil? || block.respond_to?(:call) == false
    raise ArgumentError, "Invalid Webhook registration trigger #{type} #{className}"
  end

  # Triggers whose handlers compose instead of replacing one another.
  #
  # `after_save` / `after_delete` have always accumulated. The remaining
  # `after_*` triggers are added because the SDK itself now registers
  # them for cache invalidation: without this, registering an internal
  # `after_logout` handler would silently replace an application's own,
  # with the winner decided by file load order and no warning.
  #
  # This is safe only for non-rejectable triggers. Parse Server ignores
  # their response body, and {#call_route} normalizes their result to
  # `true` regardless, so `.last` semantics are irrelevant.
  # {REJECTABLE_NON_OBJECT_TRIGGERS} are deliberately excluded: a
  # composite of those must deny if ANY handler denies, and folding with
  # `.last` would discard an earlier rejection.
  composable = type == :after_save || type == :after_delete ||
               (NON_OBJECT_TRIGGERS.include?(type) &&
                !REJECTABLE_NON_OBJECT_TRIGGERS.include?(type) &&
                type.to_s.start_with?("after_"))

  if composable
    routes[type][className] ||= []
    routes[type][className].push block
  else
    routes[type][className] = block
  end
  @routes
end

.routesOpenStruct

A hash-like structure composing of all the registered webhook triggers and functions. These are :before_save, :after_save, :before_delete, :after_delete or :function.

Returns:

  • (OpenStruct)


188
189
190
191
192
# File 'lib/parse/webhooks.rb', line 188

def routes
  return @routes unless @routes.nil?
  r = Parse::API::Hooks::TRIGGER_NAMES_LOCAL + [:function]
  @routes = OpenStruct.new(r.reduce({}) { |h, t| h[t] = {}; h })
end

.run_after_save_chain(payload) ⇒ void

This method returns an undefined value.

Fires the chained ActiveModel after_save (and after_create, for a new object) callbacks for an afterSave delivery -- exactly once per request.

This lives in call! rather than call_route because call! dispatches every trigger twice (the specific class route AND the generic "*" route). Firing the model callbacks per-route would double-fire any side effect for an app that registered both routes. Calling this once, after both route calls, fires the chain exactly once regardless of how many routes matched.

The decision to fire depends ONLY on request origin, never on what a handler returned: Parse Server discards the afterSave response body entirely, so a handler returning the parse_object must not suppress the callbacks. For trusted-Ruby-initiated saves (both the _RB_ request-id header AND the master key) Parse Stack's local run_callbacks :save already fires these after the REST response returns, so we skip them here to avoid the double-fire. The route-present guard preserves the "an unregistered afterSave trigger never fires model callbacks" contract that call_route's early return used to provide.

Parameters:



613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
# File 'lib/parse/webhooks.rb', line 613

def run_after_save_chain(payload)
  return unless payload&.after_save?
  return unless payload.parse_object.is_a?(Parse::Object)

  # Preserve the "no registered route => no model callbacks" behavior that
  # call_route's `return unless routes[type][className].present?` enforced.
  # Mirror that guard exactly: key on parse_class.to_s (as call_route does)
  # and use `.present?` on the value -- registration stores an Array, and an
  # empty/absent registration must NOT fire (matching the original).
  after_save_routes = routes[:after_save]
  return unless after_save_routes &&
                (after_save_routes[payload.parse_class.to_s].present? ||
                 after_save_routes["*"].present?)

  # Trusted-Ruby-initiated saves run their callbacks locally; firing again
  # here would double them. This must match call_route's trusted_ruby_initiated
  # EXACTLY. call_route runs (and stamps @ruby_initiated) before this for any
  # matched route, so read that stamped value rather than recomputing via
  # `ruby_initiated?` -- whose `||=` memoization re-derives on a stamped
  # `false` and could disagree with call_route's header lookup.
  return if payload.ruby_initiated? && payload.master? == true

  # By the time afterSave fires the object is ALREADY persisted in Parse
  # Server, and Parse Server discards the afterSave response body entirely
  # (it resolves success even if the handler throws). So a chained callback
  # that raises must not (a) 500 the webhook endpoint -- `call!`'s rescue
  # only catches ResponseError / ValidationError, so a bare StandardError
  # would escape -- nor (b) take out the OTHER phase's unrelated side
  # effects. Run the after_create and after_save phases independently, each
  # guarded, logging and swallowing any StandardError. This mirrors Parse's
  # own afterSave semantics (log-and-continue on a post-persist failure):
  # a raising `after_create :send_welcome_email` no longer silently skips
  # an unrelated `after_save :reindex`, and neither can crash the endpoint.
  obj = payload.parse_object
  run_after_save_phase(obj, :after_create) if payload.original.nil?
  run_after_save_phase(obj, :after_save)
  nil
end

.run_after_save_phase(obj, phase) ⇒ void

This method returns an undefined value.

Runs one phase (:after_create or :after_save) of an afterSave object's chained ActiveModel callbacks, swallowing and logging any StandardError so a post-persist callback failure can't crash the webhook endpoint or suppress the sibling phase. ActiveModel still halts the rest of this phase's chain on a raise -- only the cross-phase / endpoint blast radius is contained here. Note this also swallows a ResponseError/ValidationError raised from inside an after_save callback: afterSave is post-persist and Parse Server discards the response body, so an error! there cannot deny the (already-committed) write -- it is logged, not propagated.

Parameters:

  • obj (Parse::Object)

    the persisted afterSave object.

  • phase (Symbol)

    :after_create or :after_save.



664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# File 'lib/parse/webhooks.rb', line 664

def run_after_save_phase(obj, phase)
  case phase
  when :after_create then obj.run_after_create_callbacks
  when :after_save then obj.run_after_save_callbacks
  end
  nil
rescue => e
  # Redact the exception message before logging: a callback error can echo
  # record contents/tokens, and the rest of this file routes log output
  # through the same redactor.
  warn "[Parse::Webhooks] afterSave #{phase} callback raised for " \
       "#{obj.class}##{obj.id} -- the object is already persisted; " \
       "logging and continuing: #{e.class}: " \
       "#{Parse::Middleware::BodyBuilder.redact(e.message)}"
  nil
end

.run_function(name, params) ⇒ Object

Run a locally registered webhook function. This bypasses calling a function through Parse-Server if the method handler is registered locally.

Returns:

  • (Object)

    the result of the function.



256
257
258
259
260
261
# File 'lib/parse/webhooks.rb', line 256

def run_function(name, params)
  payload = Payload.new
  payload.function_name = name
  payload.params = params
  call_route(:function, name, payload)
end

.success(data = true) ⇒ Hash

Generates a success response for Parse Server.

Parameters:

  • data (Object) (defaults to: true)

    the data to send back with the success.

Returns:

  • (Hash)

    a success data payload



684
685
686
# File 'lib/parse/webhooks.rb', line 684

def success(data = true)
  { success: data }.to_json
end

.trigger_audit(pretty: false, network: true, client: nil, include_framework: false) ⇒ Hash, String

Audit trigger logic across all registered classes, cross-referencing model ActiveModel callbacks, locally registered webhook blocks, and the triggers registered on Parse Server. See TriggerAudit.

The server comparison reads the master-key-only hooks/triggers endpoint, so network: true (the default) requires a master-key client. Pass network: false for a credential-free audit of callbacks vs. local routes.

Parameters:

  • pretty (Boolean) (defaults to: false)

    when true, return the human-readable String summary instead of the Hash report.

  • network (Boolean) (defaults to: true)

    query Parse Server for registered triggers.

  • client (Parse::Client, nil) (defaults to: nil)

    optional client override.

  • include_framework (Boolean) (defaults to: false)

    include gem-internal callbacks.

Returns:

  • (Hash, String)

    the report Hash, or the pretty String when pretty: true.



495
496
497
498
499
500
# File 'lib/parse/webhooks/trigger_audit.rb', line 495

def trigger_audit(pretty: false, network: true, client: nil, include_framework: false)
  audit = TriggerAudit.new(
    network: network, client: client, include_framework: include_framework,
  )
  pretty ? audit.pretty : audit.to_h
end

.trigger_class_from_path(path) ⇒ String?

Extract the Parse class name from a webhook request path. Parse Server registers each trigger at <endpoint>/<triggerName>/<className> (functions at <endpoint>/<functionName>), so for a trigger the class is the last segment and the second-to-last is a known trigger name. Returns nil for a function path, a path with no recognizable trigger segment, or a className that fails the conservative charset check (Parse class names are [A-Za-z0-9_], built-ins prefixed with _). The charset gate keeps an attacker-supplied path (reachable when allow_unauthenticated is set) from injecting an arbitrary routing / scrub key.

Parameters:

  • path (String)

    the request PATH_INFO.

Returns:

  • (String, nil)

    the sanitized class name, or nil.



764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
# File 'lib/parse/webhooks.rb', line 764

def trigger_class_from_path(path)
  segments = path.to_s.split("/").reject(&:empty?)
  return nil if segments.size < 2
  trigger, klass = segments[-2], segments[-1]
  # register_triggers! builds the URL with the LOCAL snake_case trigger
  # name (`after_find`), while Parse Server sends the camelCase form in the
  # body — accept both so the path segment is recognized either way.
  known = (Parse::API::Hooks::TRIGGER_NAMES + Parse::API::Hooks::TRIGGER_NAMES_LOCAL).map(&:to_s)
  return nil unless known.include?(trigger)
  # Allow a leading `@` for the Parse pseudo-classes (`@Connect` for the
  # connection-global LiveQuery trigger, `@File` for file triggers): the
  # SDK encodes the className in the per-trigger URL, so beforeConnect
  # would not route without it. Mirrors the trigger-className validator
  # (Parse::API::PathSegment.trigger_class_name!). Still anchored and
  # charset-limited -- this gate keeps an attacker-supplied path (reachable
  # only under allow_unauthenticated) from injecting an arbitrary routing
  # / scrub key.
  return nil unless /\A@?_?[A-Za-z][A-Za-z0-9_]*\z/.match?(klass)
  klass
end

Instance Method Details

#keyString

The Parse Webhook Key to be used for authenticating webhook requests. See key on setting this value.

Returns:



129
130
131
# File 'lib/parse/webhooks.rb', line 129

def key
  self.class.key
end