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
|
- `POST /api/v1/invoices`: Create a new invoice
|
||||||
- `GET /api/v1/invoices`: List all invoices (with pagination)
|
- `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
|
- `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
|
- `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}`: Update an invoice
|
||||||
- `PATCH /api/v1/invoices/{invoice_id}/status`: Update invoice status
|
- `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-YYYYMM-XXXXXX`, where:
|
||||||
- `INV` is a fixed prefix
|
- `INV` is a fixed prefix
|
||||||
- `YYYYMM` is the year and month (e.g., 202307 for July 2023)
|
- `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 datetime import datetime
|
||||||
from typing import List
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@ -13,6 +14,7 @@ from app.schemas.invoice import (
|
|||||||
InvoiceSearchQuery,
|
InvoiceSearchQuery,
|
||||||
InvoiceStatusUpdate,
|
InvoiceStatusUpdate,
|
||||||
InvoiceUpdate,
|
InvoiceUpdate,
|
||||||
|
invoice_db_filterable,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -68,23 +70,47 @@ def create_invoice(invoice_data: InvoiceCreate, db: Session = Depends(get_db)):
|
|||||||
return db_invoice
|
return db_invoice
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[InvoiceDB])
|
@router.get("/")
|
||||||
def get_invoices(
|
def get_invoices(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
fields: Optional[str] = None,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Retrieve a list of invoices.
|
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()
|
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
|
return invoices
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{invoice_id}", response_model=InvoiceDB)
|
@router.get("/{invoice_id}")
|
||||||
def get_invoice(invoice_id: int, db: Session = Depends(get_db)):
|
def get_invoice(
|
||||||
|
invoice_id: int,
|
||||||
|
fields: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Retrieve a specific invoice by ID.
|
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()
|
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
|
||||||
if invoice is None:
|
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,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Invoice with ID {invoice_id} 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
|
return invoice
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
from typing import Dict, Any, List, Set, Union
|
||||||
|
|
||||||
|
|
||||||
def generate_invoice_number(prefix="INV", length=6):
|
def generate_invoice_number(prefix="INV", length=6):
|
||||||
@ -46,4 +47,52 @@ def calculate_total_amount(items):
|
|||||||
item_amount = item.quantity * item.unit_price
|
item_amount = item.quantity * item.unit_price
|
||||||
total += item_amount
|
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 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):
|
class InvoiceItemBase(BaseModel):
|
||||||
@ -62,6 +135,11 @@ class InvoiceDB(InvoiceBase):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# Create filterable versions of our models
|
||||||
|
invoice_db_filterable = FilterableModel(InvoiceDB)
|
||||||
|
invoice_item_db_filterable = FilterableModel(InvoiceItemDB)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceSearchQuery(BaseModel):
|
class InvoiceSearchQuery(BaseModel):
|
||||||
invoice_number: str
|
invoice_number: str
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user