Creating abstract classes with Lambda and Terraform
Objective
- Understand Python's abstract classes
- Implement a few concrete classes
- Understand basic software principles
- Create foundation for scalable serverless deployment with Lambda functions
Introduction
In this article I want to touch on different things. I have been working with AWS Lambda and Terraform for a while now and I am constantly trying to improve my knowledge. By making a small project I hope to give a good idea of a minimal Terraform deployment for people that are new to Terraform.
In previous years I have been working as a Software Engineer where code was written in a more consistent way and software practices were followed according to the different Software Design Patterns (Gang of Four Patterns). I have noticed that in my current team everybody understands Python and PySpark, but often code is simply written as a sequential script rather than a proper structure with appropriate classes. This was not a problem when my team was still small, but since we are scaling here at MarketInvoice, more people are working on the code base. For example, we have a variety of AWS Lambda functions that crawl data from third parties (i.e. Jira, PagerDuty) which are all written as standalone functions with (a lot) of overlapping code. The idea is to create a common class with shared functionality which can then be used by the different crawlers and avoid repeating writing the same code over and over again (Don't repeat yourself - Wikipedia). By utilizing these software principles, I am sure my team will become more productive and can spend more time on adding more interesting data to our data platform and reduce fixing bugs.
Initialization
First I create the Git repository for this tutorial on Github. After creating the repo, I clone it to my code directory.
~/code $ git clone https://github.com/jitsejan/abstract-lambdas-terraform.git
I have the alias that will open PyCharm from my command line so I can simply do the following:
~/code $ which charm
/usr/local/bin/charm
~/code $ cd abstract-lambdas-terraform
~/code/abstract-lambdas-terraform $ charm .
No IDE instance has been found. New one will be started.
After opening PyCharm, make sure to add the .idea
folder to the .gitignore
.
~/code/abstract-lambdas-terraform $ echo .idea >> .gitignore
Additionally, add the API key from IGDB (see below) to your shell, either add the export IGDB_KEY="<KEY>"
to ~/.bashrc
or ~/.zshrc
and run source
on the file you've just changed to make variable available in your terminal and Python.
~/code/abstract-lambdas-terraform $ echo $IGDB_KEY
<KEY>
Testing
I will use the public gaming database from IGDB: Free Video Game Database API as a source for this project. Check GitHub - public-apis/public-apis for other APIs. Make sure to sign-up and get your personal API key to interact with the IGDB API. Test the API with Postman to ensure the credentials are working and you understand the endpoints. Add your API key as user-key
to the Headers field. At the query to the (raw) Body field.
First I hit the platform
endpoint to retrieve the ID for the N64 platform.
Second I hit the games endpoint to filter for N64 games that contain Mario in the name.
Implementation
Single file
Let's first start by recreating the Postman test in Python with the following code to get the platform ID for the N64.
""" __main__.py """
import os
import requests
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def main():
""" Main function """
response = requests.get(BASE_URL.format(endpoint='platforms'),
data='fields id; where abbreviation = "N64";',
headers=HEADERS)
print(response.json())
if __name__ == "__main__":
main()
Running this in the terminal will print the response with a list containing ID 4.
~/code/abstract-lambdas-terraform $ python .
[{u'id': 4}]
After cleaning up the code by introducing a private function the code looks like this:
""" __main__.py """
import os
import requests
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def _get_platform_id(platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
response = requests.get(BASE_URL.format(endpoint=endpoint),
data=query.format(abbr=platform_abbreviation),
headers=HEADERS)
return response.json()[0]["id"]
def main():
""" Main function """
print(_get_platform_id("N64"))
if __name__ == "__main__":
main()
Let's add the second endpoint function to retrieve the games for the platform like we did in the second Postman call.
""" __main__.py """
import json
import os
import requests
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def _get_games(platform_id, name=None):
""" Get the games for a given platform and an optional name filter """
endpoint = 'games'
if name:
query = 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;'
else:
query = 'fields name; where platforms = {platform_id}; limit 50;'
response = requests.get(BASE_URL.format(endpoint=endpoint),
data=query.format(name=name, platform_id=platform_id),
headers=HEADERS)
return response.json()
def _get_platform_id(platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
response = requests.get(BASE_URL.format(endpoint=endpoint),
data=query.format(abbr=platform_abbreviation),
headers=HEADERS)
return response.json()[0]["id"]
def main():
""" Main function """
platform_id = _get_platform_id("N64")
games = _get_games(platform_id, "Mario")
print(json.dumps(games))
if __name__ == "__main__":
main()
Running this will result in the list with games. Note that I use the jq
tool to pretty print the JSON response from the API.
~/code/abstract-lambdas-terraform $ python . | jq
[
{
"id": 47731,
"name": "Mario No Photopie"
},
{
"id": 3541,
"name": "Mario no Photopi"
},
{
"id": 2327,
"name": "Mario Party"
},
{
"id": 3475,
"name": "Dr. Mario 64"
},
{
"id": 44059,
"name": "Mario Artist: Talent Studio"
},
{
"id": 2329,
"name": "Mario Party 3"
}
]
Introduce classes
We now introduce the IGDBApiResolver class that contains the code for the two endpoints. The initial version looks like this:
""" __main__.py """
import json
import os
import requests
class IGDBApiResolver:
""" Class definition for the IGDBApiResolver """
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def _get_games(self, platform_id, name=None):
""" Get the games for a given platform and an optional name filter """
endpoint = 'games'
if name:
query = 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;'
else:
query = 'fields name; where platforms = {platform_id}; limit 50;'
response = requests.get(self.BASE_URL.format(endpoint=endpoint),
data=query.format(name=name, platform_id=platform_id),
headers=self.HEADERS)
return response.json()
def _get_platform_id(self, platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
response = requests.get(self.BASE_URL.format(endpoint=endpoint),
data=query.format(abbr=platform_abbreviation),
headers=self.HEADERS)
return response.json()[0]["id"]
def main():
""" Main function """
game_api = IGDBApiResolver()
platform_id = game_api._get_platform_id("N64")
games = game_api._get_games(platform_id, "Mario")
print(json.dumps(games))
if __name__ == "__main__":
main()
Because we use the requests
call several times, it's good practice to create a function for the call. Additionally, a function that can be called from a class externally should not have the leading underscore. Let's separate private and public functions properly.
""" __main__.py """
import json
import os
import requests
class IGDBApiResolver:
""" Class definition for the IGDBApiResolver """
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def _get_api_json_response(self, endpoint, data):
""" Return the API JSON response """
response = requests.get(url=self.BASE_URL.format(endpoint=endpoint),
data=data,
headers=self.HEADERS)
return response.json()
def get_games(self, platform_id, name=None):
""" Get the games for a given platform and an optional name filter """
endpoint = 'games'
if name:
query = 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;'
else:
query = 'fields name; where platforms = {platform_id}; limit 50;'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(name=name,
platform_id=platform_id))
def get_platform_id(self, platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(abbr=platform_abbreviation))[0]['id']
def main():
""" Main function """
game_api = IGDBApiResolver()
platform_id = game_api.get_platform_id("N64")
games = game_api.get_games(platform_id, "Mario")
print(json.dumps(games))
if __name__ == "__main__":
main()
We split the class and the main function into two files. __main__.py
contains
""" __main__.py """
import json
from igdbapiresolver import IGDBApiResolver
def main():
""" Main function """
game_api = IGDBApiResolver()
platform_id = game_api.get_platform_id("N64")
games = game_api.get_games(platform_id, "Mario")
print(json.dumps(games))
if __name__ == "__main__":
main()
and igdbapiresolver.py
contains the class:
""" igdbapiresolver.py """
import os
import requests
class IGDBApiResolver:
""" Class definition for the IGDBApiResolver """
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def _get_api_json_response(self, endpoint, data):
""" Return the API JSON response """
response = requests.get(url=self.BASE_URL.format(endpoint=endpoint),
data=data,
headers=self.HEADERS)
return response.json()
def get_games(self, platform_id, name=None):
""" Get the games for a given platform and an optional name filter """
endpoint = 'games'
if name:
query = 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;'
else:
query = 'fields name; where platforms = {platform_id}; limit 50;'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(name=name,
platform_id=platform_id))
def get_platform_id(self, platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(abbr=platform_abbreviation))[0]['id']
Running this will result in the exact same list.
~/code/abstract-lambdas-terraform $ python . | jq
[
{
"id": 47731,
"name": "Mario No Photopie"
},
{
"id": 3541,
"name": "Mario no Photopi"
},
{
"id": 2327,
"name": "Mario Party"
},
{
"id": 3475,
"name": "Dr. Mario 64"
},
{
"id": 44059,
"name": "Mario Artist: Talent Studio"
},
{
"id": 2329,
"name": "Mario Party 3"
}
]
Another API..
I will use the GitHub - 15Dkatz/official_joke_api to add another API resolver to this project.
Add the following class to jokeapiresolver.py
:
""" jokeapiresolver.py """
import os
import requests
class JokeApiResolver:
""" Class definition for the JokeApiResolver """
BASE_URL = 'https://official-joke-api.appspot.com/{endpoint}'
HEADERS = None
def _get_api_json_response(self, endpoint, data=None):
""" Return the API JSON response """
response = requests.get(url=self.BASE_URL.format(endpoint=endpoint),
data=data,
headers=self.HEADERS)
return response.json()
def get_programming_joke(self):
endpoint = 'jokes/programming/random'
return self._get_api_json_response(endpoint)
def get_random_joke(self):
endpoint = 'random_joke'
return self._get_api_json_response(endpoint)
and extend the __main__.py
with the new API:
""" __main__.py """
import json
from igdbapiresolver import IGDBApiResolver
from jokeapiresolver import JokeApiResolver
def main():
""" Main function """
game_api = IGDBApiResolver()
platform_id = game_api.get_platform_id("N64")
games = game_api.get_games(platform_id, "Mario")
print(json.dumps(games))
joke_api = JokeApiResolver()
print(json.dumps(joke_api.get_random_joke()))
print(json.dumps(joke_api.get_programming_joke()))
if __name__ == "__main__":
main()
Running the main function will give back the games as before, plus two jokes from the new API.
~/code/abstract-lambdas-terraform $ python . | jq
[
{
"id": 47731,
"name": "Mario No Photopie"
},
{
"id": 3541,
"name": "Mario no Photopi"
},
{
"id": 2327,
"name": "Mario Party"
},
{
"id": 3475,
"name": "Dr. Mario 64"
},
{
"id": 44059,
"name": "Mario Artist: Talent Studio"
},
{
"id": 2329,
"name": "Mario Party 3"
}
]
{
"setup": "What does a female snake use for support?",
"type": "general",
"id": 247,
"punchline": "A co-Bra!"
}
[
{
"setup": "Where do programmers like to hangout?",
"type": "programming",
"id": 17,
"punchline": "The Foo Bar."
}
]
Introduce an Abstract Base Class (ABC)
It's time to combine some logic in one central class, since we have the _get_api_json_response
in both the classes we have introduced. It is good practice to combine common methods in a base class and inherit from that base class with subclasses for specific functionality for those classes. In this case we will make an ApiResolver base class and both the Game and Joke subclasses will inherit from that class. We will use the abc
module in Python to create an Abstract Base Class which will help us defining
- common functions shared between classes
- common properties shared between classes
- necessary functions to be implemented by the subclass (concrete class)
- necessary properties to be implemented by the subclass (concrete class)
(Note that abstract properties in Python > 3.5 are defined with the two decorators
@property
and@abstractmethod
.
Add the following to abstractapiresolver.py
:
""" abstractapiresolver.py """
from abc import ABCMeta, abstractmethod
import requests
class AbstractApiResolver(metaclass=ABCMeta):
""" Class definition of the AbstractApiResolver """
def _get_api_json_response(self, endpoint, data=None):
""" Return the API JSON response """
response = requests.get(url=self.BASE_URL.format(endpoint=endpoint),
data=data,
headers=self.HEADERS)
return response.json()
@property
@abstractmethod
def headers(self):
pass
@property
@abstractmethod
def base_url(self):
pass
We can now inherit from this ABC in the Joke API resolver as such:
""" jokeapiresolver.py """
from abstractapiresolver import AbstractApiResolver
class JokeApiResolver(AbstractApiResolver):
""" Class definition for the JokeApiResolver """
BASE_URL = 'https://official-joke-api.appspot.com/{endpoint}'
HEADERS = None
def get_programming_joke(self):
endpoint = 'jokes/programming/random'
return self._get_api_json_response(endpoint)
def get_random_joke(self):
endpoint = 'random_joke'
return self._get_api_json_response(endpoint)
and the API resolver for the games as:
""" igdbapiresolver.py """
import os
from abstractapiresolver import AbstractApiResolver
class IGDBApiResolver(AbstractApiResolver):
""" Class definition for the IGDBApiResolver """
BASE_URL = 'https://api-v3.igdb.com/{endpoint}'
HEADERS = {
'user-key': os.environ.get('IGDB_KEY', '')
}
def get_games(self, platform_id, name=None):
""" Get the games for a given platform and an optional name filter """
endpoint = 'games'
if name:
query = 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;'
else:
query = 'fields name; where platforms = {platform_id}; limit 50;'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(name=name,
platform_id=platform_id))
def get_platform_id(self, platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(abbr=platform_abbreviation))[0]['id']
Note that I did not clean this file up yet and this will not work yet. Instead, when running the code now will result in the following error:
~/code/abstract-lambdas-terraform $ python3 .
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/runpy.py", line 193, in _run_module_as_main
"__main__", mod_spec)
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/runpy.py", line 85, in _run_code
exec(code, run_globals)
File "./__main__.py", line 19, in <module>
main()
File "./__main__.py", line 8, in main
game_api = IGDBApiResolver()
TypeError: Can't instantiate abstract class IGDBApiResolver with abstract methods base_url, headers
Because in abstractapiresolver.py
we defined base_url
and headers
as abstract properties, each inherited class should define these properties, otherwise the class can not be instantiated. I have moved the properties BASE_URL
and HEADERS
to be a property of the ABC, so let's rewrite them as below.
""" jokeapiresolver.py """
from abstractapiresolver import AbstractApiResolver
class JokeApiResolver(AbstractApiResolver):
""" Class definition for the JokeApiResolver """
def get_programming_joke(self):
endpoint = 'jokes/programming/random'
return self._get_api_json_response(endpoint)
def get_random_joke(self):
endpoint = 'random_joke'
return self._get_api_json_response(endpoint)
@property
def headers(self):
return None
@property
def base_url(self):
return 'https://official-joke-api.appspot.com/{endpoint}'
and
""" igdbapiresolver.py """
import os
from abstractapiresolver import AbstractApiResolver
class IGDBApiResolver(AbstractApiResolver):
""" Class definition for the IGDBApiResolver """
def get_games(self, platform_id, name=None):
""" Get the games for a given platform and an optional name filter """
endpoint = 'games'
if name:
query = 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;'
else:
query = 'fields name; where platforms = {platform_id}; limit 50;'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(name=name,
platform_id=platform_id))
def get_platform_id(self, platform_abbreviation):
""" Get the platform ID for a given platform abbreviation """
endpoint = 'platforms'
query = 'fields id; where abbreviation = "{abbr}";'
return self._get_api_json_response(endpoint=endpoint,
data=query.format(abbr=platform_abbreviation))[0]['id']
@property
def headers(self):
return {
'user-key': os.environ.get('IGDB_KEY', '')
}
@property
def base_url(self):
return 'https://api-v3.igdb.com/{endpoint}'
In order to use base_url
and headers
we rewrite the ABC as:
""" abstractapiresolver.py """
from abc import ABCMeta, abstractmethod
import requests
class AbstractApiResolver(metaclass=ABCMeta):
""" Class definition of the AbstractApiResolver """
def _get_api_json_response(self, endpoint, data=None):
""" Return the API JSON response """
response = requests.get(url=self.base_url.format(endpoint=endpoint),
data=data,
headers=self.headers)
return response.json()
@property
@abstractmethod
def headers(self):
pass
@property
@abstractmethod
def base_url(self):
pass
Now the code will run fine, since we implemented the correct properties for the concrete classes.
Additionally, we can add a @abstractmethod
, which is a method that we define in the ABC to enforce it to be implemented by each concrete class.
Add the following to the ABC:
@abstractmethod
def get_data(self):
pass
so it becomes
""" abstractapiresolver.py """
from abc import ABCMeta, abstractmethod
import requests
class AbstractApiResolver(metaclass=ABCMeta):
""" Class definition of the AbstractApiResolver """
def _get_api_json_response(self, endpoint, data=None):
""" Return the API JSON response """
response = requests.get(url=self.base_url.format(endpoint=endpoint),
data=data,
headers=self.headers)
return response.json()
@abstractmethod
def get_data(self):
pass
@property
@abstractmethod
def headers(self):
pass
@property
@abstractmethod
def base_url(self):
pass
~/code/abstract-lambdas-terraform $ python3 . | jq
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/runpy.py", line 193, in _run_module_as_main
"__main__", mod_spec)
File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/runpy.py", line 85, in _run_code
exec(code, run_globals)
File "./__main__.py", line 19, in <module>
main()
File "./__main__.py", line 8, in main
game_api = IGDBApiResolver()
TypeError: Can't instantiate abstract class IGDBApiResolver with abstract methods get_data
Rewrite the classes
The abstract class contains the shared method to get the API response, the get_data
method that each class should implement, as well as the base_url
, headers
and endpoints
abstract properties.
""" abstractapiresolver.py """
from abc import ABCMeta, abstractmethod
import requests
class AbstractApiResolver(metaclass=ABCMeta):
""" Class definition of the AbstractApiResolver """
def _get_api_json_response(self, endpoint, data=None):
""" Return the API JSON response """
response = requests.get(url=self.base_url.format(endpoint=endpoint),
data=data,
headers=self.headers)
return response.json()
@abstractmethod
def get_data(self):
pass
@property
@abstractmethod
def headers(self):
pass
@property
@abstractmethod
def base_url(self):
pass
@property
@abstractmethod
def endpoints(self):
pass
Note that I wrote the get_data
function differently in the Joke API resolver compared to the Game API resolver, just for the sake of example. This get_data
function is generic enough to put in the ABC instead of defining it for each subclass since the endpoints are defined the same way, but that's an easy fix!
""" jokeapiresolver.py """
from abstractapiresolver import AbstractApiResolver
class JokeApiResolver(AbstractApiResolver):
""" Class definition for the JokeApiResolver """
def get_data(self, endpoint, **params):
""" Get data from the API """
url = self.endpoints[endpoint]['url']
return self._get_api_json_response(url)
@property
def base_url(self):
return 'https://official-joke-api.appspot.com/{endpoint}'
@property
def endpoints(self):
return {
'get_programming_joke': {
'data': None,
'url': 'jokes/programming/random',
},
'get_random_joke': {
'data': None,
'url': 'random_joke',
}
}
@property
def headers(self):
return None
""" igdbapiresolver.py """
import os
from abstractapiresolver import AbstractApiResolver
class IGDBApiResolver(AbstractApiResolver):
""" Class definition for the IGDBApiResolver """
def get_data(self, endpoint, **params):
""" Get data from the API """
url = self.endpoints[endpoint]['url']
data = self.endpoints[endpoint]['data'].format(**params)
return self._get_api_json_response(url, data)
@property
def base_url(self):
return 'https://api-v3.igdb.com/{endpoint}'
@property
def endpoints(self):
return {
'get_platform_id': {
'data': 'fields id; where abbreviation = "{abbr}";',
'url': 'platforms',
},
'get_games_for_platform': {
'data': 'fields name; where platforms = {platform_id}; limit 50;',
'url': 'games',
},
'get_games_for_platform_with_name': {
'data': 'fields name; where name ~ *"{name}"* & platforms = {platform_id}; limit 50;',
'url': 'games',
},
}
@property
def headers(self):
return {
'user-key': os.environ.get('IGDB_KEY', '')
}
Finally, we need to update our __main__.py
to call the two APIs with the right methods.
""" __main__.py """
import json
from igdbapiresolver import IGDBApiResolver
from jokeapiresolver import JokeApiResolver
def main():
""" Main function """
game_api = IGDBApiResolver()
platform_id = game_api.get_data(endpoint="get_platform_id",
abbr="N64")[0]['id']
games = game_api.get_data(endpoint="get_games_for_platform_with_name",
platform_id=platform_id,
name="Mario")
print(json.dumps(games))
joke_api = JokeApiResolver()
print(json.dumps(joke_api.get_data(endpoint='get_random_joke')))
print(json.dumps(joke_api.get_data(endpoint='get_programming_joke')))
if __name__ == "__main__":
main()
Lambda functions
This article is not about the use or the definition of a Lambda function, but simply an article to show how to simplify Lambda functions and layers with user defined classes.
Get a programming joke
Let's define a function that calls the Joke API and get a programming joke. lambda_handler
is the default handler for a Lambda function, which we will call with an empty event
and context
.
""" get_programming_joke.py """
import json
from jokeapiresolver import JokeApiResolver
def lambda_handler(event, context):
""" Main function """
joke_api = JokeApiResolver()
print(json.dumps(joke_api.get_data(endpoint="get_programming_joke")))
if __name__ == "__main__":
lambda_handler({}, {})
Get the Mario games for N64
In this case we add the platform_abbr
and name
to the event. This means we can keep the Lambda function the same and simply update these two parameters to get different sets of games.
""" get_games_for_platform.py """
import json
from igdbapiresolver import IGDBApiResolver
def lambda_handler(event, context):
""" Main function """
game_api = IGDBApiResolver()
platform_id = game_api.get_data(
endpoint="get_platform_id", abbr=event.get("platform_abbr", "")
)[0]["id"]
games = game_api.get_data(
endpoint="get_games_for_platform_with_name",
platform_id=platform_id,
name=event.get("name", ""),
)
print(json.dumps(games))
if __name__ == "__main__":
event = {"platform_abbr": "N64", "name": "Mario"}
lambda_handler(event, {})
Terraform
See my article on Creating a Lambda function with Terraform to upload a Looker view | JJ's World for a simple Terraform introduction. In this article I will simply show the steps to deploy the following:
get_games_for_platform
Lambda functionget_programming_joke
Lambda function- Lambda layer with
- Abstract class
AbstractApiResolver
- Concrete class
IGDBApiResolver
- Concrete class
JokeApiResolver
- Abstract class
Initialization
~/code/abstract-lambdas-terraform $ terraform init
...
~/code/abstract-lambdas-terraform $ terraform workspace new dev
Created and switched to workspace "dev"!
~/code/abstract-lambdas-terraform $ touch main.tf
~/code/abstract-lambdas-terraform $ touch variables.tf
Structure
~/code/abstract-lambdas-terraform $ tree .
.
├── README.md
├── images
│ ├── postman_get_games.png
│ └── postman_get_platform.png
├── main.tf
├── sources
│ ├── lambda-functions
│ │ ├── get-games-for-platform
│ │ │ └── get_games_for_platform.py
│ │ └── get-programming-joke
│ │ └── get_programming_joke.py
│ └── lambda-layers
│ └── abstract-layer
│ └── python
│ └── abstractlayer
│ ├── __init__.py
│ ├── __main__.py
│ ├── abstractapiresolver.py
│ ├── igdbapiresolver.py
│ └── jokeapiresolver.py
├── terraform.tfstate.d
│ └── dev
└── variables.tf
Build
Lambda layer
In order to create the right lambda.zip
for the Lambda layer, we create a dist
folder and copy the content of the python
folder inside. Additionally we need to install all the requirements (in this case only requests
) for AWS with Docker. We add everything to the ZIP file inside the dist
folder.
~/code/abstract-lambdas-terraform/sources/lambda-layers/abstract-layer $ mkdir -p dist/python
~/code/abstract-lambdas-terraform/sources/lambda-layers/abstract-layer $ docker run --rm -v $(PWD):/foo -w /foo lambci/lambda:build-python3.7 \
pip install -r requirements.txt -t ./dist/python
~/code/abstract-lambdas-terraform/sources/lambda-layers/abstract-layer $ cp -r ./python/* ./dist
~/code/abstract-lambdas-terraform/sources/lambda-layers/abstract-layer $ cd dist
~/code/abstract-lambdas-terraform/sources/lambda-layers/abstract-layer/dist $ zip -rD lambda.zip .
adding: python/abstractlayer/jokeapiresolver.py (deflated 55%)
adding: python/abstractlayer/abstractapiresolver.py (deflated 61%)
adding: python/abstractlayer/__init__.py (stored 0%)
adding: python/abstractlayer/igdbapiresolver.py (deflated 62%)
adding: python/abstractlayer/__main__.py (deflated 56%)
Lambda functions
For the Lambda functions we make sure we first rename the files to lambda.py
since that's the default filename AWS expects as default module name. Then we create again a dist
folder, add the lambda.py
and compress the file.
~/code/abstract-lambdas-terraform/sources/lambda-functions/get-games-for-platform $ mkdir dist
~/code/abstract-lambdas-terraform/sources/lambda-functions/get-games-for-platform $ cp lambda.py dist/
~/code/abstract-lambdas-terraform/sources/lambda-functions/get-games-for-platform $ cd dist && zip -rD lambda.zip .
adding: lambda.py (deflated 50%)
Validate
Run terraform validate
to ensure all files are there and there is no configuration issue.
~/code/abstract-lambdas-terraform $ terraform validate
Success! The configuration is valid.
Plan & apply
~/code/abstract-lambdas-terraform $ terraform plan
...
~/code/abstract-lambdas-terraform $ terraform apply
...
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Results
Overview of the two Lambda functions:
Running the Lambda function:
Conclusion
Using abstract classes makes it easier to create complex software, even when running serverless code on AWS. By using these type of classes you can enforce the developer that creates a new concrete class to implement all the necessary methods and properties to ensure code consistency. The approach in my team would be to create all classes as part of one Lambda layer that contains the Python module with the different abstract classes (APIConnector, FTPConnector, etc) and all concrete classes (CompaniesHouseApiConnector, ExperianApiConnector, etc). The Lambda functions to execute the actual data crawl can remain short and simple making it more scalable and flexible to add new endpoints and therefore new datasets to the data lake.