fastapi-motor-oil


Namefastapi-motor-oil JSON
Version 0.5.2 PyPI version JSON
download
home_page
SummaryCollection of async utilities for working with MongoDB and conveniently creating performant APIs with async web frameworks such a FastAPI.
upload_time2023-07-30 09:49:39
maintainer
docs_urlNone
authorPeter Volf
requires_python>=3.10,<4.0
license
keywords
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # FastAPI-motor-oil

**This project is deprecated and replaced by [motorhead](volfpeter.github.io/motorhead), adding Pydantic v2 support along with a couple of smaller improvements. Please create an issue if you need help with the migration.**

`FastAPI-motor-oil` is a collection of async utilities for working with MongoDB and conveniently creating performant APIs with async web frameworks such a [FastAPI](https://fastapi.tiangolo.com/).

Key features:

- Database **model** design with `Pydantic`.
- Relationship support and validation using async **validators and delete rules** with a declarative, decorator-based syntax.
- Declarative **index** specification.
- Typed **utilities** for convenient model and API creation.
- Ready to use, customizable **async service layer** with **transaction support** that integrates all the above to keep your API and business logic clean, flexible, and easy to understand.

By providing a convenient, declarative middle layer between MongoDB and your API, `FastAPI-motor-oil` is halfway between an object document mapper (based on `Pydantic`) and a database driver (by wrapping the official, async `motor` driver).

See the [full documentation here](https://volfpeter.github.io/fastapi-motor-oil/).

## Installation

The library is available on PyPI and can be installed with:

```console
$ pip install fastapi-motor-oil
```

## Example

Prerequisites:

- MongoDB (e.g. the Community Edition) installed and running locally;
- `fastapi` with all its dependencies (`pip install fastapi[all]`);
- This library (`pip install fastapi-motor-oil`).

In this example we will create:

- a simple `TreeNode` document model with a `name` and an optional reference to a `parent` node and some delete rules;
- the services that are necessary to create, read, update, and delete documents;
- a `fastapi` `APIRouter` factory that can be included in `fastapi` applications;
- and the `fastapi` application itself.

The project layout under your root directory will be as follows:

- `/tree_app`
  - `__init__.py`
  - `api.py`
  - `main.py`
  - `model.py`
  - `service.py`

Model definitions (in `model.py`):

```python
from fastapi_motor_oil import DocumentModel, StrObjectId, UTCDatetime
from pydantic import BaseModel

class TreeNode(DocumentModel):
    """
    Tree node document model.
    """

    name: str
    parent: StrObjectId | None
    created_at: UTCDatetime

class TreeNodeCreate(BaseModel):
    """
    Tree node creation model.
    """

    name: str
    parent: StrObjectId | None

class TreeNodeUpdate(BaseModel):
    """
    Tree node update model.
    """

    name: str | None
    parent: StrObjectId | None

```

Service implementation (in `service.py`):

```python
from typing import Any
from collections.abc import Sequence
from datetime import datetime, timezone

from bson import ObjectId
from fastapi_motor_oil import (
    CollectionOptions,
    MongoQuery,
    MongoService,
    delete_rule,
    validator,
)
from motor.core import AgnosticClientSession

from .model import TreeNodeCreate, TreeNodeUpdate

class TreeNodeService(MongoService[TreeNodeCreate, TreeNodeUpdate]):
    """
    Tree node database services.
    """

    __slots__ = ()

    collection_name: str = "tree_nodes"

    collection_options: CollectionOptions | None = None

    @delete_rule("pre")  # Delete rule that remove the subtrees of deleted nodes.
    async def dr_delete_subtree(
        self, session: AgnosticClientSession, ids: Sequence[ObjectId]
    ) -> None:
        child_ids = await self.find_ids({"parent": {"$in": ids}}, session=session)
        if len(child_ids) > 0:
            # Recursion
            await self.delete_many(
                {"_id": {"$in": child_ids}}, options={"session": session}
            )

    @delete_rule("deny")  # Delete rule that prevents the removal of root nodes.
    async def dr_deny_if_root(
        self, session: AgnosticClientSession, ids: Sequence[ObjectId]
    ) -> None:
        root_cnt = await self.count_documents(
            {"$and": [{"_id": {"$in": ids}}, {"parent": None}]},
            options={"session": session},
        )
        if root_cnt > 0:
            raise ValueError("Can not delete root nodes.")

    @validator("insert-update")
    async def v_parent_valid(
        self, query: MongoQuery | None, data: TreeNodeCreate | TreeNodeUpdate
    ) -> None:
        if data.parent is None:  # No parent node is always fine
            return

        if not await self.exists(data.parent):  # Parent must exist.
            raise ValueError("Parent does not exist.")

        if isinstance(data, TreeNodeCreate):  # No more checks during creation.
            return

        matched_ids = (
            (await self.find_ids(query)) if isinstance(data, TreeNodeUpdate) else []
        )
        if data.parent in matched_ids:  # Self reference is forbidden.
            raise ValueError("Self-reference.")

    async def _convert_for_insert(self, data: TreeNodeCreate) -> dict[str, Any]:
        return {
            **(await super()._convert_for_insert(data)),
            "created_at": datetime.now(timezone.utc),
        }
```

Routing implementation (in `api.py`):

```python
from typing import Any

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi_motor_oil import (
    AgnosticDatabase,
    DatabaseProvider,
    DeleteError,
    DeleteResultModel,
    StrObjectId,
)

from .model import TreeNode, TreeNodeCreate, TreeNodeUpdate
from .service import TreeNodeService

def make_api(
    *,
    get_database: DatabaseProvider,
    prefix: str = "/tree-node",
) -> APIRouter:
    """
    Tree node `APIRouter` factory.

    Arguments:
        get_database: FastAPI dependency that returns the `AgnosticDatabase`
                      database instance for the API.
        prefix: The prefix for the created `APIRouter`.

    Returns:
        The created `APIRouter` instance.
    """
    api = APIRouter(prefix=prefix)

    @api.get("/", response_model=list[TreeNode])
    async def get_all(
        database: AgnosticDatabase = Depends(get_database),
    ) -> list[dict[str, Any]]:
        svc = TreeNodeService(database)
        return [d async for d in svc.find()]

    @api.post("/", response_model=TreeNode)
    async def create(
        data: TreeNodeCreate,
        database: AgnosticDatabase = Depends(get_database),
    ) -> dict[str, Any]:
        svc = TreeNodeService(database)

        try:
            result = await svc.insert_one(data)
        except Exception:
            raise HTTPException(
                status_code=status.HTTP_400_BAD_REQUEST, detail="Creation failed."
            )

        if (created := await svc.get_by_id(result.inserted_id)) is not None:
            return created

        raise HTTPException(status.HTTP_409_CONFLICT)

    @api.get("/{id}", response_model=TreeNode)
    async def get_by_id(
        id: StrObjectId,
        database: AgnosticDatabase = Depends(get_database),
    ) -> dict[str, Any]:
        svc = TreeNodeService(database)
        if (result := await svc.get_by_id(id)) is not None:
            return result

        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(id))

    @api.put("/{id}", response_model=TreeNode)
    async def update_by_id(
        id: StrObjectId,
        data: TreeNodeUpdate,
        database: AgnosticDatabase = Depends(get_database),
    ) -> dict[str, Any]:
        svc = TreeNodeService(database)

        try:
            result = await svc.update_by_id(id, data)
        except Exception:
            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(id))

        if result.matched_count == 0:
            raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(id))

        if (updated := await svc.get_by_id(id)) is not None:
            return updated

        raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(id))

    @api.delete("/{id}", response_model=DeleteResultModel)
    async def delete_by_id(
        id: StrObjectId,
        database: AgnosticDatabase = Depends(get_database),
    ) -> DeleteResultModel:
        svc = TreeNodeService(database)
        try:
            result = await svc.delete_by_id(id)
        except DeleteError:
            raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(id))
        if result.deleted_count == 0:
            raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(id))

        return DeleteResultModel(delete_count=result.deleted_count)

    return api
```

Application (in `main.py`):

```python
from functools import lru_cache

from fastapi import FastAPI
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase

@lru_cache(maxsize=1)
def get_database() -> AsyncIOMotorDatabase:
    """Database provider dependency for the created API."""
    mongo_connection_string = "mongodb://127.0.0.1:27017"
    database_name = "tree-db"
    client = AsyncIOMotorClient(mongo_connection_string)
    return client[database_name]

def register_routes(app: FastAPI) -> None:
    """Registers all routes of the application."""
    from .api import make_api as make_tree_node_api

    api_prefix = "/api/v1"

    app.include_router(
        make_tree_node_api(get_database=get_database),
        prefix=api_prefix,
    )

def create_app() -> FastAPI:
    app = FastAPI()

    register_routes(app)

    return app
```

With everything in place, you can serve the application by executing `uvicorn tree_app.main:create_app --reload --factory` in your root directory. Go to [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) in the browser to see and try the created REST API.

## Requirements

The project depends on `motor` (the official asyncio MongoDB driver, which is built on top of `pymongo` and `bson`) and `pydantic`.

`fastapi` is not an actual dependency, but the code was written with `fastapi` applications with a REST API in mind.

## Development

Use `black` for code formatting and `mypy` for static code analysis.

## Contributing

Contributions are welcome.

## License - MIT

The library is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/).

            

Raw data

            {
    "_id": null,
    "home_page": "",
    "name": "fastapi-motor-oil",
    "maintainer": "",
    "docs_url": null,
    "requires_python": ">=3.10,<4.0",
    "maintainer_email": "",
    "keywords": "",
    "author": "Peter Volf",
    "author_email": "do.volfp@gmail.com",
    "download_url": "https://files.pythonhosted.org/packages/b2/03/62253a93a733c37cdb63bd519e3f3dfaef0e103a7025cbd6f74c3623489b/fastapi_motor_oil-0.5.2.tar.gz",
    "platform": null,
    "description": "# FastAPI-motor-oil\n\n**This project is deprecated and replaced by [motorhead](volfpeter.github.io/motorhead), adding Pydantic v2 support along with a couple of smaller improvements. Please create an issue if you need help with the migration.**\n\n`FastAPI-motor-oil` is a collection of async utilities for working with MongoDB and conveniently creating performant APIs with async web frameworks such a [FastAPI](https://fastapi.tiangolo.com/).\n\nKey features:\n\n- Database **model** design with `Pydantic`.\n- Relationship support and validation using async **validators and delete rules** with a declarative, decorator-based syntax.\n- Declarative **index** specification.\n- Typed **utilities** for convenient model and API creation.\n- Ready to use, customizable **async service layer** with **transaction support** that integrates all the above to keep your API and business logic clean, flexible, and easy to understand.\n\nBy providing a convenient, declarative middle layer between MongoDB and your API, `FastAPI-motor-oil` is halfway between an object document mapper (based on `Pydantic`) and a database driver (by wrapping the official, async `motor` driver).\n\nSee the [full documentation here](https://volfpeter.github.io/fastapi-motor-oil/).\n\n## Installation\n\nThe library is available on PyPI and can be installed with:\n\n```console\n$ pip install fastapi-motor-oil\n```\n\n## Example\n\nPrerequisites:\n\n- MongoDB (e.g. the Community Edition) installed and running locally;\n- `fastapi` with all its dependencies (`pip install fastapi[all]`);\n- This library (`pip install fastapi-motor-oil`).\n\nIn this example we will create:\n\n- a simple `TreeNode` document model with a `name` and an optional reference to a `parent` node and some delete rules;\n- the services that are necessary to create, read, update, and delete documents;\n- a `fastapi` `APIRouter` factory that can be included in `fastapi` applications;\n- and the `fastapi` application itself.\n\nThe project layout under your root directory will be as follows:\n\n- `/tree_app`\n  - `__init__.py`\n  - `api.py`\n  - `main.py`\n  - `model.py`\n  - `service.py`\n\nModel definitions (in `model.py`):\n\n```python\nfrom fastapi_motor_oil import DocumentModel, StrObjectId, UTCDatetime\nfrom pydantic import BaseModel\n\nclass TreeNode(DocumentModel):\n    \"\"\"\n    Tree node document model.\n    \"\"\"\n\n    name: str\n    parent: StrObjectId | None\n    created_at: UTCDatetime\n\nclass TreeNodeCreate(BaseModel):\n    \"\"\"\n    Tree node creation model.\n    \"\"\"\n\n    name: str\n    parent: StrObjectId | None\n\nclass TreeNodeUpdate(BaseModel):\n    \"\"\"\n    Tree node update model.\n    \"\"\"\n\n    name: str | None\n    parent: StrObjectId | None\n\n```\n\nService implementation (in `service.py`):\n\n```python\nfrom typing import Any\nfrom collections.abc import Sequence\nfrom datetime import datetime, timezone\n\nfrom bson import ObjectId\nfrom fastapi_motor_oil import (\n    CollectionOptions,\n    MongoQuery,\n    MongoService,\n    delete_rule,\n    validator,\n)\nfrom motor.core import AgnosticClientSession\n\nfrom .model import TreeNodeCreate, TreeNodeUpdate\n\nclass TreeNodeService(MongoService[TreeNodeCreate, TreeNodeUpdate]):\n    \"\"\"\n    Tree node database services.\n    \"\"\"\n\n    __slots__ = ()\n\n    collection_name: str = \"tree_nodes\"\n\n    collection_options: CollectionOptions | None = None\n\n    @delete_rule(\"pre\")  # Delete rule that remove the subtrees of deleted nodes.\n    async def dr_delete_subtree(\n        self, session: AgnosticClientSession, ids: Sequence[ObjectId]\n    ) -> None:\n        child_ids = await self.find_ids({\"parent\": {\"$in\": ids}}, session=session)\n        if len(child_ids) > 0:\n            # Recursion\n            await self.delete_many(\n                {\"_id\": {\"$in\": child_ids}}, options={\"session\": session}\n            )\n\n    @delete_rule(\"deny\")  # Delete rule that prevents the removal of root nodes.\n    async def dr_deny_if_root(\n        self, session: AgnosticClientSession, ids: Sequence[ObjectId]\n    ) -> None:\n        root_cnt = await self.count_documents(\n            {\"$and\": [{\"_id\": {\"$in\": ids}}, {\"parent\": None}]},\n            options={\"session\": session},\n        )\n        if root_cnt > 0:\n            raise ValueError(\"Can not delete root nodes.\")\n\n    @validator(\"insert-update\")\n    async def v_parent_valid(\n        self, query: MongoQuery | None, data: TreeNodeCreate | TreeNodeUpdate\n    ) -> None:\n        if data.parent is None:  # No parent node is always fine\n            return\n\n        if not await self.exists(data.parent):  # Parent must exist.\n            raise ValueError(\"Parent does not exist.\")\n\n        if isinstance(data, TreeNodeCreate):  # No more checks during creation.\n            return\n\n        matched_ids = (\n            (await self.find_ids(query)) if isinstance(data, TreeNodeUpdate) else []\n        )\n        if data.parent in matched_ids:  # Self reference is forbidden.\n            raise ValueError(\"Self-reference.\")\n\n    async def _convert_for_insert(self, data: TreeNodeCreate) -> dict[str, Any]:\n        return {\n            **(await super()._convert_for_insert(data)),\n            \"created_at\": datetime.now(timezone.utc),\n        }\n```\n\nRouting implementation (in `api.py`):\n\n```python\nfrom typing import Any\n\nfrom fastapi import APIRouter, Depends, HTTPException, status\nfrom fastapi_motor_oil import (\n    AgnosticDatabase,\n    DatabaseProvider,\n    DeleteError,\n    DeleteResultModel,\n    StrObjectId,\n)\n\nfrom .model import TreeNode, TreeNodeCreate, TreeNodeUpdate\nfrom .service import TreeNodeService\n\ndef make_api(\n    *,\n    get_database: DatabaseProvider,\n    prefix: str = \"/tree-node\",\n) -> APIRouter:\n    \"\"\"\n    Tree node `APIRouter` factory.\n\n    Arguments:\n        get_database: FastAPI dependency that returns the `AgnosticDatabase`\n                      database instance for the API.\n        prefix: The prefix for the created `APIRouter`.\n\n    Returns:\n        The created `APIRouter` instance.\n    \"\"\"\n    api = APIRouter(prefix=prefix)\n\n    @api.get(\"/\", response_model=list[TreeNode])\n    async def get_all(\n        database: AgnosticDatabase = Depends(get_database),\n    ) -> list[dict[str, Any]]:\n        svc = TreeNodeService(database)\n        return [d async for d in svc.find()]\n\n    @api.post(\"/\", response_model=TreeNode)\n    async def create(\n        data: TreeNodeCreate,\n        database: AgnosticDatabase = Depends(get_database),\n    ) -> dict[str, Any]:\n        svc = TreeNodeService(database)\n\n        try:\n            result = await svc.insert_one(data)\n        except Exception:\n            raise HTTPException(\n                status_code=status.HTTP_400_BAD_REQUEST, detail=\"Creation failed.\"\n            )\n\n        if (created := await svc.get_by_id(result.inserted_id)) is not None:\n            return created\n\n        raise HTTPException(status.HTTP_409_CONFLICT)\n\n    @api.get(\"/{id}\", response_model=TreeNode)\n    async def get_by_id(\n        id: StrObjectId,\n        database: AgnosticDatabase = Depends(get_database),\n    ) -> dict[str, Any]:\n        svc = TreeNodeService(database)\n        if (result := await svc.get_by_id(id)) is not None:\n            return result\n\n        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(id))\n\n    @api.put(\"/{id}\", response_model=TreeNode)\n    async def update_by_id(\n        id: StrObjectId,\n        data: TreeNodeUpdate,\n        database: AgnosticDatabase = Depends(get_database),\n    ) -> dict[str, Any]:\n        svc = TreeNodeService(database)\n\n        try:\n            result = await svc.update_by_id(id, data)\n        except Exception:\n            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(id))\n\n        if result.matched_count == 0:\n            raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(id))\n\n        if (updated := await svc.get_by_id(id)) is not None:\n            return updated\n\n        raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(id))\n\n    @api.delete(\"/{id}\", response_model=DeleteResultModel)\n    async def delete_by_id(\n        id: StrObjectId,\n        database: AgnosticDatabase = Depends(get_database),\n    ) -> DeleteResultModel:\n        svc = TreeNodeService(database)\n        try:\n            result = await svc.delete_by_id(id)\n        except DeleteError:\n            raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(id))\n        if result.deleted_count == 0:\n            raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(id))\n\n        return DeleteResultModel(delete_count=result.deleted_count)\n\n    return api\n```\n\nApplication (in `main.py`):\n\n```python\nfrom functools import lru_cache\n\nfrom fastapi import FastAPI\nfrom motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase\n\n@lru_cache(maxsize=1)\ndef get_database() -> AsyncIOMotorDatabase:\n    \"\"\"Database provider dependency for the created API.\"\"\"\n    mongo_connection_string = \"mongodb://127.0.0.1:27017\"\n    database_name = \"tree-db\"\n    client = AsyncIOMotorClient(mongo_connection_string)\n    return client[database_name]\n\ndef register_routes(app: FastAPI) -> None:\n    \"\"\"Registers all routes of the application.\"\"\"\n    from .api import make_api as make_tree_node_api\n\n    api_prefix = \"/api/v1\"\n\n    app.include_router(\n        make_tree_node_api(get_database=get_database),\n        prefix=api_prefix,\n    )\n\ndef create_app() -> FastAPI:\n    app = FastAPI()\n\n    register_routes(app)\n\n    return app\n```\n\nWith everything in place, you can serve the application by executing `uvicorn tree_app.main:create_app --reload --factory` in your root directory. Go to [http://127.0.0.1:8000/docs](http://127.0.0.1:8000/docs) in the browser to see and try the created REST API.\n\n## Requirements\n\nThe project depends on `motor` (the official asyncio MongoDB driver, which is built on top of `pymongo` and `bson`) and `pydantic`.\n\n`fastapi` is not an actual dependency, but the code was written with `fastapi` applications with a REST API in mind.\n\n## Development\n\nUse `black` for code formatting and `mypy` for static code analysis.\n\n## Contributing\n\nContributions are welcome.\n\n## License - MIT\n\nThe library is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/).\n",
    "bugtrack_url": null,
    "license": "",
    "summary": "Collection of async utilities for working with MongoDB and conveniently creating performant APIs with async web frameworks such a FastAPI.",
    "version": "0.5.2",
    "project_urls": null,
    "split_keywords": [],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "2a122b3e76b8db3f378af87e8459283521fb817b044ebe4446a6aa83da3d875e",
                "md5": "77d55af767a5b305579066d51387fcd3",
                "sha256": "16b788fb47c407b10f9d98003aa6f9d3314ff8aae0e34dbb5fbcecf70fa0cfed"
            },
            "downloads": -1,
            "filename": "fastapi_motor_oil-0.5.2-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "77d55af767a5b305579066d51387fcd3",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": ">=3.10,<4.0",
            "size": 16528,
            "upload_time": "2023-07-30T09:49:36",
            "upload_time_iso_8601": "2023-07-30T09:49:36.912920Z",
            "url": "https://files.pythonhosted.org/packages/2a/12/2b3e76b8db3f378af87e8459283521fb817b044ebe4446a6aa83da3d875e/fastapi_motor_oil-0.5.2-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "b20362253a93a733c37cdb63bd519e3f3dfaef0e103a7025cbd6f74c3623489b",
                "md5": "88b1ac0bf613a00c189186c2eb5c103b",
                "sha256": "f9e365cd359f54daf3e14fed291fc56ff1942ef8280c958b188f89eb7e313c7c"
            },
            "downloads": -1,
            "filename": "fastapi_motor_oil-0.5.2.tar.gz",
            "has_sig": false,
            "md5_digest": "88b1ac0bf613a00c189186c2eb5c103b",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": ">=3.10,<4.0",
            "size": 17118,
            "upload_time": "2023-07-30T09:49:39",
            "upload_time_iso_8601": "2023-07-30T09:49:39.030533Z",
            "url": "https://files.pythonhosted.org/packages/b2/03/62253a93a733c37cdb63bd519e3f3dfaef0e103a7025cbd6f74c3623489b/fastapi_motor_oil-0.5.2.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2023-07-30 09:49:39",
    "github": false,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "lcname": "fastapi-motor-oil"
}
        
Elapsed time: 0.53492s