Create an API using Flask, MongoDB and Docker
Tags: docker, postman, flask, mongodb
Published: 2023-06-15 16:17:01

In this article, we'll look at creating an API using the Python package, Flask, using MongoDB for our database, creating Postman tests and running everything with Docker.

![Architecture Diagram](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ahjhj8n58olyu8jzbfsg.png)

You can find all the code I'll go through in my [example-api](https://github.com/tkav/example-api) repo.

## Setting up the containers

First, we'll set up the containers using docker-compose. In a new project folder, create a `docker-compose.yml` file with the following:

```yaml
version: '3.9'

services:

  db:
    image: mongo:5.0
    ports:
      - 27017:27017
    networks:
      - backend
    env_file: .env

  api:
    build: ./
    ports:
      - 5000:5000
    volumes:
      - ./src:/src
    networks:
      - frontend
      - backend
    env_file: .env

  postman:
    image: postman/newman:alpine
    command:
      run ${POSTMAN_COLLECTION} -k
      -r cli,json
      --reporter-json-export="reports/api_test_report.json"
    volumes:
      - ./tests:/etc/newman
    depends_on:
      - "api"
      - "db"
    networks:
      - backend

networks:
  frontend:
  backend:
```

We have 3 containers:

* `db` - Used for our database, running a MongoDB image
    
* `api` - Used for our Flask API, built from a `Dockerfile`
    
* `postman` - Used for testing our API, executing postman collection tests
    

### Flask API

For the Flask API, create a `Dockerfile` with the following:

```dockerfile
FROM python:3.8-slim-buster

WORKDIR /app

COPY src/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ .

CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0"]
```

Create a new `src` folder in the project folder with a `requirements.txt` file:

```bash
mkdir src
touch requirements.txt
```

For the contents of `requirements.txt` add the following:

```plaintext
flask
flask-pymongo
```

These packages will be installed when our container is built.

Next, we need to create the app, create an `app.py` file in the `src` folder with the following:

```python
import os
import json
from flask import Flask, request
from flask_pymongo import PyMongo
from bson import json_util, ObjectId

MONGODB_URI = os.environ.get("MONGODB_ENDPOINT")

app = Flask(__name__)
app.config["MONGO_URI"] = MONGODB_URI
mongo = PyMongo(app)

def parse_json(data):
    return json.loads(json_util.dumps(data))

@app.route('/')
def hello_world():
    return 'Hello, World!'

@app.route('/items', methods=['GET'])
def get_all_items():
    items = list(mongo.db.items.find())
    return parse_json(items), 200

@app.route('/items', methods=['POST'])
def create_item():
    item = request.get_json()
    inserted_item = mongo.db.items.insert_one(item)
    return parse_json(inserted_item.inserted_id), 201

@app.route('/items/<item_id>', methods=['GET'])
def get_item(item_id):
    item = mongo.db.items.find_one_or_404({'_id': ObjectId(item_id)})
    return parse_json(item), 200

@app.route('/items/<item_id>', methods=['PUT'])
def update_item(item_id):
    item = request.get_json()
    item_id_obj = ObjectId(item_id)
    result = mongo.db.items.update_one({'_id': item_id_obj}, {'$set': item})
    if result.matched_count == 0:
        return parse_json({'error': 'Item not found'}), 404
    updated_item = mongo.db.items.find_one({'_id': item_id_obj})
    return parse_json({'message': 'Item updated successfully', 'item': updated_item}), 200

@app.route('/items/<item_id>', methods=['DELETE'])
def delete_item(item_id):
    item_id_obj = ObjectId(item_id)
    result = mongo.db.items.delete_one({'_id': item_id_obj})
    if result.deleted_count == 0:
        return parse_json({'error': 'Item not found'}), 404
    return parse_json({'message': 'Item deleted successfully'}), 200

if __name__ == "__main__":
    app.run(debug=True)
```

This is our entire app. At the top of the script, we're importing the required packages, initialising the Flask App and MongoDB instance.

We have a `parse_json` function for formatting the response from MongoDB, a `hello_world` function and CRUD operations for creating, reading, updating and deleting records.

You can see in the script, we're using an environmental variable, `os.environ.get("MONGODB_ENDPOINT")` . To set this, create a `.env` file in the project root directory with the following:

```bash
MONGODB_ENDPOINT=mongodb://db:27017/example
POSTMAN_COLLECTION=Example_API.postman_collection.json
```

### Make Commands

Finally, create a `Makefile` in the project root directory with the following:

```makefile
DOCKER_COMPOSE = docker-compose up -d 
POSTMAN_COLLECTION ?=

.PHONY: rebuild
rebuild:
	docker-compose up -d --build

.PHONY: start
start: _start_api _start_db

.PHONY: stop
stop:
	docker-compose down

.PHONY: tests
tests:
	@echo "Running tests"
	docker-compose up postman

_start_api:
	$(DOCKER_COMPOSE) api

_start_db:
	$(DOCKER_COMPOSE) db
```

This Makefile allows us to easily start and stop the docker containers.

Our `start` command only starts the `api` and `db` containers. The `postman` container will only run when we execute the `tests` method, which runs the tests immediately and will then terminate the container.

## Running the API

By now, your project directory should look like this:

```plaintext
.
├── Dockerfile
├── Makefile
├── docker-compose.yml
├── .env
└── src
    ├── app.py
    └── requirements.txt
```

We can now run the API!

To build and start the API, run:

```bash
make start
```

Go to http://localhost:5000. You should see a "Hello, World!" message:

![Browser screenshot showing Hello World message](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gn3fmst3e1xk9vw2hf7v.png)

### **🎉 The API is up and running!**

The API has the following endpoints available:

| Endpoint | Method | Description |
| --- | --- | --- |
| `/` | `GET` | Hello World |
| `/items` | `GET` | Get All Items |
| `/items/{id}` | `GET` | Get Item |
| `/items` | `POST` | Create Item |
| `/items/{id}` | `PUT` | Update Item |
| `/items/{id}` | `DELETE` | Delete Item |

**If you want to add tests to your API, keep reading!**

## Testing the API

With the API up and running, we can write our Postman tests.

Open [Postman](https://www.postman.com/downloads/) and create a New Collection.

Click the collection name and go to the `Variables` tab.

Create a new variable called `base_url` with the initial and current value, `http://localhost:5000`. Click the 💾 Save icon.

![Screenshot of Postman Collection variables](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rifyvsc0p7okce2rl79u.png)

Click `Add a request` and create a request called 'Hello World'. This will be a get request. Set the request URL to `{{base_url}}`, this will use the value from our collection variable. Click `Save` and `Send`.

Postman will execute the request and you will see the response from your API below:

![Screenshot of Postman request](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wo5z27o9j1cv13sq3foq.png)

Open the `Tests` tab. On the right, there is a `SNIPPETS` toolbar. In the list, click `Status code: Code is 200`. This will add a test like this:

```javascript
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});
```

Click `Save` and `Send`. The test will be run and shown in the output panel, in the `Test Results` tab:

![Screenshot showing Postman test results](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l2aen42dx6kz57gba3vp.png)

We've now set up a test for our `Hello World` endpoint. Now we need to create tests for the other endpoints.

Repeat this process, creating a new request and test for each of the endpoints:

### Get All Items

**Request Name:** GET All Items  
**Request Method:** GET  
**Request URL:** `{{base_url}}/items`

**Test:**

```javascript
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});
```

### Create Item

**Request Name:** CREATE Item  
**Request Method:** POST  
**Request URL:** `{{base_url}}/items`

**Test:**

```javascript
pm.test("Status code is 201", function () {
    pm.response.to.have.status(201);
});
pm.test("Returned item id", function () {
    jsonData = pm.response.json()
    pm.collectionVariables.set("item_id", jsonData.$oid);
});
```

For this test, the successful response status code after item creation is `201`. We also have a second test to obtain the returned `item_id` of our created item and set it as a collection variable. We can then use the value on future tests.

**Body:**

As this is a `POST` request, we will be sending through content to create the item. In the `Body` tab, click `raw` and paste the following:

```json
{
    "title": "Hello, World!"
}
```

This content will be sent to our API when the request is executed.

### Get Item

**Request Name:** GET Item  
**Request Method:** GET  
**Request URL:** `{{base_url}}/items/{{item_id}}`

**Test:**

```javascript
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});
pm.test("Returned result matches test item", function () {
    jsonData = pm.response.json()
    item_id = pm.collectionVariables.get("item_id");
    pm.expect(jsonData._id.$oid).to.eql(item_id);
    pm.expect(jsonData.title).to.eql("Hello, World!");
});
```

This request uses the saved `item_id` from the collection values. In the tests, we are checking the response is successful (`200`) and verifying the returned `item_id` and content matches what we created in the `Create Item` test.

### Update Item

**Request Name:** UPDATE Item  
**Request Method:** PUT  
**Request URL:** `{{base_url}}/items/{{item_id}}`

**Test:**

```javascript
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});
pm.test("Result contains updated item", function () {
    jsonData = pm.response.json()
    item_id = pm.collectionVariables.get("item_id");
    pm.expect(jsonData.item._id.$oid).to.eql(item_id);
    pm.expect(jsonData.item.title).to.eql("Hello, there!");
});
```

Again, this request uses the saved `item_id` from the collection values. This time we're checking the returned `item_id` matches the item we modified, and the `title` matches the updated value from our request `Body`.

**Body:**

```json
{
    "title": "Hello, there!"
}
```

### Delete Item

**Request Name:** DELETE Item  
**Request Method:** DELETE  
**Request URL:** `{{base_url}}/items/{{item_id}}`

**Test:**

```javascript
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});
```

## Executing all the tests

With all the requests and tests added to our collection, we can run them all together.

Click the three dots next to the collection name and click `Run collection`. Click `Run Example API` to execute all the tests.

Postman will run through the tests on your API and provide a summary:

![Screenshot of Postman collection test summary](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/um8qzehoqdzjd39nhvah.png)

**✅ If everything is set up correctly, all tests should be successful!**

## Automating the tests

The Postman collection containing the requests and tests can be exported by clicking the three dots next to the collection name and `Export`. Export the collection to your project folder, into a new folder called `tests`. Name the collection `Example API.postman_collection.json`.

With the collection in our project we can run `make tests`. This will run the `postman` container and execute the tests. You may notice all the tests have failed. This is because the `base_url` variable is set to `http://localhost:5000` and when running from the `postman` container, the Flask API is unreachable.

To fix this, add `--env-var "base_url=${API_BASE_URL}"` to the postman container defined in `docker-compose.yml`:

```yaml
  postman:
    image: postman/newman:alpine
    command:
      run ${POSTMAN_COLLECTION} -k 
      --env-var "base_url=${API_BASE_URL}"
      -r cli,json
      --reporter-json-export="reports/api_test_report.json"
    volumes:
      - ./tests:/etc/newman
    depends_on:
      - "api"
      - "db"
    networks:
      - backend
```

And add `API_BASE_URL=http://api:5000` to the `.env` file:

```bash
MONGODB_ENDPOINT=mongodb://db:27017/example
POSTMAN_COLLECTION=Example_API.postman_collection.json
API_BASE_URL=http://api:5000
```

`api` refers to the `api` docker container defined in `docker-compose.yml`. By using this, the `postman` container will be able to reach the Flask API. Now re-run `make tests` and they should all be successful.

### GitHub Actions

In the project directory, create a `.github` folder, a `workflows` folder within that and a `test.yml` file:

```bash
mkdir .github
mkdir .github/workflows
touch .github/workflows/test.yml
```

In the `.github/workflows/test.yml` file, add the following:

```yaml
name: Test API
on:
  push:
    branches:
      - main
  pull_request:
    branches:    
      - main

jobs:
  test-api:
    environment:
      name: tests
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Run tests
      run: make tests
```

Commit and push your changes to GitHub. GitHub Actions will now run `make tests` when changes are pushed or a pull-request to `main` is created:

![Screenshot of postman tests running in Github Actions](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2q5ts3mmkn33ir0zy1of.png)

## **🎉 You now have an example API with automated tests!**

If you got stuck with any steps or just want the complete solution, refer to, or clone my [example-api](https://github.com/tkav/example-api) repo.