Persisting Domain Specific Data
- Introduction
- Goals
- Prerequisites
- Configuring the Project
- Adding the Datastore
- Debugging Locally
- Testing Locally
- Building the Component
- Deploying the Component
- Testing Remotely
- Conclusion
Introduction
So far the HelloWorldService has just returned canned responses that are baked into the implementation. This was a deliberate strategy to demonstrate a minimal application with few dependencies. To make our service more useful we need to store and return real data, which we will now do.
Goals
- persist domain specific data
- make real HelloWorldService requests
Prerequisites
Configuring the Project
Before we can update the service to handle real data we need to update the project dependencies and configuration to the database. In this project we will be using CockroachDB.
helloworld.yml
When a service is deployed to MSX it must pick up the database configuration from Consul and Vault. The table below shows where to get those values with values.
Service | Name | Example |
---|---|---|
consul | {prefix}/defaultapplication/db.cockroach.host | cockroachdb-public.vms.svc.cluster.local |
consul | {prefix}/defaultapplication/db.cockroach.port | 26257 |
consul | {prefix}/defaultapplication/db.cockroach.sslmode | verify-full |
consul | {prefix}/helloworldservice/db.cockroach.databaseName | helloworld |
consul | {prefix}/helloworldservice/db.cockroach.username | helloworldservice_5cf38a82c57b4872b425bb89b0d3250d |
vault | {prefix}/helloworldservice | vzorfs0UFr124K5zoevP |
helloworld.yml | cockroach.cacert | /etc/ssl/certs/ca-bundle.crt |
The prefix depends on the version of MSX you are running:
MSX Version | Prefix |
---|---|
<= 4.0.0 | thirdpartyservices |
>= 4.1.0 | thirdpartycomponents |
When developing you can run Consul, Vault, and CockroachDB locally (help me). You can pass the required CockroachDB configuration in helloworld.yml
by adding the following.
.
.
.
cockroach:
host: "127.0.0.1"
port: "26257"
databasename: "helloworld"
username: "root"
sslmode: "disable"
cacert: "/etc/ssl/certs/ca-bundle.crt" # Required by MSX.
.
.
.
manifest.yml
When our service is deployed MSX creates the database for us, and populates Vault and Consul with the correct values. Then in our configuration code we will read those values to create our database connection string. We have to update manifest.yml
to tell MSX which database we want to use.
.
.
.
Infrastructure:
Database:
Type: Cockroach
Name: "helloworld"
.
.
.
config.py
In previous guides we created config.py
to bootstrap Consul and Vault into our service. That same module also serves as a common place for us to store other configuration. Update config.py
to include a structure to store the CockroachDB values. Note that they will be populated from Consul, Vault, and helloworld.yml
, depending on whether your service is running on local infrastructure or in an MSX environment.
Add a named tuple to config.py
for the Cockroach configuration:
.
.
.
ConsulConfig = namedtuple("ConsulConfig", ["host", "port", "cacert"])
VaultConfig = namedtuple("VaultConfig", ["scheme", "host", "port", "token", "cacert"])
CockroachConfig = namedtuple("CockroachConfig", ["host", "port", "databasename", "username", "sslmode", "cacert"])
.
.
.
Then populate it in the __init__
method:
def __init__(self, resource_name):
.
.
.
# Apply environment variables and create Vault config object.
config["vault"]["scheme"] = environ.get("SPRING_CLOUD_VAULT_SCHEME", config["vault"]["scheme"])
config["vault"]["host"] = environ.get("SPRING_CLOUD_VAULT_HOST", config["vault"]["host"])
config["vault"]["port"] = environ.get("SPRING_CLOUD_VAULT_PORT", config["vault"]["port"])
config["vault"]["token"] = environ.get("SPRING_CLOUD_VAULT_TOKEN", config["vault"]["token"])
self.vault = VaultConfig(**config["vault"])
# Create Cockroach config object.
self.cockroach = CockroachConfig(**config["cockroach"])
.
.
.
Dockerfile
No changes are required in the Dockerfile.
Makefile
No changes are required in the Makefile.
requirements.txt
The code we added above has dependencies on Vault, so we have to update requirements.txt
.
Flask==1.1.2
Flask-Cors==3.0.10
flask-restplus==0.13.0
Werkzeug==0.16.1
PyYAML==5.4.1
python-consul==1.1.0
urllib3==1.26.5
hvac==0.10.14
psycopg2-binary==2.9.1
Adding the Datastore
We need to work on a few more files before the database integration is complete.
helpers/cockroach_helper.py
The module helpers/cockroach_helper.py
provides the code to connect to CockroachDB and perform CRUD operations.
import uuid
import logging
import psycopg2
from config import Config
from helpers.consul_helper import ConsulHelper
from helpers.vault_helper import VaultHelper
new_language_dict = {'id': '55f3028f-1b94-4edd-b14f-183b51b33d68',
'name': 'Russian',
'description': 'An East Slavic language that uses the Cyrillic alphabet.'}
new_item_dict = {'id': '62ef8e5f-628a-4f8b-92c9-485981205d92',
'languageid': '55f3028f-1b94-4edd-b14f-183b51b33d68',
'languagename': 'Russian',
'value': 'Привет мир!'}
class CockroachHelper(object):
def __init__(self, config: Config):
consul_helper = ConsulHelper(config.consul)
vault_helper = VaultHelper(config.vault)
cockroach_config = config.cockroach
self._conn = None
# Common configuration.
self._host = consul_helper.get_string(
f"{config.config_prefix}/defaultapplication/db.cockroach.host",
cockroach_config.host)
self._port = consul_helper.get_string(
f"{config.config_prefix}/defaultapplication/db.cockroach.port",
cockroach_config.port)
self._sslmode = consul_helper.get_string(
f"{config.config_prefix}/defaultapplication/db.cockroach.sslmode",
cockroach_config.sslmode)
# Application configuration.
self._databasename = consul_helper.get_string(
f"{config.config_prefix}/helloworldservice/db.cockroach.databaseName",
cockroach_config.databasename)
self._username = consul_helper.get_string(
f"{config.config_prefix}/helloworldservice/db.cockroach.username",
cockroach_config.username)
self._password = vault_helper.get_string(
f"{config.config_prefix}/helloworldservice",
"db.cockroach.password",
"")
self._cacert = cockroach_config.cacert
def __enter__(self):
if self._sslmode == 'disable':
connection_str = f'postgres://{self._username}:{self._password}@{self._host}:{self._port}/{self._databasename}?sslmode={self._sslmode}'
else:
connection_str = f'postgres://{self._username}:{self._password}@{self._host}:{self._port}/{self._databasename}?sslmode={self._sslmode}&sslrootcert={self._cacert}'
logging.info(f'Opening database connection')
self._conn = psycopg2.connect(connection_str)
logging.info(f'Connection status={self._conn.status}')
return self
def __exit__(self, ex_type, ex_value, traceback):
logging.info('Closing database connection ...')
self._conn.close()
logging.info('Database connection closed')
if ex_type:
logging.info(f'{ex_type}{ex_value}{traceback}')
return False # Return True if you want to suppress full exception message and stack trace.
def log_status(self):
logging.info(f'Database connection status={self._conn.status}')
def log_column(self, table, column):
rows = self.get_rows(table)
res = [row[column] for row in rows]
logging.info(f'{column}@{table}= {str(res)}')
def test(self):
logging.info(self.create_table('Languages', ['id', 'name', 'description']))
logging.info(self.create_table('Items', ['id', 'languageid', 'languagename', 'value']))
self.log_column('Languages', 'name')
self.log_column('Items', 'languagename')
row = self.insert_row('Languages', new_language_dict)
logging.info(row)
language_id = row['id']
row = self.insert_row('Items', new_item_dict)
logging.info(row)
item_id = row['id']
self.log_column('Languages', 'name')
self.log_column('Items', 'languagename')
self.log_column('Items', 'value')
logging.info(self.update_row('Items', item_id, {'value': 'Привет рим!'}))
self.log_column('Items', 'value')
logging.info(self.delete_row('Languages', language_id))
logging.info(self.delete_row('Items', item_id))
self.log_column('Languages', 'name')
self.log_column('Items', 'languagename')
def get_rows(self, table_name):
listof_rows = []
query = f'SELECT * FROM {table_name}'
logging.info(f'Database executing={query}')
with self._conn.cursor() as cur:
cur.execute(query)
columns = [desc[0] for desc in cur.description]
rows = cur.fetchall()
self._conn.commit()
for row in rows:
row_dict = dict(zip(columns, row))
listof_rows.append(row_dict)
statusmessage = cur.statusmessage
logging.info(f'Database status message={statusmessage}')
return listof_rows
def get_row(self, tablename, keyvalue):
query = f"SELECT * FROM {tablename} where ID='{keyvalue}'"
row = {}
logging.info(f'Database executing={query}')
with self._conn.cursor() as cur:
cur.execute(query)
columns = [desc[0] for desc in cur.description]
dbrow = cur.fetchone()
self._conn.commit()
statusmessage = cur.statusmessage
if dbrow:
row = dict(zip(columns, dbrow))
logging.info(f'Database status message={statusmessage}')
return row
def update_row(self, tablename, id, row_values_dict):
l = [k + "='" + v + "'" for (k, v) in row_values_dict.items() if v]
set_pairs = ','.join(l)
update_clause = f"UPDATE {tablename} SET {set_pairs} WHERE id = '{id}'"
logging.info(f'Database executing={update_clause}')
with self._conn.cursor() as cur:
cur.execute(update_clause)
statusmessage = cur.statusmessage
self._conn.commit()
logging.info(f'Database status message={statusmessage}')
return self.get_row(tablename, id)
def create_table(self, tablename, col_name_list):
columns = ' STRING, '.join(col_name_list) + ' STRING' + ', PRIMARY KEY (' + col_name_list[0] + ')'
create_clause = f'CREATE TABLE IF NOT EXISTS {tablename} ({columns})'
logging.info(f'Database executing={create_clause}')
with self._conn.cursor() as cur:
cur.execute(create_clause)
statusmessage = cur.statusmessage
self._conn.commit()
logging.info(f'Database status message={statusmessage}')
return statusmessage
def insert_row(self, tablename, row_values_dict):
row_values_dict['id'] = str(uuid.uuid4())
columns = ','.join(row_values_dict.keys())
values = ','.join("'" + key + "'" for key in row_values_dict.values())
upsert_clause = f'UPSERT INTO {tablename} ({columns}) VALUES ({values})'
logging.info(f'Database executing={upsert_clause}')
with self._conn.cursor() as cur:
cur.execute(upsert_clause)
statusmessage = cur.statusmessage
self._conn.commit()
logging.info(f'Database status message={statusmessage}')
return row_values_dict
def delete_row(self, tablename, id):
delete_clause = f"DELETE FROM {tablename} WHERE ID='{id}'"
logging.info(f'Database executing={delete_clause}')
with self._conn.cursor() as cur:
cur.execute(delete_clause)
statusmessage = cur.statusmessage
self._conn.commit()
logging.info(f'Database status message={statusmessage}')
return statusmessage
def delete_rows(self, tablename):
delete_clause = f"DELETE FROM {tablename}"
logging.info(f'Database executing={delete_clause}')
with self._conn.cursor() as cur:
cur.execute(delete_clause)
statusmessage = cur.statusmessage
self._conn.commit()
logging.info(f'Database status message={statusmessage}')
return statusmessage
models/language.py
Update models/language.py
so that the constructor can populate an instance from a database row.
class Language:
def __init__(self, id=None, name=None, description=None, row=None):
if row:
self._id = row["id"]
self._name = row["name"]
self._description = row["description"]
else:
self._id = id
self._name = name
self._description = description
def to_dict(self):
return {
"id": self._id,
"name": self._name,
"description": self._description
}
models/item.py
Update models/item.py
so that the constructor can populate an instance from a database row.
class Item:
def __init__(self, id=None, language_id=None, language_name=None, value=None, row=None):
if row:
self._id = row.get("id", None)
self._language_id = row.get("languageid", None)
self._language_name = row.get("languagename", None)
self._value = row.get("value", None)
else:
self._id = id
self._language_id = language_id
self._language_name = language_name
self._value = value
def to_dict(self):
return {
"id": self._id,
"languageId": self._language_id,
"languageName": self._language_name,
"value": self._value
}
controllers/languages_controller.py
Update controllers/languages_controller.py
to perform database operations instead of returning fixed responses.
import http
import logging
from flask_restplus import Resource
from flask_restplus import reqparse
from models.language import Language
from helpers.cockroach_helper import CockroachHelper
LANGUAGE_INPUT_ARGUMENTS = ['name', 'description']
LANGUAGE_NOT_FOUND = 'Language not found'
class LanguagesApi(Resource):
def __init__(self, *args, **kwargs):
self._config = kwargs["config"]
def get(self):
with CockroachHelper(self._config) as db:
rows = db.get_rows('Languages')
logging.info(rows)
languages = [Language(row=x) for x in rows]
return [x.to_dict() for x in languages], http.HTTPStatus.OK
def post(self):
parser = reqparse.RequestParser()
[parser.add_argument(arg) for arg in LANGUAGE_INPUT_ARGUMENTS]
args = parser.parse_args()
logging.info(args)
with CockroachHelper(self._config) as db:
row = db.insert_row('Languages', args)
return Language(row=row).to_dict(), http.HTTPStatus.CREATED
class LanguageApi(Resource):
def get(self, id):
with CockroachHelper(self._config) as db:
row = db.get_row('Languages', id)
if not row:
return LANGUAGE_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return Language(row=row).to_dict(), http.HTTPStatus.OK
def put(self, id):
parser = reqparse.RequestParser()
[parser.add_argument(arg) for arg in LANGUAGE_INPUT_ARGUMENTS]
args = parser.parse_args()
logging.info(args)
with CockroachHelper(self._config) as db:
row = db.update_row('Languages', id, args)
if not row:
return LANGUAGE_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return Language(row=row).to_dict(), http.HTTPStatus.OK
def delete(self, id):
with CockroachHelper(self._config) as db:
result = db.delete_row("Languages", id)
if result == "DELETE 1":
return None, http.HTTPStatus.NO_CONTENT
return LANGUAGE_NOT_FOUND, http.HTTPStatus.NOT_FOUND
controllers/items_controller.py
Update controllers/languages_controller.py
to perform database operations instead of returning fixed responses.
import http
import logging
from flask_restplus import Resource
from flask_restplus import reqparse
from helpers.cockroach_helper import CockroachHelper
from models.item import Item
ITEM_INPUT_ARGUMENTS = ['languageId', 'value']
ITEM_NOT_FOUND = 'Item not found'
LANGUAGE_NOT_FOUND = 'Language not found'
LANGUAGE_ID_IS_REQUIRED = 'Language id is required'
class ItemsApi(Resource):
def __init__(self, *args, **kwargs):
self._config = kwargs["config"]
def get(self):
with CockroachHelper(self._config) as db:
rows = db.get_rows('Items')
logging.info(rows)
items = [Item(row=x) for x in rows]
return [x.to_dict() for x in items], http.HTTPStatus.OK
def post(self):
parser = reqparse.RequestParser()
[parser.add_argument(arg) for arg in ITEM_INPUT_ARGUMENTS]
args = parser.parse_args()
logging.info(args)
if "languageId" not in args or not args["languageId"]:
return LANGUAGE_ID_IS_REQUIRED, http.HTTPStatus.BAD_REQUEST
args['languageid'] = args.pop('languageId')
with CockroachHelper(self._config) as db:
language_row = db.get_row('Languages', args["languageid"])
logging.info(language_row)
if not language_row:
return LANGUAGE_NOT_FOUND, http.HTTPStatus.BAD_REQUEST
args["languagename"] = language_row["name"]
row = db.insert_row('Items', args)
return Item(row=row).to_dict(), http.HTTPStatus.CREATED
return None, http.HTTPStatus.INTERNAL_SERVER_ERROR
class ItemApi(Resource):
def get(self, id):
with CockroachHelper(self._config) as db:
row = db.get_row('Items', id)
if not row:
return ITEM_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return Item(row=row).to_dict(), http.HTTPStatus.OK
def put(self, id):
parser = reqparse.RequestParser()
[parser.add_argument(arg) for arg in ITEM_INPUT_ARGUMENTS]
args = parser.parse_args()
logging.info(args)
if 'languageId' not in args or not args['languageId']:
return LANGUAGE_ID_IS_REQUIRED, http.HTTPStatus.BAD_REQUEST
args['languageid'] = args.pop('languageId')
with CockroachHelper(self._config) as db:
language_row = db.get_row('Languages', args["languageid"])
if not language_row:
return LANGUAGE_NOT_FOUND, http.HTTPStatus.BAD_REQUEST
args["languagename"] = language_row["name"]
row = db.update_row('Items', id, args)
if not row:
return ITEM_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return Item(row=row).to_dict(), http.HTTPStatus.OK
def delete(self, id):
with CockroachHelper(self._config) as db:
result = db.delete_row('Items', id)
if result != 'DELETE 1':
return ITEM_NOT_FOUND, http.HTTPStatus.NOT_FOUND
return result, http.HTTPStatus.NO_CONTENT
app.py
The controllers above needs the application configuration in order to connect to the database. Pass that configuration to the controllers in app.py
as shown below.
.
.
.
with CockroachHelper(config) as db:
db.test()
api = Api(app)
api.add_resource(ItemsApi, "/helloworld/api/v1/items", resource_class_kwargs={"config": config})
api.add_resource(ItemApi, "/helloworld/api/v1/items/<id>", resource_class_kwargs={"config": config})
api.add_resource(LanguagesApi, "/helloworld/api/v1/languages", resource_class_kwargs={"config": config})
api.add_resource(LanguageApi, "/helloworld/api/v1/languages/<id>", resource_class_kwargs={"config": config})
.
.
.
Debugging Locally
At this juncture we are code complete, and all that remains is to test. If you are feeling lucky build, package, and deploy the component into MSX and cross your fingers that everything is correct. However running the service locally makes it easier to debug. First spin up Consul, Vault, and CockroachDB using Docker Desktop instances, then create the “helloworld” database (help me).
Testing Locally
Once Hello World Service is running, experiment with commands below in a terminal window. Remember we defined the contract for this API as an OpenAPI Specification so refer to that for details of requests (help me).
Creating Languages
Create an entry for the French language with POST request. Note that the “id” returned will be different in your request.
$ curl --request POST "http://localhost:8080/helloworld/api/v1/languages" \
--header "Content-Type: application/json" \
--data '{"name": "English", "description": "A West Germanic language that uses the Roman alphabet."}'
RESPONSE HTTP-201 Created
{
"id":"dbedcc96-6669-4286-bc72-84fc2c7623b8",
"name":"English",
"description":"A West Germanic language that uses the Roman alphabet."
}
$ curl --request POST "http://localhost:8080/helloworld/api/v1/languages" \
--header "Content-Type: application/json" \
--data '{"name": "French", "description": "A West Germanic language that uses the Roman alphabet."}'
RESPONSE HTTP-201 Created
{
"id":"0e118c70-d000-4acd-8c58-e649ce5d6fe4",
"name":"French",
"description":"A Romance language descended from the Vulgar Latin of the Roman Empire."
}
Getting All Languages
Get a list of all languages using a GET request.
$ curl --request GET "http://localhost:8080/helloworld/api/v1/languages" \
--header "Content-Type: application/json"
RESPONSE HTTP-200 OK
[
{
"id":"0e118c70-d000-4acd-8c58-e649ce5d6fe4",
"name":"French",
"description":"A Romance language descended from the Vulgar Latin of the Roman Empire."
},
{
"id":"dbedcc96-6669-4286-bc72-84fc2c7623b8",
"name":"English",
"description":"A West Germanic language that uses the Roman alphabet."
}
]
Getting Single Languages
Retrieve a language by copying the “id” returned by the GET request.
$ curl --request GET "http://localhost:8080/helloworld/api/v1/languages/0e118c70-d000-4acd-8c58-e649ce5d6fe4" \
--header "Content-Type: application/json"
RESPONSE HTTP-200 OK
{
"id":"0e118c70-d000-4acd-8c58-e649ce5d6fe4",
"name":"French",
"description":"A Romance language descended from the Vulgar Latin of the Roman Empire."
}
Updating Languages
We can change the description of the language with a PUT request.
$ curl --request PUT "http://localhost:8080/helloworld/api/v1/languages/0e118c70-d000-4acd-8c58-e649ce5d6fe4" \
--header "Content-Type: application/json" \
--data '{"name": "French", "description": "French evolved from the Latin spoken in Gaul by Asterix."}'
RESPONSE HTTP-200 OK
{
"id":"0e118c70-d000-4acd-8c58-e649ce5d6fe4",
"name":"French",
"description":"French evolved from the Latin spoken in Gaul by Asterix."
}
Deleting Languages
Finally, delete a language with a DELETE request.
$ curl --request DELETE "http://localhost:8080/helloworld/api/v1/languages/0e118c70-d000-4acd-8c58-e649ce5d6fe4"
RESPONSE HTTP-204 No Content
Creating Greeting Items
We deleted the French language item, but we can still create a greeting item for English.
$ curl --request POST "http://localhost:8080/helloworld/api/v1/items" \
--header "Content-Type: application/json" \
--data '{ "languageId": "dbedcc96-6669-4286-bc72-84fc2c7623b8", "value": "Hello, World!"}'
RESPONSE HTTP-201 OK
{
"id":"023cd7f6-37ef-4e85-90b8-9441ca2b1163",
"languageId":"dbedcc96-6669-4286-bc72-84fc2c7623b8",
"languageName":"English",
"value":"Hello, World!"
}
Now that you know how to make language and greeting requests try adding some languages and greetings of your own.
Building the Component
Like we did in earlier guides build the component helloworldservice-1.0.0-component.tar.gz
by calling make with component “NAME” and “VERSION” parameters. If you do not see helloworld.yml
being added to the tarball you need to back and check the Makefile.
$ make NAME=helloworldservice VERSION=1.0.0
.
.
.
docker save helloworldservice:1.0.0 | gzip > helloworldservice-1.0.0.tar.gz
tar -czvf helloworldservice-1.0.0-component.tar.gz manifest.yml helloworld.yml helloworldservice-1.0.0.tar.gz
a manifest.yml
a helloworld.yml
a helloworldservice-1.0.0.tar.gz
rm -f helloworldservice-1.0.0.tar.gz
Deploying the Component
Log in to your MSX environment and deploy helloworldservice-1.0.0-component.tar.gz
using MSX UI->Settings->Components (help me). If the helloworldservice is already deployed, delete it before uploading it again.
Testing Remotely
Now that you have deployed your service to an MSX environment you can make remote calls but there is a wrinkle, you need to pass a valid access token in the request. The best way to iron that out is to complete the Swagger guide (help me), but before you can do that you have to create the SSO Security Clients (help me).
Conclusion
In this guide we persisted domain specific data to CockroachDB. We walked through testing locally, which is better for development purposes, but also deploying to a real system.
PREVIOUS | NEXT | HOME |