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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a1a9aaa4ae2c0fcc61a4054b66fe4e9e9fadb65093acfa912c1b73a5216ac7be
4
+ data.tar.gz: 3ead606c9fc898c53aeb6e05222f0ddbf2c4c3f08a8ccce6e881867de537019d
5
+ SHA512:
6
+ metadata.gz: 1f5e7fb4b35c073c069812de208765c5ba7e3aaca832a8071d320a991cc2f215ecf6d27ccec6e87988719ede65550b4d0b8901810380428495427e8fa48b6716
7
+ data.tar.gz: 21f1916ec0327d598142776993685685d0257e76dfa4ac998556aa116819678e96ead4b1615e31cccab60c3645de34d29a8e46f1e42638528bf1bd89071520a1
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## 0.8.0 — 2026-05-31
4
+
5
+ Versioned in lockstep with [`dommy`](https://github.com/takahashim/dommy) 0.8.0.
6
+ No functional changes to dommy-rack itself.
7
+
8
+ ## 0.7.0 — 2026-05-30
9
+
10
+ Initial release.
11
+
12
+ Versioned in lockstep with the [`dommy`](https://github.com/takahashim/dommy)
13
+ gem. dommy-rack lets a Rack application (including Rails) be visited and
14
+ manipulated as a `Dommy::Document` without launching a real browser, providing a
15
+ small, synchronous, browser-like session API with navigation, cookies,
16
+ redirects, link clicking, and form submission.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Masayoshi Takahashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # Dommy::Rack
2
+
3
+ `dommy-rack` lets a Rack application (including Rails) be visited and manipulated as a [Dommy](https://github.com/takahashim/dommy) `Document`, without launching a real browser.
4
+ It provides a small, synchronous, browser-like session API: navigation, cookies, redirects, link clicking, form submission, JSON requests, and simple matchers.
5
+
6
+ There is no JavaScript engine and no network: requests are dispatched straight to your Rack app object, and responses are parsed into a Dommy DOM.
7
+ This makes it a fast backend for integration tests and a building block for higher-level drivers such as [capybara-dommy](https://github.com/takahashim/capybara-dommy).
8
+
9
+ ## Installation
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "dommy-rack"
14
+ ```
15
+
16
+ ```bash
17
+ bundle install
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```ruby
23
+ require "dommy/rack"
24
+
25
+ session = Dommy::Rack::Session.new(MyRackApp)
26
+
27
+ session.visit("/")
28
+ session.click_link("New post")
29
+
30
+ session.fill_in("post[title]", with: "Hello")
31
+ session.click_button("Create")
32
+
33
+ session.current_path # => "/posts/1"
34
+ session.at_css(".notice").text_content # => "Created"
35
+ ```
36
+
37
+ `Session.new` accepts any Rack-callable `app` plus options:
38
+
39
+ | option | default | meaning |
40
+ | --- | --- | --- |
41
+ | `default_host` | `"http://example.org"` | base for relative URLs |
42
+ | `follow_redirects` | `true` | follow 3xx responses |
43
+ | `max_redirects` | `5` | redirect / meta-refresh limit |
44
+ | `respect_method_override` | `true` | honor Rails `_method` |
45
+ | `method_override_param` | `"_method"` | override param name |
46
+ | `user_agent` | `"DommyRack"` | default `User-Agent` |
47
+ | `accept` | HTML accept string | default `Accept` |
48
+ | `enforce_same_origin` | `true` | block cross-origin requests |
49
+ | `follow_meta_refresh` | `true` | follow `<meta http-equiv="refresh">` (delay 0) |
50
+
51
+ ## Navigation
52
+
53
+ ```ruby
54
+ session.visit("/path")
55
+ session.get("/path", headers: {})
56
+ session.post("/path", params: {a: 1})
57
+ session.put("/path", params: {})
58
+ session.patch("/path", params: {})
59
+ session.delete("/path")
60
+ session.request("REPORT", "/path", params: {})
61
+
62
+ session.reload
63
+ session.back
64
+ session.forward
65
+
66
+ session.current_url # full URL
67
+ session.current_path # path component
68
+ session.current_host
69
+ ```
70
+
71
+ `fetch` issues a request **without** changing the current page or history, and returns the `Response` directly:
72
+
73
+ ```ruby
74
+ res = session.fetch("/api/ping", redirect: :manual) # :follow | :manual | :error
75
+ res.status
76
+ ```
77
+
78
+ ## Inspecting the current page
79
+
80
+ ```ruby
81
+ session.status # last response status (Integer)
82
+ session.headers # last response headers (Hash)
83
+ session.body # raw response body (String)
84
+ session.html # serialized document HTML
85
+ session.text # document body text
86
+
87
+ session.success? # 2xx
88
+ session.not_found? # 404
89
+ session.client_error? # 4xx
90
+ session.server_error? # 5xx
91
+
92
+ session.save_page # write HTML to a temp file, returns path
93
+ session.save_page("out.html") # write to a specific path
94
+ ```
95
+
96
+ ### DOM queries
97
+
98
+ ```ruby
99
+ session.at_css("h1") # first match (a Dommy element) or nil
100
+ session.all_css("li") # all matches
101
+ session.at_xpath("//h1")
102
+ session.all_xpath("//li")
103
+ ```
104
+
105
+ ### Redirect chain
106
+
107
+ ```ruby
108
+ session.visit("/a") # /a -> /b -> /c
109
+ session.redirected? # => true
110
+ session.redirects # => [{status: 302, url: ".../a", location: "/b"}, ...]
111
+ ```
112
+
113
+ ## Forms
114
+
115
+ ```ruby
116
+ session.fill_in("Email", with: "a@example.com") # by label, id, name, placeholder, aria-label
117
+ session.choose("Male") # radio
118
+ session.check("Subscribe") # checkbox
119
+ session.uncheck("Subscribe")
120
+ session.select("Tokyo", from: "City") # <select>
121
+ session.unselect("Tokyo", from: "City")
122
+ session.attach_file("Avatar", "/path/to/file.png")
123
+
124
+ session.click_button("Save") # submits the owning form
125
+ session.submit_form(session.at_css("form"))
126
+ ```
127
+
128
+ Locators are Capybara-style and depend on the element type:
129
+ fields match by `id`, `name`, label text, placeholder, or `aria-label`;
130
+ links match by visible text, `id`, `title`, or exact `href`;
131
+ buttons match by button text, `value`, `id`, `name`, or `alt`.
132
+
133
+ ## JSON
134
+
135
+ ```ruby
136
+ session.post_json("/api/posts", {title: "Hi"}) # also put_json / patch_json / delete_json
137
+ session.status # => 201
138
+ session.json # => {"id" => 7}
139
+ session.json(symbolize_names: true) # => {id: 7}
140
+
141
+ # On a Response:
142
+ res = session.fetch("/api/posts")
143
+ res.json? # content-type is JSON-ish (application/json, text/json, *+json)
144
+ res.json # parsed body (parses regardless of content-type)
145
+ ```
146
+
147
+ A `String` passed to `post_json` is sent verbatim (already-encoded JSON).
148
+ `Content-Type` and `Accept` default to `application/json` and can be overridden via `headers:`.
149
+
150
+ ## Persistent headers and authentication
151
+
152
+ ```ruby
153
+ session.set_header("X-Api-Key", "secret") # sent on every request
154
+ session.delete_header("X-Api-Key")
155
+ session.default_headers # current persistent headers (copy)
156
+
157
+ session.basic_auth("alice", "s3cret") # Authorization: Basic ...
158
+ session.authorization_bearer("token123") # Authorization: Bearer token123
159
+ ```
160
+
161
+ Per-request `headers:` override persistent defaults (case-insensitively).
162
+
163
+ ## Cookies
164
+
165
+ ```ruby
166
+ session.cookies # all cookies
167
+ session.get_cookie("sid")
168
+ session.set_cookie("sid", "42", path: "/")
169
+ session.clear_cookies
170
+ ```
171
+
172
+ Cookies set by the app via `Set-Cookie` are stored and replayed automatically.
173
+
174
+ ## Scoping and matchers
175
+
176
+ ```ruby
177
+ session.within("#sidebar") do |s|
178
+ s.click_link("Help")
179
+ s.has_text?("Contact us") # scoped to #sidebar
180
+ end
181
+
182
+ session.has_css?(".item", count: 3)
183
+ session.has_no_css?(".error")
184
+ session.has_text?("Welcome")
185
+ session.has_no_text?("Error")
186
+ session.has_link?("Home")
187
+ session.has_button?("Save")
188
+ session.has_field?("Email")
189
+ ```
190
+
191
+ ### iframes
192
+
193
+ ```ruby
194
+ session.within_frame("preview") do |s| # by id, name, CSS, or the sole frame
195
+ s.has_text?("inside the iframe")
196
+ end
197
+ ```
198
+
199
+ `within_frame` fetches the iframe's `src` as a sub-document and scopes finds and matchers to it for the block.
200
+
201
+ ## Instrumentation
202
+
203
+ ```ruby
204
+ session.on_request { |env| Rails.logger.info("-> #{env["PATH_INFO"]}") }
205
+ session.on_response { |response| Rails.logger.info("<- #{response.status}") }
206
+ ```
207
+
208
+ ## Errors
209
+
210
+ All errors inherit from `Dommy::Rack::Error`:
211
+ `ElementNotFoundError`, `AmbiguousElementError`, `ElementNotClickableError`,
212
+ `UnsupportedURLError`, `CrossOriginError`, `TooManyRedirectsError`,
213
+ `UnsupportedContentTypeError`, `InvalidFormError`, `FileNotFoundError`.
214
+
215
+ ## Development
216
+
217
+ ```bash
218
+ bin/setup # install dependencies
219
+ rake test # run the test suite
220
+ bin/console # interactive prompt
221
+ ```
222
+
223
+ ## Contributing
224
+
225
+ Bug reports and pull requests are welcome at
226
+ https://github.com/takahashim/dommy-rack.
227
+
228
+ ## License
229
+
230
+ Available as open source under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ task default: :test
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "time"
5
+
6
+ module Dommy
7
+ module Rack
8
+ # A simplified, same-origin cookie store. Parses Set-Cookie response
9
+ # headers, generates the Cookie request header, and applies domain, path,
10
+ # expiry, and secure matching. No public-suffix handling.
11
+ class CookieJar
12
+ CookieEntry = Struct.new(
13
+ :name, :value, :domain, :path, :expires, :secure, :http_only, :host_only,
14
+ keyword_init: true
15
+ )
16
+
17
+ def initialize
18
+ @entries = []
19
+ end
20
+
21
+ # Parse a single Set-Cookie header value and store the result.
22
+ def store_from_header(set_cookie_string, request_url)
23
+ uri = URI.parse(request_url)
24
+ entry = parse_set_cookie(set_cookie_string, uri)
25
+ return unless entry
26
+
27
+ if expired?(entry)
28
+ remove(entry.name, entry.domain, entry.path)
29
+ else
30
+ store_entry(entry)
31
+ end
32
+ end
33
+
34
+ # Manually store a cookie. domain defaults to host-only on request_host.
35
+ def set!(name, value, domain: nil, path: "/", expires: nil, secure: false, http_only: false)
36
+ entry = CookieEntry.new(
37
+ name: name.to_s,
38
+ value: value.to_s,
39
+ domain: (domain || "").sub(/\A\./, "").downcase,
40
+ path: path || "/",
41
+ expires: expires,
42
+ secure: secure,
43
+ http_only: http_only,
44
+ host_only: domain.nil?
45
+ )
46
+ store_entry(entry)
47
+ end
48
+
49
+ # First non-expired cookie value matching the name.
50
+ def get(name)
51
+ @entries.find { |e| e.name == name.to_s && !expired?(e) }&.value
52
+ end
53
+
54
+ def clear
55
+ @entries = []
56
+ end
57
+
58
+ def all
59
+ @entries.reject { |e| expired?(e) }
60
+ end
61
+
62
+ # Build the Cookie request header value for the given URL, or "".
63
+ def cookies_for(request_url)
64
+ uri = URI.parse(request_url)
65
+ secure_request = uri.scheme == "https"
66
+ host = uri.host.to_s.downcase
67
+ path = uri.path.to_s.empty? ? "/" : uri.path
68
+
69
+ matches = @entries.reject { |e| expired?(e) }.select do |e|
70
+ domain_match?(e, host) &&
71
+ path_match?(e.path, path) &&
72
+ (!e.secure || secure_request)
73
+ end
74
+
75
+ # More specific (longer) paths first, per RFC 6265.
76
+ matches.sort_by! { |e| -e.path.length }
77
+ matches.map { |e| "#{e.name}=#{e.value}" }.join("; ")
78
+ end
79
+
80
+ private
81
+
82
+ def store_entry(entry)
83
+ remove(entry.name, entry.domain, entry.path)
84
+ @entries << entry
85
+ end
86
+
87
+ def remove(name, domain, path)
88
+ @entries.reject! { |e| e.name == name && e.domain == domain && e.path == path }
89
+ end
90
+
91
+ def parse_set_cookie(string, request_uri)
92
+ segments = string.split(";").map(&:strip)
93
+ name_value = segments.shift.to_s
94
+ return nil unless name_value.include?("=")
95
+
96
+ name, value = name_value.split("=", 2)
97
+ name = name.to_s.strip
98
+ return nil if name.empty?
99
+
100
+ attrs = parse_attributes(segments)
101
+ request_host = request_uri.host.to_s.downcase
102
+
103
+ domain = attrs["domain"]
104
+ host_only = domain.nil? || domain.empty?
105
+ domain = host_only ? request_host : domain.sub(/\A\./, "").downcase
106
+
107
+ CookieEntry.new(
108
+ name: name,
109
+ value: value.to_s.strip,
110
+ domain: domain,
111
+ path: attrs["path"] || default_path(request_uri),
112
+ expires: resolve_expiry(attrs),
113
+ secure: attrs.key?("secure"),
114
+ http_only: attrs.key?("httponly"),
115
+ host_only: host_only
116
+ )
117
+ end
118
+
119
+ def parse_attributes(segments)
120
+ segments.each_with_object({}) do |segment, acc|
121
+ key, val = segment.split("=", 2)
122
+ acc[key.to_s.strip.downcase] = val&.strip
123
+ end
124
+ end
125
+
126
+ # Max-Age takes precedence over Expires.
127
+ def resolve_expiry(attrs)
128
+ if attrs["max-age"]
129
+ seconds = attrs["max-age"].to_i
130
+ Time.now + seconds
131
+ elsif attrs["expires"]
132
+ Time.parse(attrs["expires"]) rescue nil
133
+ end
134
+ end
135
+
136
+ # RFC 6265 default-path: directory portion of the request path.
137
+ def default_path(uri)
138
+ path = uri.path.to_s
139
+ return "/" if path.empty? || !path.start_with?("/")
140
+
141
+ idx = path.rindex("/")
142
+ idx.nil? || idx.zero? ? "/" : path[0...idx]
143
+ end
144
+
145
+ def expired?(entry)
146
+ entry.expires && entry.expires <= Time.now
147
+ end
148
+
149
+ def domain_match?(entry, host)
150
+ if entry.host_only
151
+ host == entry.domain
152
+ else
153
+ host == entry.domain || host.end_with?(".#{entry.domain}")
154
+ end
155
+ end
156
+
157
+ # RFC 6265 path-match.
158
+ def path_match?(cookie_path, request_path)
159
+ return true if cookie_path == request_path
160
+ return false unless request_path.start_with?(cookie_path)
161
+
162
+ cookie_path.end_with?("/") || request_path[cookie_path.length] == "/"
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rack
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a locator matches no element.
8
+ class ElementNotFoundError < Error; end
9
+
10
+ # Raised when an element cannot be clicked (e.g. a link with no href).
11
+ class ElementNotClickableError < Error; end
12
+
13
+ # Raised when a locator matches more than one element.
14
+ class AmbiguousElementError < Error; end
15
+
16
+ # Raised for hrefs that dommy-rack cannot navigate to (javascript:, mailto:, ...).
17
+ class UnsupportedURLError < Error; end
18
+
19
+ # Raised when a request would cross origins, which is not allowed.
20
+ class CrossOriginError < Error; end
21
+
22
+ # Raised when a redirect chain exceeds max_redirects.
23
+ class TooManyRedirectsError < Error; end
24
+
25
+ # Raised when a response content type cannot be handled as requested.
26
+ class UnsupportedContentTypeError < Error; end
27
+
28
+ # Raised when a form is malformed or cannot be submitted.
29
+ class InvalidFormError < Error; end
30
+
31
+ # Raised when a file to be uploaded does not exist.
32
+ class FileNotFoundError < Error; end
33
+ end
34
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dommy
4
+ module Rack
5
+ # Drives form fields in the current document: fills text inputs, toggles
6
+ # radios / checkboxes, selects options, attaches files. Pure DOM mutation —
7
+ # it locates fields via a Locator and mutates the live Dommy elements, but
8
+ # issues no requests (Session turns a subsequent submit into navigation).
9
+ class FieldInteractor
10
+ def initialize(finder, document)
11
+ @finder = finder
12
+ @document = document
13
+ end
14
+
15
+ def fill_in(locator, with:)
16
+ field = @finder.find_field(locator)
17
+ field.value = with.to_s
18
+ field
19
+ end
20
+
21
+ def choose(locator)
22
+ radio = @finder.find_field(locator)
23
+ clear_radio_group(radio)
24
+ radio.checked = true
25
+ radio
26
+ end
27
+
28
+ def check(locator)
29
+ box = @finder.find_field(locator)
30
+ box.checked = true
31
+ box
32
+ end
33
+
34
+ def uncheck(locator)
35
+ box = @finder.find_field(locator)
36
+ box.checked = false
37
+ box
38
+ end
39
+
40
+ def attach_file(locator, path)
41
+ input = @finder.find_field(locator)
42
+ raise FileNotFoundError, "no such file: #{path}" unless ::File.exist?(path)
43
+
44
+ file = Dommy::File.new(
45
+ [::File.binread(path)], ::File.basename(path), "type" => FileUpload.mime_type_for(path)
46
+ )
47
+ input.__driver_set_files__([file])
48
+ input
49
+ end
50
+
51
+ def select(value, from:)
52
+ select_el = @finder.find_field(from)
53
+ option = @finder.find_option(select_el, value)
54
+ raise ElementNotFoundError, "no option #{value.inspect} in #{from.inspect}" unless option
55
+
56
+ select_el.options.each { |o| o.remove_attribute("selected") } unless select_el.multiple
57
+ option.set_attribute("selected", "")
58
+ select_el
59
+ end
60
+
61
+ def unselect(value, from:)
62
+ select_el = @finder.find_field(from)
63
+ option = @finder.find_option(select_el, value)
64
+ option&.remove_attribute("selected")
65
+ select_el
66
+ end
67
+
68
+ private
69
+
70
+ def clear_radio_group(radio)
71
+ name = radio.get_attribute("name")
72
+ return unless name
73
+
74
+ scope = radio.closest("form") || @document
75
+ scope.query_selector_all("input[type='radio']").each do |r|
76
+ r.checked = false if r.get_attribute("name") == name
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Dommy
6
+ module Rack
7
+ # Encodes collected form params (ordered [name, value] pairs) as a
8
+ # multipart/form-data body. File/Blob values become file parts; other
9
+ # values become text parts. Owns the multipart serialization so the HTTP
10
+ # layer (not Dommy::FormData) is responsible for it.
11
+ module FileUpload
12
+ MIME_TYPES = {
13
+ ".txt" => "text/plain",
14
+ ".html" => "text/html",
15
+ ".htm" => "text/html",
16
+ ".json" => "application/json",
17
+ ".csv" => "text/csv",
18
+ ".xml" => "application/xml",
19
+ ".png" => "image/png",
20
+ ".jpg" => "image/jpeg",
21
+ ".jpeg" => "image/jpeg",
22
+ ".gif" => "image/gif",
23
+ ".pdf" => "application/pdf"
24
+ }.freeze
25
+
26
+ module_function
27
+
28
+ # Guess a MIME type from a file path's extension.
29
+ def mime_type_for(path)
30
+ MIME_TYPES.fetch(::File.extname(path).downcase, "application/octet-stream")
31
+ end
32
+
33
+ # True when any pair value is a File/Blob.
34
+ def multipart?(pairs)
35
+ return false unless pairs
36
+
37
+ pairs.any? { |(_name, value)| value.respond_to?(:__dommy_bytes__) }
38
+ end
39
+
40
+ # Returns [body (ASCII-8BIT String), content_type with boundary].
41
+ def encode(pairs, boundary = nil)
42
+ boundary ||= "----DommyRackBoundary#{SecureRandom.hex(16)}"
43
+ body = +"".b
44
+ pairs.each { |name, value| body << part(boundary, name, value) }
45
+ body << "--#{boundary}--\r\n"
46
+ [body, "multipart/form-data; boundary=#{boundary}"]
47
+ end
48
+
49
+ def part(boundary, name, value)
50
+ head = +"--#{boundary}\r\n"
51
+ if value.respond_to?(:__dommy_bytes__) # File / Blob
52
+ filename = value.respond_to?(:name) ? value.name.to_s : ""
53
+ content_type = file_content_type(value)
54
+ head << %(Content-Disposition: form-data; name="#{escape(name)}"; filename="#{escape(filename)}"\r\n)
55
+ head << "Content-Type: #{content_type}\r\n\r\n"
56
+ head.b << value.__dommy_bytes__ << "\r\n".b
57
+ else
58
+ head << %(Content-Disposition: form-data; name="#{escape(name)}"\r\n\r\n)
59
+ head.b << value.to_s.dup.force_encoding(Encoding::ASCII_8BIT) << "\r\n".b
60
+ end
61
+ end
62
+
63
+ def file_content_type(value)
64
+ type = value.respond_to?(:type) ? value.type.to_s : ""
65
+ type.empty? ? "application/octet-stream" : type
66
+ end
67
+
68
+ def escape(str)
69
+ str.to_s.gsub('"', "%22").gsub(/[\r\n]/, "")
70
+ end
71
+ end
72
+ end
73
+ end