Skip to content

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")