diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 92280d0fc..fce00c4c8 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -5,12 +5,14 @@ import akka.http.scaladsl.util.FastFuture import akka.stream.Materializer import akka.stream.scaladsl.{Flow, JsonFraming, Sink, Source} import akka.util.ByteString +import cats.Id import cats.data.EitherT import cats.implicits.{catsSyntaxOptionId, toTraverseOps} import controllers.AppError import controllers.AppError._ import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionContext, DaikokuActionMaybeWithGuest} import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent +import fr.maif.otoroshi.daikoku.audit.config.ElasticAnalyticsConfig import fr.maif.otoroshi.daikoku.ctrls.authorizations.async._ import fr.maif.otoroshi.daikoku.domain.NotificationAction.{ApiAccess, ApiSubscriptionDemand} import fr.maif.otoroshi.daikoku.domain.UsagePlanVisibility.Private @@ -4252,4 +4254,33 @@ class ApiController( value.merge } } + + def getApiSubscriptionsUsage(teamId: String) = + DaikokuAction.async(parse.json) { ctx => + TeamAdminOnly(AuditTrailEvent(s"@{user.name} has accessed to subscription usage for his team @{team.id}"))(teamId, ctx) { team => + + val subsIds = (ctx.request.body \ "subscriptions").as[JsArray] + + for { + subscriptions <- env.dataStore.apiSubscriptionRepo.forTenant(ctx.tenant).find(Json.obj("_id" -> Json.obj("$in" -> subsIds))) + planIds = subscriptions.map(_.plan.asJson).distinct + plans <- env.dataStore.usagePlanRepo.forTenant(ctx.tenant).find(Json.obj("_id" -> Json.obj("$in" -> JsArray(planIds)))) + test = subscriptions.groupBy(sub => sub.plan).toSeq + r <- Future.sequence(test.map { case (planId, subs) => getOtoroshiUsage(subs, plans.find(_.id == planId))(ctx.tenant)}) + } yield Ok(JsArray(r.flatMap(_.value))) + + } + } + + private def getOtoroshiUsage(subscriptions: Seq[ApiSubscription], plan: Option[UsagePlan])(implicit tenant: Tenant): Future[JsArray] = { + + val value1: EitherT[Future, JsArray, JsArray] = plan match { + case Some(value) => for { + otoroshi <- EitherT.fromOption[Future](tenant.otoroshiSettings.find(oto => value.otoroshiTarget.exists(_.otoroshiSettings == oto.id)), Json.arr()) + usages <- otoroshiClient.getSubscriptionLastUsage(subscriptions)(otoroshi, tenant) + } yield usages + case None => EitherT.pure[Future, JsArray](Json.arr()) + } + value1.merge + } } diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 566e83bfa..dd3c7e1c5 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -174,7 +174,8 @@ object SchemaDefinition { lazy val OtoroshiSettingsType = deriveObjectType[(DataStore, DaikokuActionContext[JsValue]), OtoroshiSettings]( ObjectTypeDescription("Settings to communicate with an instance of Otoroshi"), - ReplaceField("id", Field("id", StringType, resolve = _.value.id.value)) + ReplaceField("id", Field("id", StringType, resolve = _.value.id.value)), + ReplaceField("elasticConfig", Field("elasticConfig", OptionType(ElasticAnalyticsConfigType), resolve = _.value.elasticConfig)) ) lazy val MailerSettingsType: InterfaceType[(DataStore, DaikokuActionContext[JsValue]), MailerSettings] = InterfaceType( "MailerSettings", diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index 97a557369..2df3549d1 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -108,7 +108,8 @@ object json { .getOrElse("admin-api-apikey-id"), clientSecret = (json \ "clientSecret") .asOpt[String] - .getOrElse("admin-api-apikey-sectet") + .getOrElse("admin-api-apikey-secret"), + elasticConfig = (json \ "elasticConfig").asOpt(ElasticAnalyticsConfig.format) ) ) } recover { @@ -120,7 +121,8 @@ object json { "url" -> o.url, "host" -> o.host, "clientId" -> o.clientId, - "clientSecret" -> o.clientSecret + "clientSecret" -> o.clientSecret, + "elasticConfig" -> o.elasticConfig.map(ElasticAnalyticsConfig.format.writes).getOrElse(JsNull).as[JsValue] ) } @@ -480,7 +482,9 @@ object json { Try { JsSuccess(OtoroshiSettingsId(json.as[String])) } recover { - case e => JsError(e.getMessage) + case e => + AppLogger.error(e.getMessage, e) + JsError(e.getMessage) } get override def writes(o: OtoroshiSettingsId): JsValue = JsString(o.value) diff --git a/daikoku/app/domain/tenantEntities.scala b/daikoku/app/domain/tenantEntities.scala index 0499f78ae..f5bfa7083 100644 --- a/daikoku/app/domain/tenantEntities.scala +++ b/daikoku/app/domain/tenantEntities.scala @@ -502,7 +502,8 @@ case class OtoroshiSettings(id: OtoroshiSettingsId, url: String, host: String, clientId: String = "admin-api-apikey-id", - clientSecret: String = "admin-api-apikey-secret") + clientSecret: String = "admin-api-apikey-secret", + elasticConfig: Option[ElasticAnalyticsConfig] = None) extends CanJson[OtoroshiSettings] { def asJson: JsValue = json.OtoroshiSettingsFormat.writes(this) def toUiPayload(): JsValue = { diff --git a/daikoku/app/utils/admin.scala b/daikoku/app/utils/admin.scala index 94b814de8..169554a45 100644 --- a/daikoku/app/utils/admin.scala +++ b/daikoku/app/utils/admin.scala @@ -223,6 +223,7 @@ abstract class AdminApiController[Of, Id <: ValueType]( } def findById(id: String) = DaikokuApiAction.async { ctx => + println("hi") val notDeleted: Boolean = ctx.request.queryString.get("notDeleted").exists(_ == "true") notDeleted match { diff --git a/daikoku/app/utils/otoroshi.scala b/daikoku/app/utils/otoroshi.scala index 4b7a89828..4c74f0d62 100644 --- a/daikoku/app/utils/otoroshi.scala +++ b/daikoku/app/utils/otoroshi.scala @@ -2,11 +2,15 @@ package fr.maif.otoroshi.daikoku.utils import akka.http.scaladsl.util.FastFuture import akka.stream.Materializer +import cats.data.EitherT +import cats.implicits.catsSyntaxOptionId import controllers.AppError import controllers.AppError.OtoroshiError +import fr.maif.otoroshi.daikoku.audit.config.ElasticAnalyticsConfig import fr.maif.otoroshi.daikoku.domain.json.ActualOtoroshiApiKeyFormat -import fr.maif.otoroshi.daikoku.domain.{ActualOtoroshiApiKey, OtoroshiSettings} +import fr.maif.otoroshi.daikoku.domain.{ActualOtoroshiApiKey, ApiSubscription, OtoroshiSettings, Tenant, json} import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.logger.AppLogger import play.api.libs.json._ import play.api.libs.ws.{WSAuthScheme, WSRequest} import play.api.mvc._ @@ -285,4 +289,33 @@ class OtoroshiClient(env: Env) { } } } + + def getSubscriptionLastUsage(subscriptions: Seq[ApiSubscription])(implicit otoroshiSettings: OtoroshiSettings, tenant: Tenant): EitherT[Future, JsArray, JsArray] = { + otoroshiSettings.elasticConfig match { + case Some(value) => EitherT.pure[Future, JsArray](Json.arr()) + case None => for { + elasticConfig <- EitherT.fromOptionF(getElasticConfig(), Json.arr()) + log = AppLogger.warn(s"$elasticConfig") + updatedSettings = otoroshiSettings.copy(elasticConfig = elasticConfig.some) + updatedTenant = tenant.copy(otoroshiSettings = tenant.otoroshiSettings.filter(_.id != otoroshiSettings.id) + updatedSettings) + log2 = AppLogger.warn(s"$updatedSettings") + log3 = AppLogger.warn(s"${Json.prettyPrint(updatedTenant.asJson)}") + done <- EitherT.liftF(env.dataStore.tenantRepo.save(updatedTenant)) + log4 = AppLogger.warn(s"$done") + r <- getSubscriptionLastUsage(subscriptions)(updatedSettings, updatedTenant) + } yield r + } + } + + private def getElasticConfig()(implicit otoroshiSettings: OtoroshiSettings): Future[Option[ElasticAnalyticsConfig]] = { + client(s"/api/globalconfig").get().map(resp => { + if (resp.status == 200) { + val config = resp.json.as[JsObject] + val elasticReadConfig = (config \ "elasticReadsConfig").asOpt(ElasticAnalyticsConfig.format) + elasticReadConfig + } else { + None + } + }) + } } diff --git a/daikoku/conf/routes b/daikoku/conf/routes index e41c0635b..275599398 100644 --- a/daikoku/conf/routes +++ b/daikoku/conf/routes @@ -114,6 +114,7 @@ DELETE /api/teams/:teamId/subscriptions/_clean fr.maif.otoroshi.daiko POST /api/teams/:teamId/subscriptions/:id/_rotation fr.maif.otoroshi.daikoku.ctrls.ApiController.toggleApiKeyRotation(teamId, id) POST /api/teams/:teamId/subscriptions/:id/_refresh fr.maif.otoroshi.daikoku.ctrls.ApiController.regenerateApiKeySecret(teamId, id) POST /api/subscriptions/_init fr.maif.otoroshi.daikoku.ctrls.ApiController.initSubscriptions() +POST /api/teams/:teamId/subscriptions/_lastUsage fr.maif.otoroshi.daikoku.ctrls.ApiController.getApiSubscriptionsUsage(teamId) POST /api/apis/_init fr.maif.otoroshi.daikoku.ctrls.ApiController.initApis() GET /api/teams/:teamId/subscription/:id/informations fr.maif.otoroshi.daikoku.ctrls.ApiController.getSubscriptionInformations(teamId, id) GET /api/teams/:teamId/apis/:apiId/:version/subscriptions fr.maif.otoroshi.daikoku.ctrls.ApiController.getApiSubscriptions(teamId, apiId, version) diff --git a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx index 957c0def7..1bdefba86 100644 --- a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx +++ b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx @@ -89,6 +89,47 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { const { confirm, openFormModal, openSubMetadataModal, } = useContext(ModalContext); const plansQuery = useQuery(['plans'], () => Services.getAllPlanOfApi(api.team, api._id, api.currentVersion)) + const subscriptionsQuery = useQuery(['subscriptions'], () => client!.query<{ apiApiSubscriptions: Array; }>({ + query: Services.graphql.getApiSubscriptions, + fetchPolicy: "no-cache", + variables: { + apiId: api._id, + teamId: currentTeam._id, + version: api.currentVersion + } + }).then(({ data: { apiApiSubscriptions } }) => { + if (!filters || (!filters.tags.length && !Object.keys(filters.metadata).length && !filters.clientIds.length)) { + return apiApiSubscriptions + } else { + const filterByMetadata = (subscription: ApiSubscriptionGql) => { + const meta = { ...(subscription.metadata || {}), ...(subscription.customMetadata || {}) }; + + return !Object.keys(meta) || (!filters.metadata.length || filters.metadata.every(item => { + const value = meta[item.key] + return value && value.includes(item.value) + })) + } + + const filterByTags = (subscription: ApiSubscriptionGql) => { + return filters.tags.every(tag => subscription.tags.includes(tag)) + } + + const filterByClientIds = (subscription: ApiSubscriptionGql) => { + return filters.clientIds.includes(subscription.apiKey.clientId) + } + + return apiApiSubscriptions + .filter(filterByMetadata) + .filter(filterByTags) + .filter(filterByClientIds) + } + }) + ) + const lastUsagesQuery = useQuery({ + queryKey: ['usages'], + queryFn: () => Services.getSubscriptionsLastUsages(api.team, subscriptionsQuery.data?.map(s => s._id) || []), + enabled: !!subscriptionsQuery.data && !isError(subscriptionsQuery.data) + }) useEffect(() => { document.title = `${currentTeam.name} - ${translate('Subscriptions')}`; diff --git a/daikoku/javascript/src/components/frontend/team/MyHome.tsx b/daikoku/javascript/src/components/frontend/team/MyHome.tsx index 0bac74536..b3d1bb90f 100644 --- a/daikoku/javascript/src/components/frontend/team/MyHome.tsx +++ b/daikoku/javascript/src/components/frontend/team/MyHome.tsx @@ -7,7 +7,7 @@ import { useNavigate} from 'react-router-dom'; import { I18nContext, updateTeam } from '../../../core'; import * as Services from '../../../services'; import { converter } from '../../../services/showdown'; -import { IApiWithAuthorization, isError, IState, ITenant, IUserSimple } from '../../../types'; +import { IApiWithAuthorization, isError, IState, ITenant, IUsagePlan, IUserSimple } from '../../../types'; import { ApiList } from './ApiList'; import { api as API, CanIDoAction, manage, Spinner } from '../../utils'; diff --git a/daikoku/javascript/src/services/index.ts b/daikoku/javascript/src/services/index.ts index cd67af09c..4a15d0286 100644 --- a/daikoku/javascript/src/services/index.ts +++ b/daikoku/javascript/src/services/index.ts @@ -39,6 +39,7 @@ import { IApiKey, IOtoroshiApiKey, } from '../types/api'; +import { base64 } from 'js-md5'; const HEADERS = { Accept: 'application/json', @@ -1941,3 +1942,9 @@ export const cancelProcess = (teamId: string, demandId: string) => export const fetchInvoices = (teamId: string, apiId: string, planId: string, callback: string) => customFetch(`/api/teams/${teamId}/apis/${apiId}/plan/${planId}/invoices?callback=${callback}`); + +export const getSubscriptionsLastUsages = (teamId: string, subscriptions: Array): PromiseWithError => + customFetch(`/api/teams/${teamId}/subscriptions/_lastUsage`, { + method: 'POST', + body: JSON.stringify({subscriptions}) + })