An API with a Repository
Overview
We know how to build a very simple API with FastAPI and we also know how to use the Repository Pattern to isolate repositories. Let's use them together to create an API with three endpoints to create a new book, get all books, and get one book given an id.
For this test, we'll need to use a client to call the API endpoints, and for this reason, we need to add requests
package to the requirements file:
requirements.txt
@@ -1,2 +1,3 @@
fastapi==0.61.0
pytest==6.0.1
+requests==2.24.0
and install it:
pip install -r requirements.txt
Example
First step, we need to create a new test file:
touch tests/test_03_api_with_repository.py
Initial Test
Let's start with the more simple test: getting all books. To do it, we add an initial test:
tests/test_03_api_with_repository.py
@@ -0,0 +1,21 @@
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture
+def app():
+ app = FastAPI()
+
+ return app
+
+
+@pytest.fixture
+def client(app):
+ return TestClient(app)
+
+
+def test_get_all_books_successfully(app, client) -> None:
+ url_get_books = app.url_path_for("books:get-all-books")
+ response_get_books = client.get(url_get_books)
+ assert response_get_books.status_code == 200
First API Endpoint
To create the first API endpoint, we need to add a new route to handle the GET requests. Here we can see how to use the dependency injection to load the in-memory repository to be used from the endpoint.
tests/test_03_api_with_repository.py
@@ -1,18 +1,30 @@
+from typing import List
+
import pytest
-from fastapi import FastAPI
+from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
+
+from tests.repositories import InMemoryBookRepository
+from tests.schemas import Book
@pytest.fixture
def app():
app = FastAPI()
+
+ @app.get("/", name="books:get-all-books")
+ def get_all_books(
+ books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
+ ) -> List[Book]:
+ books = books_repo.list()
+ return books
return app
@pytest.fixture
def client(app):
return TestClient(app)
def test_get_all_books_successfully(app, client) -> None:
Create a New Book
Next step is to create a test to create new books, and implement the API route to handle POST requests. This endpoint
it also uses Depends
to inject the repository.
tests/test_03_api_with_repository.py
@@ -12,22 +12,44 @@
def app():
app = FastAPI()
@app.get("/", name="books:get-all-books")
def get_all_books(
books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
) -> List[Book]:
books = books_repo.list()
return books
+ @app.post("/", name="books:create-book", status_code=201)
+ def create_new_book(
+ book: Book,
+ books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
+ ) -> Book:
+ book_db = books_repo.add(book)
+ return book_db
+
return app
@pytest.fixture
def client(app):
return TestClient(app)
def test_get_all_books_successfully(app, client) -> None:
url_get_books = app.url_path_for("books:get-all-books")
response_get_books = client.get(url_get_books)
assert response_get_books.status_code == 200
+
+
+def test_create_valid_book_successfully(app, client, a_book) -> None:
+ url_create_book = app.url_path_for("books:create-book")
+ response_new_book = client.post(url_create_book, json={'book': a_book.dict()})
+ assert response_new_book.status_code == 201
+
+ new_book_json = response_new_book.json()
+ assert isinstance(new_book_json, dict)
+
+ assert 'id' in new_book_json
+ assert len(new_book_json['id']) == 36
+
+ assert a_book == Book(**new_book_json)
Test Invalid Data
Ok, now that we can add a new book to the repository using the API, we need to be sure that only valid data is used. Let's add a new test to check that an attempt to create an invalid book returns an error code.
tests/test_03_api_with_repository.py
@@ -46,10 +46,17 @@
response_new_book = client.post(url_create_book, json={'book': a_book.dict()})
assert response_new_book.status_code == 201
new_book_json = response_new_book.json()
assert isinstance(new_book_json, dict)
assert 'id' in new_book_json
assert len(new_book_json['id']) == 36
assert a_book == Book(**new_book_json)
+
+
+def test_invalid_create_book_raises_error(app, client) -> None:
+ url_create_book = app.url_path_for("books:create-book")
+ empty_payload = {}
+ response_new_book = client.post(url_create_book, json=empty_payload)
+ assert response_new_book.status_code == 422
It looks that everything works fine!
Get by ID
Finally, we define a new test to check the get-by-id API endpoint, and we create the route for the new GET method.
tests/test_03_api_with_repository.py
@@ -1,30 +1,39 @@
from typing import List
+from uuid import UUID
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from tests.repositories import InMemoryBookRepository
from tests.schemas import Book
@pytest.fixture
def app():
app = FastAPI()
@app.get("/", name="books:get-all-books")
def get_all_books(
books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
) -> List[Book]:
books = books_repo.list()
return books
+
+ @app.get("/{book_id}/", name="books:get-book-by-id")
+ def get_book_by_id(
+ book_id: UUID,
+ books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
+ ) -> Book:
+ book = books_repo.get(book_id=book_id)
+ return book
@app.post("/", name="books:create-book", status_code=201)
def create_new_book(
book: Book,
books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
) -> Book:
book_db = books_repo.add(book)
return book_db
return app
@@ -53,10 +62,25 @@
assert len(new_book_json['id']) == 36
assert a_book == Book(**new_book_json)
def test_invalid_create_book_raises_error(app, client) -> None:
url_create_book = app.url_path_for("books:create-book")
empty_payload = {}
response_new_book = client.post(url_create_book, json=empty_payload)
assert response_new_book.status_code == 422
+
+
+def test_get_book_by_id_successfully(app, client, a_book, another_book) -> None:
+ url_create_book = app.url_path_for("books:create-book")
+
+ response_new_book_1 = client.post(url_create_book, json={'book': a_book.dict()})
+ assert response_new_book_1.status_code == 201
+ book1_id = response_new_book_1.json()['id']
+
+ response_new_book_2 = client.post(url_create_book, json={'book': another_book.dict()})
+ assert response_new_book_2.status_code == 201
+
+ get_book_url = app.url_path_for("books:get-book-by-id", book_id=book1_id)
+ response_get_book = client.get(get_book_url)
+ assert a_book == Book(**response_get_book.json())
All green!
Code Refactor
As we have seen in other cases, it's a good practice to move all the code different than a test to its own file,
sometimes to the conftest.py
and only for testing, and sometimes will be to other files to be able to reuse the code
when writing the app. In this case, we extract the fixture app
that contains the simple FastAPI app with its routes,
and also the client
fixture, to do the requests.
tests/test_03_api_with_repository.py
@@ -1,54 +1,11 @@
-from typing import List
-from uuid import UUID
-
-import pytest
-from fastapi import Depends, FastAPI
-from fastapi.testclient import TestClient
-
-from tests.repositories import InMemoryBookRepository
from tests.schemas import Book
-
-
-@pytest.fixture
-def app():
- app = FastAPI()
-
- @app.get("/", name="books:get-all-books")
- def get_all_books(
- books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
- ) -> List[Book]:
- books = books_repo.list()
- return books
-
- @app.get("/{book_id}/", name="books:get-book-by-id")
- def get_book_by_id(
- book_id: UUID,
- books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
- ) -> Book:
- book = books_repo.get(book_id=book_id)
- return book
-
- @app.post("/", name="books:create-book", status_code=201)
- def create_new_book(
- book: Book,
- books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
- ) -> Book:
- book_db = books_repo.add(book)
- return book_db
-
- return app
-
-
-@pytest.fixture
-def client(app):
- return TestClient(app)
def test_get_all_books_successfully(app, client) -> None:
url_get_books = app.url_path_for("books:get-all-books")
response_get_books = client.get(url_get_books)
assert response_get_books.status_code == 200
def test_create_valid_book_successfully(app, client, a_book) -> None:
url_create_book = app.url_path_for("books:create-book")
tests/conftest.py
@@ -1,13 +1,54 @@
+from typing import List
+from uuid import UUID
+
import pytest
+from fastapi import FastAPI, Depends
+from starlette.testclient import TestClient
+from tests.repositories import InMemoryBookRepository
from tests.schemas import Book
+
+
+@pytest.fixture
+def app():
+ app = FastAPI()
+
+ @app.get("/", name="books:get-all-books")
+ def get_all_books(
+ books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
+ ) -> List[Book]:
+ books = books_repo.list()
+ return books
+
+ @app.get("/{book_id}/", name="books:get-book-by-id")
+ def get_book_by_id(
+ book_id: UUID,
+ books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
+ ) -> Book:
+ book = books_repo.get(book_id=book_id)
+ return book
+
+ @app.post("/", name="books:create-book", status_code=201)
+ def create_new_book(
+ book: Book,
+ books_repo: InMemoryBookRepository = Depends(InMemoryBookRepository)
+ ) -> Book:
+ book_db = books_repo.add(book)
+ return book_db
+
+ return app
+
+
+@pytest.fixture
+def client(app):
+ return TestClient(app)
@pytest.fixture()
def a_book() -> Book:
return Book(title="A nice title", author="John Smith")
@pytest.fixture()
def another_book() -> Book:
return Book(title="Another nice title", author="Jane Boo")