Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 83 additions & 21 deletions python/aibrix/aibrix/metadata/api/v1/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import warnings
from typing import Optional

import redis.asyncio as redis
Expand All @@ -23,6 +24,9 @@
logger = init_logger(__name__)
router = APIRouter()

# Legacy router for backward compatibility with old POST-based endpoints
legacy_router = APIRouter()


class User(BaseModel):
"""User model with rate limiting configuration.
Expand Down Expand Up @@ -77,7 +81,10 @@ async def _get_redis_client(request: Request) -> redis.Redis:
return request.app.state.redis_client


@router.post("/CreateUser")
# ==================== RESTful API Endpoints ====================


@router.post("/users")
async def create_user(request: Request, user: User) -> UserResponse:
"""Create a new user with rate limits.

Expand Down Expand Up @@ -106,13 +113,13 @@ async def create_user(request: Request, user: User) -> UserResponse:
return UserResponse(message=f"Created User: {user.name}", user=user)


@router.post("/ReadUser")
async def read_user(request: Request, user: User) -> UserResponse:
"""Read user information.
@router.get("/users/{name}")
async def get_user(request: Request, name: str) -> UserResponse:
"""Read user information by name.

Args:
request: FastAPI request
user: User with name to look up
name: User name to look up

Returns:
UserResponse with user data
Expand All @@ -121,11 +128,11 @@ async def read_user(request: Request, user: User) -> UserResponse:
HTTPException: 404 if user not found
"""
redis_client = await _get_redis_client(request)
key = _gen_key(user.name)
key = _gen_key(name)

data = await redis_client.get(key)
if not data:
logger.warning(f"User not found: {user.name}")
logger.warning(f"User not found: {name}")
raise HTTPException(status_code=404, detail="user does not exist")

# Parse JSON data
Expand All @@ -135,12 +142,13 @@ async def read_user(request: Request, user: User) -> UserResponse:
return UserResponse(message=f"User: {stored_user.name}", user=stored_user)


@router.post("/UpdateUser")
async def update_user(request: Request, user: User) -> UserResponse:
@router.put("/users/{name}")
async def update_user(request: Request, name: str, user: User) -> UserResponse:
Comment on lines +145 to +146
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silently overriding user.name with the path parameter can hide client mistakes (e.g., sending mismatched names) and makes the request semantics ambiguous. Prefer either (a) validating that user.name matches name and returning a 400 on mismatch, or (b) using a different request model for updates that omits name entirely so the path is the single source of truth.

Copilot uses AI. Check for mistakes.
"""Update user information.

Args:
request: FastAPI request
name: User name in the URL path
user: Updated user data

Returns:
Expand All @@ -150,28 +158,31 @@ async def update_user(request: Request, user: User) -> UserResponse:
HTTPException: 404 if user not found
"""
redis_client = await _get_redis_client(request)
key = _gen_key(user.name)
key = _gen_key(name)

# Check if user exists
exists = await redis_client.exists(key)
if not exists:
logger.warning(f"Cannot update non-existent user: {user.name}")
raise HTTPException(status_code=404, detail=f"User: {user.name} does not exist")
logger.warning(f"Cannot update non-existent user: {name}")
raise HTTPException(
status_code=404, detail=f"User: {name} does not exist"
)

# Update user
# Use the name from URL path, override body if different
user.name = name
Comment on lines +171 to +172
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silently overriding user.name with the path parameter can hide client mistakes (e.g., sending mismatched names) and makes the request semantics ambiguous. Prefer either (a) validating that user.name matches name and returning a 400 on mismatch, or (b) using a different request model for updates that omits name entirely so the path is the single source of truth.

Suggested change
# Use the name from URL path, override body if different
user.name = name
# Validate that the name in the body matches the path parameter
if user.name != name:
logger.warning(
f"Name mismatch in update_user: path name='{name}', body name='{user.name}'"
)
raise HTTPException(
status_code=400,
detail="User name in path does not match user name in request body",
)

Copilot uses AI. Check for mistakes.
await redis_client.set(key, user.model_dump_json())

logger.info(f"Updated user: {user.name}, rpm={user.rpm}, tpm={user.tpm}")
return UserResponse(message=f"Updated User: {user.name}", user=user)


@router.post("/DeleteUser")
async def delete_user(request: Request, user: User) -> UserResponse:
@router.delete("/users/{name}")
async def delete_user(request: Request, name: str) -> UserResponse:
"""Delete a user.

Args:
request: FastAPI request
user: User with name to delete
name: User name to delete

Returns:
UserResponse with deletion status
Expand All @@ -180,16 +191,67 @@ async def delete_user(request: Request, user: User) -> UserResponse:
HTTPException: 404 if user not found
"""
redis_client = await _get_redis_client(request)
key = _gen_key(user.name)
key = _gen_key(name)

# Check if user exists
exists = await redis_client.exists(key)
if not exists:
logger.warning(f"Cannot delete non-existent user: {user.name}")
raise HTTPException(status_code=404, detail=f"User: {user.name} does not exist")
logger.warning(f"Cannot delete non-existent user: {name}")
raise HTTPException(
status_code=404, detail=f"User: {name} does not exist"
)

# Delete user
await redis_client.delete(key)

logger.info(f"Deleted user: {user.name}")
return UserResponse(message=f"Deleted User: {user.name}", user=user)
logger.info(f"Deleted user: {name}")
return UserResponse(message=f"Deleted User: {name}", user=None)
Comment on lines +207 to +208
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes legacy /DeleteUser behavior: it now returns user=None via delegation to delete_user, whereas the previous implementation returned the deleted user in the user field. That breaks the PR’s stated ‘full backward compatibility’. Consider keeping the RESTful DELETE /users/{name} response as-is, but have legacy_delete_user return UserResponse(..., user=user) (or reconstruct the prior payload) to preserve the legacy contract.

Copilot uses AI. Check for mistakes.


# ==================== Legacy API Endpoints (Deprecated) ====================
# These endpoints are kept for backward compatibility.
# Use the RESTful endpoints above (/users, /users/{name}) instead.


@legacy_router.post("/CreateUser")
async def legacy_create_user(request: Request, user: User) -> UserResponse:
"""Create a new user. Deprecated: use POST /users instead."""
warnings.warn(
"POST /CreateUser is deprecated, use POST /users instead",
DeprecationWarning,
stacklevel=1,
)
Comment on lines +219 to +223
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using warnings.warn(..., DeprecationWarning) inside an API handler is easy to miss in production because DeprecationWarning is filtered out by default in many runtime configurations, and it is not visible to API consumers. If the goal is to ‘emit deprecation warnings’ to clients, consider adding an HTTP Warning/Deprecation signal (e.g., response headers) and/or logging via logger.warning. If you keep warnings.warn, stacklevel should generally be 2 so the warning points at the legacy endpoint wrapper rather than the warnings.warn line.

Copilot uses AI. Check for mistakes.
return await create_user(request, user)


@legacy_router.post("/ReadUser")
async def legacy_read_user(request: Request, user: User) -> UserResponse:
"""Read user information. Deprecated: use GET /users/{name} instead."""
warnings.warn(
"POST /ReadUser is deprecated, use GET /users/{name} instead",
DeprecationWarning,
stacklevel=1,
)
return await get_user(request, user.name)


@legacy_router.post("/UpdateUser")
async def legacy_update_user(request: Request, user: User) -> UserResponse:
"""Update user information. Deprecated: use PUT /users/{name} instead."""
warnings.warn(
"POST /UpdateUser is deprecated, use PUT /users/{name} instead",
DeprecationWarning,
stacklevel=1,
)
return await update_user(request, user.name, user)


@legacy_router.post("/DeleteUser")
async def legacy_delete_user(request: Request, user: User) -> UserResponse:
"""Delete a user. Deprecated: use DELETE /users/{name} instead."""
warnings.warn(
"POST /DeleteUser is deprecated, use DELETE /users/{name} instead",
DeprecationWarning,
stacklevel=1,
)
return await delete_user(request, user.name)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes legacy /DeleteUser behavior: it now returns user=None via delegation to delete_user, whereas the previous implementation returned the deleted user in the user field. That breaks the PR’s stated ‘full backward compatibility’. Consider keeping the RESTful DELETE /users/{name} response as-is, but have legacy_delete_user return UserResponse(..., user=user) (or reconstruct the prior payload) to preserve the legacy contract.

Suggested change
return await delete_user(request, user.name)
# Legacy behavior: return the deleted user in the response.
# First, read the user (will raise 404 if it does not exist).
existing_user = await get_user(request, user.name)
# Then perform the deletion using the RESTful handler.
delete_result = await delete_user(request, user.name)
# Return a response that preserves the legacy contract: include the
# deleted user in the `user` field while keeping the delete message.
return UserResponse(message=delete_result.message, user=existing_user.user)

Copilot uses AI. Check for mistakes.
Comment on lines +216 to +257
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The legacy endpoints are also unauthenticated and unauthorized, maintaining the security risk for backward-compatible clients.

6 changes: 4 additions & 2 deletions python/aibrix/aibrix/metadata/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,11 @@ def build_app(args: argparse.Namespace, params={}):
)
logger.info("Models API mounted at /v1/models")

# Initialize user CRUD API
# Initialize user CRUD API (RESTful endpoints)
app.include_router(users.router, tags=["users"])
logger.info("User CRUD API mounted")
# Mount legacy endpoints for backward compatibility (deprecated)
app.include_router(users.legacy_router, tags=["users-legacy"])
logger.info("User CRUD API mounted (RESTful + legacy endpoints)")

# Initialize batches API
if not args.disable_batch_api:
Expand Down
Loading
Loading