The 2nd The Twelve-Factor App on AWS & Django(Let’s create backend APIs)

DevOps

Introduction

In the previous article (Part 1), I wrote about the purpose and significance of developing The Twelve-Factor App.

I want to implement and develop, as shown in the figure below. Since building the infrastructure will start to cost a lot of money, I would like to begin implementing the app.

Create backend API

We want to create a TODO app to manage tasks that everyone has used at least once.
The backend of the TODO app will be a RESTful API in Django, and the frontend will be a SPA in Nuxt.

This time we would like to create the backend API. The library and version to be used are as follows.

Language/FW/LibraryVersion
macOS12.3.1
Python3.10.6
pyenv2.3.0-49-gae22c695
pip22.2.2
pipenv2022.8.5
Django4.1
djangorestframework3.13.1
pytest7.1.2
pytest-django4.5.2
pytest-factoryboy2.5.0
pytest-freezegun0.4.2
pytest-cov3.0.0

First, we will build a Python environment, which we will install using pyenv so that we can switch versions later. Next, please install Python according to your environment. Finally, I used the following command to install it on my Mac.

% brew update

% brew install pyenv

% echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zshrc

% echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zshrc

% echo 'eval "$(pyenv init -)"' >> ~/.zshrc

% exec "$SHELL"

Once pyenv is installed, create the app.

// Create a directory to store the app
% mkdir sample-ecs-todo-app

// move to the directory you created
% cd sample-ecs-todo-app

// Install Python3.10.6
% pyenv install 3.10.6

// Set the Python version to 3.10.6
% pyenv global 3.10.6

// Update pip to the latest version
% pip install --upgrade pip

// Install pipenv
% pip install pipenv

// Install Django to create a Django project
% pip install django

// Create a directory to store back-end apps
% mkdir backend

// move to the directory where you created the project
% cd backend

// Create a project with django-admin command
% django-admin startproject config .

// Set environment variables to create a virtual environment under the project directory
% export PIPENV_VENV_IN_PROJECT=true

// Create a virtual environment with Python 3.10.6
% pipenv --python 3.10.6

// Install Django
% pipenv install django

// Install django-environ(gives you an easy way to configure Django application using environment variables)
% pipenv install django-environ

// Install psycopg2-binary(PostgreSQL database adapter for Python)
% pipenv install psycopg2-binary

// Install Gunicorn​(Python HTTP server)
% pipenv install gunicorn

// Execute migration
% python manage.py migrate

// Create a superuser
% python manage.py createsuperuser
Username (leave blank to use 'admin'): admin
Email address: admin@example.com
Password:
Password (again):
Superuser created successfully.

// Run dev server
% python manage.py runserver

Go to http://127.0.0.1:8000/, and your Django setup is complete if you see the below image.

Install djangorestframework to create a RESTful API in Django.

// Install djangorestframework
% pipenv install djangorestframework

// Create a TODO app
% python manage.py startapp todo

Once you have created the TODO application template, create and edit the following files.

backend/config/settings.py

from pathlib import Path

import environ

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/

+ env = environ.Env()
+ dot_env_path = BASE_DIR / '.env'
+ if dot_env_path.exists():
+     env.read_env(str(dot_env_path))


# SECURITY WARNING: keep the secret key used in production secret!
- SECRET_KEY = 'django-insecure-5&cyjjqq=+l^mye=6ton2m%f6=)3vs)q%7^4^66sl_y+y_+tdw'
+ SECRET_KEY = env.str('SECRET_KEY', 'django-insecure-5&cyjjqq=+l^mye=6ton2m%f6=)3vs)q%7^4^66sl_y+y_+tdw')

# SECURITY WARNING: don't run with debug turned on in production!
- DEBUG = True
+ DEBUG = env.bool('DEBUG', False)

- ALLOWED_HOSTS = []
+ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['127.0.0.1', 'localhost'])

+ LOCAL_DEV = env.bool('LOCAL_DEV', default=False)


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
+     'rest_framework',
+     'todo',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
    'middlewares.RequestLogMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'config.wsgi.application'


# Database
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases

+ if LOCAL_DEV:
+     DATABASES = {
+         'default': {
+             'ENGINE': 'django.db.backends.sqlite3',
+             'NAME': BASE_DIR / 'db.sqlite3',
+             'TEST': {
+                 'CHARSET': 'UTF8',
+                 'NAME': ':memory:',
+             },
+         }
+     }
+ else:
+     DATABASES = {
+         'default': env.db(),
+     }

# Password validation
# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/4.1/topics/i18n/

- LANGUAGE_CODE = 'en-us'
+ LANGUAGE_CODE = 'ja'

- TIME_ZONE = 'UTC'
+ TIME_ZONE = 'Asia/Tokyo'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.1/howto/static-files/

STATIC_URL = 'static/'

# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

+ # logging
+ LOGGING = {
+     'version': 1,
+     'disable_existing_loggers': True,
+     'formatters': {
+         'backend': {
+             '()': 'formatter.JsonFormatter',
+             'format': '[%(levelname)s]\t%(asctime)s.%(msecs)dZ\t%(levelno)s\t%(message)s\n'
+         }
+     },
+     'handlers': {
+         'console': {
+             'level': 'INFO',
+             'class': 'logging.StreamHandler',
+             'formatter': 'backend',
+         },
+     },
+     'loggers': {
+         'django.request': {
+             'handlers': ['console'],
+             'level': 'INFO',
+         },
+         'django.db.backends': {
+             'handlers': ['console'],
+             'level': 'DEBUG',
+         },
+         'backend': {
+             'handlers': ['console'],
+             'level': 'DEBUG',
+             'propagate': False,
+         },
+     },
+ }

Define environment variables in the .env file for development in the local environment.

backend/.env

+ SECRET_KEY=django-insecure-5&cyjjqq=+l^mye=6ton2m%f6=)3vs)q%7^4^66sl_y+y_+tdw
+ DEBUG=True
+ LOCAL_DEV=True

Create a formatter to structure the logs into JSON format for easier viewing in AWS CloudWatch logs.

backend/formatter.py

+ import json
+ import logging
+ import traceback
+ 
+ 
+ class JsonFormatter(logging.Formatter):
+     def format(self, record):
+         if self.usesTime():
+             record.asctime = self.formatTime(record, self.datefmt)
+ 
+         json_log = {
+             'aws_request_id': getattr(record, 'aws_request_id', '00000000-0000-0000-0000-000000000000'),
+             'log_level': str(getattr(record, 'levelname', '')),
+             'timestamp': '%(asctime)s.%(msecs)dZ' % dict(asctime=record.asctime, msecs=record.msecs),
+             'aws_request_id': getattr(record, 'aws_request_id', '00000000-0000-0000-0000-000000000000'),
+             'message': record.getMessage(),
+             'status_code': str(getattr(record, 'status_code', '')),
+             'execution_time': str(getattr(record, 'execution_time', '')),
+             'stack_trace': {},
+         }
+ 
+         request = getattr(record, 'request', None)
+ 
+         if request:
+             json_log = {
+                 'aws_cf_id': request.META.get('HTTP_X_AMZ_CF_ID', ''),
+                 'aws_trace_id': request.META.get('HTTP_X_AMZN_TRACE_ID', ''),
+                 'x_real_ip': request.META.get('HTTP_X_REAL_IP', ''),
+                 'x_forwarded_for': request.META.get('HTTP_X_FORWARDED_FOR', ''),
+                 'request_method': request.method,
+                 'request_path': request.get_full_path(),
+                 'request_body': request.request_body,
+                 'user_agent': request.META.get('HTTP_USER_AGENT', ''),
+                 'user': str(getattr(request, 'user', '')),
+                 'stack_trace': {},
+             }
+ 
+         if record.exc_info:
+             json_log['stack_trace'] = traceback.format_exc().splitlines()
+ 
+         return json.dumps(json_log, ensure_ascii=False)

Create middleware using a formatter logging HTTP requests in JSON format.

backend/middlewares.py

+ import copy
+ import json
+ import logging
+ import re
+ import time
+ 
+ from django.utils.deprecation import MiddlewareMixin
+ 
+ logger = logging.getLogger('backend')
+ 
+ 
+ class RequestLogMiddleware(MiddlewareMixin):
+     """Request Logging Middleware."""
+ 
+     def __init__(self, *args, **kwargs):
+         """Constructor method."""
+         super().__init__(*args, **kwargs)
+ 
+     def is_json_format(self, request_body):
+         try:
+             json.loads(request_body)
+         except json.JSONDecodeError:
+             return False
+ 
+         return True
+ 
+     def process_request(self, request):
+         """Set Request Start Time to measure time taken to service request."""
+         request.start_time = time.time()
+         request.request_body = ''
+         if request.method in ['PUT', 'POST', 'PATCH']:
+             try:
+                 request_body = request.body.decode('utf-8')
+ 
+                 if request_body and self.is_json_format(request_body):
+                     request.request_body = json.loads(request_body)
+                 else:
+                     request.request_body = request_body
+             except UnicodeDecodeError:
+                 request.request_body = copy.deepcopy(request.body)
+ 
+     def process_response(self, request, response):
+         status_code = getattr(response, 'status_code', '')
+         log_info = {
+             'request': request,
+             'status_code': status_code,
+             'execution_time': time.time() - request.start_time,
+         }
+         if status_code == 200:
+             logger.info(msg='OK', extra=log_info)
+         return response

Create a model to store todo in DB.

backend/todo/models.py

+ from django.db import models
+ 
+ 
+ class Todo(models.Model):
+     ACTIVE = 0
+     DONE = 1
+     STATUS = ((ACTIVE, 'active'), (DONE, 'done'))
+ 
+     title = models.CharField(max_length=30)
+     description = models.TextField(blank=True, null=True)
+     status = models.SmallIntegerField(choices=STATUS, default=ACTIVE)
+     created_at = models.DateTimeField(auto_now_add=True)

Allows the data in the Todo table to change from the Django admin.

backend/todo/admin.py

+ from django.contrib import admin
+ 
+ from todo import models
+ 
+ 
+ @admin.register(models.Todo)
+ class TodoAdmin(admin.ModelAdmin):
+     list_display = ['id', 'title', 'description', 'status', 'created_at']
+     search_fields = ['title']

backend/todo/serializers.py

+ from rest_framework import serializers
+ 
+ from todo.models import Todo
+ 
+ 
+ class TodoSerializer(serializers.ModelSerializer):
+     class Meta:
+         model = Todo
+         fields = ['id', 'title', 'description', 'created_at']
+ 
+     def create(self, validated_data):
+         return Todo.objects.create(**validated_data)
+ 
+     def update(self, instance, validated_data):
+         instance.status = validated_data.get('status', instance.status)
+         instance.save()
+         return instance

Create an API to retrieve, create, and update Todo.

backend/todo/views.py

+ from rest_framework import viewsets
+ 
+ from todo.models import Todo
+ from todo.serializers import TodoSerializer
+ 
+ 
+ class TodoViewSet(viewsets.ModelViewSet):
+     queryset = Todo.objects.all().order_by('-id')
+     serializer_class = TodoSerializer

Associate the created Todo API with the URL.

backend/todo/urls.py

+ from rest_framework import routers
+ 
+ from todo.views import TodoViewSet
+ 
+ router = routers.DefaultRouter()
+ router.register(r'todos', TodoViewSet)

Publish URLs externally.

backend/config/urls.py

+ from todo.urls import router

urlpatterns = [
      path('admin/', admin.site.urls),
+     path('api/', include(router.urls)),
]

Once you have added or edited the above code, execute the following command for DB migration.

// Make migration files
% python manage.py makemigrations

// Confirm migration files
% python manage.py showmigrations

// Execute migration
% python manage.py migrate

Go to http://127.0.0.1:8000/admin/ to access the Django admin page and log in with the superuser account you created above.

Once logged in, click the “Add” button to go to the add page, fill out the form, and click the “SAVE” button to create a todo.

Once created, go to http://127.0.0.1:8000/api/todos/ to get the created TODO in JSON format, as shown below.

I have completed the implementation of the backend API.

Since we would like to build CI later, we implement automated API tests and produce coverage reports.

First, install the necessary libraries to implement automated tests.

// Install pytest(Library to makes it easy to write small, readable tests)
% pipenv install --dev pytest

// Install pytest-django(Library to provide useful tools for testing Django)
% pipenv install --dev pytest-django 

// Install pytest-factoryboy(Library to provide a tool for creating on demand pre-configured objects)
% pipenv install --dev pytest-factoryboy

// Install pytest-freezegun(Library to fix time)
% pipenv install --dev pytest-freezegun

// Install pytest-cov(Libraries to produce coverage)
% pipenv install --dev pytest-cov

Once you have installed the library, create a following empty file.

backend/todo/tests/__init__.py

+ 

Create a fixture that produces test data for Todo.

backend/todo/tests/factories.py

+ import factory
+ 
+ from todo.models import Todo
+ 
+ 
+ class TodoFactory(factory.django.DjangoModelFactory):
+     class Meta:
+         model = Todo
+ 
+     id = 1
+     title = 'Implement API'
+     description = 'Implement an API to retrieve Todo'
+     status = 0

Implement an automated test of the API. Register two Todos and verify that you can retrieve two Todos via the API in descending order of ID.

backend/todo/tests/tests_views.py

+ import datetime
+ import json
+ import zoneinfo
+ 
+ import pytest
+ from rest_framework.test import APIRequestFactory
+ 
+ from todo.tests.factories import TodoFactory
+ from todo.views import TodoViewSet
+ 
+ 
+ @pytest.mark.freeze_time(datetime.datetime(2022, 8, 11, 9, 0, 0, tzinfo=zoneinfo.ZoneInfo('Asia/Tokyo')))
+ @pytest.mark.django_db
+ def tests_should_get_two_todos():
+     TodoFactory()
+     TodoFactory(id=2, title='Code Review', description='Review Pull Request #1')
+ 
+     client = APIRequestFactory()
+     todo_list = TodoViewSet.as_view({'get': 'list'})
+ 
+     request = client.get('/api/todos/')
+     response = todo_list(request)
+     response.render()
+ 
+     assert response.status_code == 200
+     assert json.loads(response.content) == [
+         {
+             'id': 2,
+             'title': 'Code Review',
+             'description': 'Review Pull Request #1',
+             'status': 0,
+             'created_at': '2022-08-11T09:00:00+09:00'
+         },
+         {
+             'id': 1,
+             'title': 'Implement API',
+             'description': 'Implement an API to retrieve Todo',
+             'status': 1,
+             'created_at': '2022-08-11T09:00:00+09:00'
+         }
+     ]

Create pytest settings.

backend/pytest.ini

+ [pytest]
+ DJANGO_SETTINGS_MODULE = config.settings
+ python_files = tests_*.py

Once you have created the above file, run pytest –cov –cov-report=html -v. If you can run it, you should be able to see the following test results.

% pytest --cov --cov-report=html -v 
======================================================================================= test session starts ========================================================================================
platform darwin -- Python 3.10.6, pytest-7.1.2, pluggy-1.0.0 -- /Users/staff/Dev/sample-ecs-todo-app/backend/.venv/bin/python
cachedir: .pytest_cache
django: settings: config.settings (from ini)
rootdir: /Users/staff/Dev/sample-ecs-todo-app/backend, configfile: pytest.ini
plugins: freezegun-0.4.2, factoryboy-2.5.0, Faker-13.15.1, django-4.5.2, cov-3.0.0
collected 1 item

todo/tests/tests_views.py::tests_should_get_two_todos PASSED  

You could confirm that the automated test passed and the API is working as expected.

You also could confirm the coverage in backend/htmlcov directory, and by opening index.html, you can see it for each file. Then, by clicking on a file, you can see each file’s code is covered and not covered.

If you open backend/todo/serializers.py, it will highlight the codes covered in green and those not in red.

The backend implementation is now complete. The final directory structure is now as follows.

sample-ecs-todo-app
└── backend
    ├── Pipfile
    ├── Pipfile.lock
    ├── README.md
    ├── __init__.py
    ├── config
    │   ├── __init__.py
    │   ├── asgi.py
    │   ├── settings.py
    │   ├── urls.py
    │   └── wsgi.py
    ├── db.sqlite3
    ├── manage.py
    ├── pytest.ini
    └── todo
        ├── __init__.py
        ├── admin.py
        ├── apps.py
        ├── migrations
        │   ├── 0001_initial.py
        │   └── __init__.py
        ├── models.py
        ├── serializers.py
        ├── tests
        │   ├── __init__.py
        │   ├── factories.py
        │   └── tests_views.py
        ├── tests.py
        ├── urls.py
        └── views.py

I have pushed the above source code to Github, please clone it.

In the following article, I want to create a front-end SPA with Nuxt.

コメント

タイトルとURLをコピーしました