Skip to content

FastAPI Integration Example

This example demonstrates how to build a modern REST API using FastAPI and Zenoo RPC to interact with Odoo. The API provides endpoints for managing customers, products, and orders with proper authentication, validation, and error handling.

Overview

We'll create a FastAPI application that:

  • Provides REST endpoints for Odoo data
  • Uses dependency injection for Zenoo RPC client
  • Implements proper authentication and authorization
  • Includes request/response validation with Pydantic
  • Handles errors gracefully
  • Supports async operations for high performance

Project Structure

fastapi_odoo_api/
├── app/
│   ├── __init__.py
│   ├── main.py              # FastAPI application
│   ├── config.py            # Configuration
│   ├── dependencies.py      # Dependency injection
│   ├── models/              # Pydantic models
│   │   ├── __init__.py
│   │   ├── customer.py
│   │   ├── product.py
│   │   └── order.py
│   ├── routers/             # API routers
│   │   ├── __init__.py
│   │   ├── customers.py
│   │   ├── products.py
│   │   └── orders.py
│   └── utils/               # Utilities
│       ├── __init__.py
│       └── auth.py
├── requirements.txt
└── README.md

Installation and Setup

Requirements

# requirements.txt
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
zenoo-rpc>=0.3.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
pydantic>=2.0.0
python-dotenv>=1.0.0

Configuration

# app/config.py
from pydantic_settings import BaseSettings
from typing import Optional

class Settings(BaseSettings):
    # Odoo Configuration
    odoo_host: str = "localhost"
    odoo_port: int = 8069
    odoo_protocol: str = "http"
    odoo_database: str = "demo"
    odoo_username: str = "admin"
    odoo_password: str = "admin"

    # API Configuration
    api_title: str = "Odoo FastAPI Integration"
    api_version: str = "1.0.0"
    api_description: str = "REST API for Odoo using Zenoo RPC"

    # Security
    secret_key: str = "your-secret-key-here"
    algorithm: str = "HS256"
    access_token_expire_minutes: int = 30

    # Performance
    cache_ttl: int = 300  # 5 minutes
    max_connections: int = 100

    class Config:
        env_file = ".env"

settings = Settings()

Dependencies

# app/dependencies.py
import asyncio
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from zenoo_rpc import ZenooClient
from zenoo_rpc.exceptions import ZenooError, AuthenticationError
from .config import settings
from .utils.auth import verify_token

security = HTTPBearer()

# Global client instance
_client_instance = None
_client_lock = asyncio.Lock()

async def get_zenoo_client() -> AsyncGenerator[ZenooClient, None]:
    """Dependency to get Zenoo RPC client instance"""
    global _client_instance

    async with _client_lock:
        if _client_instance is None:
            try:
                _client_instance = ZenooClient(
                    host=settings.odoo_host,
                    port=settings.odoo_port,
                    protocol=settings.odoo_protocol,
                    max_connections=settings.max_connections
                )

                # Setup caching
                await _client_instance.cache_manager.setup_memory_cache(
                    max_size=1000,
                    default_ttl=settings.cache_ttl
                )

                # Authenticate
                await _client_instance.login(
                    settings.odoo_database,
                    settings.odoo_username,
                    settings.odoo_password
                )

            except AuthenticationError as e:
                raise HTTPException(
                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                    detail=f"Failed to authenticate with Odoo: {e}"
                )
            except ZenooError as e:
                raise HTTPException(
                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                    detail=f"Failed to connect to Odoo: {e}"
                )

    yield _client_instance

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security)
) -> dict:
    """Dependency to get current authenticated user"""
    try:
        payload = verify_token(credentials.credentials)
        return payload
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

Pydantic Models

# app/models/customer.py
from pydantic import BaseModel, EmailStr, Field
from typing import Optional
from datetime import datetime

class CustomerBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: Optional[EmailStr] = None
    phone: Optional[str] = Field(None, max_length=20)
    is_company: bool = False
    street: Optional[str] = Field(None, max_length=200)
    city: Optional[str] = Field(None, max_length=50)
    zip: Optional[str] = Field(None, max_length=10)
    country_code: Optional[str] = Field(None, max_length=2)

class CustomerCreate(CustomerBase):
    pass

class CustomerUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=100)
    email: Optional[EmailStr] = None
    phone: Optional[str] = Field(None, max_length=20)
    is_company: Optional[bool] = None
    street: Optional[str] = Field(None, max_length=200)
    city: Optional[str] = Field(None, max_length=50)
    zip: Optional[str] = Field(None, max_length=10)
    country_code: Optional[str] = Field(None, max_length=2)
    active: Optional[bool] = None

class Customer(CustomerBase):
    id: int
    active: bool = True
    create_date: Optional[datetime] = None
    write_date: Optional[datetime] = None

    class Config:
        from_attributes = True

class CustomerList(BaseModel):
    customers: list[Customer]
    total: int
    page: int
    page_size: int
    total_pages: int
# app/models/product.py
from pydantic import BaseModel, Field
from typing import Optional
from decimal import Decimal

class ProductBase(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    default_code: Optional[str] = Field(None, max_length=50)
    list_price: Decimal = Field(..., ge=0)
    standard_price: Decimal = Field(..., ge=0)
    type: str = Field("consu", regex="^(consu|service|product)$")
    categ_id: Optional[int] = None

class ProductCreate(ProductBase):
    pass

class ProductUpdate(BaseModel):
    name: Optional[str] = Field(None, min_length=1, max_length=100)
    default_code: Optional[str] = Field(None, max_length=50)
    list_price: Optional[Decimal] = Field(None, ge=0)
    standard_price: Optional[Decimal] = Field(None, ge=0)
    type: Optional[str] = Field(None, regex="^(consu|service|product)$")
    categ_id: Optional[int] = None
    active: Optional[bool] = None

class Product(ProductBase):
    id: int
    active: bool = True
    qty_available: Optional[Decimal] = None

    class Config:
        from_attributes = True

API Routers

# app/routers/customers.py
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import List
from zenoo_rpc import ZenooClient
from zenoo_rpc.models.common import ResPartner, ResCountry
from zenoo_rpc.exceptions import ZenooError, ValidationError
from ..dependencies import get_zenoo_client, get_current_user
from ..models.customer import Customer, CustomerCreate, CustomerUpdate, CustomerList

router = APIRouter(prefix="/customers", tags=["customers"])

@router.get("/", response_model=CustomerList)
async def list_customers(
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100),
    search: str = Query(None, max_length=100),
    is_company: bool = Query(None),
    active: bool = Query(True),
    client: ZenooClient = Depends(get_zenoo_client),
    current_user: dict = Depends(get_current_user)
):
    """List customers with pagination and filtering"""
    try:
        # Build query
        query = client.model(ResPartner)

        # Apply filters
        if active is not None:
            query = query.filter(active=active)

        if is_company is not None:
            query = query.filter(is_company=is_company)

        if search:
            query = query.filter(name__ilike=f"%{search}%")

        # Get total count
        total = await query.count()

        # Get paginated results
        offset = (page - 1) * page_size
        partners = await query.order_by("name").limit(page_size).offset(offset).all()

        # Convert to response model
        customers = [
            Customer(
                id=partner.id,
                name=partner.name,
                email=partner.email,
                phone=partner.phone,
                is_company=partner.is_company,
                street=partner.street,
                city=partner.city,
                zip=partner.zip,
                active=partner.active,
                create_date=partner.create_date,
                write_date=partner.write_date
            )
            for partner in partners
        ]

        total_pages = (total + page_size - 1) // page_size

        return CustomerList(
            customers=customers,
            total=total,
            page=page,
            page_size=page_size,
            total_pages=total_pages
        )

    except ZenooError as e:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Odoo service error: {e}"
        )

@router.get("/{customer_id}", response_model=Customer)
async def get_customer(
    customer_id: int,
    client: ZenooClient = Depends(get_zenoo_client),
    current_user: dict = Depends(get_current_user)
):
    """Get a specific customer by ID"""
    try:
        partner = await client.model(ResPartner).get(customer_id)

        if not partner:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Customer not found"
            )

        return Customer(
            id=partner.id,
            name=partner.name,
            email=partner.email,
            phone=partner.phone,
            is_company=partner.is_company,
            street=partner.street,
            city=partner.city,
            zip=partner.zip,
            active=partner.active,
            create_date=partner.create_date,
            write_date=partner.write_date
        )

    except ZenooError as e:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Odoo service error: {e}"
        )

@router.post("/", response_model=Customer, status_code=status.HTTP_201_CREATED)
async def create_customer(
    customer_data: CustomerCreate,
    client: ZenooClient = Depends(get_zenoo_client),
    current_user: dict = Depends(get_current_user)
):
    """Create a new customer"""
    try:
        # Prepare data for Odoo
        odoo_data = customer_data.dict(exclude_none=True)

        # Handle country code
        if customer_data.country_code:
            country = await client.model(ResCountry).filter(
                code=customer_data.country_code.upper()
            ).first()
            if country:
                odoo_data["country_id"] = country.id
            del odoo_data["country_code"]

        # Create partner in Odoo
        partner = await client.model(ResPartner).create(odoo_data)

        return Customer(
            id=partner.id,
            name=partner.name,
            email=partner.email,
            phone=partner.phone,
            is_company=partner.is_company,
            street=partner.street,
            city=partner.city,
            zip=partner.zip,
            active=partner.active,
            create_date=partner.create_date,
            write_date=partner.write_date
        )

    except ValidationError as e:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Validation error: {e}"
        )
    except ZenooError as e:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Odoo service error: {e}"
        )

@router.put("/{customer_id}", response_model=Customer)
async def update_customer(
    customer_id: int,
    customer_data: CustomerUpdate,
    client: ZenooClient = Depends(get_zenoo_client),
    current_user: dict = Depends(get_current_user)
):
    """Update an existing customer"""
    try:
        # Check if customer exists
        existing_partner = await client.model(ResPartner).get(customer_id)
        if not existing_partner:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Customer not found"
            )

        # Prepare update data
        update_data = customer_data.dict(exclude_none=True)

        # Handle country code
        if customer_data.country_code:
            country = await client.model(ResCountry).filter(
                code=customer_data.country_code.upper()
            ).first()
            if country:
                update_data["country_id"] = country.id
            del update_data["country_code"]

        # Update partner in Odoo
        partner = await client.model(ResPartner).update(customer_id, update_data)

        return Customer(
            id=partner.id,
            name=partner.name,
            email=partner.email,
            phone=partner.phone,
            is_company=partner.is_company,
            street=partner.street,
            city=partner.city,
            zip=partner.zip,
            active=partner.active,
            create_date=partner.create_date,
            write_date=partner.write_date
        )

    except ValidationError as e:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail=f"Validation error: {e}"
        )
    except ZenooError as e:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Odoo service error: {e}"
        )

@router.delete("/{customer_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_customer(
    customer_id: int,
    client: ZenooClient = Depends(get_zenoo_client),
    current_user: dict = Depends(get_current_user)
):
    """Delete a customer (soft delete by setting active=False)"""
    try:
        # Check if customer exists
        existing_partner = await client.model(ResPartner).get(customer_id)
        if not existing_partner:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail="Customer not found"
            )

        # Soft delete by setting active=False
        await client.model(ResPartner).update(customer_id, {"active": False})

    except ZenooError as e:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail=f"Odoo service error: {e}"
        )

Main Application

# app/main.py
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
import logging
from .config import settings
from .routers import customers, products, orders
from .dependencies import _client_instance

# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Application lifespan events"""
    # Startup
    logger.info("Starting FastAPI application")
    yield

    # Shutdown
    logger.info("Shutting down FastAPI application")
    if _client_instance:
        await _client_instance.close()

# Create FastAPI app
app = FastAPI(
    title=settings.api_title,
    version=settings.api_version,
    description=settings.api_description,
    lifespan=lifespan
)

# Add CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Configure appropriately for production
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# Include routers
app.include_router(customers.router, prefix="/api/v1")
app.include_router(products.router, prefix="/api/v1")
app.include_router(orders.router, prefix="/api/v1")

@app.get("/")
async def root():
    """Root endpoint"""
    return {
        "message": "Odoo FastAPI Integration",
        "version": settings.api_version,
        "docs": "/docs"
    }

@app.get("/health")
async def health_check():
    """Health check endpoint"""
    return {"status": "healthy", "service": "odoo-fastapi-api"}

# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
    logger.error(f"Global exception: {exc}")
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"}
    )

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "app.main:app",
        host="0.0.0.0",
        port=8000,
        reload=True,
        log_level="info"
    )

Running the Application

Development

# Install dependencies
pip install -r requirements.txt

# Set environment variables
export ODOO_HOST=localhost
export ODOO_PORT=8069
export ODOO_DATABASE=demo
export ODOO_USERNAME=admin
export ODOO_PASSWORD=admin

# Run the application
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000

Production with Docker

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
version: '3.8'
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      - ODOO_HOST=odoo
      - ODOO_PORT=8069
      - ODOO_DATABASE=demo
      - ODOO_USERNAME=admin
      - ODOO_PASSWORD=admin
    depends_on:
      - odoo

  odoo:
    image: odoo:17
    ports:
      - "8069:8069"
    environment:
      - HOST=db
      - USER=odoo
      - PASSWORD=odoo
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      - POSTGRES_DB=postgres
      - POSTGRES_USER=odoo
      - POSTGRES_PASSWORD=odoo

API Usage Examples

Create a Customer

curl -X POST "http://localhost:8000/api/v1/customers/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "name": "Acme Corporation",
    "email": "contact@acme.com",
    "phone": "+1-555-0123",
    "is_company": true,
    "street": "123 Business Ave",
    "city": "Business City",
    "zip": "12345",
    "country_code": "US"
  }'

List Customers

curl "http://localhost:8000/api/v1/customers/?page=1&page_size=10&search=acme" \
  -H "Authorization: Bearer YOUR_TOKEN"

Update a Customer

curl -X PUT "http://localhost:8000/api/v1/customers/1" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{
    "email": "newemail@acme.com",
    "phone": "+1-555-9999"
  }'

Features

  • RESTful API with proper HTTP methods and status codes
  • Async/await for high performance
  • Pydantic validation for request/response data
  • Dependency injection for clean architecture
  • Error handling with proper HTTP status codes
  • Authentication with JWT tokens
  • Pagination for large datasets
  • Filtering and search capabilities
  • Caching for improved performance
  • Docker support for easy deployment
  • OpenAPI documentation at /docs

Next Steps

  • Add more endpoints for products and orders
  • Implement rate limiting
  • Add comprehensive logging and monitoring
  • Set up automated testing
  • Configure production security settings

This example provides a solid foundation for building production-ready APIs that integrate with Odoo using Zenoo RPC and FastAPI.