Adding middleware to an API Client dependency ๐Ÿค”

Hey,

I'm looking for some advice/inspiration on how to add some sort of middleware to our apiClient @Dependency within the TCA app.

We use a token/secret auth system for authenticating our endpoints. If the token and secret become out of sync we need to boot the user out of the app and get them to sign in again.

Because of this I'd like to watch the api requests and be able to push the user out to the login screen if a 403 (or something is received).

Initially I was thinking about having some sort of AsyncStream that I can listen to but that won't as I can't return anything to the request if it fails. It's a one way stream of data.

I was wondering if anyone had implemented anything like this before?

My initial intention was for a top level reducer to be able to subscribe to these updates and switch the app state over if things go wrong.

Hmm... maybe that is still possible?

OK... ignore this...

The error that was occurring was not due to what I was doing. It was due to what I believe is a SwiftUI bug (detailed below).

OK... my approach to the status code 403 stuff works... But I've uncovered what I think is a bug in SwiftUI.

I've created a gist here... SwiftUI bug with TabBars and switching them out for other views ยท GitHub

Code here...

import SwiftUI

struct ContentView: View {
	@State var loggedIn: Bool = false

	var body: some View {
		switch loggedIn {
		case false:
			Button("Login") {
				loggedIn = true
			}
		case true:
			TabView {
				NavigationView {
					Text("Home")
						.navigationBarTitle("Home")
						.onAppear {
							print("๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ")
							print("Home on appear")
							print("๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ")
						}
						.toolbar {
							ToolbarItem(placement: .navigationBarTrailing) {
								Button("Logout") {
									loggedIn = false
								}
							}
						}
				}
				.tabItem {
					Image(systemName: "house")
					Text("Home")
				}

				NavigationView {
					Text("Savings")
						.navigationBarTitle("Savings")
						.onAppear {
							print("๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ")
							print("Savings tab on appear")
							print("๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ")
						}
						.toolbar {
							ToolbarItem(placement: .navigationBarTrailing) {
								Button("Logout") {
									loggedIn = false
								}
							}
						}
				}
				.tabItem {
					Image(systemName: "dollarsign.circle")
					Text("Savings")
				}

				NavigationView {
					Text("Profile")
						.navigationBarTitle("Profile")
						.onAppear {
							print("๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ")
							print("Profile tab on appear")
							print("๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ๐Ÿ")
						}
						.toolbar {
							ToolbarItem(placement: .navigationBarTrailing) {
								Button("Logout") {
									loggedIn = false
								}
							}
						}
				}
				.tabItem {
					Image(systemName: "person")
					Text("Profile")
				}
			}
		}
	}
}

And a little video here... SwiftUI bug with TabView and .onAppear - YouTube

If you put this code into a brand new SwiftUI project and run it.

What you should see is when you tap "Login" you should see "Home on appear" in the console.
And then you move to the Profile tab and you should see "Profile tab on appear" in the console.

Then, when you tap "Logout" you should not expect to see anything in the console.

BUT... we see "Home on appear" in the console again as the TabView is being removed from the stack.

What it looks like is happening is...

When a TabView is removed from the stack like this. Every tab that you have visited in that session that is not the current tab will have the onAppear triggered again.

1 Like

It's actually worse than I thought. It also triggers the .task for the tabs too...

With this code...

import SwiftUI

struct ContentView: View {
	@State var loggedIn: Bool = false

	var body: some View {
		switch loggedIn {
		case false:
			Button("Login") {
				loggedIn = true
			}
			.onAppear {
				print("๐Ÿ Login on appear")
			}
			.onDisappear {
				print("๐ŸŽ Login on disappear")
			}
			.task {
				print("๐ŸŒ Login task")
			}
		case true:
			TabView {
				NavigationView {
					Text("Home")
						.navigationBarTitle("Home")
						.onAppear {
							print("๐Ÿ Home on appear")
						}
						.onDisappear {
							print("๐ŸŽ Home on disappear")
						}
						.task {
							print("๐ŸŒ Home task")
						}
						.toolbar {
							ToolbarItem(placement: .navigationBarTrailing) {
								Button("Logout") {
									loggedIn = false
								}
							}
						}
				}
				.tabItem {
					Image(systemName: "house")
					Text("Home")
				}

				NavigationView {
					Text("Savings")
						.navigationBarTitle("Savings")
						.onAppear {
							print("๐Ÿ Savings on appear")
						}
						.onDisappear {
							print("๐ŸŽ Savings on disappear")
						}
						.task {
							print("๐ŸŒ Savings task")
						}
						.toolbar {
							ToolbarItem(placement: .navigationBarTrailing) {
								Button("Logout") {
									loggedIn = false
								}
							}
						}
				}
				.tabItem {
					Image(systemName: "dollarsign.circle")
					Text("Savings")
				}

				NavigationView {
					Text("Profile")
						.navigationBarTitle("Profile")
						.onAppear {
							print("๐Ÿ Profile on appear")
						}
						.onDisappear {
							print("๐ŸŽ Profile on disappear")
						}
						.task {
							print("๐ŸŒ Profile task")
						}
						.toolbar {
							ToolbarItem(placement: .navigationBarTrailing) {
								Button("Logout") {
									loggedIn = false
								}
							}
						}
				}
				.tabItem {
					Image(systemName: "person")
					Text("Profile")
				}
			}
			.onAppear {
				print("๐Ÿ Tabview on appear")
			}
			.onDisappear {
				print("๐ŸŽ Tabview on disappear")
			}
			.task {
				print("๐ŸŒ Tabview task")
			}
		}
	}
}

With the steps...

  1. Launch
  2. Tap Login
  3. Tap the Savings tab
  4. Tap logout

You get these logs...

  1. :green_apple: Login on appear
  2. :banana: Login task
  3. :green_apple: Tabview on appear
  4. :green_apple: Home on appear
  5. :banana: Tabview task
  6. :banana: Home task
  7. :apple: Login on disappear
  8. :green_apple: Savings on appear
  9. :banana: Savings task
  10. :apple: Home on disappear
  11. :green_apple: Login on appear
  12. :green_apple: Home on appear
  13. :apple: Home on disappear
  14. :apple: Savings on disappear
  15. :apple: Tabview on disappear
  16. :banana: Login task
  17. :banana: Home task

In the logs above... 12, 13, and 17 just shouldn't be there at all.

:man_facepalming:

Did you make any progress on the TCA / Authentication Delegate / APIClient dependency problem? I'm sort of trying to wrap my head around this too.

Seems like I need to be able to inject into my dependency some kind of delegate (or, at a minimum a closure) which in turn, can trigger the Login flow of the app (which is all setup in TCA), and return the credentials, and have the dependency added to the Reducer using .dependency()....

As a workaround: remove "task", trigger any work on "appear" but only if it wasn't followed by an immediate "disappear": in other words on appear spawn a one-shot timer with some 0.1 second timeout and shut this timer down on disappear.

Hey, yes we did.

On the ApiClient I added an addMiddleware function. Middleware has a request and a response function on it. Each of which is called before the request and after the response respectively.

So for instance, our AuthHeaders middleware is defined like...

public extension Middleware {
	static func authHeaders(
		credentialsStore: CredentialsStore,
		date: @escaping () -> Date,
		device: UIDevice,
		appVersion: String
	) -> Middleware {
		let headerStorage = HeaderStorage(
			credentialsStore: credentialsStore,
			date: date,
			device: device,
			appVersion: appVersion
		)

		return .init(
			request: { request in
				await headerStorage.process(request: &request)
			},
			response: { response, request in
				await headerStorage.process(response: &response, request: request)
			}
		)
	}
}

The apiclient then stores the middleware added to it and runs them each time a request happens...

Our ApiClient request looks like this...

func request(route: ServerRoute) async throws -> (Data, URLResponse) {
	var urlRequestData = URLRequestData()

	try router
		.baseURL(baseUrlString)
		.print(route, into: &urlRequestData)

	guard var request = URLRequest(data: urlRequestData) else {
		fatalError()
	}

	for m in middleware {
		await m.request(&request)
	}

	var (data, response) = try await URLSession.shared.data(for: request)

	for m in middleware {
		try await m.response(&response, request)
	}

	return (data, response)
}

Then during start up we add the middleware to the ApiClient...

let apiClient = ApiClient.live(baseUrlString: baseURLString)

apiClient.addMiddleware(.authHeaders(
	credentialsStore: credentialsStore,
	date: Date.init,
	device: .current,
	appVersion: AppVersionInfo.live.fullVersionString()
))
apiClient.addMiddleware(.statusCodes)
apiClient.addMiddleware(.errorCodes)

And then set the dependency on the store... .dependency(\.apiClient, apiClient)

Thanks for getting back to me @Fogmeister,

I have done something similar to this, except that our "credential store" might need to actually show some UI to the user so that they can authenticate. That UI-flow is controlled via a TCA reducer. So I was having some issues getting something in a `@Dependency() be able to talk back to a TCA reducer.

If anyone else is wondering, I solved this problem by making use of an AsyncChannel from swift-async-algorithms. Using a .task { } view modifier, I set my Reducer listening to events from the channel. And my auth delegate sends an event to the channel when it requires login credentials. It then listens on the channel for when the credentials come back.

1 Like

Oh nice, I haven't heard of AsyncChannel before. But from the name it sounds like it's something we're kind of doing with an AsyncStream. I might take a look at that.