Changing the client configuration #7
Replies: 2 comments 7 replies
-
Just playing around with the ideas of implementing API client authentication using routing. Given my question above I'm unsure about the ergonomics of the final result though. Firstly we could define some kind of wrapper for our route that layers on authentication: enum Authorization {
case bearer(String)
}
struct Authenticated<R> {
let authorization: Authorization
let route: R
} In this example, we define an enum type representing possible "Authorisation" header values - in this example we just have a bearer token - we could extend this to support other types. We can define a parser that wraps a parser for our main route extension Authorization {
static let parser = OneOf {
Parse(.case(AuthenticationMethod.bearer)) {
"Bearer "
Parse(.string)
}
}
}
extension Authenticated {
static func parser<RouteParser: ParserPrinter>(
authenticating routeParser: RouteParser
) -> AnyParserPrinter<URLRequestData, Self>
where
RouteParser.Input == URLRequestData,
RouteParser.Output == R
{
Route(.memberwise(Authenticated<R>.init)) {
Headers {
Field("Authorization") {
Authorization.parser
}
}
routeParser
}
.eraseToAnyParserPrinter()
}
}
extension ParserPrinter where Input == URLRequestData {
/// A convenience method that turns an existing router into an authenticated router.
func authenticated() -> AnyParserPrinter<URLRequestData, Authenticated<Output>> {
Authenticated.parser(authenticating: self)
}
} With this in place, we can now create a client with authorization support: enum SiteRoute {
case example
}
extension SiteRoute {
static let router = OneOf {
// ...
}
}
let client = URLRoutingClient.live(router: SiteRoute.router.authenticated()) Now we can perform authenticated requests: try await client.request(.init(authorization: .bearer("token"), route: .example)) This isn't bad but it would be annoying to have to create a full authenticated route with the token each time. We can create a helper that turns a client of authenticated routes into a client of just routes that wraps this behaviour up for us: extension URLRoutingClient {
func authenticated<R>(
using authorization: Authorization
) -> URLRoutingClient<R> where Route == Authenticated<R> {
.init {
try await self.request(.init(authorization: authorization, route: $0) )
}
}
} Now we can easily make authenticated requests: let authedClient = client.authenticated(using: .bearer("token"))
try await authedClient.request(.example) In terms of ergonomics, I try to imagine how I might use this in say, a TCA app. My app can switch between a logged out and a logged in state. There are some routes that I need to make without an authorization header (e.g. the login routes) so I need an unauthenticated client in my root environment and once the user is logged into the app, I need to pass around an authenticated client. I might also need to periodically refresh an access token and update the client but I can't think how I'd do that with the above API. Anyone got any thoughts? It sounds like I need some kind of intermediate layer that manages the base API client and current authentication state so it can update the authenticated client when the authentication state changes. |
Beta Was this translation helpful? Give feedback.
-
I've been thinking about this some more and I'm thinking that the concept of an "authorized" route should form a core part of your route modelling. For example, given an API with a enum SiteRoute {
case login(Credentials)
case about
case account(Authorized<AccountRoute>)
}
enum AccountRoute {
case settings
case changePassword(ChangePasswordRequest)
} I'm repurposing the extension Authorized {
static func parser<RouteParser: ParserPrinter>(
@ParserBuilder _ routes: () -> RouteParser
) -> AnyParserPrinter<URLRequestData, Self>
where
RouteParser.Input == URLRequestData,
RouteParser.Output == R
{
Route(.memberwise(Authorized<R>.init)) {
Headers {
Field("Authorization") {
Authorization.parser
}
}
routeParser()
}
.eraseToAnyParserPrinter()
}
} We can now define our site router with the concept of authorization baked right in: extension SiteRoute {
let parser = OneOf {
Route(.case(SiteRoute.login)) {
Path { "login" }
Method.post
Body(.json(Credentials.self))
}
Route(.case(SiteRoute.about)) {
Path { "about" }
}
Route(.case(SiteRoute.account)) {
Authorized.parser {
Path { "account" }
OneOf {
Route(.case(AccountRoute.settings)) {
Path { "settings" }
}
Route(.case(AccountRoute.changePassword)) {
Path { "password" }
Method.put
Body(.json(ChangePasswordRequest.self))
}
}
}
}
}
} With this approach, the main API client becomes: let client = URLRoutingClient<SiteRoute>.live(router: SiteRoute.router)
// We can make a public request
try await client.request(.about) // OK
// We can login to get an auth token
let result = try await client.request(.login(.init(email: "[email protected]", password: "password")), as: AuthResponse.self)
// Now we can make authorized requests
let authorization = Authorization.bearer(result.response.token)
let settings = try await client.request(.account(.init(authorization: authorization, route: .settings)))
// We can make this a bit easier by creating a pre-authorized "account" client.
// Could some kind of general pullback/scope function make this easier?
extension URLRoutingClient where Route == SiteRoute {
/// Returns a client scoped to making authorized account requests.
func account(token: String) -> URLRoutingClient<AccountRoute> {
.init { self.request(.account(.init(authorization: .bearer(token), route: $0))) }
}
}
let accountClient = client.account(token: result.response.token)
// Now we can request account routes easily
let settings = try await accountClient.request(.settings) I like that this idea can be extended quite easily as you can use the enum SiteRoute {
case foo(Authorized<FooRoute>)
case bar(Authorized<BarRoute>)
} Or you could bundle up all of your authorized routes together: enum SiteRoute {
case public(PublicRoute)
case private(Authorized<PrivateRoute>)
}
enum PublicRoute {
// ...
}
enum PrivateRoute {
// ...
} |
Beta Was this translation helpful? Give feedback.
-
I'm curious how you would change the base configuration of the
URLRoutingClient
after you've created it with the current design.The router has APIs for setting the base URL and base URL request data - the latter is useful for setting things like default headers. How would you deal with something that can change throughout the course of an app's lifetime like authentication? How would you set custom headers on the client itself as there is no API to manipulate the router after its constructed. Would you just create a new client?
Beta Was this translation helpful? Give feedback.
All reactions