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:
@@ -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
639
api/app/api/inventory.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user