Combine - how to proceed to decode a local json file if online fetch failed? - swift

I have an up-to-date json file hosted online and a local json file in my Xcode workspace. I would like to proceeed to decode a locally stored file if fetching failed: MyError.fetchError e.g. for no internet connection. This is the pipeline:
func fetchAndDecode<T: Decodable>(url: URL) -> AnyPublisher<T, MyError> {
fetchURL(url: url)
.decode(type: T.self, decoder: JSONDecoder())
.mapError { error in
if let error = error as? DecodingError {
return MyError.parsingError
} else {
return MyError.fetchError //here somehow proceed to parse local json file
}
}
.eraseToAnyPublisher()
}
How to achieve this ?

.mapError is the wrong operator because it considers only the Error branch.
fetchURL returns obviously Data, so before decoding the data you have to replace the fetch error with the local data.
Before the .decode... line insert
.replaceError(with: try! Data(contentsOf: Bundle.main.url(forResource: "local", withExtension: "json")!))
and delete the .mapError operator.
local.json represents the file name of the local file in the bundle.

I can propose an alternate but similar method to download the data and handle the error, using the async functions introduced for iOS 15.
Create a function that reads the data asynchronously and returns the data from the server if the connection worked, otherwise it will return the local JSON if a problem was found:
func getData(fromURL url: URL) async -> Data {
let request = URLRequest(url: url)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
print("HTTP response: \(response.debugDescription)")
// Found an issue: return the local JSON
return localJSON
}
// If everything is OK, return the data from the server
return data
}
Decode the data returned:
// Use the code below in an asynchronous environment -
// either an async function or inside a Task { } closure
let data = await getData(fromURL: url)
do {
let decoded = try JSONDecoder().decode(T.self, from: data)
print("Decoded JSON: \(decoded)")
return decoded
} catch {
print("Error decoding JSON: \(error), \(error.localizedDescription)")
}

Related

Cloud Function post request not sending error message properly to IOS app

I have an iOS app receiving json data from a google cloud function.
The data is retrieved and works perfectly when there is no error.
However, when the cloud function returns an error, the function below recognizes error as nil
Client:
func initialize() {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: json)
let task = URLSession.shared.dataTask(with: request, completionHandler: { [weak self] (data, response, error) in
guard let response = response as? HTTPURLResponse,
response.statusCode == 200,
let data = data,
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String : Any],
let clientSecret = json["clientSecret"] as? String,
let publishableKey = json["publishableKey"] as? String else {
///////////
//ERROR is not being recognized
/////////
let message = error?.localizedDescription ?? "Failed to decode response from server."
print("Error loading page: \(message)")
return
}
})
task.resume()
}
Client Output:
Failed to decode response from server.
Server:
firebase.functions().httpsCallable('myFunction')(message)
.then(result => {
// this works perfectly and is recognized by the swift function //
res.send({publishableKey: publishableKey, clientSecret: clientSecret});
})
.catch(err => {
// this is not recognized by the swift function //
console.log(err) // full of data
return res.status(400).send({error: err});
});
Logs(for error case):
Function execution took 240 ms, finished with status code: 400
If your requests fails I think that your error will come into the response parameter and you have to decode it. I think that the error parameter will be different than nil only if the server can't be reached or function does not exist. So basically in the else clause you will have to search into the response parameter for error.

Swift Combine: handle no data before decode without an error

My API usually returns a certain format in JSON (simplified notation):
{
status: // http status
error?: // error handle
data?: // the response data
...
}
In my Combine operators, I take the data from a URLSession dataTaskPublisher and parse the response into a Decodable object that reflects the above schema. That works great.
However, I have an endpoint that returns the HTTP status code 201 (operation successful), and has no data at all. How would I chain this with my operators without throwing an error?
This is what I have:
publisher
.map { (data, response) in
guard data.count > 0 else {
let status = (response as! HTTPURLResponse).statusCode
return Data("{\"status\": \(status), \"data\": \"\"}".utf8)
}
return data
}
.mapError { CustomError.network(description: "\($0)")}
.decode(type: MyResponse<R>.self, decoder: self.agent.decoder)
.mapError { err -> CustomError in CustomError.decoding(description: "\(err)") }
...
As you can see, I simply construct an appropriate response, where the response's "data" is an empty string. However, this is ugly and a bit hacky, and I do not see the reason, why the pipeline should continue with parsing, etc, when I already have all I need. How can I interrupt it and finish the pipeline successfully for its final subscriber?
I would suggest creating a separate Publisher for handling the specific endpoint which doesn't return any Data. You can use a tryMap to check the HTTP status code and throw an error in case it's not in the accepted range. If you don't care about the result, only that there was a successful response, you can map to a Void. If you care about the result (or the status code), you can map to that too.
extension URLSession.DataTaskPublisher {
func emptyBodyResponsePublisher() -> AnyPublisher<Void, CustomError> {
tryMap { _, response in
guard let httpResponse = response as? HTTPURLResponse else { throw CustomError.nonHTTPResponse }
let statusCode = httpResponse.statusCode
guard (200..<300).contains(statusCode) else { throw CustomError.incorrectStatusCode(statusCode) }
return Void()
}.mapError { CustomError.network($0) }
.eraseToAnyPublisher()
}
}

How do I read the property values of a JSON error object using Combine in Swift?

All of my API endpoints return a response which looks something like this in Postman:
{
"statusCode": 401,
"error": "Unauthorized",
"message": "Missing authentication"
}
What I would like to do is make the request, and have access to these properties in Swift. There will be some cases where I use the error message property's value in the front of the app. This will be determined by the statusCode returned.
What I have right now is this:
private var cancellable: AnyCancellable?
let url = URL(string: "http://abc336699.com/create")
self.cancellable = URLSession.shared.dataTaskPublisher(for: url!)
.map { $0.data }
Prior to this, I tried tryMap, but the type of error it returned didn't give me the flexibility I wanted. I then moved on and tried Almofire, but it seemed like an over kill for what I want to do.
I wanted to check what is being returned in the response I get, but I get the following error:
Cannot assign value of type 'Publishers.Map<URLSession.DataTaskPublisher, Data>' to type 'AnyCancellable'
I want simple access to my response errors so I can integrate the API throughout the app using combine.
I am not sure from where you will be getting your data as in JSON response there is no Key for data. Before writing below code my understanding was that you want to check error and statusCode from the mentioned JSON response and then move forward with your business logic. The below code is to give you a vague idea of how we can do that.
enum CustomError: Error {
case custom(_ error: String)
case unknownStatusCode
case errorOccurred
}
let url = URL(string: "http://abc336699.com/create")
func load() -> AnyPublisher<Data,CustomError> {
URLSession.shared.dataTaskPublisher(for: url!)
.map(\.data)
.tryMap { (data) -> Data in
let genericModel = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: AnyObject]
if let statusCode = genericModel?["statusCode"] as? String {
switch statusCode {
case "200":
guard let data = genericModel?["message"] as? Data else {
throw CustomError.custom("Parsing error")
}
return data
default:
if let error = genericModel?["error"] as? String {
throw CustomError.custom(error)
} else {
throw CustomError.unknownError
}
}
}
throw CustomError.errorOccurred
}
.decode(type: YourCustomDecodableModel.self, decoder: JSONDecoder())
.mapError({ $0 as? CustomError ?? CustomError.errorOccurred })
.eraseToAnyPublisher()
}

Get top of remote file in Swift

There is a line in multiple files via the Internet. I want to avoid download the entire file. Each file may be long or short. The required line is usually about line 15 - always different, but always within the first 500 bytes.
Is there a way I can get just the top part of a remote file?
I can then use a regex pattern to find the required line.
Although I know how to download a file in a temp. location and copy it to a proper location, I think that process is too much and wasteful.
This is an example:
class func load(url: URL, to localUrl: URL, completion: #escaping () -> ()) {
let sessionConfig = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfig)
let request = try! URLRequest(url: url, method: .get)
let task = session.downloadTask(with: request) { (tempLocalUrl, response, error) in
if let tempLocalUrl = tempLocalUrl, error == nil {
// Success
if let statusCode = (response as? HTTPURLResponse)?.statusCode {
print("Success: \(statusCode)")
}
do {
try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl)
completion()
} catch (let writeError) {
print("error writing file \(localUrl) : \(writeError)")
}
} else {
print("Failure: %#", error?.localizedDescription);
}
}
task.resume()
}
PS. It would also be helpful if you could include a method to find a line beginning with, say "abc=xyz", where I want "xyz".
To only download partial content, you need a server that supports sending ranges. To test this, make a HEAD request and check if the server responds with an Accept-Range: bytes header. If so, you can request partial content by adding a header like Range: bytes=0-499 to your GET requests to only receive the initial 500 bytes.

Partially downloading data in Swift

I'm trying to develop a download accelerator in Swift. It should get the file's size and divide it to n parts. Then it should download them at once by running multiple threads, and then merge the parts.
I read C# - Creating a Download Accelerator, unfortunately it doesn't help me.
I can do the multiple thread part easily by
DispatchQueue.main.async {
// The new thread
}
but the other part is harder. I usually download a file like this:
try Data(contentsOf: URL(string: assetsUrl!)!)
or I can do the thing that is explained in this answer
class Downloader {
class func load(url: URL, to localUrl: URL, completion: #escaping () -> ()) {
let sessionConfig = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfig)
let request = try! URLRequest(url: url, method: .get)
let task = session.downloadTask(with: request) { (tempLocalUrl, response, error) in
if let tempLocalUrl = tempLocalUrl, error == nil {
// Success
if let statusCode = (response as? HTTPURLResponse)?.statusCode {
print("Success: \(statusCode)")
}
do {
try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl)
completion()
} catch (let writeError) {
print("error writing file \(localUrl) : \(writeError)")
}
} else {
print("Failure: %#", error?.localizedDescription);
}
}
task.resume()
}
}
But this is not C - it's very simplistic and doesn't accept many arguments. How can I make it get "first 200_000 bytes" from the server?
First of all, the server needs to implement HTTP range requests. If it doesn't, and you don't control the server, then you will not be able to do this.
If the server supports HTTP range requests, then you need to specify the range with request headers, as explained here: https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
The essentials are that you first send a HEAD request to figure out whether the server supports HTTP range requests. This is determined by whether the response includes the Accept-Ranges header, with a non-zero value.
If the server supports HTTP range requests, then you can make a request for the resource, with the Range header set for example to a value of bytes=0-1023 (depends which format the Accept-Ranges header specified, in this case bytes)