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:

  1. Success --> Model: Decodable
  2. Error --> Network error
  3. 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-user
2Accept: application/json
3Content-Type: application/json
4
5{"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: String
3 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 request
15 .serializingResponse(using: TwoDecodableResponseSerializer<Model>(errorCode: 422))
16 .response
17 
18 switch response.result {
19 case .success(let result):
20 switch result {
21 case .success(let model):
22 return model
23 case .failure(let error):
24 throw error
25 }
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 unknown
5}

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: Public
10
11 public func serialize(request: URLRequest?, response: HTTPURLResponse?, data: Data?, error: Error?) throws -> Result<T, APIError> {
12 guard error == nil else { return .failure(.unknown) }
13
14 guard let response else { return .failure(.noResponseError) }
15
16 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 }
28
29 // MARK: Private
30
31 private let errorCode: Int
32
33 private lazy var successSerializer = DecodableResponseSerializer<T>(decoder: decoder)
34 private lazy var errorSerializer = DecodableResponseSerializer<ServerErrorBag>(decoder: decoder)
35
36}

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...

  1. Call our APIClient to register a user method which calls sendRequestAsync internally
  2. Create an HTTP request and attach a dual response decoder to the response handling
  3. HTTP request will fire & process and if there are errors they will be thrown
  4. Our call site catch block will handle that specific API error
  5. Error handling code inspects the errors property of the ServerErrorBag 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 = emailError
12 }
13 
14 if let passwordError = errorBag.errors["password"]?.first {
15 self.passwordError = passwordError
16 }
17 }
18} catch {
19 // Other error occurred, we will ignore for demo purposes
20}

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.

1@State private var email = "[email protected]"
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) { _ in
13 resetErrors()
14}
15 
16private func resetErrors() {
17 withAnimation {
18 emailError = nil
19 // and clear out any other errors as well
20 }
21}