Skip to main content

hypercall_api/handlers/
username.rs

1//! API handlers for the username system.
2//!
3//! - `GET  /username?wallet=0x...`     — lookup username by wallet
4//! - `GET  /username?username=alice`   — reverse lookup wallet by username
5//! - `POST /username`                  — set username (authenticated)
6//! - `DELETE /username`                — delete username (authenticated)
7
8use axum::extract::{Query, State};
9use serde::{Deserialize, Serialize};
10use utoipa::ToSchema;
11
12use super::AppState;
13use crate::error::ApiError;
14use crate::middleware::SignerContext;
15use crate::sonic_json::SonicJson;
16use crate::username_service::UsernameServiceError;
17
18// ---------------------------------------------------------------------------
19// Request / response types
20// ---------------------------------------------------------------------------
21
22#[derive(Debug, Deserialize)]
23pub struct UsernameQuery {
24    pub wallet: Option<String>,
25    pub username: Option<String>,
26}
27
28#[derive(Debug, Deserialize, ToSchema)]
29pub struct SetUsernameRequest {
30    pub username: String,
31}
32
33#[derive(Debug, Serialize, ToSchema)]
34pub struct UsernameResponse {
35    pub wallet_address: String,
36    pub username: String,
37}
38
39#[derive(Debug, Serialize, ToSchema)]
40pub struct DeleteUsernameResponse {
41    pub deleted: bool,
42}
43
44// ---------------------------------------------------------------------------
45// Handlers
46// ---------------------------------------------------------------------------
47
48/// Lookup username by wallet or reverse-lookup wallet by username.
49#[utoipa::path(
50    get,
51    path = "/username",
52    params(
53        ("wallet" = Option<String>, Query, description = "Wallet address to look up"),
54        ("username" = Option<String>, Query, description = "Username to reverse-look up"),
55    ),
56    responses(
57        (status = 200, description = "Username found", body = UsernameResponse),
58        (status = 400, description = "Must provide wallet or username query param"),
59        (status = 404, description = "No username found"),
60    ),
61    tag = "Username"
62)]
63pub async fn get_username(
64    State(state): State<AppState>,
65    Query(query): Query<UsernameQuery>,
66) -> Result<SonicJson<UsernameResponse>, ApiError> {
67    let svc = &state.username_service;
68
69    if let Some(wallet) = &query.wallet {
70        let row = svc.get_by_wallet(wallet).await.map_err(|e| {
71            tracing::error!("get_username by wallet failed: {e}");
72            ApiError::internal_error("Failed to look up username")
73        })?;
74
75        match row {
76            Some(r) => Ok(SonicJson(UsernameResponse {
77                wallet_address: r.wallet_address,
78                username: r.username,
79            })),
80            None => Err(ApiError::not_found("No username set for this wallet")),
81        }
82    } else if let Some(username) = &query.username {
83        let row = svc.get_by_username(username).await.map_err(|e| {
84            tracing::error!("get_username by name failed: {e}");
85            ApiError::internal_error("Failed to look up wallet")
86        })?;
87
88        match row {
89            Some(r) => Ok(SonicJson(UsernameResponse {
90                wallet_address: r.wallet_address,
91                username: r.username,
92            })),
93            None => Err(ApiError::not_found("No wallet found for this username")),
94        }
95    } else {
96        Err(ApiError::bad_request(
97            "Must provide either 'wallet' or 'username' query parameter",
98        ))
99    }
100}
101
102/// Set the display username for the authenticated wallet.
103#[utoipa::path(
104    post,
105    path = "/username",
106    request_body = SetUsernameRequest,
107    responses(
108        (status = 200, description = "Username set", body = UsernameResponse),
109        (status = 400, description = "Validation error"),
110        (status = 409, description = "Username already taken"),
111        (status = 500, description = "Internal server error"),
112    ),
113    security(("eip712_signature" = [])),
114    tag = "Username"
115)]
116pub async fn set_username(
117    State(state): State<AppState>,
118    signer_ctx: SignerContext,
119    SonicJson(request): SonicJson<SetUsernameRequest>,
120) -> Result<SonicJson<UsernameResponse>, ApiError> {
121    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
122
123    tracing::info!(wallet = %wallet, "set_username request");
124    tracing::debug!(wallet = %wallet, username = %request.username, "set_username details");
125
126    // Validate first (before hitting DB) to give fast feedback
127    crate::username_service::validate_username(&request.username).map_err(ApiError::bad_request)?;
128
129    // Check if the desired username is already taken by another wallet
130    let existing = state
131        .username_service
132        .get_by_username(&request.username)
133        .await
134        .map_err(|e| {
135            tracing::error!("set_username uniqueness check failed: {e}");
136            ApiError::internal_error("Failed to check username availability")
137        })?;
138
139    if let Some(ref row) = existing {
140        if row.wallet_address.to_lowercase() != wallet.to_lowercase() {
141            return Err(ApiError::new(
142                axum::http::StatusCode::CONFLICT,
143                "username_taken",
144                "This username is already taken",
145            ));
146        }
147    }
148
149    let row = state
150        .username_service
151        .set_username(&wallet, &request.username)
152        .await
153        .map_err(|e| match e {
154            UsernameServiceError::UniqueViolation => ApiError::new(
155                axum::http::StatusCode::CONFLICT,
156                "username_taken",
157                "This username is already taken",
158            ),
159            UsernameServiceError::Internal(inner) => {
160                tracing::error!("set_username failed: {inner}");
161                ApiError::internal_error("Failed to set username")
162            }
163        })?;
164
165    Ok(SonicJson(UsernameResponse {
166        wallet_address: row.wallet_address,
167        username: row.username,
168    }))
169}
170
171/// Delete the display username for the authenticated wallet.
172#[utoipa::path(
173    delete,
174    path = "/username",
175    responses(
176        (status = 200, description = "Username deleted", body = DeleteUsernameResponse),
177        (status = 500, description = "Internal server error"),
178    ),
179    security(("eip712_signature" = [])),
180    tag = "Username"
181)]
182pub async fn delete_username(
183    State(state): State<AppState>,
184    signer_ctx: SignerContext,
185) -> Result<SonicJson<DeleteUsernameResponse>, ApiError> {
186    let wallet = format!("{:#x}", signer_ctx.wallet_address.0);
187
188    tracing::info!(wallet = %wallet, "delete_username request");
189
190    let deleted = state
191        .username_service
192        .delete_username(&wallet)
193        .await
194        .map_err(|e| {
195            tracing::error!("delete_username failed: {e}");
196            ApiError::internal_error("Failed to delete username")
197        })?;
198
199    Ok(SonicJson(DeleteUsernameResponse { deleted }))
200}