Skip to content

Commit b2ebd1e

Browse files
committed
Workaround for nasa/apod-api#48 by requesting date range; remove custom encoding logic
1 parent 6a58831 commit b2ebd1e

File tree

4 files changed

+94
-58
lines changed

4 files changed

+94
-58
lines changed

APOD/ContentView.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ struct ContentView: View {
9797
Image(uiImage: image)
9898
}
9999
} else {
100-
APODEntryView.failureImage
100+
APODEntryView.failureImage.flexibleFrame()
101101
}
102102
}.onTapGesture { withAnimation { titleShown.toggle() } }
103103

Shared/APODClient.swift

+64-46
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,17 @@ private let CACHE_URL = URL(
2323
fileURLWithPath: "cache", relativeTo:
2424
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.APOD")!)
2525

26-
public class APODEntry: Decodable {
27-
public var date: YearMonthDay
28-
var remoteImageURL: URL
29-
public var copyright: String?
30-
public var title: String?
31-
public var explanation: String?
32-
public var mediaType: MediaType
33-
34-
var localDataURL: URL {
35-
CACHE_URL.appendingPathComponent(date.description).appendingPathExtension(DATA_PATH_EXTENSION)
36-
}
37-
var localImageURL: URL {
38-
CACHE_URL.appendingPathComponent(date.description)
39-
}
26+
public class APODEntry: Codable {
27+
private let rawEntry: RawAPODEntry
28+
29+
public var date: YearMonthDay { rawEntry.date }
30+
public var title: String? { rawEntry.title }
31+
public var copyright: String? { rawEntry.copyright }
32+
public var explanation: String? { rawEntry.explanation }
33+
34+
public let localDataURL: URL
35+
public let localImageURL: URL
36+
public let remoteImageURL: URL
4037

4138
var PREVIEW_overrideImage: UIImage?
4239
private var _loadedImage: UIImage?
@@ -45,43 +42,43 @@ public class APODEntry: Decodable {
4542
return _loadedImage
4643
}
4744

48-
public enum MediaType {
49-
case image
50-
case video
51-
case unknown(String?)
52-
53-
init(rawValue: String?) {
54-
if rawValue == "image" {
55-
self = .image
56-
} else if rawValue == "video" {
57-
self = .video
58-
} else {
59-
self = .unknown(rawValue)
60-
}
45+
public required init(from decoder: Decoder) throws {
46+
rawEntry = try RawAPODEntry(from: decoder)
47+
localDataURL = CACHE_URL.appendingPathComponent(rawEntry.date.description).appendingPathExtension(DATA_PATH_EXTENSION)
48+
localImageURL = CACHE_URL.appendingPathComponent(rawEntry.date.description)
49+
50+
if let hdurl = rawEntry.hdurl {
51+
remoteImageURL = hdurl
52+
} else if let url = rawEntry.url {
53+
remoteImageURL = url
54+
} else {
55+
throw APODErrors.missingURL
6156
}
6257
}
6358

59+
public func encode(to encoder: Encoder) throws {
60+
try rawEntry.encode(to: encoder)
61+
}
62+
}
63+
64+
struct RawAPODEntry: Codable {
65+
var date: YearMonthDay
66+
var hdurl: URL?
67+
var url: URL?
68+
var title: String?
69+
var copyright: String?
70+
var explanation: String?
71+
var mediaType: String?
72+
6473
enum CodingKeys: String, CodingKey {
6574
case copyright
6675
case date
6776
case explanation
6877
case hdurl
6978
case url
70-
case media_type
71-
case service_version
79+
case mediaType = "media_type"
7280
case title
7381
}
74-
75-
public required init(from decoder: Decoder) throws {
76-
let container = try decoder.container(keyedBy: CodingKeys.self)
77-
date = try container.decode(YearMonthDay.self, forKey: .date)
78-
let urlString = try container.decodeIfPresent(String.self, forKey: .hdurl) ?? container.decode(String.self, forKey: .url)
79-
remoteImageURL = try URL(string: urlString).orThrow(APODErrors.invalidURL(urlString))
80-
copyright = try container.decodeIfPresent(String.self, forKey: .copyright)
81-
title = try container.decodeIfPresent(String.self, forKey: .title)
82-
explanation = try container.decodeIfPresent(String.self, forKey: .explanation)
83-
mediaType = MediaType(rawValue: try container.decodeIfPresent(String.self, forKey: .media_type))
84-
}
8582
}
8683

8784
func _downloadImageIfNeeded(_ entry: APODEntry) -> AnyPublisher<APODEntry, Error> {
@@ -92,7 +89,18 @@ func _downloadImageIfNeeded(_ entry: APODEntry) -> AnyPublisher<APODEntry, Error
9289
print("Downloading image for \(entry.date)")
9390
return URLSession.shared.downloadTaskPublisher(for: entry.remoteImageURL)
9491
.tryMap { url in
95-
try FileManager.default.moveItem(at: url, to: entry.localImageURL)
92+
print("Trying to move \(url) to \(entry.localImageURL)")
93+
do {
94+
try FileManager.default.moveItem(at: url, to: entry.localImageURL)
95+
} catch {
96+
if (try? entry.localImageURL.checkResourceIsReachable()) ?? false {
97+
// This race should be rare in practice, but happens frequently during development, when a new build
98+
// is installed in the simulator, and the app and extension both try to fill the cache at the same time.
99+
print("Image already cached for \(entry.date), continuing")
100+
return entry
101+
}
102+
throw error
103+
}
96104
print("Moved downloaded file!")
97105
return entry
98106
}
@@ -108,7 +116,6 @@ public class APODClient {
108116
private init() {
109117
do {
110118
try FileManager.default.createDirectory(at: CACHE_URL, withIntermediateDirectories: true)
111-
112119
for url in try FileManager.default.contentsOfDirectory(at: CACHE_URL, includingPropertiesForKeys: nil) where url.pathExtension == DATA_PATH_EXTENSION {
113120
do {
114121
let data = try Data(contentsOf: url)
@@ -135,17 +142,28 @@ public class APODClient {
135142

136143
var components = API_URL
137144
components.queryItems[withDefault: []]
138-
.append(URLQueryItem(name: "date", value: YearMonthDay.current.description))
145+
.append(contentsOf: [
146+
// Rather than requesting the current date, request a range starting from yesterday to avoid "no data available"
147+
// https://github.com/nasa/apod-api/issues/48
148+
URLQueryItem(name: "start_date", value: (YearMonthDay.yesterday ?? YearMonthDay.today).description),
149+
// Including end_date returns 400 when end_date is after today
150+
])
139151

140152
return URLSession.shared.dataTaskPublisher(for: components.url.orFatalError("Failed to build API URL"))
141153
.tryMap() { (data, response) in
142154
print("Got response! \(response)")
143-
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
155+
guard let response = response as? HTTPURLResponse else {
144156
throw URLError(.badServerResponse)
145157
}
158+
guard response.statusCode == 200 else {
159+
throw APODErrors.failureResponse(statusCode: response.statusCode)
160+
}
146161

147-
let entry = try JSONDecoder().decode(APODEntry.self, from: data)
148-
try data.write(to: entry.localDataURL)
162+
let entries = try JSONDecoder().decode([APODEntry].self, from: data)
163+
guard let entry = entries.last else {
164+
throw APODErrors.emptyResponse
165+
}
166+
try JSONEncoder().encode(entry).write(to: entry.localDataURL)
149167
return entry
150168
}
151169
.flatMap(_downloadImageIfNeeded)

Shared/Utilities.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,17 @@ public enum Loading<T> {
3838
public enum APODErrors: Error {
3939
case invalidDate(String)
4040
case invalidURL(String)
41+
case missingURL
42+
case emptyResponse
43+
case failureResponse(statusCode: Int)
4144
}
4245

4346
public extension Optional {
44-
func orThrow(_ error: Error) throws -> Wrapped {
47+
func orThrow(_ error: @autoclosure () -> Error) throws -> Wrapped {
4548
if let self = self {
4649
return self
4750
}
48-
throw error
51+
throw error()
4952
}
5053

5154
func orFatalError(_ message: @autoclosure () -> String, file: StaticString = #file, line: UInt = #line) -> Wrapped {

Shared/YearMonthDay.swift

+24-9
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,31 @@ import Foundation
1010
let TIME_ZONE_LA = TimeZone(identifier: "America/Los_Angeles")!
1111

1212
public struct YearMonthDay {
13-
let year: Int
14-
let month: Int
15-
let day: Int
13+
public let year: Int
14+
public let month: Int
15+
public let day: Int
1616

17-
public static var current: YearMonthDay {
18-
// Use current time zone in LA because in evenings the API starts returning "No data available for [tomorrow's date]"
19-
var calendar = Calendar.current
20-
calendar.timeZone = TIME_ZONE_LA
21-
let components = calendar.dateComponents([.year, .month, .day], from: Date())
22-
return YearMonthDay(year: components.year!, month: components.month!, day: components.day!)
17+
public init(year: Int, month: Int, day: Int) {
18+
self.year = year
19+
self.month = month
20+
self.day = day
21+
}
22+
23+
public init(localTime date: Date) {
24+
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
25+
year = components.year!
26+
month = components.month!
27+
day = components.day!
28+
}
29+
30+
public static var yesterday: YearMonthDay? {
31+
return Calendar.current.date(byAdding: .day, value: -1, to: Date()).map(YearMonthDay.init)
32+
}
33+
public static var today: YearMonthDay {
34+
return YearMonthDay(localTime: Date())
35+
}
36+
public static var tomorrow: YearMonthDay? {
37+
return Calendar.current.date(byAdding: .day, value: 1, to: Date()).map(YearMonthDay.init)
2338
}
2439

2540
public func asDate() -> Date? {

0 commit comments

Comments
 (0)