(Discussion) Potential idea for making ORM RestAPI-friendly?
See original GitHub issueHey, this is a random thought that I figured some opinions would be nice. This is an attempt solution at an annoyance of RestAPI frameworks being set-up so that DRY principles are constantly broken. This solution is proposed as an addon to Tortoise, but perhaps it shouldn’t - unsure.
For the sake of example, take the following FastAPI route Python pseudocode using Pydantic, and Tortoise:
class PydanticChannel(PydanticModel):
name: str
class TortoiseChannel(TortoiseModel):
name = CharField(max_length=128, unique=True)
@route.post("/")
async def new_channel(schema):
if await models.TortoiseChannel.exists(name=schema.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A channel with this name already exists.",
)
return await models.TortoiseChannel.create(**schema.dict())
@route.get("/{name}")
async def get_channel(name):
# We don't use 'exists' because we assume hit_rate > miss_rate.
chnl = await models.TortoiseChannel.get_or_none(name=name)
if not chnl:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel cannot be found.",
)
return chnl
@route.patch("/{name}")
async def patch(name, schema):
# We don't use 'exists' because we assume hit_rate > miss_rate.
chnl = await models.TortoiseChannel.get_or_none(name=name)
if not chnl:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel cannot be found.",
)
elif await models.TortoiseChannel.exists(name=schema.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A channel with this name already exists.",
)
await chnl.update_from_dict(channel.dict()).save()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@route.delete('/{name}')
async def delete(name):
chnl = await models.TortoiseChannel.get_or_none(name=name)
if not chnl:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel cannot be found.",
)
await chnl.delete()
return Response(status_code=status.HTTP_204_NO_CONTENT)
As you can see in the example above there is a lot of repetitive code between the different routes - noticeably, error ‘404’ (not found) and ‘409’ (conflict). All of this stems from objects that either do not exists, or do exists; something the database naturally has to query to find out anyhow. Wondering if this can be figured out, I’ve come up with a solution that utilizes a 'generate_get_model` function that generates a ‘get_model’ function to optimize this process.
On second pass, however, it came to mind that this would be an interesting problem that Tortoise could solve out of the box to guarantee (unique) support for RESTful designs. Take the following example (not definite, but something I believe might be interesting):
from fastapi import status, HTTPException
class TortoiseChannel(TortoiseModel):
name = CharField(max_length=128, unique=True)
class Rest:
class HTTP_404_NOT_FOUND:
status = status.HTTP_404_NOT_FOUND
info = "Channel could not be found."
exception = tortoise.exceptions.DoesNotExist
class HTTP_409_CONFLICT:
status = status.HTTP_409_CONFLICT
info = "Channel with this information already exists."
exception = tortoise.exceptions.IntegrityError
condition = lambda model: model.name != "admin" # Random conditional for example sake.
class Config:
rest_exception = HTTPException
With a model like this, a .rest()
function could be added that can indicate which error to look-out for and raise a specific (in the case of FastAPI) HTTPException
(.rest()
takes *args
). Therefore, the code above would become something like:
@route.post("/")
async def new_channel(schema):
return await models.TortoiseChannel.create(**schema.dict()).rest(status.HTTP_409_CONFLICT)
@route.get("/{name}")
async def get_channel(name):
return await models.TortoiseChannel.get(name=name).rest(status.HTTP_404_NOT_FOUND)
@route.patch("/{name}")
async def patch(name, schema):
await models.TortoiseChannel.exists(name).rest(status.HTTP_404_NOT_FOUND)
await models.TortoiseChannel.update_from_dict(*schema.dict()).rest=(status.HTTP_409_CONFLICT)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@route.delete('/{name}')
async def delete(name):
await models.TortoiseChannel.delete(name=name).rest=(status.HTTP_404_NOT_FOUND)
return Response(status_code=status.HTTP_204_NO_CONTENT)
Therefore, .rest()
servers almost like an middleware that checks for either an exception thrown, or a condition of the previous call. If you take the following:
class TortoiseChannel(TortoiseModel):
name = CharField(max_length=128, unique=True)
class Rest:
class HTTP_404_NOT_FOUND:
status = status.HTTP_404_NOT_FOUND
info = "Channel could not be found."
exception = tortoise.exceptions.DoesNotExist
class HTTP_409_CONFLICT:
status = status.HTTP_409_CONFLICT
info = "Channel with this information already exists or is invalid."
exception = tortoise.exceptions.IntegrityError
condition = lambda model: model.name != "admin"
class Config:
rest_exception = HTTPException
@route.get("/{name}")
async def get_channel(name):
return await models.TortoiseChannel.get(name=name).rest(status.HTTP_404_NOT_FOUND)
@route.post("/")
async def new_channel(schema):
return await models.TortoiseChannel.create(**schema.dict()).rest(status.HTTP_409_CONFLICT)
This would be equivalent to:
async def catch_404(model):
try:
return await model
except tortoise.exceptions.DoesNotExist:
raise HTTPException(status=status.HTTP_404_NOT_FOUND, info="Channel could not be found.")
async def catch_409_and_conditional(model):
condition = lambda model: model.name != "admin"
info = "Channel with this information already exists or is invalid."
try:
if condition(ret):
raise HTTPException(status=status.HTTP_409_CONFLICT, info=info)
return await model
except tortoise.exceptions.IntegrityError:
raise HTTPException(status=status.HTTP_409_CONFLICT, info=info)
@route.get("/{name}")
async def get_channel(name):
return await catch_404(models.TortoiseChannel.get(name=name))
@route.post("/")
async def new_channel(schema):
return await catch_409_and_conditional(models.TortoiseChannel.create(**schema.dict()))
Something along those lines, or a completely different idea/interface that helps with the DRY situation with RESTful APIs. If anyone has any suggestion or would like to share how they tried solving the DRY issue, that would be lovely.
.rest()
could be separated into .exceptions()
and .conditionals()
, but hopefully the point is through. 😃
Issue Analytics
- State:
- Created 2 years ago
- Comments:6 (1 by maintainers)
There is a package that implements CRUD nicely (even simpler than DRF) and it has integration with TortoiseORM (as well as other ORMs). I think this is what you wanted: https://github.com/awtkns/fastapi-crudrouter
(If so, this issue can be closed)
Bless your soul man, thank you for the share. Will definitely be diving deeper into this - just from the README, this is exactly what I was looking for. Valeu!