From f5b4caf520ee3163ca5adf6753fd05f0f6af2ad0 Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 24 Jul 2024 16:19:31 +0200 Subject: [PATCH] admin: add APIs to list and get users --- crates/handlers/src/admin/mod.rs | 4 +- crates/handlers/src/admin/model.rs | 73 ++- crates/handlers/src/admin/params.rs | 154 +++++ crates/handlers/src/admin/v1/mod.rs | 37 ++ .../src/admin/v1/users/by_username.rs | 87 +++ crates/handlers/src/admin/v1/users/get.rs | 79 +++ crates/handlers/src/admin/v1/users/list.rs | 135 ++++ crates/handlers/src/admin/v1/users/mod.rs | 23 + docs/api.schema.json | 587 +++++++++++++++++- 9 files changed, 1176 insertions(+), 3 deletions(-) create mode 100644 crates/handlers/src/admin/params.rs create mode 100644 crates/handlers/src/admin/v1/mod.rs create mode 100644 crates/handlers/src/admin/v1/users/by_username.rs create mode 100644 crates/handlers/src/admin/v1/users/get.rs create mode 100644 crates/handlers/src/admin/v1/users/list.rs create mode 100644 crates/handlers/src/admin/v1/users/mod.rs diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index a918d940..5c63b09a 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -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::::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( diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 91a5ec06..df8e20e8 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -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, + + /// When the user was locked. If null, the user is not locked. + locked_at: Option>, + + /// 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 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 + } +} diff --git a/crates/handlers/src/admin/params.rs b/crates/handlers/src/admin/params.rs new file mode 100644 index 00000000..36f41171 --- /dev/null +++ b/crates/handlers/src/admin/params.rs @@ -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")] +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")] + before: Option, + + /// Retrieve the items after the given ID + #[serde(rename = "page[after]")] + #[schemars(with = "Option")] + after: Option, + + /// Retrieve the first N items + #[serde(rename = "page[first]")] + first: Option, + + /// Retrieve the last N items + #[serde(rename = "page[last]")] + last: Option, +} + +#[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")] +pub struct Pagination(pub mas_storage::Pagination); + +#[async_trait] +impl FromRequestParts for Pagination { + type Rejection = PaginationRejection; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result { + let params = Query::::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, + })) + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs new file mode 100644 index 00000000..0dabc3ec --- /dev/null +++ b/crates/handlers/src/admin/v1/mod.rs @@ -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() -> ApiRouter +where + S: Clone + Send + Sync + 'static, + CallContext: FromRequestParts, +{ + ApiRouter::::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), + ) +} diff --git a/crates/handlers/src/admin/v1/users/by_username.rs b/crates/handlers/src/admin/v1/users/by_username.rs new file mode 100644 index 00000000..ec177802 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/by_username.rs @@ -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")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[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>, _>(|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, +) -> Result>, 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))) +} diff --git a/crates/handlers/src/admin/v1/users/get.rs b/crates/handlers/src/admin/v1/users/get.rs new file mode 100644 index 00000000..4b6fdcee --- /dev/null +++ b/crates/handlers/src/admin/v1/users/get.rs @@ -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")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[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>, _>(|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>, RouteError> { + let user = repo + .user() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + Ok(Json(SingleResponse::new_canonical(User::from(user)))) +} diff --git a/crates/handlers/src/admin/v1/users/list.rs b/crates/handlers/src/admin/v1/users/list.rs new file mode 100644 index 00000000..b3aebf12 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/list.rs @@ -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")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + #[serde(rename = "filter[can_request_admin]")] + can_request_admin: Option, + + #[serde(rename = "filter[status]")] + status: Option, +} + +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")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[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>, _>(|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>, 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, + ))) +} diff --git a/crates/handlers/src/admin/v1/users/mod.rs b/crates/handlers/src/admin/v1/users/mod.rs new file mode 100644 index 00000000..9ea1c525 --- /dev/null +++ b/crates/handlers/src/admin/v1/users/mod.rs @@ -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}, +}; diff --git a/docs/api.schema.json b/docs/api.schema.json index 33e9c21a..c099ecf5 100644 --- a/docs/api.schema.json +++ b/docs/api.schema.json @@ -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": [