AWS Compute Blog
Generating REST APIs from data classes in Python
This post is courtesy of Robert Enyedi – Senior Research Engineer – AI Labs
Implementing and managing public APIs is greatly simplified by API Gateway. Among the various features of API Gateway, the ability to import API definitions in the Open API format is powerful.
In this post, I show how you can automatically generate REST APIs directly from Python data classes. This method includes a highly automated workflow for exposing Python services as public APIs using the API Gateway. Recent changes in the Python language open the door for full automation of API publishing directly from code.
Open API and API Gateway
The Open API specification is a popular mechanism to declare the structure of REST APIs. It’s language-independent and allows you to determine API operations and their data types. Previously called Swagger, it is a standardization effort with benefits for the service developer and service consumer. It reduces repetitive tasks, increases API quality, and removes the guesswork from calling a service.
Examples shown here use data classes, which are supported in Python 3.7 or higher. There are backports of data classes to Python 3.6 available but they are beyond the scope of this post.
Python standard type annotations
The type hints syntax, defined in PEP 0526 and implemented in Python 3.5, allow the declaration of a type for identifiers. This includes local variables, function and method parameters, and return type or class fields. They improve the readability of the code and provide useful information for tools. This allows your IDE to be more effective at auto-completion, semantic error detection, and refactoring.
Code checkers such as Mypy can better catch problems at build time. These are the typical advantages of statically typed languages. With Python, because type annotations are optional and a recent addition to the language, not all the project’s dependencies have types. That’s why tooling is less accurate in detecting all error conditions.
Python data classes
Data classes are an even more recent addition to the language. Described in PEP 557 and introduced in Python 3.7 they allow a simplified declaration of class data structures useful for storing state. Combined with type hints, one can use the @dataclass decorator:
@dataclass
class Person:
name: str
age: int
Then the Python implementation can generate:
- The constructor:
Person(”Joe”, 12)
- Comparator methods to allow operations such as:
Person(name=”Joe”, age=12) == Person(name=”Joe”, age=12)
- The
__repr__()
implementation to pretty print the object:
Person(name='Joe', age=12)
Building an API using data classes
Data classes containing fields with type hints lend themselves to automation of API definitions. This solution uses data classes to generate Open API service definitions with AWS extensions and to create API Gateway configurations.
Similar solutions exist for strictly typed languages like Java, C# or Scala. In Python, this level of automation was not available until version 3.7. This code uses the Dataclasses JSON library to automate the serialization of data classes.
1. Start with the entity definition, in this case a person:
@dataclass
@dataclass_json
class Person:
name: str
age: int
2. Create one class for the request and another for the response to help payload serialization:
@dataclass
@dataclass_json
class CreatePersonRequest:
person: Person
@dataclass
@dataclass_json
class CreatePersonResponse:
person_id: int
3. Next, implement the route handler (this example uses the Flask Web framework):
OPERATION_CREATE_PERSON: str = 'create-person'
@app.route(f'/{OPERATION_CREATE_PERSON}', methods=['POST'])
def create_person():
payload = request.get_json()
logging.info(f"Incoming payload for {OPERATION_CREATE_PERSON}: {payload}")
person = CreatePersonRequest.from_json(payload)
The payload is deserialized transparently using the schema derived from the data class definition of Person.
4. To generate a corresponding API definition, enter:
spec = {}
generate_operation(path=OPERATION_CREATE_PERSON,
request_schema=CreatePersonRequest.schema(),
request_schema_name=CreatePersonResponse.__name__,
response_schema=CreatePersonResponse.schema(),
response_schema_name=CreatePersonResponse.__name__,
spec=spec)
spec_dict = spec.to_dict()
The implementation of generate_operation()
makes use of the apispec library to programmatically construct the Open API definition.
With spec_dict
containing the Open API specification, it’s used to either create or update the API definition. You can also run any Open API tools on this definition, such as SDK generators, mock servers, or documentation generators. There’s a comprehensive catalog of tools maintained at https://openapi.tools/.
As a sensible default, the code generates API operations guarded by API keys supplied with the x-api-key
header:
"securitySchemes": {
"api_key": {
"type": "apiKey",
"name": "x-api-key",
"in": "header"
}
}
The spec uses API Gateway extensions to include implementation-specific metadata. The most important is the one linking the API definition to the ECS backend:
"x-amazon-apigateway-integration": {
"passthroughBehavior": "when_no_match",
"type": "http_proxy",
"httpMethod": "POST",
"uri": "http://myecshost-1234567890.us-east-1.elb.amazonaws.com/create-person"
}
You can use a similar pattern to connect the gateway to a different service, such as AWS Lambda:
"x-amazon-apigateway-integration": {
"uri": "arn:aws:apigateway:...:lambda:path/.../functions/arn:aws:lambda:...:...:function:yourLambdaFunction/invocations",
"responses": {
"default": {
"statusCode": "200"
}
},
"passthroughBehavior": "when_no_match",
"httpMethod": "POST",
"contentHandling": "CONVERT_TO_TEXT",
"type": "aws"
}
For more information on the API Gateway extension to Open API, visit the AWS documentation.
Generating the API using API Gateway
This example uses the boto3 API Gateway API to expose a public API.
1. To create the API, enter the following:
api_definition = json.dumps(spec_dict, indent=2)
api_gateway_client.import_rest_api( body=api_definition )
2. To update the API, merge the changes into a manually modified API definition (mode='merge')
, or completely overwrite the API (mode='overwrite')
. It is often safer to merge the API, as follows:
api_gateway_client.put_rest_api(body=api_definition, mode='merge', restApiId=find_api_id(api_gateway_client, api_name))
The find_api_id()
helper function looks up the API ID based on its name.
3. Check the API Gateway dashboard in the AWS Management Console for the new API definition. It shows the API and its resources:
Now you are ready to issue a test call to the external API to validate its security and functionality. The Open API definition of a manually created or modified API can be exported by various means, including from the stage editor.
Validate the API
The correct way to call the API is shown in test_get_dubbing_job_status_API()
from test/ondemand_test_call_service.py:
response = _send_request(secure=True,
host='<<yourapi>>.execute-api.us-east-1.amazonaws.com',
service_port=80,
path='sample-generated-api',
operation=OPERATION_CREATE_PERSON,
request=CreatePersonRequest(Person(name='Jane Doe', age='40')),
api_key='<<yourapikey>>')
response_obj = CreatePersonResponse.from_json(response)
assert response_obj.person_id is not None
If you call the API without the api_key parameter, it returns an HTTP 403 code and the error message:
{"message":"Forbidden"}
Conclusion
This post shows how to automatically expose Python services as public APIs directly from the code. With the introduction of Python data classes, it is easy to automate JSON serialization.
Now you can fully automate the API generation and deployment tasks for API Gateway. Introducing a new entity is trivial, and adding a new field to your API requires only writing its definition. You can develop a fully functional API based upon these building blocks.
Learn more from this sample repository, and adapt the code for your projects to achieve a high level of automation for your public APIs.