Validated fields (ready-made types)¶
Instead of repeating Field(gt=0, ...) on every schema, the SDK ships
Annotated types with the validation rule baked in — the field becomes
self-describing. They follow the same *Field convention as the BR
fields (CPFField, UFField, …): anything ending in Field is a
ready-to-use schema field type. An invalid value becomes a
ValidationError → HTTP 422 via the SDK handler.
from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import CentsField, PercentField, SlugField
class ProductCreateSchema(BaseSchema):
slug: SlugField # lowercase kebab-case
price_cents: CentsField # int >= 0 (money in cents)
discount: PercentField # float in [0, 100]
Integers¶
| Type | Rule | Use |
|---|---|---|
PositiveIntField |
> 0 |
quantities, counts, 1-based ids |
NonNegativeIntField |
>= 0 |
counters that reset to zero |
CentsField |
>= 0 |
money in minor units (cents) |
PortField |
1..65535 |
TCP/UDP port |
Money in cents
Store monetary values as an integer of cents (CentsField) to
avoid binary-float rounding. Divide by 100 only at the presentation
edge. When the contract requires an exact decimal (e.g. a gateway
payload), use PriceField.
Floats¶
| Type | Rule |
|---|---|
PositiveFloatField |
> 0 |
NonNegativeFloatField |
>= 0 |
PercentField |
0..100 |
RatioField |
0..1 (fractions/probabilities) |
LatitudeField |
-90..90 |
LongitudeField |
-180..180 |
Decimals¶
PriceField is a non-negative Decimal with 2 places:
from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import PriceField
class InvoiceSchema(BaseSchema):
total: PriceField # "9.99" -> Decimal("9.99"); "1.999" and "-1" rejected
For ratios and percentages, there is the decimal counterpart of
RatioField / PercentField:
| Type | Rule | When |
|---|---|---|
DecimalRatioField |
Decimal in 0..1 |
A fraction that multiplies money |
DecimalPercentField |
Decimal in 0..100 |
A percentage that multiplies money |
SignedDecimalRatioField |
Decimal <= 1 |
A derived fraction that may go negative |
from decimal import Decimal
from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import DecimalRatioField, PriceField
class QuoteSchema(BaseSchema):
reference: PriceField
discount: DecimalRatioField # Decimal("0.28"), not 0.28
def proposal(self) -> Decimal:
"""Return the reference amount minus the discount."""
return self.reference * (Decimal(1) - self.discount)
Never use RatioField where the fraction multiplies money
RatioField and PercentField are annotated on float.
Decimal("0.28") becomes 0.28, Pydantic accepts it silently, and the
first multiplication by a PriceField raises TypeError — or worse,
someone "fixes" it with a float() and the cent is gone. In a public
procurement bid, one cent disqualifies the proposal.
Why SignedDecimalRatioField has a ceiling but no floor
It is for a derived fraction: the balancing line the premises left no slack for, the closing difference a document must disclose rather than clamp. Over 100% is a bug; under 0% is news.
Strings¶
| Type | Rule |
|---|---|
NonEmptyStrField |
trims whitespace and rejects empty (whitespace-only too) |
SlugField |
lowercase kebab-case (my-post-1) |
HexColorField |
CSS hex color (#fff or #abc123) |
from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import HexColorField, NonEmptyStrField, SlugField
class CategorySchema(BaseSchema):
name: NonEmptyStrField # " Drinks " -> "Drinks"; " " rejected
slug: SlugField
color: HexColorField
Locale¶
LocaleField is the language counterpart of UFField: an
Annotated[Locale, ...] that normalizes the input to the canonical form of
the Locale enum. Under BaseSchema the populated
attribute is the canonical str, not the member — BaseSchema sets
use_enum_values=True, so "pt_BR" goes in and "pt-BR" comes out, and
isinstance(value, Locale) is False. On a plain pydantic.BaseModel, without
that config, you get the member. It accepts case/separator variants
("pt_BR", "PT-BR") and the bare primary subtag ("pt" → Locale.PT_BR);
a tag outside the enum becomes a 422.
from tempest_fastapi_sdk import BaseSchema, Locale
from tempest_fastapi_sdk.utils import LocaleField
class ProfileUpdateSchema(BaseSchema):
locale: LocaleField | None = None # "pt_BR" -> Locale.PT_BR; "xx-YY" -> 422
Enum vs. Field
Use the Locale enum when you already hold the canonical tag
(constants, internal logic). Use LocaleField on a request schema,
where the input is client-supplied and worth normalizing — the same
relationship UF has to UFField. To store the language on a table, see
LocaleColumnMixin.
Pix key¶
PixKeyField validates the five BACEN Pix key types in a single
field and normalizes to a canonical form. An invalid key →
ValidationError → 422.
| Type | Example | Normalizes to |
|---|---|---|
| CPF | 529.982.247-25 |
52998224725 (digits) |
| CNPJ | 11.222.333/0001-81 |
11222333000181 (digits) |
Ana@Example.com |
ana@example.com (lowercased) |
|
| Phone | +5511999998888 |
+5511999998888 (E.164) |
| Random (EVP) | 123e4567-e89b-12d3-a456-426614174000 |
same, lowercased (UUID) |
from tempest_fastapi_sdk import BaseSchema, CentsField
from tempest_fastapi_sdk.utils import PixKeyField
class PixTransferSchema(BaseSchema):
to_key: PixKeyField # accepts CPF/CNPJ/email/phone/random
amount_cents: CentsField
Need to know which type a key is (routing, UI, business rule)? Use the helpers — they don't require the field:
from tempest_fastapi_sdk.utils import PixKeyType, detect_pix_key_type, is_valid_pix_key
detect_pix_key_type("ana@example.com") # -> PixKeyType.EMAIL
detect_pix_key_type("+5511999998888") # -> PixKeyType.PHONE
detect_pix_key_type("bad-key") # -> None
is_valid_pix_key("529.982.247-25") # -> True
Validation by shape + check digits
Detection is by shape: @ → email, leading + → E.164 phone, UUID →
random; otherwise digits + check digits decide between CPF and
CNPJ (a CPF with wrong check digits is rejected). Phone follows
Brazilian E.164 (+55 + area + number, 10-11 digits after +55).
Full example (schema + route + 422)¶
The types are plain Annotated — they work in any BaseSchema and
FastAPI turns the ValidationError into a 422 via the SDK handler.
Complete program:
# src/schemas/product.py
from tempest_fastapi_sdk import BaseSchema
from tempest_fastapi_sdk.utils import (
CentsField,
NonEmptyStrField,
PercentField,
SlugField,
)
class ProductCreateSchema(BaseSchema):
name: NonEmptyStrField
slug: SlugField
price_cents: CentsField
discount_percent: PercentField = 0.0
# src/api/routers/products.py
from fastapi import APIRouter
from src.schemas.product import ProductCreateSchema
router = APIRouter()
@router.post("/products")
async def create_product(payload: ProductCreateSchema) -> dict[str, str]:
"""Create a product. An invalid payload never reaches here — it 422s."""
return {"slug": payload.slug}
An invalid POST (price_cents: -5, slug: "Not A Slug") gets a 422
with the body the SDK handler standardizes:
{
"detail": [
{"loc": ["body", "price_cents"], "msg": "Input should be greater than or equal to 0"},
{"loc": ["body", "slug"], "msg": "String should match pattern ..."}
]
}
Optional, default and list
They compose like any Pydantic type: CentsField | None = None,
discount: PercentField = 0.0, list[SlugField]. The rule still
applies to each list element.
Composing your own¶
Every *Field is just an Annotated[type, Field(...)]. Need a rule the
SDK doesn't ship? Build yours with the same pattern — no magic:
from typing import Annotated
from pydantic import Field
from tempest_fastapi_sdk import BaseSchema
# Stock quantity: integer from 0 to 100000.
StockField = Annotated[int, Field(ge=0, le=100_000)]
# 3 uppercase letters (e.g. an ISO-4217 currency code).
CurrencyCodeField = Annotated[str, Field(pattern=r"^[A-Z]{3}$")]
class SkuSchema(BaseSchema):
stock: StockField
currency: CurrencyCodeField # "BRL" ok; "brl" / "REAL" rejected
Prefer the SDK types when an equivalent exists (self-description + cross-service consistency); roll your own only for domain-specific rules.
Common gotchas
CentsFieldvsPriceField— store money as an integer of cents (CentsField) to dodge binary float; usePriceField(Decimal, 2 places) only when the contract demands a decimal.PercentFieldvsRatioField—Percentis0..100(a human types "15");Ratiois0..1(fraction/probability, "0.15"). Don't mix the two in the same flow.NonEmptyStrFieldtrims before validating —" "(whitespace-only) is rejected as empty.
Recap¶
- Everything is
Annotated[..., rule]with a*Fieldsuffix — self-describing, no repeatedField(...)in the schema. - Integers:
PositiveIntField,NonNegativeIntField,CentsField,PortField. - Floats:
PositiveFloatField,NonNegativeFloatField,PercentField,RatioField,LatitudeField,LongitudeField. - Decimal:
PriceField(2 places,>= 0). - Strings:
NonEmptyStrField,SlugField,HexColorField. - BR:
CPFField,CNPJField,CPFOrCNPJField,PhoneBRField,CEPField,UFField,CityNameField(see Brazilian helpers). - Pix:
PixKeyField(CPF/CNPJ/email/phone/random) +detect_pix_key_type/is_valid_pix_key. - Money: prefer
CentsField(integer) to store;PriceFieldon the decimal contract. - Compose your own with
Annotated[type, Field(...)]for domain rules.