dapper91 / pydantic-xml

python xml for humans
https://pydantic-xml.readthedocs.io
The Unlicense
141 stars 14 forks source link

Using directly with FastAPI #149

Closed elijahsgh closed 6 months ago

elijahsgh commented 6 months ago

I stumbled onto pydantic xml and it's been fantastic. Thanks!

I was was wanting to use it directly with FastAPI and had some feedback and questions.

In FastAPI, the expectation is you can use a model directly with the endpoints. Here is some example code that doesn't work but I think it should:

@app.post('/sub', response_class=PlainTextResponse)
async def sub(
    xml_body: MyModel
):

This doesn't seem to work because MyModel() can't be initialized with values.

This works perfectly:

@app.post('/sub', response_class=PlainTextResponse)
async def sub(
    xml_body: Annotated[str, Body(..., media_type='text/plain')]
):
    feed = MyModel.from_xml(xml_body)

My question is why can't I instantiate from XML and why do I have to call Model.from_xml? Did I miss something in the docs? Is there a different class for this?

dapper91 commented 6 months ago

@elijahsgh Hi,

Unfortunately FastAPI intentionally focused on json so that right now there is no easy way to integrate it with other content types like xml. The simplest solution I can see to get rid of boilerplate code is to add a validator for the body argument:

import functools as ft
from typing import Annotated, Any, Type, TypeVar
from xml.etree import ElementTree as etree

from fastapi import Body
from pydantic import BeforeValidator
from pydantic_xml import BaseXmlModel

XmlModelT = TypeVar('XmlModelT', bound=BaseXmlModel)

def validate_xml_model(model_type: Type[XmlModelT], value: bytes | dict) -> XmlModelT:
    if isinstance(value, bytes):
        try:
            return model_type.from_xml(value)
        except etree.ParseError as e:
            raise ValueError(f"invalid xml: {e}")
    else:
        return model_type.model_validate(value)

def XmlBody(model_type: Type[XmlModelT], **kwargs: Any) -> Type[XmlModelT]:
    return Annotated[
        model_type,
        Body(media_type='application/xml', **kwargs),
        BeforeValidator(ft.partial(validate_xml_model, model_type))
    ]

class MyModel(BaseXmlModel):
    ...

@app.post('/sub', response_class=PlainTextResponse)
async def sub(
    xml_body: XmlBody(MyModel)
):
    assert isinstance(xml_body, MyModel)
    ...
elijahsgh commented 6 months ago

Here's what I ended up with.

async def initxmlfrombody(request: Request) -> AtomFeed:
    body = await request.body()
    return AtomFeed.from_xml(body)

@app.post('/sub', response_class=PlainTextResponse)
async def sub(
    feed: Annotated[AtomFeed, Depends(initxmlfrombody)]
):

...

This leverages the dependency injection of FastAPI with a little factory function.

If AtomFeed (a pydantic xml model) could be instanced with something like AtomFeed(my_xml) instead of AtomFeed.from_xml(my_xml) I think the dependency injection might be able to work directly with it.

In the meantime I think I have it working.