diff --git a/otoroshi/app/api/api.scala b/otoroshi/app/api/api.scala index 1a80eb07f..578aba17b 100644 --- a/otoroshi/app/api/api.scala +++ b/otoroshi/app/api/api.scala @@ -988,7 +988,7 @@ class OtoroshiResources(env: Env) { stateOne = id => env.proxyState.apiConsumerSubscription(id), stateUpdate = seq => env.proxyState.updateApiConsumerSubscriptions(seq), writeValidator = ApiConsumerSubscription.writeValidator, -// deleteValidator = deleteValidatorForApiConsumerSubscription, + deleteValidator = ApiConsumerSubscription.deleteValidator, ) ) )++ env.adminExtensions.resources() diff --git a/otoroshi/app/next/controllers/apis.scala b/otoroshi/app/next/controllers/apis.scala index 31bc04141..cbabefb70 100644 --- a/otoroshi/app/next/controllers/apis.scala +++ b/otoroshi/app/next/controllers/apis.scala @@ -2,6 +2,7 @@ package otoroshi.next.controllers.adminapi import akka.NotUsed import akka.stream.scaladsl.Source +import next.models.{Api, ApiConsumerStatus, ApiPublished, ApiRemoved, ApiState} import otoroshi.actions.ApiAction import otoroshi.env.Env import otoroshi.events.{AdminApiEvent, Audit} @@ -121,4 +122,150 @@ class ApisController(ApiAction: ApiAction, cc: ControllerComponents)(implicit en } } + + def start(id: String) = { + ApiAction.async { ctx => + ctx.canReadService(id) { + Audit.send( + AdminApiEvent( + env.snowflakeGenerator.nextIdStr(), + env.env, + Some(ctx.apiKey), + ctx.user, + "ACCESS_SERVICE_APIS", + "User started the api", + ctx.from, + ctx.ua, + Json.obj("apiId" -> id) + ) + ) + + toggleApiRoutesStatus(id, newStatus = true) + } + } + } + + def toggleApiRoutesStatus(apiId: String, newStatus: Boolean): Future[Result] = { + env.datastores.apiDataStore.findById(apiId).flatMap { + case Some(api) => env.datastores.apiDataStore.set(api.copy( + state = ApiPublished, + routes = api.routes.map(route => route.copy(enabled = newStatus)))) + .flatMap(_ => Results.Ok.future) + case None => Results.NotFound.future + } + } + + def stop(id: String) = { + ApiAction.async { ctx => + ctx.canReadService(id) { + Audit.send( + AdminApiEvent( + env.snowflakeGenerator.nextIdStr(), + env.env, + Some(ctx.apiKey), + ctx.user, + "ACCESS_SERVICE_APIS", + "User stopped the api", + ctx.from, + ctx.ua, + Json.obj("apiId" -> id) + ) + ) + + toggleApiRoutesStatus(id, newStatus = false) + } + } + } + + def publishConsumer(apiId: String, consumerId: String): Action[AnyContent] = { + ApiAction.async { ctx => + ctx.canReadService(apiId) { + Audit.send( + AdminApiEvent( + env.snowflakeGenerator.nextIdStr(), + env.env, + Some(ctx.apiKey), + ctx.user, + "ACCESS_SERVICE_API_CONSUMER", + "User published the consumer", + ctx.from, + ctx.ua, + Json.obj("apiId" -> apiId, "consumerId" -> consumerId) + ) + ) + + updateConsumerStatus(apiId, consumerId, ApiConsumerStatus.Published) + } + } + } + + def updateConsumerStatus(apiId: String, consumerId: String, status: ApiConsumerStatus): Future[Result] = { + env.datastores.apiDataStore.findById(apiId).flatMap { + case Some(api) => + var result: Option[String] = Some("") + val newAPI = api.copy(consumers = api.consumers.map(consumer => { + if(consumer.id == consumerId) { + if (Api.updateConsumerStatus(consumer, consumer.copy(status = status))) { + consumer.copy(status = status) + } else { + result = None + consumer + } + } else { + consumer + } + })) + + result match { + case None => Results.BadRequest(Json.obj("error" -> "you can't update consumer status")).future + case Some(_) => env.datastores.apiDataStore.set(newAPI) + .flatMap(_ => Results.Ok.vfuture) + } + case None => Results.NotFound.future + } + } + + def deprecateConsumer(apiId: String, consumerId: String) = { + ApiAction.async { ctx => + ctx.canReadService(apiId) { + Audit.send( + AdminApiEvent( + env.snowflakeGenerator.nextIdStr(), + env.env, + Some(ctx.apiKey), + ctx.user, + "ACCESS_SERVICE_API_CONSUMER", + "User deprecated the consumer", + ctx.from, + ctx.ua, + Json.obj("apiId" -> apiId, "consumerId" -> consumerId) + ) + ) + + updateConsumerStatus(apiId, consumerId, ApiConsumerStatus.Deprecated) + } + } + } + + def closeConsumer(apiId: String, consumerId: String) = { + ApiAction.async { ctx => + ctx.canReadService(apiId) { + Audit.send( + AdminApiEvent( + env.snowflakeGenerator.nextIdStr(), + env.env, + Some(ctx.apiKey), + ctx.user, + "ACCESS_SERVICE_API_CONSUMER", + "User deprecated the consumer", + ctx.from, + ctx.ua, + Json.obj("apiId" -> apiId, "consumerId" -> consumerId) + ) + ) + + updateConsumerStatus(apiId, consumerId, ApiConsumerStatus.Closed) + } + } + } } diff --git a/otoroshi/app/next/models/Api.scala b/otoroshi/app/next/models/Api.scala index 9f446bf66..08405bf58 100644 --- a/otoroshi/app/next/models/Api.scala +++ b/otoroshi/app/next/models/Api.scala @@ -3,7 +3,7 @@ package next.models import akka.util.ByteString import diffson.PatchOps import org.joda.time.DateTime -import otoroshi.api.WriteAction +import otoroshi.api.{DeleteAction, WriteAction} import otoroshi.env.Env import otoroshi.models.{EntityLocation, EntityLocationSupport, LoadBalancing, RemainingQuotas, ServiceDescriptor} import otoroshi.next.models._ @@ -67,7 +67,12 @@ case object ApiRemoved extends ApiState { // } //} -case class ApiRoute(id: String, name: Option[String], frontend: NgFrontend, flowRef: String, backend: String) +case class ApiRoute(id: String, + enabled: Boolean, + name: Option[String], + frontend: NgFrontend, + flowRef: String, + backend: String) object ApiRoute { val _fmt: Format[ApiRoute] = new Format[ApiRoute] { @@ -75,6 +80,7 @@ object ApiRoute { override def reads(json: JsValue): JsResult[ApiRoute] = Try { ApiRoute( id = json.select("id").asString, + enabled = json.select("enabled").asOptBoolean.getOrElse(true), name = json.select("name").asOptString, frontend = NgFrontend.readFrom(json \ "frontend"), flowRef = (json \ "flow_ref").asString, @@ -89,6 +95,7 @@ object ApiRoute { override def writes(o: ApiRoute): JsValue = Json.obj( "id" -> o.id, + "enabled" -> o.enabled, "name" -> o.name, "frontend" -> o.frontend.json, "backend" -> o.backend, @@ -297,12 +304,12 @@ object ApiBlueprint { case class ApiConsumer( id: String, name: String, - description: Option[String], + description: Option[String] = None, autoValidation: Boolean, consumerKind: ApiConsumerKind, settings: ApiConsumerSettings, status: ApiConsumerStatus, - subscriptions: Seq[ApiConsumerSubscriptionRef] + subscriptions: Seq[ApiConsumerSubscriptionRef] = Seq.empty ) object ApiConsumer { @@ -446,6 +453,30 @@ case class ApiConsumerSubscription( object ApiConsumerSubscription { + def deleteValidator(entity: ApiConsumerSubscription, + body: JsValue, + singularName: String, + id: String, + action: DeleteAction, + env: Env): Future[Either[JsValue, Unit]] = { + implicit val ec: ExecutionContext = env.otoroshiExecutionContext + implicit val e: Env = env + + env.datastores.apiDataStore.findById(entity.apiRef) flatMap { + case Some(api) => env.datastores.apiDataStore.set(api.copy(consumers = api.consumers.map(consumer => { + if (consumer.id == entity.consumerRef) { + consumer.copy(subscriptions = consumer.subscriptions.filter(_.ref != id)) + } + else + consumer + }))).map(_ => ().right) + case None => Json.obj( + "error" -> "api not found", + "http_status_code" -> 404 + ).as[JsValue].left.vfuture + } + } + def writeValidator(entity: ApiConsumerSubscription, body: JsValue, singularName: String, @@ -713,7 +744,7 @@ case class Api( def toRoutes(implicit env: Env): Future[Seq[NgRoute]] = { implicit val ec = env.otoroshiExecutionContext - if (state == ApiStaging) { + if (state != ApiRemoved) { Seq.empty.vfuture } else { Future.sequence(routes.map(route => apiRouteToNgRoute(route.id))) @@ -760,7 +791,7 @@ case class Api( description = description, tags = tags, metadata = metadata, - enabled = true, + enabled = apiRoute.enabled, capture = capture, debugFlow = debugFlow, exportReporting = exportReporting, @@ -801,16 +832,8 @@ object Api { newApi.consumers.foreach(consumer => { api.consumers.find(_.id == consumer.id).map(oldConsumer => { - // println(s"${oldConsumer.id} ${oldConsumer.status} - ${consumer.status}") - // staging -> published = ok - // published -> deprecated = ok - // deprecated -> closed = ok - // deprecated -> published = ok - - if (consumer.status == ApiConsumerStatus.Published && oldConsumer.status == ApiConsumerStatus.Deprecated) { - - } else if (oldConsumer.status.orderPosition > consumer.status.orderPosition) { - return Json.obj( + if (!updateConsumerStatus(oldConsumer, consumer)) { + return Json.obj( "error" -> s"api has rejected your demand : you can't get back to a consumer status", "http_status_code" -> 400 ).leftf @@ -823,6 +846,20 @@ object Api { newApi.rightf } + def updateConsumerStatus(oldConsumer: ApiConsumer, consumer: ApiConsumer): Boolean = { + // staging -> published = ok + // published -> deprecated = ok + // deprecated -> closed = ok + // deprecated -> published = ok + if (consumer.status == ApiConsumerStatus.Published && oldConsumer.status == ApiConsumerStatus.Deprecated) { + true + } else if (oldConsumer.status.orderPosition > consumer.status.orderPosition) { + false + } else { + true + } + } + def fromJsons(value: JsValue): Api = try { format.reads(value).get diff --git a/otoroshi/conf/routes b/otoroshi/conf/routes index e76d4c739..a10b322fe 100644 --- a/otoroshi/conf/routes +++ b/otoroshi/conf/routes @@ -616,7 +616,12 @@ GET /apis/cluster/node/metrics otoroshi.api.Gener GET /apis/cluster otoroshi.controllers.adminapi.ClusterController.getClusterMembers() GET /apis/api.otoroshi.io/v1/apis/:id/live otoroshi.next.controllers.adminapi.ApisController.liveStats(id, every: Option[Int]) -GET /apis/apis.otoroshi.io/v1/apiconsumersubscriptions/:id/_validate otoroshi.next.controllers.adminapi.ApisController.liveStats(id, every: Option[Int]) +GET /apis/api.otoroshi.io/v1/apis/:id/_start otoroshi.next.controllers.adminapi.ApisController.start(id) +GET /apis/api.otoroshi.io/v1/apis/:id/_stop otoroshi.next.controllers.adminapi.ApisController.stop(id) + +GET /apis/api.otoroshi.io/v1/apis/:apiId/consumers/:consumerId/_publish otoroshi.next.controllers.adminapi.ApisController.publishConsumer(apiId, consumerId) +GET /apis/api.otoroshi.io/v1/apis/:apiId/consumers/:consumerId/_deprecate otoroshi.next.controllers.adminapi.ApisController.deprecateConsumer(apiId, consumerId) +GET /apis/api.otoroshi.io/v1/apis/:apiId/consumers/:consumerId/_close otoroshi.next.controllers.adminapi.ApisController.closeConsumer(apiId, consumerId) # New admin API - generic apis POST /apis/:group/:version/:entity/_bulk otoroshi.api.GenericApiController.bulkCreate(group, version, entity) diff --git a/otoroshi/javascript/src/components/nginputs/inputs.js b/otoroshi/javascript/src/components/nginputs/inputs.js index 6c194f8fb..c0b330168 100644 --- a/otoroshi/javascript/src/components/nginputs/inputs.js +++ b/otoroshi/javascript/src/components/nginputs/inputs.js @@ -704,6 +704,7 @@ export class NgArrayRenderer extends Component { render() { const schema = this.props.schema; + console.log(schema) const props = schema.props || {}; const readOnly = this.props.readOnly; const ItemRenderer = schema.itemRenderer || this.props.rawSchema.itemRenderer; diff --git a/otoroshi/javascript/src/pages/ApiEditor/Sidebar.js b/otoroshi/javascript/src/pages/ApiEditor/Sidebar.js index b5b4c0e38..d7b24dc87 100644 --- a/otoroshi/javascript/src/pages/ApiEditor/Sidebar.js +++ b/otoroshi/javascript/src/pages/ApiEditor/Sidebar.js @@ -40,6 +40,13 @@ const LINKS = (id) => tab: 'Consumers', tooltip: { ...createTooltip(`Show consumers tab`) }, }, + { + to: `/apis/${id}/subscriptions`, + icon: 'fa-key', + title: 'Subscriptions', + tab: 'Subscriptions', + tooltip: { ...createTooltip(`Show subscriptions tab`) }, + }, { to: `/apis/${id}/playground`, icon: 'fa-play', diff --git a/otoroshi/javascript/src/pages/ApiEditor/index.js b/otoroshi/javascript/src/pages/ApiEditor/index.js index f1e2d171f..ecc37efee 100644 --- a/otoroshi/javascript/src/pages/ApiEditor/index.js +++ b/otoroshi/javascript/src/pages/ApiEditor/index.js @@ -102,7 +102,7 @@ function Subscriptions(props) { }) }) - const deleteItem = item => client.delete(item.id) + const deleteItem = item => client.delete(item) .then(() => window.location.reload()) const fields = [] @@ -195,8 +195,9 @@ function SubscriptionDesigner(props) { } }, token_refs: { - type: 'array', - label: 'Token refs' + array: true, + label: 'Token refs', + type: 'string' } } diff --git a/otoroshi/test/Suites.scala b/otoroshi/test/Suites.scala index d7044b2d2..5ee574b5f 100644 --- a/otoroshi/test/Suites.scala +++ b/otoroshi/test/Suites.scala @@ -103,7 +103,8 @@ object OtoroshiTests { new ApikeyGroupApiSpec(name, config), new ApikeyServiceApiSpec(name, config), new ApikeyApiSpec(name, config), - new Log4ShellSpec() + new Log4ShellSpec(), + new ApiEntityTestSpec(name, config) ) Option(System.getenv("TEST_ANALYTICS")) match { case Some("true") => suites :+ new AnalyticsSpec(name, config) @@ -179,11 +180,16 @@ class AnalyticsTests new AlertAndAnalyticsSpec("InMemory", Configurations.InMemoryConfiguration) ) -class GreenScoreTest +class GreenScoreTests extends Suites( new GreenScoreTestSpec("GreenScore", Configurations.InMemoryConfiguration) ) +class ApiEntityTests + extends Suites( + new ApiEntityTestSpec("ApiEntity", Configurations.InMemoryConfiguration) + ) + //class ApiKeysTest // extends Suites( // new ApiKeysSpec("ApiKeysSpec", Configurations.InMemoryConfiguration) diff --git a/otoroshi/test/functional/ApiEntityTestSpec.scala b/otoroshi/test/functional/ApiEntityTestSpec.scala new file mode 100644 index 000000000..961f5615b --- /dev/null +++ b/otoroshi/test/functional/ApiEntityTestSpec.scala @@ -0,0 +1,102 @@ +package functional + +import com.typesafe.config.ConfigFactory +import next.models.ApiConsumerSettings.Apikey +import next.models.{Api, ApiConsumer, ApiConsumerKind, ApiConsumerSettings, ApiConsumerStatus} +import org.joda.time.DateTime +import otoroshi.greenscore.EcoMetrics.MAX_GREEN_SCORE_NOTE +import otoroshi.greenscore._ +import otoroshi.models.{EntityLocation, RoundRobin} +import otoroshi.next.models._ +import otoroshi.utils.syntax.implicits.{BetterJsValue, BetterSyntax} +import play.api.Configuration +import play.api.libs.json.{JsArray, JsNull, JsObject, JsValue} +import play.api.libs.ws.WSAuthScheme + +import scala.concurrent.duration.{DurationInt, FiniteDuration} +import scala.concurrent.{Await, Future} + +class ApiEntityTestSpec(name: String, configurationSpec: => Configuration) extends OtoroshiSpec { + + implicit lazy val mat = otoroshiComponents.materializer + implicit lazy val env = otoroshiComponents.env + + override def getTestConfiguration(configuration: Configuration) = + Configuration( + ConfigFactory + .parseString("") + .resolve() + ).withFallback(configurationSpec).withFallback(configuration) + + def wait[A](fu: Future[A], duration: Option[FiniteDuration] = Some(10.seconds)): A = Await.result(fu, duration.get) + + def fetch( + path: String = "", + method: String = "get", + body: Option[JsValue] = JsNull.some + ) = { + println(s"http://otoroshi-api.oto.tools:$port$path") + val request = wsClient + .url(s"http://otoroshi-api.oto.tools:$port$path") + .withAuth("admin-api-apikey-id", "admin-api-apikey-secret", WSAuthScheme.BASIC) + + method match { + case "post" => wait(request.post(body.get)) + case "put" => wait(request.put(body.get)) + case _ => wait(request.get()) + } + } + + + s"Api Entity" should { + "warm up" in { + startOtoroshi() + getOtoroshiServices().futureValue // WARM UP + getOtoroshiRoutes().futureValue + +// wait(createOtoroshiRoute(initialRoute)) + } + + // create an empty group without routes called TEMPLATE_WITHOUT_CHANGE_OR_DATES + "create api" in { + val created = createApi() + created mustBe 201 + } + + def createApi() = { + val template = env.datastores.apiDataStore.template(env) + .copy(id = "template_api") + + fetch( + path = "/apis/apis.otoroshi.io/v1/apis", + method = "post", + body = template.json.some).status + } + + "add consumer to api" in { + createApi() + val result = fetch(path = "/apis/api.otoroshi.io/v1/apis/template_api") + val api = Api.format.reads(result.json).get + .copy(consumers = Seq(ApiConsumer( + id = "api_consumner", + name = "apikey consumer", + autoValidation = false, + description = None, + consumerKind = ApiConsumerKind.Apikey, + settings = Apikey("apikey", 1000, 1000, 1000), + status = ApiConsumerStatus.Staging, + subscriptions = Seq.empty + ))) + + val patched = fetch( + path = "/apis/apis.otoroshi.io/v1/apis/template_api", + method = "put", + body = api.json.some).status + patched mustBe 204 + } + + "shutdown" in { + stopAll() + } + } +}