Skip to content

Commit

Permalink
Add tests for ApiGateway and ApiProvider (#17)
Browse files Browse the repository at this point in the history
  • Loading branch information
caspervdw authored Oct 2, 2023
1 parent e2c69fd commit 7aac476
Show file tree
Hide file tree
Showing 14 changed files with 391 additions and 37 deletions.
38 changes: 36 additions & 2 deletions clean_python/api_client/api_gateway.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from datetime import datetime
from http import HTTPStatus
from typing import Optional

import inject

from clean_python import DoesNotExist
from clean_python import Id
from clean_python import Json

from .. import SyncGateway
from .api_provider import SyncApiProvider
from .exceptions import ApiException

__all__ = ["SyncApiGateway"]

Expand All @@ -28,12 +32,42 @@ def provider(self) -> SyncApiProvider:
return self.provider_override or inject.instance(SyncApiProvider)

def get(self, id: Id) -> Optional[Json]:
return self.provider.request("GET", self.path.format(id=id))
try:
return self.provider.request("GET", self.path.format(id=id))
except ApiException as e:
if e.status is HTTPStatus.NOT_FOUND:
return None
raise e

def add(self, item: Json) -> Json:
result = self.provider.request("POST", self.path.format(id=""), json=item)
assert result is not None
return result

def remove(self, id: Id) -> bool:
return self.provider.request("DELETE", self.path.format(id=id)) is not None
try:
self.provider.request("DELETE", self.path.format(id=id)) is not None
except ApiException as e:
if e.status is HTTPStatus.NOT_FOUND:
return False
raise e
else:
return True

def update(
self, item: Json, if_unmodified_since: Optional[datetime] = None
) -> Json:
if if_unmodified_since is not None:
raise NotImplementedError("if_unmodified_since not implemented")
item = item.copy()
id_ = item.pop("id", None)
if id_ is None:
raise DoesNotExist("resource", id_)
try:
result = self.provider.request("PATCH", self.path.format(id=id_), json=item)
assert result is not None
return result
except ApiException as e:
if e.status is HTTPStatus.NOT_FOUND:
raise DoesNotExist("resource", id_)
raise e
48 changes: 31 additions & 17 deletions clean_python/api_client/api_provider.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json as json_lib
import re
from http import HTTPStatus
from typing import Callable
from typing import Optional
Expand All @@ -21,6 +23,15 @@ def is_success(status: HTTPStatus) -> bool:
return (int(status) // 100) == 2


JSON_CONTENT_TYPE_REGEX = re.compile(r"^application\/[^+]*[+]?(json);?.*$")


def is_json_content_type(content_type: Optional[str]) -> bool:
if not content_type:
return False
return bool(JSON_CONTENT_TYPE_REGEX.match(content_type))


def join(url: str, path: str) -> str:
"""Results in a full url without trailing slash"""
assert url.endswith("/")
Expand Down Expand Up @@ -71,31 +82,34 @@ def request(
timeout: float = 5.0,
) -> Optional[Json]:
assert ctx.tenant is not None
url = join(self._url, path)
token = self._fetch_token(self._pool, ctx.tenant.id)
headers = {}
request_kwargs = {
"method": method,
"url": add_query_params(join(self._url, path), params),
"timeout": timeout,
}
# for urllib3<2, we dump json ourselves
if json is not None and fields is not None:
raise ValueError("Cannot both specify 'json' and 'fields'")
elif json is not None:
request_kwargs["body"] = json_lib.dumps(json).encode()
headers["Content-Type"] = "application/json"
elif fields is not None:
request_kwargs["fields"] = fields
token = self._fetch_token(self._pool, ctx.tenant.id)
if token is not None:
headers["Authorization"] = f"Bearer {token}"
response = self._pool.request(
method=method,
url=add_query_params(url, params),
json=json,
fields=fields,
headers=headers,
timeout=timeout,
)
response = self._pool.request(headers=headers, **request_kwargs)
status = HTTPStatus(response.status)
content_type = response.headers.get("Content-Type")
if content_type is None and status is HTTPStatus.NO_CONTENT:
return {"status": int(status)} # we have to return something...
if content_type != "application/json":
if status is HTTPStatus.NO_CONTENT:
return None
if not is_json_content_type(content_type):
raise ApiException(
f"Unexpected content type '{content_type}'", status=status
)
body = response.json()
if status is HTTPStatus.NOT_FOUND:
return None
elif is_success(status):
body = json_lib.loads(response.data.decode())
if is_success(status):
return body
else:
raise ApiException(body, status=status)
3 changes: 3 additions & 0 deletions clean_python/api_client/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ class ApiException(ValueError):
def __init__(self, obj: Any, status: HTTPStatus):
self.status = status
super().__init__(obj)

def __str__(self):
return f"{self.status}: {super().__str__()}"
12 changes: 12 additions & 0 deletions integration_tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
# (c) Nelen & Schuurmans

import asyncio
import multiprocessing
import os

import pytest
import uvicorn


def pytest_sessionstart(session):
Expand Down Expand Up @@ -39,3 +41,13 @@ async def postgres_url():
@pytest.fixture(scope="session")
async def s3_url():
return os.environ.get("S3_URL", "http://localhost:9000")


@pytest.fixture(scope="session")
async def fastapi_example_app():
port = int(os.environ.get("API_PORT", "8005"))
config = uvicorn.Config("fastapi_example:app", host="0.0.0.0", port=port)
p = multiprocessing.Process(target=uvicorn.Server(config).run)
p.start()
yield f"http://localhost:{port}"
p.terminate()
13 changes: 13 additions & 0 deletions integration_tests/fastapi_example/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from clean_python import InMemoryGateway
from clean_python.fastapi import Service

from .presentation import V1Books

service = Service(V1Books())

app = service.create_app(
title="Book service",
description="Service for testing clean-python",
hostname="testserver",
access_logger_gateway=InMemoryGateway([]),
)
14 changes: 14 additions & 0 deletions integration_tests/fastapi_example/application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from typing import Optional

from clean_python import InMemoryGateway
from clean_python import Manage

from .domain import Book
from .domain import BookRepository


class ManageBook(Manage[Book]):
def __init__(self, repo: Optional[BookRepository] = None):
if repo is None:
repo = BookRepository(InMemoryGateway([]))
self.repo = repo
16 changes: 16 additions & 0 deletions integration_tests/fastapi_example/domain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from clean_python import Repository
from clean_python import RootEntity
from clean_python import ValueObject


class Author(ValueObject):
name: str


class Book(RootEntity):
author: Author
title: str


class BookRepository(Repository[Book]):
pass
70 changes: 70 additions & 0 deletions integration_tests/fastapi_example/presentation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
from http import HTTPStatus
from typing import Optional

from fastapi import Depends
from fastapi import Form
from fastapi import Response
from fastapi import UploadFile

from clean_python import DoesNotExist
from clean_python import Page
from clean_python import ValueObject
from clean_python.fastapi import delete
from clean_python.fastapi import get
from clean_python.fastapi import patch
from clean_python.fastapi import post
from clean_python.fastapi import RequestQuery
from clean_python.fastapi import Resource
from clean_python.fastapi import v

from .application import ManageBook
from .domain import Author
from .domain import Book


class BookCreate(ValueObject):
author: Author
title: str


class BookUpdate(ValueObject):
author: Optional[Author] = None
title: Optional[str] = None


class V1Books(Resource, version=v(1), name="books"):
def __init__(self):
self.manager = ManageBook()

@get("/books", response_model=Page[Book])
async def list(self, q: RequestQuery = Depends()):
return await self.manager.filter([], q.as_page_options())

@post("/books", status_code=HTTPStatus.CREATED, response_model=Book)
async def create(self, obj: BookCreate):
return await self.manager.create(obj.model_dump())

@get("/books/{id}", response_model=Book)
async def retrieve(self, id: int):
return await self.manager.retrieve(id)

@patch("/books/{id}", response_model=Book)
async def update(self, id: int, obj: BookUpdate):
return await self.manager.update(id, obj.model_dump(exclude_unset=True))

@delete("/books/{id}", status_code=HTTPStatus.NO_CONTENT, response_class=Response)
async def destroy(self, id: int):
if not await self.manager.destroy(id):
raise DoesNotExist("object", id)

@get("/text")
async def text(self):
return Response("foo", media_type="text/plain")

@post("/form", response_model=Author)
async def form(self, name: str = Form()):
return {"name": name}

@post("/file")
async def file(self, file: UploadFile):
return {file.filename: (await file.read()).decode()}
62 changes: 62 additions & 0 deletions integration_tests/test_api_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pytest

from clean_python import ctx
from clean_python import DoesNotExist
from clean_python import Json
from clean_python import Tenant
from clean_python.api_client import SyncApiGateway
from clean_python.api_client import SyncApiProvider


class BooksGateway(SyncApiGateway, path="v1/books/{id}"):
pass


@pytest.fixture
def provider(fastapi_example_app) -> SyncApiProvider:
ctx.tenant = Tenant(id=2, name="")
yield SyncApiProvider(fastapi_example_app + "/", lambda a, b: "token")
ctx.tenant = None


@pytest.fixture
def gateway(provider) -> SyncApiGateway:
return BooksGateway(provider)


@pytest.fixture
def book(gateway: SyncApiGateway):
return gateway.add({"title": "fixture", "author": {"name": "foo"}})


def test_add(gateway: SyncApiGateway):
response = gateway.add({"title": "test_add", "author": {"name": "foo"}})
assert isinstance(response["id"], int)
assert response["title"] == "test_add"
assert response["author"] == {"name": "foo"}
assert response["created_at"] == response["updated_at"]


def test_get(gateway: SyncApiGateway, book: Json):
response = gateway.get(book["id"])
assert response == book


def test_remove_and_404(gateway: SyncApiGateway, book: Json):
assert gateway.remove(book["id"]) is True
assert gateway.get(book["id"]) is None
assert gateway.remove(book["id"]) is False


def test_update(gateway: SyncApiGateway, book: Json):
response = gateway.update({"id": book["id"], "title": "test_update"})

assert response["id"] == book["id"]
assert response["title"] == "test_update"
assert response["author"] == {"name": "foo"}
assert response["created_at"] != response["updated_at"]


def test_update_404(gateway: SyncApiGateway):
with pytest.raises(DoesNotExist):
gateway.update({"id": 123456, "title": "test_update_404"})
Loading

0 comments on commit 7aac476

Please sign in to comment.