From e428da9954977edb5a85e394bde2d017725f1472 Mon Sep 17 00:00:00 2001 From: Artur Jurat Date: Sun, 8 Dec 2024 21:00:21 +0100 Subject: [PATCH] chore: Use local_id in subscription rest api --- .github/workflows/ci-rust.yaml | 2 +- .../crates/diesel-models/src/query/mod.rs | 5 +++ .../diesel-models/src/query/subscriptions.rs | 40 +++++++++++++---- .../crates/meteroid-store/src/domain/mod.rs | 10 +++++ .../src/repositories/invoices.rs | 7 +-- .../src/repositories/subscriptions.rs | 45 ++++++++++--------- .../meteroid/src/api/subscriptions/service.rs | 10 +++-- .../src/api_rest/subscriptions/mapping.rs | 9 ++-- .../src/api_rest/subscriptions/model.rs | 14 +++--- .../src/api_rest/subscriptions/router.rs | 21 ++++----- .../src/eventbus/analytics_handler.rs | 12 +++-- modules/meteroid/src/seeder/runner.rs | 4 +- spec/api/v1/openapi.json | 37 +++++---------- 13 files changed, 126 insertions(+), 90 deletions(-) diff --git a/.github/workflows/ci-rust.yaml b/.github/workflows/ci-rust.yaml index 80994c08..f4e9f330 100644 --- a/.github/workflows/ci-rust.yaml +++ b/.github/workflows/ci-rust.yaml @@ -123,6 +123,6 @@ jobs: run: | cargo run -p meteroid --bin openapi-generate if [[ -n "$(git status --porcelain spec/api/v1/openapi.json)" ]]; then - echo "openapi.json has uncommitted changes. Please commit the changes." + echo "openapi.json is not up to date. Please run `cargo run -p meteroid --bin openapi-generate` and commit changes." exit 1 fi diff --git a/modules/meteroid/crates/diesel-models/src/query/mod.rs b/modules/meteroid/crates/diesel-models/src/query/mod.rs index 3eb0d403..9ae830fe 100644 --- a/modules/meteroid/crates/diesel-models/src/query/mod.rs +++ b/modules/meteroid/crates/diesel-models/src/query/mod.rs @@ -28,3 +28,8 @@ pub mod subscriptions; pub mod tenants; pub mod users; pub mod webhooks; + +pub enum IdentityDb { + UUID(uuid::Uuid), + LOCAL(String), +} diff --git a/modules/meteroid/crates/diesel-models/src/query/subscriptions.rs b/modules/meteroid/crates/diesel-models/src/query/subscriptions.rs index 3953aa3e..dc278a8d 100644 --- a/modules/meteroid/crates/diesel-models/src/query/subscriptions.rs +++ b/modules/meteroid/crates/diesel-models/src/query/subscriptions.rs @@ -18,6 +18,7 @@ use crate::extend::cursor_pagination::{ CursorPaginate, CursorPaginatedVec, CursorPaginationRequest, }; use crate::extend::pagination::{Paginate, PaginatedVec, PaginationRequest}; +use crate::query::IdentityDb; use error_stack::ResultExt; use uuid::Uuid; @@ -58,21 +59,30 @@ impl SubscriptionRow { pub async fn get_subscription_by_id( conn: &mut PgConn, tenant_id_param: &uuid::Uuid, - subscription_id: &uuid::Uuid, + subscription_id_param: IdentityDb, ) -> DbResult { use crate::schema::subscription::dsl::*; use crate::schema::plan::dsl as p_dsl; use crate::schema::plan_version::dsl as pv_dsl; - let query = subscription - .filter(id.eq(subscription_id)) + let mut query = subscription .filter(tenant_id.eq(tenant_id_param)) .inner_join(crate::schema::customer::table) .inner_join( pv_dsl::plan_version.inner_join(p_dsl::plan.on(p_dsl::id.eq(pv_dsl::plan_id))), ) - .select(SubscriptionForDisplayRow::as_select()); + .select(SubscriptionForDisplayRow::as_select()) + .into_boxed(); + + match subscription_id_param { + IdentityDb::UUID(id_param) => { + query = query.filter(id.eq(id_param)); + } + IdentityDb::LOCAL(local_id_param) => { + query = query.filter(local_id.eq(local_id_param)); + } + } log::debug!("{}", debug_query::(&query).to_string()); @@ -188,8 +198,8 @@ impl SubscriptionRow { pub async fn list_subscriptions( conn: &mut PgConn, tenant_id_param: uuid::Uuid, - customer_id_opt: Option, - plan_id_param_opt: Option, + customer_id_opt: Option, + plan_id_param_opt: Option, pagination: PaginationRequest, ) -> DbResult> { use crate::schema::plan::dsl as p_dsl; @@ -205,11 +215,25 @@ impl SubscriptionRow { .into_boxed(); if let Some(customer_id_param) = customer_id_opt { - query = query.filter(customer_id.eq(customer_id_param)); + match customer_id_param { + IdentityDb::UUID(customer_id_param) => { + query = query.filter(customer_id.eq(customer_id_param)); + } + IdentityDb::LOCAL(customer_local_id) => { + query = query.filter(crate::schema::customer::local_id.eq(customer_local_id)); + } + } } if let Some(plan_id_param) = plan_id_param_opt { - query = query.filter(crate::schema::plan::id.eq(plan_id_param)); + match plan_id_param { + IdentityDb::UUID(plan_id) => { + query = query.filter(p_dsl::id.eq(plan_id)); + } + IdentityDb::LOCAL(plan_local_id) => { + query = query.filter(p_dsl::local_id.eq(plan_local_id)); + } + } } // diff --git a/modules/meteroid/crates/meteroid-store/src/domain/mod.rs b/modules/meteroid/crates/meteroid-store/src/domain/mod.rs index 91a4fdbc..870b9a03 100644 --- a/modules/meteroid/crates/meteroid-store/src/domain/mod.rs +++ b/modules/meteroid/crates/meteroid-store/src/domain/mod.rs @@ -1,10 +1,12 @@ pub use api_tokens::*; pub use billable_metrics::*; pub use customers::*; +use diesel_models::query::IdentityDb; pub use invoice_lines::*; pub use invoices::*; pub use invoicing_entities::*; pub use misc::*; +use o2o::o2o; pub use organizations::*; pub use plans::*; pub use price_components::*; @@ -16,6 +18,7 @@ pub use subscription_components::*; pub use subscription_coupons::*; pub use subscriptions::*; pub use tenants::*; +use uuid::Uuid; pub mod customers; pub mod invoices; @@ -47,3 +50,10 @@ pub mod subscription_coupons; pub mod subscriptions; pub mod users; pub mod webhooks; + +#[derive(Debug, Clone, PartialEq, Eq, o2o)] +#[owned_into(IdentityDb)] +pub enum Identity { + UUID(Uuid), + LOCAL(String), +} diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/invoices.rs b/modules/meteroid/crates/meteroid-store/src/repositories/invoices.rs index 179f08fa..cd749be9 100644 --- a/modules/meteroid/crates/meteroid-store/src/repositories/invoices.rs +++ b/modules/meteroid/crates/meteroid-store/src/repositories/invoices.rs @@ -11,8 +11,9 @@ use error_stack::{Report, ResultExt}; use crate::compute::InvoiceLineInterface; use crate::domain::outbox_event::OutboxEvent; use crate::domain::{ - CursorPaginatedVec, CursorPaginationRequest, DetailedInvoice, Invoice, InvoiceLinesPatch, - InvoiceNew, InvoiceWithCustomer, OrderByRequest, PaginatedVec, PaginationRequest, + CursorPaginatedVec, CursorPaginationRequest, DetailedInvoice, Identity, Invoice, + InvoiceLinesPatch, InvoiceNew, InvoiceWithCustomer, OrderByRequest, PaginatedVec, + PaginationRequest, }; use crate::repositories::customer_balance::CustomerBalance; use crate::repositories::SubscriptionInterface; @@ -564,7 +565,7 @@ async fn compute_invoice_patch( .into()), Some(subscription_id) => { let subscription_details = store - .get_subscription_details(tenant_id, subscription_id) + .get_subscription_details(tenant_id, Identity::UUID(subscription_id)) .await?; let lines = store .compute_dated_invoice_lines(&invoice.invoice.invoice_date, &subscription_details) diff --git a/modules/meteroid/crates/meteroid-store/src/repositories/subscriptions.rs b/modules/meteroid/crates/meteroid-store/src/repositories/subscriptions.rs index b85c75d1..fe68ab8a 100644 --- a/modules/meteroid/crates/meteroid-store/src/repositories/subscriptions.rs +++ b/modules/meteroid/crates/meteroid-store/src/repositories/subscriptions.rs @@ -5,11 +5,12 @@ use crate::domain::enums::{ use crate::domain::{ BillableMetric, BillingConfig, CreateSubscription, CreateSubscriptionAddOns, CreateSubscriptionComponents, CreateSubscriptionCoupons, CreatedSubscription, - CursorPaginatedVec, CursorPaginationRequest, Customer, InlineCustomer, InlineInvoicingEntity, - InvoicingEntity, PaginatedVec, PaginationRequest, PriceComponent, Schedule, Subscription, - SubscriptionAddOnCustomization, SubscriptionAddOnNew, SubscriptionAddOnNewInternal, - SubscriptionComponent, SubscriptionComponentNew, SubscriptionComponentNewInternal, - SubscriptionDetails, SubscriptionFee, SubscriptionInvoiceCandidate, SubscriptionNew, + CursorPaginatedVec, CursorPaginationRequest, Customer, Identity, InlineCustomer, + InlineInvoicingEntity, InvoicingEntity, PaginatedVec, PaginationRequest, PriceComponent, + Schedule, Subscription, SubscriptionAddOnCustomization, SubscriptionAddOnNew, + SubscriptionAddOnNewInternal, SubscriptionComponent, SubscriptionComponentNew, + SubscriptionComponentNewInternal, SubscriptionDetails, SubscriptionFee, + SubscriptionInvoiceCandidate, SubscriptionNew, }; use crate::errors::StoreError; use crate::store::{PgConn, Store}; @@ -40,6 +41,7 @@ use diesel_models::billable_metrics::BillableMetricRow; use diesel_models::coupons::CouponRow; use diesel_models::price_components::PriceComponentRow; use diesel_models::query::plans::get_plan_names_by_version_ids; +use diesel_models::query::IdentityDb; use diesel_models::schedules::ScheduleRow; use diesel_models::slot_transactions::SlotTransactionRow; use diesel_models::subscription_add_ons::{SubscriptionAddOnRow, SubscriptionAddOnRowNew}; @@ -73,7 +75,7 @@ pub trait SubscriptionInterface { async fn get_subscription_details( &self, tenant_id: Uuid, - subscription_id: Uuid, + subscription_id: Identity, ) -> StoreResult; async fn insert_subscription_components( @@ -93,8 +95,8 @@ pub trait SubscriptionInterface { async fn list_subscriptions( &self, tenant_id: Uuid, - customer_id: Option, - plan_id: Option, + customer_id: Option, + plan_id: Option, pagination: PaginationRequest, ) -> StoreResult>; @@ -569,19 +571,19 @@ impl SubscriptionInterface for Store { async fn get_subscription_details( &self, tenant_id: Uuid, - subscription_id: Uuid, + subscription_id: Identity, ) -> StoreResult { let mut conn = self.get_conn().await?; let db_subscription = - SubscriptionRow::get_subscription_by_id(&mut conn, &tenant_id, &subscription_id) + SubscriptionRow::get_subscription_by_id(&mut conn, &tenant_id, subscription_id.into()) .await .map_err(Into::>::into)?; let subscription: Subscription = db_subscription.into(); let schedules: Vec = - ScheduleRow::list_schedules_by_subscription(&mut conn, &tenant_id, &subscription_id) + ScheduleRow::list_schedules_by_subscription(&mut conn, &tenant_id, &subscription.id) .await .map_err(Into::>::into)? .into_iter() @@ -592,7 +594,7 @@ impl SubscriptionInterface for Store { SubscriptionComponentRow::list_subscription_components_by_subscription( &mut conn, &tenant_id, - &subscription_id, + &subscription.id, ) .await .map_err(Into::>::into)? @@ -601,7 +603,7 @@ impl SubscriptionInterface for Store { .collect::, _>>()?; let subscription_add_ons: Vec = - SubscriptionAddOnRow::list_by_subscription_id(&mut conn, &tenant_id, &subscription_id) + SubscriptionAddOnRow::list_by_subscription_id(&mut conn, &tenant_id, &subscription.id) .await .map_err(Into::>::into)? .into_iter() @@ -623,7 +625,7 @@ impl SubscriptionInterface for Store { metric_ids = metric_ids.into_iter().unique().collect::>(); let applied_coupons = - AppliedCouponDetailedRow::list_by_subscription_id(&mut conn, &subscription_id) + AppliedCouponDetailedRow::list_by_subscription_id(&mut conn, &subscription.id) .await .map_err(Into::>::into)? .into_iter() @@ -711,7 +713,10 @@ impl SubscriptionInterface for Store { .transaction(|conn| { async move { let subscription: SubscriptionDetails = self - .get_subscription_details(context.tenant_id, subscription_id) + .get_subscription_details( + context.tenant_id, + Identity::UUID(subscription_id), + ) .await?; let now = chrono::Utc::now().naive_utc(); @@ -739,7 +744,7 @@ impl SubscriptionInterface for Store { let res = SubscriptionRow::get_subscription_by_id( conn, &context.tenant_id, - &subscription_id, + IdentityDb::UUID(subscription_id), ) .await .map_err(Into::>::into)?; @@ -785,8 +790,8 @@ impl SubscriptionInterface for Store { async fn list_subscriptions( &self, tenant_id: Uuid, - customer_id: Option, - plan_id: Option, + customer_id: Option, + plan_id: Option, pagination: PaginationRequest, ) -> StoreResult> { let mut conn = self.get_conn().await?; @@ -794,8 +799,8 @@ impl SubscriptionInterface for Store { let db_subscriptions = SubscriptionRow::list_subscriptions( &mut conn, tenant_id, - customer_id, - plan_id, + customer_id.map(Into::into), + plan_id.map(Into::into), pagination.into(), ) .await diff --git a/modules/meteroid/src/api/subscriptions/service.rs b/modules/meteroid/src/api/subscriptions/service.rs index 9c55178d..568ae387 100644 --- a/modules/meteroid/src/api/subscriptions/service.rs +++ b/modules/meteroid/src/api/subscriptions/service.rs @@ -13,6 +13,7 @@ use meteroid_grpc::meteroid::api::subscriptions::v1::{ }; use meteroid_store::domain; +use meteroid_store::domain::Identity; use meteroid_store::repositories::subscriptions::{ CancellationEffectiveAt, SubscriptionSlotsInterface, }; @@ -101,7 +102,10 @@ impl SubscriptionsService for SubscriptionServiceComponents { let subscription = self .store - .get_subscription_details(tenant_id, parse_uuid!(inner.subscription_id)?) + .get_subscription_details( + tenant_id, + Identity::UUID(parse_uuid!(inner.subscription_id)?), + ) .await .map_err(Into::::into) .map_err(Into::::into) @@ -125,8 +129,8 @@ impl SubscriptionsService for SubscriptionServiceComponents { .store .list_subscriptions( tenant_id, - customer_id, - plan_id, + customer_id.map(|id| Identity::UUID(id)), + plan_id.map(|id| Identity::UUID(id)), domain::PaginationRequest { page: inner.pagination.as_ref().map(|p| p.page).unwrap_or(0), per_page: inner.pagination.as_ref().map(|p| p.per_page), diff --git a/modules/meteroid/src/api_rest/subscriptions/mapping.rs b/modules/meteroid/src/api_rest/subscriptions/mapping.rs index c2269915..f30f6640 100644 --- a/modules/meteroid/src/api_rest/subscriptions/mapping.rs +++ b/modules/meteroid/src/api_rest/subscriptions/mapping.rs @@ -3,20 +3,19 @@ use meteroid_store::domain; pub fn domain_to_rest(s: domain::Subscription) -> Subscription { Subscription { - id: s.id, - customer_id: s.customer_id, + id: s.local_id, + customer_id: s.customer_local_id, customer_name: s.customer_name, customer_alias: s.customer_alias, billing_day: s.billing_day, - tenant_id: s.tenant_id, currency: s.currency, } } pub fn domain_to_rest_details(s: domain::SubscriptionDetails) -> SubscriptionDetails { SubscriptionDetails { - id: s.id, - customer_id: s.customer_id, + id: s.local_id, + customer_id: s.customer_local_id, customer_name: s.customer_name, customer_alias: s.customer_alias, billing_day: s.billing_day, diff --git a/modules/meteroid/src/api_rest/subscriptions/model.rs b/modules/meteroid/src/api_rest/subscriptions/model.rs index 5c21026f..65832123 100644 --- a/modules/meteroid/src/api_rest/subscriptions/model.rs +++ b/modules/meteroid/src/api_rest/subscriptions/model.rs @@ -1,30 +1,28 @@ use crate::api_rest::model::PaginatedRequest; use utoipa::ToSchema; -use uuid::Uuid; #[derive(ToSchema, serde::Serialize, serde::Deserialize)] pub struct SubscriptionRequest { #[serde(flatten)] pub pagination: PaginatedRequest, - pub customer_id: Option, - pub plan_id: Option, + pub customer_id: Option, + pub plan_id: Option, } #[derive(Clone, ToSchema, serde::Serialize, serde::Deserialize)] pub struct Subscription { - pub id: Uuid, - pub customer_id: Uuid, + pub id: String, + pub customer_id: String, pub customer_name: String, pub customer_alias: Option, pub billing_day: i16, - pub tenant_id: Uuid, pub currency: String, } #[derive(Clone, ToSchema, serde::Serialize, serde::Deserialize)] pub struct SubscriptionDetails { - pub id: Uuid, - pub customer_id: Uuid, + pub id: String, + pub customer_id: String, pub customer_name: String, pub customer_alias: Option, pub billing_day: i16, diff --git a/modules/meteroid/src/api_rest/subscriptions/router.rs b/modules/meteroid/src/api_rest/subscriptions/router.rs index c627b23d..3aaaa8f9 100644 --- a/modules/meteroid/src/api_rest/subscriptions/router.rs +++ b/modules/meteroid/src/api_rest/subscriptions/router.rs @@ -12,6 +12,7 @@ use crate::api_rest::subscriptions::model::{ }; use crate::errors::RestApiError; use common_grpc::middleware::server::auth::AuthorizedAsTenant; +use meteroid_store::domain::Identity; use meteroid_store::repositories::SubscriptionInterface; use meteroid_store::{domain, Store}; use uuid::Uuid; @@ -54,14 +55,14 @@ async fn list_subscriptions_handler( store: Store, pagination: PaginatedRequest, tenant_id: Uuid, - customer_id: Option, - plan_id: Option, + customer_id: Option, + plan_id: Option, ) -> Result, RestApiError> { let res = store .list_subscriptions( tenant_id, - customer_id, - plan_id, + customer_id.map(|v| Identity::LOCAL(v)), + plan_id.map(|v| Identity::LOCAL(v)), domain::PaginationRequest { page: pagination.offset.unwrap_or(0), per_page: pagination.limit, @@ -89,9 +90,9 @@ async fn list_subscriptions_handler( #[utoipa::path( get, tag = "subscription", - path = "/api/v1/subscriptions/:uuid", + path = "/api/v1/subscriptions/:id", params( - ("uuid" = Uuid, Path, description = "subscription UUID") + ("id" = String, Path, description = "subscription ID") ), responses( (status = 200, description = "Details of subscription", body = SubscriptionDetails), @@ -102,9 +103,9 @@ async fn list_subscriptions_handler( pub(crate) async fn subscription_details( Extension(authorized_state): Extension, State(app_state): State, - Path(uuid): Path, + Path(id): Path, ) -> Result { - subscription_details_handler(app_state.store, authorized_state.tenant_id, uuid) + subscription_details_handler(app_state.store, authorized_state.tenant_id, id) .await .map(Json) .map_err(|e| { @@ -116,10 +117,10 @@ pub(crate) async fn subscription_details( async fn subscription_details_handler( store: Store, tenant_id: Uuid, - subscription_id: Uuid, + subscription_id: String, ) -> Result { let res = store - .get_subscription_details(tenant_id, subscription_id) + .get_subscription_details(tenant_id, Identity::LOCAL(subscription_id)) .await .map_err(|e| { log::error!("Error handling subscription_details: {}", e); diff --git a/modules/meteroid/src/eventbus/analytics_handler.rs b/modules/meteroid/src/eventbus/analytics_handler.rs index de8b9148..051a3cba 100644 --- a/modules/meteroid/src/eventbus/analytics_handler.rs +++ b/modules/meteroid/src/eventbus/analytics_handler.rs @@ -14,7 +14,7 @@ use common_eventbus::{ }; use common_eventbus::{EventBusError, EventHandler}; use common_logging::unwrapper::UnwrapLogger; -use meteroid_store::domain::DetailedInvoice; +use meteroid_store::domain::{DetailedInvoice, Identity}; use meteroid_store::repositories::api_tokens::ApiTokensInterface; use meteroid_store::repositories::billable_metrics::BillableMetricInterface; use meteroid_store::repositories::price_components::PriceComponentInterface; @@ -428,7 +428,10 @@ impl AnalyticsHandler { ) -> Result<(), EventBusError> { let subscription = self .store - .get_subscription_details(event_data_details.tenant_id, event_data_details.entity_id) + .get_subscription_details( + event_data_details.tenant_id, + Identity::UUID(event_data_details.entity_id), + ) .await .map_err(|e| EventBusError::EventHandlerFailed(e.to_string()))?; @@ -456,7 +459,10 @@ impl AnalyticsHandler { ) -> Result<(), EventBusError> { let subscription = self .store - .get_subscription_details(event_data_details.tenant_id, event_data_details.entity_id) + .get_subscription_details( + event_data_details.tenant_id, + Identity::UUID(event_data_details.entity_id), + ) .await .map_err(|e| EventBusError::EventHandlerFailed(e.to_string()))?; diff --git a/modules/meteroid/src/seeder/runner.rs b/modules/meteroid/src/seeder/runner.rs index 99f142d7..86031833 100644 --- a/modules/meteroid/src/seeder/runner.rs +++ b/modules/meteroid/src/seeder/runner.rs @@ -29,7 +29,7 @@ use chrono::Utc; use nanoid::nanoid; use meteroid_store::domain::{ - Address, BillingConfig, InlineCustomer, InlineInvoicingEntity, TenantContext, + Address, BillingConfig, Identity, InlineCustomer, InlineInvoicingEntity, TenantContext, }; use meteroid_store::repositories::billable_metrics::BillableMetricInterface; use meteroid_store::repositories::invoicing_entities::InvoicingEntityInterface; @@ -412,7 +412,7 @@ pub async fn run( // TODO don't refetch the details, we should have everything, or at the least do it in a batch let details = store - .get_subscription_details(subscription.tenant_id, subscription.id) + .get_subscription_details(subscription.tenant_id, Identity::UUID(subscription.id)) .await .change_context(SeederError::TempError)?; diff --git a/spec/api/v1/openapi.json b/spec/api/v1/openapi.json index 7a41f585..1bed9cb4 100644 --- a/spec/api/v1/openapi.json +++ b/spec/api/v1/openapi.json @@ -56,7 +56,7 @@ } } }, - "/api/v1/subscriptions/:uuid": { + "/api/v1/subscriptions/:id": { "get": { "tags": [ "subscription" @@ -64,13 +64,12 @@ "operationId": "subscription_details", "parameters": [ { - "name": "uuid", + "name": "id", "in": "path", - "description": "subscription UUID", + "description": "subscription ID", "required": true, "schema": { - "type": "string", - "format": "uuid" + "type": "string" } } ], @@ -216,7 +215,6 @@ "customer_id", "customer_name", "billing_day", - "tenant_id", "currency" ], "properties": { @@ -234,19 +232,13 @@ ] }, "customer_id": { - "type": "string", - "format": "uuid" + "type": "string" }, "customer_name": { "type": "string" }, "id": { - "type": "string", - "format": "uuid" - }, - "tenant_id": { - "type": "string", - "format": "uuid" + "type": "string" } } } @@ -270,7 +262,6 @@ "customer_id", "customer_name", "billing_day", - "tenant_id", "currency" ], "properties": { @@ -288,19 +279,13 @@ ] }, "customer_id": { - "type": "string", - "format": "uuid" + "type": "string" }, "customer_name": { "type": "string" }, "id": { - "type": "string", - "format": "uuid" - }, - "tenant_id": { - "type": "string", - "format": "uuid" + "type": "string" } } }, @@ -328,15 +313,13 @@ ] }, "customer_id": { - "type": "string", - "format": "uuid" + "type": "string" }, "customer_name": { "type": "string" }, "id": { - "type": "string", - "format": "uuid" + "type": "string" } } }