In the first episode SwiftUI and REST (Episode I), we explored how to use a REST API with GET requests. In this episode, we’ll learn how to perform POST requests and use a bearer token to access REST APIs that require authentication. At the end of the post, you’ll find a link to the GitHub project, which includes the code for the mobile application as well as a simple server for testing purposes.
Post
First, let’s define the structure we’ll use, starting with the information to be sent to the server for login (note that the server already has a user x@y.com with the password 123456).
struct UserInfo: Codable { var email: String var password: String }
This structure is Codable, meaning it can both decode and encode. In this case, we’ll use it to encode the information to be sent with the POST request.
let encoder = JSONEncoder() let credentials = UserInfo(email: username, password: password) let data = try encoder.encode(credentials)
The POST request should return a token that must be used to call the other APIs exposed by the server. To retrieve it, define the following structure:
struct LoginResponse: Codable { var user: UserAuth } struct UserAuth: Codable { var id: String? var email: String? var token: String? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decodeIfPresent(String.self, forKey: .id) email = try container.decodeIfPresent(String.self, forKey: .email) token = try container.decodeIfPresent(String.self, forKey: .token) } }
Now, let’s merge everything into the LoginView
:
struct LoginView: View { @State var password = "" @State var username = "" @Binding var token: String var body: some View { VStack { TextField("Username", text: $username) SecureField("Password", text: $password) Button("Login") { Task { await login() } } }.padding() } func login() async { guard let url = URL(string: "YOUR_IP:8080/api/v1/user/login") else { print("Invalid URL") return } do { var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpMethod = "POST" let encoder = JSONEncoder() let credentials = UserInfo(email: username, password: password) let data = try encoder.encode(credentials) request.httpBody = data let (dataResponse, response) = try await URLSession.shared.data(for: request) let httpResponse = response as? HTTPURLResponse print(httpResponse!.statusCode) if httpResponse!.statusCode != 200 { print("Login error") return } let decoder = JSONDecoder() do { print(response) let decodedResponse = try decoder.decode(LoginResponse.self, from: dataResponse) let resultString = String(data: dataResponse, encoding: .utf8) print("Data in String: \(String(describing: resultString))") print(decodedResponse.user.token ?? "") self.token = decodedResponse.user.token ?? "" } catch let DecodingError.dataCorrupted(context) { print(context) } catch let DecodingError.keyNotFound(key, context) { print("Key '\(key)' not found:", context.debugDescription) print("codingPath:", context.codingPath) } catch let DecodingError.valueNotFound(value, context) { print("Value '\(value)' not found:", context.debugDescription) print("codingPath:", context.codingPath) } catch let DecodingError.typeMismatch(type, context) { print("Type '\(type)' mismatch:", context.debugDescription) print("codingPath:", context.codingPath) } catch { print("error: ", error) } } catch { print("Invalid data") } } }
This view defines three variables: username
, password
, and token
. Please note that token
is defined as @Binding
because we want this token to be used also in other views that call additional APIs.
Here is the piece of code
let resultString = String(data: dataResponse, encoding: .utf8) print("Data in String: \(String(describing: resultString))")
It can be helpful to check what information is received from the server.
There’s another trick that’s used for the preview, which is necessary when a view uses a @Binding
:
#Preview { @State var t = "" return LoginView(password: "", username: "", token: $t) }
Now, let’s take a look at the main view:
struct ContentView: View { @State var token: String = "" var body: some View { NavigationStack { if token.count == 0 { LoginView(token: $token) } else { BooksView(token: $token) } } } }
The token, which will be bound by the LoginView
, is defined here. Therefore, this view
is displayed when the token is empty. Otherwise, the BooksView
is shown, which receives the token as a parameter.
Now, let’s take a look at this last view, but first, let’s examine the structures used:
struct ResponseBook: Codable { // the response for the books operation var books: Array<Book>? var error: String? var total: Int init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) books = try container.decodeIfPresent(Array<Book>.self, forKey: .books) total = try container.decode(Int.self, forKey: .total) error = try container.decodeIfPresent(String.self, forKey: .error) } } struct Book: Codable { var id: String var title: String var subtitle: String? init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) title = try container.decode(String.self, forKey: .title) subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) } }
If you have any doubts about the init
function, please refer to the previous post.
Call API with authentication
What we aim to do is receive the list of books for our user, identified by the token, and return a JSON object that contains the list of books, the number of books, and an error message if one is present. The books should be displayed in a list.
Here’s the first piece of code:
struct BooksView: View { @State var books = [Book]() @Binding var token: String let host = "YOUR_IP:8080" var body: some View { List { ForEach(books, id: \.id) { item in Text(item.title) } }.navigationTitle("Books") .task { await loadBooks() } }
For each book show the title, the books are loaded with this function:
guard let url = URL(string: host + "/api/v1/books") else { print("Invalid URL") return } do { var request = URLRequest(url: url) request.setValue( "Bearer \(token)", forHTTPHeaderField: "Authorization") request.httpMethod = "GET" let (dataResponse, _) = try await URLSession.shared.data(for: request) let decoder = JSONDecoder() do { // process data let decodedResponse = try decoder.decode(ResponseBook.self, from: dataResponse) let resultString = String(data: dataResponse, encoding: .utf8) print("Data in String: \(String(describing: resultString))") if let books = decodedResponse.books { self.books = books } } catch let DecodingError.dataCorrupted(context) { print(context) } catch let DecodingError.keyNotFound(key, context) { print("Key '\(key)' not found:", context.debugDescription) print("codingPath:", context.codingPath) } catch let DecodingError.valueNotFound(value, context) { print("Value '\(value)' not found:", context.debugDescription) print("codingPath:", context.codingPath) } catch let DecodingError.typeMismatch(type, context) { print("Type '\(type)' mismatch:", context.debugDescription) print("codingPath:", context.codingPath) } catch { print("error: ", error) } } catch { print("Invalid data") } }
The new thing is:
var request = URLRequest(url: url) request.setValue( "Bearer \(token)", forHTTPHeaderField: "Authorization") request.httpMethod = "GET"
With this code, set the bearer token and specify that we are making a GET request. The rest of the code is very similar to what we used in the previous example.
In the code, replace YOUR_IP
with the IP address of the computer that runs the server. To run the server, you need to install Docker Desktop. After that, go to the my-library-be
directory and execute docker-compose up
.