diff --git a/daikoku/app/controllers/AdminApiController.scala b/daikoku/app/controllers/AdminApiController.scala index e29203604..e720a7300 100644 --- a/daikoku/app/controllers/AdminApiController.scala +++ b/daikoku/app/controllers/AdminApiController.scala @@ -1,9 +1,11 @@ package fr.maif.otoroshi.daikoku.ctrls +import cats.data.EitherT import org.apache.pekko.http.scaladsl.util.FastFuture import org.apache.pekko.stream.Materializer import org.apache.pekko.util.ByteString import cats.implicits._ +import controllers.AppError import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionContext} import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent import fr.maif.otoroshi.daikoku.ctrls.authorizations.async.DaikokuAdminOnly @@ -22,7 +24,7 @@ import play.api.mvc._ import storage.drivers.postgres.PostgresDataStore import storage.{DataStore, Repo} -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Using} class StateController(DaikokuAction: DaikokuAction, @@ -237,6 +239,12 @@ class TenantAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: Tenant): EitherT[Future, AppError, Tenant] = + EitherT(env.dataStore.tenantRepo.findOne(Json.obj("_id" -> Json.obj("$ne" -> entity.id.asJson), "domain" -> entity.domain)).map { + case Some(_) => Left(AppError.EntityConflict("tenant.domain already used")) + case None => Right(entity) + }) } class UserAdminApiController(daa: DaikokuApiAction, @@ -254,6 +262,12 @@ class UserAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: User): EitherT[Future, AppError, User] = + EitherT(env.dataStore.userRepo.findOne(Json.obj("_id" -> Json.obj("$ne" -> entity.id.asJson), "email" -> entity.email)).map { + case Some(_) => Left(AppError.EntityConflict("user.email already used")) + case None => Right(entity) + }) } class TeamAdminApiController(daa: DaikokuApiAction, @@ -261,16 +275,31 @@ class TeamAdminApiController(daa: DaikokuApiAction, cc: ControllerComponents) extends AdminApiController[Team, TeamId](daa, env, cc) { override def entityClass = classOf[Team] + override def entityName: String = "team" + override def pathRoot: String = s"/admin-api/${entityName}s" + override def entityStore(tenant: Tenant, ds: DataStore): Repo[Team, TeamId] = ds.teamRepo.forTenant(tenant) + override def toJson(entity: Team): JsValue = entity.asJson + override def fromJson(entity: JsValue): Either[String, Team] = TeamFormat .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: Team): EitherT[Future, AppError, Team] = { + import cats.implicits._ + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- entity.users.map(u => EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(u.userId), AppError.UserNotFound)) + .toList + .sequence + } yield entity + } } class ApiAdminApiController(daa: DaikokuApiAction, @@ -288,6 +317,33 @@ class ApiAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: Api): EitherT[Future, AppError, Api] = { + import cats.implicits._ + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- entity.possibleUsagePlans.map(planId => EitherT.fromOptionF[Future, AppError, UsagePlan](env.dataStore.usagePlanRepo.forTenant(entity.tenant).findById(planId), AppError.EntityNotFound(s"Usage Plan (${planId.value})"))) + .toList + .sequence + _ <- EitherT.cond[Future][AppError, Unit](entity.possibleUsagePlans.contains(entity.defaultUsagePlan), (), AppError.EntityNotFound(s"Default Usage Plan (${entity.defaultUsagePlan.value})")) + _ <- EitherT.fromOptionF[Future, AppError, Team](env.dataStore.teamRepo.forTenant(entity.tenant).findById(entity.team), AppError.TeamNotFound) + _ <- EitherT.fromOptionF[Future, AppError, Team](env.dataStore.teamRepo.forTenant(entity.tenant).findById(entity.team), AppError.TeamNotFound) + _ <- EitherT.fromOptionF[Future, AppError, Team](env.dataStore.teamRepo.forTenant(entity.tenant).findOne(Json.obj("_id" -> Json.obj("$ne" -> entity.id.asJson), "name" -> entity.name)), AppError.EntityConflict("Team name already exists")) + _ <- entity.documentation.pages.map(_.id).map(pageId => EitherT.fromOptionF[Future, AppError, ApiDocumentationPage](env.dataStore.apiDocumentationPageRepo.forTenant(entity.tenant).findById(pageId), AppError.EntityNotFound(s"Documentation page (${pageId.value})"))) + .toList + .sequence + _ <- entity.parent match { + case Some(api) => EitherT.fromOptionF[Future, AppError, Api](env.dataStore.apiRepo.forTenant(entity.tenant).findById(api), AppError.EntityNotFound("parent API")) + case None => EitherT.pure[Future, AppError](()) + } + _ <- entity.apis match { + case Some(apis) => apis.map(api => EitherT.fromOptionF[Future, AppError, Api](env.dataStore.apiRepo.forTenant(entity.tenant).findById(api), AppError.EntityNotFound(s"api as children (${api.value})"))) + .toList + .sequence + case None => EitherT.pure[Future, AppError](Seq.empty[Api]) + } + } yield entity + } } class ApiSubscriptionAdminApiController(daa: DaikokuApiAction, @@ -307,6 +363,22 @@ class ApiSubscriptionAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: ApiSubscription): EitherT[Future, AppError, ApiSubscription] = { + import cats.implicits._ + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- EitherT.fromOptionF[Future, AppError, UsagePlan](env.dataStore.usagePlanRepo.forTenant(entity.tenant).findById(entity.plan), AppError.PlanNotFound) + _ <- EitherT.fromOptionF[Future, AppError, Team](env.dataStore.teamRepo.forTenant(entity.tenant).findById(entity.team), AppError.TeamNotFound) + _ <- EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(entity.by), AppError.UserNotFound) + _ <- entity.parent match { + case Some(parent) => EitherT.fromOptionF[Future, AppError, ApiSubscription](env.dataStore.apiSubscriptionRepo.forTenant(entity.tenant).findById(parent), AppError.EntityNotFound(s"parent subscription (${parent.value})")) + .map(_ => ()) + case None => EitherT.pure[Future, AppError](()) + } + + } yield entity + } } class ApiDocumentationPageAdminApiController(daa: DaikokuApiAction, @@ -329,6 +401,10 @@ class ApiDocumentationPageAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: ApiDocumentationPage): EitherT[Future, AppError, ApiDocumentationPage] = + EitherT.pure[Future, AppError](entity) + } class NotificationAdminApiController(daa: DaikokuApiAction, @@ -347,6 +423,11 @@ class NotificationAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: Notification): EitherT[Future, AppError, Notification] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + } yield entity } class UserSessionAdminApiController(daa: DaikokuApiAction, @@ -365,6 +446,11 @@ class UserSessionAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: UserSession): EitherT[Future, AppError, UserSession] = + for { + _ <- EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(entity.userId), AppError.UserNotFound) + } yield entity } class ApiKeyConsumptionAdminApiController(daa: DaikokuApiAction, @@ -384,6 +470,13 @@ class ApiKeyConsumptionAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: ApiKeyConsumption): EitherT[Future, AppError, ApiKeyConsumption] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- EitherT.fromOptionF[Future, AppError, UsagePlan](env.dataStore.usagePlanRepo.forTenant(entity.tenant).findById(entity.plan), AppError.PlanNotFound) + _ <- EitherT.fromOptionF[Future, AppError, Api](env.dataStore.apiRepo.forTenant(entity.tenant).findById(entity.api), AppError.ApiNotFound) + } yield entity } class AuditEventAdminApiController(daa: DaikokuApiAction, @@ -402,6 +495,9 @@ class AuditEventAdminApiController(daa: DaikokuApiAction, case Some(v) => Right(v) case None => Left("Not an object") } + + override def validate(entity: JsObject): EitherT[Future, AppError, JsObject] = + EitherT.pure[Future, AppError](entity) } class CredentialsAdminApiController(DaikokuApiAction: DaikokuApiAction, @@ -438,6 +534,15 @@ class MessagesAdminApiController(daa: DaikokuApiAction, case Some(v) => Right(entity.as(json.MessageFormat)) case None => Left("Not an object") } + + override def validate(entity: Message): EitherT[Future, AppError, Message] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(entity.sender), AppError.EntityNotFound(s"sender (${entity.sender.value}")) + _ <- entity.participants.map(u => EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(u), AppError.EntityNotFound(s"participant (${u.value})"))) + .toList + .sequence + } yield entity } class IssuesAdminApiController(daa: DaikokuApiAction, @@ -456,6 +561,12 @@ class IssuesAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: ApiIssue): EitherT[Future, AppError, ApiIssue] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(entity.by), AppError.UserNotFound) + } yield entity } class PostsAdminApiController(daa: DaikokuApiAction, @@ -474,6 +585,11 @@ class PostsAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: ApiPost): EitherT[Future, AppError, ApiPost] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + } yield entity } class CmsPagesAdminApiController(daa: DaikokuApiAction, @@ -492,6 +608,11 @@ class CmsPagesAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: CmsPage): EitherT[Future, AppError, CmsPage] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + } yield entity } class TranslationsAdminApiController(daa: DaikokuApiAction, @@ -510,6 +631,11 @@ class TranslationsAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: Translation): EitherT[Future, AppError, Translation] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + } yield entity } class UsagePlansAdminApiController(daa: DaikokuApiAction, @@ -528,6 +654,19 @@ class UsagePlansAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: UsagePlan): EitherT[Future, AppError, UsagePlan] = + for { + tenant <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- entity.otoroshiTarget match { + case Some(target) => EitherT.cond[Future][AppError, Unit](tenant.otoroshiSettings.map(_.id).contains(target.otoroshiSettings), (), AppError.EntityNotFound(s"Otoroshi setting (${target.otoroshiSettings.value})")) + case None => EitherT.pure[Future, AppError](()) + } + _ <- entity.paymentSettings match { + case Some(target) => EitherT.cond[Future][AppError, Unit](tenant.thirdPartyPaymentSettings.map(_.id).contains(target.thirdPartyPaymentSettingsId), (), AppError.EntityNotFound(s"Otororoshi setting (${target.thirdPartyPaymentSettingsId.value})")) + case None => EitherT.pure[Future, AppError](()) + } + } yield entity } class SubscriptionDemandsAdminApiController(daa: DaikokuApiAction, @@ -549,71 +688,21 @@ class SubscriptionDemandsAdminApiController(daa: DaikokuApiAction, .reads(entity) .asEither .leftMap(_.flatMap(_._2).map(_.message).mkString(", ")) + + override def validate(entity: SubscriptionDemand): EitherT[Future, AppError, SubscriptionDemand] = + for { + _ <- EitherT.fromOptionF[Future, AppError, Tenant](env.dataStore.tenantRepo.findById(entity.tenant), AppError.TenantNotFound) + _ <- EitherT.fromOptionF[Future, AppError, Api](env.dataStore.apiRepo.forTenant(entity.tenant).findById(entity.api), AppError.ApiNotFound) + _ <- EitherT.fromOptionF[Future, AppError, UsagePlan](env.dataStore.usagePlanRepo.forTenant(entity.tenant).findById(entity.plan), AppError.PlanNotFound) + _ <- EitherT.fromOptionF[Future, AppError, Team](env.dataStore.teamRepo.forTenant(entity.tenant).findById(entity.team), AppError.TeamNotFound) + _ <- EitherT.fromOptionF[Future, AppError, User](env.dataStore.userRepo.findById(entity.from), AppError.UserNotFound) + } yield entity } class AdminApiSwaggerController( - env: Env, cc: ControllerComponents, - ctrl1: TenantAdminApiController, - ctrl2: UserAdminApiController, - ctrl3: TeamAdminApiController, - ctrl4: ApiAdminApiController, - ctrl5: ApiSubscriptionAdminApiController, - ctrl6: ApiDocumentationPageAdminApiController, - ctrl7: NotificationAdminApiController, - ctrl8: UserSessionAdminApiController, - ctrl9: ApiKeyConsumptionAdminApiController, - ctrl10: AuditEventAdminApiController, - ctrl11: MessagesAdminApiController, - ctrl12: IssuesAdminApiController, - ctrl13: PostsAdminApiController, - ctrl14: TranslationsAdminApiController, - ctrl15: UsagePlansAdminApiController, - ctrl16: SubscriptionDemandsAdminApiController ) extends AbstractController(cc) { - def schema[A, B <: ValueType]( - controller: AdminApiController[A, B]): JsObject = - controller.openApiComponent(env) - def path[A, B <: ValueType](controller: AdminApiController[A, B]): JsObject = - controller.openApiPath(env) - - def schemas: JsValue = - schema(ctrl1) ++ - schema(ctrl2) ++ - schema(ctrl3) ++ - schema(ctrl4) ++ - schema(ctrl5) ++ - schema(ctrl6) ++ - schema(ctrl7) ++ - schema(ctrl8) ++ - schema(ctrl9) ++ - schema(ctrl10) ++ - schema(ctrl11) ++ - schema(ctrl12) ++ - schema(ctrl13) ++ - schema(ctrl14) ++ - schema(ctrl15) ++ - schema(ctrl16) - - def paths: JsValue = - path(ctrl1) ++ - path(ctrl2) ++ - path(ctrl3) ++ - path(ctrl4) ++ - path(ctrl5) ++ - path(ctrl6) ++ - path(ctrl7) ++ - path(ctrl8) ++ - path(ctrl9) ++ - path(ctrl10) ++ - path(ctrl11) ++ - path(ctrl12) ++ - path(ctrl13) ++ - path(ctrl14) ++ - path(ctrl15) ++ - path(ctrl16) ++ - ctrl1.pathForIntegrationApi() def swagger() = Action { Using(scala.io.Source.fromResource("public/swaggers/admin-api-openapi.json")) { @@ -629,35 +718,4 @@ class AdminApiSwaggerController( ) } } - - def _swagger() = Action { - Ok( - Json.obj( - "openapi" -> "3.0.1", - "externalDocs" -> Json.obj( - "description" -> "Find out more about Daikoku", - "url" -> "https://maif.github.io/Daikoku/" - ), - "info" -> Json.obj( - "license" -> Json.obj( - "name" -> "Apache 2.0", - "url" -> "http://www.apache.org/licenses/LICENSE-2.0.html" - ), - "contact" -> Json.obj( - "name" -> "Daikoku Team", - "email" -> "oss@maif.fr" - ), - "description" -> "Admin API of Daikoku", - "title" -> "Daikoku Admin API", - "version" -> "1.0.0-dev" - ), - "tags" -> Json.arr(), - "components" -> Json.obj( - "schemas" -> schemas - ), - "paths" -> paths - )).withHeaders( - "Access-Control-Allow-Origin" -> "*" - ) - } } diff --git a/daikoku/app/utils/admin.scala b/daikoku/app/utils/admin.scala index c269e6074..a088e7fc3 100644 --- a/daikoku/app/utils/admin.scala +++ b/daikoku/app/utils/admin.scala @@ -1,26 +1,25 @@ package fr.maif.otoroshi.daikoku.utils.admin -import java.util.Base64 -import org.apache.pekko.http.scaladsl.util.FastFuture -import org.apache.pekko.stream.scaladsl.Source -import org.apache.pekko.util.ByteString +import cats.data.EitherT import com.auth0.jwt.JWT import com.google.common.base.Charsets -import fr.maif.otoroshi.daikoku.domain.{Tenant, ValueType, json} +import controllers.AppError +import fr.maif.otoroshi.daikoku.domain.{Tenant, ValueType} import fr.maif.otoroshi.daikoku.env.{Env, LocalAdminApiConfig, OtoroshiAdminApiConfig} -import fr.maif.otoroshi.daikoku.logger.AppLogger import fr.maif.otoroshi.daikoku.login.TenantHelper import fr.maif.otoroshi.daikoku.utils.Errors -import org.joda.time.DateTime +import org.apache.pekko.http.scaladsl.util.FastFuture +import org.apache.pekko.stream.scaladsl.Source +import org.apache.pekko.util.ByteString import play.api.Logger import play.api.http.HttpEntity import play.api.libs.json._ import play.api.mvc._ import storage.{DataStore, Repo} -import scala.collection.concurrent.TrieMap +import java.util.Base64 import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} +import scala.util.{Success, Try} case class DaikokuApiActionContext[A](request: Request[A], tenant: Tenant) @@ -32,7 +31,7 @@ class DaikokuApiAction(val parser: BodyParser[AnyContent], env: Env) def decodeBase64(encoded: String): String = new String(Base64.getUrlDecoder.decode(encoded), Charsets.UTF_8) - def extractUsernamePassword(header: String): Option[(String, String)] = { + private def extractUsernamePassword(header: String): Option[(String, String)] = { val base64 = header.replace("Basic ", "").replace("basic ", "") Option(base64) .map(decodeBase64) @@ -48,11 +47,10 @@ class DaikokuApiAction(val parser: BodyParser[AnyContent], env: Env) env.config.adminApiConfig match { case OtoroshiAdminApiConfig(headerName, algo) => request.headers.get(headerName) match { - case Some(value) => { + case Some(value) => Try(JWT.require(algo).build().verify(value)) match { - case Success(decoded) if !decoded.getClaim("apikey").isNull => { + case Success(decoded) if !decoded.getClaim("apikey").isNull => block(DaikokuApiActionContext[A](request, tenant)) - } case _ => Errors.craftResponseResult("No api key provided", Results.Unauthorized, @@ -60,7 +58,6 @@ class DaikokuApiAction(val parser: BodyParser[AnyContent], env: Env) None, env) } - } case _ => Errors.craftResponseResult("No api key provided", Results.Unauthorized, @@ -123,25 +120,20 @@ class DaikokuApiActionWithoutTenant(val parser: BodyParser[AnyContent], env.config.adminApiConfig match { case OtoroshiAdminApiConfig(headerName, algo) => request.headers.get(headerName) match { - case Some(value) => { + case Some(value) => Try(JWT.require(algo).build().verify(value)) match { - case Success(decoded) if !decoded.getClaim("apikey").isNull => { - block(request) - } - case _ => - Errors.craftResponseResult("No api key provided", - Results.Unauthorized, - request, - None, - env) + case Success(decoded) if !decoded.getClaim("apikey").isNull => block(request) + case _ => Errors.craftResponseResult("No api key provided", + Results.Unauthorized, + request, + None, + env) } - } - case _ => - Errors.craftResponseResult("No api key provided", - Results.Unauthorized, - request, - None, - env) + case _ => Errors.craftResponseResult("No api key provided", + Results.Unauthorized, + request, + None, + env) } case LocalAdminApiConfig(keyValue) => request @@ -179,8 +171,9 @@ abstract class AdminApiController[Of, Id <: ValueType]( def toJson(entity: Of): JsValue def fromJson(entity: JsValue): Either[String, Of] def entityClass: Class[Of] + def validate(entity: Of): EitherT[Future, AppError, Of] - def findAll() = DaikokuApiAction.async { ctx => + def findAll(): Action[AnyContent] = DaikokuApiAction.async { ctx => val paginationPage: Int = ctx.request.queryString .get("page") .flatMap(_.headOption) @@ -194,59 +187,56 @@ abstract class AdminApiController[Of, Id <: ValueType]( .getOrElse(Int.MaxValue) val paginationPosition = (paginationPage - 1) * paginationPageSize val allEntities = - ctx.request.queryString.get("notDeleted").exists(_ == "true") match { - case true => entityStore(ctx.tenant, env.dataStore).findAllNotDeleted() - case false => entityStore(ctx.tenant, env.dataStore).findAll() + if (ctx.request.queryString.get("notDeleted").exists(_.contains("true"))) { + entityStore(ctx.tenant, env.dataStore).findAllNotDeleted() + } else { + entityStore(ctx.tenant, env.dataStore).findAll() } allEntities .map( all => - all - .drop(paginationPosition) - .take(paginationPageSize) + all.slice(paginationPosition, paginationPosition + paginationPageSize) .map(entity => toJson(entity))) .map { all => - ctx.request.queryString.get("stream").exists(_ == "true") match { - case true => - Ok.sendEntity( - HttpEntity.Streamed( - Source(all.map(a => ByteString(Json.stringify(a))).toList), - None, - Some("application/x-ndjson"))) - case false => Ok(JsArray(all)) + if (ctx.request.queryString.get("stream").exists(_.contains("true"))) { + Ok.sendEntity( + HttpEntity.Streamed( + Source(all.map(a => ByteString(Json.stringify(a))).toList), + None, + Some("application/x-ndjson"))) + } else { + Ok(JsArray(all)) } } } - def findById(id: String) = DaikokuApiAction.async { ctx => - println("hi") + def findById(id: String): Action[AnyContent] = DaikokuApiAction.async { ctx => val notDeleted: Boolean = - ctx.request.queryString.get("notDeleted").exists(_ == "true") - notDeleted match { - case true => - entityStore(ctx.tenant, env.dataStore).findByIdNotDeleted(id).flatMap { - case Some(entity) => FastFuture.successful(Ok(toJson(entity))) - case None => - Errors.craftResponseResult(s"$entityName not found", - Results.NotFound, - ctx.request, - None, - env) - } - case false => - entityStore(ctx.tenant, env.dataStore).findById(id).flatMap { - case Some(entity) => FastFuture.successful(Ok(toJson(entity))) - case None => - Errors.craftResponseResult(s"$entityName not found", - Results.NotFound, - ctx.request, - None, - env) - } + ctx.request.queryString.get("notDeleted").exists(_.contains("true")) + if (notDeleted) { + entityStore(ctx.tenant, env.dataStore).findByIdNotDeleted(id).flatMap { + case Some(entity) => FastFuture.successful(Ok(toJson(entity))) + case None => + Errors.craftResponseResult(s"$entityName not found", + Results.NotFound, + ctx.request, + None, + env) + } + } else { + entityStore(ctx.tenant, env.dataStore).findById(id).flatMap { + case Some(entity) => FastFuture.successful(Ok(toJson(entity))) + case None => + Errors.craftResponseResult(s"$entityName not found", + Results.NotFound, + ctx.request, + None, + env) + } } } - def createEntity() = DaikokuApiAction.async(parse.json) { ctx => + def createEntity(): Action[JsValue] = DaikokuApiAction.async(parse.json) { ctx => fromJson(ctx.request.body) match { case Left(e) => logger.error(s"Bad $entityName format", new RuntimeException(e)) @@ -256,13 +246,17 @@ abstract class AdminApiController[Of, Id <: ValueType]( None, env) case Right(newEntity) => - entityStore(ctx.tenant, env.dataStore) - .save(newEntity) - .map(_ => Created(toJson(newEntity))) + validate(newEntity) + .map(entity => entityStore(ctx.tenant, env.dataStore) + .save(entity) + .map(_ => Created(toJson(entity)))) + .leftMap(_.renderF()) + .merge.flatten + } } - def updateEntity(id: String) = DaikokuApiAction.async(parse.json) { ctx => + def updateEntity(id: String): Action[JsValue] = DaikokuApiAction.async(parse.json) { ctx => entityStore(ctx.tenant, env.dataStore).findById(id).flatMap { case None => Errors.craftResponseResult(s"Entity $entityName not found", @@ -270,7 +264,7 @@ abstract class AdminApiController[Of, Id <: ValueType]( ctx.request, None, env) - case Some(entity) => { + case Some(_) => fromJson(ctx.request.body) match { case Left(e) => logger.error(s"Bad $entityName format", new RuntimeException(e)) @@ -279,31 +273,26 @@ abstract class AdminApiController[Of, Id <: ValueType]( ctx.request, None, env) - case Right(newEntity) => { - entityStore(ctx.tenant, env.dataStore) - .save(newEntity) - .map(_ => Ok(toJson(newEntity))) - } + case Right(newEntity) => + validate(newEntity) + .map(entity => entityStore(ctx.tenant, env.dataStore) + .save(entity) + .map(_ => NoContent)) + .leftMap(_.renderF()) + .merge.flatten } - } } } - def patchEntity(id: String) = DaikokuApiAction.async(parse.json) { ctx => - object JsonImplicits { - implicit val jodaDateTimeWrites: Writes[DateTime] = - json.DateTimeFormat.writes - implicit val jodaDateTimeReads: Reads[DateTime] = - json.DateTimeFormat.reads - } - + def patchEntity(id: String): Action[JsValue] = DaikokuApiAction.async(parse.json) { ctx => object JsonPatchHelpers { import diffson.playJson.DiffsonProtocol._ import play.api.libs.json._ def patchJson(patchOps: JsValue, document: JsValue): JsValue = { - val patch = - diffson.playJson.DiffsonProtocol.JsonPatchFormat.reads(patchOps).get + logger.warn(Json.stringify(patchOps)) + val patch = diffson.playJson.DiffsonProtocol.JsonPatchFormat.reads(patchOps).get + logger.warn(s"$patch") patch.apply(document).get } } @@ -326,10 +315,13 @@ abstract class AdminApiController[Of, Id <: ValueType]( ctx.request, None, env) - case Right(newNewEntity) => - entityStore(ctx.tenant, env.dataStore) - .save(newNewEntity) - .map(_ => Ok(toJson(newNewEntity))) + case Right(patchedEntity) => + validate(patchedEntity) + .map(entity => entityStore(ctx.tenant, env.dataStore) + .save(entity) + .map(_ => NoContent)) + .leftMap(_.renderF()) + .merge.flatten } } @@ -362,859 +354,16 @@ abstract class AdminApiController[Of, Id <: ValueType]( value } - def deleteEntity(id: String) = DaikokuApiAction.async { ctx => - ctx.request.queryString.get("logically").exists(_ == "true") match { - case true => - entityStore(ctx.tenant, env.dataStore) - .deleteByIdLogically(id) - .map(_ => Ok(Json.obj("done" -> true))) - case false => - entityStore(ctx.tenant, env.dataStore) - .deleteById(id) - .map(_ => Ok(Json.obj("done" -> true))) - } - } - - private def transformType(fieldName: String, - fieldType: String, - fieldGeneric: Option[String], - missing: TrieMap[String, Unit], - required: TrieMap[String, Unit]): JsObject = { - val f = fieldType - required.putIfAbsent(fieldName, ()) - f match { - case "int" => Json.obj("type" -> "integer", "format" -> "int32") - case "long" => Json.obj("type" -> "integer", "format" -> "int64") - case "double" => Json.obj("type" -> "number", "format" -> "float64") - case "scala.math.BigDecimal" => - Json.obj("type" -> "number", "format" -> "float64") - case "scala.math.BigInteger" => - Json.obj("type" -> "integer", "format" -> "int64") - case "play.api.libs.json.JsObject" => Json.obj("type" -> "object") - case "play.api.libs.json.JsArray" => Json.obj("type" -> "array") - case "org.joda.time.DateTime" => - Json.obj("type" -> "integer", "format" -> "timestamp") - case "java.lang.String" => Json.obj("type" -> "string") - case "scala.concurrent.duration.FiniteDuration" => - Json.obj("type" -> "integer", "format" -> "int64") - case "scala.collection.immutable.Set" => - fieldGeneric match { - case None => Json.obj("type" -> "any") - case Some(_generic) => - val generic = _generic.replace(">", "").split("\\<").toSeq.apply(1) - Json.obj("type" -> "array", - "items" -> transformType(fieldName, - generic, - None, - missing, - required)) - } - case "scala.collection.immutable.Seq" => - fieldGeneric match { - case None => Json.obj("type" -> "any") - case Some(_generic) => - val generic = _generic.replace(">", "").split("\\<").toSeq.apply(1) - Json.obj("type" -> "array", - "items" -> transformType(fieldName, - generic, - None, - missing, - required)) - } - case "scala.collection.immutable.Map" => Json.obj("type" -> "object") - case "scala.Option" => - fieldGeneric match { - case None => Json.obj("type" -> "any") - case Some(_generic) => - required.remove(fieldName) - val generic = _generic.replace(">", "").split("\\<").toSeq.apply(1) - transformType(fieldName, generic, None, missing, required) - } - case "java.lang.Object" if fieldName == "avgDuration" => - Json.obj("type" -> "number", "format" -> "float64") - case "java.lang.Object" if fieldName == "avgOverhead" => - Json.obj("type" -> "number", "format" -> "float64") - case "java.lang.Object" => - Json.obj("type" -> "object") //todo find a better way to get warrped type By scala.Option https://stackoverflow.com/questions/54914316/get-type-of-primitive-field-from-an-object-using-scala-reflection - case str - if str.startsWith("fr.maif.otoroshi.daikoku.domain") && str.endsWith( - "Id") => - Json.obj("type" -> "string") - case str if str.startsWith("fr.maif.otoroshi.daikoku") => - missing.putIfAbsent(str, ()) - Json.obj("$ref" -> s"#/components/schemas/${str.replace("$", ".")}") - case _ => Json.obj("type" -> f) - - } - } - - private def properties(clazz: Class[_], - missing: TrieMap[String, Unit], - required: TrieMap[String, Unit]): JsObject = { - val fields = (clazz.getDeclaredFields.toSeq).map { f => - val a = transformType(f.getName, - f.getType.getName, - Option(f.getGenericType).map(_.getTypeName), - missing, - required) // ++ Json.obj("description" -> "--") - Json.obj(f.getName -> a) - } - fields.foldLeft(Json.obj())(_ ++ _) - } - - private def notFound: String = s"""{ - | "description": "entity not found", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/error" - | } - | } - | } - |}""".stripMargin - - private def badFormat: String = s"""{ - | "description": "bad entity format", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/error" - | } - | } - | } - |}""".stripMargin - - private def unauthorized: String = s"""{ - | "description": "unauthorized", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/error" - | } - | } - | } - |}""".stripMargin - - private def computeRef(ref: String): String = { - ref match { - case "play.api.libs.json.JsObject" => "object" - case _ => ref - } - } - - def openApiPath(implicit env: Env): JsObject = Json.obj( - s"$pathRoot/{id}" -> Json.obj( - "delete" -> Json.parse(s"""{ - | "summary": "delete a $entityName", - | "operationId": "${entityName}s.delete", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "entity deleted", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/done" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "id", - | "required": true - | } - | ], - | "tags": [ - | "$entityName" - | ] - |} - """.stripMargin), - "patch" -> Json.parse(s"""{ - | "summary": "update a $entityName with JSON patch or by merging JSON object", - | "operationId": "${entityName}s.patch", - | "requestBody": { - | "description": "the patch to update the $entityName or a JSON object", - | "required": true, - | "content": { - | "application/json": { - | "schema": { - | "oneOf": [ - | {"$$ref": "#/components/schemas/patch"}, - | {"$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}"} - | ] - | } - | } - | } - | }, - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "updated entity", - | "content": { - | "application/json": { - | "schema": { - | "type": "object", - | "items": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "id", - | "required": true - | } - | ], - | "tags": [ - | "$entityName" - | ] - |} - """.stripMargin), - "put" -> Json.parse(s"""{ - | "summary": "update a $entityName", - | "operationId": "${entityName}s.update", - | "requestBody": { - | "description": "the $entityName to update", - | "required": true, - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - | }, - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "updated entity", - | "content": { - | "application/json": { - | "schema": { - | "type": "object", - | "items": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "id", - | "required": true - | } - | ], - | "tags": [ - | "$entityName" - | ] - |} - """.stripMargin), - "get" -> Json.parse(s"""{ - | "summary": "read a $entityName", - | "operationId": "${entityName}s.findById", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "found entity", - | "content": { - | "application/json": { - | "schema": { - | "type": "object", - | "items": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "id", - | "required": true - | } - | ], - | "tags": [ - | "$entityName" - | ] - |} - """.stripMargin), - ), - s"$pathRoot" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "read all $entityName", - |"operationId": "${entityName}s.findAll", - |"responses": { - | "401": $unauthorized, - | "200": { - | "description": "success", - | "content": { - | "application/json": { - | "schema": { - | "type": "array", - | "items": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - | } - | } - |}, - |"tags": [ - | "$entityName" - |]} - """.stripMargin), - "post" -> Json.parse(s"""{ - |"summary": "creates a $entityName", - |"requestBody": { - | "description": "the $entityName to create", - | "required": true, - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - |}, - |"operationId": "${entityName}s.create", - |"responses": { - | "401": $unauthorized, - | "400": $badFormat, - | "201": { - | "description": "entity created", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/${computeRef( - entityClass.getName)}" - | } - | } - | } - | } - |}, - |"tags": [ - | "$entityName" - |]} - """.stripMargin), - ) - ) - - def openApiComponent(implicit env: Env): JsObject = { - val clazz = entityClass - val required = new TrieMap[String, Unit]() - val missing = new TrieMap[String, Unit]() - val cache = new TrieMap[String, JsObject]() - if (clazz.getName == "play.api.libs.json.JsObject") { - Json.obj() + def deleteEntity(id: String): Action[AnyContent] = DaikokuApiAction.async { ctx => + if (ctx.request.queryString.get("logically").exists(_.contains("true"))) { + entityStore(ctx.tenant, env.dataStore) + .deleteByIdLogically(id) + .map(_ => Ok(Json.obj("done" -> true))) } else { - val entity = Json.obj( - clazz.getName -> cache.getOrElseUpdate( - clazz.getName, - Json.obj( - "description" -> description, - "properties" -> properties(entityClass, missing, required) - ) ++ (if (required.isEmpty) { - Json.obj() - } else { - Json.obj( - "required" -> JsArray( - required.keySet.map(JsString.apply).toSeq) - ) - }) - ) - ) - def findMissing(miss: Set[String]): JsObject = { - val requiredd = new TrieMap[String, Unit]() - val missingg = new TrieMap[String, Unit]() - val res = miss - .map { str => - val clazzzz = env.environment.classLoader.loadClass(str) - val ne: JsObject = cache.getOrElseUpdate( - str, - Json.obj( - "description" -> str, // TODO: find actual desc - "properties" -> properties(clazzzz, missingg, requiredd) - ) ++ (if (requiredd.isEmpty) { - Json.obj() - } else { - Json.obj( - "required" -> JsArray( - requiredd.keySet.map(JsString.apply).toSeq) - ) - }) - ) - Json.obj(str -> ne) - } - .foldLeft(Json.obj())(_ ++ _) - if (missingg.isEmpty) { - res - } else { - res ++ findMissing(missingg.keySet.toSet) - } - } - - entity ++ Json.obj("object" -> Json.obj("type" -> "object")) ++ Json - .parse("""{ - | "done": { - | "description": "task is done", - | "properties": { - | "done": { - | "type": "boolean" - | } - | } - | } - |} - """.stripMargin) - .as[JsObject] ++ Json.parse("""{ - | "error": { - | "description": "error response", - | "properties": { - | "error": { - | "type": "string" - | } - | } - | } - |} - """.stripMargin).as[JsObject] ++ Json.obj( - "patch" -> Json.obj( - "description" -> "A set of changes described in JSON Patch format: http://jsonpatch.com/ (RFC 6902)", - "type" -> "array", - "items" -> Json.obj( - "type" -> "object", - "required" -> Json.arr("op", "path"), - "properties" -> Json.obj( - "op" -> Json.obj( - "type" -> "string", - "enum" -> Json.arr("add", "replace", "remove", "copy", "test") - ), - "path" -> Json.obj("type" -> "string"), - "value" -> Json.obj() - ) - ) - )) ++ findMissing(missing.keySet.toSet) + entityStore(ctx.tenant, env.dataStore) + .deleteById(id) + .map(_ => Ok(Json.obj("done" -> true))) } } - def pathForIntegrationApi(): JsObject = { - Json.obj( - //todo: replace with real schema - s"integration-api/apis" -> Json.obj( - "get" -> Json.parse(s"""{ - | "summary": "get all public apis for integration", - | "operationId": "integration-api.findallapis", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "List of public APIs", - | "content": { - | "application/json": { - | "schema": { - | "type": "array", - | "items": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Api" - | } - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |} - """.stripMargin) - ), - //todo: replace with real schema - s"integration-api/{teamId}" -> Json.obj( - "get" -> Json.parse(s"""{ - | "summary": "get all teams for integration", - | "operationId": "integration-api.findallateams", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "List of teams", - | "content": { - | "application/json": { - | "schema": { - | "type": "array", - | "items" : { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Team" - | } - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - //todo: replace with real schema - s"integration-api/{teamId}/{apiId}/complete" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "get complete API for integration", - | "operationId": "integration-api.findcompleteapi", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "Complete API", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Team" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "apiId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - //todo: replace with real schema - s"integration-api/{teamId}/{apiId}/description" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "get API description", - | "operationId": "integration-api.findapidescription", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "API description", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Api" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "apiId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - //todo: replace with real schema - s"integration-api/{teamId}/{apiId}/plans" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "get API plans", - | "operationId": "integration-api.findapiplans", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "API plans", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Api" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "apiId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - //todo: update schema - s"integration-api/{teamId}/{apiId}/documentation" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "get API documentation", - | "operationId": "integration-api.findapidocumentation", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "API documentation", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Api" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "apiId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - //todo: replace with real schema - s"integration-api/{teamId}/{apiId}/apidoc" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "get API description", - | "operationId": "integration-api.findapidoc", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "API doc (swagger)", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Api" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "apiId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - //todo: replace with real schema - s"integration-api/{teamId}/{apiId}" -> Json.obj( - "get" -> Json.parse(s"""{ - |"summary": "get API", - | "operationId": "integration-api.findapi", - | "responses": { - | "401": $unauthorized, - | "404": $notFound, - | "200": { - | "description": "API", - | "content": { - | "application/json": { - | "schema": { - | "$$ref": "#/components/schemas/fr.maif.otoroshi.daikoku.domain.Api" - | } - | } - | } - | } - | }, - | "parameters": [ - | { - | "schema": { - | "type": "string" - | }, - | "in": "header", - | "name": "X-Personal-Token", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "teamId", - | "required": true - | }, - | { - | "schema": { - | "type": "string" - | }, - | "in": "path", - | "name": "apiId", - | "required": true - | } - | ], - | "tags": [ - | "integration-apis" - | ] - |}""".stripMargin) - ), - ) - } } diff --git a/daikoku/conf/routes b/daikoku/conf/routes index 8577b91f0..2216f2682 100644 --- a/daikoku/conf/routes +++ b/daikoku/conf/routes @@ -349,7 +349,6 @@ POST /admin-api/state/import fr.maif.otoroshi.daikoku DELETE /admin-api/tenants/:id fr.maif.otoroshi.daikoku.ctrls.TenantAdminApiController.deleteEntity(id) PATCH /admin-api/tenants/:id fr.maif.otoroshi.daikoku.ctrls.TenantAdminApiController.patchEntity(id) PUT /admin-api/tenants/:id fr.maif.otoroshi.daikoku.ctrls.TenantAdminApiController.updateEntity(id) - GET /admin-api/tenants/:id fr.maif.otoroshi.daikoku.ctrls.TenantAdminApiController.findById(id) POST /admin-api/tenants fr.maif.otoroshi.daikoku.ctrls.TenantAdminApiController.createEntity() GET /admin-api/tenants fr.maif.otoroshi.daikoku.ctrls.TenantAdminApiController.findAll() diff --git a/daikoku/public/swaggers/admin-api-openapi.json b/daikoku/public/swaggers/admin-api-openapi.json index 1dfac1d21..101313f66 100644 --- a/daikoku/public/swaggers/admin-api-openapi.json +++ b/daikoku/public/swaggers/admin-api-openapi.json @@ -8835,10 +8835,10 @@ ] } }, - "/integration-api/apis": { + "integration-api/apis": { "get": { "summary": "get all public apis for integration", - "operationId": "/integration-api.findallapis", + "operationId": "integration-api.findallapis", "responses": { "200": { "description": "List of public APIs", @@ -8885,14 +8885,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}": { + "integration-api/{teamId}": { "get": { "summary": "get all teams for integration", - "operationId": "/integration-api.findallateams", + "operationId": "integration-api.findallateams", "responses": { "200": { "description": "List of teams", @@ -8947,14 +8947,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}/{apiId}/complete": { + "integration-api/{teamId}/{apiId}/complete": { "get": { "summary": "get complete API for integration", - "operationId": "/integration-api.findcompleteapi", + "operationId": "integration-api.findcompleteapi", "responses": { "200": { "description": "Complete API", @@ -9014,14 +9014,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}/{apiId}/description": { + "integration-api/{teamId}/{apiId}/description": { "get": { "summary": "get API description", - "operationId": "/integration-api.findapidescription", + "operationId": "integration-api.findapidescription", "responses": { "200": { "description": "API description", @@ -9081,14 +9081,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}/{apiId}/plans": { + "integration-api/{teamId}/{apiId}/plans": { "get": { "summary": "get API plans", - "operationId": "/integration-api.findapiplans", + "operationId": "integration-api.findapiplans", "responses": { "200": { "description": "API plans", @@ -9148,14 +9148,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}/{apiId}/documentation": { + "integration-api/{teamId}/{apiId}/documentation": { "get": { "summary": "get API documentation", - "operationId": "/integration-api.findapidocumentation", + "operationId": "integration-api.findapidocumentation", "responses": { "200": { "description": "API documentation", @@ -9215,14 +9215,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}/{apiId}/apidoc": { + "integration-api/{teamId}/{apiId}/apidoc": { "get": { "summary": "get API description", - "operationId": "/integration-api.findapidoc", + "operationId": "integration-api.findapidoc", "responses": { "200": { "description": "API doc (swagger)", @@ -9282,14 +9282,14 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } }, - "/integration-api/{teamId}/{apiId}": { + "integration-api/{teamId}/{apiId}": { "get": { "summary": "get API", - "operationId": "/integration-api.findapi", + "operationId": "integration-api.findapi", "responses": { "200": { "description": "API", @@ -9349,7 +9349,7 @@ } ], "tags": [ - "/integration-apis" + "integration-apis" ] } } diff --git a/daikoku/public/swaggers/admin-api-openapi.yaml b/daikoku/public/swaggers/admin-api-openapi.yaml index 0e65a857c..d537841a4 100644 --- a/daikoku/public/swaggers/admin-api-openapi.yaml +++ b/daikoku/public/swaggers/admin-api-openapi.yaml @@ -5660,7 +5660,7 @@ paths: /integration-api/apis: get: summary: get all public apis for integration - operationId: /integration-api.findallapis + operationId: integration-api.findallapis responses: '200': description: List of public APIs @@ -5689,11 +5689,11 @@ paths: name: X-Personal-Token required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}: get: summary: get all teams for integration - operationId: /integration-api.findallateams + operationId: integration-api.findallateams responses: '200': description: List of teams @@ -5727,11 +5727,11 @@ paths: name: teamId required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}/{apiId}/complete: get: summary: get complete API for integration - operationId: /integration-api.findcompleteapi + operationId: integration-api.findcompleteapi responses: '200': description: Complete API @@ -5768,11 +5768,11 @@ paths: name: apiId required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}/{apiId}/description: get: summary: get API description - operationId: /integration-api.findapidescription + operationId: integration-api.findapidescription responses: '200': description: API description @@ -5809,11 +5809,11 @@ paths: name: apiId required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}/{apiId}/plans: get: summary: get API plans - operationId: /integration-api.findapiplans + operationId: integration-api.findapiplans responses: '200': description: API plans @@ -5850,11 +5850,11 @@ paths: name: apiId required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}/{apiId}/documentation: get: summary: get API documentation - operationId: /integration-api.findapidocumentation + operationId: integration-api.findapidocumentation responses: '200': description: API documentation @@ -5891,11 +5891,11 @@ paths: name: apiId required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}/{apiId}/apidoc: get: summary: get API description - operationId: /integration-api.findapidoc + operationId: integration-api.findapidoc responses: '200': description: API doc (swagger) @@ -5932,11 +5932,11 @@ paths: name: apiId required: true tags: - - /integration-apis + - integration-apis /integration-api/{teamId}/{apiId}: get: summary: get API - operationId: /integration-api.findapi + operationId: integration-api.findapi responses: '200': description: API @@ -5973,4 +5973,4 @@ paths: name: apiId required: true tags: - - /integration-apis + - integration-apis diff --git a/daikoku/test/daikoku/AdminApiControllerSpec.scala b/daikoku/test/daikoku/AdminApiControllerSpec.scala new file mode 100644 index 000000000..9168865d8 --- /dev/null +++ b/daikoku/test/daikoku/AdminApiControllerSpec.scala @@ -0,0 +1,477 @@ +package fr.maif.otoroshi.daikoku.tests + +import cats.implicits.catsSyntaxOptionId +import fr.maif.otoroshi.daikoku.domain.{ApiSubscription, TenantId, UserId} +import fr.maif.otoroshi.daikoku.tests.utils.DaikokuSpecHelper +import org.scalatest.concurrent.IntegrationPatience +import org.scalatest.{BeforeAndAfter, BeforeAndAfterEach} +import org.scalatestplus.play.PlaySpec +import play.api.libs.json.{JsObject, Json} + +import java.util.Base64 + +class AdminApiControllerSpec + extends PlaySpec + with DaikokuSpecHelper + with IntegrationPatience + with BeforeAndAfterEach + with BeforeAndAfter { + + def getAdminApiHeader(adminApiSubscription: ApiSubscription): Map[String, String] = { + Map("Authorization" -> s"Basic ${Base64.getEncoder.encodeToString(s"${adminApiSubscription.apiKey.clientId}:${adminApiSubscription.apiKey.clientSecret}".getBytes())}") + } + + s"Admin API" should { + "A call to tenant admin API" must { + "POST :: Conflict" in { + setupEnvBlocking( + tenants = Seq(tenant), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants", + method = "POST", + headers = getAdminApiHeader(adminApiSubscription), + body = tenant.copy(id = TenantId("test")).asJson.some + )(tenant) + + resp.status mustBe 409 + + } + + "PUT :: Conflict" in { + setupEnvBlocking( + tenants = Seq(tenant, tenant.copy(id = TenantId("test"), domain = "https://daikoku.io")), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val respConflict = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + method = "PUT", + headers = getAdminApiHeader(adminApiSubscription), + body = tenant.copy(domain = "https://daikoku.io").asJson.some + )(tenant) + + respConflict.status mustBe 409 + } + + "PUT :: Not Found" in { + setupEnvBlocking( + tenants = Seq(tenant, tenant.copy(id = TenantId("test"), domain = "https://daikoku.io")), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val respNotFound = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/unknown", + method = "PUT", + headers = getAdminApiHeader(adminApiSubscription), + body = tenant.copy(domain = "https://daikoku.io").asJson.some + )(tenant) + + respNotFound.status mustBe 404 + } + + "PATCH :: Conflict" in { + setupEnvBlocking( + tenants = Seq(tenant, tenant.copy(id = TenantId("test"), domain = "https://daikoku.io")), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + method = "PATCH", + headers = getAdminApiHeader(adminApiSubscription), + body = Json.arr(Json.obj("op" -> "replace", "path" -> "/domain", "value"-> "https://daikoku.io")).some + )(tenant) + + resp.status mustBe 409 + + } + + "GET :: Not Found" in { + setupEnvBlocking( + tenants = Seq(tenant), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/unknown", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + resp.status mustBe 404 + } + + "GET :: Ok" in { + setupEnvBlocking( + tenants = Seq(tenant), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + resp.status mustBe 200 + resp.json mustBe tenant.asJson + } + + "POST :: Created" in { + setupEnvBlocking( + tenants = Seq(tenant), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val domain = "https://daikoku.io" + val id = TenantId("creation") + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants", + method = "POST", + headers = getAdminApiHeader(adminApiSubscription), + body = tenant.copy(id = id, domain = domain).asJson.some + )(tenant) + + resp.status mustBe 201 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 200 + (verif.json.as[JsObject] \ "domain").as[String] mustBe domain + } + "PUT :: No Content" in { + setupEnvBlocking( + tenants = Seq(tenant), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val name = "Evil corp." + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + method = "PUT", + headers = getAdminApiHeader(adminApiSubscription), + body = tenant.copy(name = name).asJson.some + )(tenant) + + resp.status mustBe 204 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 200 + (verif.json.as[JsObject] \ "name").as[String] mustBe name + } + "PATCH :: No Content" in { + setupEnvBlocking( + tenants = Seq(tenant), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val name = "Evil corp." + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + method = "PATCH", + headers = getAdminApiHeader(adminApiSubscription), + body = Json.arr(Json.obj("op" -> "replace", "path" -> "/name", "value"-> name)).some + )(tenant) + + resp.status mustBe 204 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${tenant.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 200 + (verif.json.as[JsObject] \ "name").as[String] mustBe name + } + "DELETE :: works" in { + val id = TenantId("delete") + setupEnvBlocking( + tenants = Seq(tenant, tenant.copy(id = id, domain = "http://daikoku.io")), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${id.value}", + method = "DELETE", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + resp.status mustBe 200 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/tenants/${id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 404 + } + } + + "A call to user admin API" must { + "POST :: Conflict" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users", + method = "POST", + headers = getAdminApiHeader(adminApiSubscription), + body = user.copy(id = UserId("test")).asJson.some + )(tenant) + + resp.status mustBe 409 + + } + + "PUT :: Conflict" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user, userAdmin), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val respConflict = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + method = "PUT", + headers = getAdminApiHeader(adminApiSubscription), + body = user.copy(email = userAdmin.email).asJson.some + )(tenant) + + respConflict.status mustBe 409 + } + + "PUT :: Not Found" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val respNotFound = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/unknown", + method = "PUT", + headers = getAdminApiHeader(adminApiSubscription), + body = user.copy(name = "test").asJson.some + )(tenant) + + respNotFound.status mustBe 404 + } + + "PATCH :: Conflict" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user, userAdmin), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + method = "PATCH", + headers = getAdminApiHeader(adminApiSubscription), + body = Json.arr(Json.obj("op" -> "replace", "path" -> "/email", "value" -> userAdmin.email)).some + )(tenant) + + resp.status mustBe 409 + + } + + "GET :: Not Found" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/unknown", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + resp.status mustBe 404 + } + + "GET :: Ok" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + resp.status mustBe 200 + resp.json mustBe user.asJson + } + + "POST :: Created" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users", + method = "POST", + headers = getAdminApiHeader(adminApiSubscription), + body = user.asJson.some + )(tenant) + + resp.status mustBe 201 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 200 + verif.json mustBe user.asJson + } + "PUT :: No Content" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val name = "fifou" + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + method = "PUT", + headers = getAdminApiHeader(adminApiSubscription), + body = user.copy(name = name).asJson.some + )(tenant) + + resp.status mustBe 204 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 200 + (verif.json.as[JsObject] \ "name").as[String] mustBe name + } + "PATCH :: No Content" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val name = "fifou" + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + method = "PATCH", + headers = getAdminApiHeader(adminApiSubscription), + body = Json.arr(Json.obj("op" -> "replace", "path" -> "/name", "value" -> name)).some + )(tenant) + + resp.status mustBe 204 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 200 + (verif.json.as[JsObject] \ "name").as[String] mustBe name + } + "DELETE :: Ok" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(user), + teams = Seq(defaultAdminTeam), + subscriptions = Seq(adminApiSubscription) + ) + val resp = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + method = "DELETE", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + resp.status mustBe 200 + + val verif = httpJsonCallWithoutSessionBlocking( + path = s"/admin-api/users/${user.id.value}", + headers = getAdminApiHeader(adminApiSubscription), + )(tenant) + + verif.status mustBe 404 + } + } + + + "a call to team admin API" must { + + } + "a call to api admin API" must { + + } + "a call to api subscription admin API" must { + + } + "a call to api documentation admin API" must { + + } + "a call to notification admin API" must { + + } + "a call to user session admin API" must { + + } + "a call to apikey consumption admin API" must { + + } + "a call to audit event admin API" must { + + } + "a call to message admin API" must { + + } + "a call to issue admin API" must { + + } + "a call to post admin API" must { + + } + "a call to CMS pages admin API" must { + + } + "a call to translation admin API" must { + + } + "a call to usage plan admin API" must { + + } + "a call to subscription demand admin API" must { + + } + } +} diff --git a/daikoku/test/daikoku/suites.scala b/daikoku/test/daikoku/suites.scala index 00328a4c2..4ae319bd4 100644 --- a/daikoku/test/daikoku/suites.scala +++ b/daikoku/test/daikoku/suites.scala @@ -815,6 +815,24 @@ object utils { visibility = ApiVisibility.AdminOnly, authorizedTeams = Seq(defaultAdminTeam.id) ) + val adminApiSubscription = ApiSubscription( + id = ApiSubscriptionId(IdGenerator.token(32)), + tenant = Tenant.Default, + apiKey = OtoroshiApiKey( + clientName = "admin-apikey-test", + clientId = IdGenerator.token(10), + clientSecret = IdGenerator.token(10) + ), + plan = adminApiPlan.id, + createdAt = DateTime.now(), + team = defaultAdminTeam.id, + api = adminApi.id, + by = tenantAdmin.id, + customName = Some("admin key for test"), + rotation = None, + integrationToken = IdGenerator.token(64) + ) + val adminApi2plan = Admin( id = UsagePlanId("admin"), tenant = TenantId("tenant2"), @@ -876,7 +894,8 @@ object utils { defaultLanguage = Some("En"), adminApi = adminApi.id, adminSubscriptions = Seq.empty, - contact = "contact@test-corp.foo.bar" + contact = "contact@test-corp.foo.bar", + tenantMode = TenantMode.Default.some ) val tenant2 = Tenant( id = TenantId("tenant2"),