Create a service from a blueprint

Overview

This tutorial will show you how to build a backend service based on one of the blueprints available in Walhall.

This tutorial is written in Flask. Versions of this tutorial in other backend frameworks are in the works.

If you want to spoil it for yourself, you can view the finished code here on GitHub.

What you’re going to build

In this tutorial, you will build a contact and appointment service in Flask. You could use it in your Walhall app as an appointment management system that allows you to make appointments for some contacts.

The service will contain two basic data models (Contact and Appointment) and endpoints for the standard CRUD operations for both.

The endpoint for returning a list of Appointments will return them in chronological order.

Authentication and authorization will be handled by BiFrost once you implement its public key (exposed as an environment variable) to decode the authorization token passed in the request header. The container where your service runs in Walhall will provide data storage.

The blueprint will come pre-configured with Walhall’s CI/CD pipeline.

Requirements

This tutorial is intended for backend developers who are familiar with building microservices.

You must have:

Set up the repository

First, you need to get the source code of the Flask service blueprint.

Go to the GitHub repository and click the Use this template button above the file list to copy the repository into your account. Note that later you will need to add it to your app as a custom logic module.
Screenshot: "Use this template" button in the Flask blueprint service repository

Once you have your own copy of the Flask blueprint, clone it to your local development environment.

Set up your development environment

Now you need to set up your local development environment in order to test the service as you build it. Navigate to the location where you cloned the service blueprint repo and make the following changes:

  1. Change the default service names in the docker-compose.yml and .drone.yml files to use the new name of your service:
    • postgres_blueprint_flask_servicepostgres_crm_flask_service
    • blueprint_flask_servicecrm_flask_service
  2. Add the Flask-Migrate requirement to requirements/base.txt:
    Flask-Migrate==2.5.2
  3. Open your terminal, navigate to the repo directory, and build the service container:
    $ docker-compose build
  4. Then, run the container:
    $ docker-compose up

The service will be exposed on port 8089. It will expose documentation for its endpoints at the /docs endpoint via SwaggerUI.

Create data models

Now that you’re all set up, it’s time to start writing some actual code. We’ll start by defining the data models for Contact and Appointment.

The Flask blueprint uses SQLAlchemy to map objects to your Walhall app’s database. The credentials for the database are stored in Walhall, so you don’t need to think about this; all you have to do is define the models and a database migration script using Flask-Migrate.

Update imports

Open app/models.py and insert the following import commands at the top:

# app/models.py
import uuid

from sqlalchemy.dialects.postgresql import UUID

Models

Create the Contact class:

# Model definition for Contact
class Contact(db.Model):
    uuid = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    first_name = db.Column(db.String(50), nullable=False)
    last_name = db.Column(db.String(50), nullable=False)
    company = db.Column(db.String(100))
    address = db.Column(db.String(100), nullable=False)
    phone = db.Column(db.String(50))
    email = db.Column(db.String(50))

    def __repr__(self):
        return f'<Contact {self.first_name} {self.last_name}>'

Underneath that, create the Appointment class:

# Model definition for Appointment
class Appointment(db.Model):
    uuid = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name = db.Column(db.String(50), nullable=False)
    start_date = db.Column(db.DateTime(), nullable=False)
    end_date = db.Column(db.DateTime(), nullable=False)
    organization_uuid = db.Column(UUID(as_uuid=True))
    contact_uuid = db.Column(UUID(as_uuid=True), db.ForeignKey('contact.uuid'), nullable=False)
    contact = db.relationship('Contact', backref=db.backref('appointments', lazy=True))

    def __repr__(self):
        return f'<Appointment {self.name}>'

Migrations

Now you need to add a call to run the migration.

In that same file, above the definition of the Contact class, add the following line:

migrate = Migrate(db=db)

Update container & prepare local database

With your service still running, open a new terminal window and run an interactive shell in the service’s container:

$ docker-compose exec crm_flask_service bash

In the shell, run these two commands to create a migration repository and perform the initial migration:

$ flask db init
$ flask db migrate

Now open the entrypoint script (scripts/docker-entrypoint.sh) for the Docker container and add a command for running the migration:

# scripts/docker-entrypoint.sh
flask db upgrade

And now go back to your terminal, stop the container, and then reload docker-compose:

$ docker-compose up

And with that, you’ve defined the data models for your service and created tables in your local database for testing them.

Define RESTful endpoints

The next step is to define the endpoints for the data models and their API resources. The Flask blueprint uses Flask-RESTPlus to help you create the endpoints faster and generate a Swagger schema for the service, which is required by BiFrost to connect to its API gateway.

Copy and paste the code from this section into app/resources.py.

Imports

Replace the import commands at the beginning of the file with this block:

# app/resources.py
from datetime import datetime

from flask import current_app as app, request, abort, g
from flask_restplus import Api, Resource, fields, inputs
from sqlalchemy import or_, and_
from .models import db, Contact, Appointment

Contact

Start by creating the API model for Contact beneath the commented line “Define your own resources here.” API models are used for generating the Swagger schema, serializing objects, and validating request data.

# API model for Contact
contact = api.model('Contact', {
    'uuid': fields.String(readonly=True, description='UUID'),
    'first_name': fields.String(required=True, description='First Name'),
    'last_name': fields.String(required=True, description='Last Name'),
    'company': fields.String(required=False, description='Company'),
    'address': fields.String(required=True, description='Address'),
    'phone': fields.String(required=False, description='Phone'),
    'email': fields.String(required=False, description='Email'),
})

Below that, you need to create the API resources:

  1. ContactList: API resource for returning a list of Contacts and creating a new Contact.
  2. ContactDetail: API resource for returning information about a specific Contact, updating the Contact, and deleting it.

These will define the CRUD operations for routes defined with the route decorator.

  • The marshal_with decorator takes the Contact model and serializes the output.
  • The expect decorator checks the input request data with the model and validates it.

Code for ContactList:

# ContactList API resource
@api.route('/contact/')
class ContactList(Resource):

    @api.marshal_with(contact, as_list=True)
    def get(self):
        return Contact.query.all()

    @api.marshal_with(contact)
    @api.expect(contact, validate=True)
    def post(self):
        kwargs = request.json
        contact = Contact(**kwargs)
        db.session.add(contact)
        db.session.commit()
        return contact, 201

Code for ContactDetail:

# ContactDetail API resource
@api.route('/contact/<string:contact_uuid>/')
class ContactDetail(Resource):

    @api.marshal_with(contact, as_list=True)
    def get(self, contact_uuid):
        contact = Contact.query.get_or_404(contact_uuid)
        return contact

    @api.marshal_with(contact, as_list=True)
    def put(self, contact_uuid):
        contact = Contact.query.get_or_404(contact_uuid)
        kwargs = request.json
        contact.first_name = kwargs.get('first_name')
        contact.last_name = kwargs.get('last_name')
        contact.company = kwargs.get('company')
        contact.address = kwargs.get('address')
        contact.phone = kwargs.get('phone')
        contact.email = kwargs.get('email')
        db.session.commit()
        return contact, 200

    def delete(self, contact_uuid):
        contact = Contact.query.get_or_404(contact_uuid)
        db.session.delete(contact)
        db.session.commit()
        return '', 204

Appointment

Start with the API model for Appointment:

# API model for Appointment
appointment = api.model('Appointment', {
    'uuid': fields.String(readonly=True, description='UUID'),
    'name': fields.String(required=True, description='Name'),
    'start_date': fields.DateTime(required=True, description='Start date'),
    'end_date': fields.DateTime(required=True, description='End date'),
    'organization_uuid': fields.String(readonly=True, description='Organization UUID'),
    'contact_uuid': fields.String(required=True, description='Contact UUID'),
    'contact': fields.Nested(contact, readonly=True, description='Contact')
})

API resources for Appointment:

  1. AppointmentList: API resource for returning a list of Appointments and creating a new Appointment.
  2. AppointmentDetail: API resource for returning information about a specific Appointment, updating the Appointment, and deleting it.

Note that the Appointment resources will only return the appointments to which the requester has permissions (based on the UUID of their organization).

Code for AppointmentList:

# AppointmentList API resource
@api.route('/appointment/')
class AppointmentList(Resource):

    @api.marshal_with(appointment, as_list=True)
    def get(self):
        query = Appointment.query
        args = appointment_parser.parse_args()
        if args.get('date'):
            date = args['date']
            day_start = datetime(date.year, date.month, date.day, 0, 0, 0)
            day_end = datetime(date.year, date.month, date.day, 23, 59, 59)
            query = query.filter(or_(
                and_(Appointment.start_date <= day_start, day_start <= Appointment.end_date),
                and_(day_start <= Appointment.start_date, Appointment.start_date <= day_end)
            ))

        if 'organization_uuid' in g:
            query = query.filter_by(organization_uuid=g.get('organization_uuid'))

        return query.all()

    @api.marshal_with(appointment)
    @api.expect(appointment, validate=True)
    def post(self):
        kwargs = request.json
        appointment = Appointment(**kwargs)
        appointment.organization_uuid = g.get('organization_uuid')
        db.session.add(appointment)
        db.session.commit()
        return appointment, 201

Code for AppointmentDetail:

# AppointmentDetail API resource
@api.route('/appointment/<string:appointment_uuid>/')
class AppointmentDetail(Resource):

    @api.marshal_with(appointment, as_list=True)
    def get(self, appointment_uuid):
        appointment = Appointment.query.get_or_404(appointment_uuid)
        if 'organization_uuid' in g and not appointment.organization_uuid == g.get('organization_uuid'):
            abort(404)
        return appointment

    @api.marshal_with(appointment, as_list=True)
    def put(self, appointment_uuid):
        appointment = Appointment.query.get_or_404(appointment_uuid)
        if 'organization_uuid' in g and not appointment.organization_uuid == g.get('organization_uuid'):
            abort(404)
        kwargs = request.json
        appointment.name = kwargs.get('name')
        appointment.start_date = kwargs.get('start_date')
        appointment.end_date = kwargs.get('end_date')
        appointment.contact_uuid = kwargs.get('contact_uuid')
        db.session.commit()
        return appointment, 200

    def delete(self, appointment_uuid):
        appointment = Appointment.query.get_or_404(appointment_uuid)
        if 'organization_uuid' in g and not appointment.organization_uuid == g.get('organization_uuid'):
            abort(404)
        db.session.delete(appointment)
        db.session.commit()
        return '', 204

API parsers

For our use case, the GET /appointment method must return a list of appointments sorted in chronological order. Directly underneath the Appointment API model definition, add the following lines:

appointment_parser = api.parser()
appointment_parser.add_argument('date', type=inputs.date_from_iso8601, help='Date', location='args')

Test locally

Now that you’ve got your models and API endpoints defined, it’s time to test it out locally. You can use the sample cURL commands below to test your service endpoints. We recommend using a program like Postman.

Create contact

Sample request:

curl -X POST \
  http://localhost:8089/contact/ \
  -H 'Content-Type: application/json' \
  -d '{
    "first_name": "Margaret",
    "last_name": "Hamilton",
    "company": "NASA",
    "address": "300 E St SW, Washington, DC 20546, USA",
    "phone": "+12023334444",
    "email": "margaret.hamilton@example.com"
}'

Sample response:

{
    "uuid": "aea876ae-a104-4835-9f5b-c0fbde5808f2",
    "first_name": "Margaret",
    "last_name": "Hamilton",
    "company": "NASA",
    "address": "300 E St SW, Washington, DC 20546, USA",
    "phone": "+12023334444",
    "email": "margaret.hamilton@example.com"
}

Get list of contacts

Sample request:

curl -X GET \
  http://localhost:8089/contact/ \
  -H 'Content-Type: application/json'

Sample response:

[
    {
        "uuid": "aea876ae-a104-4835-9f5b-c0fbde5808f2",
        "first_name": "Margaret",
        "last_name": "Hamilton",
        "company": "NASA",
        "address": "300 E St SW, Washington, DC 20546, USA",
        "phone": "+12023334444",
        "email": "margaret.hamilton@example.com"
    }
]

Update contact

Sample request:

curl -X PUT \
  http://localhost:8089/contact/{contact-uuid}/ \
  -H 'Content-Type: application/json' \
  -d '{
    "first_name": "John",
    "last_name": "Glenn",
    "company": "NASA",
    "address": "300 E St SW, Washington, DC 20546, USA",
    "phone": "+12023334444",
    "email": "john.glenn@example.com"
}'

Sample response:

{
    "uuid": "aea876ae-a104-4835-9f5b-c0fbde5808f2",
    "first_name": "John",
    "last_name": "Glenn",
    "company": "NASA",
    "address": "300 E St SW, Washington, DC 20546, USA",
    "phone": "+12023334444",
    "email": "john.glenn@example.com"
}

Delete contact

Sample request:

curl -X DELETE \
  http://localhost:8089/contact/{contact-uuid}/ \
  -H 'Content-Type: application/json'

Sample response:

204 NO CONTENT

Create appointment

Before you test this endpoint, you should do POST /contact again and then use the UUID of the created contact in this request.

Sample request:

curl -X POST \
  http://localhost:8089/appointment/ \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Documentation party",
    "start_date": "2019-08-26T13:05:46+00:00",
    "end_date": "2019-08-27T13:05:46+00:00",
    "contact_uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13"
}'

Sample response:

{
    "uuid": "d6a1b13c-7497-4f30-afc6-b3df7510d0e6",
    "name": "Documentation party",
    "start_date": "2019-08-26T13:05:46",
    "end_date": "2019-08-27T13:05:46",
    "organization_uuid": null,
    "contact_uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13",
    "contact": {
        "uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13",
        "first_name": "Margaret",
        "last_name": "Hamilton",
        "company": "NASA",
        "address": "300 E St SW, Washington, DC 20546, USA",
        "phone": "+12023334444",
        "email": "margaret.hamilton@example.com"
    }
}

Get list of appointments

Sample request:

curl -X GET \
  http://localhost:8089/appointment/ \
  -H 'Content-Type: application/json'

Sample response:

[
    {
        "uuid": "d6a1b13c-7497-4f30-afc6-b3df7510d0e6",
        "name": "Documentation party",
        "start_date": "2019-08-26T13:05:46",
        "end_date": "2019-08-27T13:05:46",
        "organization_uuid": null,
        "contact_uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13",
        "contact": {
            "uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13",
            "first_name": "Margaret",
            "last_name": "Hamilton",
            "company": "NASA",
            "address": "300 E St SW, Washington, DC 20546, USA",
            "phone": "+12023334444",
            "email": "margaret.hamilton@example.com"
        }
    }
]

Update appointment

Sample request:

curl -X PUT \
  http://localhost:8089/appointment/{appointment-uuid}/ \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Documentation festival",
    "start_date": "2019-09-26T13:05:46+00:00",
    "end_date": "2019-09-27T13:05:46+00:00",
    "contact_uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13"
}'

Sample response:

{
    "uuid": "d6a1b13c-7497-4f30-afc6-b3df7510d0e6",
    "name": "Documentation festival",
    "start_date": "2019-09-26T13:05:46",
    "end_date": "2019-09-27T13:05:46",
    "organization_uuid": null,
    "contact_uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13",
    "contact": {
        "uuid": "bdbc3fd2-66fb-4b79-9d6a-4e927d382b13",
        "first_name": "Margaret",
        "last_name": "Hamilton",
        "company": "NASA",
        "address": "300 E St SW, Washington, DC 20546, USA",
        "phone": "+12023334444",
        "email": "margaret.hamilton@example.com"
    }
}

Delete appointment

Sample request:

curl -X DELETE \
  http://localhost:8089/appointment/{appointment-uuid}/ \
  -H 'Content-Type: application/json'

Sample response:

204 NO CONTENT

Implement JWT authentication

Now that you know that the service is working, all you need to do is add authentication with BiFrost. See the BiFrost JWT documentation for more detailed information on how this works.

The Flask blueprint takes BiFrost’s public key from the environment variable JWT_PUBLIC_KEY_RSA_BIFROST and the UUID of the organization where the request originated in app/config.py. These variables are used to create the auth_required decorator in app/auth.py, which checks each type of API request for the correct authorization.

Now open app/resources.py. At the top, import the auth_required decorator:

# app/resources.py
...
from .auth import auth_required

Then, insert @auth_required above each API route (e.g., def get(), def post(), etc.). Example:

# app/resources.py
...
    @auth_required
    @api.marshal_with(contact, as_list=True)
    def get(self):
        return Contact.query.all()

If you wish to continue testing the service locally, then change the JWT_DISABLED flag in app/config.py to True:

# app/config.py

class Config:
    ...
     JWT_DISABLED = True

Deploy the service

Now your service is ready to be deployed to Walhall. At this point, if you took the second option and created your service repo with “Use this template,” you need to add the service to your app as a custom logic module.

Create a new tag for your service with the name v1.0.0 and push this tag. This will trigger the build process for the latest version of the service.

Then, log in to Walhall and go to the app where you added your new service. You should see your custom CRM service logic module. Click Update and wait for the new tag to appear. Select the new tag from the Version dropdown and click Deploy.

When the deployment process is finished, you will be able to access the /contact and /appointment endpoints over your app’s API URL.