{-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} -- {{{ Imports import Text.RSS.Conduit.Parse as Parser import Text.RSS.Conduit.Render as Renderer import Text.RSS.Extensions import Text.RSS.Extensions.Atom import Text.RSS.Extensions.Content import Text.RSS.Extensions.DublinCore import Text.RSS.Extensions.Syndication import Text.RSS.Lens import Text.RSS.Types import Text.RSS1.Conduit.Parse as Parser import Conduit import Control.Exception.Safe as Exception import Control.Monad import Control.Monad.Trans.Resource import Data.Char import Data.Conduit import Data.Conduit.List import Data.Default import Data.Maybe import Data.String import qualified Data.Text.Lazy.Encoding as Lazy import Data.Time.Calendar hiding (DayOfWeek (..)) import Data.Time.LocalTime import Data.Version import Data.Void import Data.XML.Types import Lens.Micro import System.FilePath import System.IO import System.Timeout import Test.Tasty import Test.Tasty.Golden (findByExtension, goldenVsString) import Test.Tasty.HUnit import Text.Atom.Conduit.Parse import Text.Atom.Types import Text.XML.Stream.Parse as XML hiding (choose) import Text.XML.Stream.Render import URI.ByteString import URI.ByteString.QQ -- }}} main :: IO () main = do goldenTests <- genGoldenTests defaultMain $ testGroup "Tests" [ unitTests , goldenTests ] unitTests :: TestTree unitTests = testGroup "Unit tests" [ skipHoursCase , skipDaysCase , rss1TextInputCase , rss2TextInputCase , rss1ImageCase , rss2ImageCase , categoryCase , cloudCase , guidCase , enclosureCase , sourceCase , rss1ItemCase , rss2ItemCase1 , rss2ItemCase2 , rss1ChannelItemsCase , rss1DocumentCase , dublinCoreChannelCase , dublinCoreItemCase , contentItemCase , syndicationChannelCase , atomChannelCase , multipleExtensionsCase ] genGoldenTests :: IO TestTree genGoldenTests = do xmlFiles <- findByExtension [".xml"] "." return $ testGroup "RSS golden tests" $ do xmlFile <- xmlFiles let goldenFile = addExtension xmlFile ".golden" f file = fmap (Lazy.encodeUtf8 . fromString . show) $ runResourceT $ runConduit $ sourceFile file .| Conduit.decodeUtf8C .| XML.parseText def .| parser parser = rssDocument :: MonadThrow m => ConduitM Event o m (Maybe (RssDocument NoExtensions)) return $ goldenVsString xmlFile goldenFile $ f xmlFile skipHoursCase :: TestTree skipHoursCase = testCase "<skipHours> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssSkipHours result @?= [Hour 0, Hour 9, Hour 18, Hour 21] where input = [ "<skipHours>" , "<hour>21</hour>" , "<hour>9</hour>" , "<hour>0</hour>" , "<hour>18</hour>" , "<hour>9</hour>" , "</skipHours>" ] skipDaysCase :: TestTree skipDaysCase = testCase "<skipDays> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssSkipDays result @?= [Monday, Saturday, Friday] where input = [ "<skipDays>" , "<day>Monday</day>" , "<day>Monday</day>" , "<day>Friday</day>" , "<day>Saturday</day>" , "</skipDays>" ] rss1TextInputCase :: TestTree rss1TextInputCase = testCase "RSS1 <textinput> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rss1TextInput result^.textInputTitleL @?= "Search XML.com" result^.textInputDescriptionL @?= "Search XML.com's XML collection" result^.textInputNameL @?= "s" result^.textInputLinkL @=? RssURI [uri|http://search.xml.com|] where input = [ "<textinput xmlns=\"http://purl.org/rss/1.0/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" rdf:about=\"http://search.xml.com\">" , "<title>Search XML.com</title>" , "<description>Search XML.com's XML collection</description>" , "<name>s</name>" , "<link>http://search.xml.com</link>" , "</textinput>" ] rss2TextInputCase :: TestTree rss2TextInputCase = testCase "RSS2 <textInput> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssTextInput result^.textInputTitleL @?= "Title" result^.textInputDescriptionL @?= "Description" result^.textInputNameL @?= "Name" result^.textInputLinkL @=? RssURI [uri|http://link.ext|] where input = [ "<textInput>" , "<title>Title</title>" , "<description>Description</description>" , "<name>Name</name>" , "<link>http://link.ext</link>" , "</textInput>" ] rss1ImageCase :: TestTree rss1ImageCase = testCase "RSS1 <image> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rss1Image result^.imageUriL @?= RssURI [uri|http://xml.com/universal/images/xml_tiny.gif|] result^.imageTitleL @?= "XML.com" result^.imageLinkL @?= RssURI [uri|http://www.xml.com|] where input = [ "<image xmlns=\"http://purl.org/rss/1.0/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" rdf:about=\"http://xml.com/universal/images/xml_tiny.gif\">" , "<url>http://xml.com/universal/images/xml_tiny.gif</url>" , "<title>XML.com</title>" , "<ignored>Ignored</ignored>" , "<link>http://www.xml.com</link>" , "<ignored>Ignored</ignored>" , "</image>" ] rss2ImageCase :: TestTree rss2ImageCase = testCase "RSS2 <image> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssImage result^.imageUriL @?= RssURI [uri|http://image.ext|] result^.imageTitleL @?= "Title" result^.imageLinkL @?= RssURI [uri|http://link.ext|] result^.imageWidthL @?= Just 100 result^.imageHeightL @?= Just 200 result^.imageDescriptionL @?= "Description" where input = [ "<image>" , "<url>http://image.ext</url>" , "<title>Title</title>" , "<ignored>Ignored</ignored>" , "<link>http://link.ext</link>" , "<width>100</width>" , "<height>200</height>" , "<description>Description</description>" , "<ignored>Ignored</ignored>" , "</image>" ] categoryCase :: TestTree categoryCase = testCase "<category> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssCategory result @?= RssCategory "Domain" "Name" where input = [ "<category domain=\"Domain\">" , "Name" , "</category>" ] cloudCase :: TestTree cloudCase = testCase "<cloud> element" $ do result1:result2:_ <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| XML.many rssCloud result1 @?= RssCloud uri "pingMe" ProtocolSoap result2 @?= RssCloud uri "myCloud.rssPleaseNotify" ProtocolXmlRpc where input = [ "<cloud domain=\"rpc.sys.com\" port=\"80\" path=\"/RPC2\" registerProcedure=\"pingMe\" protocol=\"soap\"/>" , "<cloud domain=\"rpc.sys.com\" port=\"80\" path=\"/RPC2\" registerProcedure=\"myCloud.rssPleaseNotify\" protocol=\"xml-rpc\" />" ] uri = RssURI [relativeRef|//rpc.sys.com:80/RPC2|] guidCase :: TestTree guidCase = testCase "<guid> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| XML.many rssGuid result @?= [GuidUri uri, GuidText "1", GuidText "2"] where input = [ "<guid isPermaLink=\"true\">//guid.ext</guid>" , "<guid isPermaLink=\"false\">1</guid>" , "<guid>2</guid>" ] uri = RssURI [relativeRef|//guid.ext|] enclosureCase :: TestTree enclosureCase = testCase "<enclosure> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssEnclosure result @?= RssEnclosure url 12216320 "audio/mpeg" where input = [ "<enclosure url=\"http://www.scripting.com/mp3s/weatherReportSuite.mp3\" length=\"12216320\" type=\"audio/mpeg\" />" ] url = RssURI [uri|http://www.scripting.com/mp3s/weatherReportSuite.mp3|] sourceCase :: TestTree sourceCase = testCase "<source> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssSource result @?= RssSource url "Tomalak's Realm" where input = [ "<source url=\"http://www.tomalak.org/links2.xml\">Tomalak's Realm</source>" ] url = RssURI [uri|http://www.tomalak.org/links2.xml|] rss1ItemCase :: TestTree rss1ItemCase = testCase "RSS1 <item> element" $ do Just result <- runResourceT $ runConduit $ sourceList input .| XML.parseText def .| rss1Item result^.itemTitleL @?= "Processing Inclusions with XSLT" result^.itemLinkL @?= Just link result^.itemDescriptionL @?= "Processing document inclusions with general XML tools can be problematic. This article proposes a way of preserving inclusion information through SAX-based processing." result^.itemExtensionsL @?= NoItemExtensions where input = [ "<item xmlns=\"http://purl.org/rss/1.0/\"" , "xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"" , "xmlns:dc=\"http://purl.org/dc/elements/1.1/\"" , "rdf:about=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\" >" , "<title>Processing Inclusions with XSLT</title>" , "<description>Processing document inclusions with general XML tools can be" , " problematic. This article proposes a way of preserving inclusion" , " information through SAX-based processing." , "</description>" , "<link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>" , "<sometag>Some content in unknown tag, should be ignored.</sometag>" , "</item>" ] link = RssURI [uri|http://xml.com/pub/2000/08/09/xslt/xslt.html|] rss2ItemCase1 :: TestTree rss2ItemCase1 = testCase "RSS2 <item> element 1" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssItem result^.itemTitleL @?= "Example entry" result^.itemLinkL @?= Just link result^.itemDescriptionL @?= "Here is some text containing an interesting description." result^.itemGuidL @?= Just (GuidText "7bd204c6-1655-4c27-aeee-53f933c5395f") result^.itemExtensionsL @?= NoItemExtensions isJust (result^.itemPubDateL) @?= True where input = [ "<item>" , "<title>Example entry</title>" , "<description>Here is some text containing an interesting description.</description>" , "<link>http://www.example.com/blog/post/1</link>" , "<guid isPermaLink=\"false\">7bd204c6-1655-4c27-aeee-53f933c5395f</guid>" , "<pubDate>Sun, 06 Sep 2009 16:20:00 +0000</pubDate>" , "<sometag>Some content in unknown tag, should be ignored.</sometag>" , "</item>" ] link = RssURI [uri|http://www.example.com/blog/post/1|] rss2ItemCase2 :: TestTree rss2ItemCase2 = testCase "RSS2 <item> element 2" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssItem result^.itemTitleL @?= "Plop" result^.itemLinkL @?= Nothing result^.itemDescriptionL @?= "" result^.itemAuthorL @?= "author@w3schools.com" result^.itemGuidL @?= Nothing result^.itemExtensionsL @?= NoItemExtensions isJust (result^.itemPubDateL) @?= True where input = [ "<item>" , "<title>Plop</title>" , "<author>author@w3schools.com</author>" , "<pubDate>2018-07-13T00:00:00-04:00</pubDate>" , "</item>" ] link = RssURI [uri|http://www.example.com/blog/post/1|] rss2ItemCase3 :: TestTree rss2ItemCase3 = testCase "RSS2 <item> element 3" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssItem result^.itemExtensionsL @?= NoItemExtensions isJust (result^.itemPubDateL) @?= True where input = [ "<item>" , "<title>Plop</title>" , "<author>author@w3schools.com</author>" , "<pubDate>2018-07-13</pubDate>" , "</item>" ] rss2ItemCase4 :: TestTree rss2ItemCase4 = testCase "RSS2 <item> element 4" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssItem result^.itemExtensionsL @?= NoItemExtensions isJust (result^.itemPubDateL) @?= True where input = [ "<item>" , "<title>Plop</title>" , "<author>author@w3schools.com</author>" , "<pubDate>2018-07-13 00:00:00</pubDate>" , "</item>" ] rss2ItemCase5 :: TestTree rss2ItemCase5 = testCase "RSS2 <item> element 5" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rssItem result^.itemExtensionsL @?= NoItemExtensions isJust (result^.itemPubDateL) @?= True where input = [ "<item>" , "<title>Plop</title>" , "<author>author@w3schools.com</author>" , "<pubDate>2018-07-13 00:00</pubDate>" , "</item>" ] rss1ChannelItemsCase :: TestTree rss1ChannelItemsCase = testCase "RSS1 <items> element" $ do result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| force "ERROR" rss1ChannelItems result @?= [resource1, resource2] where input = [ "<items xmlns=\"http://purl.org/rss/1.0/\" xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">" , "<rdf:Seq>" , "<rdf:li rdf:resource=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\" />" , "<rdf:li rdf:resource=\"http://xml.com/pub/2000/08/09/rdfdb/index.html\" />" , "</rdf:Seq>" , "</items>" ] resource1 = "http://xml.com/pub/2000/08/09/xslt/xslt.html" resource2 = "http://xml.com/pub/2000/08/09/rdfdb/index.html" rss1DocumentCase :: TestTree rss1DocumentCase = testCase "<rdf> element" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rss1Document result^.documentVersionL @?= Version [1] [] result^.channelTitleL @?= "XML.com" result^.channelDescriptionL @?= "XML.com features a rich mix of information and services for the XML community." result^.channelLinkL @?= link result^?channelImageL._Just.imageTitleL @?= Just "XML.com" result^?channelImageL._Just.imageLinkL @?= Just imageLink result^?channelImageL._Just.imageUriL @?= Just imageUri length (result^.channelItemsL) @?= 2 result^?channelTextInputL._Just.textInputTitleL @?= Just "Search XML.com" result^?channelTextInputL._Just.textInputDescriptionL @?= Just "Search XML.com's XML collection" result^?channelTextInputL._Just.textInputNameL @?= Just "s" result^?channelTextInputL._Just.textInputLinkL @?= Just textInputLink result^.channelExtensionsL @?= NoChannelExtensions where input = [ "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>" , "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns=\"http://purl.org/rss/1.0/\">" , "<channel rdf:about=\"http://www.xml.com/xml/news.rss\">" , "<title>XML.com</title>" , "<link>http://xml.com/pub</link>" , "<description>XML.com features a rich mix of information and services for the XML community.</description>" , "<image rdf:resource=\"http://xml.com/universal/images/xml_tiny.gif\" />" , "<items>" , "<rdf:Seq>" , "<rdf:li rdf:resource=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\" />" , "<rdf:li rdf:resource=\"http://xml.com/pub/2000/08/09/rdfdb/index.html\" />" , "</rdf:Seq>" , "</items>" , "</channel>" , "<image rdf:about=\"http://xml.com/universal/images/xml_tiny.gif\">" , "<title>XML.com</title>" , "<link>http://www.xml.com</link>" , "<url>http://xml.com/universal/images/xml_tiny.gif</url>" , "</image>" , "<item rdf:about=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\">" , "<title>Processing Inclusions with XSLT</title>" , "<link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>" , "<description>Processing document inclusions with general XML tools can be problematic. This article proposes a way of preserving inclusion information through SAX-based processing.</description>" , "</item>" , "<item rdf:about=\"http://xml.com/pub/2000/08/09/xslt/xslt.html\">" , "<title>Putting RDF to Work</title>" , "<link>http://xml.com/pub/2000/08/09/rdfdb/index.html</link>" , "<description>Tool and API support for the Resource Description Framework is slowly coming of age. Edd Dumbill takes a look at RDFDB, one of the most exciting new RDF toolkits.</description>" , "</item>" , "<textinput rdf:about=\"http://search.xml.com\">" , "<title>Search XML.com</title>" , "<description>Search XML.com's XML collection</description>" , "<name>s</name>" , "<link>http://search.xml.com</link>" , "</textinput>" , "</rdf:RDF>" ] link = RssURI [uri|http://xml.com/pub|] imageLink = RssURI [uri|http://www.xml.com|] imageUri = RssURI [uri|http://xml.com/universal/images/xml_tiny.gif|] textInputLink = RssURI [uri|http://search.xml.com|] dublinCoreChannelCase :: TestTree dublinCoreChannelCase = testCase "Dublin Core <channel> extension" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rssDocument result^.channelExtensionsL @?= DublinCoreChannel dublinCoreElement NoChannelExtensions where input = [ "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>" , "<rss xmlns:dc=\"http://purl.org/dc/elements/1.1/\" version=\"2.0\">" , "<channel>" , "<title>RSS Title</title>" , "<link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>" , "<dc:publisher>The O'Reilly Network</dc:publisher>" , "<dc:creator>Rael Dornfest (mailto:rael@oreilly.com)</dc:creator>" , "<dc:date>2000-01-01T12:00:00+00:00</dc:date>" , "<dc:language>EN</dc:language>" , "<dc:rights>Copyright © 2000 O'Reilly & Associates, Inc.</dc:rights>" , "<dc:subject>XML</dc:subject>" , "</channel>" , "</rss>" ] dublinCoreElement = mkDcMetaData { elementCreator = "Rael Dornfest (mailto:rael@oreilly.com)" , elementDate = Just date , elementLanguage = "EN" , elementPublisher = "The O'Reilly Network" , elementRights = "Copyright © 2000 O'Reilly & Associates, Inc." , elementSubject = "XML" } date = localTimeToUTC utc $ LocalTime (fromGregorian 2000 1 1) (TimeOfDay 12 0 0) dublinCoreItemCase :: TestTree dublinCoreItemCase = testCase "Dublin Core <item> extension" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rssItem result^.itemExtensionsL @?= DublinCoreItem dublinCoreElement NoItemExtensions where input = [ "<item xmlns:dc=\"http://purl.org/dc/elements/1.1/\">" , "<title>Example entry</title>" , "<dc:description>XML is placing increasingly heavy loads on the existing technical " , "infrastructure of the Internet.</dc:description>" , "<dc:language>EN</dc:language>" , "<dc:publisher>The O'Reilly Network</dc:publisher>" , "<dc:creator>Simon St.Laurent (mailto:simonstl@simonstl.com)</dc:creator>" , "<dc:date>2000-01-01T12:00:00+00:00</dc:date>" , "<dc:rights>Copyright © 2000 O'Reilly & Associates, Inc.</dc:rights>" , "<dc:subject>XML</dc:subject>" , "</item>" ] dublinCoreElement = mkDcMetaData { elementCreator = "Simon St.Laurent (mailto:simonstl@simonstl.com)" , elementDate = Just date , elementLanguage = "EN" , elementDescription = "XML is placing increasingly heavy loads on the existing technical infrastructure of the Internet." , elementPublisher = "The O'Reilly Network" , elementRights = "Copyright © 2000 O'Reilly & Associates, Inc." , elementSubject = "XML" } date = localTimeToUTC utc $ LocalTime (fromGregorian 2000 1 1) (TimeOfDay 12 0 0) contentItemCase :: TestTree contentItemCase = testCase "Content <item> extension" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rssItem result^.itemExtensionsL @?= ContentItem "<p>What a <em>beautiful</em> day!</p>" NoItemExtensions where input = [ "<item xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">" , "<title>Example entry</title>" , "<content:encoded><![CDATA[<p>What a <em>beautiful</em> day!</p>]]></content:encoded>" , "</item>" ] syndicationChannelCase :: TestTree syndicationChannelCase = testCase "Syndication <channel> extension" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rssDocument result^.channelExtensionsL @?= SyndicationChannel syndicationInfo NoChannelExtensions where input = [ "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>" , "<rss xmlns:sy=\"http://purl.org/rss/1.0/modules/syndication/\" version=\"2.0\">" , "<channel>" , "<title>RSS Title</title>" , "<link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>" , "<sy:updatePeriod>hourly</sy:updatePeriod>" , "<sy:updateFrequency>2</sy:updateFrequency>" , "<sy:updateBase>2000-01-01T12:00:00+00:00</sy:updateBase>" , "</channel>" , "</rss>" ] syndicationInfo = mkSyndicationInfo { updatePeriod = Just Hourly , updateFrequency = Just 2 , updateBase = Just date } date = localTimeToUTC utc $ LocalTime (fromGregorian 2000 1 1) (TimeOfDay 12 0 0) atomChannelCase :: TestTree atomChannelCase = testCase "Atom <channel> extension" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rssDocument result^.channelExtensionsL @?= AtomChannel (Just link) NoChannelExtensions where input = [ "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>" , "<rss xmlns:atom=\"http://www.w3.org/2005/Atom\" version=\"2.0\">" , "<channel>" , "<title>RSS Title</title>" , "<link>http://xml.com/pub/2000/08/09/xslt/xslt.html</link>" , "<atom:link href=\"http://dallas.example.com/rss.xml\" rel=\"self\" type=\"application/rss+xml\" />" , "</channel>" , "</rss>" ] url = AtomURI [uri|http://dallas.example.com/rss.xml|] link = AtomLink url "self" "application/rss+xml" mempty mempty mempty multipleExtensionsCase :: TestTree multipleExtensionsCase = testCase "Multiple extensions" $ do Just result <- runResourceT . runConduit $ sourceList input .| XML.parseText def .| rssItem result^.itemExtensionsL @?= ContentItem "<p>What a <em>beautiful</em> day!</p>" (AtomItem (Just link) NoItemExtensions) where input = [ "<item xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"" , " xmlns:atom=\"http://www.w3.org/2005/Atom\">" , "<title>Example entry</title>" , "<atom:link href=\"http://dallas.example.com/rss.xml\" rel=\"self\" type=\"application/rss+xml\" />" , "<content:encoded><![CDATA[<p>What a <em>beautiful</em> day!</p>]]></content:encoded>" , "</item>" ] url = AtomURI [uri|http://dallas.example.com/rss.xml|] link = AtomLink url "self" "application/rss+xml" mempty mempty mempty