feat: Phase 2 - Service Management CRUD API (Backend)

Backend Implementation:
- Service model with comprehensive fields (name, url, type, status, health_endpoint)
- Complete CRUD API endpoints for service management
- Health check mechanism with httpx and response time tracking
- Service status tracking (healthy/unhealthy/unknown)
- Service type categorization (backend, frontend, database, cache, etc.)

API Endpoints:
- GET /api/services - Get all services
- POST /api/services - Create new service
- GET /api/services/{id} - Get service by ID
- PUT /api/services/{id} - Update service
- DELETE /api/services/{id} - Delete service
- POST /api/services/{id}/health-check - Check specific service health
- POST /api/services/health-check/all - Check all services health

Frontend Preparation:
- TypeScript type definitions for Service
- Service API client with full CRUD methods
- Health check client methods

Files Added:
- backend/app/models/service.py - Service data model
- backend/app/schemas/service.py - Request/response schemas
- backend/app/services/service_service.py - Business logic
- backend/app/routes/services.py - API route handlers
- frontend/src/types/service.ts - TypeScript types
- frontend/src/api/service.ts - API client

Updated:
- backend/app/main.py - Added services router
- docs/PROGRESS.md - Added Phase 2 status

Next: Frontend UI implementation (Services list page, Add/Edit modal, Health monitoring)

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jungwoo choi
2025-10-28 16:44:33 +09:00
parent f4b75b96a5
commit e60e531cdc
8 changed files with 642 additions and 3 deletions

View File

@ -6,8 +6,8 @@
## Current Status
- **Date Started**: 2025-09-09
- **Last Updated**: 2025-10-28
- **Current Phase**: Phase 1 Complete ✅ (Authentication System)
- **Next Action**: Phase 2 - Service Management CRUD
- **Current Phase**: Phase 2 In Progress 🔄 (Service Management CRUD - Backend Complete)
- **Next Action**: Phase 2 - Frontend UI Implementation
## Completed Checkpoints
@ -92,6 +92,44 @@ All authentication endpoints tested and working:
- ✅ Duplicate email prevention
- ✅ Unauthorized access blocking
### Phase 2: Service Management CRUD 🔄
**Started Date**: 2025-10-28
**Status**: Backend Complete, Frontend In Progress
#### Backend (FastAPI + MongoDB) ✅
✅ Service model with comprehensive fields
✅ Service CRUD API endpoints (Create, Read, Update, Delete)
✅ Health check mechanism with httpx
✅ Response time measurement
✅ Status tracking (healthy/unhealthy/unknown)
✅ Service type categorization (backend, frontend, database, etc.)
**API Endpoints**:
- GET `/api/services` - Get all services
- POST `/api/services` - Create new service
- GET `/api/services/{id}` - Get service by ID
- PUT `/api/services/{id}` - Update service
- DELETE `/api/services/{id}` - Delete service
- POST `/api/services/{id}/health-check` - Check specific service health
- POST `/api/services/health-check/all` - Check all services health
**Files Created**:
- `/services/console/backend/app/models/service.py` - Service model
- `/services/console/backend/app/schemas/service.py` - Service schemas
- `/services/console/backend/app/services/service_service.py` - Business logic
- `/services/console/backend/app/routes/services.py` - API routes
#### Frontend (React + TypeScript) 🔄
✅ TypeScript type definitions
✅ Service API client
⏳ Services list page (pending)
⏳ Add/Edit service modal (pending)
⏳ Health status display (pending)
**Files Created**:
- `/services/console/frontend/src/types/service.ts` - TypeScript types
- `/services/console/frontend/src/api/service.ts` - API client
### Earlier Checkpoints
✅ Project structure planning (CLAUDE.md)
✅ Implementation plan created (docs/PLAN.md)

View File

@ -5,7 +5,7 @@ import logging
from .core.config import settings
from .db.mongodb import MongoDB
from .routes import auth
from .routes import auth, services
# Configure logging
logging.basicConfig(
@ -56,6 +56,7 @@ app.add_middleware(
# Include routers
app.include_router(auth.router)
app.include_router(services.router)
# Health check endpoints

View File

@ -0,0 +1,81 @@
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from bson import ObjectId
from pydantic_core import core_schema
class PyObjectId(str):
"""Custom ObjectId type for Pydantic v2"""
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
return core_schema.union_schema([
core_schema.is_instance_schema(ObjectId),
core_schema.chain_schema([
core_schema.str_schema(),
core_schema.no_info_plain_validator_function(cls.validate),
])
],
serialization=core_schema.plain_serializer_function_ser_schema(
lambda x: str(x)
))
@classmethod
def validate(cls, v):
if not ObjectId.is_valid(v):
raise ValueError("Invalid ObjectId")
return ObjectId(v)
class ServiceStatus:
"""Service status constants"""
HEALTHY = "healthy"
UNHEALTHY = "unhealthy"
UNKNOWN = "unknown"
class ServiceType:
"""Service type constants"""
BACKEND = "backend"
FRONTEND = "frontend"
DATABASE = "database"
CACHE = "cache"
MESSAGE_QUEUE = "message_queue"
OTHER = "other"
class Service(BaseModel):
"""Service model for MongoDB"""
id: Optional[PyObjectId] = Field(alias="_id", default=None)
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
service_type: str = Field(default=ServiceType.BACKEND)
url: str = Field(..., min_length=1)
health_endpoint: Optional[str] = Field(default="/health")
status: str = Field(default=ServiceStatus.UNKNOWN)
last_health_check: Optional[datetime] = None
response_time_ms: Optional[float] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
metadata: Dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(
populate_by_name=True,
arbitrary_types_allowed=True,
json_encoders={ObjectId: str},
json_schema_extra={
"example": {
"name": "News API",
"description": "News generation and management API",
"service_type": "backend",
"url": "http://news-api:8050",
"health_endpoint": "/health",
"status": "healthy",
"metadata": {
"version": "1.0.0",
"port": 8050
}
}
}
)

View File

@ -0,0 +1,113 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from app.models.service import Service
from app.models.user import User
from app.schemas.service import (
ServiceCreate,
ServiceUpdate,
ServiceResponse,
ServiceHealthCheck
)
from app.services.service_service import ServiceService
from app.core.security import get_current_user
router = APIRouter(prefix="/api/services", tags=["services"])
@router.post("", response_model=ServiceResponse, status_code=status.HTTP_201_CREATED)
async def create_service(
service_data: ServiceCreate,
current_user: User = Depends(get_current_user)
):
"""
Create a new service
Requires authentication.
"""
service = await ServiceService.create_service(service_data)
return service.model_dump(by_alias=True)
@router.get("", response_model=List[ServiceResponse])
async def get_all_services(
current_user: User = Depends(get_current_user)
):
"""
Get all services
Requires authentication.
"""
services = await ServiceService.get_all_services()
return [service.model_dump(by_alias=True) for service in services]
@router.get("/{service_id}", response_model=ServiceResponse)
async def get_service(
service_id: str,
current_user: User = Depends(get_current_user)
):
"""
Get a service by ID
Requires authentication.
"""
service = await ServiceService.get_service(service_id)
return service.model_dump(by_alias=True)
@router.put("/{service_id}", response_model=ServiceResponse)
async def update_service(
service_id: str,
service_data: ServiceUpdate,
current_user: User = Depends(get_current_user)
):
"""
Update a service
Requires authentication.
"""
service = await ServiceService.update_service(service_id, service_data)
return service.model_dump(by_alias=True)
@router.delete("/{service_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_service(
service_id: str,
current_user: User = Depends(get_current_user)
):
"""
Delete a service
Requires authentication.
"""
await ServiceService.delete_service(service_id)
return None
@router.post("/{service_id}/health-check", response_model=ServiceHealthCheck)
async def check_service_health(
service_id: str,
current_user: User = Depends(get_current_user)
):
"""
Check health of a specific service
Requires authentication.
"""
result = await ServiceService.check_service_health(service_id)
return result
@router.post("/health-check/all", response_model=List[ServiceHealthCheck])
async def check_all_services_health(
current_user: User = Depends(get_current_user)
):
"""
Check health of all services
Requires authentication.
"""
results = await ServiceService.check_all_services_health()
return results

View File

@ -0,0 +1,93 @@
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
class ServiceCreate(BaseModel):
"""Schema for creating a new service"""
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
service_type: str = Field(default="backend")
url: str = Field(..., min_length=1)
health_endpoint: Optional[str] = Field(default="/health")
metadata: Dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "News API",
"description": "News generation and management API",
"service_type": "backend",
"url": "http://news-api:8050",
"health_endpoint": "/health",
"metadata": {
"version": "1.0.0",
"port": 8050
}
}
}
)
class ServiceUpdate(BaseModel):
"""Schema for updating a service"""
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
service_type: Optional[str] = None
url: Optional[str] = Field(default=None, min_length=1)
health_endpoint: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
model_config = ConfigDict(
json_schema_extra={
"example": {
"description": "Updated description",
"metadata": {
"version": "1.1.0"
}
}
}
)
class ServiceResponse(BaseModel):
"""Schema for service response"""
id: str = Field(alias="_id")
name: str
description: Optional[str] = None
service_type: str
url: str
health_endpoint: Optional[str] = None
status: str
last_health_check: Optional[datetime] = None
response_time_ms: Optional[float] = None
created_at: datetime
updated_at: datetime
metadata: Dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(
populate_by_name=True,
from_attributes=True
)
class ServiceHealthCheck(BaseModel):
"""Schema for health check result"""
service_id: str
service_name: str
status: str
response_time_ms: Optional[float] = None
checked_at: datetime
error_message: Optional[str] = None
model_config = ConfigDict(
json_schema_extra={
"example": {
"service_id": "507f1f77bcf86cd799439011",
"service_name": "News API",
"status": "healthy",
"response_time_ms": 45.2,
"checked_at": "2025-10-28T10:00:00Z"
}
}
)

View File

@ -0,0 +1,212 @@
from datetime import datetime
from typing import List, Optional
import time
import httpx
from bson import ObjectId
from fastapi import HTTPException, status
from app.db.mongodb import MongoDB
from app.models.service import Service, ServiceStatus
from app.schemas.service import ServiceCreate, ServiceUpdate, ServiceHealthCheck
class ServiceService:
"""Service management business logic"""
@staticmethod
async def create_service(service_data: ServiceCreate) -> Service:
"""Create a new service"""
db = MongoDB.db
# Check if service with same name already exists
existing = await db.services.find_one({"name": service_data.name})
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Service with this name already exists"
)
# Create service document
service = Service(
**service_data.model_dump(),
status=ServiceStatus.UNKNOWN,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
# Insert into database
result = await db.services.insert_one(service.model_dump(by_alias=True, exclude={"id"}))
service.id = str(result.inserted_id)
return service
@staticmethod
async def get_service(service_id: str) -> Service:
"""Get service by ID"""
db = MongoDB.db
if not ObjectId.is_valid(service_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid service ID"
)
service_doc = await db.services.find_one({"_id": ObjectId(service_id)})
if not service_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Service not found"
)
return Service(**service_doc)
@staticmethod
async def get_all_services() -> List[Service]:
"""Get all services"""
db = MongoDB.db
cursor = db.services.find()
services = []
async for doc in cursor:
services.append(Service(**doc))
return services
@staticmethod
async def update_service(service_id: str, service_data: ServiceUpdate) -> Service:
"""Update a service"""
db = MongoDB.db
if not ObjectId.is_valid(service_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid service ID"
)
# Get existing service
existing = await db.services.find_one({"_id": ObjectId(service_id)})
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Service not found"
)
# Update only provided fields
update_data = service_data.model_dump(exclude_unset=True)
if update_data:
update_data["updated_at"] = datetime.utcnow()
# Check for name conflict if name is being updated
if "name" in update_data:
name_conflict = await db.services.find_one({
"name": update_data["name"],
"_id": {"$ne": ObjectId(service_id)}
})
if name_conflict:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Service with this name already exists"
)
await db.services.update_one(
{"_id": ObjectId(service_id)},
{"$set": update_data}
)
# Return updated service
updated_doc = await db.services.find_one({"_id": ObjectId(service_id)})
return Service(**updated_doc)
@staticmethod
async def delete_service(service_id: str) -> bool:
"""Delete a service"""
db = MongoDB.db
if not ObjectId.is_valid(service_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid service ID"
)
result = await db.services.delete_one({"_id": ObjectId(service_id)})
if result.deleted_count == 0:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Service not found"
)
return True
@staticmethod
async def check_service_health(service_id: str) -> ServiceHealthCheck:
"""Check health of a specific service"""
db = MongoDB.db
# Get service
service = await ServiceService.get_service(service_id)
# Perform health check
start_time = time.time()
status_result = ServiceStatus.UNKNOWN
error_message = None
try:
health_url = f"{service.url.rstrip('/')}{service.health_endpoint or '/health'}"
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(health_url)
if response.status_code == 200:
status_result = ServiceStatus.HEALTHY
else:
status_result = ServiceStatus.UNHEALTHY
error_message = f"HTTP {response.status_code}"
except httpx.TimeoutException:
status_result = ServiceStatus.UNHEALTHY
error_message = "Request timeout"
except httpx.RequestError as e:
status_result = ServiceStatus.UNHEALTHY
error_message = f"Connection error: {str(e)}"
except Exception as e:
status_result = ServiceStatus.UNHEALTHY
error_message = f"Error: {str(e)}"
response_time = (time.time() - start_time) * 1000 # Convert to ms
checked_at = datetime.utcnow()
# Update service status in database
await db.services.update_one(
{"_id": ObjectId(service_id)},
{
"$set": {
"status": status_result,
"last_health_check": checked_at,
"response_time_ms": response_time if status_result == ServiceStatus.HEALTHY else None,
"updated_at": checked_at
}
}
)
return ServiceHealthCheck(
service_id=service_id,
service_name=service.name,
status=status_result,
response_time_ms=response_time if status_result == ServiceStatus.HEALTHY else None,
checked_at=checked_at,
error_message=error_message
)
@staticmethod
async def check_all_services_health() -> List[ServiceHealthCheck]:
"""Check health of all services"""
services = await ServiceService.get_all_services()
results = []
for service in services:
result = await ServiceService.check_service_health(str(service.id))
results.append(result)
return results

View File

@ -0,0 +1,45 @@
import api from './auth';
import type { Service, ServiceCreate, ServiceUpdate, ServiceHealthCheck } from '../types/service';
export const serviceAPI = {
// Get all services
getAll: async (): Promise<Service[]> => {
const { data } = await api.get<Service[]>('/api/services');
return data;
},
// Get service by ID
getById: async (id: string): Promise<Service> => {
const { data } = await api.get<Service>(`/api/services/${id}`);
return data;
},
// Create new service
create: async (serviceData: ServiceCreate): Promise<Service> => {
const { data } = await api.post<Service>('/api/services', serviceData);
return data;
},
// Update service
update: async (id: string, serviceData: ServiceUpdate): Promise<Service> => {
const { data} = await api.put<Service>(`/api/services/${id}`, serviceData);
return data;
},
// Delete service
delete: async (id: string): Promise<void> => {
await api.delete(`/api/services/${id}`);
},
// Check service health
checkHealth: async (id: string): Promise<ServiceHealthCheck> => {
const { data } = await api.post<ServiceHealthCheck>(`/api/services/${id}/health-check`);
return data;
},
// Check all services health
checkAllHealth: async (): Promise<ServiceHealthCheck[]> => {
const { data } = await api.post<ServiceHealthCheck[]>('/api/services/health-check/all');
return data;
},
};

View File

@ -0,0 +1,56 @@
export interface Service {
_id: string;
name: string;
description?: string;
service_type: ServiceType;
url: string;
health_endpoint?: string;
status: ServiceStatus;
last_health_check?: string;
response_time_ms?: number;
created_at: string;
updated_at: string;
metadata: Record<string, any>;
}
export enum ServiceType {
BACKEND = 'backend',
FRONTEND = 'frontend',
DATABASE = 'database',
CACHE = 'cache',
MESSAGE_QUEUE = 'message_queue',
OTHER = 'other',
}
export enum ServiceStatus {
HEALTHY = 'healthy',
UNHEALTHY = 'unhealthy',
UNKNOWN = 'unknown',
}
export interface ServiceCreate {
name: string;
description?: string;
service_type: ServiceType;
url: string;
health_endpoint?: string;
metadata?: Record<string, any>;
}
export interface ServiceUpdate {
name?: string;
description?: string;
service_type?: ServiceType;
url?: string;
health_endpoint?: string;
metadata?: Record<string, any>;
}
export interface ServiceHealthCheck {
service_id: string;
service_name: string;
status: ServiceStatus;
response_time_ms?: number;
checked_at: string;
error_message?: string;
}