simple-rss 2.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +174 -0
- data/README.md +492 -8
- data/Rakefile +1 -1
- data/examples/digest.rb +19 -0
- data/examples/discover.rb +14 -0
- data/examples/feedbag.rb +10 -0
- data/lib/simple-rss/discovery.rb +107 -0
- data/lib/simple-rss/entry_normalizer.rb +356 -0
- data/lib/simple-rss/http_client.rb +216 -0
- data/lib/simple-rss/json_entry_normalizer.rb +147 -0
- data/lib/simple-rss/json_feed.rb +176 -0
- data/lib/simple-rss/normalized_entry.rb +79 -0
- data/lib/simple-rss/request_errors.rb +21 -0
- data/lib/simple-rss/request_policy.rb +72 -0
- data/lib/simple-rss/xml_element.rb +136 -0
- data/lib/simple-rss.rb +225 -77
- data/simple-rss.gemspec +5 -5
- data/test/base/category_parsing_test.rb +217 -0
- data/test/base/date_ordering_test.rb +95 -0
- data/test/base/discovery_dependency_test.rb +29 -0
- data/test/base/discovery_test.rb +151 -0
- data/test/base/discovery_transport_test.rb +413 -0
- data/test/base/enumerable_test.rb +16 -0
- data/test/base/feedbag_integration_test.rb +21 -0
- data/test/base/json_feed_test.rb +372 -0
- data/test/base/normalized_entries_test.rb +410 -0
- data/test/base/normalized_fetch_test.rb +132 -0
- data/test/base/relation_links_test.rb +162 -0
- data/test/data/atom_categories.xml +23 -0
- data/test/data/atom_nested_link.xml +13 -0
- data/test/data/discovery.html +28 -0
- data/test/data/json_feed_1.json +75 -0
- data/test/data/json_feed_1_1.json +78 -0
- data/test/data/mixed_dates.xml +55 -0
- data/test/data/normalized_atom.xml +22 -0
- data/test/data/normalized_rss.xml +21 -0
- data/test/data/rss_categories.xml +15 -0
- data/test/support/http_server.rb +44 -0
- data/test/support/replace_method.rb +14 -0
- metadata +42 -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 & 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="   " 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 & updates' href='news.xml?edition=1&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&lang=en#duplicate' title='Duplicate'>
|
|
12
|
+
<link rel=alternate type=application/rss+xml href='news.xml?edition=2&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><link rel=alternate type=application/rss+xml href=text.xml></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 & 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 & 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 & 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&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"><p>A summary.</p></summary>
|
|
14
|
+
<content type="html"><p>Full &amp; complete.</p></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 & feeds</title>
|
|
8
|
+
<link xml:base="../">article?one=1&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 & 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
|