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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/Rakefile +8 -0
- data/lib/dommy/rack/cookie_jar.rb +166 -0
- data/lib/dommy/rack/errors.rb +34 -0
- data/lib/dommy/rack/field_interactor.rb +81 -0
- data/lib/dommy/rack/file_upload.rb +73 -0
- data/lib/dommy/rack/form_submission.rb +273 -0
- data/lib/dommy/rack/header_store.rb +58 -0
- data/lib/dommy/rack/history.rb +45 -0
- data/lib/dommy/rack/locator.rb +115 -0
- data/lib/dommy/rack/navigation.rb +176 -0
- data/lib/dommy/rack/request_builder.rb +134 -0
- data/lib/dommy/rack/response.rb +153 -0
- data/lib/dommy/rack/session.rb +525 -0
- data/lib/dommy/rack/version.rb +7 -0
- data/lib/dommy/rack/visibility.rb +47 -0
- data/lib/dommy/rack.rb +24 -0
- data/sig/dommy/rack.rbs +232 -0
- metadata +94 -0
|
@@ -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
|