Add field filtering support to GET endpoints using query parameters
This commit is contained in:
parent
0b5aa51985
commit
a3124ddd4a
55
README.md
55
README.md
@ -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
|
||||
@ -127,4 +133,51 @@ Invoices are automatically assigned a unique invoice number with the format:
|
||||
- `INV-YYYYMM-XXXXXX`, where:
|
||||
- `INV` is a fixed prefix
|
||||
- `YYYYMM` is the year and month (e.g., 202307 for July 2023)
|
||||
- `XXXXXX` is a random alphanumeric string
|
||||
- `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)
|
@ -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
|
||||
|
||||
|
||||
|
@ -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):
|
||||
@ -46,4 +47,52 @@ def calculate_total_amount(items):
|
||||
item_amount = item.quantity * item.unit_price
|
||||
total += item_amount
|
||||
|
||||
return total
|
||||
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}
|
@ -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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user