Add field filtering support to GET endpoints using query parameters

This commit is contained in:
Automated Action 2025-05-19 09:09:38 +00:00
parent 0b5aa51985
commit a3124ddd4a
4 changed files with 222 additions and 8 deletions

View File

@ -73,7 +73,13 @@ This is a FastAPI application for generating and managing invoices. It allows yo
- `POST /api/v1/invoices`: Create a new invoice
- `GET /api/v1/invoices`: List all invoices (with pagination)
- Query parameters:
- `skip`: Number of records to skip (default: 0)
- `limit`: Maximum number of records to return (default: 100)
- `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
- `GET /api/v1/invoices/{invoice_id}`: Get a specific invoice by ID
- Query parameters:
- `fields`: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
- `POST /api/v1/invoices/find`: Find an invoice by invoice number
- `PATCH /api/v1/invoices/{invoice_id}`: Update an invoice
- `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status
@ -128,3 +134,50 @@ Invoices are automatically assigned a unique invoice number with the format:
- `INV` is a fixed prefix
- `YYYYMM` is the year and month (e.g., 202307 for July 2023)
- `XXXXXX` is a random alphanumeric string
## Field Filtering
The API supports field filtering for GET operations. This allows clients to request only the specific fields they need, which can improve performance and reduce bandwidth usage.
### How to Use Field Filtering:
1. Add a `fields` query parameter to GET requests
2. Specify a comma-separated list of field names to include in the response
### Example:
To get only the ID, invoice number, and total amount of invoices:
```
GET /api/v1/invoices?fields=id,invoice_number,total_amount
```
This would return:
```json
[
{
"id": 1,
"invoice_number": "INV-202307-A1B2C3",
"total_amount": 100.0
},
{
"id": 2,
"invoice_number": "INV-202307-D4E5F6",
"total_amount": 200.0
}
]
```
### Available Fields:
Invoices have the following fields that can be filtered:
- `id`
- `invoice_number`
- `date_created`
- `due_date`
- `total_amount`
- `status`
- `customer_name`
- `customer_email`
- `customer_address`
- `notes`
- `items` (this includes related invoice items)

View File

@ -1,7 +1,8 @@
from datetime import datetime
from typing import List
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
@ -13,6 +14,7 @@ from app.schemas.invoice import (
InvoiceSearchQuery,
InvoiceStatusUpdate,
InvoiceUpdate,
invoice_db_filterable,
)
router = APIRouter()
@ -68,23 +70,47 @@ def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)):
return db_invoice
@router.get("/", response_model=List[InvoiceDB])
@router.get("/")
def get_invoices(
skip: int = 0,
limit: int = 100,
fields: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
Retrieve a list of invoices.
Parameters:
- skip: Number of records to skip
- limit: Maximum number of records to return
- fields: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
If not provided, all fields will be returned.
"""
invoices = db.query(Invoice).offset(skip).limit(limit).all()
# If fields parameter is provided, filter the response
if fields:
# Process the response to include only the specified fields
filtered_response = invoice_db_filterable.process_response(invoices, fields)
return JSONResponse(content=filtered_response)
# Otherwise, return all fields
return invoices
@router.get("/{invoice_id}", response_model=InvoiceDB)
def get_invoice(invoice_id: int, db: Session = Depends(get_db)):
@router.get("/{invoice_id}")
def get_invoice(
invoice_id: int,
fields: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
Retrieve a specific invoice by ID.
Parameters:
- invoice_id: ID of the invoice to retrieve
- fields: Comma-separated list of fields to include in the response (e.g., "id,invoice_number,total_amount")
If not provided, all fields will be returned.
"""
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if invoice is None:
@ -92,6 +118,14 @@ def get_invoice(invoice_id: int, db: Session = Depends(get_db)):
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Invoice with ID {invoice_id} not found",
)
# If fields parameter is provided, filter the response
if fields:
# Process the response to include only the specified fields
filtered_response = invoice_db_filterable.process_response(invoice, fields)
return JSONResponse(content=filtered_response)
# Otherwise, return all fields
return invoice

View File

@ -1,6 +1,7 @@
import datetime
import random
import string
from typing import Dict, Any, List, Set, Union
def generate_invoice_number(prefix="INV", length=6):
@ -47,3 +48,51 @@ def calculate_total_amount(items):
total += item_amount
return total
def filter_fields(data: Union[Dict[str, Any], List[Dict[str, Any]]], fields: str = None) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
"""
Filter dictionary or list of dictionaries to only include specified fields.
Args:
data (Union[Dict[str, Any], List[Dict[str, Any]]]): The data to filter
fields (str, optional): Comma-separated string of fields to include.
If None, returns original data.
Returns:
Union[Dict[str, Any], List[Dict[str, Any]]]: Filtered data
Example:
>>> data = {"id": 1, "name": "John", "email": "john@example.com"}
>>> filter_fields(data, "id,name")
{"id": 1, "name": "John"}
"""
if fields is None:
return data
# Parse the fields parameter
fields_set: Set[str] = {field.strip() for field in fields.split(",")} if fields else set()
if not fields_set:
return data
# Handle list of dictionaries
if isinstance(data, list):
return [_filter_dict(item, fields_set) for item in data]
# Handle single dictionary
return _filter_dict(data, fields_set)
def _filter_dict(data: Dict[str, Any], fields_set: Set[str]) -> Dict[str, Any]:
"""
Filter a dictionary to only include specified fields.
Args:
data (Dict[str, Any]): Dictionary to filter
fields_set (Set[str]): Set of fields to include
Returns:
Dict[str, Any]: Filtered dictionary
"""
return {k: v for k, v in data.items() if k in fields_set}

View File

@ -1,7 +1,80 @@
from datetime import datetime
from typing import List, Optional
from typing import Any, Dict, List, Optional, Type, TypeVar, Generic, Union
from pydantic import BaseModel, Field, validator
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel, Field, validator, create_model
T = TypeVar('T', bound=BaseModel)
class FilterableModel(Generic[T]):
"""
Wrapper class for filtering fields in Pydantic models.
"""
def __init__(self, model_class: Type[T]):
self.model_class = model_class
def filter_model(self, fields: str = None) -> Type[BaseModel]:
"""
Create a new model with only the specified fields from the original model.
If fields is None, returns the original model.
Args:
fields (str, optional): Comma-separated string of fields to include.
Returns:
Type[BaseModel]: A new model class with only the specified fields.
"""
if fields is None:
return self.model_class
# Parse the fields
field_set = {field.strip() for field in fields.split(",")} if fields else set()
if not field_set:
return self.model_class
# Get fields from the original model
original_fields = self.model_class.__annotations__
original_field_defaults = {
field_name: (field_type, self.model_class.__fields__[field_name].default)
for field_name, field_type in original_fields.items()
if field_name in field_set and field_name in self.model_class.__fields__
}
# Create a new model with only the specified fields
filtered_model = create_model(
f"{self.model_class.__name__}Filtered",
**original_field_defaults
)
# Copy the Config from the original model if it exists
if hasattr(self.model_class, "Config"):
filtered_model.Config = type("Config", (), dict(vars(self.model_class.Config)))
return filtered_model
def process_response(self, obj: Union[T, List[T]], fields: str = None) -> Union[Dict[str, Any], List[Dict[str, Any]]]:
"""
Process the response to include only the specified fields.
Args:
obj (Union[T, List[T]]): The object(s) to process
fields (str, optional): Comma-separated string of fields to include.
Returns:
Union[Dict[str, Any], List[Dict[str, Any]]]: The filtered response
"""
if fields is None:
return jsonable_encoder(obj)
filtered_model = self.filter_model(fields)
if isinstance(obj, list):
return [jsonable_encoder(filtered_model.parse_obj(item)) for item in obj]
else:
return jsonable_encoder(filtered_model.parse_obj(obj))
class InvoiceItemBase(BaseModel):
@ -62,6 +135,11 @@ class InvoiceDB(InvoiceBase):
from_attributes = True
# Create filterable versions of our models
invoice_db_filterable = FilterableModel(InvoiceDB)
invoice_item_db_filterable = FilterableModel(InvoiceItemDB)
class InvoiceSearchQuery(BaseModel):
invoice_number: str