cercide / fastapi-xml

adds xml support to fastapi
MIT License
13 stars 3 forks source link

Support for pydantic_xml #16

Open skewty opened 2 weeks ago

skewty commented 2 weeks ago

I got it working with minimal changes to your code:

response.py

from pydantic_xml import BaseXmlModel, element

class XmlResponse(JSONResponse):
    def render(self, content: Any) -> bytes:
        if isinstance(content, BaseXmlModel):
            xml = content.to_xml()
            return xml if isinstance(xml, bytes) else xml.encode("utf-8")
        serializer = self.get_serializer()
        return serializer.render(content, ns_map=NS_MAP).encode("utf-8")

route.py

from pydantic import ValidationError
from pydantic_xml import BaseXmlModel

class XmlRoute(APIRoute):
    @staticmethod
    async def _request_handler(...) -> Response:
        body: Any = None
        if body_field:
            body_bytes = await request.body()
            if issubclass(body_field.type_, BaseXmlModel):
                try:
                    body = body_field.type_.from_xml(body_bytes)
                except ValidationError as e:
                    raise HTTPException(status_code=400, detail=str(e))
                except Exception as e:
                    raise HTTPException(
                        status_code=400, detail="There was an error parsing the body"
                    )
            else: ...

Sample App

from typing import Annotated
import fastapi, uvicorn
from annotated_types import MinLen
from fastapi_xml import add_openapi_extension
from fastapi_xml import XmlBody, XmlRoute, XmlAppResponse
from pydantic_xml import BaseXmlModel

class SampleRequest(BaseXmlModel):
    Message: Annotated[str, MinLen(1), element()]

class SampleResponse(BaseXmlModel):
    Message: Annotated[str, MinLen(1), element()]

app = fastapi.FastAPI(title="FastAPI::XML", default_response_class=XmlAppResponse)
app.router.route_class = XmlRoute
add_openapi_extension(app)

@app.post("/notification")
def post_event(data: SampleRequest = XmlBody()) -> SampleResponse:
    return SampleResponse(Message=data.Message + " response")

uvicorn.run(app, host="0.0.0.0", port=8000)

WARNING: when tag names are specified in pydantic_xml.element they do not seem to get detected / used. I worked around this by not using PEP8 compliant snake_case named fields in my pydantic_xml model definitions. Not sure how hard this would be to implement.

skewty commented 2 weeks ago

I have been playing around with this and some schema options are not fully / properly supported (expected).

from typing import Annotated
import fastapi, uvicorn
from annotated_types import MinLen
from fastapi_xml import add_openapi_extension
from fastapi_xml import XmlBody, XmlRoute, XmlAppResponse
from pydantic_xml import BaseXmlModel, element

class RequestA(BaseXmlModel):
    FieldA: Annotated[str, MinLen(1), element()]

class RequestB(BaseXmlModel):
    FieldB: Annotated[str, MinLen(1), element()]

class SampleResponse(BaseXmlModel):
    Result: Annotated[str, MinLen(1), element()]

app = fastapi.FastAPI(title="FastAPI::XML", default_response_class=XmlAppResponse)
app.router.route_class = XmlRoute
add_openapi_extension(app)

RequestAorB = RequestA | RequestB

@app.post("/notification")
def post_event(data: RequestAorB = XmlBody()) -> SampleResponse:
    return SampleResponse(Result="Success")

uvicorn.run(app, host="0.0.0.0", port=8000)

Notice that using union on the request type causes the base tag to be lost. We get <notagname> instead.

image

But the understanding of anyOf is still there.

image

skewty commented 2 weeks ago

Is there any interest in this feature? Pydantic is a core part of FastAPI so this seems to be a good match for a project with this name.

skewty commented 2 weeks ago

It would also be nice to be able to include some ValidationError handling that returns valid XML instead of JSON. This gets pretty close although I am still plagued by not having tag name come through as reported in OP. There are some work arounds but it would be better to solve the issue.

from fastapi_xml import XmlAppResponse, XmlBody, XmlRoute
from pydantic import ConfigDict, ValidationError
from pydantic_xml import BaseXmlModel, element

class ValidationErrorError(BaseXmlModel, tag="Error"):
    model_config = ConfigDict(populate_by_name=True)
    type: Annotated[str, element(tag="Type", examples=["string_too_short"])]
    loc: Annotated[tuple[str, ...], element(tag="Location", examples=["FieldA"])]
    msg: Annotated[
        str,
        element(tag="Message", examples=["String should have at least 5 characters"]),
    ]
    input: Annotated[str, element(tag="Input", examples=["test"])]
    url: Annotated[
        str,
        element(
            tag="URL", examples=["https://errors.pydantic.dev/2.9/v/string_too_short"]
        ),
    ]

class ValidationErrorDetail(BaseXmlModel, tag="Detail"):
    model_config = ConfigDict(populate_by_name=True)
    error: Annotated[tuple[ValidationErrorError, ...], element(tag="Error")]

@app.exception_handler(ValidationError)
async def validation_error_handler(
    _: fastapi.Request, ex: ValidationError
) -> fastapi.Response:
    data = {"error": ex.errors()}
    model = ValidationErrorDetail.model_validate(data)
    xml = model.to_xml(xml_declaration=True, encoding="UTF-8")
    # xml = xmltodict.unparse({"detail": {"error": ex.errors()}})
    return XmlAppResponse(
        status_code=fastapi.status.HTTP_422_UNPROCESSABLE_ENTITY, content=xml
    )

router = fastapi.APIRouter(
    route_class=XmlRoute,
    default_response_class=XmlAppResponse,
    responses={
        # https://fastapi.tiangolo.com/advanced/additional-responses/
        422: {"description": "Validation Error", "model": ValidationErrorDetail}
    },
)

gives image

but as above

image

skewty commented 2 weeks ago

I would be willing to help with refactoring the existing code to support both code paths. I think 1st step would be to change @staticmethod[^footnote] to @classmethod so a subclass can actually change how it's parent does things. I wasn't able to alter your code easily without copy pasting massive portions due to this decision. I basically have to fork.

[^footnote]: Guido van Rossum, the creator of Python, has expressed regret over the introduction of static methods in Python. In a 2005 blog post, he wrote that static methods were an “accident” and that he would have done things differently if he had the chance.

cercide commented 2 weeks ago

Hey @skewty,

Thank you for your interest in this project! Any help is greatly appreciated. Feel free to fork the project and submit a pull request if you'd like to contribute.

pydantic_xml

I've been searching for a proper solution that supports Pydantic's BaseModel for quite some time, but I haven't yet found the perfect fit. On one hand, there's xsdata-pydantic, which extends | xsdata with Pydantic. On the other hand, there's pydantic_xml, which extends Pydantic’s BaseModel.

At this point, neither library offers an ideal solution. Therefore, it might be wiser to integrate pydantic_xml in a more flexible way, so that those who prefer to use xsdata don’t need pydantic_xml.

To achieve this, I suggest implementing a dedicated Router and Response class for pydantic_xml in a separate module. This way, users who don’t want to use pydantic_xml won’t need to install it as an optional dependency.

Error Response

This is a great suggestion, and I'm excited to see it in the project! I’ve mentioned it in previous issues as well. However, this topic is unrelated to the current issue. Please open a separate issue for further discussion, as it's off-topic here.