dommy-rack 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "stringio"
5
+ require "rack/utils"
6
+
7
+ module Dommy
8
+ module Rack
9
+ # Builds a Rack-compatible env hash from a high-level request description.
10
+ # Stateless aside from the session config it reads defaults from.
11
+ class RequestBuilder
12
+ FORM_URLENCODED = "application/x-www-form-urlencoded"
13
+ BODY_METHODS = %w[POST PUT PATCH DELETE].freeze
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def build(method:, url:, headers: {}, body: nil, params: nil, cookie_string: "")
20
+ raise ArgumentError, "pass either :params or :body, not both" if params && body
21
+
22
+ verb = method.to_s.upcase
23
+ uri = URI.parse(url)
24
+ env_headers = normalize_headers(headers)
25
+
26
+ body_string, query_extra, content_type = encode_payload(verb, params, body, env_headers)
27
+ query = merge_query(uri.query, query_extra)
28
+
29
+ env = base_env(verb, uri, query, body_string)
30
+ apply_default_headers(env, env_headers, cookie_string)
31
+ env["CONTENT_TYPE"] = content_type if content_type
32
+ env.merge!(env_headers)
33
+ env
34
+ end
35
+
36
+ private
37
+
38
+ def base_env(verb, uri, query, body_string)
39
+ {
40
+ "REQUEST_METHOD" => verb,
41
+ "SCRIPT_NAME" => "",
42
+ "PATH_INFO" => uri.path.to_s.empty? ? "/" : uri.path,
43
+ "QUERY_STRING" => query,
44
+ "SERVER_NAME" => uri.host.to_s,
45
+ "SERVER_PORT" => uri.port.to_s,
46
+ "SERVER_PROTOCOL" => "HTTP/1.1",
47
+ "HTTP_HOST" => host_header(uri),
48
+ "CONTENT_LENGTH" => body_string.bytesize.to_s,
49
+ "rack.url_scheme" => uri.scheme || "http",
50
+ "rack.input" => StringIO.new(body_string),
51
+ "rack.errors" => $stderr
52
+ }
53
+ end
54
+
55
+ def apply_default_headers(env, env_headers, cookie_string)
56
+ env["HTTP_ACCEPT"] = @config.accept unless env_headers.key?("HTTP_ACCEPT")
57
+ env["HTTP_USER_AGENT"] = @config.user_agent unless env_headers.key?("HTTP_USER_AGENT")
58
+ env["HTTP_COOKIE"] = cookie_string unless cookie_string.to_s.empty?
59
+ end
60
+
61
+ # Returns [body_string, query_extra, content_type]. For GET-style verbs,
62
+ # params go into the query string and the body stays empty.
63
+ def encode_payload(verb, params, body, env_headers)
64
+ if params
65
+ pairs = to_pairs(params)
66
+ if BODY_METHODS.include?(verb)
67
+ if FileUpload.multipart?(pairs)
68
+ multipart_body, content_type = FileUpload.encode(pairs)
69
+ [multipart_body, nil, content_type]
70
+ else
71
+ [encode_query(pairs), nil, env_headers["CONTENT_TYPE"] || FORM_URLENCODED]
72
+ end
73
+ else
74
+ ["", encode_query(pairs), nil]
75
+ end
76
+ elsif body.is_a?(String)
77
+ [body, nil, nil]
78
+ elsif body.respond_to?(:read)
79
+ [body.read.to_s, nil, nil]
80
+ else
81
+ ["", nil, nil]
82
+ end
83
+ end
84
+
85
+ def merge_query(existing, extra)
86
+ [existing, extra].compact.reject(&:empty?).join("&")
87
+ end
88
+
89
+ # Normalize params to ordered [name, value] pairs. A Hash expands array
90
+ # values into repeated pairs; an Array is already ordered pairs. Keeping
91
+ # order lets Rack reconstruct nested params (e.g. address[][street]).
92
+ def to_pairs(params)
93
+ if params.is_a?(::Hash)
94
+ params.flat_map { |key, value| Array(value).map { |v| [key.to_s, v] } }
95
+ else
96
+ params.map { |key, value| [key.to_s, value] }
97
+ end
98
+ end
99
+
100
+ # urlencode ordered pairs preserving order.
101
+ def encode_query(pairs)
102
+ pairs.map { |key, value| "#{::Rack::Utils.escape(key)}=#{::Rack::Utils.escape(scalar(value))}" }.join("&")
103
+ end
104
+
105
+ # File/Blob values can appear in a GET form; reduce them to their
106
+ # filename so build_query never serializes raw bytes into a query.
107
+ def scalar(value)
108
+ return value.to_s unless value.respond_to?(:__dommy_bytes__)
109
+
110
+ value.respond_to?(:name) ? value.name.to_s : ""
111
+ end
112
+
113
+ def normalize_headers(headers)
114
+ headers.each_with_object({}) do |(name, value), acc|
115
+ acc[normalize_header_name(name)] = value.to_s
116
+ end
117
+ end
118
+
119
+ def normalize_header_name(name)
120
+ key = name.to_s
121
+ case key.downcase
122
+ when "content-type" then "CONTENT_TYPE"
123
+ when "content-length" then "CONTENT_LENGTH"
124
+ else "HTTP_#{key.upcase.tr("-", "_")}"
125
+ end
126
+ end
127
+
128
+ def host_header(uri)
129
+ default = uri.scheme == "https" ? 443 : 80
130
+ uri.port && uri.port != default ? "#{uri.host}:#{uri.port}" : uri.host.to_s
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Dommy
6
+ module Rack
7
+ # Wraps a single completed Rack response triple plus the absolute URL it
8
+ # was fetched at. The body is drained eagerly (Rack bodies are one-shot)
9
+ # and the Dommy document is parsed lazily on first access.
10
+ class Response
11
+ HTML_CONTENT_TYPES = ["text/html", "application/xhtml+xml"].freeze
12
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
13
+
14
+ attr_reader :status, :headers, :url
15
+
16
+ # The redirects followed to reach this response, oldest first. Each entry
17
+ # is {status:, url:, location:}. Empty unless this was the final response
18
+ # of a followed redirect chain. Set by Navigation.
19
+ attr_accessor :redirects
20
+
21
+ def initialize(status, headers, body, url:)
22
+ @status = status.to_i
23
+ @headers = headers || {}
24
+ @url = url
25
+ @body = drain_body(body)
26
+ @document_parsed = false
27
+ @window = nil
28
+ @redirects = []
29
+ end
30
+
31
+ def body
32
+ @body
33
+ end
34
+
35
+ # Content-Type with any parameters (charset, boundary) stripped.
36
+ def content_type
37
+ raw = header("content-type")
38
+ return nil unless raw
39
+
40
+ raw.split(";", 2).first.to_s.strip.downcase
41
+ end
42
+
43
+ def html?
44
+ HTML_CONTENT_TYPES.include?(content_type)
45
+ end
46
+
47
+ # True when the response advertises a JSON content type, including
48
+ # structured-suffix types such as application/vnd.api+json.
49
+ def json?
50
+ ct = content_type
51
+ return false unless ct
52
+
53
+ ct == "application/json" || ct == "text/json" || ct.end_with?("+json")
54
+ end
55
+
56
+ # The parsed JSON body. Parses regardless of Content-Type so that
57
+ # servers mislabeling JSON still work; raises JSON::ParserError on
58
+ # invalid JSON. Pass symbolize_names: true for symbol keys.
59
+ def json(symbolize_names: false)
60
+ JSON.parse(@body, symbolize_names: symbolize_names)
61
+ end
62
+
63
+ def redirect?
64
+ REDIRECT_STATUSES.include?(@status)
65
+ end
66
+
67
+ # --- Status-class predicates ---
68
+
69
+ def success? = (200..299).cover?(@status)
70
+ def client_error? = (400..499).cover?(@status)
71
+ def server_error? = (500..599).cover?(@status)
72
+ def error? = @status >= 400
73
+ def not_found? = @status == 404
74
+
75
+ def location_header
76
+ header("location")
77
+ end
78
+
79
+ # The redirect target of an immediate (delay 0) <meta http-equiv=
80
+ # "refresh">, or nil. The HTML analog of location_header. A self-refresh
81
+ # with no URL is ignored to avoid reload loops; non-HTML responses and
82
+ # non-zero delays return nil.
83
+ def meta_refresh_url
84
+ return nil unless html?
85
+
86
+ meta = document&.query_selector_all("meta")
87
+ &.find { |m| m.get_attribute("http-equiv")&.downcase == "refresh" }
88
+ return nil unless meta
89
+
90
+ delay, _, rest = meta.get_attribute("content").to_s.partition(";")
91
+ return nil unless delay.strip.match?(/\A0+\z/)
92
+
93
+ url = rest.strip.sub(/\Aurl\s*=\s*/i, "").delete("\"'").strip
94
+ url.empty? ? nil : url
95
+ end
96
+
97
+ # All Set-Cookie values, handling both single-string (newline-joined)
98
+ # and array header shapes across Rack 2 and Rack 3.
99
+ def set_cookie_strings
100
+ values = lookup_header("set-cookie")
101
+ Array(values).flat_map { |v| v.to_s.split("\n") }.reject(&:empty?)
102
+ end
103
+
104
+ # The parsed Dommy window, or nil for non-HTML responses.
105
+ def window
106
+ parse_document! unless @document_parsed
107
+ @window
108
+ end
109
+
110
+ def document
111
+ window&.document
112
+ end
113
+
114
+ private
115
+
116
+ def drain_body(body)
117
+ return +"" if body.nil?
118
+ return body.dup if body.is_a?(String)
119
+
120
+ parts = []
121
+ body.each { |part| parts << part }
122
+ parts.join
123
+ ensure
124
+ body.close if body.respond_to?(:close)
125
+ end
126
+
127
+ def parse_document!
128
+ @document_parsed = true
129
+ return unless html?
130
+
131
+ @window = Dommy.parse(@body)
132
+ # Location#href= updates origin too (Dommy resolves absolute URLs),
133
+ # so a single assignment configures the full document URL.
134
+ @window.location.__js_set__("href", @url) if @url
135
+ @window.document.content_type = content_type if content_type
136
+ end
137
+
138
+ # Case-insensitive single-value header lookup.
139
+ def header(name)
140
+ value = lookup_header(name)
141
+ value.is_a?(Array) ? value.first : value
142
+ end
143
+
144
+ def lookup_header(name)
145
+ target = name.downcase
146
+ @headers.each do |key, value|
147
+ return value if key.to_s.downcase == target
148
+ end
149
+ nil
150
+ end
151
+ end
152
+ end
153
+ end