Parsing RSS and Atom Feeds

Episode #390 | 29 minutes | published on May 11, 2019 | Uses FeedKit-8.1.1, Xcode-10.2.1, Swift-5.0
Subscribers Only
To get the information we need for the Podcast Detail screen, we’ll have to get the feed URL and parse it. There’s no built-in Codable support for XML, so we’ll look at using FeedKit to parse the feeds and extract the relevant information we need.

Episode Links

Parsing RSS and Atom Feeds

To get the information we need for the Podcast Detail screen, we’ll have to get the feed URL and parse it. There’s no built-in Codable support for XML, so we’ll look at using FeedKit to parse the feeds and extract the relevant information we need.

Installing FeedKit

We will start by adding FeedKit to the Podfile.

pod 'FeedKit'

We need to install FeedKit

pod install

Creating a model to represent a Podcast

To store the extracted data we will create a basic model Podcast with optional settings.

class Podcast {
    var title: String?
    var author: String?
    var description: String?
    var primaryGenre: String?
    var artworkURL: URL?
}

Create an enum to define loading errors arising from FeedKit and network issues.

enum PodcastLoadingError : Error {
    case networkingError(Error)
    case requestFailed(Int)
    case serverError(Int)
    case notFound
    case feedParsingError(Error)
    case missingAttribute(String)
}

We will define a function that will take a feed and call a completion block later with the loaded Podcast instance

import Foundation
import FeedKit

class PodcastFeedLoader {
    func fetch(feed: URL, completion: @escaping (Swift.Result<Podcast, PodcastLoadingError>) -> Void) {
    //...
    }
}

We will first fetch the data by creating the request to URLRequest. As data may not change frequently, we will use returnCacheDataElseLoad cache policy which will return cached data or we will load from the original source.

let req = URLRequest(url: feed, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 60) 

We will use URLSession to create a network request using URLRequest and completionHandler. Handling the errors as a networking error.

URLSession.shared.dataTask(with: req) { data, response, error in
    if let error = error {
        DispatchQueue.main.async {
        completion(.failure(.networkingError(error)))
        }
    return
    }
}

In the case of response, check the HTTP.statusCode. For status code 200 we will parse the data using FeedKitset while for failure case, use PodcastLoadingError to define failure cases.

let http = response as! HTTPURLResponse
switch http.statusCode {
case 200:
    if let data = data {
        self.loadFeed(data: data, completion: completion)
    }

case 404:
    DispatchQueue.main.async {
        completion(.failure(.notFound))
    }

case 500...599:
    DispatchQueue.main.async {
        completion(.failure(.serverError(http.statusCode)))
    }

default:
    DispatchQueue.main.async {
        completion(.failure(.requestFailed(http.statusCode)))
    }
}

Don't forget to resume dataTask.

Once we get data, we will use FeedParser to parse the feed asynchronously. We are extracting information obtained from Atom and RSS feed format. In this tutorial, we are not handing feed format obtained in JSON.

private func loadFeed(data: Data, completion: @escaping (Swift.Result<Podcast, PodcastLoadingError>) -> Void) {
    let parser = FeedParser(data: data)
    parser.parseAsync { parseResult in
        let result: Swift.Result<Podcast, PodcastLoadingError>
        do {
            switch parseResult {
            case .atom(let atom):
                result = try .success(self.convert(atom: atom))
            case .rss(let rss):
                result = try .success(self.convert(rss: rss))
            case .json(_): fatalError()
            case .failure(let e):
                result = .failure(.feedParsingError(e))
            }
        } catch let e as PodcastLoadingError {
            result = .failure(e)
        } catch {
            fatalError()
        }
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

Now, we will have a check for required and optional attribute in the parsed feed.
Note the attributes in Atom feed format. Check for subtitle and author attribute. Extract the relevant information and create a Podcast object.

private func convert(atom: AtomFeed) throws -> Podcast {
    guard let name = atom.title else { throw PodcastLoadingError.missingAttribute("title")  }

    let author = atom.authors?.compactMap({ $0.name }).joined(separator: ", ") ?? ""

    guard let logoURL = atom.logo.flatMap(URL.init) else {
        throw PodcastLoadingError.missingAttribute("logo")
    }

    let description = atom.subtitle?.value ?? ""

    let p = Podcast()
    p.title = name
    p.author = author
    p.artworkURL = logoURL
    p.description = description
    return p
}

Note that RSS feed has description attribute and iTunesOwner. Store this information in a Podcast object.

private func convert(rss: RSSFeed) throws -> Podcast {
    guard let title = rss.title else { throw PodcastLoadingError.missingAttribute("title") }
    guard let author = rss.iTunes?.iTunesOwner?.name else {
        throw PodcastLoadingError.missingAttribute("itunes:owner name")
    }
    let description = rss.description ?? ""
    guard let logoURL = rss.iTunes?.iTunesImage?.attributes?.href.flatMap(URL.init) else {
        throw PodcastLoadingError.missingAttribute("itunes:image url")
    }

    let p = Podcast()
    p.title = title
    p.author = author
    p.artworkURL = logoURL
    p.description = description
    return p
}

Resolving Security Issue

Next let's create a test for PodcastFeedLoader by creating an array of feed URLs. We will run into issues loading arbitrary feeds because not all of them work over HTTPS. To allow these to load we need to disable App Transport Security.

Open up Info.plist and create an entry for App Transport Security Settings, then set its Allow Arbitrary Loads to YES.

blog comments powered by Disqus