How to handle path operations getting mixed-up in FastAPI?

Viewed 81

I've got two path operation functions that look similar, except that the first one returns all the data for a specific user and the second one only the data of the current (logged in) user (with the schema UserOut, which has fewer fields):

@router.get("/{id}", response_model=User)
async def get_user(user_id: PydanticObjectId):
    user = await User.find_one(User.id == user_id)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user


@router.get("/me")
async def get_current_user(current_user: User = Depends(get_current_active_user)):
    return current_user

The problem is when I call from Postman the second method, it takes the endpoint /me as the id of the first endpoint, so I always get the pydantic validation error that "me" is not a valid user ID.

How could I solve this problem? Do I need to necessarily modify my endpoints or are there any other alternatives?

2 Answers

You can solve this quite easily by switching places of the two path operation functions (i.e., declare /me before /{id}):

@router.get("/me")
async def get_current_user():
    pass
    
@router.get("/{id}")
async def get_user():
    pass

Because path operations are evaluated in order, this makes sure that endpoint /me (which is a fixed path and can be used to get data about the current user) is evaluated first, and if there is not a match, the endpoint /{id} is then evaluated (which is used to get data about a specific user by some user ID).

The fact that both endpoints return the same resource (they both return a user) is an indicator that maybe they should just be 1 endpoint that returns a user specified by an ID, or the current logged-in one by default.

While you can differentiate based on how the endpoints are defined (as given by the other answer), I find that solution to be "brittle", in the sense that you (and other developers maintaining and reading your code) would have to always remember that the order matters, which can easily cause bugs when you (or someone else) reorganize or refactor the code.

So, as an alternative, you can just combine those 2 endpoints into 1 get_user endpoint:

from typing import Optional
from fastapi import Query
from pydantic import BaseModel, NonNegativeInt

class User(BaseModel):
    user_id: NonNegativeInt
    other_field: str = ""

    @classmethod
    async def find_one(cls, user_id: NonNegativeInt) -> "User":
        """This is a fake method that returns a User from somewhere"""
        return cls(user_id=user_id, other_field=str(user_id) * 5)

def get_current_active_user() -> User:
    """This is a fake method that somehow gets the logged-in user"""
    return User(user_id=0, other_field="This is the logged-in user")

@router.get("/user", response_model=User)
async def get_user(user_id: Optional[NonNegativeInt] = Query(default=None)):
    if user_id:
        user = await User.find_one(user_id)
    else:
        user = get_current_active_user()

    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    return user
$ curl -XGET localhost:8000/user
{"user_id":0,"other_field":"This is the logged-in user"}

$ curl -XGET localhost:8000/user?user_id=444
{"user_id":444,"other_field":"This is a different user"}

The advantages of this are, for one, the order doesn't anymore matter, and two, the same set of validations and parameters on the request and the response can be done on the same endpoint function.

Now for the case of having 2 different response models:

... the first one returns all the data for a specific user and the second one only the data of the current (logged in) user (with the schema UserOut, which has less fields)

I don't know the use-case why the response for the logged-in user needs to have less fields, but, check out the section on defining multiple models from the FastAPI tutorials. Basically, you would want a base user model that has the minimum fields (to represent your logged-in user) and a more specific user model with all the other fields to represent the "found" users) Then define your route to be a Union of these 2 different types.

class BaseUser(BaseModel):
    user_id: NonNegativeInt

class LoggedInUser(BaseUser):
    pass

class SomeOtherUser(BaseUser):
    other_field: str

    @classmethod
    async def find_one(cls, user_id: NonNegativeInt) -> "SomeOtherUser":
        """This is a fake method that returns a User from somewhere"""
        return cls(user_id=user_id, other_field="This is a different user")

def get_current_active_user() -> LoggedInUser:
    """This is a fake method that somehow gets the logged-in user"""
    return LoggedInUser(user_id=0)

@router.get("/user", response_model=Union[SomeOtherUser, LoggedInUser])
async def get_user(user_id: Optional[NonNegativeInt] = Query(default=None)):
    if user_id:
        user = await SomeOtherUser.find_one(user_id)
    else:
        user = get_current_active_user()

    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    return user
$ curl -XGET localhost:8000/user
{"user_id":0}

$ curl -XGET localhost:8000/user?user_id=444
{"user_id":444,"other_field":"This is a different user"}
Related