Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Public middleware API #345

Closed
florimondmanca opened this issue Sep 15, 2019 · 13 comments
Closed

Public middleware API #345

florimondmanca opened this issue Sep 15, 2019 · 13 comments
Labels
enhancement New feature or request

Comments

@florimondmanca
Copy link
Member

Problem
We currently have an internal middleware API that was brought in via #295 and #268.

We should be looking at making that API (or a different form of it?) public so that users can extend the functionality of the client to fit their needs.

For example, a retry functionality (discussed in #108 and drafted in #134 then abandoned) could probably be implemented as a middleware (basic idea here: #268 (comment)).

Questions to be answered

  • Is a public middleware API still a good idea at all? (IMO, it is.)
  • We already have a middleware-side interface: request, get_response -> response. What's the client-side API going to look like? Probably something like…
client = httpx.Client()
client.add_middleware(RetryMiddleware, max_retries=3)
  • Should we apply existing internal middleware such as auth and redirect by default?

IMO, yes: treat those as core middleware, and add custom middleware on top. It might depend on the use case, but we can start off with that.

  • Should users be able to we control at what level a new middleware is applied, and how?

@sethmlarson proposed an idea inspired by Pyramid here: #295 (comment)

  • Should middleware be able to store session-wide data, and how?

This was originally mentioned in #295 (comment), and would be used to store state worth remembering such as cookies or Alt-Svc headers for HTTP/3 (see #275 (comment)).

IMO the use case still needs to be refined for us to clearly see what needs to be done.

What needs to be done

This is a proposal. :)

  • Answer the questions above and add the public middleware API.
  • Document how to write middleware (Advanced section?). We learnt by experience (e.g. DigestAuth as middleware - No nonce count #332 (comment)) that middleware instances shouldn't hold any mutable state, so let's make sure we mention that here.
  • Document how to use middleware (Advanced section?).
  • (Later) Figure out middleware ordering.
  • (Later) Figure out client-side storage.

All thoughts welcome!

@kesavkolla
Copy link

Yes this would be a great help. Specially I'm looking for an usecase for supporting OAuth2. I want the middleware ability so that I can add logic for getting OAuth token and attach required bearer header to actual request.

@florimondmanca
Copy link
Member Author

@kesavkolla Actually, the auth parameter also accepts a request -> request callable to modify the request before sending it (this is not yet properly documented, see #343). Without knowing the details of OAuth2, here's a proof-of-concept example:

import httpx

class OAuth2:
    def __init__(self, token: str):
        self.token = token

    def __call__(self, request):
        request.headers["<oauth-header>"] = build_oauth_bearer_header(self.token)
        return request

client = httpx.Client(auth=OAuth2(token="..."))

@kesavkolla
Copy link

It's not just injecting the header. There can be more auth flow that need to be executed. If token is expired need to call an API to refresh token etc... I was looking something similar to https://github.com/requests/requests-oauthlib

@florimondmanca
Copy link
Member Author

florimondmanca commented Sep 16, 2019

Okay so, I took a look at what the OAuth2Session is doing, and I'm not sure the current middleware API enables that use case, and I don't even think it should.

The high-level API of requests-oauthlib requires the user to interact with a special kind of Requests session. But you wouldn't be able to interact with middleware directly, because once registered they're basically buried in the client's middleware stack.

So I think you'd have more luck subclassing Client/AsyncClient directly, or going one level down by subclassing the ConnectionPool dispatcher. (A dispatcher is what an HTTPX client uses to actually send requests.)

An example with ConnectionPool, along with a Starlette-based version of their overview Flask example (obviously not tested):

from httpx import ConnectionPool

class OAuth2Dispatch(ConnectionPool):
    async def request(self, method, url, **kwargs):
        # …Attach header and check for token expiry here…
        return await super().request(method, url, **kwargs)

    # Helper methods for the OAuth2 flow:
    def authorization_url(self, ...): ...
    async def fetch_token(self, ...): ...
from httpx_oauthlib import OAuth2Dispatch

from starlette.applications import Starlette
from starlette.responses import RedirectResponse, JSONResponse
from starlette.middleware.session import SessionMiddleware

app = Starlette()
app.add_middleware(SessionMiddleware)

# This information is obtained upon registration of a new GitHub
client_id = "<your client key>"
client_secret = "<your client secret>"
authorization_base_url = 'https://github.com/login/oauth/authorize'
token_url = 'https://github.com/login/oauth/access_token'

@app.route("/login")
async def login(request):
    github = OAuth2Dispatch(client_id)
    authorization_url, state = github.authorization_url(authorization_base_url)

    # State is used to prevent CSRF, keep this for later.
    request.session['oauth_state'] = state
    return RedirectResponse(url=authorization_url)

@app.route("/callback")
async def callback(request):
    github = OAuth2Dispatch(client_id, state=session['oauth_state'])
    token = await github.fetch_token(token_url, client_secret=client_secret, authorization_response=request.url)

    r = await github.request("GET", "https://api.github.com/user")
    return JSONResponse(r.json())

@lepture
Copy link

lepture commented Oct 9, 2019

@kesavkolla I think what you need is something like https://docs.authlib.org/en/latest/client/httpx.html

Stay tuned, Authlib HTTPX feature is not released yet, it will be released in v0.13.

@florimondmanca
Copy link
Member Author

https://docs.authlib.org/en/latest/client/httpx.html

Looking great @lepture! I see there's also a Starlette integration — wondering how much of it is Starlette vs plain ASGI? — looks like two nice components for implementing complex authentication schemes in async web apps.

If you'd like any feedback on those integrations, feel free to ping @tomchristie, @sethmlarson, @yeraydiazdiaz or myself on a PR or issue. :) I think @tomchristie might be interested in trying out the Starlette integration for HostedAPI? (See tweet)

@lepture
Copy link

lepture commented Oct 9, 2019

@florimondmanca The Starlette integration is not async currently. It was implemented by lepture/authlib#153. I'm working on Starlette integration now to get it async since I've implemented the HTTPX async OAuth clients already.

Basically, web frameworks integrations API are all the same. In your web routes:

def login_via_github(request):
    redirect_uri = 'your-callback-uri'
    return oauth.github.authorize_redirect(request, redirect_uri)

def authorize_github(request)
    token = oauth.github.authorize_access_token(request)

The documentation for Starlette is not updated yet. But basically all framework integrations are the same with a little differences among each of them. So the actually documentation is https://docs.authlib.org/en/latest/client/frameworks.html

@lepture
Copy link

lepture commented Oct 10, 2019

@florimondmanca @tomchristie @sethmlarson

Here is a demo for Starlette Google login with Authlib. It works with Authlib wip-starlette branch.

@lepture
Copy link

lepture commented Oct 22, 2019

wondering how much of it is Starlette vs plain ASGI?

Oh, I just found that I only imported one thing from starlette:

from starlette.responses import RedirectResponse

That means I can actually create a plain ASGI client.

@tomchristie
Copy link
Member

So, I'd still like to see this, yup!

I think we'd go with Client(middleware: typing.Sequence[Middleware] = None).

The middleware ought to wrap around the call into send_single_request, here...

response = await self.send_single_request(

I think a good tack onto a PR for this would be to start with a PR that only supports an optional single item of middleware. We can then update the PR to support multiple middleware items. (I'm keen that we keep a close eye on making sure we're keeping things as clean as possible here, and tackling it in two stages would help us review it as fully as possible.)

This was referenced Nov 27, 2019
@tomchristie tomchristie added the enhancement New feature or request label Dec 5, 2019
@tomchristie
Copy link
Member

This will need some significant design work.

I think we should treat this as out-of-scope at this point in time, in order to get an API-stable 1.0 release squared away.

@florimondmanca
Copy link
Member Author

florimondmanca commented Dec 5, 2019

That's how I'm feeling about this, too. We need to make sure we can safely introduce a middleware/extension API as an addition-only change, but not sure it's 100% necessary for a solid 1.0 release.

@tomchristie
Copy link
Member

Great. Going to close this as out-of-scope for now, for the purposes of good housekeeping.
Yes, we clearly will come back to it, but it's not something we should focus on right now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

4 participants