From e9d74d424b065c0461d7e40f1bbd75c09aca8cee Mon Sep 17 00:00:00 2001 From: Tamo Date: Wed, 9 Oct 2024 14:22:56 +0200 Subject: [PATCH] implements more route --- meilisearch-types/src/keys.rs | 19 ++++- meilisearch-types/src/tasks.rs | 2 +- meilisearch/src/routes/api_key.rs | 132 +++++++++++++++++++++++++++++- meilisearch/src/routes/logs.rs | 3 +- meilisearch/src/routes/mod.rs | 29 ++++--- 5 files changed, 166 insertions(+), 19 deletions(-) diff --git a/meilisearch-types/src/keys.rs b/meilisearch-types/src/keys.rs index f7d80bbcb..092df8be0 100644 --- a/meilisearch-types/src/keys.rs +++ b/meilisearch-types/src/keys.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use time::format_description::well_known::Rfc3339; use time::macros::{format_description, time}; use time::{Date, OffsetDateTime, PrimitiveDateTime}; +use utoipa::ToSchema; use uuid::Uuid; use crate::deserr::{immutable_field_error, DeserrError, DeserrJsonError}; @@ -32,19 +33,31 @@ impl MergeWithError for Dese } } -#[derive(Debug, Deserr)] +#[derive(Debug, Deserr, ToSchema)] #[deserr(error = DeserrJsonError, rename_all = camelCase, deny_unknown_fields)] +#[schema(rename_all = "camelCase")] pub struct CreateApiKey { + /// A description for the key. `null` if empty. + #[schema(example = json!(null))] #[deserr(default, error = DeserrJsonError)] pub description: Option, + /// A human-readable name for the key. `null` if empty. + #[schema(example = "Indexing Products API key")] #[deserr(default, error = DeserrJsonError)] pub name: Option, + /// A uuid v4 to identify the API Key. If not specified, it's generated by Meilisearch. + #[schema(value_type = Uuid, example = json!(null))] #[deserr(default = Uuid::new_v4(), error = DeserrJsonError, try_from(&String) = Uuid::from_str -> uuid::Error)] pub uid: KeyId, + /// A list of actions permitted for the key. `["*"]` for all actions. The `*` character can be used as a wildcard when located at the last position. e.g. `documents.*` to authorize access on all documents endpoints. + #[schema(example = json!(["documents.add"]))] #[deserr(error = DeserrJsonError, missing_field_error = DeserrJsonError::missing_api_key_actions)] pub actions: Vec, + /// A list of accesible indexes permitted for the key. `["*"]` for all indexes. The `*` character can be used as a wildcard when located at the last position. e.g. `products_*` to allow access to all indexes whose names start with `products_`. #[deserr(error = DeserrJsonError, missing_field_error = DeserrJsonError::missing_api_key_indexes)] + #[schema(value_type = Vec, example = json!(["products"]))] pub indexes: Vec, + /// Represent the expiration date and time as RFC 3339 format. `null` equals to no expiration time. #[deserr(error = DeserrJsonError, try_from(Option) = parse_expiration_date -> ParseOffsetDateTimeError, missing_field_error = DeserrJsonError::missing_api_key_expires_at)] pub expires_at: Option, } @@ -179,7 +192,9 @@ fn parse_expiration_date( } } -#[derive(Copy, Clone, Serialize, Deserialize, Debug, Eq, PartialEq, Hash, Sequence, Deserr)] +#[derive( + Copy, Clone, Serialize, Deserialize, Debug, Eq, PartialEq, Hash, Sequence, Deserr, ToSchema, +)] #[repr(u8)] pub enum Action { #[serde(rename = "*")] diff --git a/meilisearch-types/src/tasks.rs b/meilisearch-types/src/tasks.rs index dd07982dd..1f1ab7a87 100644 --- a/meilisearch-types/src/tasks.rs +++ b/meilisearch-types/src/tasks.rs @@ -150,7 +150,7 @@ pub enum KindWithContent { SnapshotCreation, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct IndexSwap { pub indexes: (String, String), diff --git a/meilisearch/src/routes/api_key.rs b/meilisearch/src/routes/api_key.rs index 0bd4b9d59..dd6c54e27 100644 --- a/meilisearch/src/routes/api_key.rs +++ b/meilisearch/src/routes/api_key.rs @@ -13,6 +13,7 @@ use meilisearch_types::error::{Code, ResponseError}; use meilisearch_types::keys::{CreateApiKey, Key, PatchApiKey}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; +use utoipa::{IntoParams, OpenApi, ToSchema}; use uuid::Uuid; use super::PAGINATION_DEFAULT_LIMIT; @@ -21,6 +22,20 @@ use crate::extractors::authentication::GuardedData; use crate::extractors::sequential_extractor::SeqHandler; use crate::routes::Pagination; +#[derive(OpenApi)] +#[openapi( + paths(create_api_key, list_api_keys), + tags(( + name = "Keys", + description = "Manage API `keys` for a Meilisearch instance. Each key has a given set of permissions. +You must have the master key or the default admin key to access the keys route. More information about the keys and their rights. +Accessing any route under `/keys` without having set a master key will result in an error.", + external_docs(url = "https://www.meilisearch.com/docs/reference/api/keys"), + + )), +)] +pub struct ApiKeyApi; + pub fn configure(cfg: &mut web::ServiceConfig) { cfg.service( web::resource("") @@ -35,6 +50,52 @@ pub fn configure(cfg: &mut web::ServiceConfig) { ); } + +/// Create an API Key +/// +/// Create an API Key. +#[utoipa::path( + post, + path = "/", + tag = "Keys", + security(("Bearer" = ["keys.create", "keys.*", "*"])), + request_body = CreateApiKey, + responses( + (status = 202, description = "Key has been created", body = KeyView, content_type = "application/json", example = json!( + { + "uid": "01b4bc42-eb33-4041-b481-254d00cce834", + "key": "d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4", + "name": "Indexing Products API key", + "description": null, + "actions": [ + "documents.add" + ], + "indexes": [ + "products" + ], + "expiresAt": "2021-11-13T00:00:00Z", + "createdAt": "2021-11-12T10:00:00Z", + "updatedAt": "2021-11-12T10:00:00Z" + } + )), + (status = 401, description = "The route has been hit on an unprotected instance", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "Meilisearch is running without a master key. To access this API endpoint, you must have set a master key at launch.", + "code": "missing_master_key", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_master_key" + } + )), + (status = 401, description = "The authorization header is missing", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "The Authorization header is missing. It must use the bearer authorization method.", + "code": "missing_authorization_header", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_authorization_header" + } + )), + ) +)] pub async fn create_api_key( auth_controller: GuardedData, Data>, body: AwebJson, @@ -51,11 +112,14 @@ pub async fn create_api_key( Ok(HttpResponse::Created().json(res)) } -#[derive(Deserr, Debug, Clone, Copy)] +#[derive(Deserr, Debug, Clone, Copy, IntoParams)] #[deserr(error = DeserrQueryParamError, rename_all = camelCase, deny_unknown_fields)] +#[into_params(rename_all = "camelCase", parameter_in = Query)] pub struct ListApiKeys { + #[into_params(value_type = usize, default = 0)] #[deserr(default, error = DeserrQueryParamError)] pub offset: Param, + #[into_params(value_type = usize, default = PAGINATION_DEFAULT_LIMIT)] #[deserr(default = Param(PAGINATION_DEFAULT_LIMIT), error = DeserrQueryParamError)] pub limit: Param, } @@ -66,6 +130,59 @@ impl ListApiKeys { } } + +/// Get API Keys +/// +/// List all API Keys +#[utoipa::path( + get, + path = "/", + tag = "Keys", + security(("Bearer" = ["keys.get", "keys.*", "*"])), + params(ListApiKeys), + responses( + (status = 202, description = "List of keys", body = PaginationView, content_type = "application/json", example = json!( + { + "results": [ + { + "uid": "01b4bc42-eb33-4041-b481-254d00cce834", + "key": "d0552b41536279a0ad88bd595327b96f01176a60c2243e906c52ac02375f9bc4", + "name": "An API Key", + "description": null, + "actions": [ + "documents.add" + ], + "indexes": [ + "movies" + ], + "expiresAt": "2022-11-12T10:00:00Z", + "createdAt": "2021-11-12T10:00:00Z", + "updatedAt": "2021-11-12T10:00:00Z" + } + ], + "limit": 20, + "offset": 0, + "total": 1 + } + )), + (status = 401, description = "The route has been hit on an unprotected instance", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "Meilisearch is running without a master key. To access this API endpoint, you must have set a master key at launch.", + "code": "missing_master_key", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_master_key" + } + )), + (status = 401, description = "The authorization header is missing", body = ResponseError, content_type = "application/json", example = json!( + { + "message": "The Authorization header is missing. It must use the bearer authorization method.", + "code": "missing_authorization_header", + "type": "auth", + "link": "https://docs.meilisearch.com/errors#missing_authorization_header" + } + )), + ) +)] pub async fn list_api_keys( auth_controller: GuardedData, Data>, list_api_keys: AwebQueryParameter, @@ -144,19 +261,28 @@ pub struct AuthParam { key: String, } -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, ToSchema)] #[serde(rename_all = "camelCase")] -struct KeyView { +pub(super) struct KeyView { + /// The name of the API Key if any name: Option, + /// The description of the API Key if any description: Option, + /// The actual API Key you can send to Meilisearch key: String, + /// The `Uuid` specified while creating the key or autogenerated by Meilisearch. uid: Uuid, + /// The actions accessible with this key. actions: Vec, + /// The indexes accessible with this key. indexes: Vec, + /// The expiration date of the key. Once this timestamp is exceeded the key is not deleted but cannot be used anymore. #[serde(serialize_with = "time::serde::rfc3339::option::serialize")] expires_at: Option, + /// The date of creation of this API Key. #[serde(serialize_with = "time::serde::rfc3339::serialize")] created_at: OffsetDateTime, + /// The date of the last update made on this key. #[serde(serialize_with = "time::serde::rfc3339::serialize")] updated_at: OffsetDateTime, } diff --git a/meilisearch/src/routes/logs.rs b/meilisearch/src/routes/logs.rs index 5115c3256..dc6b6d14c 100644 --- a/meilisearch/src/routes/logs.rs +++ b/meilisearch/src/routes/logs.rs @@ -297,7 +297,8 @@ fn entry_stream( 2024-10-08T13:35:02.643750Z WARN HTTP request{method=GET host="localhost:7700" route=/metrics query_parameters= user_agent=HTTPie/3.2.3 status_code=400 error=Getting metrics requires enabling the `metrics` experimental feature. See https://github.com/meilisearch/product/discussions/625}: tracing_actix_web::middleware: Error encountered while processing the incoming HTTP request: ResponseError { code: 400, message: "Getting metrics requires enabling the `metrics` experimental feature. See https://github.com/meilisearch/product/discussions/625", error_code: "feature_not_enabled", error_type: "invalid_request", error_link: "https://docs.meilisearch.com/errors#feature_not_enabled" } 2024-10-08T13:35:02.644191Z INFO HTTP request{method=GET host="localhost:7700" route=/metrics query_parameters= user_agent=HTTPie/3.2.3 status_code=400 error=Getting metrics requires enabling the `metrics` experimental feature. See https://github.com/meilisearch/product/discussions/625}: meilisearch: close time.busy=1.66ms time.idle=658µs 2024-10-08T13:35:18.564152Z INFO HTTP request{method=PATCH host="localhost:7700" route=/experimental-features query_parameters= user_agent=curl/8.6.0 status_code=200}: meilisearch: close time.busy=1.17ms time.idle=127µs -2024-10-08T13:35:23.094987Z INFO HTTP request{method=GET host="localhost:7700" route=/metrics query_parameters= user_agent=HTTPie/3.2.3 status_code=200}: meilisearch: close time.busy=2.12ms time.idle=595µs "# +2024-10-08T13:35:23.094987Z INFO HTTP request{method=GET host="localhost:7700" route=/metrics query_parameters= user_agent=HTTPie/3.2.3 status_code=200}: meilisearch: close time.busy=2.12ms time.idle=595µs +"# )), (status = 400, description = "The route is already being used", body = ResponseError, content_type = "application/json", example = json!( { diff --git a/meilisearch/src/routes/mod.rs b/meilisearch/src/routes/mod.rs index b19631495..8837f94d9 100644 --- a/meilisearch/src/routes/mod.rs +++ b/meilisearch/src/routes/mod.rs @@ -10,6 +10,7 @@ use index_scheduler::IndexScheduler; use meilisearch_auth::AuthController; use meilisearch_types::error::ErrorType; use meilisearch_types::error::{Code, ResponseError}; +use meilisearch_types::keys::CreateApiKey; use meilisearch_types::settings::Checked; use meilisearch_types::settings::FacetingSettings; use meilisearch_types::settings::MinWordSizeTyposSetting; @@ -27,6 +28,8 @@ use utoipa::ToSchema; use utoipa_rapidoc::RapiDoc; use utoipa_scalar::{Scalar, Servable as ScalarServable}; +use self::api_key::KeyView; +use self::api_key::ListApiKeys; use self::indexes::IndexStats; use self::logs::GetLogs; use self::logs::LogMode; @@ -54,32 +57,33 @@ pub mod tasks; (path = "/tasks", api = tasks::TaskApi), (path = "/snapshots", api = snapshot::SnapshotApi), (path = "/dumps", api = dump::DumpApi), + (path = "/keys", api = api_key::ApiKeyApi), (path = "/metrics", api = metrics::MetricApi), (path = "/logs", api = logs::LogsApi), ), paths(get_health, get_version, get_stats), modifiers(&OpenApiAuth), - components(schemas(UpdateStderrLogs, LogMode, GetLogs, IndexStats, Stats, HealthStatus, HealthResponse, VersionResponse, Code, ErrorType, AllTasks, TaskView, Status, DetailsView, ResponseError, Settings, Settings, TypoSettings, MinWordSizeTyposSetting, FacetingSettings, PaginationSettings, SummarizedTaskView, Kind)) + components(schemas(PaginationView, KeyView, Action, CreateApiKey, UpdateStderrLogs, LogMode, GetLogs, IndexStats, Stats, HealthStatus, HealthResponse, VersionResponse, Code, ErrorType, AllTasks, TaskView, Status, DetailsView, ResponseError, Settings, Settings, TypoSettings, MinWordSizeTyposSetting, FacetingSettings, PaginationSettings, SummarizedTaskView, Kind)) )] pub struct MeilisearchApi; pub fn configure(cfg: &mut web::ServiceConfig) { let openapi = MeilisearchApi::openapi(); - cfg.service(web::scope("/tasks").configure(tasks::configure)) - .service(Scalar::with_url("/scalar", openapi.clone())) - .service(RapiDoc::with_openapi("/api-docs/openapi.json", openapi).path("/rapidoc")) - .service(web::resource("/health").route(web::get().to(get_health))) - .service(web::scope("/logs").configure(logs::configure)) + cfg.service(web::scope("/tasks").configure(tasks::configure)) // done + .service(Scalar::with_url("/scalar", openapi.clone())) // done + .service(RapiDoc::with_openapi("/api-docs/openapi.json", openapi).path("/rapidoc")) // done + .service(web::resource("/health").route(web::get().to(get_health))) // done + .service(web::scope("/logs").configure(logs::configure)) // done .service(web::scope("/keys").configure(api_key::configure)) - .service(web::scope("/dumps").configure(dump::configure)) - .service(web::scope("/snapshots").configure(snapshot::configure)) - .service(web::resource("/stats").route(web::get().to(get_stats))) - .service(web::resource("/version").route(web::get().to(get_version))) + .service(web::scope("/dumps").configure(dump::configure)) // done + .service(web::scope("/snapshots").configure(snapshot::configure)) // done + .service(web::resource("/stats").route(web::get().to(get_stats))) // done + .service(web::resource("/version").route(web::get().to(get_version))) // done .service(web::scope("/indexes").configure(indexes::configure)) .service(web::scope("/multi-search").configure(multi_search::configure)) .service(web::scope("/swap-indexes").configure(swap_indexes::configure)) - .service(web::scope("/metrics").configure(metrics::configure)) + .service(web::scope("/metrics").configure(metrics::configure)) // done .service(web::scope("/experimental-features").configure(features::configure)); } @@ -167,7 +171,8 @@ pub struct Pagination { pub limit: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ToSchema)] +#[schema(rename_all = "camelCase")] pub struct PaginationView { pub results: Vec, pub offset: usize,