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

feat(BA-572): Add pydantic-only API hander decorator #3511

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

seedspirit
Copy link

@seedspirit seedspirit commented Jan 21, 2025

resolves #3512 (BA-572)

Description

Implement Pydantic api handler decorator to handle all request components (body, query params, path variables) through Pydantic models instead of web.Request

Implementation

1. Request Body:
    @pydantic_api_handler
    async def handler(body: BodyParam[UserModel]):  # UserModel is a Pydantic model
        user = body.parsed                          # 'parsed' property gets pydantic model you defined
        return BaseResponse(data=YourResponseModel(user=user.id))

2. Query Parameters:
    @pydantic_api_handler
    async def handler(query: QueryParam[QueryPathModel]):
        parsed_query = query.parsed
        return BaseResponse(data=YourResponseModel(search=parsed_query.query))

3. Headers:
    @pydantic_api_handler
    async def handler(headers: HeaderParam[HeaderModel]):
        parsed_header = headers.parsed
        return BaseResponse(data=YourResponseModel(data=parsed_header.token))

4. Path Parameters:
    @pydantic_api_handler
    async def handler(path: PathModel = PathParam(PathModel)):
        parsed_path = path.parsed
        return BaseResponse(data=YourResponseModel(path=parsed_path))

To use MiddlewareParam

  1. Define a class that inherits from MiddlewareParam
  2. Implement the from_request class method to extract required data from the request object
5. Middleware Parameters:
    # Need to extend MiddlewareParam and implement 'from_request'
    class AuthMiddlewareParam(MiddlewareParam):
        user_id: str
        user_email: str
        @classmethod
        def from_request(cls, request: web.Request) -> Self:
            # Extract and validate data from request
            user_id = request["user"]["uuid"]
            user_email = request["user"]["email"]
            return cls(user_id=user_id)

    @pydantic_api_handler
    async def handler(auth: AuthMiddlewareParam):  # No generic, so no need to call 'parsed'
        return BaseResponse(data=YourResponseModel(author_name=auth.name))

Multiple Parameters

    @pydantic_api_handler
    async def handler(
        user: BodyParam[UserModel],  # body
        query: QueryParam[QueryModel],  # query parameters
        headers: HeaderParam[HeaderModel],  # headers
        auth: AuthMiddleware,  # middleware parameter
    ):
        return BaseResponse(data=YourResponseModel(
                user=user.parsed.user_id,
                query=query.parsed.page,
                headers=headers.parsed.auth,
                user_id=auth.user_id
            )
        )

Test

  • Test code with pytest aiohttp plugins which provides fixtures for aiohttp test server and client creation
  • Tested BodyParam, QueryParam, HeaderParam, PathParam, MiddlewareParam work, and raise exception if invalid input are in

Checklist: (if applicable)

  • Milestone metadata specifying the target backport version
  • Mention to the original issue
  • Installer updates including:
    • Fixtures for db schema changes
    • New mandatory config options
  • Update of end-to-end CLI integration tests in ai.backend.test
  • API server-client counterparts (e.g., manager API -> client SDK)
  • Test case(s) to:
    • Demonstrate the difference of before/after
    • Demonstrate the flow of abstract/conceptual models with a concrete implementation
  • Documentation
    • Contents in the docs directory
    • docstrings in public interfaces and type annotations

📚 Documentation preview 📚: https://sorna--3511.org.readthedocs.build/en/3511/


📚 Documentation preview 📚: https://sorna-ko--3511.org.readthedocs.build/ko/3511/

@github-actions github-actions bot added comp:manager Related to Manager component size:L 100~500 LoC labels Jan 21, 2025
@seedspirit seedspirit changed the title feat(BA-572): Add pydantic-only API hander decorator in manager feat(BA-572): Add pydantic-only API hander decorator Jan 21, 2025
@github-actions github-actions bot added the area:docs Documentations label Jan 21, 2025
@github-actions github-actions bot added comp:common Related to Common component size:XL 500~ LoC and removed size:L 100~500 LoC labels Jan 23, 2025
@seedspirit seedspirit marked this pull request as ready for review January 23, 2025 10:45
Comment on lines +118 to +122
class BackendError(web.HTTPError):
"""
An RFC-7807 error class as a drop-in replacement of the original
aiohttp.web.HTTPError subclasses.
"""
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, BackendError wasn’t in common package…
For now, I’ll apply it this way, and I’ll create a separate issue to organize exceptions and refactor later.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll keep it in mind until the next refactoring

Comment on lines 116 to 121
origin_name = get_origin(param_type).__name__
pydantic_model = get_args(param_type)[0]
param_instance = param_type(pydantic_model)

match origin_name:
case "BodyParam":
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it difficult to use type information instead of origin_name?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HyeockJinKim
I think it can be achieved by if origin_type is BodyParam. However, since it is not recommended to use is to compare between classes, how about make enum, register it as a class variable and compare them?

class _ParamType(Enum):
    BODY = auto()
    QUERY = auto()
    HEADER = auto()
    PATH = auto()
    MIDDLEWARE = auto()

class BodyParam(Generic[T]):
    _param_type: _ParamType = _ParamType.BODY
    
 
async def _extract_param_value(
    request: web.Request, parsed_signature: _ParsedSignature
) -> Optional[Any]:
    try:
        input_param_type = parsed_signature.input_param_type

        origin_type = get_origin(input_param_type)

        if not hasattr(origin_type, "_param_type"):
            raise UnknownRequestParamType

        match origin_type._param_type:
            case _ParamType.BODY:
                return param_instance.from_body(body)
            case _ParamType.QUERY:
                return param_instance.from_query(request.query)
            case _ParamType.HEADER:
                return param_instance.from_header(request.headers)
            case _ParamType.PATH:
                return param_instance.from_path(request.match_info)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think using a match case would make it easier to distinguish types. Why is it necessary to introduce an enum?
for example:

def match_case_example(param):
    match param:
        case BodyParam():
            param.from_body()
        case QueryParam():
            param.from_query()
        case PathParam():
            param.from_path()
        case HeaderParam():
            param.from_header()

Comment on lines 163 to 190
async def pydantic_handler(request: web.Request, handler) -> web.Response:
signature = inspect.signature(handler)
handler_params = _HandlerParameters()
for name, param in signature.parameters.items():
# Raise error when parameter has no type hint or not wrapped by 'Annotated'
if param.annotation is inspect.Parameter.empty:
raise InvalidAPIParameters(
f"Type hint or Annotated must be added in API handler signature: {param.name}"
)

parsed_signature = _ParsedSignature(name=name, param_type=param.annotation)
value = await extract_param_value(request=request, parsed_signature=parsed_signature)

if not value:
raise InvalidAPIParameters(
f"Type hint or Annotated must be added in API handler signature: {param.name}"
)

handler_params.add(name, value)

response = await handler(**handler_params.get_all())

if not isinstance(response, BaseResponse):
raise InvalidAPIParameters(
f"Only Response wrapped by BaseResponse Class can be handle: {type(response)}"
)

return web.json_response(response.data.model_dump(mode="json"), status=response.status_code)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn’t it be a private function?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I will change function name

Comment on lines 109 to 114
class UserPathModel(BaseModel):
user_id: str


class UserPathResponse(BaseModel):
user_id: str
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make it clear in the naming that the mocked request and response are for testing purposes.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add prefix 'Test' in request and response models

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area:docs Documentations comp:common Related to Common component comp:manager Related to Manager component size:XL 500~ LoC
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Implement Pydantic Handling Decorator for Request/Response Validation for new VFolder API
2 participants