DEV Community

Emmanuel Akolbire
Emmanuel Akolbire

Posted on

Project Translate: The Translate API (Part 2)

Hey developers! 👋 In this post, we'll implement the text translation endpoint using Python, AWS Lambda, and a clean Hexagonal Architecture. Let's dive in! You can check out my GitHub for the complete code.

The Setup

We create a new project with the directory structure shown in the picture
Then we install the dependency, namely boto3, with pip. We also make sure to create a requirements.txt file so we know which version to install when the script is packaged.

We'll be employing Hexagonal(Layered) Architecture in the design of our API. Hexagonal Architecture or Ports and Adpaters is a design pattern that aims at creating loosely coupled components. A helpful guide can be found here. Although python is a dynamically typed language, we can still use this pattern.

We'll be using the project directory structure shown below
Project structure

The Translation Record Model

Let's start with a simple but effective model to track our translations. We'll use Python's dataclasses - they're clean, efficient, and give us nice features out of the box.

#translate/models.py

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Record:
    id: str
    input_text: str
    output_text: str
    created_at: datetime = field(default=datetime.now)

Enter fullscreen mode Exit fullscreen mode

Let's break down what each field does:

  • id: A unique identifier for each translation record
  • input_text: The original text that needs translation
  • output_text: The translated result
  • created_at: Timestamp of when the translation was performed, automatically set to the current time

You might wonder why we're using @dataclass instead of a regular class. Here's what makes dataclasses great for our use case:

  • Less Boilerplate: We don't need to write init, repr, or eq methods
  • Default Values: Easy handling of default values with the field function
  • Type Hints: Built-in support for type hints, making our code more maintainable

Next, we'll define our ports using Python's Protocol class - a more Pythonic approach to interfaces. Let's dive in!
Why Protocols Over Abstract Base Classes?
Before we jump into the code, let's understand why we're choosing Protocols:

  • More Pythonic - follows duck typing principles
  • Structural subtyping instead of nominal subtyping
  • Better integration with static type checkers
  • No explicit inheritance required
#translate/ports.py

from typing import Protocol
from models import Record


class TextPersistencePort(Protocol):
    def save(self, input_text: str, output_text: str) -> Record:
        pass


class TranslationPort(Protocol):
    def translate(self, text: str, lang: str) -> str:
        pass

Enter fullscreen mode Exit fullscreen mode

Now we define the adapters that implement the ports. The DynamoDBPersistenceAdapter stores the input and output in DynamoDB and return a Record object. The AWSTranslateAdapter translates the text with AWS Translate and returns the result.

#translate/adapters.py

import boto3
from uuid import uuid4
from datetime import datetime

from models import Record


class DynamoDBPersistenceAdapter:
    """
    Implementation of TextPersistencePort using DynamoDB as storage.
    """

    def __init__(self, table_name: str):
        dynamodb = boto3.resource("dynamodb")
        self.table = dynamodb.Table(table_name)

    def save(self, input_text, output_text):
        """
        Save input and output text to DynamoDB.

        Args:
            input_text: The input text to save
            output_text: The output text to save

        Returns:
            Record object containing saved data
        """

        id = str(uuid4())
        created_at = datetime.now()

        self.table.put_item(
            Item={
                "id": str(uuid4()),
                "input_text": input_text,
                "output_text": output_text,
                "created_at": str(created_at),
            }
        )

        record = Record(id, input_text, output_text, created_at)
        return record


class AWSTranslateAdapter:
    """
    Implementation of TranslationPort using DynamoDB as storage.
    """

    def __init__(self):
        self.client = boto3.client("translate")

    def translate(self, text, lang):
        """
        Translate text to lang

        Args:
            text: The text to translate
            lang: The language code to translate to

        Returns:
            The translated string
        """

        result = self.client.translate_text(
            Text=text,
            SourceLanguageCode="auto",
            TargetLanguageCode=lang,
        )

        translated_text = result["TranslatedText"]

        return translated_text

Enter fullscreen mode Exit fullscreen mode

Now we'll create the Lambda handler that ties everything together.
We'll define the Handler class with handles the requests to Lambda from the API Gateway. It parses the body for the required fields, translates the text, stores the input and output and returns a response

#translate/main.py

import os
import json
import logging
from dataclasses import dataclass
from typing import Dict, Any

from ports import TextPersistencePort, TranslationPort
from adapters import DynamoDBPersistenceAdapter, AWSTranslateAdapter

logger = logging.getLogger(__name__)


@dataclass
class TranslationRequest:
    """Dataclass for translation requests"""

    text: str
    lang: str

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> "TranslationRequest":
        if not isinstance(data["text"], str):
            raise ValueError("text must be a string")

        if not isinstance(data["lang"], str):
            raise ValueError("lang must be a string")

        return cls(data.get("text"), data.get("lang"))


class Handler:

    def __init__(
        self,
        text_port: TextPersistencePort,
        translate_port: TranslationPort,
    ):

        self.text_port = text_port
        self.translate_port = translate_port

    def __call__(self, request, *args):
        """
        Process a translation request.

        Args:
            request: dict containing the request data

        Returns:
            dict with status code and response body
        """

        try:
            body: dict = json.loads(request["body"])
            request = TranslationRequest.from_dict(body)
        except (json.JSONDecodeError, KeyError) as e:
            logger.error(f"Invalid request: {str(e)}")
            return self._get_error_response("Invalid request", status_code=400)

        try:
            result = self.translate_port.translate(request.text, request.lang)

            output = self.text_port.save(request.text, result)
            logger.info(f"Saved record with ID: {output.id}")

            return self._get_success_response(result)
        except Exception as e:
            return self._get_error_response("An error was encountered", status_code=500)

    def _get_success_response(self, text: str):
        """
        Generate a successful response.

        Args:
            text: The translated text

        Returns:
            Dictionary with status code and response body
        """

        return {
            "statusCode": "200", 
            "headers": {
                "Access-Control-Allow-Headers": "Content-Type",
                "Access-Control-Allow-Origin": "*",
                "Access-Control-Allow-Methods": "OPTIONS,POST,GET"
            },
            "body": json.dumps({"result": text})}

    def _get_error_response(self, error: str, status_code: int):
        """
        Generate an error response.

        Args:
            error: The error message
            status_code: HTTP status code

        Returns:
            Dictionary with status code and response body
        """

        return {"statusCode": str(status_code),
                "headers": {
                    "Access-Control-Allow-Headers": "Content-Type",
                    "Access-Control-Allow-Origin": "*",
                    "Access-Control-Allow-Methods": "OPTIONS,POST,GET"
                },
                "body": json.dumps({"detail": error})}


text_port = DynamoDBPersistenceAdapter(os.environ.get("DYNAMODB_TABLE"))
translate_port = AWSTranslateAdapter()

handler = Handler(text_port, translate_port)

Enter fullscreen mode Exit fullscreen mode

In order to allow Cross Origin Requests we add the Access-Control-Allow headers to the reponse object. For example, in the _get_success_response method


def _get_error_response(self, error: str, status_code: int):
       ...
       return {"statusCode": str(status_code),
                "headers": {
                    "Access-Control-Allow-Headers": "Content-Type",
                    "Access-Control-Allow-Origin": "*",
                    "Access-Control-Allow-Methods": "OPTIONS,POST,GET"
                },
                "body": json.dumps({"detail": error})}
Enter fullscreen mode Exit fullscreen mode

In the next installment of this series, we'll dive into the code that handles file translation. Stay tuned! 🚀

Top comments (0)