Consuming Laravel validation errors in a Swift network client
Creating a robust app network manager on client
In your Swift project, a core piece is a section of code responsable for making network requests and parsing responses. In that layer, it's common two have three distinct paths of result types:
- Success -->
Model: Decodable
- Error -->
Network error
- Validation error -->
ErrorBag: Decodable
Using a library like Alamofire, you get #1 and #2 out of the box, but making #3 work, especially for APIs that return error messages, is critical to having a modern high quality application.
Laravel validation errors
Submitting a POST request to a route usually has some sort of validation, where that be a FormRequest
with a set of rules, or a laravel-data
object with properties with validation rules attached, there's gotta be something somewhere!
Now this API client that lives in Swift (say an iOS app), should make a request that signals to the backend that it wants to have application/json
be returned, because if not, Laravel will redirect and populate the session with errors. For mobile clients, we want to simply return an array of error messages and the validator looks at the Accept
header to make a decision if we should redirect or not.
Example request:
1HTTP 1.1 POST /api/update-user2Accept: application/json3Content-Type: application/json45{"name": "Brad"}
Example validation error response:
1{2 "message": "Name not equal to Ben"3 "errors": {4 "name": [5 "Name not equal to Ben"6 ]7 }8}
Swift error model
To parse this error format in Swift, use a simple Codable
struct:
1struct ServerErrorBag: Codable, Equatable, Hashable {2 let message: String3 let errors: [String: [String]]4}
Now how do we handle not only the built in errors thrown from making an HTTP request on cilent but ALSO validation?! Well here is the answer!
Handling validation errors in Swift
APIClient
Here is an APIClient
class that uses async/await
in Swift, to send a request and return a Model
which is constrained to be Codable
. Nothing crazy here, but in Alamofire, we can use the serializingResponse
function to handle parsing responses in a custom manner.
1class APIClient { 2 func sendRequestAsync<Model: Codable>( 3 _ convertible: URLConvertible, 4 method: HTTPMethod = .get, 5 headers: HTTPHeaders? = nil, 6 parameters: Parameters? = nil) 7 async throws -> Model 8 { 9 var httpHeaders = headers ?? HTTPHeaders()10 httpHeaders.add(.accept("application/json"))11 12 let request = session.request(convertible, method: method, parameters: parameters, headers: httpHeaders)13 14 let response = await request15 .serializingResponse(using: TwoDecodableResponseSerializer<Model>(errorCode: 422))16 .response17 18 switch response.result {19 case .success(let result):20 switch result {21 case .success(let model):22 return model23 case .failure(let error):24 throw error25 }26 case .failure(let error):27 throw APIError.AFError(error: error)28 }29 }30}
Swift error cases
We'll model the possible error cases we know and care about by doing the following:
1enum APIError: Error {2 case AFError(error: AFError)3 case serverError(errorBag: ServerErrorBag)4 case unknown5}
The .serverError
case uses the ServerErrorBag
we defined above, as a enum with associated value.
Decoding error responses and success responses
What is this TwoDecodableResponseSerializer
you might ask? Well here is what it is, I found this from deep Google searches to try to crack this problem. I modified it a bit, but kept the name the same (naming is hard). It conforms to the ResponseSerializer
requirement from serializingResponse(using:
from Alamofire.
1class TwoDecodableResponseSerializer<T: Decodable>: ResponseSerializer { 2 3 // MARK: Lifecycle 4 5 init(errorCode: Int) { 6 self.errorCode = errorCode 7 } 8 9 // MARK: Public1011 public func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> Result<T, APIError> {12 guard error == nil else { return .failure(.unknown) }1314 guard let response else { return .failure(.noResponseError) }1516 do {17 if response.statusCode == errorCode {18 let result = try errorSerializer.serialize(request: request, response: response, data: data, error: nil)19 return .failure(.serverError(errorBag: result))20 } else {21 let result = try successSerializer.serialize(request: request, response: response, data: data, error: nil)22 return .success(result)23 }24 } catch let err {25 return .failure(.genericError(error: err))26 }27 }2829 // MARK: Private3031 private let errorCode: Int3233 private lazy var successSerializer = DecodableResponseSerializer<T>(decoder: decoder)34 private lazy var errorSerializer = DecodableResponseSerializer<ServerErrorBag>(decoder: decoder)3536}
The successSerializer
is a standard response serializer to return you a Codable
model. However, we also have an errorSerializer
which is another standard response serializer but this time specialized to only return the struct we defined earlier: ServerErrorBag
!
We pass in an HTTP status code to use, to trigger attempting to parse with our errorSerializer
. Laravel uses HTTP 422
: Unprocessable Entity to signal there are validation errors. So we use that as the indicator we shouuld try to look for an error response we expect.
Usage in real world code
We will...
- Call our APIClient to register a user method which calls
sendRequestAsync
internally - Create an HTTP request and attach a dual response decoder to the response handling
- HTTP request will fire & process and if there are errors they will be thrown
- Our call site catch block will handle that specific API error
- Error handling code inspects the
errors
property of theServerErrorBag
to pull out specific form elements with errors.
1 do { 2 let newUser = try await apiClient.register(name: name, email: email, password: password) 3 loginUser(user: newUser) 4} catch APIError.serverError(let errorBag) { 5 withAnimation { 6 if let nameError = errorBag.errors["name"]?.first { 7 self.nameError = nameError 8 } 9 10 if let emailError = errorBag.errors["email"]?.first {11 self.emailError = emailError12 }13 14 if let passwordError = errorBag.errors["password"]?.first {15 self.passwordError = passwordError16 }17 }18} catch {19 // Other error occurred, we will ignore for demo purposes20}
Wrapping up
Now you can handle the unexpected and the expected error cases. One last tidbit is, if you are using SwiftUI, you probably want to store and clear out those error states when modifying user input.
2@State private var emailError: String? 3 4var body: some View { 5 TextField("Email", text: $email) 6 7 if let emailError { 8 Text(emailError) 9 .foregroundColor(.red)10 }11}12.onChange(of: email) { _ in13 resetErrors()14}15 16private func resetErrors() {17 withAnimation {18 emailError = nil19 // and clear out any other errors as well20 }21}