Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit f5b4caf

Browse files
committed
admin: add APIs to list and get users
1 parent c177233 commit f5b4caf

File tree

9 files changed

+1176
-3
lines changed

9 files changed

+1176
-3
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ use tower_http::cors::{Any, CorsLayer};
2525

2626
mod call_context;
2727
mod model;
28+
mod params;
2829
mod response;
30+
mod v1;
2931

3032
use self::call_context::CallContext;
3133

@@ -36,7 +38,7 @@ where
3638
{
3739
let mut api = OpenApi::default();
3840
let router = ApiRouter::<S>::new()
39-
// TODO: add routes
41+
.nest("/api/admin/v1", self::v1::router())
4042
.finish_api_with(&mut api, |t| {
4143
t.title("Matrix Authentication Service admin API")
4244
.security_scheme(

crates/handlers/src/admin/model.rs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
use chrono::{DateTime, Utc};
16+
use schemars::JsonSchema;
17+
use serde::Serialize;
1518
use ulid::Ulid;
1619

1720
/// A resource, with a type and an ID
18-
#[allow(dead_code)]
1921
pub trait Resource {
2022
/// The type of the resource
2123
const KIND: &'static str;
@@ -33,3 +35,72 @@ pub trait Resource {
3335
format!("{}/{}", Self::PATH, self.id())
3436
}
3537
}
38+
39+
/// A user
40+
#[derive(Serialize, JsonSchema)]
41+
pub struct User {
42+
#[serde(skip)]
43+
id: Ulid,
44+
45+
/// The username (localpart) of the user
46+
username: String,
47+
48+
/// When the user was created
49+
created_at: DateTime<Utc>,
50+
51+
/// When the user was locked. If null, the user is not locked.
52+
locked_at: Option<DateTime<Utc>>,
53+
54+
/// Whether the user can request admin privileges.
55+
can_request_admin: bool,
56+
}
57+
58+
impl User {
59+
/// Samples of users with different properties for examples in the schema
60+
pub fn samples() -> [Self; 3] {
61+
[
62+
Self {
63+
id: Ulid::from_bytes([0x01; 16]),
64+
username: "alice".to_owned(),
65+
created_at: DateTime::default(),
66+
locked_at: None,
67+
can_request_admin: false,
68+
},
69+
Self {
70+
id: Ulid::from_bytes([0x02; 16]),
71+
username: "bob".to_owned(),
72+
created_at: DateTime::default(),
73+
locked_at: None,
74+
can_request_admin: true,
75+
},
76+
Self {
77+
id: Ulid::from_bytes([0x03; 16]),
78+
username: "charlie".to_owned(),
79+
created_at: DateTime::default(),
80+
locked_at: Some(DateTime::default()),
81+
can_request_admin: false,
82+
},
83+
]
84+
}
85+
}
86+
87+
impl From<mas_data_model::User> for User {
88+
fn from(user: mas_data_model::User) -> Self {
89+
Self {
90+
id: user.id,
91+
username: user.username,
92+
created_at: user.created_at,
93+
locked_at: user.locked_at,
94+
can_request_admin: user.can_request_admin,
95+
}
96+
}
97+
}
98+
99+
impl Resource for User {
100+
const KIND: &'static str = "user";
101+
const PATH: &'static str = "/api/admin/v1/users";
102+
103+
fn id(&self) -> Ulid {
104+
self.id
105+
}
106+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Generated code from schemars violates this rule
16+
#![allow(clippy::str_to_string)]
17+
18+
use std::num::NonZeroUsize;
19+
20+
use aide::OperationIo;
21+
use async_trait::async_trait;
22+
use axum::{
23+
extract::{
24+
rejection::{PathRejection, QueryRejection},
25+
FromRequestParts, Path, Query,
26+
},
27+
response::IntoResponse,
28+
Json,
29+
};
30+
use axum_macros::FromRequestParts;
31+
use hyper::StatusCode;
32+
use mas_storage::pagination::PaginationDirection;
33+
use schemars::JsonSchema;
34+
use serde::Deserialize;
35+
use ulid::Ulid;
36+
37+
use super::response::ErrorResponse;
38+
39+
#[derive(Debug, thiserror::Error)]
40+
#[error("Invalid ULID in path")]
41+
pub struct UlidPathParamRejection(#[from] PathRejection);
42+
43+
impl IntoResponse for UlidPathParamRejection {
44+
fn into_response(self) -> axum::response::Response {
45+
(
46+
StatusCode::BAD_REQUEST,
47+
Json(ErrorResponse::from_error(&self)),
48+
)
49+
.into_response()
50+
}
51+
}
52+
53+
#[derive(JsonSchema, Debug, Clone, Copy, Deserialize)]
54+
struct UlidInPath {
55+
#[schemars(
56+
with = "String",
57+
title = "ULID",
58+
description = "A ULID as per https://github.com/ulid/spec",
59+
regex(pattern = r"^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$")
60+
)]
61+
id: Ulid,
62+
}
63+
64+
#[derive(FromRequestParts, OperationIo, Debug, Clone, Copy)]
65+
#[from_request(rejection(UlidPathParamRejection))]
66+
#[aide(input_with = "Path<UlidInPath>")]
67+
pub struct UlidPathParam(#[from_request(via(Path))] UlidInPath);
68+
69+
impl std::ops::Deref for UlidPathParam {
70+
type Target = Ulid;
71+
72+
fn deref(&self) -> &Self::Target {
73+
&self.0.id
74+
}
75+
}
76+
77+
/// The default page size if not specified
78+
const DEFAULT_PAGE_SIZE: usize = 10;
79+
80+
#[derive(Deserialize, JsonSchema, Clone, Copy)]
81+
struct PaginationParams {
82+
/// Retrieve the items before the given ID
83+
#[serde(rename = "page[before]")]
84+
#[schemars(with = "Option<String>")]
85+
before: Option<Ulid>,
86+
87+
/// Retrieve the items after the given ID
88+
#[serde(rename = "page[after]")]
89+
#[schemars(with = "Option<String>")]
90+
after: Option<Ulid>,
91+
92+
/// Retrieve the first N items
93+
#[serde(rename = "page[first]")]
94+
first: Option<NonZeroUsize>,
95+
96+
/// Retrieve the last N items
97+
#[serde(rename = "page[last]")]
98+
last: Option<NonZeroUsize>,
99+
}
100+
101+
#[derive(Debug, thiserror::Error)]
102+
pub enum PaginationRejection {
103+
#[error("Invalid pagination parameters")]
104+
Invalid(#[from] QueryRejection),
105+
106+
#[error("Cannot specify both `page[first]` and `page[last]` parameters")]
107+
FirstAndLast,
108+
}
109+
110+
impl IntoResponse for PaginationRejection {
111+
fn into_response(self) -> axum::response::Response {
112+
(
113+
StatusCode::BAD_REQUEST,
114+
Json(ErrorResponse::from_error(&self)),
115+
)
116+
.into_response()
117+
}
118+
}
119+
120+
/// An extractor for pagination parameters in the query string
121+
#[derive(OperationIo, Debug, Clone, Copy)]
122+
#[aide(input_with = "Query<PaginationParams>")]
123+
pub struct Pagination(pub mas_storage::Pagination);
124+
125+
#[async_trait]
126+
impl<S: Send + Sync> FromRequestParts<S> for Pagination {
127+
type Rejection = PaginationRejection;
128+
129+
async fn from_request_parts(
130+
parts: &mut axum::http::request::Parts,
131+
state: &S,
132+
) -> Result<Self, Self::Rejection> {
133+
let params = Query::<PaginationParams>::from_request_parts(parts, state).await?;
134+
135+
// Figure out the direction and the count out of the first and last parameters
136+
let (direction, count) = match (params.first, params.last) {
137+
// Make sure we don't specify both first and last
138+
(Some(_), Some(_)) => return Err(PaginationRejection::FirstAndLast),
139+
140+
// Default to forward pagination with a default page size
141+
(None, None) => (PaginationDirection::Forward, DEFAULT_PAGE_SIZE),
142+
143+
(Some(first), None) => (PaginationDirection::Forward, first.into()),
144+
(None, Some(last)) => (PaginationDirection::Backward, last.into()),
145+
};
146+
147+
Ok(Self(mas_storage::Pagination {
148+
before: params.before,
149+
after: params.after,
150+
direction,
151+
count,
152+
}))
153+
}
154+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use aide::axum::{routing::get_with, ApiRouter};
16+
use axum::extract::FromRequestParts;
17+
18+
use super::call_context::CallContext;
19+
20+
mod users;
21+
22+
pub fn router<S>() -> ApiRouter<S>
23+
where
24+
S: Clone + Send + Sync + 'static,
25+
CallContext: FromRequestParts<S>,
26+
{
27+
ApiRouter::<S>::new()
28+
.api_route("/users", get_with(self::users::list, self::users::list_doc))
29+
.api_route(
30+
"/users/:id",
31+
get_with(self::users::get, self::users::get_doc),
32+
)
33+
.api_route(
34+
"/users/by-username/:username",
35+
get_with(self::users::by_username, self::users::by_username_doc),
36+
)
37+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright 2024 The Matrix.org Foundation C.I.C.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use aide::{transform::TransformOperation, OperationIo};
16+
use axum::{extract::Path, response::IntoResponse, Json};
17+
use hyper::StatusCode;
18+
use schemars::JsonSchema;
19+
use serde::Deserialize;
20+
21+
use crate::{
22+
admin::{
23+
call_context::CallContext,
24+
model::User,
25+
response::{ErrorResponse, SingleResponse},
26+
},
27+
impl_from_error_for_route,
28+
};
29+
30+
#[derive(Debug, thiserror::Error, OperationIo)]
31+
#[aide(output_with = "Json<ErrorResponse>")]
32+
pub enum RouteError {
33+
#[error(transparent)]
34+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
35+
36+
#[error("User with username {0:?} not found")]
37+
NotFound(String),
38+
}
39+
40+
impl_from_error_for_route!(mas_storage::RepositoryError);
41+
42+
impl IntoResponse for RouteError {
43+
fn into_response(self) -> axum::response::Response {
44+
let error = ErrorResponse::from_error(&self);
45+
let status = match self {
46+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
47+
Self::NotFound(_) => StatusCode::NOT_FOUND,
48+
};
49+
(status, Json(error)).into_response()
50+
}
51+
}
52+
53+
#[derive(Deserialize, JsonSchema)]
54+
pub struct UsernamePathParam {
55+
/// The username (localpart) of the user to get
56+
username: String,
57+
}
58+
59+
pub fn doc(operation: TransformOperation) -> TransformOperation {
60+
operation
61+
.description("Get a user by its username (localpart)")
62+
.response_with::<200, Json<SingleResponse<User>>, _>(|t| {
63+
let [sample, ..] = User::samples();
64+
let response =
65+
SingleResponse::new(sample, "/api/admin/v1/users/by-username/alice".to_owned());
66+
t.description("User was found").example(response)
67+
})
68+
.response_with::<404, RouteError, _>(|t| {
69+
let response = ErrorResponse::from_error(&RouteError::NotFound("alice".to_owned()));
70+
t.description("User was not found").example(response)
71+
})
72+
}
73+
74+
#[tracing::instrument(name = "handler.admin.v1.users.by_username", skip_all, err)]
75+
pub async fn handler(
76+
CallContext { mut repo, .. }: CallContext,
77+
Path(UsernamePathParam { username }): Path<UsernamePathParam>,
78+
) -> Result<Json<SingleResponse<User>>, RouteError> {
79+
let self_path = format!("/api/admin/v1/users/by-username/{username}");
80+
let user = repo
81+
.user()
82+
.find_by_username(&username)
83+
.await?
84+
.ok_or(RouteError::NotFound(username))?;
85+
86+
Ok(Json(SingleResponse::new(User::from(user), self_path)))
87+
}

0 commit comments

Comments
 (0)