You've already forked authentication-service
mirror of
https://github.com/matrix-org/matrix-authentication-service.git
synced 2025-08-06 06:02:40 +03:00
admin: add APIs to list and get users
This commit is contained in:
@@ -25,7 +25,9 @@ use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
mod call_context;
|
||||
mod model;
|
||||
mod params;
|
||||
mod response;
|
||||
mod v1;
|
||||
|
||||
use self::call_context::CallContext;
|
||||
|
||||
@@ -36,7 +38,7 @@ where
|
||||
{
|
||||
let mut api = OpenApi::default();
|
||||
let router = ApiRouter::<S>::new()
|
||||
// TODO: add routes
|
||||
.nest("/api/admin/v1", self::v1::router())
|
||||
.finish_api_with(&mut api, |t| {
|
||||
t.title("Matrix Authentication Service admin API")
|
||||
.security_scheme(
|
||||
|
@@ -12,10 +12,12 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
/// A resource, with a type and an ID
|
||||
#[allow(dead_code)]
|
||||
pub trait Resource {
|
||||
/// The type of the resource
|
||||
const KIND: &'static str;
|
||||
@@ -33,3 +35,72 @@ pub trait Resource {
|
||||
format!("{}/{}", Self::PATH, self.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// A user
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct User {
|
||||
#[serde(skip)]
|
||||
id: Ulid,
|
||||
|
||||
/// The username (localpart) of the user
|
||||
username: String,
|
||||
|
||||
/// When the user was created
|
||||
created_at: DateTime<Utc>,
|
||||
|
||||
/// When the user was locked. If null, the user is not locked.
|
||||
locked_at: Option<DateTime<Utc>>,
|
||||
|
||||
/// Whether the user can request admin privileges.
|
||||
can_request_admin: bool,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Samples of users with different properties for examples in the schema
|
||||
pub fn samples() -> [Self; 3] {
|
||||
[
|
||||
Self {
|
||||
id: Ulid::from_bytes([0x01; 16]),
|
||||
username: "alice".to_owned(),
|
||||
created_at: DateTime::default(),
|
||||
locked_at: None,
|
||||
can_request_admin: false,
|
||||
},
|
||||
Self {
|
||||
id: Ulid::from_bytes([0x02; 16]),
|
||||
username: "bob".to_owned(),
|
||||
created_at: DateTime::default(),
|
||||
locked_at: None,
|
||||
can_request_admin: true,
|
||||
},
|
||||
Self {
|
||||
id: Ulid::from_bytes([0x03; 16]),
|
||||
username: "charlie".to_owned(),
|
||||
created_at: DateTime::default(),
|
||||
locked_at: Some(DateTime::default()),
|
||||
can_request_admin: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mas_data_model::User> for User {
|
||||
fn from(user: mas_data_model::User) -> Self {
|
||||
Self {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
created_at: user.created_at,
|
||||
locked_at: user.locked_at,
|
||||
can_request_admin: user.can_request_admin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Resource for User {
|
||||
const KIND: &'static str = "user";
|
||||
const PATH: &'static str = "/api/admin/v1/users";
|
||||
|
||||
fn id(&self) -> Ulid {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
154
crates/handlers/src/admin/params.rs
Normal file
154
crates/handlers/src/admin/params.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Generated code from schemars violates this rule
|
||||
#![allow(clippy::str_to_string)]
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use aide::OperationIo;
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{
|
||||
rejection::{PathRejection, QueryRejection},
|
||||
FromRequestParts, Path, Query,
|
||||
},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_storage::pagination::PaginationDirection;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::response::ErrorResponse;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("Invalid ULID in path")]
|
||||
pub struct UlidPathParamRejection(#[from] PathRejection);
|
||||
|
||||
impl IntoResponse for UlidPathParamRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse::from_error(&self)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(JsonSchema, Debug, Clone, Copy, Deserialize)]
|
||||
struct UlidInPath {
|
||||
#[schemars(
|
||||
with = "String",
|
||||
title = "ULID",
|
||||
description = "A ULID as per https://github.com/ulid/spec",
|
||||
regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$")
|
||||
)]
|
||||
id: Ulid,
|
||||
}
|
||||
|
||||
#[derive(FromRequestParts, OperationIo, Debug, Clone, Copy)]
|
||||
#[from_request(rejection(UlidPathParamRejection))]
|
||||
#[aide(input_with = "Path<UlidInPath>")]
|
||||
pub struct UlidPathParam(#[from_request(via(Path))] UlidInPath);
|
||||
|
||||
impl std::ops::Deref for UlidPathParam {
|
||||
type Target = Ulid;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0.id
|
||||
}
|
||||
}
|
||||
|
||||
/// The default page size if not specified
|
||||
const DEFAULT_PAGE_SIZE: usize = 10;
|
||||
|
||||
#[derive(Deserialize, JsonSchema, Clone, Copy)]
|
||||
struct PaginationParams {
|
||||
/// Retrieve the items before the given ID
|
||||
#[serde(rename = "page[before]")]
|
||||
#[schemars(with = "Option<String>")]
|
||||
before: Option<Ulid>,
|
||||
|
||||
/// Retrieve the items after the given ID
|
||||
#[serde(rename = "page[after]")]
|
||||
#[schemars(with = "Option<String>")]
|
||||
after: Option<Ulid>,
|
||||
|
||||
/// Retrieve the first N items
|
||||
#[serde(rename = "page[first]")]
|
||||
first: Option<NonZeroUsize>,
|
||||
|
||||
/// Retrieve the last N items
|
||||
#[serde(rename = "page[last]")]
|
||||
last: Option<NonZeroUsize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PaginationRejection {
|
||||
#[error("Invalid pagination parameters")]
|
||||
Invalid(#[from] QueryRejection),
|
||||
|
||||
#[error("Cannot specify both `page[first]` and `page[last]` parameters")]
|
||||
FirstAndLast,
|
||||
}
|
||||
|
||||
impl IntoResponse for PaginationRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse::from_error(&self)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// An extractor for pagination parameters in the query string
|
||||
#[derive(OperationIo, Debug, Clone, Copy)]
|
||||
#[aide(input_with = "Query<PaginationParams>")]
|
||||
pub struct Pagination(pub mas_storage::Pagination);
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Send + Sync> FromRequestParts<S> for Pagination {
|
||||
type Rejection = PaginationRejection;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let params = Query::<PaginationParams>::from_request_parts(parts, state).await?;
|
||||
|
||||
// Figure out the direction and the count out of the first and last parameters
|
||||
let (direction, count) = match (params.first, params.last) {
|
||||
// Make sure we don't specify both first and last
|
||||
(Some(_), Some(_)) => return Err(PaginationRejection::FirstAndLast),
|
||||
|
||||
// Default to forward pagination with a default page size
|
||||
(None, None) => (PaginationDirection::Forward, DEFAULT_PAGE_SIZE),
|
||||
|
||||
(Some(first), None) => (PaginationDirection::Forward, first.into()),
|
||||
(None, Some(last)) => (PaginationDirection::Backward, last.into()),
|
||||
};
|
||||
|
||||
Ok(Self(mas_storage::Pagination {
|
||||
before: params.before,
|
||||
after: params.after,
|
||||
direction,
|
||||
count,
|
||||
}))
|
||||
}
|
||||
}
|
37
crates/handlers/src/admin/v1/mod.rs
Normal file
37
crates/handlers/src/admin/v1/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use aide::axum::{routing::get_with, ApiRouter};
|
||||
use axum::extract::FromRequestParts;
|
||||
|
||||
use super::call_context::CallContext;
|
||||
|
||||
mod users;
|
||||
|
||||
pub fn router<S>() -> ApiRouter<S>
|
||||
where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
CallContext: FromRequestParts<S>,
|
||||
{
|
||||
ApiRouter::<S>::new()
|
||||
.api_route("/users", get_with(self::users::list, self::users::list_doc))
|
||||
.api_route(
|
||||
"/users/:id",
|
||||
get_with(self::users::get, self::users::get_doc),
|
||||
)
|
||||
.api_route(
|
||||
"/users/by-username/:username",
|
||||
get_with(self::users::by_username, self::users::by_username_doc),
|
||||
)
|
||||
}
|
87
crates/handlers/src/admin/v1/users/by_username.rs
Normal file
87
crates/handlers/src/admin/v1/users/by_username.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use aide::{transform::TransformOperation, OperationIo};
|
||||
use axum::{extract::Path, response::IntoResponse, Json};
|
||||
use hyper::StatusCode;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
call_context::CallContext,
|
||||
model::User,
|
||||
response::{ErrorResponse, SingleResponse},
|
||||
},
|
||||
impl_from_error_for_route,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error, OperationIo)]
|
||||
#[aide(output_with = "Json<ErrorResponse>")]
|
||||
pub enum RouteError {
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
|
||||
#[error("User with username {0:?} not found")]
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let error = ErrorResponse::from_error(&self);
|
||||
let status = match self {
|
||||
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
};
|
||||
(status, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct UsernamePathParam {
|
||||
/// The username (localpart) of the user to get
|
||||
username: String,
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
operation
|
||||
.description("Get a user by its username (localpart)")
|
||||
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
|
||||
let [sample, ..] = User::samples();
|
||||
let response =
|
||||
SingleResponse::new(sample, "/api/admin/v1/users/by-username/alice".to_owned());
|
||||
t.description("User was found").example(response)
|
||||
})
|
||||
.response_with::<404, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::NotFound("alice".to_owned()));
|
||||
t.description("User was not found").example(response)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handler.admin.v1.users.by_username", skip_all, err)]
|
||||
pub async fn handler(
|
||||
CallContext { mut repo, .. }: CallContext,
|
||||
Path(UsernamePathParam { username }): Path<UsernamePathParam>,
|
||||
) -> Result<Json<SingleResponse<User>>, RouteError> {
|
||||
let self_path = format!("/api/admin/v1/users/by-username/{username}");
|
||||
let user = repo
|
||||
.user()
|
||||
.find_by_username(&username)
|
||||
.await?
|
||||
.ok_or(RouteError::NotFound(username))?;
|
||||
|
||||
Ok(Json(SingleResponse::new(User::from(user), self_path)))
|
||||
}
|
79
crates/handlers/src/admin/v1/users/get.rs
Normal file
79
crates/handlers/src/admin/v1/users/get.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use aide::{transform::TransformOperation, OperationIo};
|
||||
use axum::{response::IntoResponse, Json};
|
||||
use hyper::StatusCode;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
call_context::CallContext,
|
||||
model::User,
|
||||
params::UlidPathParam,
|
||||
response::{ErrorResponse, SingleResponse},
|
||||
},
|
||||
impl_from_error_for_route,
|
||||
};
|
||||
|
||||
#[derive(Debug, thiserror::Error, OperationIo)]
|
||||
#[aide(output_with = "Json<ErrorResponse>")]
|
||||
pub enum RouteError {
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
|
||||
#[error("User ID {0} not found")]
|
||||
NotFound(Ulid),
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let error = ErrorResponse::from_error(&self);
|
||||
let status = match self {
|
||||
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
};
|
||||
(status, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
operation
|
||||
.description("Get a user")
|
||||
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
|
||||
let [sample, ..] = User::samples();
|
||||
let response = SingleResponse::new_canonical(sample);
|
||||
t.description("User was found").example(response)
|
||||
})
|
||||
.response_with::<404, RouteError, _>(|t| {
|
||||
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
|
||||
t.description("User was not found").example(response)
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handler.admin.v1.users.get", skip_all, err)]
|
||||
pub async fn handler(
|
||||
CallContext { mut repo, .. }: CallContext,
|
||||
id: UlidPathParam,
|
||||
) -> Result<Json<SingleResponse<User>>, RouteError> {
|
||||
let user = repo
|
||||
.user()
|
||||
.lookup(*id)
|
||||
.await?
|
||||
.ok_or(RouteError::NotFound(*id))?;
|
||||
|
||||
Ok(Json(SingleResponse::new_canonical(User::from(user))))
|
||||
}
|
135
crates/handlers/src/admin/v1/users/list.rs
Normal file
135
crates/handlers/src/admin/v1/users/list.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use aide::{transform::TransformOperation, OperationIo};
|
||||
use axum::{
|
||||
extract::{rejection::QueryRejection, Query},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
};
|
||||
use axum_macros::FromRequestParts;
|
||||
use hyper::StatusCode;
|
||||
use mas_storage::{user::UserFilter, Page};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
admin::{
|
||||
call_context::CallContext,
|
||||
model::{Resource, User},
|
||||
params::Pagination,
|
||||
response::{ErrorResponse, PaginatedResponse},
|
||||
},
|
||||
impl_from_error_for_route,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum UserStatus {
|
||||
/// The user is active
|
||||
Active,
|
||||
|
||||
/// The user is locked
|
||||
Locked,
|
||||
}
|
||||
|
||||
#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
|
||||
#[aide(input_with = "Query<FilterParams>")]
|
||||
#[from_request(via(Query), rejection(RouteError))]
|
||||
pub struct FilterParams {
|
||||
#[serde(rename = "filter[can_request_admin]")]
|
||||
can_request_admin: Option<bool>,
|
||||
|
||||
#[serde(rename = "filter[status]")]
|
||||
status: Option<UserStatus>,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a FilterParams> for UserFilter<'a> {
|
||||
fn from(val: &'a FilterParams) -> Self {
|
||||
let filter = UserFilter::default();
|
||||
|
||||
let filter = match val.can_request_admin {
|
||||
Some(true) => filter.can_request_admin_only(),
|
||||
Some(false) => filter.cannot_request_admin_only(),
|
||||
None => filter,
|
||||
};
|
||||
|
||||
let filter = match val.status {
|
||||
Some(UserStatus::Active) => filter.active_only(),
|
||||
Some(UserStatus::Locked) => filter.locked_only(),
|
||||
None => filter,
|
||||
};
|
||||
|
||||
filter
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, OperationIo)]
|
||||
#[aide(output_with = "Json<ErrorResponse>")]
|
||||
pub enum RouteError {
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
|
||||
#[error("Invalid filter parameters")]
|
||||
InvalidFilter(#[from] QueryRejection),
|
||||
}
|
||||
|
||||
impl_from_error_for_route!(mas_storage::RepositoryError);
|
||||
|
||||
impl IntoResponse for RouteError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let error = ErrorResponse::from_error(&self);
|
||||
let status = match self {
|
||||
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
|
||||
};
|
||||
(status, Json(error)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn doc(operation: TransformOperation) -> TransformOperation {
|
||||
operation
|
||||
.description("List users")
|
||||
.response_with::<200, Json<PaginatedResponse<User>>, _>(|t| {
|
||||
let users = User::samples();
|
||||
let pagination = mas_storage::Pagination::first(users.len());
|
||||
let page = Page {
|
||||
edges: users.into(),
|
||||
has_next_page: true,
|
||||
has_previous_page: false,
|
||||
};
|
||||
|
||||
t.description("Paginated response of users")
|
||||
.example(PaginatedResponse::new(page, pagination, 42, User::PATH))
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "handler.admin.v1.users.list", skip_all, err)]
|
||||
pub async fn handler(
|
||||
CallContext { mut repo, .. }: CallContext,
|
||||
Pagination(pagination): Pagination,
|
||||
filter: FilterParams,
|
||||
) -> Result<Json<PaginatedResponse<User>>, RouteError> {
|
||||
let filter = UserFilter::from(&filter);
|
||||
|
||||
let page = repo.user().list(filter, pagination).await?;
|
||||
let count = repo.user().count(filter).await?;
|
||||
|
||||
Ok(Json(PaginatedResponse::new(
|
||||
page.map(User::from),
|
||||
pagination,
|
||||
count,
|
||||
User::PATH,
|
||||
)))
|
||||
}
|
23
crates/handlers/src/admin/v1/users/mod.rs
Normal file
23
crates/handlers/src/admin/v1/users/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
mod by_username;
|
||||
mod get;
|
||||
mod list;
|
||||
|
||||
pub use self::{
|
||||
by_username::{doc as by_username_doc, handler as by_username},
|
||||
get::{doc as get_doc, handler as get},
|
||||
list::{doc as list_doc, handler as list},
|
||||
};
|
@@ -15,7 +15,293 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"paths": {},
|
||||
"paths": {
|
||||
"/api/admin/v1/users": {
|
||||
"get": {
|
||||
"description": "List users",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[before]",
|
||||
"description": "Retrieve the items before the given ID",
|
||||
"schema": {
|
||||
"description": "Retrieve the items before the given ID",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[after]",
|
||||
"description": "Retrieve the items after the given ID",
|
||||
"schema": {
|
||||
"description": "Retrieve the items after the given ID",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[first]",
|
||||
"description": "Retrieve the first N items",
|
||||
"schema": {
|
||||
"description": "Retrieve the first N items",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "page[last]",
|
||||
"description": "Retrieve the last N items",
|
||||
"schema": {
|
||||
"description": "Retrieve the last N items",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[can_request_admin]",
|
||||
"schema": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"style": "form"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "filter[status]",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"style": "form"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Paginated response of users",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PaginatedResponse_for_User"
|
||||
},
|
||||
"example": {
|
||||
"meta": {
|
||||
"count": 42
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"type": "user",
|
||||
"id": "01040G2081040G2081040G2081",
|
||||
"attributes": {
|
||||
"username": "alice",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"locked_at": null,
|
||||
"can_request_admin": false
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"id": "02081040G2081040G2081040G2",
|
||||
"attributes": {
|
||||
"username": "bob",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"locked_at": null,
|
||||
"can_request_admin": true
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/02081040G2081040G2081040G2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "user",
|
||||
"id": "030C1G60R30C1G60R30C1G60R3",
|
||||
"attributes": {
|
||||
"username": "charlie",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"locked_at": "1970-01-01T00:00:00Z",
|
||||
"can_request_admin": false
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/030C1G60R30C1G60R30C1G60R3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users?page[first]=3",
|
||||
"first": "/api/admin/v1/users?page[first]=3",
|
||||
"last": "/api/admin/v1/users?page[last]=3",
|
||||
"next": "/api/admin/v1/users?page[after]=030C1G60R30C1G60R30C1G60R3&page[first]=3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/users/{id}": {
|
||||
"get": {
|
||||
"description": "Get a user",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "id",
|
||||
"description": "A ULID as per https://github.com/ulid/spec",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "ULID",
|
||||
"description": "A ULID as per https://github.com/ulid/spec",
|
||||
"type": "string",
|
||||
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User was found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SingleResponse_for_User"
|
||||
},
|
||||
"example": {
|
||||
"data": {
|
||||
"type": "user",
|
||||
"id": "01040G2081040G2081040G2081",
|
||||
"attributes": {
|
||||
"username": "alice",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"locked_at": null,
|
||||
"can_request_admin": false
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User was not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "User ID 00000000000000000000000000 not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/v1/users/by-username/{username}": {
|
||||
"get": {
|
||||
"description": "Get a user by its username (localpart)",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "username",
|
||||
"description": "The username (localpart) of the user to get",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"description": "The username (localpart) of the user to get",
|
||||
"type": "string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User was found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SingleResponse_for_User"
|
||||
},
|
||||
"example": {
|
||||
"data": {
|
||||
"type": "user",
|
||||
"id": "01040G2081040G2081040G2081",
|
||||
"attributes": {
|
||||
"username": "alice",
|
||||
"created_at": "1970-01-01T00:00:00Z",
|
||||
"locked_at": null,
|
||||
"can_request_admin": false
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/01040G2081040G2081040G2081"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"self": "/api/admin/v1/users/by-username/alice"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User was not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"errors": [
|
||||
{
|
||||
"title": "User with username \"alice\" not found"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"oauth2": {
|
||||
@@ -38,6 +324,305 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"PaginationParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page[before]": {
|
||||
"description": "Retrieve the items before the given ID",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"page[after]": {
|
||||
"description": "Retrieve the items after the given ID",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"page[first]": {
|
||||
"description": "Retrieve the first N items",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint",
|
||||
"minimum": 1.0
|
||||
},
|
||||
"page[last]": {
|
||||
"description": "Retrieve the last N items",
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"format": "uint",
|
||||
"minimum": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"FilterParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter[can_request_admin]": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"filter[status]": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserStatus"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"UserStatus": {
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "The user is active",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "The user is locked",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"locked"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"PaginatedResponse_for_User": {
|
||||
"description": "A top-level response with a page of resources",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data",
|
||||
"links",
|
||||
"meta"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"description": "Response metadata",
|
||||
"$ref": "#/components/schemas/PaginationMeta"
|
||||
},
|
||||
"data": {
|
||||
"description": "The list of resources",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SingleResource_for_User"
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"description": "Related links",
|
||||
"$ref": "#/components/schemas/PaginationLinks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PaginationMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"count"
|
||||
],
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "The total number of results",
|
||||
"type": "integer",
|
||||
"format": "uint",
|
||||
"minimum": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"SingleResource_for_User": {
|
||||
"description": "A single resource, with its type, ID, attributes and related links",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"attributes",
|
||||
"id",
|
||||
"links",
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "The type of the resource",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "The ID of the resource",
|
||||
"type": "string"
|
||||
},
|
||||
"attributes": {
|
||||
"description": "The attributes of the resource",
|
||||
"$ref": "#/components/schemas/User"
|
||||
},
|
||||
"links": {
|
||||
"description": "Related links",
|
||||
"$ref": "#/components/schemas/SelfLinks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"User": {
|
||||
"description": "A user",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"can_request_admin",
|
||||
"created_at",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"username": {
|
||||
"description": "The username (localpart) of the user",
|
||||
"type": "string"
|
||||
},
|
||||
"created_at": {
|
||||
"description": "When the user was created",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"locked_at": {
|
||||
"description": "When the user was locked. If null, the user is not locked.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"can_request_admin": {
|
||||
"description": "Whether the user can request admin privileges.",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SelfLinks": {
|
||||
"description": "Related links",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"self"
|
||||
],
|
||||
"properties": {
|
||||
"self": {
|
||||
"description": "The canonical link to the current resource",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PaginationLinks": {
|
||||
"description": "Related links",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"first",
|
||||
"last",
|
||||
"self"
|
||||
],
|
||||
"properties": {
|
||||
"self": {
|
||||
"description": "The canonical link to the current page",
|
||||
"type": "string"
|
||||
},
|
||||
"first": {
|
||||
"description": "The link to the first page of results",
|
||||
"type": "string"
|
||||
},
|
||||
"last": {
|
||||
"description": "The link to the last page of results",
|
||||
"type": "string"
|
||||
},
|
||||
"next": {
|
||||
"description": "The link to the next page of results\n\nOnly present if there is a next page",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"prev": {
|
||||
"description": "The link to the previous page of results\n\nOnly present if there is a previous page",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"description": "A top-level response with a list of errors",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"errors"
|
||||
],
|
||||
"properties": {
|
||||
"errors": {
|
||||
"description": "The list of errors",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"description": "A single error",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"title": {
|
||||
"description": "A human-readable title for the error",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UlidInPath": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"title": "ULID",
|
||||
"description": "A ULID as per https://github.com/ulid/spec",
|
||||
"type": "string",
|
||||
"pattern": "^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SingleResponse_for_User": {
|
||||
"description": "A top-level response with a single resource",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data",
|
||||
"links"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/SingleResource_for_User"
|
||||
},
|
||||
"links": {
|
||||
"$ref": "#/components/schemas/SelfLinks"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UsernamePathParam": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"username": {
|
||||
"description": "The username (localpart) of the user to get",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
|
Reference in New Issue
Block a user