Swift UIKit (Storyboard)
CodePath BlogSmart Group Project
MichaelDacanay/BlogSmart: CodePath iOS Final Group Project (github.com)
In the Write ViewController, the design has been mocked out. However, the buttons and interaction don’t work yet. Parse API is used for the backend: Back4App Dashboard.
After working with my group mate, we completed the basic functionality of the application. Now, I will add the ChatGPT functionality. Instead of using an SDK, I will do a HTTP call using the URLRequest/URLSession library and the https://api.openai.com/v1/chat/completions
endpoint. The URLSession module is Apple’s built-in library for making HTTP calls.
You can get your own API key from OpenAI (developer of ChatGPT) by signing up for an account. Click your profile and View API Keys: API keys — OpenAI API.
Here is my Swift code that calls the API:
let apiKey = "<OPENAI_API_KEY>"
let endpoint = "https://api.openai.com/v1/chat/completions"
let headers = [
"Content-Type": "application/json",
"Authorization": "Bearer \(apiKey)"
]
// Use the URL to instantiate a request
let request = NSMutableURLRequest(url: NSURL(string: endpoint)! as URL)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
let json: [String: Any] = [
"model": "gpt-3.5-turbo",
"messages": [["role": "user", "content": "Say this is a test!"]],
"temperature": 0.7
]
let jsonData = try? JSONSerialization.data(withJSONObject: json)
request.httpBody = jsonData
// Create a URLSession using a shared instance and call its dataTask method
// The data task method attempts to retrieve the contents of a URL based on the specified URL.
// When finished, it calls it's completion handler (closure) passing in optional values for data (the data we want to fetch), response (info about the response like status code) and error (if the request was unsuccessful)
let task = URLSession.shared.dataTask(with: request as URLRequest, completionHandler: { data, response, error in
// Handle any errors
if let error = error {
print("❌ Network error: \(error.localizedDescription)")
}
// Make sure we have data
guard let data = data else {
print("❌ Data is nil")
return
}
// The `JSONSerialization.jsonObject(with: data)` method is a "throwing" function (meaning it can throw an error) so we wrap it in a `do` `catch`
// We cast the resultant returned object to a dictionary with a `String` key, `Any` value pair.
do {
let jsonDictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any]
print(jsonDictionary)
} catch {
print("❌ Error parsing JSON: \(error.localizedDescription)")
}
})
// Initiate the network request
task.resume()
The top section basically reenacts the headers and body of the HTTP request:
let apiKey = "<OPENAI_API_KEY>"
let endpoint = "https://api.openai.com/v1/chat/completions"
let headers = [
"Content-Type": "application/json",
"Authorization": "Bearer \(apiKey)"
]
// Use the URL to instantiate a request
let request = NSMutableURLRequest(url: NSURL(string: endpoint)! as URL)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
let json: [String: Any] = [
"model": "gpt-3.5-turbo",
"messages": [["role": "user", "content": "Say this is a test!"]],
"temperature": 0.7
]
let jsonData = try? JSONSerialization.data(withJSONObject: json)
request.httpBody = jsonData
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "Say this is a test!"}],
"temperature": 0.7
}'
The return value as seen in the console:
Optional(["created": 1682696015, "model": gpt-3.5-turbo-0301, "id": chatcmpl-7AKOFnb5UCVHhB3vIHEjhVAa0hg2u, "choices": <__NSSingleObjectArrayI 0x600003f7d970>(
{
"finish_reason" = stop;
index = 0;
message = {
content = "This is a test!";
role = assistant;
};
}
)
, "usage": {
"completion_tokens" = 5;
"prompt_tokens" = 14;
"total_tokens" = 19;
}, "object": chat.completion])
If I prettify it:
Optional([
"created": 1682696015,
"model": gpt-3.5-turbo-0301,
"id": chatcmpl-7AKOFnb5UCVHhB3vIHEjhVAa0hg2u,
"choices": <__NSSingleObjectArrayI 0x600003f7d970>(
{
"finish_reason" = stop;
index = 0;
message = {
content = "This is a test!";
role = assistant;
};
}
),
"usage": {
"completion_tokens" = 5;
"prompt_tokens" = 14;
"total_tokens" = 19;
},
"object": chat.completion
])
According to the official documentation, the return JSON should look like this:
{
"id":"chatcmpl-abc123",
"object":"chat.completion",
"created":1677858242,
"model":"gpt-3.5-turbo-0301",
"usage":{
"prompt_tokens":13,
"completion_tokens":7,
"total_tokens":20
},
"choices":[
{
"message":{
"role":"assistant",
"content":"\n\nThis is a test!"
},
"finish_reason":"stop",
"index":0
}
]
}
There are some syntactical differences like []
in JSON and <__NSSingleObjectArrayI 0x600003f7d970>()
in Swift. Also, in JSON the response is wrapped in {}
, while the Swift is wrapped in a Optional([])
. However, structurally they are the same, which is as expected.
Decoding the JSON to Swift struct
In order to use the response returned from the API, we must parse the JSON string to a struct object with properties that can be more easily accessed.
struct GPTResponse: Decodable {
let id: String
let object: String
let created: Int
let model: String
let usage: Usage
let choices: [Choice]
}
struct Usage: Decodable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
struct Choice: Decodable {
let message: Message
let finishReason: String
let index: Int
enum CodingKeys: String, CodingKey {
case message
case finishReason = "finish_reason"
case index
}
}
struct Message: Codable {
let role: String
let content: String
}
Here we create a GPTResponse
struct that will be used to map the JSON response returned from the HTTP request. Each of the keys in the JSON response is a stored property of the GPTResponse
struct e.g. id
, object
, choices
, etc. One question is what is the Decodable
keyword and what is the difference versus Codable
.
Codable
is a protocol in Swift that combines the functionality of both Encodable
and Decodable
protocols.
Encodable
allows encoding objects to a format like JSON, while Decodable
allows decoding objects from that format back into an object in memory.
By conforming to the Codable
protocol, you can easily convert Swift objects to and from JSON, plist, or other serialized formats.
Both Codable
and Decodable
can be used to decode objects from a serialized format, but Codable
adds the ability to also encode objects to that format.
In summary, if you only need to decode objects from a serialized format, use Decodable
. If you need to both encode and decode objects, use Codable
.
In this application, we will only need to decode the struct from JSON and not vice versa, so inheriting from the Decodable
protocol is sufficient.
I was confused with this part:
struct Usage: Decodable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
The CodingKeys
as just tripping me up. What are the String, CodingKey
to the right of the enum name? At first glance … is it a dictionary with String
corresponding to promptTokens, completionTokens, totalTokens
? Well, surely that does not make sense. If they were matching anything, it would be the "prompt_tokens", etc.
Is this inheritance, like a class? String
is the raw value type of the CodingKeys
enum. It specifies the type of values that the cases of CodingKeys
can have. CodingKey
is a protocol from the Foundation
framework that defines the requirements for an enumeration that acts as a key for encoding and decoding.
When you specify CodingKey
as a protocol, you are telling Swift that this enum should conform to the CodingKey
protocol, which requires that the enum defines a string-based key for each property that you want to encode or decode.
In the context of the Usage
struct, CodingKeys
defines the mapping between the JSON keys and the Swift properties, and the String
type for the enum cases specifies the type of values that can be used for the JSON keys.
Does the order of String, CodingKey matter?
Yes, the order of String
and CodingKey
does matter. The String
type should always be first and the CodingKey
protocol should be second, separated by a comma. This is because CodingKey
is a protocol that inherits from String
, so the String
type needs to be declared first in order for the compiler to recognize it as a String
type conforming to the CodingKey
protocol.
struct Usage: Decodable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
This is a Swift struct that conforms to the Decodable
protocol, which allows it to be initialized from JSON data. It contains three properties promptTokens
, completionTokens
, and totalTokens
, each of type Int
.
The CodingKeys
enumeration is used to map the property names to the corresponding keys in the JSON data. In this case, the keys in the JSON data have underscores in them, but the property names in Swift use camelCase, so the CodingKeys
enum maps the underscored keys to the camelCase property names.
For example, the key “prompt_tokens” in the JSON data is mapped to the promptTokens
property in Swift. This is done by setting the promptTokens
case of the CodingKeys
enum to the string value "prompt_tokens". The same goes for the other two properties, completionTokens
and totalTokens
.
By providing this CodingKeys
enum, the Swift Decodable
protocol knows how to map the JSON data to the corresponding properties in the Usage
struct.
So isn’t the above equivalent with this?:
struct Usage: Decodable {
let prompt_tokens: Int
let completion_tokens: Int
let total_tokens: Int
}
No, it is not equivalent. The first struct definition uses the CodingKeys
enum to map between the property names in the JSON and the property names in the Swift struct. The CodingKeys
enum tells the JSON decoder to look for the prompt_tokens
key in the JSON and map it to the promptTokens
property in the Swift struct, and so on for the other properties. Both structs “expect the property names in the JSON to match the property names in the Swift struct exactly”. For example, the struct definitions would “expect the JSON to have property names like prompt_tokens
, completion_tokens
, and total_tokens
exactly, without any variations in capitalization or underscores”.
struct Usage: Decodable {
let promptTokens: Int
let completionTokens: Int
let totalTokens: Int
enum CodingKeys: String, CodingKey {
case promptTokens = "prompt_tokens"
case completionTokens = "completion_tokens"
case totalTokens = "total_tokens"
}
}
Does this struct support both promptTokens and prompt_tokens as JSON keys? No, this struct only supports “prompt_tokens” as the JSON key. The CodingKeys
enum provides a mapping between the JSON key names and the corresponding property names in the struct. In this case, the CodingKeys
enum maps the JSON key "prompt_tokens" to the promptTokens
property, so the JSON must have the key "prompt_tokens" in order to be decoded correctly into the Usage
struct. The same applies to completionTokens
and totalTokens
.
So the JSON must have keys with underscores. But the swift object can use camelcase when accessing its properties.
Adding the Loading Icon
This was one of the simpler tasks. First, instantiate the loading icon:
// loading icon
let activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator.center = view.center
activityIndicator.hidesWhenStopped = true
activityIndicator.color = .orange
view.addSubview(activityIndicator)
It is an orange color, positioned in the center, and begins hidden. The idea is that the loading icon is used when a networking request such as an API call take a noticeable amount of time such as more than a second. During the duration when the HTTP request is sent, is conveyed to the API servers, and a response is returned, we will display a loading icon:
The duration is while we wait. So we begin the loading icon animation before we send the HTTP call. And it lasts until we get a response back i.e. go into the completion handler:
// Start animating the activity indicator before the network request starts.
activityIndicator.startAnimating() // <-----
// Create a URLSession using a shared instance and call its dataTask method
// The data task method attempts to retrieve the contents of a URL based on the specified URL asynchronously.
// When finished, it calls it's completion handler (closure) passing in optional values for data (the data we want to fetch), response (info about the response like status code) and error (if the request was unsuccessful)
let task = URLSession.shared.dataTask(with: request as URLRequest, completionHandler: { data, response, error in
// Make sure we have data
guard let data = data else {
print("❌ Data is nil")
return
}
// The `JSONSerialization.jsonObject(with: data)` method is a "throwing" function (meaning it can throw an error) so we wrap it in a `do` `catch`
// We cast the resultant returned object to a dictionary with a `String` key, `Any` value pair.
do {
// Create a JSON Decoder
let decoder = JSONDecoder()
// Use the JSON decoder to try and map the data to our custom model.
// GPTResponse.self is a reference to the type itself, tells the decoder what to map to.
let response = try decoder.decode(GPTResponse.self, from: data)
post.summary = response.choices[0].message.content
// Set the user as the current user
post.user = User.current
// Save post (async)
post.save { [weak self] result in
// Switch to the main thread for any UI updates
DispatchQueue.main.async {
switch result {
case .success(let post):
print("✅ Post Saved! \(post)")
// Get the current user
if var currentUser = User.current {
// Update the `lastPostedDate` property on the user with the current date.
currentUser.lastPostedDate = Date()
// Save updates to the user (async)
currentUser.save { [weak self] result in
switch result {
case .success(let user):
print("✅ User Saved! \(user)")
// Switch to the main thread for any UI updates
DispatchQueue.main.async {
activityIndicator.stopAnimating() // <-----
// Return to previous view controller
self?.navigationController?.popViewController(animated: true)
NotificationCenter.default.post(name: Notification.Name("Go back to the initial screen"), object: nil)
}
case .failure(let error):
self?.showAlert(description: error.localizedDescription)
}
}
}
case .failure(let error):
self?.showAlert(description: error.localizedDescription)
}
}
}
} catch {
print("❌ Error parsing JSON: \(error.localizedDescription)")
}
})
// Initiate the network request
task.resume()
// ############################################################
Loading the API Key Securely
Lastly, we loaded the API key so that it is not hardcoded into the code, exposing it to security risks.
- Create
Keys.plist
in the project root directory
Select Property List
Call it Keys
. The plist
file extension is added automatically.
Add the key/value pair as a new row. I called my key OPENAI_API_KEY
.
Then load the contents of the OPENAI_API_KEY
:
We can then add the apiKey in our HTTP request headers
:
// Create a URL for the request
// In this case, the custom search URL you created in in part 1
let endpoint = "https://api.openai.com/v1/chat/completions"
let headers = [
"Content-Type": "application/json",
"Authorization": "Bearer \(apiKey)"
]
let request = NSMutableURLRequest(url: NSURL(string: endpoint)! as URL)
request.httpMethod = "POST"
request.allHTTPHeaderFields = headers
If configured correctly, the HTTP request will return a JSON value.
Much of this was made possible with help from CodePath and ChatGPT. ChatGPT responses are in italics.