Fetching Swift
June 7, 2024
I'm clearing the decks in the lead up to WWDC in three & a bit days time. I'll leave the prognostication & the wishlists to others, but here's another quick Swift post. Guaranteed to be relevant until at least next week.
This is a part of an ongoing series of posts over the years that I've written about getting & decoding JSON files, which as it turns out is still a lot of what I use Swift for. The earlier posts aren't required reading for this in any way, but it is interesting to me to see them reflect my waxing & waning enthusiasm over that time. In 2017-2018, 2018, 2021, & now.
Wrapping Codable in another protocol that also deals with getting a JSON file can DRY up your code. Here I've renamed the protocol to Fetchable, separated out how it deals with local & remote files, made the error reporting more consistent with how I work, & fixed up the example's code's use of async. All while staying somewhat enthusiastic!
First the protocol, which is where all the work happens:
import Foundation
protocol Fetchable: Decodable {
}
extension Fetchable {
static func fetch(from url: URL) async -> Result<Self, any Error> {
do {
let data: Data
if url.isFileURL {
let task = Task {
return try Data(contentsOf: url)
}
data = try await task.value
} else {
(data, _) = try await URLSession.shared.data(from: url)
}
let decoded = try JSONDecoder().decode(Self.self, from: data)
return .success(decoded)
} catch {
return .failure(error)
}
}
}
extension Array: Fetchable where Element: Fetchable {}
I thought about having parameters for the decoding strategies in there at one time, but as this code's mostly copied & pasted it's easy enough to change it on the fly if the calls are consistent. Maybe I'll add those back in when I have to rewrite the thing when this all changes again next week.
In any case, the Fetchable protocol can then be applied to this simple struct for example:
struct Story: Fetchable {
let text: String
}
Which could then be used like so:
@main
struct App {
static func main() async {
// fetch a local file
let file = URL(fileURLWithPath: "./story-copy.json")
let data = await [Story].fetch(from: file)
doSomethingWith(data)
// fetch a remote file
if let url = URL(string: "https://mikekreuzer.com/story.json") {
let data = await [Story].fetch(from: url)
doSomethingWith(data)
}
}
// whatever it is you're doing with the data
static func doSomethingWith(_ data: Result<Array<Story>, any Error>) {
switch data {
case .success(let story):
if let first = story.first {
print(first.text)
}
case .failure(let error):
print(error)
}
}
}
The key lines being let data = await [Story].fetch(from: url)
and let data = await [Story].fetch(from: file)
. Those one-liners are the payoff. The rest of the code's just there to make the example work.
There's the usual inconsistency across all of this, so perhaps some or all of the following will change. (Which is where I sound more like 2024 me, & less like 2017 me.) But the protocol keeps most of this safely wrapped up & hidden inside it.
- initing a remote URL returns an Optional, initing a local file URL doesn't
- Data(contentsOf:) is synchronous, so to push that into another thread requires a Task, but remote gets have been async since forever.
- JSONDecoder().decode(from:) expects the whole file being decoded to be in memory & won't work async line by line, so this all won't work for very large files, & an async call to url.lines isn't useful when it could, I'd say probably even should be done that way
- the ungainly mix of Results and throws in error handling is everywhere in Swift, even for new-ish things. Task for example returns a Result, but to get the outcome that this Result wraps you use .value, which throws. It's easy enough to wrap all of these in a do/catch here & translate the outcomes to a Result, to isolate the likely changes to this one point in your code. I'd expect them all to become Results at some point along the way, or maybe I just hope that, but it's unlikely to happen all at once.
Still, all still to my mind a neat example of some of what's possible in Swift.
The story.json file used in the example really exists, & along with the earlier posts is a funny little reminder of past me. The file's all that's left of my first app on the App Store. It has value to me just as that, but it's also a fun little time capsule, it was part of an experiment in replacing RSS with JSON, before social media came along & kicked both RSS & it to the kerb. I still have the Objective-C code that called it somewhere.