From 59f25241cc9aa4081f9e74f1f1a9111ffb7a903e Mon Sep 17 00:00:00 2001
From: Roman Mogylatov <rmogilatov@gmail.com>
Date: Thu, 12 Nov 2020 17:48:07 -0500
Subject: [PATCH] Add FastAPI mini app

---
 examples/miniapps/fastapi/README.rst          | 121 ++++++++++++++++++
 examples/miniapps/fastapi/config.yml          |   5 +
 .../fastapi/giphynavigator/__init__.py        |   1 +
 .../fastapi/giphynavigator/application.py     |  21 +++
 .../fastapi/giphynavigator/containers.py      |  21 +++
 .../fastapi/giphynavigator/endpoints.py       |  18 +++
 .../miniapps/fastapi/giphynavigator/giphy.py  |  26 ++++
 .../fastapi/giphynavigator/services.py        |  18 +++
 .../miniapps/fastapi/giphynavigator/tests.py  |  78 +++++++++++
 examples/miniapps/fastapi/requirements.txt    |   8 ++
 10 files changed, 317 insertions(+)
 create mode 100644 examples/miniapps/fastapi/README.rst
 create mode 100644 examples/miniapps/fastapi/config.yml
 create mode 100644 examples/miniapps/fastapi/giphynavigator/__init__.py
 create mode 100644 examples/miniapps/fastapi/giphynavigator/application.py
 create mode 100644 examples/miniapps/fastapi/giphynavigator/containers.py
 create mode 100644 examples/miniapps/fastapi/giphynavigator/endpoints.py
 create mode 100644 examples/miniapps/fastapi/giphynavigator/giphy.py
 create mode 100644 examples/miniapps/fastapi/giphynavigator/services.py
 create mode 100644 examples/miniapps/fastapi/giphynavigator/tests.py
 create mode 100644 examples/miniapps/fastapi/requirements.txt

diff --git a/examples/miniapps/fastapi/README.rst b/examples/miniapps/fastapi/README.rst
new file mode 100644
index 00000000..7e99ab8b
--- /dev/null
+++ b/examples/miniapps/fastapi/README.rst
@@ -0,0 +1,121 @@
+FastAPI + Dependency Injector Example
+=====================================
+
+This is an `FastAPI <https://fastapi.tiangolo.com/>`_ +
+`Dependency Injector <https://python-dependency-injector.ets-labs.org/>`_ example application.
+
+The example application is a REST API that searches for funny GIFs on the `Giphy <https://giphy.com/>`_.
+
+Run
+---
+
+Create virtual environment:
+
+.. code-block:: bash
+
+   virtualenv venv
+   . venv/bin/activate
+
+Install requirements:
+
+.. code-block:: bash
+
+    pip install -r requirements.txt
+
+To run the application do:
+
+.. code-block:: bash
+
+    export GIPHY_API_KEY=wBJ2wZG7SRqfrU9nPgPiWvORmloDyuL0
+    uvicorn giphynavigator.application:app --reload
+
+The output should be something like:
+
+.. code-block::
+
+   INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
+   INFO:     Started reloader process [4795] using watchgod
+   INFO:     Started server process [4797]
+   INFO:     Waiting for application startup.
+   INFO:     Application startup complete.
+
+After that visit http://127.0.0.1:8000/ in your browser or use CLI command (``curl``, ``httpie``,
+etc). You should see something like:
+
+.. code-block:: json
+
+   {
+       "query": "Dependency Injector",
+       "limit": 10,
+       "gifs": [
+           {
+               "url": "https://giphy.com/gifs/boxes-dependent-swbf2-6Eo7KzABxgJMY"
+           },
+           {
+               "url": "https://giphy.com/gifs/depends-J56qCcOhk6hKE"
+           },
+           {
+               "url": "https://giphy.com/gifs/web-series-ccstudios-bro-dependent-1lhU8KAVwmVVu"
+           },
+           {
+               "url": "https://giphy.com/gifs/TheBoysTV-friends-friend-weneedeachother-XxR9qcIwcf5Jq404Sx"
+           },
+           {
+               "url": "https://giphy.com/gifs/netflix-a-series-of-unfortunate-events-asoue-9rgeQXbwoK53pcxn7f"
+           },
+           {
+               "url": "https://giphy.com/gifs/black-and-white-sad-skins-Hs4YzLs2zJuLu"
+           },
+           {
+               "url": "https://giphy.com/gifs/always-there-for-you-i-am-here-PlayjhCco9jHBYrd9w"
+           },
+           {
+               "url": "https://giphy.com/gifs/stream-famous-dollar-YT2dvOByEwXCdoYiA1"
+           },
+           {
+               "url": "https://giphy.com/gifs/i-love-you-there-for-am-1BhGzgpZXYWwWMAGB1"
+           },
+           {
+               "url": "https://giphy.com/gifs/life-like-twerk-9hlnWxjHqmH28"
+           }
+       ]
+   }
+
+.. note::
+
+   To create your own Giphy API key follow this
+   `guide <https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key>`_.
+
+Test
+----
+
+This application comes with the unit tests.
+
+To run the tests do:
+
+.. code-block:: bash
+
+   py.test giphynavigator/tests.py --cov=giphynavigator
+
+The output should be something like:
+
+.. code-block::
+
+   platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
+   plugins: cov-2.10.0, asyncio-0.14.0
+   collected 3 items
+
+   giphynavigator/tests.py ...                                     [100%]
+
+   ---------- coverage: platform darwin, python 3.8.3-final-0 -----------
+   Name                            Stmts   Miss  Cover
+   ---------------------------------------------------
+   giphynavigator/__init__.py          0      0   100%
+   giphynavigator/application.py      13      0   100%
+   giphynavigator/containers.py        6      0   100%
+   giphynavigator/endpoints.py         5      0   100%
+   giphynavigator/giphy.py            14      9    36%
+   giphynavigator/services.py          9      1    89%
+   giphynavigator/tests.py            38      0   100%
+   ---------------------------------------------------
+   TOTAL                              85     10    88%
diff --git a/examples/miniapps/fastapi/config.yml b/examples/miniapps/fastapi/config.yml
new file mode 100644
index 00000000..d1276f8c
--- /dev/null
+++ b/examples/miniapps/fastapi/config.yml
@@ -0,0 +1,5 @@
+giphy:
+  request_timeout: 10
+default:
+  query: "Dependency Injector"
+  limit: 10
diff --git a/examples/miniapps/fastapi/giphynavigator/__init__.py b/examples/miniapps/fastapi/giphynavigator/__init__.py
new file mode 100644
index 00000000..1c744ca5
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/__init__.py
@@ -0,0 +1 @@
+"""Top-level package."""
diff --git a/examples/miniapps/fastapi/giphynavigator/application.py b/examples/miniapps/fastapi/giphynavigator/application.py
new file mode 100644
index 00000000..805f4868
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/application.py
@@ -0,0 +1,21 @@
+"""Application module."""
+
+from fastapi import FastAPI
+
+from .containers import Container
+from . import endpoints
+
+
+def create_app() -> FastAPI:
+    container = Container()
+    container.config.from_yaml('config.yml')
+    container.config.giphy.api_key.from_env('GIPHY_API_KEY')
+    container.wire(modules=[endpoints])
+
+    app = FastAPI()
+    app.container = container
+    app.add_api_route('/', endpoints.index)
+    return app
+
+
+app = create_app()
diff --git a/examples/miniapps/fastapi/giphynavigator/containers.py b/examples/miniapps/fastapi/giphynavigator/containers.py
new file mode 100644
index 00000000..730c162e
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/containers.py
@@ -0,0 +1,21 @@
+"""Containers module."""
+
+from dependency_injector import containers, providers
+
+from . import giphy, services
+
+
+class Container(containers.DeclarativeContainer):
+
+    config = providers.Configuration()
+
+    giphy_client = providers.Factory(
+        giphy.GiphyClient,
+        api_key=config.giphy.api_key,
+        timeout=config.giphy.request_timeout,
+    )
+
+    search_service = providers.Factory(
+        services.SearchService,
+        giphy_client=giphy_client,
+    )
diff --git a/examples/miniapps/fastapi/giphynavigator/endpoints.py b/examples/miniapps/fastapi/giphynavigator/endpoints.py
new file mode 100644
index 00000000..e1a37716
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/endpoints.py
@@ -0,0 +1,18 @@
+"""Endpoints module."""
+
+from dependency_injector.wiring import Provide
+
+from .containers import Container
+
+
+async def index(
+        query: str = Provide[Container.config.default.query],
+        limit: int = Provide[Container.config.default.limit.as_int()],
+        search_service = Provide[Container.search_service],
+):
+    gifs = await search_service.search(query, limit)
+    return {
+        'query': query,
+        'limit': limit,
+        'gifs': gifs,
+    }
diff --git a/examples/miniapps/fastapi/giphynavigator/giphy.py b/examples/miniapps/fastapi/giphynavigator/giphy.py
new file mode 100644
index 00000000..22a5f6a4
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/giphy.py
@@ -0,0 +1,26 @@
+"""Giphy client module."""
+
+from aiohttp import ClientSession, ClientTimeout
+
+
+class GiphyClient:
+
+    API_URL = 'https://api.giphy.com/v1'
+
+    def __init__(self, api_key, timeout):
+        self._api_key = api_key
+        self._timeout = ClientTimeout(timeout)
+
+    async def search(self, query, limit):
+        """Make search API call and return result."""
+        url = f'{self.API_URL}/gifs/search'
+        params = {
+            'q': query,
+            'api_key': self._api_key,
+            'limit': limit,
+        }
+        async with ClientSession(timeout=self._timeout) as session:
+            async with session.get(url, params=params) as response:
+                if response.status != 200:
+                    response.raise_for_status()
+                return await response.json()
diff --git a/examples/miniapps/fastapi/giphynavigator/services.py b/examples/miniapps/fastapi/giphynavigator/services.py
new file mode 100644
index 00000000..1c86e0d7
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/services.py
@@ -0,0 +1,18 @@
+"""Services module."""
+
+from .giphy import GiphyClient
+
+
+class SearchService:
+
+    def __init__(self, giphy_client: GiphyClient):
+        self._giphy_client = giphy_client
+
+    async def search(self, query, limit):
+        """Search for gifs and return formatted data."""
+        if not query:
+            return []
+
+        result = await self._giphy_client.search(query, limit)
+
+        return [{'url': gif['url']} for gif in result['data']]
diff --git a/examples/miniapps/fastapi/giphynavigator/tests.py b/examples/miniapps/fastapi/giphynavigator/tests.py
new file mode 100644
index 00000000..4ae91493
--- /dev/null
+++ b/examples/miniapps/fastapi/giphynavigator/tests.py
@@ -0,0 +1,78 @@
+"""Tests module."""
+
+from unittest import mock
+
+import pytest
+from httpx import AsyncClient
+
+from giphynavigator.application import app
+from giphynavigator.giphy import GiphyClient
+
+
+@pytest.fixture
+def client(event_loop):
+    client = AsyncClient(app=app, base_url='http://test')
+    yield client
+    event_loop.run_until_complete(client.aclose())
+
+
+@pytest.mark.asyncio
+async def test_index(client):
+    giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
+    giphy_client_mock.search.return_value = {
+        'data': [
+            {'url': 'https://giphy.com/gif1.gif'},
+            {'url': 'https://giphy.com/gif2.gif'},
+        ],
+    }
+
+    with app.container.giphy_client.override(giphy_client_mock):
+        response = await client.get(
+            '/',
+            params={
+                'query': 'test',
+                'limit': 10,
+            },
+        )
+
+    assert response.status_code == 200
+    data = response.json()
+    assert data == {
+        'query': 'test',
+        'limit': 10,
+        'gifs': [
+            {'url': 'https://giphy.com/gif1.gif'},
+            {'url': 'https://giphy.com/gif2.gif'},
+        ],
+    }
+
+
+@pytest.mark.asyncio
+async def test_index_no_data(client):
+    giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
+    giphy_client_mock.search.return_value = {
+        'data': [],
+    }
+
+    with app.container.giphy_client.override(giphy_client_mock):
+        response = await client.get('/')
+
+    assert response.status_code == 200
+    data = response.json()
+    assert data['gifs'] == []
+
+
+@pytest.mark.asyncio
+async def test_index_default_params(client):
+    giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
+    giphy_client_mock.search.return_value = {
+        'data': [],
+    }
+
+    with app.container.giphy_client.override(giphy_client_mock):
+        response = await client.get('/')
+
+    assert response.status_code == 200
+    data = response.json()
+    assert data['query'] == app.container.config.default.query()
+    assert data['limit'] == app.container.config.default.limit()
diff --git a/examples/miniapps/fastapi/requirements.txt b/examples/miniapps/fastapi/requirements.txt
new file mode 100644
index 00000000..6eddac10
--- /dev/null
+++ b/examples/miniapps/fastapi/requirements.txt
@@ -0,0 +1,8 @@
+dependency-injector
+fastapi
+uvicorn
+aiohttp
+httpx
+pyyaml
+pytest-asyncio
+pytest-cov