Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for ApiGateway and ApiProvider #17

Merged
merged 4 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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