8. Define additional mutations
In this section, you'll learn how to build authenticated mutations and handle information returned from those mutations, enabling you to book and cancel trips in your app.
Add authentication handling
Before you can book a trip, you need to be able to pass your authentication token along to the example server. To do that, let's dig a little deeper into how iOS's ApolloClient
works.
The ApolloClient
uses something called a NetworkTransport
under the hood. By default, the client creates a RequestChainNetworkTransport
instance to handle talking over HTTP to your server.
A RequestChain
runs your request through an array of ApolloInterceptor
objects which can mutate the request and/or check the cache before it hits the network, and then do additional work after a response is received from the network.
The RequestChainNetworkTransport
uses an object that conforms to the InterceptorProvider
protocol in order to create that array of interceptors for each operation it executes. There are a couple of providers that are set up by default, which return a fairly standard array of interceptors.
The nice thing is that you can also add your own interceptors to the chain anywhere you need to perform custom actions. In this case, you want to have an interceptor that will add your token.
First, create the new interceptor. Go to File > New > File... and create a new Swift File. Name it TokenAddingInterceptor.swift, and make sure it's added to the RocketReserver target. Open that file, and add the following code:
1import Foundation
2import Apollo
3
4class TokenAddingInterceptor: ApolloInterceptor {
5 func interceptAsync<Operation: GraphQLOperation>(
6 chain: RequestChain,
7 request: HTTPRequest<Operation>,
8 response: HTTPResponse<Operation>?,
9 completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void) {
10
11 // TODO
12 }
13}
Next, import KeychainSwift
at the top of the file so you can access the key you stored in the keychain in the last step of the tutorial:
1import KeychainSwift
Then, replace the TODO
within the interceptAsync
method with code to get the token from the keychain, and add it to your headers if it exists:
1let keychain = KeychainSwift()
2if let token = keychain.get(LoginViewController.loginKeychainKey) {
3 request.addHeader(name: "Authorization", value: token)
4} // else do nothing
5
6chain.proceedAsync(request: request,
7 response: response,
8 completion: completion)
An array of ApolloInterceptor
s which are handed off to each request to perform in order is set up by an object conforming to the InterceptorProvider
protocol. There's a DefaultInterceptorProvider
which has an array with most of the Interceptors you'd want to use.
You can also make your own object conforming to InterceptorProvider
- or, in this case, since the interceptor only needs to be added to the beginning of the list to run before all the other interceptors, you can subclass the existing DefaultInterceptorProvider
.
Go to File > New > File... and create a new Swift File. Name it NetworkInterceptorProvider.swift, and make sure it's added to the RocketReserver target. Add code which inserts your TokenAddingInterceptor
before the other interceptors provided by the DefaultInterceptorProvider
:
1import Foundation
2import Apollo
3
4class NetworkInterceptorProvider: DefaultInterceptorProvider {
5 override func interceptors<Operation: GraphQLOperation>(for operation: Operation) -> [ApolloInterceptor] {
6 var interceptors = super.interceptors(for: operation)
7 interceptors.insert(TokenAddingInterceptor(), at: 0)
8 return interceptors
9 }
10}
Another way to do this would be to copy and paste the list interceptors provided by the
DefaultInterceptorProvider
(which are all public), and then place your interceptors in the points in the array where you want them. However, since in this case we can run this interceptor first, it's simpler to subclass.
Next, go back to your Network
class. Replace the ApolloClient
with an updated lazy var
which creates the RequestChainNetworkTransport
manually, using your custom interceptor provider:
1class Network {
2 static let shared = Network()
3
4 private(set) lazy var apollo: ApolloClient = {
5 let client = URLSessionClient()
6 let cache = InMemoryNormalizedCache()
7 let store = ApolloStore(cache: cache)
8 let provider = NetworkInterceptorProvider(client: client, store: store)
9 let url = URL(string: "https://apollo-fullstack-tutorial.herokuapp.com/graphql")!
10 let transport = RequestChainNetworkTransport(interceptorProvider: provider,
11 endpointURL: url)
12 return ApolloClient(networkTransport: transport, store: store)
13 }()
14}
Now, go back to TokenAddingInterceptor.swift.
Click on the line numbers to add a breakpoint at the line where you're instantiating the Keychain
:
Build and run the application. Whenever a network request goes out, that breakpoint should now get hit. If you're logged in, your token will be sent to the server whenever you make a request!
Add the BookTrip
mutation
In Sandbox, open the Schema tab by clicking its icon, select the Mutations
, and take a look at the bookTrips
mutation:
Click the play button to the right to open this mutation in Explorer. Click the plus button to add the bookTrips
mutation:
You can see in the left sidebar that this takes an argument of an array of IDs (which was added as $bookTripsLaunchIds
), and the object returned from the operation has three properties:
A
success
boolean indicating whether the booking succeededA
message
string to display to the userA list of
launches
the current user has booked
Click the plus signs next to success
and message
to add those to your operation.
In the "Variables" section of Sandbox Explorer, add an array of identifiers. In this case, we'll use a single identifier to book one trip:
1{"bookTripsLaunchIds": ["25"]}
Next, directly next to the word "Variables", you'll see the word "Headers". Click that to bring up the headers section. Click the "New Header" button, and add "Authorization" in the header key text box and paste the token you got back in the last section for the value:
Now, click the Submit Operation button to run your authorized query. You'll get back information regarding the trips (or in this case, trip) you've just booked.
Note: If you receive an error that says "Cannot read property 'id' of null", that means your user was not found based on the token you passed through. Make sure your authorization header is properly formatted and that you're actually logged in!
With a mutation written like this, you can book any number of trips you want at the same time. However, the booking mechanism in our application will only let you book one trip at a time.
Luckily, there's an easy way to update the mutation so it's required to only take a single object. First, update the name of your operation in Explorer to the singular BookTrip
(and remove Mutation
since that will be added for us by code generation). Next, update the mutationto take a single $id
, then pass an array containing that $id
to the bookTrips
mutation:
1mutation BookTrip($id:ID!) {
2 bookTrips(launchIds:[$id]) {
3 success
4 message
5 }
6}
This is helpful because the Swift code generation will now generate a method that only accepts a single ID
instead of an array, but you'll still be calling the same mutation under the hood, without the backend needing to change anything.
In the Variables section of Sandbox Explorer, update the JSON dictionary to use id
as the key, and remove the array brackets from around the identifier:
1{"id": "25"}
Click the Submit Operation button to run your updated query. The response you get back should be identical to the one you got earlier:
Now that you've fleshed out your operation, it's time to put it into the app. Go to File > New > File... > Empty, and name this file BookTrip.graphql
. Paste in the final query from the Sandbox Explorer.
Build the application to run the code generation. Then, in DetailViewController.swift
, fill in the bookTrip
method with the code to book your trip based on the flight's ID:
1private func bookTrip(with id: GraphQLID) {
2 Network.shared.apollo.perform(mutation: BookTripMutation(id: id)) { [weak self] result in
3 guard let self = self else {
4 return
5 }
6 switch result {
7 case .success(let graphQLResult):
8 if let bookingResult = graphQLResult.data?.bookTrips {
9 // TODO
10 }
11
12 if let errors = graphQLResult.errors {
13 // From UIViewController+Alert.swift
14 self.showAlertForErrors(errors)
15 }
16 case .failure(let error):
17 self.showAlert(title: "Network Error",
18 message: error.localizedDescription)
19 }
20 }
21}
Then, update the cancelTrip
method print the ID of the flight being cancelled (you'll be adding the actual cancellation in the next step):
1private func cancelTrip(with id: GraphQLID) {
2 print("Cancel trip \(id)")
3 // TODO: Add code to cancel trip
4}
Next, update the bookOrCancelTapped
method to use the two methods you've just added instead of print
ing:
1if launch.isBooked {
2 self.cancelTrip(with: launch.id)
3} else {
4 self.bookTrip(with: launch.id)
5}
In bookTrip
, replace the TODO
with code to handle what comes back in the success
property:
1if bookingResult.success {
2 self.showAlert(title: "Success!",
3 message: bookingResult.message ?? "Trip booked successfully")
4} else {
5 self.showAlert(title: "Could not book trip",
6 message: bookingResult.message ?? "Unknown failure.")
7}
You've now got the code to book a trip. Before you run it, let's add the code to cancel a trip as well.
Add the CancelTrip
mutation
The process for the CancelTrip
mutation is similar to the one for BookTrip
. Go back to the Sandbox's Schema tab, select Mutations,e and look at the cancelTrip
mutation's documentation:
Click the play button to the right to open this operation in Explorer, add a new tab to Explorer for this new operation, then click the plus button to create your operation:
Check off success
and message
again to add those properties to the list of ones you want to get back with your cancellation information.
Again, Explorer's gotten a little verbose here, so update your operation's name and variables to be a little shorter:
1mutation CancelTrip($id: ID!) {
2 cancelTrip(launchId: $id) {
3 success
4 message
5 }
6}
One key difference from bookTrips
is that you're only allowed to cancel one trip at a time because only one ID!
is accepted as a parameter.
In the Variables section of Sandbox Explorer, you can use the exact same JSON that you used for BookTrip
(because it also used a single identifier called "id"):
1{"id": "25"}
Make sure that in the Headers section, you add your authorization token again (the token added to the tab with BookTrip
won't carry over to this new tab):
Click the Submit Operation button to cancel the trip, and you should see a successful request:
It works! Once again, go back to Xcode and create a new empty file, and name it CancelTrip.graphql
. Paste in the final query from Sandbox Explorer. Build the application without running it to cause the code generation to see this new mutation and generate code for it.
Next, go to the cancelTrip(with id:)
method in DetailViewController.swift
. Replace the print
statement with code that makes the call to cancel the trip:
1Network.shared.apollo.perform(mutation: CancelTripMutation(id: id)) { [weak self] result in
2 guard let self = self else {
3 return
4 }
5 switch result {
6 case .success(let graphQLResult):
7 if let cancelResult = graphQLResult.data?.cancelTrip {
8 if cancelResult.success {
9 // TODO
10 }
11 }
12
13 if let errors = graphQLResult.errors {
14 // From UIViewController+Alert.swift
15 self.showAlertForErrors(errors)
16 }
17 case .failure(let error):
18 self.showAlert(title: "Network Error",
19 message: error.localizedDescription)
20 }
21}
In cancelTrip(with id:)
, replace the TODO
with code to handle what comes back in that mutation's success
property:
1if cancelResult.success {
2 self.showAlert(title: "Trip cancelled",
3 message: cancelResult.message ?? "Your trip has been officially cancelled.")
4} else {
5 self.showAlert(title: "Could not cancel trip",
6 message: cancelResult.message ?? "Unknown failure.")
7}
Build and run the application. Select any launch and try to book it. You'll get a success message, but you'll notice that the UI doesn't update, even if you go out of the detail view and back into it again.
Why is that? Because the trip you've got stored locally in your cache still has the old value for isBooked
.
There are a number of ways to change this, a couple of which you'll learn in the next section. For now we'll focus on the one that requires the fewest changes to your code: re-fetching the booking info from the network.
Force a fetch from the network
The fetch
method of ApolloClient
provides defaults for most of its parameters, so if you're using the default configuration, the only value you need to provide yourself is the Query
.
However, an important parameter to be aware of is the cachePolicy
. By default, this has the value of returnCacheDataElseFetch
, which does essentially what it says on the label: it looks in the current cache (by default an in-memory cache) for data, and fetches it from the network if it's not present.
If the data is present, the default behavior is to return the local copy to prevent an unnecessary network fetch. However, this is sometimes not the desired behavior (especially after executing a mutation).
There are several different cache policies available to you, but the easiest way to absolutely force a refresh from the network that still updates the cache is to use fetchIgnoringCacheData
. This policy bypasses the cache when going to the network, but it also stores the results of the fetch in the cache for future use.
Update the loadLaunchDetails
method to take a parameter to determine if it should force reload. If it should force reload, update the cache policy from the default .returnCacheDataElseFetch
, which will return data from the cache if it exists, to .fetchIgnoringCacheData
:
1private func loadLaunchDetails(forceReload: Bool = false) {
2 guard
3 let launchID = self.launchID,
4 (forceReload || launchID != self.launch?.id) else {
5 // This is the launch we're already displaying, or the ID is nil.
6 return
7 }
8
9 let cachePolicy: CachePolicy
10 if forceReload {
11 cachePolicy = .fetchIgnoringCacheData
12 } else {
13 cachePolicy = .returnCacheDataElseFetch
14 }
15
16 Network.shared.apollo.fetch(query: LaunchDetailsQuery(launchId: launchID), cachePolicy: cachePolicy) { [weak self] result in
17 // (Handling of the network call's completion remains the same)
18 }
19}
Next, add the following line to both the bookingResult.success
and cancelResult.success
branches in their respective methods before showing the alerts:
1self.loadLaunchDetails(forceReload: true)
Run the application. When you book or cancel a trip, the application will fetch the updated state and update the UI with the correct state. When you go out and back in, the cache will be updated with the most recent state, and the most recent state will display.
In the next section, you'll learn how to use subscriptions with the Apollo client.