rustauth-core 0.2.0

Core types and primitives for RustAuth.
Documentation
use http::{Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

use super::shared::{
    auth_session_cookies, current_session, error_response, invalid_additional_field_response,
    json_openapi_response, json_response, unauthorized,
};
use crate::api::additional_fields::{json_to_db_value, update_values, AdditionalFieldError};
use crate::api::{
    create_auth_endpoint, parse_request_body, AsyncAuthEndpoint, AuthEndpointOptions,
    OpenApiOperation,
};
use crate::error::RustAuthError;
use crate::user::UpdateUserInput;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateUserBody {
    #[serde(default)]
    name: Option<String>,
    #[serde(default)]
    image: Option<Value>,
    #[serde(default)]
    username: Option<Value>,
    #[serde(default)]
    display_username: Option<Value>,
    #[serde(default)]
    email: Option<Value>,
    #[serde(flatten)]
    extra: Map<String, Value>,
}

#[derive(Debug, Serialize)]
struct UpdateUserResponse {
    status: bool,
}

pub(super) fn update_user_endpoint() -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/update-user",
        Method::POST,
        AuthEndpointOptions::new()
            .operation_id("updateUser")
            .openapi(
                OpenApiOperation::new("updateUser")
                    .description("Update the current user")
                    .request_body(update_user_request_body())
                    .response("200", update_user_response()),
            ),
        move |context, request| async move {
            let Some((session, user, mut cookies)) = current_session(&context, &request).await?
            else {
                return unauthorized();
            };
            let raw_body: Value = parse_request_body(&request)?;
            let Some(body_object) = raw_body.as_object() else {
                return error_response(
                    StatusCode::BAD_REQUEST,
                    "INVALID_REQUEST_BODY",
                    "Body must be an object",
                );
            };
            let body: UpdateUserBody =
                serde_json::from_value(raw_body.clone()).map_err(|error| {
                    crate::error::RustAuthError::InvalidRequestBody {
                        encoding: "JSON",
                        message: error.to_string(),
                    }
                })?;
            if body.email.is_some() {
                return error_response(
                    StatusCode::BAD_REQUEST,
                    "EMAIL_CAN_NOT_BE_UPDATED",
                    "Email can not be updated",
                );
            }

            let mut input = UpdateUserInput::new();
            if let Some(name) = body.name {
                input = input.name(name);
            }
            if let Some(image) = body.image {
                input = input.image(match image {
                    Value::Null => None,
                    Value::String(value) => Some(value),
                    _ => {
                        return error_response(
                            StatusCode::BAD_REQUEST,
                            "INVALID_REQUEST_BODY",
                            "image must be a string or null",
                        )
                    }
                });
            }
            if let Some(username) = body.username {
                input = input.username(match username {
                    Value::Null => None,
                    Value::String(value) => Some(value),
                    _ => {
                        return error_response(
                            StatusCode::BAD_REQUEST,
                            "INVALID_REQUEST_BODY",
                            "username must be a string or null",
                        )
                    }
                });
            }
            if context.has_plugin("username") {
                if let Some(Some(username)) = input.username.as_ref() {
                    if let Some(existing_user) =
                        context.users()?.find_user_by_username(username).await?
                    {
                        if existing_user.id != user.id {
                            return error_response(
                                StatusCode::BAD_REQUEST,
                                "USERNAME_IS_ALREADY_TAKEN",
                                "Username is already taken. Please try another.",
                            );
                        }
                    }
                }
            }
            if let Some(display_username) = body.display_username {
                input = input.display_username(match display_username {
                    Value::Null => None,
                    Value::String(value) => Some(value),
                    _ => {
                        return error_response(
                            StatusCode::BAD_REQUEST,
                            "INVALID_REQUEST_BODY",
                            "displayUsername must be a string or null",
                        )
                    }
                });
            }
            let additional_fields =
                match update_values(&context.options.user.additional_fields, body_object) {
                    Ok(fields) => fields,
                    Err(error) => return invalid_additional_field_response(error),
                };
            for (field, value) in body.extra {
                if is_core_field(&field)
                    || context.options.user.additional_fields.contains_key(&field)
                {
                    continue;
                }
                let logical_field = camel_to_snake(&field);
                if context
                    .options
                    .user
                    .additional_fields
                    .contains_key(&logical_field)
                {
                    continue;
                }
                let Ok(db_field) = context.db_schema.field("user", &logical_field) else {
                    return error_response(
                        StatusCode::BAD_REQUEST,
                        "INVALID_REQUEST_BODY",
                        format!("unknown user field `{field}`"),
                    );
                };
                if !db_field.input {
                    return invalid_additional_field_response(AdditionalFieldError::NotInput(
                        field.clone(),
                    ));
                }
                let db_value = match json_to_db_value(&logical_field, &db_field.field_type, &value)
                {
                    Ok(value) => value,
                    Err(message) => {
                        return invalid_additional_field_response(
                            AdditionalFieldError::InvalidType(message),
                        );
                    }
                };
                input = input.field(db_field.name.clone(), db_value);
            }
            input = input.additional_fields(additional_fields);
            if input.is_empty() {
                return error_response(
                    StatusCode::BAD_REQUEST,
                    "NO_FIELDS_TO_UPDATE",
                    "No fields to update",
                );
            }

            let updated = context
                .users()?
                .update_user(&user.id, input)
                .await?
                .ok_or_else(|| RustAuthError::Api("user not found".to_owned()))?;
            context.sessions()?.refresh_user_sessions(&user.id).await?;
            if context.options.session.cookie_cache.enabled {
                cookies.extend(auth_session_cookies(&context, &session, &updated, false)?);
            }
            json_response(
                StatusCode::OK,
                &UpdateUserResponse { status: true },
                cookies,
            )
        },
    )
}

fn is_core_field(field: &str) -> bool {
    matches!(
        field,
        "name" | "image" | "username" | "displayUsername" | "email"
    )
}

fn camel_to_snake(field: &str) -> String {
    let mut output = String::with_capacity(field.len());
    for (index, ch) in field.chars().enumerate() {
        if ch.is_ascii_uppercase() {
            if index > 0 {
                output.push('_');
            }
            output.push(ch.to_ascii_lowercase());
        } else {
            output.push(ch);
        }
    }
    output
}

fn update_user_request_body() -> Value {
    serde_json::json!({
        "content": {
            "application/json": {
                "schema": {
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "string",
                            "description": "The name of the user",
                        },
                        "image": {
                            "type": "string",
                            "description": "The image of the user",
                            "nullable": true,
                        },
                        "username": {
                            "type": "string",
                            "description": "The username of the user",
                            "nullable": true,
                        },
                        "displayUsername": {
                            "type": "string",
                            "description": "The display username of the user",
                            "nullable": true,
                        },
                    },
                    "additionalProperties": true,
                },
            },
        },
    })
}

fn update_user_response() -> Value {
    json_openapi_response(
        "Success",
        serde_json::json!({
            "type": "object",
            "properties": {
                "user": {
                    "$ref": "#/components/schemas/User",
                },
            },
        }),
    )
}