Class: Parse::Client
- Inherits:
-
Object
- Object
- Parse::Client
- Includes:
- API::Aggregate, API::Analytics, API::Batch, API::CloudFunctions, API::Config, API::Files, API::Hooks, API::Objects, API::Push, API::Schema, API::Server, API::Sessions, API::Users
- Defined in:
- lib/parse/client.rb
Overview
This class is the core and low level API for the Parse SDK REST interface that is used by the other components. It can manage multiple sessions, which means you can have multiple client instances pointing to different Parse Applications at the same time. It handles sending raw requests as well as providing Request/Response objects for all API handlers. The connection engine is Faraday, which means it is open to add any additional middleware for features you'd like to implement.
Defined Under Namespace
Modules: Connectable Classes: DuplicateValueError, ResponseError
Constant Summary collapse
- USER_AGENT_HEADER =
The user agent header key.
"User-Agent".freeze
- USER_AGENT_VERSION =
The value for the User-Agent header.
"Parse-Stack v#{Parse::Stack::VERSION}".freeze
- DEFAULT_RETRIES =
The default retry count
2- RETRY_DELAY =
The wait time in seconds between retries
1.5- LIVE_QUERY_LOOPBACK_HOSTS =
Hosts considered "loopback" for the cleartext-ws:// guard in #configure_live_query. Mirrors LiveQuery::Client::LOOPBACK_HOSTS so the explicit-URL path and the derived-URL path agree on what counts as local.
%w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
Constants included from API::Server
API::Server::DEPRECATED_SERVER_VERSION_BELOW
Constants included from API::Hooks
Class Attribute Summary collapse
-
.clients ⇒ Object
readonly
Returns the value of attribute clients.
Instance Attribute Summary collapse
-
#api_key ⇒ String
readonly
The Parse API key to be sent in every API request.
-
#application_id ⇒ String
(also: #app_id)
readonly
The Parse application identifier to be sent in every API request.
-
#cache ⇒ Moneta::Transformer, Moneta::Expires
The underlying cache store for caching API requests.
-
#master_key ⇒ String
readonly
The Parse master key for this application, which when set, will be sent in every API request.
-
#retry_limit ⇒ Integer
If set, returns the current retry count for this instance.
-
#server_url ⇒ String
readonly
The Parse server url that will be receiving these API requests.
-
#session_token ⇒ String?
readonly
The session token bound to this client, if any (see the
:session_tokenconstructor option).
Attributes included from API::Server
Attributes included from API::Config
Class Method Summary collapse
-
.client(conn = :default) ⇒ Parse::Client
Returns or create a new Parse::Client connection for the given connection name.
-
.client?(conn = :default) ⇒ Boolean
True if a Parse::Client has been configured.
-
.setup(opts = {}) { ... } ⇒ Client
Setup the a new client with the appropriate Parse app keys, middleware and options.
Instance Method Summary collapse
-
#anonymous ⇒ Parse::Client
A NEW anonymous client that mirrors THIS client's connection but carries neither a master key nor a session token — every request it makes is unauthenticated (app-id + REST key only).
-
#become(session_token) ⇒ Parse::Client
A NEW non-master Client that mirrors THIS client's connection settings (
server_url/application_id/api_key) but carries no master key and binds +session_token+, so it acts on the server as that user (ACL / CLP /protectedFieldsenforced, no master-key fallback). -
#body_carries_atomic_op?(body) ⇒ Boolean
Whether a request body carries a Parse atomic operation, i.e.
-
#clear_cache! ⇒ Object
Clear the client cache.
-
#configure_live_query(opts) ⇒ Object
private
Configure LiveQuery with the given options.
-
#connected?(endpoint = nil) ⇒ Boolean
Connectivity probe.
-
#delete(uri, body = nil, headers = {}) ⇒ Parse::Response
Send a DELETE request.
-
#get(uri, query = nil, headers = {}) ⇒ Parse::Response
Send a GET request.
-
#idempotent_retry?(method, body, headers = nil) ⇒ Boolean
Whether a request whose outcome is UNKNOWN (a 500/503 or a dropped connection) is safe to transparently re-send.
-
#initialize(opts = {}) ⇒ Client
constructor
Create a new client connected to the Parse Server REST API endpoint.
-
#inspect ⇒ Object
Redacted inspection.
-
#post(uri, body = nil, headers = {}) ⇒ Parse::Response
Send a POST request.
-
#put(uri, body = nil, headers = {}) ⇒ Parse::Response
Send a PUT request.
-
#reachable? ⇒ Boolean
No-credentials liveness probe.
-
#request(method, uri = nil, body: nil, query: nil, headers: nil, opts: {}) ⇒ Parse::Response
Send a REST API request to the server.
-
#send_request(req) ⇒ Parse::Response
Send a Request object.
-
#server_deduped_request?(headers) ⇒ Boolean
Whether this request is covered by Parse Server's server-side request-id deduplication, making a replay a no-op.
-
#url_prefix ⇒ String
The url prefix of the Parse Server url.
- #validate_live_query_url!(url, allow_insecure:) ⇒ Object private
- #warn_about_unknown_live_query_keys!(live_query_opts) ⇒ Object private
-
#with_session(&block) ⇒ Object
Run a block with this client's bound #session_token active as the ambient session, so every query / object operation inside it that resolves the default client (e.g.
Post.count,Post.all,obj.save) is authorized by Parse Server as that user — ACL and CLP enforced, master key suppressed — without threadingsession_token:through each call.
Methods included from API::Users
#create_user, #current_user, #delete_user, #fetch_user, #find_users, #login, #login_with_mfa, #logout, #request_password_reset, #set_service_auth_data, #signup, #update_user
Methods included from API::Sessions
Methods included from API::Server
#server_health, #server_info!, #server_version
Methods included from API::Schema
#create_schema, #schema, #schemas, #update_schema
Methods included from API::Push
Methods included from API::Objects
#create_object, #delete_object, #fetch_object, #find_objects, #update_object, #uri_path
Methods included from API::Hooks
#create_function, #create_trigger, #delete_function, #delete_trigger, #fetch_function, #fetch_trigger, #functions, #triggers, #update_function, #update_trigger
Methods included from API::Files
Methods included from API::Config
#config!, #config_entries, #update_config
Methods included from API::CloudFunctions
#call_function, #call_function_with_session, #trigger_job, #trigger_job_with_session
Methods included from API::Batch
Methods included from API::Aggregate
#aggregate_objects, #aggregate_pipeline, #aggregate_uri_path
Methods included from API::Analytics
Constructor Details
#initialize(opts = {}) ⇒ Client
Create a new client connected to the Parse Server REST API endpoint.
578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 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 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 |
# File 'lib/parse/client.rb', line 578 def initialize(opts = {}) @server_url = opts[:server_url] || ENV["PARSE_SERVER_URL"] || Parse::Protocol::SERVER_URL @application_id = opts[:application_id] || opts[:app_id] || ENV["PARSE_SERVER_APPLICATION_ID"] || ENV["PARSE_APP_ID"] @api_key = opts[:api_key] || opts[:rest_api_key] || ENV["PARSE_SERVER_REST_API_KEY"] || ENV["PARSE_API_KEY"] # Distinguish an explicit `master_key: nil` (deliberately a non-master # client — what user_client / session_client / user_agent rely on) from # an omitted key (fall back to ENV). The previous `opts[:master_key] || # ENV[...]` form silently re-inherited the process master key for the # explicit-nil case, putting a "non-master" client back into master mode # in any deployment that exports PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY. @master_key = if opts.key?(:master_key) opts[:master_key] else ENV["PARSE_SERVER_MASTER_KEY"] || ENV["PARSE_MASTER_KEY"] end # Optional token bound to this client; applied per request as the # lowest-priority auth fallback (see #request). Normalize blank/whitespace # to nil so it never trips the "token present" branch at request time # (where `present?` is false for whitespace) and silently fall back to the # master key on a master-configured client. bound_token = opts[:session_token] bound_token = bound_token.session_token if bound_token.respond_to?(:session_token) bound_token = bound_token.to_s.strip @session_token = bound_token.empty? ? nil : bound_token @require_https = opts.fetch(:require_https, ENV["PARSE_REQUIRE_HTTPS"] == "true") @allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false) # Security check for HTTP usage (except localhost/127.0.0.1 for development) if @server_url&.start_with?("http://") && !@server_url.match?(%r{^http://(localhost|127\.0\.0\.1)(:|/)}) if @require_https raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{@server_url}. " \ "Set require_https: false or use an HTTPS URL." else warn "[Parse::Client] SECURITY WARNING: Using HTTP instead of HTTPS for Parse server. " \ "This exposes credentials and data to network interception. " \ "Use HTTPS in production: #{@server_url}" end end # Determine the HTTP adapter to use # Priority: explicit :adapter > :connection_pooling setting > default (pooling enabled) # Falls back to default adapter if net_http_persistent is not available if opts[:adapter] # User explicitly specified an adapter, use it directly adapter = opts[:adapter] = {} elsif opts[:connection_pooling] == false # User explicitly disabled connection pooling adapter = Faraday.default_adapter = {} elsif opts[:connection_pooling].is_a?(Hash) # User provided connection pooling with custom options if NET_HTTP_PERSISTENT_AVAILABLE adapter = :net_http_persistent = opts[:connection_pooling] else adapter = Faraday.default_adapter = {} end else # Default: use persistent connections for better performance (if available) if NET_HTTP_PERSISTENT_AVAILABLE adapter = :net_http_persistent = {} else adapter = Faraday.default_adapter = {} end end opts[:expires] ||= 3 if @server_url.nil? || @application_id.nil? || (@api_key.nil? && @master_key.nil?) raise Parse::Error::ConnectionError, "Please call Parse.setup(server_url:, application_id:, api_key:) to setup a client" end @server_url += "/" unless @server_url.ends_with?("/") # Resolve timeouts. Defaults guard the calling thread against an # unresponsive Parse Server (slowloris, hung dyno) which would # otherwise tie up Puma/Sidekiq workers indefinitely. open_timeout = opts.fetch(:open_timeout, (ENV["PARSE_OPEN_TIMEOUT"] || 5).to_i) read_timeout = opts.fetch(:timeout, (ENV["PARSE_TIMEOUT"] || 30).to_i) #Configure Faraday opts[:faraday] ||= {} # Guard against silent TLS downgrade or attacker-controlled proxy via # opts[:faraday]. The require_https check earlier only inspects the URL # scheme; without this guard a caller passing # faraday: { ssl: { verify: false }, proxy: "http://attacker" } # would neuter TLS verification on an HTTPS connection. validate_faraday_opts!(opts[:faraday]) opts[:faraday].merge!(:url => @server_url) @conn = Faraday.new(opts[:faraday]) do |conn| # Apply timeouts before any user-supplied middleware sees a request. conn..timeout = read_timeout if read_timeout > 0 conn..open_timeout = open_timeout if open_timeout > 0 #conn.request :json # Configure logging if enabled if opts[:logging].present? # Configure the new structured logging middleware Parse::Middleware::Logging.enabled = true Parse::Middleware::Logging.logger = opts[:logger] if opts[:logger] case opts[:logging] when :debug Parse::Middleware::Logging.log_level = :debug Parse::Middleware::BodyBuilder.logging = true when :warn Parse::Middleware::Logging.log_level = :warn else Parse::Middleware::Logging.log_level = :info end end # This middleware handles sending the proper authentication headers to Parse # on each request. # this is the required authentication middleware. Should be the first thing # so that other middlewares have access to the env that is being set by # this middleware. First added is first to brocess. conn.use Parse::Middleware::Authentication, application_id: @application_id, master_key: @master_key, api_key: @api_key # Request/response logging middleware (configured via Parse.logging_enabled) conn.use Parse::Middleware::Logging # Performance profiling middleware (configured via Parse.profiling_enabled) conn.use Parse::Middleware::Profiling # This middleware turns the result from Parse into a Parse::Response object # and making sure request that are going out, follow the proper MIME format. # We place it after the Authentication middleware in case we need to use then # authentication information when building request and responses. conn.use Parse::Middleware::BodyBuilder if opts[:cache].present? if opts[:expires].to_i <= 0 warn "[Parse::Client] Cache store provided but :expires is not set or is 0. " \ "Caching will be disabled. Set :expires to enable caching (e.g., expires: 10)." else # advanced: provide a REDIS url, we'll configure a Moneta Redis store. if opts[:cache].is_a?(String) && opts[:cache].starts_with?("redis://") begin opts[:cache] = Moneta.new(:Redis, url: opts[:cache]) rescue LoadError puts "[Parse::Middleware::Caching] Did you forget to load the redis gem (Gemfile)?" raise end end unless [:key?, :[], :delete, :store].all? { |method| opts[:cache].respond_to?(method) } raise ArgumentError, "Parse::Client option :cache needs to be a type of Moneta store" end # If the caller passed a `Parse::Cache::Redis` wrapper, let its # built-in namespace flow through automatically. An explicit # `cache_namespace:` still wins so callers can override. if defined?(Parse::Cache::Redis) && opts[:cache].is_a?(Parse::Cache::Redis) opts[:cache_namespace] ||= opts[:cache].namespace end self.cache = opts[:cache] conn.use Parse::Middleware::Caching, self.cache, { expires: opts[:expires].to_i, # Optional `cache_namespace:` prefixes every key so two Parse # apps sharing one Redis don't collide on `mk:/classes/Song/abc`. # Explicit only — we do NOT auto-derive from app_id to keep # existing single-app deployments backward-compatible. namespace: opts[:cache_namespace], } # Inform about opt-in cache behavior unless Parse.default_query_cache warn "[Parse::Client] Caching middleware enabled (expires: #{opts[:expires]}s). " \ "Queries do NOT use cache by default. Use `cache: true` on queries to opt-in, " \ "or set `Parse.default_query_cache = true` for opt-out behavior." end end end yield(conn) if block_given? # Configure the adapter with optional settings # For net_http_persistent: # - pool_size must be passed as an adapter argument (constructor param, no setter) # - idle_timeout and keep_alive have setters and are configured in the block if .any? # Extract constructor arguments for the adapter adapter_args = {} adapter_args[:pool_size] = [:pool_size] if [:pool_size] conn.adapter adapter, **adapter_args do |http| http.idle_timeout = [:idle_timeout] if [:idle_timeout] http.keep_alive = [:keep_alive] if [:keep_alive] end else conn.adapter adapter end end # Faraday's constructor may still synthesise a ProxyOptions from # HTTPS_PROXY/HTTP_PROXY env vars regardless of the `proxy: nil` # we pass in opts. Clear the proxy on the connection itself to be # sure no env-derived MITM survives. @conn.proxy = nil if !@allow_faraday_proxy && @conn.respond_to?(:proxy=) Parse::Client.clients[:default] ||= self # Configure LiveQuery if URL provided configure_live_query(opts) end |
Class Attribute Details
.clients ⇒ Object (readonly)
Returns the value of attribute clients.
422 423 424 |
# File 'lib/parse/client.rb', line 422 def clients @clients end |
Instance Attribute Details
#api_key ⇒ String (readonly)
The Parse API key to be sent in every API request.
331 |
# File 'lib/parse/client.rb', line 331 attr_accessor :cache |
#application_id ⇒ String (readonly) Also known as: app_id
The Parse application identifier to be sent in every API request.
331 |
# File 'lib/parse/client.rb', line 331 attr_accessor :cache |
#cache ⇒ Moneta::Transformer, Moneta::Expires
The underlying cache store for caching API requests.
331 332 333 |
# File 'lib/parse/client.rb', line 331 def cache @cache end |
#master_key ⇒ String (readonly)
The Parse master key for this application, which when set, will be sent in every API request. (There is a way to prevent this on a per request basis.)
331 |
# File 'lib/parse/client.rb', line 331 attr_accessor :cache |
#retry_limit ⇒ Integer
If set, returns the current retry count for this instance. Otherwise, returns DEFAULT_RETRIES. Set to 0 to disable retry mechanism.
331 |
# File 'lib/parse/client.rb', line 331 attr_accessor :cache |
#server_url ⇒ String (readonly)
The Parse server url that will be receiving these API requests. By default this will be Protocol::SERVER_URL.
331 |
# File 'lib/parse/client.rb', line 331 attr_accessor :cache |
#session_token ⇒ String? (readonly)
Returns the session token bound to this client, if any
(see the :session_token constructor option). Applied as the
lowest-priority auth fallback on every request.
337 338 339 |
# File 'lib/parse/client.rb', line 337 def session_token @session_token end |
Class Method Details
.client(conn = :default) ⇒ Parse::Client
Returns or create a new Parse::Client connection for the given connection name.
434 435 436 |
# File 'lib/parse/client.rb', line 434 def client(conn = :default) @clients[conn] ||= self.new end |
.client?(conn = :default) ⇒ Boolean
Returns true if a Parse::Client has been configured.
426 427 428 |
# File 'lib/parse/client.rb', line 426 def client?(conn = :default) @clients[conn].present? end |
.setup(opts = {}) { ... } ⇒ Client
Setup the a new client with the appropriate Parse app keys, middleware and options.
453 454 455 |
# File 'lib/parse/client.rb', line 453 def setup(opts = {}, &block) @clients[:default] = self.new(opts, &block) end |
Instance Method Details
#anonymous ⇒ Parse::Client
A NEW anonymous client that mirrors THIS client's connection but carries neither a master key nor a session token — every request it makes is unauthenticated (app-id + REST key only). Use it to drop the bound user identity for a one-off public read without mutating a shared client. Equivalent to #become with no token.
380 381 382 |
# File 'lib/parse/client.rb', line 380 def anonymous become(nil) end |
#become(session_token) ⇒ Parse::Client
A NEW non-master Parse::Client that mirrors THIS client's connection
settings (server_url / application_id / api_key) but carries no
master key and binds +session_token+, so it acts on the server as that
user (ACL / CLP / protectedFields enforced, no master-key fallback).
This is the general primitive behind Webhooks::Payload#user_client
and User#session_client: derive a user-scoped client from a
configured (e.g. master) client without re-specifying the connection.
user_client = Parse.client.become(user.session_token) Parse::Query.new("Post", client: user_client).results # as the user
364 365 366 367 368 369 370 371 372 |
# File 'lib/parse/client.rb', line 364 def become(session_token) Parse::Client.new( server_url: @server_url, app_id: @application_id, api_key: @api_key, master_key: nil, session_token: session_token, ) end |
#body_carries_atomic_op?(body) ⇒ Boolean
Whether a request body carries a Parse atomic operation, i.e. any field
whose value is a Hash with an __op key (Increment, Add, AddUnique,
Remove, AddRelation, RemoveRelation, Delete). Such ops are not idempotent
and must not be replayed on an ambiguous failure. Assumes the body is a
Ruby Hash, which the SDK's normal save/update path always provides; a
pre-serialized String body is treated as op-free (and therefore
retryable), so callers handing request a raw JSON string for a
PUT-with-op would bypass this guard.
1313 1314 1315 1316 |
# File 'lib/parse/client.rb', line 1313 def body_carries_atomic_op?(body) return false unless body.is_a?(Hash) body.any? { |_k, v| v.is_a?(Hash) && (v.key?("__op") || v.key?(:__op)) } end |
#clear_cache! ⇒ Object
Clear the client cache
934 935 936 |
# File 'lib/parse/client.rb', line 934 def clear_cache! self.cache.clear if self.cache.present? end |
#configure_live_query(opts) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Configure LiveQuery with the given options
842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 |
# File 'lib/parse/client.rb', line 842 def configure_live_query(opts) live_query_url = opts[:live_query_url] || ENV["PARSE_LIVE_QUERY_URL"] return unless live_query_url || opts[:live_query] require_relative "live_query" live_query_opts = opts[:live_query].is_a?(Hash) ? opts[:live_query] : {} resolved_url = live_query_url || live_query_opts[:url] # Refuse explicit `ws://` against a non-loopback host unless # `allow_insecure: true` is also passed in `live_query:`. The # downstream `derive_websocket_url` path already enforces this for # URLs derived from a Parse Server `http://` URL, but an explicit # `live_query: { url: "ws://prod-host" }` or # `live_query_url: "ws://prod-host"` bypassed it — the master key # and any session token would ride the connect frame in cleartext. validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure]) # Warn (don't raise) on `live_query: { ... }` keys that are not # `Parse::LiveQuery::Configuration` setters. The block form would # otherwise silently swallow typos like # `live_query: { ssl_min_versoin: :TLSv1_3 }` and leave TLS at the # default, losing the operator's intent. The pre-fix kwargs form # raised `ArgumentError` here; this restores the surface without # making it a hard failure for unknown-but-harmless keys. warn_about_unknown_live_query_keys!(live_query_opts) Parse::LiveQuery.configure do |config| config.application_id = @application_id if @application_id config.client_key = @api_key if @api_key config.master_key = @master_key if @master_key # Apply hash-form options first so the resolved URL (which honors # top-level `live_query_url:` over `live_query: { url: }`) wins. # Without this, the loop would re-write `config.url` from the # hash and silently invert the documented precedence. live_query_opts.each do |key, value| next if key == :url setter = "#{key}=" config.public_send(setter, value) if config.respond_to?(setter) end config.url = resolved_url if resolved_url end end |
#connected?(endpoint = nil) ⇒ Boolean
Connectivity probe. By default hits the Parse Server health endpoint — the same target as #reachable? — so it returns +true+ whenever the server is up, regardless of CLP configuration.
Why not a _User find? A limit-0 find against +_User+ exercises the auth
stack, but locking +_User+ finds to the master key via a Class-Level
Permission is standard production hardening — on such a server the probe
gets a permission error and (wrongly) reports "not connected" for a
perfectly healthy, correctly-configured deployment. The default therefore
avoids any data class.
To ALSO validate credentials, pass endpoint: a path to a class the
configured key is allowed to read (e.g. "classes/_User" on a default
server, or one of your own readable classes). The probe runs limit: 0
so it never pulls rows, and routes through the auth middleware, so a wrong
application_id / REST key surfaces as an Error::AuthenticationError
and is converted to +false+. Any connection, timeout, or API error returns
+false+ rather than raising; genuine programming errors (e.g.
+NoMethodError+) still propagate.
974 975 976 977 978 979 980 |
# File 'lib/parse/client.rb', line 974 def connected?(endpoint = nil) path = endpoint || Parse::API::Server::SERVER_HEALTH_PATH response = request(:get, path, query: { limit: 0 }, opts: { cache: false }) response.success? rescue Parse::Error, Faraday::Error false end |
#delete(uri, body = nil, headers = {}) ⇒ Parse::Response
Send a DELETE request.
1350 1351 1352 |
# File 'lib/parse/client.rb', line 1350 def delete(uri, body = nil, headers = {}) request :delete, uri, body: body, headers: headers end |
#get(uri, query = nil, headers = {}) ⇒ Parse::Response
Send a GET request.
1323 1324 1325 |
# File 'lib/parse/client.rb', line 1323 def get(uri, query = nil, headers = {}) request :get, uri, query: query, headers: headers end |
#idempotent_retry?(method, body, headers = nil) ⇒ Boolean
Whether a request whose outcome is UNKNOWN (a 500/503 or a dropped connection) is safe to transparently re-send.
Server-dedup fast path: when the operator has asserted Parse Server
idempotency is configured (Request.assume_server_idempotency)
AND this request carries a stable X-Parse-Request-Id header, the
server deduplicates a replay, so even a POST or an atomic-op write is
safe to retry — the write applies at most once. The replay is NOT a
transparent success, though: Parse Server rejects the duplicate with
error 159, surfaced as a raised Error::DuplicateRequestError
the caller must rescue (the original write already landed). The SDK sends
the same request id on every retry (the header is set once and preserved
across the retry), which is what makes the server-side dedup match.
Otherwise the conservative method/body heuristic applies: GET and DELETE
are idempotent; a full-object PUT update is idempotent ONLY when it
carries no atomic __op mutation (Increment/Add/AddUnique/Remove/Relation
would double-apply on replay); POST (object create / batch) is never
auto-retried. 429 throttles are handled at the call site, since the
server provably discarded the request and those re-send regardless.
1279 1280 1281 1282 1283 1284 1285 1286 |
# File 'lib/parse/client.rb', line 1279 def idempotent_retry?(method, body, headers = nil) return true if server_deduped_request?(headers) case method when :get, :delete then true when :put then !body_carries_atomic_op?(body) else false end end |
#inspect ⇒ Object
Redacted inspection. The default Ruby #inspect would dump every ivar,
exposing the master key and any bound session token in cleartext wherever
a client is logged or surfaced in an error reporter. Show only the
connection identity and a boolean for each credential's presence.
344 345 346 347 348 |
# File 'lib/parse/client.rb', line 344 def inspect "#<#{self.class.name} server_url=#{@server_url.inspect} " \ "app_id=#{@application_id.inspect} master_key=#{@master_key ? "[FILTERED]" : "nil"} " \ "session_token=#{@session_token ? "[FILTERED]" : "nil"}>" end |
#post(uri, body = nil, headers = {}) ⇒ Parse::Response
Send a POST request.
1332 1333 1334 |
# File 'lib/parse/client.rb', line 1332 def post(uri, body = nil, headers = {}) request :post, uri, body: body, headers: headers end |
#put(uri, body = nil, headers = {}) ⇒ Parse::Response
Send a PUT request.
1341 1342 1343 |
# File 'lib/parse/client.rb', line 1341 def put(uri, body = nil, headers = {}) request :put, uri, body: body, headers: headers end |
#reachable? ⇒ Boolean
No-credentials liveness probe. Hits the Parse Server health endpoint and returns +true+ when the server responds with status "ok". No application credentials are required, so this passes even when the configured application_id or REST key is wrong. Use #connected? to also validate credentials.
944 945 946 947 948 949 |
# File 'lib/parse/client.rb', line 944 def reachable? response = request(:get, Parse::API::Server::SERVER_HEALTH_PATH, opts: { cache: false }) response.success? rescue Parse::Error, Faraday::Error false end |
#request(method, uri = nil, body: nil, query: nil, headers: nil, opts: {}) ⇒ Parse::Response
Send a REST API request to the server. This is the low-level API used for all requests to the Parse server with the provided options. Every request sent to Parse through the client goes through the configured set of middleware that can be modified by applying different headers or specific options. This method supports retrying requests a few times when a ServiceUnavailableError is raised.
1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 |
# File 'lib/parse/client.rb', line 1032 def request(method, uri = nil, body: nil, query: nil, headers: nil, opts: {}) # Pre-declare locals referenced inside rescue blocks so CodeQL's # uninitialized-variable analysis is satisfied even if an exception # raises before the natural assignment site. response = nil _retry_count = nil _retry_delay = nil _request = nil # Kwarg-absorption guard. The `**opts` splat in API helper methods # (lib/parse/api/*.rb) absorbs a caller-passed `opts: { ... }` # keyword as a key named `:opts` rather than as the request options # hash itself. The auth context (session_token, use_master_key) # buried under :opts then never reaches the request — the call # silently goes out anonymous (or master, if one is configured), # which is a permission-downgrade footgun. Fail loudly here so the # bug surfaces in dev/test instead of in production. if opts.is_a?(Hash) && opts[:opts].is_a?(Hash) raise ArgumentError, "Parse::Client#request received nested `opts: { opts: { ... } }` — " \ "pass session_token: / use_master_key: directly as keywords, " \ "not wrapped in an `opts:` hash. " \ "Bad: Parse.client.create_object('X', body, opts: { session_token: t }) " \ "Good: Parse.client.create_object('X', body, session_token: t, use_master_key: false)" end # Retry budget. Initialized ONCE here, ABOVE the `begin` below, so the # `retry` keyword in the rescue clauses (which re-runs only the begin # block, not the whole method) preserves the countdown across attempts. # If this initialization ran inside the begin it would reset on every # attempt, turning a transient 500/503/429 into an infinite retry loop. _retry_count ||= self.retry_limit if opts[:retry] == false _retry_count = 0 elsif opts[:retry].to_i > 0 _retry_count = opts[:retry] end # The effective starting budget, captured ONCE after the opts override # (and, like `_retry_count`, above the `begin` so `retry` doesn't reset # it). The backoff multiplier is `(_retry_max - _retry_count)`, which # grows 1, 2, 3, … as attempts are consumed. Deriving it from # `self.retry_limit` instead would go to zero or negative whenever a # caller passes `opts: { retry: N }` with N above the instance default, # silently disabling the backoff (every retry firing at zero delay). _retry_max ||= _retry_count begin headers ||= {} # if the first argument is a Parse::Request object, then construct it _request = nil if method.is_a?(Request) _request = method method = _request.method uri ||= _request.path query ||= _request.query body ||= _request.body headers.merge! _request.headers else _request = Parse::Request.new(method, uri, body: body, headers: headers, opts: opts) end # http method method = method.downcase.to_sym # set the User-Agent headers[USER_AGENT_HEADER] = USER_AGENT_VERSION if opts[:cache] == false headers[Parse::Middleware::Caching::CACHE_CONTROL] = "no-cache" elsif opts[:cache] == :write_only # Write-only mode: skip reading from cache, but still write to cache # Useful for fetch!/reload! which want fresh data but should update cache headers[Parse::Middleware::Caching::CACHE_WRITE_ONLY] = "true" elsif opts[:cache].is_a?(Numeric) # specify the cache duration of this request headers[Parse::Middleware::Caching::CACHE_EXPIRES_DURATION] = opts[:cache].to_s end # Resolve the auth context in three layers: # 1. explicit per-call `use_master_key:` and `session_token:` # 2. ambient session set by `Parse.with_session { ... }` (fiber-local) # 3. process-wide `Parse.client_mode` flag — when true, master key is # never sent unless the caller explicitly passed `use_master_key: true` explicit_master = opts.key?(:use_master_key) if opts[:use_master_key] == false headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true" elsif Parse.client_mode && opts[:use_master_key] != true # client mode defaults master key OFF unless explicitly opted in headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true" end token = opts[:session_token] # When no explicit token was passed AND the caller didn't ask to send # the master key, fall through to (in order) the fiber-local ambient set # by `Parse.with_session`, then this client's own bound `@session_token`. # Explicit `use_master_key: true` is treated as a deliberate admin call # and skips both — otherwise an `admin.do_thing(use_master_key: true)` # nested inside a `with_session(user)` block (or on a token-bound client) # would silently downgrade. The ambient wins over the bound token so a # `with_session` override inside a user-scoped client still takes effect. if token.nil? && !(explicit_master && opts[:use_master_key] == true) ambient = Parse.current_session_token # A whitespace-only ambient must not count as present: otherwise it # blocks the bound-token fallback below and then fails the later # `token.present?` check, silently sending the master key instead. token = ambient if ambient.is_a?(String) && !ambient.strip.empty? token = @session_token if (token.nil? || token.to_s.strip.empty?) && @session_token end if token.present? token = token.session_token if token.respond_to?(:session_token) headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true" headers[Parse::Protocol::SESSION_TOKEN] = token end #if it is a :get request, then use query params, otherwise body. params = (method == :get ? query : body) || {} # if the path does not start with the '/1/' prefix, then add it to be nice. # actually send the request and return the body response_env = @conn.send(method, uri, params, headers) response = response_env.body response.request = _request case response.http_status when 401, 403 Parse::Client._safe_warn("AuthenticationError", response) raise Parse::Error::AuthenticationError, response when 400, 408 if response.code == Parse::Response::ERROR_TIMEOUT || response.code == 143 #"net/http: timeout awaiting response headers" Parse::Client._safe_warn("TimeoutError", response) raise Parse::Error::TimeoutError, response end when 404 unless response.object_not_found? Parse::Client._safe_warn("ConnectionError", response) raise Parse::Error::ConnectionError, response end when 405, 406 Parse::Client._safe_warn("ProtocolError", response) raise Parse::Error::ProtocolError, response when 429 # Request over the throttle limit Parse::Client._safe_warn("RequestLimitExceededError", response) raise Parse::Error::RequestLimitExceededError, response when 500, 503 Parse::Client._safe_warn("ServiceUnavailableError", response) raise Parse::Error::ServiceUnavailableError, response end if response.error? if response.code <= Parse::Response::ERROR_SERVICE_UNAVAILABLE Parse::Client._safe_warn("ServiceUnavailableError", response) raise Parse::Error::ServiceUnavailableError, response elsif response.code <= 100 Parse::Client._safe_warn("ServerError", response) raise Parse::Error::ServerError, response elsif response.code == Parse::Response::ERROR_EXCEEDED_BURST_LIMIT Parse::Client._safe_warn("RequestLimitExceededError", response) raise Parse::Error::RequestLimitExceededError, response elsif response.code == 209 # Error 209: invalid session token Parse::Client._safe_warn("InvalidSessionTokenError", response) raise Parse::Error::InvalidSessionTokenError, response elsif response.code == Parse::Response::ERROR_DUPLICATE_REQUEST # 159 # Request-id idempotency rejected a duplicate — the original write # already applied (NOT a second time). Surface a typed, catchable # signal rather than a generic error; this is what a transparently- # retried write that landed-but-lost-its-response sees on the replay. Parse::Client._safe_warn("DuplicateRequestError", response) raise Parse::Error::DuplicateRequestError, response end end response rescue Parse::Error::RequestLimitExceededError, Parse::Error::ServiceUnavailableError => e # 429 (RequestLimitExceeded): the server threw the request away, so # re-sending is safe for any method. 500/503 (ServiceUnavailable) is # ambiguous — a write may have applied before the error — so only # re-send when the request is idempotent (see #idempotent_retry?). retryable = e.is_a?(Parse::Error::RequestLimitExceededError) || idempotent_retry?(method, body, headers) if _retry_count > 0 && retryable warn "[Parse:Retry] Retries remaining #{_retry_count} : #{response.request}" _retry_count -= 1 # Use Retry-After header if available, otherwise use linear backoff retry_after = response.retry_after if response.respond_to?(:retry_after) if retry_after && retry_after > 0 _retry_delay = retry_after warn "[Parse:Retry] Using Retry-After header: #{_retry_delay}s" else # Linear backoff (RETRY_DELAY × attempt number) with +/-25% jitter. # Never zero — # zero-wait retries amplify DoS against upstream and stampede on 429. backoff_delay = RETRY_DELAY * (_retry_max - _retry_count) _retry_delay = backoff_delay * (0.75 + rand * 0.5) end sleep _retry_delay if _retry_delay > 0 retry end raise rescue Faraday::ClientError, Faraday::TimeoutError, Net::OpenTimeout => e # Request timed out mid-flight: the outcome is unknown (the server may # have received and applied the write but never answered), so only # re-send idempotent requests to avoid double-applying. # # Faraday 2.x raises `Faraday::TimeoutError` for a read timeout # (`Timeout::Error` / `Errno::ETIMEDOUT`); it subclasses `Faraday::Error`, # not `ClientError`, so it must be listed explicitly to be caught. We # deliberately do NOT catch `Faraday::ConnectionFailed` (connection # refused/reset, plus the wrapped connect-timeout): refused is a # non-transient "server down / misconfigured" failure, and auto-retrying # it only adds backoff latency before the inevitable error. Broadening to # reset connections safely (retry reset, fail fast on refused) is tracked # as a follow-up. if _retry_count > 0 && idempotent_retry?(method, body, headers) warn "[Parse:Retry] Retries remaining #{_retry_count} : #{_request}" _retry_count -= 1 backoff_delay = RETRY_DELAY * (_retry_max - _retry_count) _retry_delay = backoff_delay * (0.75 + rand * 0.5) sleep _retry_delay if _retry_delay > 0 retry end raise Parse::Error::ConnectionError, "#{_request} : #{e.class} - #{e.}" end end |
#send_request(req) ⇒ Parse::Response
Send a Request object.
1358 1359 1360 1361 |
# File 'lib/parse/client.rb', line 1358 def send_request(req) #Parse::Request object raise ArgumentError, "Object not of Parse::Request type." unless req.is_a?(Parse::Request) request req.method, req.path, req.body, req.headers end |
#server_deduped_request?(headers) ⇒ Boolean
Whether this request is covered by Parse Server's server-side request-id deduplication, making a replay a no-op. True only when the operator has opted in via Request.assume_server_idempotency AND the request actually carries a non-blank request-id header (writes to inherently non-idempotent paths — sessions, logout, functions, push, jobs — never get a request id, so they correctly fail this check).
1296 1297 1298 1299 1300 1301 |
# File 'lib/parse/client.rb', line 1296 def server_deduped_request?(headers) return false unless Parse::Request.assume_server_idempotency return false unless headers.is_a?(Hash) rid = headers[Parse::Request.request_id_header] rid.is_a?(String) && !rid.strip.empty? end |
#url_prefix ⇒ String
Returns the url prefix of the Parse Server url.
929 930 931 |
# File 'lib/parse/client.rb', line 929 def url_prefix @conn.url_prefix end |
#validate_live_query_url!(url, allow_insecure:) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
890 891 892 893 894 895 896 897 898 899 900 901 902 903 |
# File 'lib/parse/client.rb', line 890 def validate_live_query_url!(url, allow_insecure:) return unless url.is_a?(String) && url.start_with?("ws://") host = URI.parse(url).host.to_s rescue "" return if LIVE_QUERY_LOOPBACK_HOSTS.include?(host) return if allow_insecure raise ArgumentError, "[Parse::Client] Refusing explicit insecure LiveQuery URL #{url.inspect}. " \ "The connect frame carries the master key and any session token in " \ "plaintext on this socket. Use wss:// for routable hosts, or pass " \ "`live_query: { allow_insecure: true }` to opt into cleartext for " \ "local development on a non-loopback address." end |
#warn_about_unknown_live_query_keys!(live_query_opts) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
906 907 908 909 910 911 912 913 914 915 916 917 918 |
# File 'lib/parse/client.rb', line 906 def warn_about_unknown_live_query_keys!(live_query_opts) return unless live_query_opts.is_a?(Hash) && live_query_opts.any? probe = Parse::LiveQuery::Configuration.new unknown = live_query_opts.keys.reject { |k| probe.respond_to?("#{k}=") } return if unknown.empty? warn "[Parse::Client] Ignoring unknown live_query option(s): " \ "#{unknown.inspect}. Valid keys are Parse::LiveQuery::Configuration " \ "setters (url, application_id, client_key, master_key, ping_interval, " \ "pong_timeout, allow_insecure, ssl_min_version, ssl_max_version, " \ "logging_enabled, log_level, ...). Check for typos." end |
#with_session(&block) ⇒ Object
Run a block with this client's bound #session_token active as the
ambient session, so every query / object operation inside it that resolves
the default client (e.g. Post.count, Post.all, obj.save) is
authorized by Parse Server as that user — ACL and CLP enforced, master key
suppressed — without threading session_token: through each call.
This is the client-receiver flavor of Parse.with_session (and mirrors
User#with_session); it scopes by binding the token as the AMBIENT
session — it does not re-route operations through this client object, so
the connection used inside the block is still the resolved default client.
If you need operations to run against a different client, pass that client
explicitly (e.g. Parse::Query.new("Post", client: #{become}(...))).
total = Parse::User.login(u, p).with_session { Post.count } # readable Posts only
Scopes REST-routed operations (find / get / count / save). It does
NOT scope mongo-direct queries (results_direct, aggregate, Atlas
search): those resolve auth from the query's own session_token: /
acl_user: and, absent that, run in MASTER mode — so a mongo-direct read
inside this block is a full master read, not anonymous. Scope mongo-direct
explicitly with a per-query session_token: or a scoped Agent.
409 410 411 412 413 |
# File 'lib/parse/client.rb', line 409 def with_session(&block) raise ArgumentError, "Parse::Client#with_session requires a block" unless block_given? raise ArgumentError, "Parse::Client#with_session requires a client with a bound session_token" if @session_token.nil? Parse.with_session(@session_token, &block) end |