How to parse the XML?

The book with the Challenge: Parse the XML Courses Feed. I get trouble here, who can help me?

Here’s what I came up with. It returns all the courses, not just the next offering of each title.

    func stringFromXMLNode(context: NSXMLNode, xpath: String) throws -> String? {
        let nodes = try context.nodesForXPath(xpath)
        if let node = nodes.first,
            let string = node.stringValue {
            return string
        }
        return nil
    }

    func coursesFromXMLData(data: NSData) throws -> [Course] {
        let dateFormatter = NSDateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd hh:mm:ss z"

        var courses: [Course] = []

        let doc = try NSXMLDocument(data: data, options:0)
        let courseNodes = try doc.nodesForXPath("//class")
        for courseNode in courseNodes {
            if let courseTitle = try stringFromXMLNode(courseNode, xpath: "offering"),
                let urlString = try stringFromXMLNode(courseNode, xpath: "offering/@href"),
                let courseURL = NSURL(string: urlString),
                let dateString = try stringFromXMLNode(courseNode, xpath: "begin"),
                let courseStartDate = dateFormatter.dateFromString(dateString) {
                courses.append(Course(title: courseTitle, url: courseURL, nextStartDate: courseStartDate))
            }
        }

        return courses
    }

Mine is very similar to Jim’s, but I had to add a do-catch statement for all the try expressions. It handles all errors that I can see at this point, except one. This excerpt from resultFromXMLData(data:):

let courseNodes = try doc.nodes(forXPath: "//class")

I can change “//class” to “//clss” to try to make it throw an error and it never parses the data and doesn’t throw an error. In the documentation for nodes(forXPath:) it says “You call this method in a try expression and handle any errors in the catch clauses of a do statement…” which I think I’m doing.

The main do statement in resultFromXMLData(data:) doesn’t seem to do anything except cancel the compile warning messages from Xcode about errors not being dealt with. I had to add the guard statements to catch the individual errors from parsing the nodes. Otherwise, I could change one of the strings (like “offering”) to gibberish, and nothing would happen, just like with the “//class” error above. It feels like I’m doing something wrong here, I just haven’t figured out what it is yet.

Spoiler alert: The following code has my solutions to all the challenges. If you want to figure them out on your own, don’t look ahead.

MainWindowController.swift:

import Cocoa

class MainWindowController: NSWindowController {

@IBOutlet var tableView: NSTableView!
@IBOutlet var arrayController: NSArrayController!
@IBOutlet var progressSpinner: NSProgressIndicator!

let fetcher = ScheduleFetcher()
@objc dynamic var courses: [Course] = []

override var windowNibName: NSNib.Name? {
	return NSNib.Name(rawValue: "MainWindowController")
}

override func windowDidLoad() {
	super.windowDidLoad()
	
	tableView.target = self
	tableView.doubleAction = #selector(openClass(sender:))
	
	progressSpinner.isHidden = false
	progressSpinner.startAnimation(self)
	
	fetcher.fetchCoursesUsing { (result) in
		switch result {
		case .Success(let courses):
			print("Got courses: \(courses)")
			self.courses = courses
			self.progressSpinner.isHidden = true
			self.progressSpinner.stopAnimation(self)
		case .Failure(let error):
			self.progressSpinner.isHidden = true
			self.progressSpinner.stopAnimation(self)
			print("Got error: \(error)")
			NSAlert(error: error).runModal()
			self.courses = []
		}
	}
}

@objc func openClass(sender: AnyObject) {
	if let course = arrayController.selectedObjects.first as? Course {
		NSWorkspace.shared.open(course.url as URL)
	}
}
}

ScheduleFetcher.swift:

import Foundation

class ScheduleFetcher {

enum FetchCoursesResult {
	case Success([Course])
	case Failure(Error)
}

var isXML: Bool = false
let session: URLSession
let urlStringJSON = "http://bookapi.bignerdranch.com/courses.json"
let urlStringXML = "http:/bookapi.bignerdranch.com/courses.xml"

init() {
	let config = URLSessionConfiguration.default
	session = URLSession(configuration: config)
}

//------------------------------------------------------------------------

func isXML(url: URL) -> Bool {
	if url.pathExtension == "xml" {
		isXML = true
	}
	else if url.pathExtension == "json" {
		isXML = false
	}
	else {
		print("Unexpected extension.")
		// ... some sort of error handling here
	}
	return isXML
}

//------------------------------------------------------------------------

func fetchCoursesUsing(completionHandler: @escaping(FetchCoursesResult) -> (Void)) {
	
	let url = URL(string: urlStringXML)!
	let request = URLRequest(url: url as URL)
	isXML = isXML(url: url)
	
	let task = session.dataTask(with: request, completionHandler: {
		(data, response, error) -> Void in
		var result: FetchCoursesResult
		
		if data == nil {
			result = .Failure(error!)
		}
		else if let response = response as? HTTPURLResponse  {
			print("\(String(describing: data?.count)) bytes, HTTP \(response.statusCode).")
			if response.statusCode == 200 {
				if self.isXML(url: url) {
					result = self.resultFromXMLData(data: data!)
				} else {
					result = self.resultFromJSONData(data: data!)
				}
			}
			else {
				let error = self.errorWithCode(code: 1,
								localizedDescription: "Bad status code \(response.statusCode).")
				result = .Failure(error)
			}
		}
		else {
			let error = self.errorWithCode(code: 1,
										   localizedDescription: "Unexpected response object.")
			result = .Failure(error)
		}
		OperationQueue.main.addOperation({
			completionHandler(result)
		})
		
	})
	task.resume()
}

//------------------------------------------------------------------------

func errorWithCode(code: Int, localizedDescription: String) -> Error {
	return NSError(domain: "ScheduleFetcher",
				   code: code,
				   userInfo: [NSLocalizedDescriptionKey: localizedDescription])
}

//------------------------------------------------------------------------

func courseFromDictionary(courseDict: NSDictionary) -> Course? {
	
	if let title = courseDict["title"] as? String,
		let urlString = courseDict["url"] as? String,
		let upcomingArray = courseDict["upcoming"] as? [NSDictionary],
		let nextUpcomingDict = upcomingArray.first,
		let nextStartDateString = nextUpcomingDict["start_date"] as? String
	{
		let url = NSURL(string: urlString)!
		
		let dateFormatter = DateFormatter()			// book says to use var here, causes warning
		dateFormatter.dateFormat = "yyyy-MM-dd"
		let nextStartDate = dateFormatter.date(from: nextStartDateString)!
		
		return Course(title: title,
					  url: url,
					  nextStartDate: nextStartDate)
	}
	return nil
}

//-------------------------------------------------------------------------------------------------------

func resultFromJSONData(data: Data) -> FetchCoursesResult {

	var topLevelDict = Dictionary<String, Any>()
	
	do {
		topLevelDict = (try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any])!
	} catch {
		return .Failure(error)
	}
	let courseDicts = topLevelDict["courses"] as! [NSDictionary]
	var courses: [Course] = []
	for courseDict in courseDicts {
		guard let course = courseFromDictionary(courseDict: courseDict) else {
			return .Failure(errorWithCode(code: 2,
							localizedDescription: "resultFromJSONData: Unexpected data structure"))
		}
		courses.append(course)
	}
	return .Success(courses)
}

//-----------------------------------------------------------------------------------------------------

func stringFromXMLNode(context: XMLNode, xpath: String) throws -> String? {
	let nodes = try context.nodes(forXPath: xpath)
	if let node = nodes.first, let string = node.stringValue {
		return string
	}
	return nil
}

//-----------------------------------------------------------------------------------------------------

func resultFromXMLData(data: Data) -> FetchCoursesResult {
	
	var courses: [Course] = []
	let dateFormatter = DateFormatter()
	dateFormatter.dateFormat = "yyyy-MM-dd hh:mm:ss z"
	
	do {
		let doc = try XMLDocument(data: data, options: [])
		let courseNodes = try doc.nodes(forXPath: "//class")
		for courseNode in courseNodes {
			guard let courseTitle = try stringFromXMLNode(context: courseNode, xpath: "offering"),
				let urlString = try stringFromXMLNode(context: courseNode, xpath: "offering/@href"),
			let courseURL = URL(string: urlString),
				let dateString = try stringFromXMLNode(context: courseNode, xpath: "begin"),
			let courseStartDate = dateFormatter.date(from: dateString) else {
				return .Failure(errorWithCode(code: 2,
							localizedDescription: "resultFromXMLData: Unexpected data structure"))
			}
			courses.append(Course(title: courseTitle,
									  url: courseURL as NSURL,
									  nextStartDate: courseStartDate))
		}
	} catch {
		return .Failure(error)
	}
	return .Success(courses)
}

//--------------------------------------------------------------------------------------------------------
}

Course.swift:

import Foundation

class Course: NSObject {
    @objc dynamic let title:String
    @objc dynamic let url: NSURL
    @objc dynamic let nextStartDate: Date

    init(title: String, url: NSURL, nextStartDate: Date) {
	    self.title = title
	    self.url = url
	    self.nextStartDate = nextStartDate
	    super.init()
    }
}

Any tips on how to make this better, and how to fix/handle the aforementioned error issue would be appreciated. Thanks!

let courseNodes = try doc.nodes(forXPath: “//class”) seems to always want to return an empty array if you mess with the forXPath: parameter. So, I just included this, after that statement: if courseNodes.isEmpty { return .Failure(error) } Perhaps not the best, but a fix for now. Thanks for sharing your code. It was very helpful. Cheers.