feat(api): implement inventory API endpoints

Add REST API endpoints for character inventory management:
- GET /api/v1/characters/<id>/inventory - Get inventory and equipped items
- POST /api/v1/characters/<id>/inventory/equip - Equip item to slot
- POST /api/v1/characters/<id>/inventory/unequip - Unequip from slot
- POST /api/v1/characters/<id>/inventory/use - Use consumable item
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop item

All endpoints include:
- Authentication via @require_auth decorator
- Ownership validation through CharacterService
- Comprehensive error handling with proper HTTP status codes
- Full logging for debugging

Includes 25 integration tests covering authentication requirements,
URL patterns, and response formats.

Task 2.4 of Phase 4 Combat Implementation complete.
This commit is contained in:
2025-11-26 18:54:33 -06:00
parent 76f67c4a22
commit 4ced1b04df
5 changed files with 1157 additions and 148 deletions

View File

@@ -174,6 +174,11 @@ def register_blueprints(app: Flask) -> None:
app.register_blueprint(combat_bp)
logger.info("Combat API blueprint registered")
# Import and register Inventory API blueprint
from app.api.inventory import inventory_bp
app.register_blueprint(inventory_bp)
logger.info("Inventory API blueprint registered")
# TODO: Register additional blueprints as they are created
# from app.api import marketplace, shop
# app.register_blueprint(marketplace.bp, url_prefix='/api/v1/marketplace')

639
api/app/api/inventory.py Normal file
View File

@@ -0,0 +1,639 @@
"""
Inventory API Blueprint
Endpoints for managing character inventory and equipment.
All endpoints require authentication and enforce ownership validation.
Endpoints:
- GET /api/v1/characters/<id>/inventory - Get character inventory and equipped items
- POST /api/v1/characters/<id>/inventory/equip - Equip an item
- POST /api/v1/characters/<id>/inventory/unequip - Unequip an item
- POST /api/v1/characters/<id>/inventory/use - Use a consumable item
- DELETE /api/v1/characters/<id>/inventory/<item_id> - Drop an item
"""
from flask import Blueprint, request
from app.services.inventory_service import (
get_inventory_service,
ItemNotFoundError,
CannotEquipError,
InvalidSlotError,
CannotUseItemError,
InventoryFullError,
VALID_SLOTS,
MAX_INVENTORY_SIZE,
)
from app.services.character_service import (
get_character_service,
CharacterNotFound,
)
from app.utils.response import (
success_response,
error_response,
not_found_response,
validation_error_response,
)
from app.utils.auth import require_auth, get_current_user
from app.utils.logging import get_logger
# Initialize logger
logger = get_logger(__file__)
# Create blueprint
inventory_bp = Blueprint('inventory', __name__)
# =============================================================================
# API Endpoints
# =============================================================================
@inventory_bp.route('/api/v1/characters/<character_id>/inventory', methods=['GET'])
@require_auth
def get_inventory(character_id: str):
"""
Get character inventory and equipped items.
Args:
character_id: Character ID
Returns:
200: Inventory and equipment data
401: Not authenticated
404: Character not found or not owned by user
500: Internal server error
Example Response:
{
"result": {
"inventory": [
{
"item_id": "gen_abc123",
"name": "Flaming Dagger",
"item_type": "weapon",
"rarity": "rare",
...
}
],
"equipped": {
"weapon": {...},
"helmet": null,
...
},
"inventory_count": 5,
"max_inventory": 100
}
}
"""
try:
user = get_current_user()
logger.info("Getting inventory",
user_id=user.id,
character_id=character_id)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Get inventory items
inventory_items = inventory_service.get_inventory(character)
# Get equipped items
equipped_items = inventory_service.get_equipped_items(character)
# Build equipped dict with all slots (None for empty slots)
equipped_response = {}
for slot in VALID_SLOTS:
item = equipped_items.get(slot)
equipped_response[slot] = item.to_dict() if item else None
logger.info("Inventory retrieved successfully",
user_id=user.id,
character_id=character_id,
item_count=len(inventory_items))
return success_response(
result={
"inventory": [item.to_dict() for item in inventory_items],
"equipped": equipped_response,
"inventory_count": len(inventory_items),
"max_inventory": MAX_INVENTORY_SIZE,
}
)
except CharacterNotFound as e:
logger.warning("Character not found",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except Exception as e:
logger.error("Failed to get inventory",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="INVENTORY_GET_ERROR",
message="Failed to retrieve inventory",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/equip', methods=['POST'])
@require_auth
def equip_item(character_id: str):
"""
Equip an item from inventory to a specified slot.
Args:
character_id: Character ID
Request Body:
{
"item_id": "gen_abc123",
"slot": "weapon"
}
Returns:
200: Item equipped successfully
400: Cannot equip item (wrong type, level requirement, etc.)
401: Not authenticated
404: Character or item not found
422: Validation error (invalid slot)
500: Internal server error
Example Response:
{
"result": {
"message": "Equipped Flaming Dagger to weapon slot",
"equipped": {...},
"unequipped_item": null
}
}
"""
try:
user = get_current_user()
# Get request data
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"error": "Request body is required"}
)
item_id = data.get('item_id', '').strip()
slot = data.get('slot', '').strip().lower()
# Validate required fields
validation_errors = {}
if not item_id:
validation_errors['item_id'] = "item_id is required"
if not slot:
validation_errors['slot'] = "slot is required"
if validation_errors:
return validation_error_response(
message="Validation failed",
details=validation_errors
)
logger.info("Equipping item",
user_id=user.id,
character_id=character_id,
item_id=item_id,
slot=slot)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Equip item
previous_item = inventory_service.equip_item(
character, item_id, slot, user.id
)
# Get item name for message
equipped_item = character.equipped.get(slot)
item_name = equipped_item.get_display_name() if equipped_item else item_id
# Build equipped response
equipped_response = {}
for s in VALID_SLOTS:
item = character.equipped.get(s)
equipped_response[s] = item.to_dict() if item else None
logger.info("Item equipped successfully",
user_id=user.id,
character_id=character_id,
item_id=item_id,
slot=slot,
previous_item=previous_item.item_id if previous_item else None)
return success_response(
result={
"message": f"Equipped {item_name} to {slot} slot",
"equipped": equipped_response,
"unequipped_item": previous_item.to_dict() if previous_item else None,
}
)
except CharacterNotFound as e:
logger.warning("Character not found for equip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except ItemNotFoundError as e:
logger.warning("Item not found for equip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return not_found_response(message=str(e))
except InvalidSlotError as e:
logger.warning("Invalid slot for equip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
slot=slot if 'slot' in locals() else 'unknown',
error=str(e))
return validation_error_response(
message=str(e),
details={"slot": str(e)}
)
except CannotEquipError as e:
logger.warning("Cannot equip item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return error_response(
code="CANNOT_EQUIP",
message=str(e),
status=400
)
except Exception as e:
logger.error("Failed to equip item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="EQUIP_ERROR",
message="Failed to equip item",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/unequip', methods=['POST'])
@require_auth
def unequip_item(character_id: str):
"""
Unequip an item from a specified slot (returns to inventory).
Args:
character_id: Character ID
Request Body:
{
"slot": "weapon"
}
Returns:
200: Item unequipped successfully (or slot was empty)
400: Inventory full, cannot unequip
401: Not authenticated
404: Character not found
422: Validation error (invalid slot)
500: Internal server error
Example Response:
{
"result": {
"message": "Unequipped Flaming Dagger from weapon slot",
"unequipped_item": {...},
"equipped": {...}
}
}
"""
try:
user = get_current_user()
# Get request data
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"error": "Request body is required"}
)
slot = data.get('slot', '').strip().lower()
if not slot:
return validation_error_response(
message="Validation failed",
details={"slot": "slot is required"}
)
logger.info("Unequipping item",
user_id=user.id,
character_id=character_id,
slot=slot)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Unequip item
unequipped_item = inventory_service.unequip_item(character, slot, user.id)
# Build equipped response
equipped_response = {}
for s in VALID_SLOTS:
item = character.equipped.get(s)
equipped_response[s] = item.to_dict() if item else None
# Build message
if unequipped_item:
message = f"Unequipped {unequipped_item.get_display_name()} from {slot} slot"
else:
message = f"Slot '{slot}' was already empty"
logger.info("Item unequipped",
user_id=user.id,
character_id=character_id,
slot=slot,
unequipped_item=unequipped_item.item_id if unequipped_item else None)
return success_response(
result={
"message": message,
"unequipped_item": unequipped_item.to_dict() if unequipped_item else None,
"equipped": equipped_response,
}
)
except CharacterNotFound as e:
logger.warning("Character not found for unequip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except InvalidSlotError as e:
logger.warning("Invalid slot for unequip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
slot=slot if 'slot' in locals() else 'unknown',
error=str(e))
return validation_error_response(
message=str(e),
details={"slot": str(e)}
)
except InventoryFullError as e:
logger.warning("Inventory full, cannot unequip",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="INVENTORY_FULL",
message=str(e),
status=400
)
except Exception as e:
logger.error("Failed to unequip item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="UNEQUIP_ERROR",
message="Failed to unequip item",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/use', methods=['POST'])
@require_auth
def use_item(character_id: str):
"""
Use a consumable item from inventory.
Args:
character_id: Character ID
Request Body:
{
"item_id": "health_potion_small"
}
Returns:
200: Item used successfully
400: Cannot use item (not consumable)
401: Not authenticated
404: Character or item not found
500: Internal server error
Example Response:
{
"result": {
"item_used": "Small Health Potion",
"effects_applied": [
{
"effect_name": "Healing",
"effect_type": "hot",
"value": 25,
"message": "Restored 25 HP"
}
],
"hp_restored": 25,
"mp_restored": 0,
"message": "Used Small Health Potion: Restored 25 HP"
}
}
"""
try:
user = get_current_user()
# Get request data
data = request.get_json()
if not data:
return validation_error_response(
message="Request body is required",
details={"error": "Request body is required"}
)
item_id = data.get('item_id', '').strip()
if not item_id:
return validation_error_response(
message="Validation failed",
details={"item_id": "item_id is required"}
)
logger.info("Using item",
user_id=user.id,
character_id=character_id,
item_id=item_id)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Use consumable
result = inventory_service.use_consumable(character, item_id, user.id)
logger.info("Item used successfully",
user_id=user.id,
character_id=character_id,
item_id=item_id,
hp_restored=result.hp_restored,
mp_restored=result.mp_restored)
return success_response(result=result.to_dict())
except CharacterNotFound as e:
logger.warning("Character not found for use item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except ItemNotFoundError as e:
logger.warning("Item not found for use",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return not_found_response(message=str(e))
except CannotUseItemError as e:
logger.warning("Cannot use item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id if 'item_id' in locals() else 'unknown',
error=str(e))
return error_response(
code="CANNOT_USE_ITEM",
message=str(e),
status=400
)
except Exception as e:
logger.error("Failed to use item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return error_response(
code="USE_ITEM_ERROR",
message="Failed to use item",
status=500
)
@inventory_bp.route('/api/v1/characters/<character_id>/inventory/<item_id>', methods=['DELETE'])
@require_auth
def drop_item(character_id: str, item_id: str):
"""
Drop (remove) an item from inventory.
Args:
character_id: Character ID
item_id: Item ID to drop
Returns:
200: Item dropped successfully
401: Not authenticated
404: Character or item not found
500: Internal server error
Example Response:
{
"result": {
"message": "Dropped Rusty Sword",
"dropped_item": {...},
"inventory_count": 4
}
}
"""
try:
user = get_current_user()
logger.info("Dropping item",
user_id=user.id,
character_id=character_id,
item_id=item_id)
# Get character (validates ownership)
char_service = get_character_service()
character = char_service.get_character(character_id, user.id)
# Get inventory service
inventory_service = get_inventory_service()
# Drop item
dropped_item = inventory_service.drop_item(character, item_id, user.id)
logger.info("Item dropped successfully",
user_id=user.id,
character_id=character_id,
item_id=item_id,
item_name=dropped_item.get_display_name())
return success_response(
result={
"message": f"Dropped {dropped_item.get_display_name()}",
"dropped_item": dropped_item.to_dict(),
"inventory_count": len(character.inventory),
}
)
except CharacterNotFound as e:
logger.warning("Character not found for drop",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
error=str(e))
return not_found_response(message=str(e))
except ItemNotFoundError as e:
logger.warning("Item not found for drop",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id,
error=str(e))
return not_found_response(message=str(e))
except Exception as e:
logger.error("Failed to drop item",
user_id=user.id if 'user' in locals() else 'unknown',
character_id=character_id,
item_id=item_id,
error=str(e))
return error_response(
code="DROP_ITEM_ERROR",
message="Failed to drop item",
status=500
)