Skip to content

Making Aliases

In some situations, we need to follow multiple naming conventions at the same time. For instance, it's a common approach to convert fields of some structure from camelCase (or another case) to snake_case to follow Python’s naming conventions.

Tip

If you don't know what are "snake_case", "camelCase", "Header-Case" and why you need to convert cases, visit Motivation/Converting Case

When you call routed function(method), Sensei collects argument names and adds the corresponding key to request arguments.

Assume, we have to make the following request:

POST /users HTTP/1.1
Content-Type: application/json

{
    "firstName": "John Doe",
    "birthCity": "Manchester",
    ...
}

Since the argument's name corresponds to the key in routed function's request arguments, you need to write this code:

@router.post('/users')
def create_user(firstName: str, birthCity: str) -> User:
    pass

But this code violates Python naming conventions. For instance, PyCharm warns you that "Argument name should be lowercase." Because, according to the conventions, arguments in Python should be of the snake_case. To resolve this issue, you can use Case Converters.

Case Converters

Case Converter is a function that takes the string of one case and converts it to the string of another case and similar structure.

Example

This function converts a string to snake_case

def snake_case(s: str) -> str:
    s = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', s)
    s = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s)
    s = re.sub(r'\W+', '_', s).lower()
    s = re.sub(r'_+', '_', s)
    return s

Sensei has a few built-in case converters:

  • snake_case
  • camel_case
  • pascal_case
  • constant_case
  • kebab_case
  • header_case

They are all closed relative to each other. This means if we take any provided case converter, we can use it with any other supported case (case related to one of the built-in case converter).

For instance, snake_case("myString") == my_string, snake_case("MY_STRING") == my_string, etc...

Math Definition

There is an explanation for the closure property of case converters for math lovers.

Definition Let C = {f1, f2, ..., fn}, is the set of functions, fi : Di -> Ei.
{f1, f2, ..., fn} closed relative to each other <=> ∀ k, m ∈ {1, ..., n} and ∀ x ∈ Dk condition fk(x) ∈ Dm is met

Corollary Dk = Ek = Dm = Em = D ∀ k, m ∈ {1, ..., n} => {f1, f2, ..., fn} closed relative to each other.

Let's introduce an auxiliary function - rcase(str) = random_caseC(str). This is the function, that chooses a random case from C and converts string str to the chosen case. Here is an illustration of this closure property.

graph LR
  A[str] -->|passed to function| B{"rcase(str)"};
  B --> |converts to| C[result];
  C --> |can be used as arg again| A;
  A ---->D[final result];

Case Converters can be used for converting case of request parameters and keys of JSON response.

But it's important to know, that case converters in the following three examples work only at the first nesting level without touching nested models. Let's explore how to apply converters.

Router (Router Level)

You can pass case converters as arguments to the Router constructor. This process is called "applying case converters at Router level."

<param_type> corresponds to the <param_type>_case argument in the Router constructor, where <param_type> is path, query, etc. And there is response_case that corresponds to the conversion of response fields.

Let's assume we have the API using camelCase:

from sensei import Router, camel_case, snake_case

router = Router(
    'https://api.example.com',
    body_case=camel_case,
    response_case=snake_case
)

@router.post('/users')
def create_user(first_name: str, birth_city: str, ...) -> User: 
    pass

The example above converts parameters like first_name to firstName, birth_city to birthCity, etc.

When parsing JSON response, the example performs the reverse process, that is converts firstName to first_name, birthCity to birth_city, etc. Here the original response case is camelCase because we assume that API is good-designed and keeps input and output cases the same.

Info

Default value of header_case is header_case converter

from sensei.cases import header_case as to_header_case

class Router(IRouter):
    def __init__(
        ...
        header_case: CaseConverter | None = to_header_case,    
    ):

Because headers are called this way:

  • X-Token
  • Content-Type
  • etc...

Route Decorator (Route Level)

You can pass case converters as arguments to route decorator. This process is called "applying case converters at Route level." These converters have higher priority, than router level converters.

Arguments, responsible for applying converters, have the same names as the Router constructor.

Tip

If API is bad-designed and follows different name conventions at the same time, you can use it. For instance, all endpoints accept request body with keys of kebab-case, but one "rogue" endpoint accepts keys with keys of camelCase.

from sensei import Router, kebab_case, camel_case

router = Router(
    'https://api.example.com',
    body_case=kebab_case
)

@router.post('/users', body_case=camel_case)
def create_user(first_name: str, birth_city: str, ...) -> User: 
    pass

Class Hooks (Routed Model level)

When you use Routed Models, you can define converters through hooks. <param_type> corresponds to __<param_type>_case__ hook. This process is called "applying case converters at Routed Model level." Let's look at the example below:

router = Router(host, response_case=camel_case)

class User(APIModel):
    @classmethod
    def __header_case__(cls, s: str) -> str:
        return kebab_case(s)

    @staticmethod
    def __response_case__(s: str) -> str:
        return snake_case(s)

    @classmethod
    @router.get('/users/{id_}')
    def get(cls, id_: Annotated[int, Path(alias='id')]) -> Self: pass

Hook function can be represented both as a class method and as a static method, but not instance methods.

So, response_case=camel_case in Router

router = Router(host, response_case=camel_case)

Will be overridden by hook __response_case__

@staticmethod
def __response_case__(s: str) -> str:
    return snake_case(s)

Consequently, this level has the same priority as router level, because it replaces it. As well as router level level, it has lower priority than route level.

Hook Levels (Priority)

Code that handles intercepted function calls, events, or messages passed between software components is called a hook. The levels that were described above determine the scope of applying some hooks. In the context of this article, hooks are the case converters.

  • Router Level: apply hook to each routed function associated with that router
  • Route Level: apply hook only to this routed function
  • Routed Model Level: apply hook to each routed method associated with that model. Replaces Router Level.

Moreover, there are different types of hook levels, each of which has a special property. In the case of case converters, this type is called Priority Levels. Because, if multiple hooks are applied to one target (routed function), only one will be executed, based on its priority. They can be described in that diagram:

flowchart 

    RoutedModel["Routed Model Level (Second Priority)"] --> |replaces| Router["Router Level (Second Priority)"]
    Route["Route Level (First Priority)"] ---->|priority over| Router["Router Level (Second Priority)"]

    style Route fill:#C94A2D,stroke:#000,stroke-width:2px;  
    style RoutedModel fill:#D88C00,stroke:#000,stroke-width:2px; 
    style Router fill:#2F8B5D,stroke:#000,stroke-width:2px;

AliasGenerator

What if we need to apply case converters at the deeper nesting levels? Assume the API has two endpoints:

1) For getting a bestseller of a specific year

Bestseller Endpoint Bestseller Endpoint

2) And publishing book for a sale.

List-Item Endpoint

For some reason, the first endpoint returns a response of camelCase, but the second endpoint accepts a book of kebab case. To follow Python naming conventions, you have to make snake_case attributes. At the same time, you need to make only one model that can handle both the kebab-case and the camel_case, to follow DRY.

Info

Even if the case of the first and second endpoints are equal, you still would not be able to apply anything other than the approach described below, because the Book has a nested Author model. Cases are not equal only to show what are validation_alias and serialization_alias in one example.

You can't apply case converters through router, route decorator or class hooks, because they can be used only at the first nesting level. To achieve the goal, you can use the AliasGenerator class from pydantic with the model_config attribute.

from sensei import APIModel, camel_case, kebab_case, Router, Body
from pydantic import ConfigDict, AliasGenerator
from typing import Annotated

router = Router('https://api.example.com')

SHARED_CONFIG = ConfigDict(
    alias_generator=AliasGenerator(
        validation_alias=lambda field_name: camel_case(field_name),
        serialization_alias=lambda field_name: kebab_case(field_name),
    ),
    populate_by_name=True  # (1)!
)

class Author(APIModel):
    model_config = SHARED_CONFIG 

    first_name: str
    last_name: str


class Book(APIModel):
    model_config = SHARED_CONFIG

    title: str
    author: Author
    year: int

@router.get('/bestseller')
def get_bestseller(year: int) -> Book:
    pass

@router.post('/list-item')
def list_item(book: Annotated[Book, Body(embed=False)]) -> None: 
    pass
  1. Allows using both original field names and aliases

Let's explore what's going on.

Validation Alias

The validation alias is used when validating incoming data in models. That appears when Sensei unpacks the response to some model. In this example, the validation_alias lambda function takes a field name and converts it to camelCase.

When you access the /bestseller endpoint, Sensei handles the response of camelCase by unpacking it to the Book model. At the same time, Book accepts fields of configured validation_alias, that is camelCase.

@router.get('/bestseller')
def get_bestseller(year: int) -> Book: 
    pass

Let's break this routed function down into steps:

1) Making HTTP request and getting a similar response:

{
  "title": "1984", 
  "author": {
    "firstName": "George", 
    "lastName": "Orwell"
  }, 
  "year": 1949
}
2) Unpacking response to the Book model:
book = Book(
    title="1984", 
    author={'firstName': 'George', 'lastName': 'Orwell'},
    year=1949
)
Where the Author model uses AliasGenerator to convert original field names to the chosen case and compares with input in the model constructor. firstName corresponds to first_name, lastName to last_name etc.

3) Returning the result

Serialization Alias

The serialization alias is used when serializing the model using model_dump(by_alias=True).
That appears when Sensei serializes request arguments represented as some model. In this example, the serialization_alias lambda function converts field names to a camelCase.

When you access the/list-item endpoint, Sensei handles the arguments of the routed function by serializing it by model_dump(by_alias=True) and pass it into request parameters.

@router.post('/list-item')
def list_item(book: Annotated[Book, Body(embed=False)]) -> None: 
    pass

You can use original field names along with aliases, because SHARED_CONFIG includes populate_by_name=True.

list_item(Book(
        title="1984", 
        author={'first_name': 'George', 'last_name': 'Orwell'},
        year=1949
))

Tip

If validation_alias and serialization_alias are equal, you can use the alias argument.

SHARED_CONFIG = ConfigDict(
    alias_generator=AliasGenerator(
        alias=lambda field_name: camel_case(field_name),
    ),
    populate_by_name=True 
)

Field-Specific Aliases

In some cases, you may want to apply a specific alias for a certain field.

from pydantic import ConfigDict, AliasGenerator, Field

...


class Book(APIModel):
    model_config = SHARED_CONFIG

    title: str
    author: Author
    year: int = Field(
        validation_alias="pubYear", 
        serialization_alias="publication-year"
    )

This specific alias takes precedence over the AliasGenerator, which means that even though other fields are transformed by the generator, year will be serialized as publication-year and validated as pubYear

Info

The same approach can be used in params aliases:

@router.post('/list-item')
def list_item(book: Annotated[Book, Body(alias="bookForSale", embed=False)]) -> None: 
    pass

But validation_alias and serialization_alias can't be used like before. The argument alias means the same as serialization_alias.

Recap

In Sensei, managing different naming conventions (like camelCase, snake_case, etc.) is crucial for building APIs that conform to Python's standards. Here’s a concise summary of the key concepts:

  1. Case Converters: Built-in functions that convert strings between various cases. Examples include snake_case, camel_case, header_case, etc. These converters are closed relative to one another, meaning you can interchangeably apply them.

  2. Router Configuration: The Router constructor allows you to specify case converters for request bodies and responses. This ensures that parameter names in your function match the expected format of the API.

  3. Route Decorators: You can apply a converter taking precedence over the corresponding Router case converter, by providing it directly in the route decorator. This is useful for handling endpoints that do not conform to the general conventions.

  4. Class Hooks: For Routed Models, you can define case converters through

  5. Class Hooks: For Routed Models, you can define case converters through class hooks within your model classes.

  6. AliasGenerator: When dealing with nested models and varying case conventions (like camelCase for responses and kebab-case for requests), the AliasGenerator from Pydantic is utilized. It allows for defining separate validation and serialization aliases.

  7. Field-Specific Aliases: For certain fields requiring unique aliasing, you can specify individual aliases using the Field class. This takes precedence over the generic alias generation.

By leveraging these tools, developers can effectively manage API naming conventions and ensure seamless integration with Python’s style while adhering to DRY principles.