Class: Dommy::Response
- Inherits:
-
Object
- Object
- Dommy::Response
- Includes:
- Bridge::Methods
- Defined in:
- lib/dommy/fetch.rb
Overview
Response polyfill — just enough surface for Fetchy:
[:status] / [:ok] / [:url] / [:headers] (with
.entries() / .get(name)) and .text() / .json() / .body
/ .arrayBuffer() which all return Promise-like values.
Constant Summary collapse
- NULL_BODY_STATUSES =
WHATWG null-body statuses: a Response with one of these may not carry a body (constructing one with a body is a TypeError). 101/103 are also null-body but fall outside the 200–599 range the constructor accepts.
[204, 205, 304].freeze
- REDIRECT_STATUSES =
Redirect statuses accepted by
Response.redirect(url, status). [301, 302, 303, 307, 308].freeze
- FORBIDDEN_RESPONSE_HEADERS =
Forbidden response-header names (WHATWG Fetch): never exposed on a Response's Headers.
Set-Cookieis handled by the network layer's cookie jar, not JS — and a real server often sends MULTIPLE Set-Cookie headers folded into one newline-joined value, which is an invalid Headers value and used to crash Response construction (e.g. doubleclick's IDE+test_cookie). %w[set-cookie set-cookie2].freeze
Class Method Summary collapse
-
.__construct__(window, body, init) ⇒ Object
WHATWG
new Response(body, init). -
.__error__(window) ⇒ Object
Static
Response.error()— a network-error response (status 0, not ok, type "error"). -
.__json__(window, data, init = nil) ⇒ Object
Static
Response.json(data, init)— serializedatato JSON, defaulting Content-Type to application/json. -
.__redirect__(window, url, status = nil) ⇒ Object
Static
Response.redirect(url, status = 302)— a redirect response whoseLocationheader is the parsed-and-serializedurl. - .coerce_headers(raw) ⇒ Object
- .coerce_status(value) ⇒ Object
-
.extract_body(body) ⇒ Object
WHATWG "extract a body": map a body source to
[byte_string, default_content_type_or_nil]. -
.multipart_body(form_data) ⇒ Object
Serialize a FormData as a multipart/form-data body.
-
.utf8_decode(bytes) ⇒ Object
WHATWG "UTF-8 decode" for a body's text(): interpret the raw bytes as UTF-8, replacing any ill-formed sequence with U+FFFD, and drop a single leading byte-order mark (U+FEFF).
-
.validate_status_text!(text) ⇒ Object
WHATWG reason-phrase: HTAB / SP / VCHAR (0x21–0x7E) / obs-text (0x80–0xFF).
Instance Method Summary collapse
- #__js_call__(method, _args) ⇒ Object
- #__js_get__(key) ⇒ Object
- #__js_set__(_key, _value) ⇒ Object
-
#initialize(window, body:, status: 200, status_text: "", headers: nil, url: "", redirected: false, type: "default", has_body: true) ⇒ Response
constructor
A new instance of Response.
-
#strip_forbidden_headers(headers) ⇒ Object
Drop forbidden response headers (Set-Cookie/Set-Cookie2) before they reach the Headers object.
Methods included from Bridge::Methods
Constructor Details
#initialize(window, body:, status: 200, status_text: "", headers: nil, url: "", redirected: false, type: "default", has_body: true) ⇒ Response
Returns a new instance of Response.
706 707 708 709 710 711 712 713 714 715 716 717 718 719 |
# File 'lib/dommy/fetch.rb', line 706 def initialize(window, body:, status: 200, status_text: "", headers: nil, url: "", redirected: false, type: "default", has_body: true) @window = window @body = body.to_s @status = status @status_text = status_text.to_s @headers = Headers.new(strip_forbidden_headers(headers)) @url = url.to_s @redirected = redirected ? true : false @type = type @has_body = has_body ? true : false @body_used = false @body_stream = nil end |
Class Method Details
.__construct__(window, body, init) ⇒ Object
WHATWG new Response(body, init). Validates the status (200–599, else a
RangeError; a null-body status 204/205/304 with a body is a TypeError),
defaults statusText to "" and status to 200, accepts init.headers as a
plain object or a Headers instance, and — per the body-extraction step —
defaults Content-Type to text/plain for a non-null body when none was
supplied. A constructed response's url is "".
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 |
# File 'lib/dommy/fetch.rb', line 737 def self.__construct__(window, body, init) opts = init.is_a?(Hash) ? init : {} status = coerce_status(opts["status"] || opts[:status] || 200) unless status.between?(200, 599) raise Bridge::RangeError, "Failed to construct 'Response': The status provided (#{status}) is outside the range [200, 599]." end has_body = !(body.nil? || (defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED))) if has_body && NULL_BODY_STATUSES.include?(status) raise Bridge::TypeError, "Failed to construct 'Response': Response with null body status (#{status}) cannot have body." end # Extract a body: derive its bytes and the Content-Type it implies (Blob → # its MIME type, URLSearchParams → urlencoded, FormData → multipart, a # string → text/plain). The implied type is only the *default* — an # explicit init.headers Content-Type still wins. body_bytes, default_ct = has_body ? extract_body(body) : ["", nil] headers = coerce_headers(opts["headers"] || opts[:headers]) if default_ct && headers.keys.none? { |k| k.to_s.downcase == "content-type" } headers = headers.merge("Content-Type" => default_ct) end new(window, body: body_bytes, status: status, status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""), headers: headers, has_body: has_body) end |
.__error__(window) ⇒ Object
Static Response.error() — a network-error response (status 0, not ok,
type "error"). (WHATWG Fetch §Response.error)
825 826 827 828 829 830 |
# File 'lib/dommy/fetch.rb', line 825 def self.__error__(window) resp = new(window, body: "", status: 0, type: "error", has_body: false) # WHATWG: a network-error response's header guard is "immutable". resp.__js_get__("headers").make_immutable! resp end |
.__json__(window, data, init = nil) ⇒ Object
Static Response.json(data, init) — serialize data to JSON, defaulting
Content-Type to application/json. (WHATWG Fetch §Response.json)
771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 |
# File 'lib/dommy/fetch.rb', line 771 def self.__json__(window, data, init = nil) # WHATWG: serialize `data` as JSON; if that yields `undefined` (the value # is JS `undefined` — or absent — or otherwise non-serializable), throw a # TypeError. JS `null` serializes to "null" and is allowed. if defined?(Bridge::UNDEFINED) && data.equal?(Bridge::UNDEFINED) raise Bridge::TypeError, "Failed to execute 'json' on 'Response': The data is not JSON-serializable." end opts = init.is_a?(Hash) ? init : {} status = coerce_status(opts["status"] || opts[:status] || 200) unless status.between?(200, 599) raise Bridge::RangeError, "Failed to execute 'json' on 'Response': The status provided (#{status}) is outside the range [200, 599]." end if NULL_BODY_STATUSES.include?(status) raise Bridge::TypeError, "Failed to execute 'json' on 'Response': Response with null body status (#{status}) cannot have body." end headers = coerce_headers(opts["headers"] || opts[:headers]) unless headers.keys.any? { |k| k.to_s.downcase == "content-type" } headers = headers.merge("Content-Type" => "application/json") end new(window, body: JSON.generate(data), status: status, status_text: validate_status_text!(opts["statusText"] || opts[:statusText] || ""), headers: headers) end |
.__redirect__(window, url, status = nil) ⇒ Object
Static Response.redirect(url, status = 302) — a redirect response whose
Location header is the parsed-and-serialized url. Parsing failure is a
TypeError; a non-redirect status is a RangeError. The url is resolved
against the window's base URL so a relative target works. (WHATWG Fetch
§Response.redirect)
807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 |
# File 'lib/dommy/fetch.rb', line 807 def self.__redirect__(window, url, status = nil) base = window.respond_to?(:location) && window.location.respond_to?(:href) ? window.location.href : nil parsed = Dommy::URL.new(url.to_s, base) # raises Bridge::TypeError on failure status = coerce_status(status.nil? || (defined?(Bridge::UNDEFINED) && status.equal?(Bridge::UNDEFINED)) ? 302 : status) unless REDIRECT_STATUSES.include?(status) raise Bridge::RangeError, "Failed to execute 'redirect' on 'Response': Invalid status code #{status}." end resp = new(window, body: "", status: status, headers: {"Location" => parsed.href}, has_body: false) # WHATWG: a redirect response's header guard is "immutable". resp.__js_get__("headers").make_immutable! resp end |
.coerce_headers(raw) ⇒ Object
847 848 849 850 851 852 853 |
# File 'lib/dommy/fetch.rb', line 847 def self.coerce_headers(raw) case raw when Headers then raw.to_h when Hash then raw else {} end end |
.coerce_status(value) ⇒ Object
832 833 834 |
# File 'lib/dommy/fetch.rb', line 832 def self.coerce_status(value) value.is_a?(Numeric) ? value.to_i : value.to_s.to_i end |
.extract_body(body) ⇒ Object
WHATWG "extract a body": map a body source to [byte_string, default_content_type_or_nil]. The default Content-Type is applied only
when the caller supplied none.
869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 |
# File 'lib/dommy/fetch.rb', line 869 def self.extract_body(body) case body when Blob # File < Blob [body.__dommy_bytes__, (body.type.to_s.empty? ? nil : body.type)] when URLSearchParams [body.to_s, "application/x-www-form-urlencoded;charset=UTF-8"] when FormData multipart_body(body) when Bridge::Bytes # an ArrayBuffer / TypedArray body [body.pack_bytes, nil] when String [body, "text/plain;charset=UTF-8"] else if defined?(Bridge::UNDEFINED) && body.equal?(Bridge::UNDEFINED) ["", nil] else [body.to_s, "text/plain;charset=UTF-8"] end end end |
.multipart_body(form_data) ⇒ Object
Serialize a FormData as a multipart/form-data body. Returns [bytes, content_type] where content_type carries the generated boundary.
892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 |
# File 'lib/dommy/fetch.rb', line 892 def self.multipart_body(form_data) boundary = "----DommyFormBoundary#{SecureRandom.hex(12)}" crlf = "\r\n" out = +"" form_data.entries.each do |name, value| out << "--#{boundary}#{crlf}" if value.is_a?(Blob) filename = value.respond_to?(:name) ? value.name : "blob" out << %(Content-Disposition: form-data; name="#{name}"; filename="#{filename}"#{crlf}) content_type = value.type.to_s.empty? ? "application/octet-stream" : value.type out << "Content-Type: #{content_type}#{crlf}#{crlf}" out << value.__dommy_bytes__ << crlf else out << %(Content-Disposition: form-data; name="#{name}"#{crlf}#{crlf}) out << value.to_s << crlf end end out << "--#{boundary}--#{crlf}" [out, "multipart/form-data; boundary=#{boundary}"] end |
.utf8_decode(bytes) ⇒ Object
WHATWG "UTF-8 decode" for a body's text(): interpret the raw bytes as UTF-8, replacing any ill-formed sequence with U+FFFD, and drop a single leading byte-order mark (U+FEFF). Well-formed UTF-8 (the common case) is returned unchanged. Used by both Request and Response text().
859 860 861 862 863 864 |
# File 'lib/dommy/fetch.rb', line 859 def self.utf8_decode(bytes) s = bytes.to_s.dup.force_encoding(Encoding::UTF_8) s = s.scrub("\u{FFFD}") unless s.valid_encoding? s = s[1..] if s.start_with?("\u{FEFF}") s end |
.validate_status_text!(text) ⇒ Object
WHATWG reason-phrase: HTAB / SP / VCHAR (0x21–0x7E) / obs-text (0x80–0xFF). Any other byte (NUL, CR, LF, other controls, DEL) makes statusText invalid → TypeError.
839 840 841 842 843 844 845 |
# File 'lib/dommy/fetch.rb', line 839 def self.validate_status_text!(text) str = text.to_s if str.each_byte.any? { |b| (b < 0x20 && b != 0x09) || b == 0x7f } raise Bridge::TypeError, "Failed to construct 'Response': Invalid statusText." end str end |
Instance Method Details
#__js_call__(method, _args) ⇒ Object
950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 |
# File 'lib/dommy/fetch.rb', line 950 def __js_call__(method, _args) case method when "text" consume_body { immediate(Response.utf8_decode(@body)) } when "json" consume_body do immediate(JSON.parse(scrub_lone_surrogates(@body))) rescue JSON::ParserError => e rejected(ErrorValue.new("JSON parse: #{e.}")) end when "arrayBuffer" # arrayBuffer()'s spec return type is ArrayBuffer — wrap so the host # bridge decodes it to a bare JS ArrayBuffer (not a Uint8Array view). consume_body { immediate(Bridge::ArrayBuffer.new(@body.bytes)) } when "blob" consume_body do immediate(Blob.new([@body], {"type" => @headers.__js_call__("get", ["content-type"]) || ""}, @window)) end when "formData" consume_body { consume_form_data } when "clone" clone_response end end |
#__js_get__(key) ⇒ Object
913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 |
# File 'lib/dommy/fetch.rb', line 913 def __js_get__(key) case key when "status" @status when "ok" @status >= 200 && @status < 300 when "statusText" @status_text when "url" @url when "redirected" # Fetch API: true when the response is the result of a followed # redirect (so `response.url` is the final, not requested, URL). @redirected when "type" # WHATWG response type: "default" (constructed), "error" (Response.error), # "basic" (a same-origin fetch), … @type when "headers" @headers when "body" # WHATWG: a ReadableStream of the body bytes, or null when there is no # body. Merely reading `.body` does not consume it (identity preserved). body_stream when "bodyUsed" body_used? else Bridge::ABSENT end end |
#__js_set__(_key, _value) ⇒ Object
944 945 946 |
# File 'lib/dommy/fetch.rb', line 944 def __js_set__(_key, _value) Bridge::UNHANDLED end |
#strip_forbidden_headers(headers) ⇒ Object
Drop forbidden response headers (Set-Cookie/Set-Cookie2) before they reach the Headers object. Only a Hash (the network path) carries them; a Headers or nil passes through unchanged.
724 725 726 727 728 729 |
# File 'lib/dommy/fetch.rb', line 724 def strip_forbidden_headers(headers) return {} if headers.nil? return headers unless headers.is_a?(Hash) headers.reject { |name, _| FORBIDDEN_RESPONSE_HEADERS.include?(name.to_s.downcase) } end |