simple-rss 2.1.0 → 2.3.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +174 -0
  3. data/README.md +492 -8
  4. data/Rakefile +1 -1
  5. data/examples/digest.rb +19 -0
  6. data/examples/discover.rb +14 -0
  7. data/examples/feedbag.rb +10 -0
  8. data/lib/simple-rss/discovery.rb +107 -0
  9. data/lib/simple-rss/entry_normalizer.rb +356 -0
  10. data/lib/simple-rss/http_client.rb +216 -0
  11. data/lib/simple-rss/json_entry_normalizer.rb +147 -0
  12. data/lib/simple-rss/json_feed.rb +176 -0
  13. data/lib/simple-rss/normalized_entry.rb +79 -0
  14. data/lib/simple-rss/request_errors.rb +21 -0
  15. data/lib/simple-rss/request_policy.rb +72 -0
  16. data/lib/simple-rss/xml_element.rb +136 -0
  17. data/lib/simple-rss.rb +437 -71
  18. data/simple-rss.gemspec +5 -5
  19. data/test/base/category_parsing_test.rb +217 -0
  20. data/test/base/date_ordering_test.rb +95 -0
  21. data/test/base/discovery_dependency_test.rb +29 -0
  22. data/test/base/discovery_test.rb +151 -0
  23. data/test/base/discovery_transport_test.rb +413 -0
  24. data/test/base/enumerable_test.rb +16 -0
  25. data/test/base/feed_merging_and_diffing_test.rb +140 -0
  26. data/test/base/feedbag_integration_test.rb +21 -0
  27. data/test/base/fetch_integration_test.rb +25 -0
  28. data/test/base/fetch_test.rb +0 -27
  29. data/test/base/filtering_and_validation_test.rb +187 -0
  30. data/test/base/json_feed_test.rb +372 -0
  31. data/test/base/media_and_enclosure_helpers_test.rb +84 -0
  32. data/test/base/normalized_entries_test.rb +410 -0
  33. data/test/base/normalized_fetch_test.rb +132 -0
  34. data/test/base/relation_links_test.rb +162 -0
  35. data/test/data/atom_categories.xml +23 -0
  36. data/test/data/atom_nested_link.xml +13 -0
  37. data/test/data/discovery.html +28 -0
  38. data/test/data/json_feed_1.json +75 -0
  39. data/test/data/json_feed_1_1.json +78 -0
  40. data/test/data/mixed_dates.xml +55 -0
  41. data/test/data/normalized_atom.xml +22 -0
  42. data/test/data/normalized_rss.xml +21 -0
  43. data/test/data/rss_categories.xml +15 -0
  44. data/test/support/http_server.rb +44 -0
  45. data/test/support/replace_method.rb +14 -0
  46. metadata +46 -10
@@ -0,0 +1,162 @@
1
+ require "test_helper"
2
+ require "json"
3
+
4
+ class RelationLinksTest < Test::Unit::TestCase
5
+ def test_documented_accessor_and_legacy_key_return_the_same_link
6
+ feed = parse_entry(<<~XML)
7
+ <link rel="self" href="https://example.com/api/1"/>
8
+ <link rel="alternate" href="https://example.com/posts/1"/>
9
+ XML
10
+ item = feed.first
11
+
12
+ assert_equal "https://example.com/posts/1", item.link_alternate
13
+ assert_equal item.link_alternate, item[:link_alternate]
14
+ assert_equal item.link_alternate, item[:"link+alternate"]
15
+ assert_equal "https://example.com/api/1", item.link
16
+ end
17
+
18
+ def test_relations_accept_attribute_order_quotes_whitespace_and_paired_tags
19
+ item = parse_entry(<<~XML).first
20
+ <link href = '/posts/1?view=full' title="Article > summary" rel = 'alternate'/>
21
+ <atom:link xmlns:atom="http://www.w3.org/2005/Atom"
22
+ rel = "self"
23
+ href = "https://example.com/api/1"></atom:link>
24
+ <link href='https://example.com/edit/1' rel='edit'>Edit entry</link>
25
+ <link rel="replies" type="application/atom+xml" href="/posts/1/replies" />
26
+ XML
27
+
28
+ assert_equal "/posts/1?view=full", item.link_alternate
29
+ assert_equal "https://example.com/api/1", item.link_self
30
+ assert_equal "https://example.com/edit/1", item.link_edit
31
+ assert_equal "/posts/1/replies", item.link_replies
32
+ %w[alternate self edit replies].each do |relation|
33
+ assert_equal item[:"link_#{relation}"], item[:"link+#{relation}"]
34
+ end
35
+ end
36
+
37
+ def test_nested_media_does_not_replace_the_link_href
38
+ source = File.read(File.join(__dir__, "../data/atom_nested_link.xml"))
39
+ feed = SimpleRSS.parse(source)
40
+
41
+ assert_equal "https://example.com/story", feed.first.link_alternate
42
+ assert_equal "https://example.com/story", feed.first[:"link+alternate"]
43
+ assert_equal source, feed.source
44
+ end
45
+
46
+ def test_relations_do_not_borrow_neighboring_attributes_or_content
47
+ item = parse_entry(<<~XML).first
48
+ <link href="/unrelated"/>
49
+ <link rel="self" href="/self"/>
50
+ <link rel="alternate"><media:thumbnail href="/thumbnail"/></link>
51
+ <link rel="replies" href="/replies"></link>
52
+ XML
53
+
54
+ assert_equal "/self", item.link_self
55
+ assert_equal "/replies", item.link_replies
56
+ assert_nil item.link_alternate
57
+ assert_nil item[:"link+alternate"]
58
+ assert_nil item.link_edit
59
+ end
60
+
61
+ def test_relations_ignore_nested_source_content_comments_and_cdata
62
+ item = parse_entry(<<~XML).first
63
+ <source xml:base="https://example.com/"><link rel="alternate" href="/source"/></source>
64
+ <content type="xhtml" xml:base='https://example.com/'><div><link rel="edit" href="/content"/></div></content>
65
+ <!-- <link rel="self" href="/comment"/> -->
66
+ <![CDATA[<link rel="replies" href="/cdata"/>]]>
67
+ <?example <link rel="self" href="/instruction"/> ?>
68
+ <extension><link rel="replies" href="/extension"/></extension>
69
+ <link rel="alternate" href="/entry"/>
70
+ XML
71
+
72
+ assert_equal "/entry", item.link_alternate
73
+ assert_nil item.link_self
74
+ assert_nil item.link_edit
75
+ assert_nil item.link_replies
76
+ end
77
+
78
+ def test_relations_match_exact_element_and_attribute_names
79
+ item = parse_entry(<<~XML).first
80
+ <linkage rel="alternate" href="/wrong-element"/>
81
+ <other:link rel="alternate" href="/wrong-namespace"/>
82
+ <link data-rel="alternate" href="/wrong-attribute"/>
83
+ <link title="rel='alternate' href='/quoted'" href="/wrong-value"/>
84
+ <link rel="self" data-href="/wrong-href"/>
85
+ <link rel="alternate" href="/correct"/>
86
+ XML
87
+
88
+ assert_equal "/correct", item.link_alternate
89
+ assert_nil item.link_self
90
+ end
91
+
92
+ def test_relations_stay_within_their_entry
93
+ feed = SimpleRSS.parse <<~XML
94
+ <feed xmlns="http://www.w3.org/2005/Atom">
95
+ <title>Separate entries</title>
96
+ <link rel="edit" href="/feed-edit"/>
97
+ <entry><title>First</title><link rel="self" href="/first"/></entry>
98
+ <entry><title>Second</title><link rel="edit" href="/second"/></entry>
99
+ </feed>
100
+ XML
101
+
102
+ assert_equal "/first", feed.first.link_self
103
+ assert_nil feed.first.link_edit
104
+ assert_equal "/second", feed[1].link_edit
105
+ assert_nil feed[1].link_self
106
+ end
107
+
108
+ def test_first_matching_relation_wins
109
+ item = parse_entry(<<~XML).first
110
+ <link rel="alternate" href="/first"/>
111
+ <link rel="alternate" href="/second"></link>
112
+ XML
113
+
114
+ assert_equal "/first", item.link_alternate
115
+ end
116
+
117
+ def test_relations_preserve_case_insensitive_matching
118
+ item = parse_entry('<LINK REL="ALTERNATE" HREF="/article"/>').first
119
+
120
+ assert_equal "/article", item.link_alternate
121
+ assert_equal "/article", item[:"link+alternate"]
122
+ end
123
+
124
+ def test_hash_and_json_serialization_include_both_relation_keys
125
+ feed = parse_entry('<link rel="alternate" href="/article"/>')
126
+ expected = { title: "Entry", link: "/article", "link+alternate": "/article", link_alternate: "/article" }
127
+
128
+ assert_equal expected, feed.first
129
+ assert_equal expected, feed.to_hash[:items].first
130
+ assert_equal expected, feed.as_json[:items].first
131
+ assert_equal expected, JSON.parse(feed.to_json, symbolize_names: true)[:items].first
132
+ end
133
+
134
+ def test_custom_relations_preserve_legacy_keys_and_unrelated_tag_names
135
+ original_tags = SimpleRSS.item_tags.dup
136
+ SimpleRSS.item_tags.push(:"link+enclosure", :"custom:label+featured", :"custom:value")
137
+ item = parse_entry(<<~XML).first
138
+ <link href="/episode.mp3" rel="enclosure"/>
139
+ <custom:label rel="featured">Featured</custom:label>
140
+ <custom:value>Unchanged</custom:value>
141
+ XML
142
+
143
+ assert_equal "/episode.mp3", item.link_enclosure
144
+ assert_equal "/episode.mp3", item[:"link+enclosure"]
145
+ assert_equal "Featured", item.custom_label_featured
146
+ assert_equal "Featured", item[:"custom_label+featured"]
147
+ assert_equal "Unchanged", item.custom_value
148
+ ensure
149
+ SimpleRSS.item_tags.replace(original_tags)
150
+ end
151
+
152
+ private
153
+
154
+ def parse_entry(content)
155
+ SimpleRSS.parse <<~XML
156
+ <feed xmlns="http://www.w3.org/2005/Atom">
157
+ <title>Relation links</title>
158
+ <entry><title>Entry</title>#{content}</entry>
159
+ </feed>
160
+ XML
161
+ end
162
+ end
@@ -0,0 +1,23 @@
1
+ <feed xmlns="http://www.w3.org/2005/Atom" xmlns:topics="http://www.w3.org/2005/Atom">
2
+ <title>Atom categories</title>
3
+ <entry>
4
+ <title>Ruby news</title>
5
+ <category label="Human label" scheme="https://example.com/topics" term="ruby"/>
6
+ <category
7
+ scheme = 'https://example.com/topics'
8
+ term = 'rails'
9
+ label = 'Framework'>Ignored element content</category>
10
+ <topics:category term="Ruby &amp; RSS"/>
11
+ <category term="ruby"/>
12
+ </entry>
13
+ <entry>
14
+ <title>No usable terms</title>
15
+ <category label="Label only">Not a term</category>
16
+ <category term="" label="Empty"/>
17
+ <category term=" &#32; " label="Whitespace"/>
18
+ </entry>
19
+ <entry>
20
+ <title>Other news</title>
21
+ <category term="sports"/>
22
+ </entry>
23
+ </feed>
@@ -0,0 +1,13 @@
1
+ <feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
2
+ <title>Nested link media</title>
3
+ <entry>
4
+ <title>Story</title>
5
+ <link href="https://example.com/story" rel="alternate" title="story" type="text/html">
6
+ <media:content>
7
+ <media:thumbnail height="60" url="https://example.com/thumbnail.jpg" width="106">
8
+ <img alt="Thumbnail" src="https://example.com/thumbnail.jpg"/>
9
+ </media:thumbnail>
10
+ </media:content>
11
+ </link>
12
+ </entry>
13
+ </feed>
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <HTML>
3
+ <HEAD>
4
+ <meta charset=utf-8>
5
+ <base HREF='../syndication/'>
6
+ <link title='News &amp; updates' href='news.xml?edition=1&amp;lang=en#items' TYPE='Application/RSS+XML; charset=UTF-8' REL='ALTERNATE'>
7
+ <LINK REL="alternate feed" TYPE=application/atom+xml HREF="atom.xml" title="Atom">
8
+ <link rel=alternate type=application/feed+json href=feed.json title='JSON Feed'>
9
+ <link rel=alternate type=application/json href=legacy.json />
10
+ <link rel=alternate type=application/rdf+xml href=feed.rdf>
11
+ <link rel=alternate type=application/rss+xml href='news.xml?edition=1&amp;lang=en#duplicate' title='Duplicate'>
12
+ <link rel=alternate type=application/rss+xml href='news.xml?edition=2&amp;lang=en'>
13
+ <base href='https://ignored.example.com/'>
14
+ <link rel=stylesheet type=application/rss+xml href=wrong.xml>
15
+ <link rel=alternate type=text/css href=wrong.css>
16
+ <link rel=alternate type=application/rss+xml>
17
+ <!-- <link rel=alternate type=application/rss+xml href=comment.xml> -->
18
+ <script>var html = '<link rel=alternate type=application/rss+xml href=script.xml>';</script>
19
+ <style>.example::before { content: '<link rel=alternate type=application/rss+xml href=style.xml>'; }</style>
20
+ <template><base href='https://wrong.example.com/'><link rel=alternate type=application/rss+xml href=template.xml></template>
21
+ <noscript><link rel=alternate type=application/rss+xml href=noscript.xml></noscript>
22
+ </HEAD>
23
+ <BODY>
24
+ <article><link rel=alternate type=application/rss+xml href=body.xml></article>
25
+ <p>&lt;link rel=alternate type=application/rss+xml href=text.xml&gt;</p>
26
+ <a href=guessed-feed.xml>Feed</a>
27
+ </BODY>
28
+ </HTML>
@@ -0,0 +1,75 @@
1
+ {
2
+ "version": "https://jsonfeed.org/version/1",
3
+ "title": "Example",
4
+ "description": "A feed of examples",
5
+ "home_page_url": "https://example.com/",
6
+ "feed_url": "https://example.com/feed.json",
7
+ "icon": "https://example.com/icon.png",
8
+ "favicon": "https://example.com/favicon.png",
9
+ "expired": true,
10
+ "next_url": "https://example.com/older.json",
11
+ "user_comment": "Subscribe with a feed reader.",
12
+ "hubs": [
13
+ {
14
+ "type": "WebSub",
15
+ "url": "https://example.com/hub"
16
+ }
17
+ ],
18
+ "_publisher": {
19
+ "enabled": true,
20
+ "sequence": [
21
+ 1,
22
+ 2
23
+ ]
24
+ },
25
+ "items": [
26
+ {
27
+ "id": "opaque:%2F:001",
28
+ "title": "Ruby & feeds",
29
+ "url": "https://example.com/blog/article?one=1&two=2",
30
+ "external_url": "https://example.org/original",
31
+ "content_html": "<p>Full &amp; complete.</p>",
32
+ "content_text": "Full & complete.",
33
+ "summary": "A summary.",
34
+ "date_published": "2026-09-12T10:00:00Z",
35
+ "date_modified": "2026-09-13T11:00:00Z",
36
+ "image": "https://example.com/image.png",
37
+ "banner_image": "https://example.com/banner.png",
38
+ "tags": [
39
+ "ruby",
40
+ "feeds",
41
+ "ruby",
42
+ " "
43
+ ],
44
+ "attachments": [
45
+ {
46
+ "url": "https://example.com/audio/one.mp3",
47
+ "mime_type": "audio/mpeg",
48
+ "title": "Episode",
49
+ "size_in_bytes": 1234,
50
+ "duration_in_seconds": 62.5,
51
+ "_codec": {
52
+ "lossless": false
53
+ }
54
+ },
55
+ {
56
+ "url": "https://example.com/images/two.png",
57
+ "mime_type": "image/png",
58
+ "title": "Cover",
59
+ "size_in_bytes": 4321
60
+ }
61
+ ],
62
+ "_extension": {
63
+ "value": "original"
64
+ }
65
+ }
66
+ ],
67
+ "author": {
68
+ "name": "Example Editor",
69
+ "url": "https://example.com/editor",
70
+ "avatar": "https://example.com/avatar.png",
71
+ "_profile": {
72
+ "role": "editor"
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,78 @@
1
+ {
2
+ "version": "https://jsonfeed.org/version/1.1",
3
+ "title": "Example",
4
+ "description": "A feed of examples",
5
+ "home_page_url": "https://example.com/",
6
+ "feed_url": "https://example.com/feed.json",
7
+ "icon": "https://example.com/icon.png",
8
+ "favicon": "https://example.com/favicon.png",
9
+ "language": "en",
10
+ "expired": true,
11
+ "next_url": "https://example.com/older.json",
12
+ "user_comment": "Subscribe with a feed reader.",
13
+ "hubs": [
14
+ {
15
+ "type": "WebSub",
16
+ "url": "https://example.com/hub"
17
+ }
18
+ ],
19
+ "authors": [
20
+ {
21
+ "name": "Example Editor",
22
+ "url": "https://example.com/editor",
23
+ "avatar": "https://example.com/avatar.png",
24
+ "_profile": {
25
+ "role": "editor"
26
+ }
27
+ }
28
+ ],
29
+ "_publisher": {
30
+ "enabled": true,
31
+ "sequence": [
32
+ 1,
33
+ 2
34
+ ]
35
+ },
36
+ "items": [
37
+ {
38
+ "id": "opaque:%2F:001",
39
+ "title": "Ruby & feeds",
40
+ "url": "https://example.com/blog/article?one=1&two=2",
41
+ "external_url": "https://example.org/original",
42
+ "content_html": "<p>Full &amp; complete.</p>",
43
+ "content_text": "Full & complete.",
44
+ "summary": "A summary.",
45
+ "date_published": "2026-09-12T10:00:00Z",
46
+ "date_modified": "2026-09-13T11:00:00Z",
47
+ "image": "https://example.com/image.png",
48
+ "banner_image": "https://example.com/banner.png",
49
+ "tags": [
50
+ "ruby",
51
+ "feeds",
52
+ "ruby",
53
+ " "
54
+ ],
55
+ "attachments": [
56
+ {
57
+ "url": "https://example.com/audio/one.mp3",
58
+ "mime_type": "audio/mpeg",
59
+ "title": "Episode",
60
+ "size_in_bytes": 1234,
61
+ "duration_in_seconds": 62.5,
62
+ "_codec": {
63
+ "lossless": false
64
+ }
65
+ },
66
+ {
67
+ "url": "https://example.com/images/two.png",
68
+ "mime_type": "image/png",
69
+ "title": "Cover",
70
+ "size_in_bytes": 4321
71
+ }
72
+ ],
73
+ "_extension": {
74
+ "value": "original"
75
+ }
76
+ }
77
+ ]
78
+ }
@@ -0,0 +1,55 @@
1
+ <rss version="2.0">
2
+ <channel>
3
+ <title>Mixed dates</title>
4
+ <item>
5
+ <title>Missing first</title>
6
+ </item>
7
+ <item>
8
+ <guid>publication</guid>
9
+ <title>Publication</title>
10
+ <pubDate>2026-09-01T00:00:00Z</pubDate>
11
+ <updated>2026-09-05T00:00:00Z</updated>
12
+ <published>2026-09-06T00:00:00Z</published>
13
+ </item>
14
+ <item>
15
+ <guid>fractional</guid>
16
+ <title>Fractional</title>
17
+ <pubDate>2026-09-03T00:00:00.000000001Z</pubDate>
18
+ </item>
19
+ <item>
20
+ <guid>updated-first</guid>
21
+ <title>Updated first</title>
22
+ <pubDate>not-a-date</pubDate>
23
+ <updated>2026-09-03T00:00:00.000000002Z</updated>
24
+ <published>2026-09-07T00:00:00Z</published>
25
+ </item>
26
+ <item>
27
+ <guid>published</guid>
28
+ <title>Published</title>
29
+ <pubDate></pubDate>
30
+ <updated>not-a-date</updated>
31
+ <published>2026-09-02T00:00:00Z</published>
32
+ </item>
33
+ <item>
34
+ <guid>historical</guid>
35
+ <title>Historical</title>
36
+ <pubDate>1965-01-01T00:00:00Z</pubDate>
37
+ </item>
38
+ <item>
39
+ <guid>updated-second</guid>
40
+ <title>Updated second</title>
41
+ <updated>2026-09-03T01:00:00.000000002+01:00</updated>
42
+ </item>
43
+ <item>
44
+ <guid>blank</guid>
45
+ <title>Blank</title>
46
+ <pubDate> </pubDate>
47
+ </item>
48
+ <item>
49
+ <guid>invalid</guid>
50
+ <title>Invalid</title>
51
+ <pubDate>not-a-date</pubDate>
52
+ <updated>not-a-date</updated>
53
+ </item>
54
+ </channel>
55
+ </rss>
@@ -0,0 +1,22 @@
1
+ <feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://example.com/blog/">
2
+ <title>Example feed</title>
3
+ <entry xml:base="posts/">
4
+ <id>opaque:%2F:001</id>
5
+ <title>Ruby &amp; feeds</title>
6
+ <link rel="self" type="application/atom+xml" href="api/001"/>
7
+ <link rel="alternate" type="application/pdf" href="article.pdf"/>
8
+ <link rel="alternate" type="text/html" xml:base="../" href="article?one=1&amp;two=2">
9
+ <extension href="https://wrong.example.com/"/>
10
+ </link>
11
+ <published>2026-09-12T10:00:00Z</published>
12
+ <updated>2026-09-13T11:00:00Z</updated>
13
+ <summary type="html">&lt;p&gt;A summary.&lt;/p&gt;</summary>
14
+ <content type="html">&lt;p&gt;Full &amp;amp; complete.&lt;/p&gt;</content>
15
+ <category term="ruby" label="Ruby language" scheme="urn:topics"/>
16
+ <category term="feeds"/>
17
+ <category term="ruby"/>
18
+ <category label="Missing term"/>
19
+ <link rel="enclosure" href="/audio/one.mp3" type="audio/mpeg" length="1234"/>
20
+ <link rel="enclosure" href="/images/two.png" type="image/png" length="4321"/>
21
+ </entry>
22
+ </feed>
@@ -0,0 +1,21 @@
1
+ <rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"
2
+ xmlns:atom="http://www.w3.org/2005/Atom" xml:base="https://example.com/blog/">
3
+ <channel>
4
+ <title>Example feed</title>
5
+ <item xml:base="posts/">
6
+ <guid isPermaLink="false">opaque:%2F:001</guid>
7
+ <title>Ruby &amp; feeds</title>
8
+ <link xml:base="../">article?one=1&amp;two=2</link>
9
+ <pubDate>Sat, 12 Sep 2026 10:00:00 GMT</pubDate>
10
+ <atom:updated>2026-09-13T11:00:00Z</atom:updated>
11
+ <description><![CDATA[<p>A summary.</p>]]></description>
12
+ <content:encoded><![CDATA[<p>Full &amp; complete.</p>]]></content:encoded>
13
+ <category domain="urn:topics">ruby</category>
14
+ <category>feeds</category>
15
+ <category>ruby</category>
16
+ <category> </category>
17
+ <enclosure url="/audio/one.mp3" type="audio/mpeg" length="1234"/>
18
+ <enclosure url="/images/two.png" type="image/png" length="4321"/>
19
+ </item>
20
+ </channel>
21
+ </rss>
@@ -0,0 +1,15 @@
1
+ <rss version="2.0">
2
+ <channel>
3
+ <title>RSS categories</title>
4
+ <item>
5
+ <title>Ruby news</title>
6
+ <category domain="https://example.com/topics">Technology</category>
7
+ <category><![CDATA[ Ruby & RSS ]]></category>
8
+ <category>Technology</category>
9
+ </item>
10
+ <item>
11
+ <title>Other news</title>
12
+ <category>Sports</category>
13
+ </item>
14
+ </channel>
15
+ </rss>
@@ -0,0 +1,44 @@
1
+ require "socket"
2
+
3
+ module HTTPServer
4
+ def wait_for_disconnect(client)
5
+ client.read
6
+ rescue Errno::ECONNRESET
7
+ nil
8
+ end
9
+
10
+ def with_server(responses)
11
+ server = TCPServer.new("127.0.0.1", 0)
12
+ base_url = "http://127.0.0.1:#{server.addr[1]}"
13
+ requests = []
14
+ worker = Thread.new do
15
+ responses.each do |response|
16
+ client = server.accept
17
+ begin
18
+ request = []
19
+ while (line = client.gets)
20
+ break if line == "\r\n"
21
+
22
+ request << line.strip
23
+ end
24
+ requests << request
25
+ if response.respond_to?(:call)
26
+ response.call(client, request)
27
+ next
28
+ end
29
+ status, headers, body = response
30
+ response_headers = headers.merge("Content-Length" => body.bytesize.to_s, "Connection" => "close")
31
+ client.write("HTTP/1.1 #{status} Test\r\n" + response_headers.map { |name, value| "#{name}: #{value}\r\n" }.join + "\r\n" + body)
32
+ ensure
33
+ client.close
34
+ end
35
+ end
36
+ end
37
+ yield base_url, requests
38
+ worker.value
39
+ ensure
40
+ worker&.kill
41
+ worker&.join
42
+ server&.close
43
+ end
44
+ end
@@ -0,0 +1,14 @@
1
+ module ReplaceMethod
2
+ def with_replaced_method(object, name, replacement)
3
+ own_method = object.singleton_methods(false).include?(name)
4
+ original = object.method(name)
5
+ object.define_singleton_method(name) { |*arguments, **keywords, &block| replacement.call(*arguments, **keywords, &block) }
6
+ yield
7
+ ensure
8
+ if own_method
9
+ object.define_singleton_method(name, original)
10
+ else
11
+ object.singleton_class.remove_method(name)
12
+ end
13
+ end
14
+ end